Add unsend campaign feature (#45)

* Add unsend email editor

Add email editor

Add more email editor

Add renderer partial

Add more marketing email features

* Add more campaign feature

* Add variables

* Getting there

* campaign is there mfs

* Add migration
This commit is contained in:
KM Koushik
2024-08-10 10:09:10 +10:00
committed by GitHub
parent 0c072579b9
commit 5ddc0a7bb9
92 changed files with 11766 additions and 338 deletions

View File

@@ -0,0 +1,304 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
ScanIcon,
} from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@unsend/ui/src/popover";
import { cn } from "@unsend/ui/lib/utils";
import { Input } from "@unsend/ui/src/input";
import { Button } from "@unsend/ui/src/button";
import { AllowedAlignments, ButtonOptions } from "../types";
import { Separator } from "@unsend/ui/src/separator";
import { BorderWidth } from "../components/ui/icons/BorderWidth";
import { ColorPicker } from "../components/ui/ColorPicker";
const alignments: Array<AllowedAlignments> = ["left", "center", "right"];
export function ButtonComponent(props: NodeViewProps) {
const {
url,
text,
alignment,
borderRadius: _radius,
buttonColor,
textColor,
borderColor,
borderWidth,
} = props.node.attrs as ButtonOptions;
const { getPos, editor } = props;
return (
<NodeViewWrapper
className={`react-component ${
props.selected && "ProseMirror-selectednode"
}`}
draggable="true"
data-drag-handle=""
style={{
textAlign: alignment,
}}
>
<Popover open={props.selected}>
<PopoverTrigger asChild>
<div>
<button
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors disabled:pointer-events-none disabled:opacity-50",
"h-10 px-4 py-2",
"px-[32px] py-[20px] font-semibold no-underline"
)}
tabIndex={-1}
style={{
backgroundColor: buttonColor,
color: textColor,
borderWidth: Number(borderWidth),
borderStyle: "solid",
borderColor: borderColor,
borderRadius: Number(_radius),
}}
onClick={(e) => {
e.preventDefault();
const pos = getPos();
editor.commands.setNodeSelection(pos);
}}
>
{text}
</button>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="space-y-2 light border-gray-200"
sideOffset={10}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<Input
placeholder="Add text here"
value={text}
onChange={(e) => {
props.updateAttributes({
text: e.target.value,
});
}}
className="light"
/>
<Input
placeholder="Add link here"
value={url}
onChange={(e) => {
props.updateAttributes({
url: e.target.value,
});
}}
/>
<div className="flex flex-col gap-2">
<div className="text-xs text-gray-500 mt-4">Border</div>
<div className="flex gap-2">
<div className="flex items-center border border-transparent focus-within:border-border gap-2 px-1 py-0.5 rounded-md">
<ScanIcon className="text-slate-700 h-4 w-4" />
<Input
value={_radius}
onChange={(e) =>
props.updateAttributes({
borderRadius: e.target.value,
})
}
className="border-0 focus-visible:ring-0 h-6 p-0"
/>
</div>
<div className="flex items-center border border-transparent focus-within:border-border gap-2 px-1 py-0.5 rounded-md">
<BorderWidth className="text-slate-700 h-4 w-4" />
<Input
value={borderWidth}
onChange={(e) =>
props.updateAttributes({
borderWidth: e.target.value,
})
}
className="border-0 focus-visible:ring-0 h-6 p-0"
/>
</div>
</div>
<div className="flex gap-2">
<div>
<div className="text-xs text-gray-500 mt-4 mb-2">Alignment</div>
<div className="flex">
{alignments.map((alignment) => (
<Button
variant="ghost"
className=""
size="sm"
type="button"
onClick={() => {
props.updateAttributes({
alignment,
});
}}
>
<AlignmentIcon alignment={alignment} />
</Button>
))}
</div>
</div>
<div>
<div className="text-xs text-gray-500 mt-4 mb-2">Colors</div>
<div className="flex gap-2">
<BorderColorPickerPopup
color={borderColor}
onChange={(color) => {
props.updateAttributes({
borderColor: color,
});
}}
/>
<BackgroundColorPickerPopup
color={buttonColor}
onChange={(color) => {
props.updateAttributes({
buttonColor: color,
});
}}
/>
<TextColorPickerPopup
color={textColor}
onChange={(color) => {
props.updateAttributes({
textColor: color,
});
}}
/>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</NodeViewWrapper>
);
}
// type ColorPickerProps = {
// variant?: AllowedButtonVariant;
// color: string;
// onChange: (color: string) => void;
// };
function BackgroundColorPickerPopup(props: ColorPickerProps) {
const { color, onChange } = props;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" className="" size="sm" type="button">
<div
className="h-4 w-4 rounded border"
style={{
backgroundColor: color,
}}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-full rounded-none border-0 !bg-transparent !p-0 shadow-none drop-shadow-md">
<ColorPicker
color={color}
onChange={(newColor) => {
// HACK: This is a workaround for a bug in tiptap
// https://github.com/ueberdosis/tiptap/issues/3580
//
// ERROR: flushSync was called from inside a lifecycle
//
// To fix this, we need to make sure that the onChange
// callback is run after the current execution context.
queueMicrotask(() => {
onChange(newColor);
});
}}
/>
</PopoverContent>
</Popover>
);
}
function TextColorPickerPopup(props: ColorPickerProps) {
const { color, onChange } = props;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" type="button">
<div className="flex flex-col items-center justify-center gap-[1px]">
<span className="font-bolder font-mono text-xs text-slate-700">
A
</span>
<div className="h-[2px] w-3" style={{ backgroundColor: color }} />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-full rounded-none border-0 !bg-transparent !p-0 shadow-none drop-shadow-md">
<ColorPicker
color={color}
onChange={(color) => {
queueMicrotask(() => {
onChange(color);
});
}}
/>
</PopoverContent>
</Popover>
);
}
type ColorPickerProps = {
color: string;
onChange: (color: string) => void;
};
function BorderColorPickerPopup(props: ColorPickerProps) {
const { color, onChange } = props;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" className="" size="sm" type="button">
<BorderWidth className="h-4 w-4" style={{ color: color }} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full rounded-none border-0 !bg-transparent !p-0 shadow-none drop-shadow-md">
<ColorPicker
color={color}
onChange={(newColor) => {
// HACK: This is a workaround for a bug in tiptap
// https://github.com/ueberdosis/tiptap/issues/3580
//
// ERROR: flushSync was called from inside a lifecycle
//
// To fix this, we need to make sure that the onChange
// callback is run after the current execution context.
queueMicrotask(() => {
onChange(newColor);
});
}}
/>
</PopoverContent>
</Popover>
);
}
const AlignmentIcon = ({ alignment }: { alignment: AllowedAlignments }) => {
if (alignment === "left") {
return <AlignLeftIcon className="h-4 w-4" />;
} else if (alignment === "center") {
return <AlignCenterIcon className="h-4 w-4" />;
} else if (alignment === "right") {
return <AlignRightIcon className="h-4 w-4" />;
}
return null;
};

