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:
304
packages/email-editor/src/nodes/button.tsx
Normal file
304
packages/email-editor/src/nodes/button.tsx
Normal 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;
|
||||
};
|
14
packages/email-editor/src/nodes/unsubscribe-footer.tsx
Normal file
14
packages/email-editor/src/nodes/unsubscribe-footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
225
packages/email-editor/src/nodes/variable.tsx
Normal file
225
packages/email-editor/src/nodes/variable.tsx
Normal 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user