View File

@@ -0,0 +1,14 @@
import { NodeViewProps, NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import { cn } from "@unsend/ui/lib/utils";
export function UnsubscribeFooterComponent(props: NodeViewProps) {
return (
<NodeViewWrapper
className={`react-component footer`}
draggable="true"
data-drag-handle=""
>
<NodeViewContent className="content" />
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,225 @@
import { NodeViewProps, NodeViewWrapper, ReactRenderer } from "@tiptap/react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@unsend/ui/src/popover";
import { cn } from "@unsend/ui/lib/utils";
import { Input } from "@unsend/ui/src/input";
import { Button } from "@unsend/ui/src/button";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { SuggestionOptions } from "@tiptap/suggestion";
import tippy, { GetReferenceClientRect } from "tippy.js";
import { CheckIcon, TriangleAlert } from "lucide-react";
export interface VariableOptions {
name: string;
fallback: string;
}
export const VariableList = forwardRef((props: any, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index: number) => {
const item = props.items[index];
console.log("item: ", item);
if (item) {
props.command({ id: item, name: item, fallback: "" });
}
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length
);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % props.items.length);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
return (
<div className="z-50 h-auto min-w-[128px] rounded-md border border-gray-200 bg-white p-1 shadow-md transition-all">
{props?.items?.length ? (
props?.items?.map((item: string, index: number) => (
<button
key={index}
onClick={() => selectItem(index)}
className={cn(
"flex w-full space-x-2 rounded-md px-2 py-1 text-left text-sm text-gray-900 hover:bg-gray-100",
index === selectedIndex ? "bg-gray-200" : "bg-white"
)}
>
{item}
</button>
))
) : (
<button className="flex w-full space-x-2 rounded-md bg-white px-2 py-1 text-left text-sm text-gray-900 hover:bg-gray-100">
No result
</button>
)}
</div>
);
});
VariableList.displayName = "VariableList";
export function getVariableSuggestions(
variables: Array<string> = []
): Omit<SuggestionOptions, "editor"> {
return {
items: ({ query }) => {
return variables
.concat(query.length > 0 ? [query] : [])
.filter((item) => item.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 5);
},
render: () => {
let component: ReactRenderer<any>;
let popup: InstanceType<any> | null = null;
return {
onStart: (props) => {
component = new ReactRenderer(VariableList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy("body", {
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
});
},
onKeyDown(props) {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
if (!popup || !popup?.[0] || !component) {
return;
}
popup?.[0].destroy();
component.destroy();
},
};
},
};
}
export function VariableComponent(props: NodeViewProps) {
const { name, fallback } = props.node.attrs as VariableOptions;
const [fallbackValue, setFallbackValue] = useState(fallback);
const { getPos, editor } = props;
console.log(props.selected);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
props.updateAttributes({
fallback: fallbackValue,
});
};
return (
<NodeViewWrapper
className={`react-component inline-block ${
props.selected && "ProseMirror-selectednode"
}`}
draggable="false"
data-drag-handle=""
>
<Popover open={props.selected}>
<PopoverTrigger asChild>
<button
className={cn(
"inline-flex items-center justify-center rounded-md text-sm gap-1 ring-offset-white transition-colors",
"px-2 border border-gray-300 shadow-sm cursor-pointer text-primary/80"
)}
onClick={(e) => {
e.preventDefault();
const pos = getPos();
editor.commands.setNodeSelection(pos);
}}
>
<span className="">{`{{${name}}}`}</span>
{!fallback ? (
<TriangleAlert className="w-3 h-3 text-orange-400" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="space-y-2 light border-gray-200"
sideOffset={10}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<form onSubmit={handleSubmit} className="flex gap-2 items-center">
<Input
placeholder="Fallback value"
value={fallbackValue}
onChange={(e) => {
setFallbackValue(e.target.value);
}}
autoFocus
/>
<Button variant="silent" size="sm" className="px-1" type="submit">
<CheckIcon className="h-4 w-4" />
</Button>
</form>
<div className="text-sm text-muted-foreground">
Fallback value will be used if the variable value is empty.
</div>
</PopoverContent>
</Popover>
</NodeViewWrapper>
);
}