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:
@@ -10,6 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.1.3",
|
"@heroicons/react": "^2.1.3",
|
||||||
|
"@unsend/email-editor": "workspace:*",
|
||||||
|
"@unsend/ui": "workspace:*",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"framer-motion": "^11.0.24",
|
"framer-motion": "^11.0.24",
|
||||||
"lucide-react": "^0.359.0",
|
"lucide-react": "^0.359.0",
|
||||||
@@ -23,7 +25,6 @@
|
|||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"@unsend/eslint-config": "workspace:*",
|
"@unsend/eslint-config": "workspace:*",
|
||||||
"@unsend/tailwind-config": "workspace:*",
|
"@unsend/tailwind-config": "workspace:*",
|
||||||
"@unsend/ui": "workspace:*",
|
|
||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.0.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.1.4",
|
"eslint-config-next": "14.1.4",
|
||||||
|
38
apps/marketing/src/app/editor/page.tsx
Normal file
38
apps/marketing/src/app/editor/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Editor } from "@unsend/email-editor";
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function EditorPage() {
|
||||||
|
const [json, setJson] = useState<Record<string, any>>({
|
||||||
|
type: "doc",
|
||||||
|
content: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const onConvertToHtml = async () => {
|
||||||
|
console.log(json)
|
||||||
|
const resp = await fetch("http://localhost:3000/api/to-html", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(json),
|
||||||
|
});
|
||||||
|
|
||||||
|
const respJson = await resp.json();
|
||||||
|
console.log(respJson);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className=" max-w-2xl mx-auto">
|
||||||
|
<h1 className="text-center text-2xl py-8">
|
||||||
|
Try out unsend's email editor
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button className="w-[200px]" onClick={onConvertToHtml}>
|
||||||
|
Convert to HTML
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Editor onUpdate={(editor) => setJson(editor.getJSON())} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -3,6 +3,8 @@ import type { Metadata } from "next";
|
|||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import { ThemeProvider } from "@unsend/ui";
|
import { ThemeProvider } from "@unsend/ui";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@@ -40,7 +42,97 @@ export default function RootLayout({
|
|||||||
)}
|
)}
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||||
{children}
|
<div className="bg-neutral-950 pb-20 h-full">
|
||||||
|
<div className=" mx-auto w-full lg:max-w-6xl relative flex flex-col ">
|
||||||
|
<nav className="p-4 flex justify-between">
|
||||||
|
<div className="text-2xl font-semibold">
|
||||||
|
<Link href="/">Unsend</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8 items-center">
|
||||||
|
<Link
|
||||||
|
href="https://github.com/unsend-dev/unsend"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 496 512"
|
||||||
|
className="h-6 w-6 stroke-white fill-white"
|
||||||
|
>
|
||||||
|
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link href="https://twitter.com/unsend_dev" target="_blank">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
className="h-6 w-6 stroke-white fill-white"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<path d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="https://discord.gg/BU8n8pJv8S"
|
||||||
|
target="_blank"
|
||||||
|
className="flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 640 512"
|
||||||
|
className="h-6 w-6 stroke-white fill-white"
|
||||||
|
>
|
||||||
|
<path d="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
{/* <Link href="https://github.com/unsendhq">Github</Link> */}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-6xl mx-auto px-4">{children}</div>
|
||||||
|
<div className="flex justify-between mt-20 max-w-6xl mx-auto px-4">
|
||||||
|
<div>
|
||||||
|
<TextWithCopyButton value="hello@unsend.dev" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-8 items-center">
|
||||||
|
<Link
|
||||||
|
href="https://github.com/unsend-dev/unsend"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 496 512"
|
||||||
|
className="h-6 w-6 stroke-white fill-white"
|
||||||
|
>
|
||||||
|
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link href="https://twitter.com/unsend_dev" target="_blank">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
className="h-6 w-6 stroke-white fill-white"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<path d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="https://discord.gg/BU8n8pJv8S"
|
||||||
|
target="_blank"
|
||||||
|
className="flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 640 512"
|
||||||
|
className="h-6 w-6 stroke-white fill-white"
|
||||||
|
>
|
||||||
|
<path d="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
{/* <Link href="https://github.com/unsendhq">Github</Link> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@@ -24,46 +24,6 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="bg-neutral-950 pb-20">
|
<div className="bg-neutral-950 pb-20">
|
||||||
<div className=" mx-auto w-full lg:max-w-6xl relative flex flex-col ">
|
<div className=" mx-auto w-full lg:max-w-6xl relative flex flex-col ">
|
||||||
<nav className="p-4 flex justify-between">
|
|
||||||
<div className="text-2xl font-semibold">
|
|
||||||
<Link href="/">Unsend</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-8 items-center">
|
|
||||||
<Link href="https://github.com/unsend-dev/unsend" target="_blank">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 496 512"
|
|
||||||
className="h-6 w-6 stroke-white fill-white"
|
|
||||||
>
|
|
||||||
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<Link href="https://twitter.com/unsend_dev" target="_blank">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
className="h-6 w-6 stroke-white fill-white"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<path d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="https://discord.gg/BU8n8pJv8S"
|
|
||||||
target="_blank"
|
|
||||||
className="flex gap-2 items-center"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 640 512"
|
|
||||||
className="h-6 w-6 stroke-white fill-white"
|
|
||||||
>
|
|
||||||
<path d="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
{/* <Link href="https://github.com/unsendhq">Github</Link> */}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<div className="p-4 mt-20">
|
<div className="p-4 mt-20">
|
||||||
<h1 className="relative z-10 text-neutral-100 text-2xl lg:max-w-4xl mx-auto md:text-6xl md:leading-[4.5rem] text-center font-sans font-bold">
|
<h1 className="relative z-10 text-neutral-100 text-2xl lg:max-w-4xl mx-auto md:text-6xl md:leading-[4.5rem] text-center font-sans font-bold">
|
||||||
Open source sending infrastructure for{" "}
|
Open source sending infrastructure for{" "}
|
||||||
@@ -86,7 +46,7 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* <BackgroundBeams /> */}
|
{/* <BackgroundBeams /> */}
|
||||||
</div>
|
</div>
|
||||||
<div className=" w-full lg:max-w-5xl mx-auto flex flex-col gap-40 mt-40">
|
<div className=" w-full lg:max-w-6xl mx-auto flex flex-col gap-40 mt-40">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-center text-3xl lg:text-6xl ">Reach your users</p>
|
<p className="text-center text-3xl lg:text-6xl ">Reach your users</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,47 +196,6 @@ export default function Home() {
|
|||||||
{/* </motion.div> */}
|
{/* </motion.div> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between mt-20 max-w-5xl mx-auto px-4">
|
|
||||||
<div>
|
|
||||||
<TextWithCopyButton value="hello@unsend.dev" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-8 items-center">
|
|
||||||
<Link href="https://github.com/unsend-dev/unsend" target="_blank">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 496 512"
|
|
||||||
className="h-6 w-6 stroke-white fill-white"
|
|
||||||
>
|
|
||||||
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<Link href="https://twitter.com/unsend_dev" target="_blank">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
className="h-6 w-6 stroke-white fill-white"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<path d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="https://discord.gg/BU8n8pJv8S"
|
|
||||||
target="_blank"
|
|
||||||
className="flex gap-2 items-center"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 640 512"
|
|
||||||
className="h-6 w-6 stroke-white fill-white"
|
|
||||||
>
|
|
||||||
<path d="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
{/* <Link href="https://github.com/unsendhq">Github</Link> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -20,7 +20,6 @@
|
|||||||
"@auth/prisma-adapter": "^1.4.0",
|
"@auth/prisma-adapter": "^1.4.0",
|
||||||
"@aws-sdk/client-sesv2": "^3.535.0",
|
"@aws-sdk/client-sesv2": "^3.535.0",
|
||||||
"@aws-sdk/client-sns": "^3.540.0",
|
"@aws-sdk/client-sns": "^3.540.0",
|
||||||
"@hono/node-server": "^1.9.1",
|
|
||||||
"@hono/swagger-ui": "^0.2.1",
|
"@hono/swagger-ui": "^0.2.1",
|
||||||
"@hono/zod-openapi": "^0.10.0",
|
"@hono/zod-openapi": "^0.10.0",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
@@ -32,6 +31,7 @@
|
|||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
|
"@unsend/email-editor": "workspace:*",
|
||||||
"@unsend/ui": "workspace:*",
|
"@unsend/ui": "workspace:*",
|
||||||
"bullmq": "^5.8.2",
|
"bullmq": "^5.8.2",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"tldts": "^6.1.16",
|
"tldts": "^6.1.16",
|
||||||
"ua-parser-js": "^1.0.38",
|
"ua-parser-js": "^1.0.38",
|
||||||
"unsend": "workspace:*",
|
"unsend": "workspace:*",
|
||||||
|
"use-debounce": "^10.0.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@@ -0,0 +1,81 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CampaignStatus" AS ENUM ('DRAFT', 'SCHEDULED', 'SENT');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Email" ADD COLUMN "campaignId" TEXT,
|
||||||
|
ADD COLUMN "contactId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SesSetting" ADD COLUMN "transactionalQuota" INTEGER NOT NULL DEFAULT 50;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ContactBook" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"teamId" INTEGER NOT NULL,
|
||||||
|
"properties" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ContactBook_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Contact" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT,
|
||||||
|
"lastName" TEXT,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"subscribed" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"properties" JSONB NOT NULL,
|
||||||
|
"contactBookId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Contact_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Campaign" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"teamId" INTEGER NOT NULL,
|
||||||
|
"from" TEXT NOT NULL,
|
||||||
|
"cc" TEXT[],
|
||||||
|
"bcc" TEXT[],
|
||||||
|
"replyTo" TEXT[],
|
||||||
|
"domainId" INTEGER NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"previewText" TEXT,
|
||||||
|
"html" TEXT,
|
||||||
|
"content" TEXT,
|
||||||
|
"contactBookId" TEXT,
|
||||||
|
"total" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"sent" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"delivered" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"opened" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"clicked" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"unsubscribed" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"bounced" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"complained" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"status" "CampaignStatus" NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Campaign_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ContactBook_teamId_idx" ON "ContactBook"("teamId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Contact_contactBookId_email_key" ON "Contact"("contactBookId", "email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ContactBook" ADD CONSTRAINT "ContactBook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Contact" ADD CONSTRAINT "Contact_contactBookId_fkey" FOREIGN KEY ("contactBookId") REFERENCES "ContactBook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Campaign" ADD CONSTRAINT "Campaign_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@@ -2,7 +2,7 @@
|
|||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
previewFeatures = ["tracing"]
|
previewFeatures = ["tracing"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ model SesSetting {
|
|||||||
idPrefix String @unique
|
idPrefix String @unique
|
||||||
topic String
|
topic String
|
||||||
topicArn String?
|
topicArn String?
|
||||||
|
transactionalQuota Int @default(50)
|
||||||
callbackUrl String
|
callbackUrl String
|
||||||
callbackSuccess Boolean @default(false)
|
callbackSuccess Boolean @default(false)
|
||||||
configGeneral String?
|
configGeneral String?
|
||||||
@@ -90,14 +91,16 @@ model User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
teamUsers TeamUser[]
|
teamUsers TeamUser[]
|
||||||
domains Domain[]
|
domains Domain[]
|
||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
emails Email[]
|
emails Email[]
|
||||||
|
contactBooks ContactBook[]
|
||||||
|
campaigns Campaign[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
@@ -193,6 +196,8 @@ model Email {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
attachments String?
|
attachments String?
|
||||||
|
campaignId String?
|
||||||
|
contactId String?
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
emailEvents EmailEvent[]
|
emailEvents EmailEvent[]
|
||||||
}
|
}
|
||||||
@@ -205,3 +210,67 @@ model EmailEvent {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
|
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ContactBook {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
teamId Int
|
||||||
|
properties Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
contacts Contact[]
|
||||||
|
|
||||||
|
@@index([teamId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Contact {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
email String
|
||||||
|
subscribed Boolean @default(true)
|
||||||
|
properties Json
|
||||||
|
contactBookId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
contactBook ContactBook @relation(fields: [contactBookId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([contactBookId, email])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CampaignStatus {
|
||||||
|
DRAFT
|
||||||
|
SCHEDULED
|
||||||
|
SENT
|
||||||
|
}
|
||||||
|
|
||||||
|
model Campaign {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
teamId Int
|
||||||
|
from String
|
||||||
|
cc String[]
|
||||||
|
bcc String[]
|
||||||
|
replyTo String[]
|
||||||
|
domainId Int
|
||||||
|
subject String
|
||||||
|
previewText String?
|
||||||
|
html String?
|
||||||
|
content String?
|
||||||
|
contactBookId String?
|
||||||
|
total Int @default(0)
|
||||||
|
sent Int @default(0)
|
||||||
|
delivered Int @default(0)
|
||||||
|
opened Int @default(0)
|
||||||
|
clicked Int @default(0)
|
||||||
|
unsubscribed Int @default(0)
|
||||||
|
bounced Int @default(0)
|
||||||
|
complained Int @default(0)
|
||||||
|
status CampaignStatus @default(DRAFT)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
168
apps/web/src/app/(dashboard)/admin/edit-ses-configuration.tsx
Normal file
168
apps/web/src/app/(dashboard)/admin/edit-ses-configuration.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
|
||||||
|
import { Edit } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@unsend/ui/src/form";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
import Spinner from "@unsend/ui/src/spinner";
|
||||||
|
import { SesSetting } from "@prisma/client";
|
||||||
|
|
||||||
|
const FormSchema = z.object({
|
||||||
|
settingsId: z.string(),
|
||||||
|
sendRate: z.preprocess((val) => Number(val), z.number()),
|
||||||
|
transactionalQuota: z.preprocess(
|
||||||
|
(val) => Number(val),
|
||||||
|
z.number().min(0).max(100)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function EditSesConfiguration({
|
||||||
|
setting,
|
||||||
|
}: {
|
||||||
|
setting: SesSetting;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit SES configuration</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<EditSesSettingsForm
|
||||||
|
setting={setting}
|
||||||
|
onSuccess={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SesSettingsProps = {
|
||||||
|
setting: SesSetting;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
|
||||||
|
setting,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const updateSesSettings = api.admin.updateSesSettings.useMutation();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof FormSchema>>({
|
||||||
|
resolver: zodResolver(FormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
settingsId: setting.id,
|
||||||
|
sendRate: setting.sesEmailRateLimit,
|
||||||
|
transactionalQuota: setting.transactionalQuota,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||||
|
updateSesSettings.mutate(data, {
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.admin.invalidate();
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
toast.error("Failed to update", {
|
||||||
|
description: e.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className=" flex flex-col gap-8 w-full"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sendRate"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Send Rate</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="1" className="w-full" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.sendRate ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription>
|
||||||
|
The number of emails to send per second.
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="transactionalQuota"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Transactional Quota</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="0" className="w-full" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.transactionalQuota ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription>
|
||||||
|
The percentage of the quota to be used for transactional
|
||||||
|
emails (0-100%).
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateSesSettings.isPending}
|
||||||
|
className="w-[200px] mx-auto"
|
||||||
|
>
|
||||||
|
{updateSesSettings.isPending ? (
|
||||||
|
<Spinner className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
"Update"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
@@ -11,6 +11,8 @@ import {
|
|||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import Spinner from "@unsend/ui/src/spinner";
|
import Spinner from "@unsend/ui/src/spinner";
|
||||||
|
import EditSesConfiguration from "./edit-ses-configuration";
|
||||||
|
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
|
||||||
|
|
||||||
export default function SesConfigurations() {
|
export default function SesConfigurations() {
|
||||||
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
|
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
|
||||||
@@ -25,6 +27,9 @@ export default function SesConfigurations() {
|
|||||||
<TableHead>Callback URL</TableHead>
|
<TableHead>Callback URL</TableHead>
|
||||||
<TableHead>Callback status</TableHead>
|
<TableHead>Callback status</TableHead>
|
||||||
<TableHead>Created at</TableHead>
|
<TableHead>Created at</TableHead>
|
||||||
|
<TableHead>Send rate</TableHead>
|
||||||
|
<TableHead>Transactional quota</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -47,13 +52,25 @@ export default function SesConfigurations() {
|
|||||||
sesSettingsQuery.data?.map((sesSetting) => (
|
sesSettingsQuery.data?.map((sesSetting) => (
|
||||||
<TableRow key={sesSetting.id}>
|
<TableRow key={sesSetting.id}>
|
||||||
<TableCell>{sesSetting.region}</TableCell>
|
<TableCell>{sesSetting.region}</TableCell>
|
||||||
<TableCell>{sesSetting.callbackUrl}</TableCell>
|
<TableCell>
|
||||||
|
<div className="w-[200px] overflow-hidden text-ellipsis">
|
||||||
|
<TextWithCopyButton
|
||||||
|
value={sesSetting.callbackUrl}
|
||||||
|
className="w-[200px] overflow-hidden text-ellipsis"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{sesSetting.callbackSuccess ? "Success" : "Failed"}
|
{sesSetting.callbackSuccess ? "Success" : "Failed"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{formatDistanceToNow(sesSetting.createdAt)} ago
|
{formatDistanceToNow(sesSetting.createdAt)} ago
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>{sesSetting.sesEmailRateLimit}</TableCell>
|
||||||
|
<TableCell>{sesSetting.transactionalQuota}%</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<EditSesConfiguration setting={sesSetting} />
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
@@ -0,0 +1,347 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useInterval } from "~/hooks/useInterval";
|
||||||
|
import { Spinner } from "@unsend/ui/src/spinner";
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import { Editor } from "@unsend/email-editor";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Campaign } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@unsend/ui/src/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@unsend/ui/src/form";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
|
||||||
|
const sendSchema = z.object({
|
||||||
|
confirmation: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function EditCampaignPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { campaignId: string };
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
data: campaign,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = api.campaign.getCampaign.useQuery(
|
||||||
|
{ campaignId: params.campaignId },
|
||||||
|
{
|
||||||
|
enabled: !!params.campaignId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<Spinner className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<p className="text-red-500">Failed to load campaign</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
return <div>Campaign not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CampaignEditor campaign={campaign} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CampaignEditor({ campaign }: { campaign: Campaign }) {
|
||||||
|
const contactBooksQuery = api.contacts.getContactBooks.useQuery();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const [json, setJson] = useState<Record<string, any> | undefined>(
|
||||||
|
campaign.content ? JSON.parse(campaign.content) : undefined
|
||||||
|
);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [name, setName] = useState(campaign.name);
|
||||||
|
const [subject, setSubject] = useState(campaign.subject);
|
||||||
|
const [from, setFrom] = useState(campaign.from);
|
||||||
|
const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
|
||||||
|
const [openSendDialog, setOpenSendDialog] = useState(false);
|
||||||
|
|
||||||
|
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.campaign.getCampaign.invalidate();
|
||||||
|
setIsSaving(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const sendCampaignMutation = api.campaign.sendCampaign.useMutation();
|
||||||
|
|
||||||
|
const sendForm = useForm<z.infer<typeof sendSchema>>({
|
||||||
|
resolver: zodResolver(sendSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateEditorContent() {
|
||||||
|
updateCampaignMutation.mutate({
|
||||||
|
campaignId: campaign.id,
|
||||||
|
content: JSON.stringify(json),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deboucedUpdateCampaign = useDebouncedCallback(
|
||||||
|
updateEditorContent,
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onSendCampaign(values: z.infer<typeof sendSchema>) {
|
||||||
|
if (
|
||||||
|
values.confirmation?.toLocaleLowerCase() !== "Send".toLocaleLowerCase()
|
||||||
|
) {
|
||||||
|
sendForm.setError("confirmation", {
|
||||||
|
message: "Please type 'Send' to confirm",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCampaignMutation.mutate(
|
||||||
|
{
|
||||||
|
campaignId: campaign.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpenSendDialog(false);
|
||||||
|
toast.success(`Campaign sent successfully`);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Failed to send campaign: ${error.message}`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmation = sendForm.watch("confirmation");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="w-[600px] mx-auto">
|
||||||
|
<div className="mb-4 flex justify-between items-center">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]"
|
||||||
|
onBlur={() => {
|
||||||
|
if (name === campaign.name || !name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateCampaignMutation.mutate(
|
||||||
|
{
|
||||||
|
campaignId: campaign.id,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (e) => {
|
||||||
|
toast.error(`${e.message}. Reverting changes.`);
|
||||||
|
setName(campaign.name);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
{isSaving ? (
|
||||||
|
<div className="h-2 w-2 bg-yellow-500 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<div className="h-2 w-2 bg-emerald-500 rounded-full" />
|
||||||
|
)}
|
||||||
|
{formatDistanceToNow(campaign.updatedAt) === "less than a minute"
|
||||||
|
? "just now"
|
||||||
|
: `${formatDistanceToNow(campaign.updatedAt)} ago`}
|
||||||
|
</div>
|
||||||
|
<Dialog open={openSendDialog} onOpenChange={setOpenSendDialog}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="default">Send Campaign</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Send Campaign</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to send this campaign? This action
|
||||||
|
cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Form {...sendForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={sendForm.handleSubmit(onSendCampaign)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={sendForm.control}
|
||||||
|
name="confirmation"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Type 'Send' to confirm</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
sendCampaignMutation.isPending ||
|
||||||
|
confirmation?.toLocaleLowerCase() !==
|
||||||
|
"Send".toLocaleLowerCase()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sendCampaignMutation.isPending
|
||||||
|
? "Sending..."
|
||||||
|
: "Send"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 mt-8">
|
||||||
|
<label className="block text-sm font-medium ">Subject</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSubject(e.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (subject === campaign.subject || !subject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateCampaignMutation.mutate(
|
||||||
|
{
|
||||||
|
campaignId: campaign.id,
|
||||||
|
subject,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (e) => {
|
||||||
|
toast.error(`${e.message}. Reverting changes.`);
|
||||||
|
setSubject(campaign.subject);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="mt-1 block w-full rounded-md shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium ">From</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={from}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFrom(e.target.value);
|
||||||
|
}}
|
||||||
|
className="mt-1 block w-full rounded-md shadow-sm"
|
||||||
|
placeholder="Friendly name<hello@example.com>"
|
||||||
|
onBlur={() => {
|
||||||
|
if (from === campaign.from) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateCampaignMutation.mutate(
|
||||||
|
{
|
||||||
|
campaignId: campaign.id,
|
||||||
|
from,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (e) => {
|
||||||
|
toast.error(`${e.message}. Reverting changes.`);
|
||||||
|
setFrom(campaign.from);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-12">
|
||||||
|
<label className="block text-sm font-medium mb-1">To</label>
|
||||||
|
{contactBooksQuery.isLoading ? (
|
||||||
|
<Spinner className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={contactBookId ?? ""}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
// Update the campaign's contactBookId
|
||||||
|
updateCampaignMutation.mutate(
|
||||||
|
{
|
||||||
|
campaignId: campaign.id,
|
||||||
|
contactBookId: val,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: () => {
|
||||||
|
setContactBookId(campaign.contactBookId);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setContactBookId(val);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[300px]">
|
||||||
|
{contactBooksQuery.data?.find(
|
||||||
|
(book) => book.id === contactBookId
|
||||||
|
)?.name || "Select a contact book"}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{contactBooksQuery.data?.map((book) => (
|
||||||
|
<SelectItem key={book.id} value={book.id}>
|
||||||
|
{book.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Editor
|
||||||
|
initialContent={json}
|
||||||
|
onUpdate={(content) => {
|
||||||
|
setJson(content.getJSON());
|
||||||
|
setIsSaving(true);
|
||||||
|
deboucedUpdateCampaign();
|
||||||
|
}}
|
||||||
|
variables={["email", "firstName", "lastName"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
194
apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx
Normal file
194
apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@unsend/ui/src/breadcrumb";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import Spinner from "@unsend/ui/src/spinner";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { EmailStatusIcon } from "../../emails/email-status-badge";
|
||||||
|
import { EmailStatus } from "@prisma/client";
|
||||||
|
import { Separator } from "@unsend/ui/src/separator";
|
||||||
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export default function CampaignDetailsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { campaignId: string };
|
||||||
|
}) {
|
||||||
|
const { data: campaign, isLoading } = api.campaign.getCampaign.useQuery({
|
||||||
|
campaignId: params.campaignId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<Spinner className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
return <div>Campaign not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCards = [
|
||||||
|
{
|
||||||
|
status: "delivered",
|
||||||
|
count: campaign.delivered,
|
||||||
|
percentage: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "unsubscribed",
|
||||||
|
count: campaign.unsubscribed,
|
||||||
|
percentage: (campaign.unsubscribed / campaign.delivered) * 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "clicked",
|
||||||
|
count: campaign.clicked,
|
||||||
|
percentage: (campaign.clicked / campaign.delivered) * 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "opened",
|
||||||
|
count: campaign.opened,
|
||||||
|
percentage: (campaign.opened / campaign.delivered) * 100,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link href="/campaigns" className="text-lg">
|
||||||
|
Campaigns
|
||||||
|
</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator className="text-lg" />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage className="text-lg ">
|
||||||
|
{campaign.name}
|
||||||
|
</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<div className=" rounded-lg shadow mt-10">
|
||||||
|
<h2 className="text-xl font-semibold mb-4"> Statistics</h2>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{statusCards.map((card) => (
|
||||||
|
<div
|
||||||
|
key={card.status}
|
||||||
|
className="h-[100px] w-1/4 bg-secondary/10 border rounded-lg p-4 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{card.status !== "total" ? (
|
||||||
|
<CampaignStatusBadge status={card.status} />
|
||||||
|
) : null}
|
||||||
|
<div className="capitalize">{card.status.toLowerCase()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<div className="text-primary font-light text-2xl font-mono">
|
||||||
|
{card.count}
|
||||||
|
</div>
|
||||||
|
{card.status !== "total" ? (
|
||||||
|
<div className="text-sm pb-1">
|
||||||
|
{card.percentage.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{campaign.html && (
|
||||||
|
<div className=" rounded-lg shadow mt-16">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Email</h2>
|
||||||
|
|
||||||
|
<div className="p-2 rounded-lg border flex flex-col gap-4 w-full">
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<span className="w-[65px] text-muted-foreground ">From</span>
|
||||||
|
<span>{campaign.from}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="w-[65px] text-muted-foreground ">To</span>
|
||||||
|
{campaign.contactBookId ? (
|
||||||
|
<Link
|
||||||
|
href={`/contacts/${campaign.contactBookId}`}
|
||||||
|
className="text-primary px-4 p-1 bg-muted text-sm rounded-md flex gap-1 items-center"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{campaign.contactBook?.name}
|
||||||
|
<ExternalLinkIcon className="w-4 h-4 " />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div>No one</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="w-[65px] text-muted-foreground ">Subject</span>
|
||||||
|
<span>{campaign.subject}</span>
|
||||||
|
</div>
|
||||||
|
<div className=" dark:bg-slate-50 overflow-auto text-black rounded py-8">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: campaign.html ?? "" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CampaignStatusBadge: React.FC<{ status: string }> = ({
|
||||||
|
status,
|
||||||
|
}) => {
|
||||||
|
let outsideColor = "bg-gray-600";
|
||||||
|
let insideColor = "bg-gray-600/50";
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "delivered":
|
||||||
|
outsideColor = "bg-emerald-500/30";
|
||||||
|
insideColor = "bg-emerald-500";
|
||||||
|
break;
|
||||||
|
case "bounced":
|
||||||
|
case "unsubscribed":
|
||||||
|
outsideColor = "bg-red-500/30";
|
||||||
|
insideColor = "bg-red-500";
|
||||||
|
break;
|
||||||
|
case "clicked":
|
||||||
|
outsideColor = "bg-cyan-500/30";
|
||||||
|
insideColor = "bg-cyan-500";
|
||||||
|
break;
|
||||||
|
case "opened":
|
||||||
|
outsideColor = "bg-indigo-500/30";
|
||||||
|
insideColor = "bg-indigo-500";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "complained":
|
||||||
|
outsideColor = "bg-yellow-500/30";
|
||||||
|
insideColor = "bg-yellow-500";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
outsideColor = "bg-gray-600/40";
|
||||||
|
insideColor = "bg-gray-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex justify-center items-center p-1.5 ${outsideColor} rounded-full`}
|
||||||
|
>
|
||||||
|
<div className={`h-2 w-2 rounded-full ${insideColor}`}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
150
apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx
Normal file
150
apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
} from "@unsend/ui/src/table";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useUrlState } from "~/hooks/useUrlState";
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@unsend/ui/src/select";
|
||||||
|
import Spinner from "@unsend/ui/src/spinner";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { CampaignStatus } from "@prisma/client";
|
||||||
|
import DeleteCampaign from "./delete-campaign";
|
||||||
|
import { Edit2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import DuplicateCampaign from "./duplicate-campaign";
|
||||||
|
|
||||||
|
export default function CampaignList() {
|
||||||
|
const [page, setPage] = useUrlState("page", "1");
|
||||||
|
const [status, setStatus] = useUrlState("status");
|
||||||
|
|
||||||
|
const pageNumber = Number(page);
|
||||||
|
|
||||||
|
const campaignsQuery = api.campaign.getCampaigns.useQuery({
|
||||||
|
page: pageNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-10 flex flex-col gap-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{/* <Select
|
||||||
|
value={status ?? "All"}
|
||||||
|
onValueChange={(val) => setStatus(val === "All" ? null : val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px] capitalize">
|
||||||
|
{status || "All statuses"}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="All" className=" capitalize">
|
||||||
|
All statuses
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Active" className=" capitalize">
|
||||||
|
Active
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Inactive" className=" capitalize">
|
||||||
|
Inactive
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select> */}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col rounded-xl border border-border shadow">
|
||||||
|
<Table className="">
|
||||||
|
<TableHeader className="">
|
||||||
|
<TableRow className=" bg-muted/30">
|
||||||
|
<TableHead className="rounded-tl-xl">Name</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="">Created At</TableHead>
|
||||||
|
<TableHead className="rounded-tr-xl">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{campaignsQuery.isLoading ? (
|
||||||
|
<TableRow className="h-32">
|
||||||
|
<TableCell colSpan={4} className="text-center py-4">
|
||||||
|
<Spinner
|
||||||
|
className="w-6 h-6 mx-auto"
|
||||||
|
innerSvgClass="stroke-primary"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : campaignsQuery.data?.campaigns.length ? (
|
||||||
|
campaignsQuery.data?.campaigns.map((campaign) => (
|
||||||
|
<TableRow key={campaign.id} className="">
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Link
|
||||||
|
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-primary"
|
||||||
|
href={
|
||||||
|
campaign.status === CampaignStatus.DRAFT
|
||||||
|
? `/campaigns/${campaign.id}/edit`
|
||||||
|
: `/campaigns/${campaign.id}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{campaign.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div
|
||||||
|
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
|
||||||
|
campaign.status === CampaignStatus.DRAFT
|
||||||
|
? "bg-gray-500/10 text-gray-500 border-gray-600/10"
|
||||||
|
: campaign.status === CampaignStatus.SENT
|
||||||
|
? "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"
|
||||||
|
: "bg-yellow-500/10 text-yellow-600 border-yellow-600/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{campaign.status.toLowerCase()}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="">
|
||||||
|
{formatDistanceToNow(new Date(campaign.createdAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<DuplicateCampaign campaign={campaign} />
|
||||||
|
<DeleteCampaign campaign={campaign} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow className="h-32">
|
||||||
|
<TableCell colSpan={4} className="text-center py-4">
|
||||||
|
No campaigns found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((pageNumber - 1).toString())}
|
||||||
|
disabled={pageNumber === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((pageNumber + 1).toString())}
|
||||||
|
disabled={pageNumber >= (campaignsQuery.data?.totalPage ?? 0)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
166
apps/web/src/app/(dashboard)/campaigns/create-campaign.tsx
Normal file
166
apps/web/src/app/(dashboard)/campaigns/create-campaign.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@unsend/ui/src/form";
|
||||||
|
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Spinner from "@unsend/ui/src/spinner";
|
||||||
|
|
||||||
|
const campaignSchema = z.object({
|
||||||
|
name: z.string({ required_error: "Name is required" }).min(1, {
|
||||||
|
message: "Name is required",
|
||||||
|
}),
|
||||||
|
from: z.string({ required_error: "From email is required" }).min(1, {
|
||||||
|
message: "From email is required",
|
||||||
|
}),
|
||||||
|
subject: z.string({ required_error: "Subject is required" }).min(1, {
|
||||||
|
message: "Subject is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function CreateCampaign() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const createCampaignMutation = api.campaign.createCampaign.useMutation();
|
||||||
|
|
||||||
|
const campaignForm = useForm<z.infer<typeof campaignSchema>>({
|
||||||
|
resolver: zodResolver(campaignSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
from: "",
|
||||||
|
subject: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
async function onCampaignCreate(values: z.infer<typeof campaignSchema>) {
|
||||||
|
createCampaignMutation.mutate(
|
||||||
|
{
|
||||||
|
name: values.name,
|
||||||
|
from: values.from,
|
||||||
|
subject: values.subject,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
utils.campaign.getCampaigns.invalidate();
|
||||||
|
router.push(`/campaigns/${data.id}/edit`);
|
||||||
|
toast.success("Campaign created successfully");
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Create Campaign
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create new campaign</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Form {...campaignForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={campaignForm.handleSubmit(onCampaignCreate)}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={campaignForm.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Campaign Name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.name ? <FormMessage /> : null}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={campaignForm.control}
|
||||||
|
name="from"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>From</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Friendly Name <from@example.com>"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.from ? <FormMessage /> : null}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={campaignForm.control}
|
||||||
|
name="subject"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Subject</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Campaign Subject" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.subject ? <FormMessage /> : null}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Don't worry, you can change it later.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
className=" w-[100px]"
|
||||||
|
type="submit"
|
||||||
|
disabled={createCampaignMutation.isPending}
|
||||||
|
>
|
||||||
|
{createCampaignMutation.isPending ? (
|
||||||
|
<Spinner className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
"Create"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
134
apps/web/src/app/(dashboard)/campaigns/delete-campaign.tsx
Normal file
134
apps/web/src/app/(dashboard)/campaigns/delete-campaign.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@unsend/ui/src/form";
|
||||||
|
import { Campaign } from "@prisma/client";
|
||||||
|
|
||||||
|
const campaignSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeleteCampaign: React.FC<{
|
||||||
|
campaign: Partial<Campaign> & { id: string };
|
||||||
|
}> = ({ campaign }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const deleteCampaignMutation = api.campaign.deleteCampaign.useMutation();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const campaignForm = useForm<z.infer<typeof campaignSchema>>({
|
||||||
|
resolver: zodResolver(campaignSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onCampaignDelete(values: z.infer<typeof campaignSchema>) {
|
||||||
|
if (values.name !== campaign.name) {
|
||||||
|
campaignForm.setError("name", {
|
||||||
|
message: "Name does not match",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCampaignMutation.mutate(
|
||||||
|
{
|
||||||
|
campaignId: campaign.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.campaign.getCampaigns.invalidate();
|
||||||
|
setOpen(false);
|
||||||
|
toast.success(`Campaign deleted`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = campaignForm.watch("name");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600/80" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Campaign</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-semibold text-primary">{campaign.name}</span>?
|
||||||
|
You can't reverse this.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Form {...campaignForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={campaignForm.handleSubmit(onCampaignDelete)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={campaignForm.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.name ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription className=" text-transparent">
|
||||||
|
.
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={
|
||||||
|
deleteCampaignMutation.isPending || campaign.name !== name
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deleteCampaignMutation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteCampaign;
|
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
import { Campaign } from "@prisma/client";
|
||||||
|
|
||||||
|
export const DuplicateCampaign: React.FC<{
|
||||||
|
campaign: Partial<Campaign> & { id: string };
|
||||||
|
}> = ({ campaign }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const duplicateCampaignMutation =
|
||||||
|
api.campaign.duplicateCampaign.useMutation();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
async function onCampaignDuplicate() {
|
||||||
|
duplicateCampaignMutation.mutate(
|
||||||
|
{
|
||||||
|
campaignId: campaign.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.campaign.getCampaigns.invalidate();
|
||||||
|
setOpen(false);
|
||||||
|
toast.success(`Campaign duplicated`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Copy className="h-4 w-4 text-blue-600/80" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Duplicate Campaign</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to duplicate{" "}
|
||||||
|
<span className="font-semibold text-primary">{campaign.name}</span>?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={onCampaignDuplicate}
|
||||||
|
variant="default"
|
||||||
|
disabled={duplicateCampaignMutation.isPending}
|
||||||
|
>
|
||||||
|
{duplicateCampaignMutation.isPending
|
||||||
|
? "Duplicating..."
|
||||||
|
: "Duplicate"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DuplicateCampaign;
|
16
apps/web/src/app/(dashboard)/campaigns/page.tsx
Normal file
16
apps/web/src/app/(dashboard)/campaigns/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import CampaignList from "./campaign-list";
|
||||||
|
import CreateCampaign from "./create-campaign";
|
||||||
|
|
||||||
|
export default function ContactsPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="font-bold text-lg">Campaigns</h1>
|
||||||
|
<CreateCampaign />
|
||||||
|
</div>
|
||||||
|
<CampaignList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Textarea } from "@unsend/ui/src/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@unsend/ui/src/form";
|
||||||
|
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
|
||||||
|
const contactsSchema = z.object({
|
||||||
|
contacts: z.string({ required_error: "Contacts are required" }).min(1, {
|
||||||
|
message: "Contacts are required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AddContact({
|
||||||
|
contactBookId,
|
||||||
|
}: {
|
||||||
|
contactBookId: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const addContactsMutation = api.contacts.addContacts.useMutation();
|
||||||
|
|
||||||
|
const contactsForm = useForm<z.infer<typeof contactsSchema>>({
|
||||||
|
resolver: zodResolver(contactsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
contacts: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
async function onContactsAdd(values: z.infer<typeof contactsSchema>) {
|
||||||
|
const contactsArray = values.contacts.split(",").map((email) => ({
|
||||||
|
email: email.trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
addContactsMutation.mutate(
|
||||||
|
{
|
||||||
|
contactBookId,
|
||||||
|
contacts: contactsArray,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
utils.contacts.contacts.invalidate();
|
||||||
|
setOpen(false);
|
||||||
|
toast.success("Contacts added successfully");
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Contacts
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add new contacts</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Form {...contactsForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={contactsForm.handleSubmit(onContactsAdd)}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={contactsForm.control}
|
||||||
|
name="contacts"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Contacts</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="email1@example.com, email2@example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.contacts ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription>
|
||||||
|
Enter comma-separated email addresses.
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||||
|
type="submit"
|
||||||
|
disabled={addContactsMutation.isPending}
|
||||||
|
>
|
||||||
|
{addContactsMutation.isPending ? "Adding..." : "Add"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
} from "@unsend/ui/src/table";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useUrlState } from "~/hooks/useUrlState";
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@unsend/ui/src/select";
|
||||||
|
import Spinner from "@unsend/ui/src/spinner";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import DeleteContact from "./delete-contact";
|
||||||
|
import EditContact from "./edit-contact";
|
||||||
|
|
||||||
|
export default function ContactList({
|
||||||
|
contactBookId,
|
||||||
|
}: {
|
||||||
|
contactBookId: string;
|
||||||
|
}) {
|
||||||
|
const [page, setPage] = useUrlState("page", "1");
|
||||||
|
const [status, setStatus] = useUrlState("status");
|
||||||
|
|
||||||
|
const pageNumber = Number(page);
|
||||||
|
|
||||||
|
const contactsQuery = api.contacts.contacts.useQuery({
|
||||||
|
contactBookId,
|
||||||
|
page: pageNumber,
|
||||||
|
subscribed:
|
||||||
|
status === "Subscribed"
|
||||||
|
? true
|
||||||
|
: status === "Unsubscribed"
|
||||||
|
? false
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-10 flex flex-col gap-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Select
|
||||||
|
value={status ?? "All"}
|
||||||
|
onValueChange={(val) => setStatus(val === "All" ? null : val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px] capitalize">
|
||||||
|
{status || "All statuses"}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="All" className=" capitalize">
|
||||||
|
All statuses
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Subscribed" className=" capitalize">
|
||||||
|
Subscribed
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Unsubscribed" className=" capitalize">
|
||||||
|
Unsubscribed
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col rounded-xl border border-broder shadow">
|
||||||
|
<Table className="">
|
||||||
|
<TableHeader className="">
|
||||||
|
<TableRow className=" bg-muted/30">
|
||||||
|
<TableHead className="rounded-tl-xl">Email</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="">Created At</TableHead>
|
||||||
|
<TableHead className="rounded-tr-xl">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{contactsQuery.isLoading ? (
|
||||||
|
<TableRow className="h-32">
|
||||||
|
<TableCell colSpan={4} className="text-center py-4">
|
||||||
|
<Spinner
|
||||||
|
className="w-6 h-6 mx-auto"
|
||||||
|
innerSvgClass="stroke-primary"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : contactsQuery.data?.contacts.length ? (
|
||||||
|
contactsQuery.data?.contacts.map((contact) => (
|
||||||
|
<TableRow key={contact.id} className="">
|
||||||
|
<TableCell className="font-medium">{contact.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div
|
||||||
|
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
|
||||||
|
contact.subscribed
|
||||||
|
? "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"
|
||||||
|
: "bg-red-500/10 text-red-600 border-red-600/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{contact.subscribed ? "Subscribed" : "Unsubscribed"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="">
|
||||||
|
{formatDistanceToNow(new Date(contact.createdAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<EditContact contact={contact} />
|
||||||
|
<DeleteContact contact={contact} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow className="h-32">
|
||||||
|
<TableCell colSpan={4} className="text-center py-4">
|
||||||
|
No contacts found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((pageNumber - 1).toString())}
|
||||||
|
disabled={pageNumber === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((pageNumber + 1).toString())}
|
||||||
|
disabled={pageNumber >= (contactsQuery.data?.totalPage ?? 0)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@unsend/ui/src/form";
|
||||||
|
import { Contact } from "@prisma/client";
|
||||||
|
|
||||||
|
const contactSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeleteContact: React.FC<{
|
||||||
|
contact: Partial<Contact> & { id: string; contactBookId: string };
|
||||||
|
}> = ({ contact }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const deleteContactMutation = api.contacts.deleteContact.useMutation();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const contactForm = useForm<z.infer<typeof contactSchema>>({
|
||||||
|
resolver: zodResolver(contactSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onContactDelete(values: z.infer<typeof contactSchema>) {
|
||||||
|
if (values.email !== contact.email) {
|
||||||
|
contactForm.setError("email", {
|
||||||
|
message: "Email does not match",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteContactMutation.mutate(
|
||||||
|
{
|
||||||
|
contactId: contact.id,
|
||||||
|
contactBookId: contact.contactBookId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.contacts.contacts.invalidate();
|
||||||
|
setOpen(false);
|
||||||
|
toast.success(`Contact deleted`);
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
toast.error(`Contact not deleted: ${e.message}`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = contactForm.watch("email");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600/80" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Contact</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-semibold text-primary">{contact.email}</span>?
|
||||||
|
You can't reverse this.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Form {...contactForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={contactForm.handleSubmit(onContactDelete)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={contactForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.email ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription className=" text-transparent">
|
||||||
|
.
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={
|
||||||
|
deleteContactMutation.isPending || contact.email !== email
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deleteContactMutation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteContact;
|
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@unsend/ui/src/form";
|
||||||
|
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Edit } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
import { Switch } from "@unsend/ui/src/switch";
|
||||||
|
import { Contact } from "@prisma/client";
|
||||||
|
|
||||||
|
const contactSchema = z.object({
|
||||||
|
email: z.string().email({ message: "Invalid email address" }),
|
||||||
|
firstName: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
|
subscribed: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EditContact: React.FC<{
|
||||||
|
contact: Partial<Contact> & { id: string; contactBookId: string };
|
||||||
|
}> = ({ contact }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const updateContactMutation = api.contacts.updateContact.useMutation();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const contactForm = useForm<z.infer<typeof contactSchema>>({
|
||||||
|
resolver: zodResolver(contactSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: contact.email || "",
|
||||||
|
firstName: contact.firstName || "",
|
||||||
|
lastName: contact.lastName || "",
|
||||||
|
subscribed: contact.subscribed || false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onContactUpdate(values: z.infer<typeof contactSchema>) {
|
||||||
|
updateContactMutation.mutate(
|
||||||
|
{
|
||||||
|
contactId: contact.id,
|
||||||
|
contactBookId: contact.contactBookId,
|
||||||
|
...values,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
utils.contacts.contacts.invalidate();
|
||||||
|
setOpen(false);
|
||||||
|
toast.success("Contact updated successfully");
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Contact</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Form {...contactForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={contactForm.handleSubmit(onContactUpdate)}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={contactForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="email@example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.email ? <FormMessage /> : null}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={contactForm.control}
|
||||||
|
name="firstName"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>First Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="First Name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={contactForm.control}
|
||||||
|
name="lastName"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Last Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Last Name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={contactForm.control}
|
||||||
|
name="subscribed"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="fle flex-row gap-2">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Subscribed</FormLabel>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
className="data-[state=checked]:bg-emerald-500"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||||
|
type="submit"
|
||||||
|
disabled={updateContactMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateContactMutation.isPending ? "Updating..." : "Update"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditContact;
|
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@unsend/ui/src/breadcrumb";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import AddContact from "./add-contact";
|
||||||
|
import ContactList from "./contact-list";
|
||||||
|
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
|
||||||
|
export default function ContactsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { contactBookId: string };
|
||||||
|
}) {
|
||||||
|
const contactBookDetailQuery = api.contacts.getContactBookDetails.useQuery({
|
||||||
|
contactBookId: params.contactBookId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link href="/contacts" className="text-lg">
|
||||||
|
Contact books
|
||||||
|
</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator className="text-lg" />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage className="text-lg ">
|
||||||
|
{contactBookDetailQuery.data?.name}
|
||||||
|
</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<AddContact contactBookId={params.contactBookId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-16">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
<div className=" text-muted-foreground">Total Contacts</div>
|
||||||
|
<div className="text-xl mt-3">
|
||||||
|
{contactBookDetailQuery.data?.totalContacts !== undefined
|
||||||
|
? contactBookDetailQuery.data?.totalContacts
|
||||||
|
: "--"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">Unsubscribed</div>
|
||||||
|
<div className="text-xl mt-3">
|
||||||
|
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined
|
||||||
|
? contactBookDetailQuery.data?.unsubscribedContacts
|
||||||
|
: "--"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">Created at</div>
|
||||||
|
<div className="text-xl mt-3">
|
||||||
|
{contactBookDetailQuery.data?.createdAt
|
||||||
|
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, {
|
||||||
|
addSuffix: true,
|
||||||
|
})
|
||||||
|
: "--"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">Contact book id</div>
|
||||||
|
<div className="border mt-3 px-3 rounded bg-muted/30 ">
|
||||||
|
<TextWithCopyButton value={params.contactBookId} alwaysShowCopy />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-16">
|
||||||
|
<ContactList contactBookId={params.contactBookId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
124
apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx
Normal file
124
apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@unsend/ui/src/form";
|
||||||
|
|
||||||
|
const contactBookSchema = z.object({
|
||||||
|
name: z.string({ required_error: "Name is required" }).min(1, {
|
||||||
|
message: "Name is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AddContactBook() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const createContactBookMutation =
|
||||||
|
api.contacts.createContactBook.useMutation();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
|
||||||
|
resolver: zodResolver(contactBookSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSave(values: z.infer<typeof contactBookSchema>) {
|
||||||
|
createContactBookMutation.mutate(
|
||||||
|
{
|
||||||
|
name: values.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.contacts.getContactBooks.invalidate();
|
||||||
|
contactBookForm.reset();
|
||||||
|
setOpen(false);
|
||||||
|
toast.success("Contact book created successfully");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Contact Book
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create a new contact book</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Form {...contactBookForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={contactBookForm.handleSubmit(handleSave)}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={contactBookForm.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Contact book name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My contacts" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.name ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription>
|
||||||
|
eg: product / website / newsletter name
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||||
|
type="submit"
|
||||||
|
disabled={createContactBookMutation.isPending}
|
||||||
|
>
|
||||||
|
{createContactBookMutation.isPending
|
||||||
|
? "Creating..."
|
||||||
|
: "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
79
apps/web/src/app/(dashboard)/contacts/contact-books-list.tsx
Normal file
79
apps/web/src/app/(dashboard)/contacts/contact-books-list.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@unsend/ui/src/table";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import Spinner from "@unsend/ui/src/spinner";
|
||||||
|
import DeleteContactBook from "./delete-contact-book";
|
||||||
|
import Link from "next/link";
|
||||||
|
import EditContactBook from "./edit-contact-book";
|
||||||
|
|
||||||
|
export default function ContactBooksList() {
|
||||||
|
const contactBooksQuery = api.contacts.getContactBooks.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-10">
|
||||||
|
<div className="border rounded-xl">
|
||||||
|
<Table className="">
|
||||||
|
<TableHeader className="">
|
||||||
|
<TableRow className=" bg-muted/30">
|
||||||
|
<TableHead className="rounded-tl-xl">Name</TableHead>
|
||||||
|
<TableHead>Contacts</TableHead>
|
||||||
|
<TableHead>Created at</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{contactBooksQuery.isLoading ? (
|
||||||
|
<TableRow className="h-32">
|
||||||
|
<TableCell colSpan={6} className="text-center py-4">
|
||||||
|
<Spinner
|
||||||
|
className="w-6 h-6 mx-auto"
|
||||||
|
innerSvgClass="stroke-primary"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : contactBooksQuery.data?.length === 0 ? (
|
||||||
|
<TableRow className="h-32">
|
||||||
|
<TableCell colSpan={6} className="text-center py-4">
|
||||||
|
<p>No contact books added</p>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
contactBooksQuery.data?.map((contactBook) => (
|
||||||
|
<TableRow>
|
||||||
|
<TableHead scope="row">
|
||||||
|
<Link
|
||||||
|
href={`/contacts/${contactBook.id}`}
|
||||||
|
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
{contactBook.name}
|
||||||
|
</Link>
|
||||||
|
</TableHead>
|
||||||
|
{/* <TableCell>{contactBook.name}</TableCell> */}
|
||||||
|
<TableCell>{contactBook._count.contacts}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatDistanceToNow(contactBook.createdAt, {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<EditContactBook contactBook={contactBook} />
|
||||||
|
<DeleteContactBook contactBook={contactBook} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
142
apps/web/src/app/(dashboard)/contacts/delete-contact-book.tsx
Normal file
142
apps/web/src/app/(dashboard)/contacts/delete-contact-book.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@unsend/ui/src/form";
|
||||||
|
import { ContactBook } from "@prisma/client";
|
||||||
|
|
||||||
|
const contactBookSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeleteContactBook: React.FC<{
|
||||||
|
contactBook: Partial<ContactBook> & { id: string };
|
||||||
|
}> = ({ contactBook }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const deleteContactBookMutation =
|
||||||
|
api.contacts.deleteContactBook.useMutation();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
|
||||||
|
resolver: zodResolver(contactBookSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onContactBookDelete(
|
||||||
|
values: z.infer<typeof contactBookSchema>
|
||||||
|
) {
|
||||||
|
if (values.name !== contactBook.name) {
|
||||||
|
contactBookForm.setError("name", {
|
||||||
|
message: "Name does not match",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteContactBookMutation.mutate(
|
||||||
|
{
|
||||||
|
contactBookId: contactBook.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.contacts.getContactBooks.invalidate();
|
||||||
|
setOpen(false);
|
||||||
|
toast.success(`Contact book deleted`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = contactBookForm.watch("name");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600/80" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Contact Book</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{contactBook.name}
|
||||||
|
</span>
|
||||||
|
? You can't reverse this.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Form {...contactBookForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={contactBookForm.handleSubmit(onContactBookDelete)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={contactBookForm.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.name ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription className=" text-transparent">
|
||||||
|
.
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={
|
||||||
|
deleteContactBookMutation.isPending ||
|
||||||
|
contactBook.name !== name
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deleteContactBookMutation.isPending
|
||||||
|
? "Deleting..."
|
||||||
|
: "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteContactBook;
|
122
apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx
Normal file
122
apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Input } from "@unsend/ui/src/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@unsend/ui/src/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@unsend/ui/src/form";
|
||||||
|
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Edit } from "lucide-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
|
||||||
|
const contactBookSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: "Name is required" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EditContactBook: React.FC<{
|
||||||
|
contactBook: { id: string; name: string };
|
||||||
|
}> = ({ contactBook }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const updateContactBookMutation =
|
||||||
|
api.contacts.updateContactBook.useMutation();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
|
||||||
|
resolver: zodResolver(contactBookSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: contactBook.name || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onContactBookUpdate(
|
||||||
|
values: z.infer<typeof contactBookSchema>
|
||||||
|
) {
|
||||||
|
updateContactBookMutation.mutate(
|
||||||
|
{
|
||||||
|
contactBookId: contactBook.id,
|
||||||
|
...values,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
utils.contacts.getContactBooks.invalidate();
|
||||||
|
setOpen(false);
|
||||||
|
toast.success("Contact book updated successfully");
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Contact Book</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Form {...contactBookForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={contactBookForm.handleSubmit(onContactBookUpdate)}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={contactBookForm.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Contact Book Name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.name ? <FormMessage /> : null}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||||
|
type="submit"
|
||||||
|
disabled={updateContactBookMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateContactBookMutation.isPending
|
||||||
|
? "Updating..."
|
||||||
|
: "Update"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditContactBook;
|
16
apps/web/src/app/(dashboard)/contacts/page.tsx
Normal file
16
apps/web/src/app/(dashboard)/contacts/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import AddContactBook from "./add-contact-book";
|
||||||
|
import ContactBooksList from "./contact-books-list";
|
||||||
|
|
||||||
|
export default function ContactsPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="font-bold text-lg">Contact books</h1>
|
||||||
|
<AddContactBook />
|
||||||
|
</div>
|
||||||
|
<ContactBooksList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -66,14 +66,14 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|||||||
Domains
|
Domains
|
||||||
</NavButton>
|
</NavButton>
|
||||||
|
|
||||||
<NavButton href="/contacts" comingSoon>
|
<NavButton href="/contacts">
|
||||||
<BookUser className="h-4 w-4" />
|
<BookUser className="h-4 w-4" />
|
||||||
Contacts
|
Contacts
|
||||||
</NavButton>
|
</NavButton>
|
||||||
|
|
||||||
<NavButton href="/contacts" comingSoon>
|
<NavButton href="/campaigns">
|
||||||
<Volume2 className="h-4 w-4" />
|
<Volume2 className="h-4 w-4" />
|
||||||
Marketing
|
Campaigns
|
||||||
</NavButton>
|
</NavButton>
|
||||||
|
|
||||||
<NavButton href="/api-keys">
|
<NavButton href="/api-keys">
|
||||||
@@ -104,7 +104,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<header className="flex h-14 items-center gap-4 md:hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
|
<header className=" h-14 items-center gap-4 hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
|
||||||
<Sheet>
|
<Sheet>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
50
apps/web/src/app/api/to-html/route.ts
Normal file
50
apps/web/src/app/api/to-html/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const data = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const renderer = new EmailRenderer(data);
|
||||||
|
const time = Date.now();
|
||||||
|
const html = await renderer.render({
|
||||||
|
shouldReplaceVariableValues: true,
|
||||||
|
linkValues: {
|
||||||
|
"{{unsend_unsubscribe_url}}": "https://unsend.com/unsubscribe",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Time taken: ${Date.now() - time}ms`);
|
||||||
|
return new Response(JSON.stringify({ data: html }), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ data: "Error in converting to html" }),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OPTIONS() {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
@@ -25,7 +25,7 @@ export default async function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`font-sans ${inter.variable}`}>
|
<body className={`font-sans ${inter.variable} app`}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||||
|
51
apps/web/src/app/unsubscribe/page.tsx
Normal file
51
apps/web/src/app/unsubscribe/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import {
|
||||||
|
unsubscribeContact,
|
||||||
|
subscribeContact,
|
||||||
|
} from "~/server/service/campaign-service";
|
||||||
|
import ReSubscribe from "./re-subscribe";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
async function UnsubscribePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: { [key: string]: string | string[] | undefined };
|
||||||
|
}) {
|
||||||
|
const id = searchParams.id as string;
|
||||||
|
const hash = searchParams.hash as string;
|
||||||
|
|
||||||
|
if (!id || !hash) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="max-w-md w-full space-y-8 p-10 shadow rounded-xl">
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold ">
|
||||||
|
Unsubscribe
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Invalid unsubscribe link. Please check your URL and try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = await unsubscribeContact(id, hash);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center ">
|
||||||
|
<ReSubscribe id={id} hash={hash} contact={contact} />
|
||||||
|
|
||||||
|
<div className=" fixed bottom-10 p-4">
|
||||||
|
<p>
|
||||||
|
Powered by{" "}
|
||||||
|
<a href="https://unsend.dev" className="font-bold" target="_blank">
|
||||||
|
Unsend
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UnsubscribePage;
|
60
apps/web/src/app/unsubscribe/re-subscribe.tsx
Normal file
60
apps/web/src/app/unsubscribe/re-subscribe.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Contact } from "@prisma/client";
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import Spinner from "@unsend/ui/src/spinner";
|
||||||
|
import { toast } from "@unsend/ui/src/toaster";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
|
export default function ReSubscribe({
|
||||||
|
id,
|
||||||
|
hash,
|
||||||
|
contact,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
hash: string;
|
||||||
|
contact: Contact;
|
||||||
|
}) {
|
||||||
|
const [subscribed, setSubscribed] = useState(false);
|
||||||
|
|
||||||
|
const reSubscribe = api.campaign.reSubscribeContact.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("You have been subscribed again");
|
||||||
|
setSubscribed(true);
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
toast.error(e.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl w-full space-y-8 p-10 border shadow rounded-xl">
|
||||||
|
<h2 className=" text-center text-xl font-extrabold ">
|
||||||
|
{subscribed ? "You have subscribed again" : "You have unsubscribed"}
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
{subscribed
|
||||||
|
? "You have been added to our mailing list and will receive all emails at"
|
||||||
|
: "You have been removed from our mailing list and won't receive any emails at"}{" "}
|
||||||
|
<span className="font-bold">{contact.email}</span>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{!subscribed ? (
|
||||||
|
<Button
|
||||||
|
className="mx-auto w-[150px]"
|
||||||
|
onClick={() => reSubscribe.mutate({ id, hash })}
|
||||||
|
disabled={reSubscribe.isPending}
|
||||||
|
>
|
||||||
|
{reSubscribe.isPending ? (
|
||||||
|
<Spinner className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
"Subscribe Again"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -22,6 +22,7 @@ const FormSchema = z.object({
|
|||||||
region: z.string(),
|
region: z.string(),
|
||||||
unsendUrl: z.string().url(),
|
unsendUrl: z.string().url(),
|
||||||
sendRate: z.number(),
|
sendRate: z.number(),
|
||||||
|
transactionalQuota: z.number().min(0).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
type SesSettingsProps = {
|
type SesSettingsProps = {
|
||||||
@@ -56,6 +57,7 @@ export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
|
|||||||
region: "",
|
region: "",
|
||||||
unsendUrl: "",
|
unsendUrl: "",
|
||||||
sendRate: 1,
|
sendRate: 1,
|
||||||
|
transactionalQuota: 50,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,6 +169,26 @@ export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="transactionalQuota"
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Transactional Quota</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="0" className="w-full" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{formState.errors.transactionalQuota ? (
|
||||||
|
<FormMessage />
|
||||||
|
) : (
|
||||||
|
<FormDescription>
|
||||||
|
The percentage of the quota to be used for transactional
|
||||||
|
emails (0-100%).
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={addSesSettings.isPending}
|
disabled={addSesSettings.isPending}
|
||||||
|
25
apps/web/src/hooks/useInterval.ts
Normal file
25
apps/web/src/hooks/useInterval.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function useInterval(callback: () => void, delay: number | null) {
|
||||||
|
const savedCallback = useRef<() => void>();
|
||||||
|
|
||||||
|
// Remember the latest callback.
|
||||||
|
useEffect(() => {
|
||||||
|
savedCallback.current = callback;
|
||||||
|
}, [callback]);
|
||||||
|
|
||||||
|
// Set up the interval.
|
||||||
|
useEffect(() => {
|
||||||
|
function tick() {
|
||||||
|
if (savedCallback.current) {
|
||||||
|
savedCallback.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (delay !== null) {
|
||||||
|
const id = setInterval(tick, delay);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}
|
||||||
|
}, [delay]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useInterval;
|
@@ -4,6 +4,8 @@ import { apiRouter } from "./routers/api";
|
|||||||
import { emailRouter } from "./routers/email";
|
import { emailRouter } from "./routers/email";
|
||||||
import { teamRouter } from "./routers/team";
|
import { teamRouter } from "./routers/team";
|
||||||
import { adminRouter } from "./routers/admin";
|
import { adminRouter } from "./routers/admin";
|
||||||
|
import { contactsRouter } from "./routers/contacts";
|
||||||
|
import { campaignRouter } from "./routers/campaign";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -16,6 +18,8 @@ export const appRouter = createTRPCRouter({
|
|||||||
email: emailRouter,
|
email: emailRouter,
|
||||||
team: teamRouter,
|
team: teamRouter,
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
|
contacts: contactsRouter,
|
||||||
|
campaign: campaignRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
@@ -26,12 +26,32 @@ export const adminRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
region: z.string(),
|
region: z.string(),
|
||||||
unsendUrl: z.string().url(),
|
unsendUrl: z.string().url(),
|
||||||
|
sendRate: z.number(),
|
||||||
|
transactionalQuota: z.number(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return SesSettingsService.createSesSetting({
|
return SesSettingsService.createSesSetting({
|
||||||
region: input.region,
|
region: input.region,
|
||||||
unsendUrl: input.unsendUrl,
|
unsendUrl: input.unsendUrl,
|
||||||
|
sendingRateLimit: input.sendRate,
|
||||||
|
transactionalQuota: input.transactionalQuota,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateSesSettings: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
settingsId: z.string(),
|
||||||
|
sendRate: z.number(),
|
||||||
|
transactionalQuota: z.number(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return SesSettingsService.updateSesSetting({
|
||||||
|
id: input.settingsId,
|
||||||
|
sendingRateLimit: input.sendRate,
|
||||||
|
transactionalQuota: input.transactionalQuota,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
183
apps/web/src/server/api/routers/campaign.ts
Normal file
183
apps/web/src/server/api/routers/campaign.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
teamProcedure,
|
||||||
|
createTRPCRouter,
|
||||||
|
campaignProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
} from "~/server/api/trpc";
|
||||||
|
import {
|
||||||
|
sendCampaign,
|
||||||
|
subscribeContact,
|
||||||
|
} from "~/server/service/campaign-service";
|
||||||
|
import { validateDomainFromEmail } from "~/server/service/domain-service";
|
||||||
|
|
||||||
|
export const campaignRouter = createTRPCRouter({
|
||||||
|
getCampaigns: teamProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
page: z.number().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx: { db, team }, input }) => {
|
||||||
|
const page = input.page || 1;
|
||||||
|
const limit = 30;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const whereConditions: Prisma.CampaignFindManyArgs["where"] = {
|
||||||
|
teamId: team.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const countP = db.campaign.count({ where: whereConditions });
|
||||||
|
|
||||||
|
const campaignsP = db.campaign.findMany({
|
||||||
|
where: whereConditions,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
from: true,
|
||||||
|
subject: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [campaigns, count] = await Promise.all([campaignsP, countP]);
|
||||||
|
|
||||||
|
return { campaigns, totalPage: Math.ceil(count / limit) };
|
||||||
|
}),
|
||||||
|
|
||||||
|
createCampaign: teamProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
from: z.string(),
|
||||||
|
subject: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx: { db, team }, input }) => {
|
||||||
|
const domain = await validateDomainFromEmail(input.from, team.id);
|
||||||
|
|
||||||
|
const campaign = await db.campaign.create({
|
||||||
|
data: {
|
||||||
|
...input,
|
||||||
|
teamId: team.id,
|
||||||
|
domainId: domain.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return campaign;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateCampaign: campaignProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
from: z.string().optional(),
|
||||||
|
subject: z.string().optional(),
|
||||||
|
previewText: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
contactBookId: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
|
||||||
|
const { campaignId, ...data } = input;
|
||||||
|
if (data.contactBookId) {
|
||||||
|
const contactBook = await db.contactBook.findUnique({
|
||||||
|
where: { id: data.contactBookId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contactBook) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Contact book not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let domainId = campaignOld.domainId;
|
||||||
|
if (data.from) {
|
||||||
|
const domain = await validateDomainFromEmail(data.from, team.id);
|
||||||
|
domainId = domain.id;
|
||||||
|
}
|
||||||
|
const campaign = await db.campaign.update({
|
||||||
|
where: { id: campaignId },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
domainId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return campaign;
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteCampaign: campaignProcedure.mutation(
|
||||||
|
async ({ ctx: { db, team }, input }) => {
|
||||||
|
const campaign = await db.campaign.delete({
|
||||||
|
where: { id: input.campaignId, teamId: team.id },
|
||||||
|
});
|
||||||
|
return campaign;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
getCampaign: campaignProcedure.query(async ({ ctx: { db, team }, input }) => {
|
||||||
|
const campaign = await db.campaign.findUnique({
|
||||||
|
where: { id: input.campaignId, teamId: team.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Campaign not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaign?.contactBookId) {
|
||||||
|
const contactBook = await db.contactBook.findUnique({
|
||||||
|
where: { id: campaign.contactBookId },
|
||||||
|
});
|
||||||
|
return { ...campaign, contactBook };
|
||||||
|
}
|
||||||
|
return { ...campaign, contactBook: null };
|
||||||
|
}),
|
||||||
|
|
||||||
|
sendCampaign: campaignProcedure.mutation(
|
||||||
|
async ({ ctx: { db, team }, input }) => {
|
||||||
|
await sendCampaign(input.campaignId);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
reSubscribeContact: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
hash: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx: { db }, input }) => {
|
||||||
|
await subscribeContact(input.id, input.hash);
|
||||||
|
}),
|
||||||
|
|
||||||
|
duplicateCampaign: campaignProcedure.mutation(
|
||||||
|
async ({ ctx: { db, team, campaign }, input }) => {
|
||||||
|
const newCampaign = await db.campaign.create({
|
||||||
|
data: {
|
||||||
|
name: `${campaign.name} (Copy)`,
|
||||||
|
from: campaign.from,
|
||||||
|
subject: campaign.subject,
|
||||||
|
content: campaign.content,
|
||||||
|
teamId: team.id,
|
||||||
|
domainId: campaign.domainId,
|
||||||
|
contactBookId: campaign.contactBookId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return newCampaign;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
168
apps/web/src/server/api/routers/contacts.ts
Normal file
168
apps/web/src/server/api/routers/contacts.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
contactBookProcedure,
|
||||||
|
createTRPCRouter,
|
||||||
|
teamProcedure,
|
||||||
|
} from "~/server/api/trpc";
|
||||||
|
import * as contactService from "~/server/service/contact-service";
|
||||||
|
|
||||||
|
export const contactsRouter = createTRPCRouter({
|
||||||
|
getContactBooks: teamProcedure.query(async ({ ctx: { db, team } }) => {
|
||||||
|
return db.contactBook.findMany({
|
||||||
|
where: {
|
||||||
|
teamId: team.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { contacts: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
createContactBook: teamProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx: { db, team }, input }) => {
|
||||||
|
const { name } = input;
|
||||||
|
const contactBook = await db.contactBook.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
teamId: team.id,
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return contactBook;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getContactBookDetails: contactBookProcedure.query(
|
||||||
|
async ({ ctx: { contactBook, db } }) => {
|
||||||
|
const [totalContacts, unsubscribedContacts] = await Promise.all([
|
||||||
|
db.contact.count({
|
||||||
|
where: { contactBookId: contactBook.id },
|
||||||
|
}),
|
||||||
|
db.contact.count({
|
||||||
|
where: { contactBookId: contactBook.id, subscribed: false },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...contactBook,
|
||||||
|
totalContacts,
|
||||||
|
unsubscribedContacts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
updateContactBook: contactBookProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
contactBookId: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
properties: z.record(z.string()).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx: { db }, input }) => {
|
||||||
|
const { contactBookId, ...data } = input;
|
||||||
|
return db.contactBook.update({
|
||||||
|
where: { id: contactBookId },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteContactBook: contactBookProcedure
|
||||||
|
.input(z.object({ contactBookId: z.string() }))
|
||||||
|
.mutation(async ({ ctx: { db }, input }) => {
|
||||||
|
return db.contactBook.delete({ where: { id: input.contactBookId } });
|
||||||
|
}),
|
||||||
|
|
||||||
|
contacts: contactBookProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
page: z.number().optional(),
|
||||||
|
subscribed: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx: { db }, input }) => {
|
||||||
|
const page = input.page || 1;
|
||||||
|
const limit = 30;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const whereConditions: Prisma.ContactFindManyArgs["where"] = {
|
||||||
|
contactBookId: input.contactBookId,
|
||||||
|
...(input.subscribed !== undefined
|
||||||
|
? { subscribed: input.subscribed }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const countP = db.contact.count({ where: whereConditions });
|
||||||
|
|
||||||
|
const contactsP = db.contact.findMany({
|
||||||
|
where: whereConditions,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
subscribed: true,
|
||||||
|
createdAt: true,
|
||||||
|
contactBookId: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [contacts, count] = await Promise.all([contactsP, countP]);
|
||||||
|
|
||||||
|
return { contacts, totalPage: Math.ceil(count / limit) };
|
||||||
|
}),
|
||||||
|
|
||||||
|
addContacts: contactBookProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
contacts: z.array(
|
||||||
|
z.object({
|
||||||
|
email: z.string(),
|
||||||
|
firstName: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
|
properties: z.record(z.string()).optional(),
|
||||||
|
subscribed: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx: { contactBook }, input }) => {
|
||||||
|
return contactService.bulkAddContacts(contactBook.id, input.contacts);
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateContact: contactBookProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
contactId: z.string(),
|
||||||
|
email: z.string().optional(),
|
||||||
|
firstName: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
|
properties: z.record(z.string()).optional(),
|
||||||
|
subscribed: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { contactId, ...contact } = input;
|
||||||
|
return contactService.updateContact(contactId, contact);
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteContact: contactBookProcedure
|
||||||
|
.input(z.object({ contactId: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return contactService.deleteContact(input.contactId);
|
||||||
|
}),
|
||||||
|
});
|
@@ -175,7 +175,7 @@ export const emailRouter = createTRPCRouter({
|
|||||||
select: {
|
select: {
|
||||||
emailEvents: {
|
emailEvents: {
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: "asc",
|
status: "asc",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
id: true,
|
id: true,
|
||||||
|
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ZodError } from "zod";
|
import { z, ZodError } from "zod";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
|
||||||
import { getServerAuthSession } from "~/server/auth";
|
import { getServerAuthSession } from "~/server/auth";
|
||||||
@@ -125,6 +125,60 @@ export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const contactBookProcedure = teamProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
contactBookId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.use(async ({ ctx, next, input }) => {
|
||||||
|
const contactBook = await db.contactBook.findUnique({
|
||||||
|
where: { id: input.contactBookId },
|
||||||
|
});
|
||||||
|
if (!contactBook) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Contact book not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contactBook.teamId !== ctx.team.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this contact book",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({ ctx: { ...ctx, contactBook } });
|
||||||
|
});
|
||||||
|
|
||||||
|
export const campaignProcedure = teamProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
campaignId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.use(async ({ ctx, next, input }) => {
|
||||||
|
const campaign = await db.campaign.findUnique({
|
||||||
|
where: { id: input.campaignId },
|
||||||
|
});
|
||||||
|
if (!campaign) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Campaign not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaign.teamId !== ctx.team.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this campaign",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({ ctx: { ...ctx, campaign } });
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* To manage application settings, for hosted version, authenticated users will be considered as admin
|
* To manage application settings, for hosted version, authenticated users will be considered as admin
|
||||||
*/
|
*/
|
||||||
|
@@ -112,6 +112,7 @@ export async function sendEmailThroughSes({
|
|||||||
replyTo,
|
replyTo,
|
||||||
region,
|
region,
|
||||||
configurationSetName,
|
configurationSetName,
|
||||||
|
unsubUrl,
|
||||||
}: Partial<EmailContent> & {
|
}: Partial<EmailContent> & {
|
||||||
region: string;
|
region: string;
|
||||||
configurationSetName: string;
|
configurationSetName: string;
|
||||||
@@ -149,6 +150,14 @@ export async function sendEmailThroughSes({
|
|||||||
Charset: "UTF-8",
|
Charset: "UTF-8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...(unsubUrl
|
||||||
|
? {
|
||||||
|
Headers: [
|
||||||
|
{ Name: "List-Unsubscribe", Value: `<${unsubUrl}>` },
|
||||||
|
{ Name: "List-Unsubscribe-Post", Value: "One-Click" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ConfigurationSetName: configurationSetName,
|
ConfigurationSetName: configurationSetName,
|
||||||
|
27
apps/web/src/server/public-api/api-utils.ts
Normal file
27
apps/web/src/server/public-api/api-utils.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { UnsendApiError } from "./api-error";
|
||||||
|
|
||||||
|
export const getContactBook = async (c: Context, teamId: number) => {
|
||||||
|
const contactBookId = c.req.param("contactBookId");
|
||||||
|
|
||||||
|
if (!contactBookId) {
|
||||||
|
throw new UnsendApiError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "contactBookId is mandatory",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactBook = await db.contactBook.findUnique({
|
||||||
|
where: { id: contactBookId, teamId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contactBook) {
|
||||||
|
throw new UnsendApiError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Contact book not found for this team",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return contactBook;
|
||||||
|
};
|
65
apps/web/src/server/public-api/api/contacts/add-contact.ts
Normal file
65
apps/web/src/server/public-api/api/contacts/add-contact.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||||
|
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||||
|
import { addOrUpdateContact } from "~/server/service/contact-service";
|
||||||
|
import { getContactBook } from "../../api-utils";
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "post",
|
||||||
|
path: "/v1/contactBooks/{contactBookId}/contacts",
|
||||||
|
request: {
|
||||||
|
params: z.object({
|
||||||
|
contactBookId: z
|
||||||
|
.string()
|
||||||
|
.min(3)
|
||||||
|
.openapi({
|
||||||
|
param: {
|
||||||
|
name: "contactBookId",
|
||||||
|
in: "path",
|
||||||
|
},
|
||||||
|
example: "cuiwqdj74rygf74",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
body: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
email: z.string(),
|
||||||
|
firstName: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
|
properties: z.record(z.string()).optional(),
|
||||||
|
subscribed: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({ contactId: z.string().optional() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: "Retrieve the user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function addContact(app: PublicAPIApp) {
|
||||||
|
app.openapi(route, async (c) => {
|
||||||
|
const team = await getTeamFromToken(c);
|
||||||
|
|
||||||
|
const contactBook = await getContactBook(c, team.id);
|
||||||
|
|
||||||
|
const contact = await addOrUpdateContact(
|
||||||
|
contactBook.id,
|
||||||
|
c.req.valid("json")
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ contactId: contact.id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default addContact;
|
82
apps/web/src/server/public-api/api/contacts/get-contact.ts
Normal file
82
apps/web/src/server/public-api/api/contacts/get-contact.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||||
|
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { UnsendApiError } from "../../api-error";
|
||||||
|
import { getContactBook } from "../../api-utils";
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/v1/contactBooks/{contactBookId}/contacts/{contactId}",
|
||||||
|
request: {
|
||||||
|
params: z.object({
|
||||||
|
contactBookId: z.string().openapi({
|
||||||
|
param: {
|
||||||
|
name: "contactBookId",
|
||||||
|
in: "path",
|
||||||
|
},
|
||||||
|
example: "cuiwqdj74rygf74",
|
||||||
|
}),
|
||||||
|
contactId: z.string().openapi({
|
||||||
|
param: {
|
||||||
|
name: "contactId",
|
||||||
|
in: "path",
|
||||||
|
},
|
||||||
|
example: "cuiwqdj74rygf74",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
firstName: z.string().optional().nullable(),
|
||||||
|
lastName: z.string().optional().nullable(),
|
||||||
|
email: z.string(),
|
||||||
|
subscribed: z.boolean().default(true),
|
||||||
|
properties: z.record(z.string()),
|
||||||
|
contactBookId: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: "Retrieve the contact",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function getContact(app: PublicAPIApp) {
|
||||||
|
app.openapi(route, async (c) => {
|
||||||
|
const team = await getTeamFromToken(c);
|
||||||
|
|
||||||
|
await getContactBook(c, team.id);
|
||||||
|
|
||||||
|
const contactId = c.req.param("contactId");
|
||||||
|
|
||||||
|
const contact = await db.contact.findUnique({
|
||||||
|
where: {
|
||||||
|
id: contactId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
throw new UnsendApiError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Contact not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure properties is a Record<string, string>
|
||||||
|
const sanitizedContact = {
|
||||||
|
...contact,
|
||||||
|
properties: contact.properties as Record<string, string>,
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json(sanitizedContact);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getContact;
|
@@ -0,0 +1,66 @@
|
|||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||||
|
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||||
|
import { updateContact } from "~/server/service/contact-service";
|
||||||
|
import { getContactBook } from "../../api-utils";
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "patch",
|
||||||
|
path: "/v1/contactBooks/{contactBookId}/contacts/{contactId}",
|
||||||
|
request: {
|
||||||
|
params: z.object({
|
||||||
|
contactBookId: z.string().openapi({
|
||||||
|
param: {
|
||||||
|
name: "contactBookId",
|
||||||
|
in: "path",
|
||||||
|
},
|
||||||
|
example: "cuiwqdj74rygf74",
|
||||||
|
}),
|
||||||
|
contactId: z.string().openapi({
|
||||||
|
param: {
|
||||||
|
name: "contactId",
|
||||||
|
in: "path",
|
||||||
|
},
|
||||||
|
example: "cuiwqdj74rygf74",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
body: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
firstName: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
|
properties: z.record(z.string()).optional(),
|
||||||
|
subscribed: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({ contactId: z.string().optional() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: "Retrieve the user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateContactInfo(app: PublicAPIApp) {
|
||||||
|
app.openapi(route, async (c) => {
|
||||||
|
const team = await getTeamFromToken(c);
|
||||||
|
|
||||||
|
await getContactBook(c, team.id);
|
||||||
|
const contactId = c.req.param("contactId");
|
||||||
|
|
||||||
|
const contact = await updateContact(contactId, c.req.valid("json"));
|
||||||
|
|
||||||
|
return c.json({ contactId: contact.id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default updateContactInfo;
|
@@ -50,7 +50,7 @@ const route = createRoute({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
description: "Retrieve the user",
|
description: "Retrieve the email",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@@ -2,6 +2,9 @@ import { getApp } from "./hono";
|
|||||||
import getDomains from "./api/domains/get-domains";
|
import getDomains from "./api/domains/get-domains";
|
||||||
import sendEmail from "./api/emails/send-email";
|
import sendEmail from "./api/emails/send-email";
|
||||||
import getEmail from "./api/emails/get-email";
|
import getEmail from "./api/emails/get-email";
|
||||||
|
import addContact from "./api/contacts/add-contact";
|
||||||
|
import updateContactInfo from "./api/contacts/update-contact";
|
||||||
|
import getContact from "./api/contacts/get-contact";
|
||||||
|
|
||||||
export const app = getApp();
|
export const app = getApp();
|
||||||
|
|
||||||
@@ -12,4 +15,9 @@ getDomains(app);
|
|||||||
getEmail(app);
|
getEmail(app);
|
||||||
sendEmail(app);
|
sendEmail(app);
|
||||||
|
|
||||||
|
/**Contact related APIs */
|
||||||
|
addContact(app);
|
||||||
|
updateContactInfo(app);
|
||||||
|
getContact(app);
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
309
apps/web/src/server/service/campaign-service.ts
Normal file
309
apps/web/src/server/service/campaign-service.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { Campaign, Contact, EmailStatus } from "@prisma/client";
|
||||||
|
import { validateDomainFromEmail } from "./domain-service";
|
||||||
|
import { EmailQueueService } from "./email-queue-service";
|
||||||
|
|
||||||
|
export async function sendCampaign(id: string) {
|
||||||
|
let campaign = await db.campaign.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new Error("Campaign not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!campaign.content) {
|
||||||
|
throw new Error("No content added for campaign");
|
||||||
|
}
|
||||||
|
|
||||||
|
let jsonContent: Record<string, any>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
jsonContent = JSON.parse(campaign.content);
|
||||||
|
const renderer = new EmailRenderer(jsonContent);
|
||||||
|
const html = await renderer.render();
|
||||||
|
campaign = await db.campaign.update({
|
||||||
|
where: { id },
|
||||||
|
data: { html },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
throw new Error("Failed to parse campaign content");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!campaign.contactBookId) {
|
||||||
|
throw new Error("No contact book found for campaign");
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactBook = await db.contactBook.findUnique({
|
||||||
|
where: { id: campaign.contactBookId },
|
||||||
|
include: {
|
||||||
|
contacts: {
|
||||||
|
where: {
|
||||||
|
subscribed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contactBook) {
|
||||||
|
throw new Error("Contact book not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!campaign.html) {
|
||||||
|
throw new Error("No HTML content for campaign");
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendCampaignEmail(campaign, {
|
||||||
|
campaignId: campaign.id,
|
||||||
|
from: campaign.from,
|
||||||
|
subject: campaign.subject,
|
||||||
|
html: campaign.html,
|
||||||
|
replyTo: campaign.replyTo,
|
||||||
|
cc: campaign.cc,
|
||||||
|
bcc: campaign.bcc,
|
||||||
|
teamId: campaign.teamId,
|
||||||
|
contacts: contactBook.contacts,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.campaign.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: "SENT", total: contactBook.contacts.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUnsubUrl(contactId: string, campaignId: string) {
|
||||||
|
const unsubId = `${contactId}-${campaignId}`;
|
||||||
|
|
||||||
|
const unsubHash = createHash("sha256")
|
||||||
|
.update(`${unsubId}-${env.NEXTAUTH_SECRET}`)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
return `${env.NEXTAUTH_URL}/unsubscribe?id=${unsubId}&hash=${unsubHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsubscribeContact(id: string, hash: string) {
|
||||||
|
const [contactId, campaignId] = id.split("-");
|
||||||
|
|
||||||
|
if (!contactId || !campaignId) {
|
||||||
|
throw new Error("Invalid unsubscribe link");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the hash
|
||||||
|
const expectedHash = createHash("sha256")
|
||||||
|
.update(`${id}-${env.NEXTAUTH_SECRET}`)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
if (hash !== expectedHash) {
|
||||||
|
throw new Error("Invalid unsubscribe link");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the contact's subscription status
|
||||||
|
try {
|
||||||
|
const contact = await db.contact.findUnique({
|
||||||
|
where: { id: contactId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
throw new Error("Contact not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contact.subscribed) {
|
||||||
|
await db.contact.update({
|
||||||
|
where: { id: contactId },
|
||||||
|
data: { subscribed: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.campaign.update({
|
||||||
|
where: { id: campaignId },
|
||||||
|
data: {
|
||||||
|
unsubscribed: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error unsubscribing contact:", error);
|
||||||
|
throw new Error("Failed to unsubscribe contact");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribeContact(id: string, hash: string) {
|
||||||
|
const [contactId, campaignId] = id.split("-");
|
||||||
|
|
||||||
|
if (!contactId || !campaignId) {
|
||||||
|
throw new Error("Invalid subscribe link");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the hash
|
||||||
|
const expectedHash = createHash("sha256")
|
||||||
|
.update(`${id}-${env.NEXTAUTH_SECRET}`)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
if (hash !== expectedHash) {
|
||||||
|
throw new Error("Invalid subscribe link");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the contact's subscription status
|
||||||
|
try {
|
||||||
|
const contact = await db.contact.findUnique({
|
||||||
|
where: { id: contactId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
throw new Error("Contact not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contact.subscribed) {
|
||||||
|
await db.contact.update({
|
||||||
|
where: { id: contactId },
|
||||||
|
data: { subscribed: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.campaign.update({
|
||||||
|
where: { id: campaignId },
|
||||||
|
data: {
|
||||||
|
unsubscribed: {
|
||||||
|
decrement: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error subscribing contact:", error);
|
||||||
|
throw new Error("Failed to subscribe contact");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CampainEmail = {
|
||||||
|
campaignId: string;
|
||||||
|
from: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
replyTo?: string[];
|
||||||
|
cc?: string[];
|
||||||
|
bcc?: string[];
|
||||||
|
teamId: number;
|
||||||
|
contacts: Array<Contact>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function sendCampaignEmail(
|
||||||
|
campaign: Campaign,
|
||||||
|
emailData: CampainEmail
|
||||||
|
) {
|
||||||
|
const { campaignId, from, subject, replyTo, cc, bcc, teamId, contacts } =
|
||||||
|
emailData;
|
||||||
|
|
||||||
|
const jsonContent = JSON.parse(campaign.content || "{}");
|
||||||
|
const renderer = new EmailRenderer(jsonContent);
|
||||||
|
|
||||||
|
const domain = await validateDomainFromEmail(from, teamId);
|
||||||
|
|
||||||
|
const contactWithHtml = await Promise.all(
|
||||||
|
contacts.map(async (contact) => {
|
||||||
|
const unsubscribeUrl = createUnsubUrl(contact.id, campaignId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...contact,
|
||||||
|
html: await renderer.render({
|
||||||
|
shouldReplaceVariableValues: true,
|
||||||
|
variableValues: {
|
||||||
|
email: contact.email,
|
||||||
|
firstName: contact.firstName,
|
||||||
|
lastName: contact.lastName,
|
||||||
|
},
|
||||||
|
linkValues: {
|
||||||
|
"{{unsend_unsubscribe_url}}": unsubscribeUrl,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create emails in bulk
|
||||||
|
await db.email.createMany({
|
||||||
|
data: contactWithHtml.map((contact) => ({
|
||||||
|
to: [contact.email],
|
||||||
|
replyTo: replyTo
|
||||||
|
? Array.isArray(replyTo)
|
||||||
|
? replyTo
|
||||||
|
: [replyTo]
|
||||||
|
: undefined,
|
||||||
|
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
|
||||||
|
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
|
||||||
|
from,
|
||||||
|
subject,
|
||||||
|
html: contact.html,
|
||||||
|
teamId,
|
||||||
|
campaignId,
|
||||||
|
contactId: contact.id,
|
||||||
|
domainId: domain.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch created emails
|
||||||
|
const emails = await db.email.findMany({
|
||||||
|
where: {
|
||||||
|
teamId,
|
||||||
|
campaignId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue emails
|
||||||
|
await Promise.all(
|
||||||
|
emails.map((email) =>
|
||||||
|
EmailQueueService.queueEmail(email.id, domain.region, false)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCampaignAnalytics(
|
||||||
|
campaignId: string,
|
||||||
|
emailStatus: EmailStatus
|
||||||
|
) {
|
||||||
|
const campaign = await db.campaign.findUnique({
|
||||||
|
where: { id: campaignId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
throw new Error("Campaign not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, any> = {};
|
||||||
|
|
||||||
|
switch (emailStatus) {
|
||||||
|
case EmailStatus.SENT:
|
||||||
|
updateData.sent = { increment: 1 };
|
||||||
|
break;
|
||||||
|
case EmailStatus.DELIVERED:
|
||||||
|
updateData.delivered = { increment: 1 };
|
||||||
|
break;
|
||||||
|
case EmailStatus.OPENED:
|
||||||
|
updateData.opened = { increment: 1 };
|
||||||
|
break;
|
||||||
|
case EmailStatus.CLICKED:
|
||||||
|
updateData.clicked = { increment: 1 };
|
||||||
|
break;
|
||||||
|
case EmailStatus.BOUNCED:
|
||||||
|
updateData.bounced = { increment: 1 };
|
||||||
|
break;
|
||||||
|
case EmailStatus.COMPLAINED:
|
||||||
|
updateData.complained = { increment: 1 };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.campaign.update({
|
||||||
|
where: { id: campaignId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
}
|
92
apps/web/src/server/service/contact-service.ts
Normal file
92
apps/web/src/server/service/contact-service.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { db } from "../db";
|
||||||
|
|
||||||
|
export type ContactInput = {
|
||||||
|
email: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
properties?: Record<string, string>;
|
||||||
|
subscribed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function addOrUpdateContact(
|
||||||
|
contactBookId: string,
|
||||||
|
contact: ContactInput
|
||||||
|
) {
|
||||||
|
const createdContact = await db.contact.upsert({
|
||||||
|
where: {
|
||||||
|
contactBookId_email: {
|
||||||
|
contactBookId,
|
||||||
|
email: contact.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
contactBookId,
|
||||||
|
email: contact.email,
|
||||||
|
firstName: contact.firstName,
|
||||||
|
lastName: contact.lastName,
|
||||||
|
properties: contact.properties ?? {},
|
||||||
|
subscribed: contact.subscribed,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
firstName: contact.firstName,
|
||||||
|
lastName: contact.lastName,
|
||||||
|
properties: contact.properties ?? {},
|
||||||
|
subscribed: contact.subscribed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return createdContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateContact(
|
||||||
|
contactId: string,
|
||||||
|
contact: Partial<ContactInput>
|
||||||
|
) {
|
||||||
|
return db.contact.update({
|
||||||
|
where: {
|
||||||
|
id: contactId,
|
||||||
|
},
|
||||||
|
data: contact,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteContact(contactId: string) {
|
||||||
|
return db.contact.delete({
|
||||||
|
where: {
|
||||||
|
id: contactId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkAddContacts(
|
||||||
|
contactBookId: string,
|
||||||
|
contacts: Array<ContactInput>
|
||||||
|
) {
|
||||||
|
const createdContacts = await Promise.all(
|
||||||
|
contacts.map((contact) => addOrUpdateContact(contactBookId, contact))
|
||||||
|
);
|
||||||
|
|
||||||
|
return createdContacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsubscribeContact(contactId: string) {
|
||||||
|
await db.contact.update({
|
||||||
|
where: {
|
||||||
|
id: contactId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
subscribed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribeContact(contactId: string) {
|
||||||
|
await db.contact.update({
|
||||||
|
where: {
|
||||||
|
id: contactId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
subscribed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
@@ -4,9 +4,45 @@ import * as tldts from "tldts";
|
|||||||
import * as ses from "~/server/aws/ses";
|
import * as ses from "~/server/aws/ses";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { SesSettingsService } from "./ses-settings-service";
|
import { SesSettingsService } from "./ses-settings-service";
|
||||||
|
import { UnsendApiError } from "../public-api/api-error";
|
||||||
|
|
||||||
const dnsResolveTxt = util.promisify(dns.resolveTxt);
|
const dnsResolveTxt = util.promisify(dns.resolveTxt);
|
||||||
|
|
||||||
|
export async function validateDomainFromEmail(email: string, teamId: number) {
|
||||||
|
let fromDomain = email.split("@")[1];
|
||||||
|
if (fromDomain?.endsWith(">")) {
|
||||||
|
fromDomain = fromDomain.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fromDomain) {
|
||||||
|
throw new UnsendApiError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "From email is invalid",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await db.domain.findUnique({
|
||||||
|
where: { name: fromDomain, teamId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
throw new UnsendApiError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message:
|
||||||
|
"Domain of from email is wrong. Use the domain verified by unsend",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain.status !== "SUCCESS") {
|
||||||
|
throw new UnsendApiError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Domain is not verified",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createDomain(
|
export async function createDomain(
|
||||||
teamId: number,
|
teamId: number,
|
||||||
name: string,
|
name: string,
|
||||||
|
@@ -5,54 +5,120 @@ import { getConfigurationSetName } from "~/utils/ses-utils";
|
|||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
|
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
|
||||||
import { getRedis } from "../redis";
|
import { getRedis } from "../redis";
|
||||||
|
import { createUnsubUrl } from "./campaign-service";
|
||||||
|
|
||||||
|
function createQueueAndWorker(region: string, quota: number, suffix: string) {
|
||||||
|
const connection = getRedis();
|
||||||
|
|
||||||
|
const queueName = `${region}-${suffix}`;
|
||||||
|
|
||||||
|
const queue = new Queue(queueName, { connection });
|
||||||
|
|
||||||
|
const worker = new Worker(queueName, executeEmail, {
|
||||||
|
concurrency: quota,
|
||||||
|
connection,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { queue, worker };
|
||||||
|
}
|
||||||
|
|
||||||
export class EmailQueueService {
|
export class EmailQueueService {
|
||||||
private static initialized = false;
|
private static initialized = false;
|
||||||
private static regionQueue = new Map<string, Queue>();
|
public static transactionalQueue = new Map<string, Queue>();
|
||||||
private static regionWorker = new Map<string, Worker>();
|
private static transactionalWorker = new Map<string, Worker>();
|
||||||
|
public static marketingQueue = new Map<string, Queue>();
|
||||||
|
private static marketingWorker = new Map<string, Worker>();
|
||||||
|
|
||||||
public static initializeQueue(region: string, quota: number) {
|
public static initializeQueue(
|
||||||
const connection = getRedis();
|
region: string,
|
||||||
|
quota: number,
|
||||||
|
transactionalQuotaPercentage: number
|
||||||
|
) {
|
||||||
console.log(`[EmailQueueService]: Initializing queue for region ${region}`);
|
console.log(`[EmailQueueService]: Initializing queue for region ${region}`);
|
||||||
|
|
||||||
const queueName = `${region}-transaction`;
|
const transactionalQuota = Math.floor(
|
||||||
|
(quota * transactionalQuotaPercentage) / 100
|
||||||
|
);
|
||||||
|
const marketingQuota = quota - transactionalQuota;
|
||||||
|
|
||||||
const queue = new Queue(queueName, { connection });
|
console.log(
|
||||||
|
"is transactional queue",
|
||||||
|
this.transactionalQueue.has(region),
|
||||||
|
"is marketing queue",
|
||||||
|
this.marketingQueue.has(region)
|
||||||
|
);
|
||||||
|
|
||||||
const worker = new Worker(queueName, executeEmail, {
|
if (this.transactionalQueue.has(region)) {
|
||||||
limiter: {
|
console.log(
|
||||||
max: quota,
|
`[EmailQueueService]: Updating transactional quota for region ${region} to ${transactionalQuota}`
|
||||||
duration: 1000,
|
);
|
||||||
},
|
const transactionalWorker = this.transactionalWorker.get(region);
|
||||||
concurrency: quota,
|
if (transactionalWorker) {
|
||||||
connection,
|
transactionalWorker.concurrency = transactionalQuota;
|
||||||
});
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[EmailQueueService]: Creating transactional queue for region ${region} with quota ${transactionalQuota}`
|
||||||
|
);
|
||||||
|
const { queue: transactionalQueue, worker: transactionalWorker } =
|
||||||
|
createQueueAndWorker(region, transactionalQuota ?? 1, "transaction");
|
||||||
|
this.transactionalQueue.set(region, transactionalQueue);
|
||||||
|
this.transactionalWorker.set(region, transactionalWorker);
|
||||||
|
}
|
||||||
|
|
||||||
this.regionQueue.set(region, queue);
|
if (this.marketingQueue.has(region)) {
|
||||||
this.regionWorker.set(region, worker);
|
console.log(
|
||||||
|
`[EmailQueueService]: Updating marketing quota for region ${region} to ${marketingQuota}`
|
||||||
|
);
|
||||||
|
const marketingWorker = this.marketingWorker.get(region);
|
||||||
|
if (marketingWorker) {
|
||||||
|
marketingWorker.concurrency = marketingQuota;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[EmailQueueService]: Creating marketing queue for region ${region} with quota ${marketingQuota}`
|
||||||
|
);
|
||||||
|
const { queue: marketingQueue, worker: marketingWorker } =
|
||||||
|
createQueueAndWorker(region, marketingQuota ?? 1, "marketing");
|
||||||
|
this.marketingQueue.set(region, marketingQueue);
|
||||||
|
this.marketingWorker.set(region, marketingWorker);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async queueEmail(emailId: string, region: string) {
|
public static async queueEmail(
|
||||||
|
emailId: string,
|
||||||
|
region: string,
|
||||||
|
transactional: boolean,
|
||||||
|
unsubUrl?: string
|
||||||
|
) {
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
await this.init();
|
await this.init();
|
||||||
}
|
}
|
||||||
const queue = this.regionQueue.get(region);
|
const queue = transactional
|
||||||
|
? this.transactionalQueue.get(region)
|
||||||
|
: this.marketingQueue.get(region);
|
||||||
if (!queue) {
|
if (!queue) {
|
||||||
throw new Error(`Queue for region ${region} not found`);
|
throw new Error(`Queue for region ${region} not found`);
|
||||||
}
|
}
|
||||||
queue.add("send-email", { emailId, timestamp: Date.now() });
|
queue.add("send-email", { emailId, timestamp: Date.now(), unsubUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async init() {
|
public static async init() {
|
||||||
const sesSettings = await db.sesSetting.findMany();
|
const sesSettings = await db.sesSetting.findMany();
|
||||||
for (const sesSetting of sesSettings) {
|
for (const sesSetting of sesSettings) {
|
||||||
this.initializeQueue(sesSetting.region, sesSetting.sesEmailRateLimit);
|
this.initializeQueue(
|
||||||
|
sesSetting.region,
|
||||||
|
sesSetting.sesEmailRateLimit,
|
||||||
|
sesSetting.transactionalQuota
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
|
async function executeEmail(
|
||||||
|
job: Job<{ emailId: string; timestamp: number; unsubUrl?: string }>
|
||||||
|
) {
|
||||||
console.log(
|
console.log(
|
||||||
`[EmailQueueService]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms`
|
`[EmailQueueService]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms`
|
||||||
);
|
);
|
||||||
@@ -88,13 +154,15 @@ async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[EmailQueueService]: Sending email ${email.id}`);
|
console.log(`[EmailQueueService]: Sending email ${email.id}`);
|
||||||
|
const unsubUrl = job.data.unsubUrl;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messageId = attachments.length
|
const messageId = attachments.length
|
||||||
? await sendEmailWithAttachments({
|
? await sendEmailWithAttachments({
|
||||||
to: email.to,
|
to: email.to,
|
||||||
from: email.from,
|
from: email.from,
|
||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
text: email.text ?? undefined,
|
text: email.text ?? "",
|
||||||
html: email.html ?? undefined,
|
html: email.html ?? undefined,
|
||||||
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
||||||
configurationSetName,
|
configurationSetName,
|
||||||
@@ -105,11 +173,12 @@ async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
|
|||||||
from: email.from,
|
from: email.from,
|
||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
replyTo: email.replyTo ?? undefined,
|
replyTo: email.replyTo ?? undefined,
|
||||||
text: email.text ?? undefined,
|
text: email.text ?? "",
|
||||||
html: email.html ?? undefined,
|
html: email.html ?? undefined,
|
||||||
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
||||||
configurationSetName,
|
configurationSetName,
|
||||||
attachments,
|
attachments,
|
||||||
|
unsubUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete attachments after sending the email
|
// Delete attachments after sending the email
|
||||||
|
@@ -2,7 +2,14 @@ import { EmailContent } from "~/types";
|
|||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { UnsendApiError } from "~/server/public-api/api-error";
|
import { UnsendApiError } from "~/server/public-api/api-error";
|
||||||
import { EmailQueueService } from "./email-queue-service";
|
import { EmailQueueService } from "./email-queue-service";
|
||||||
|
import { validateDomainFromEmail } from "./domain-service";
|
||||||
|
import { Campaign, Contact } from "@prisma/client";
|
||||||
|
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||||
|
import { createUnsubUrl } from "./campaign-service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
Send transactional email
|
||||||
|
*/
|
||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
emailContent: EmailContent & { teamId: number }
|
emailContent: EmailContent & { teamId: number }
|
||||||
) {
|
) {
|
||||||
@@ -19,29 +26,7 @@ export async function sendEmail(
|
|||||||
bcc,
|
bcc,
|
||||||
} = emailContent;
|
} = emailContent;
|
||||||
|
|
||||||
let fromDomain = from.split("@")[1];
|
const domain = await validateDomainFromEmail(from, teamId);
|
||||||
if (fromDomain?.endsWith(">")) {
|
|
||||||
fromDomain = fromDomain.slice(0, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = await db.domain.findFirst({
|
|
||||||
where: { teamId, name: fromDomain },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
throw new UnsendApiError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message:
|
|
||||||
"Domain of from email is wrong. Use the email verified by unsend",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domain.status !== "SUCCESS") {
|
|
||||||
throw new UnsendApiError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Domain is not verified",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = await db.email.create({
|
const email = await db.email.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -64,7 +49,7 @@ export async function sendEmail(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await EmailQueueService.queueEmail(email.id, domain.region);
|
await EmailQueueService.queueEmail(email.id, domain.region, true);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await db.emailEvent.create({
|
await db.emailEvent.create({
|
||||||
data: {
|
data: {
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
import { EmailStatus } from "@prisma/client";
|
import { EmailStatus, Prisma } from "@prisma/client";
|
||||||
import { SesEvent, SesEventDataKey } from "~/types/aws-types";
|
import { SesClick, SesEvent, SesEventDataKey } from "~/types/aws-types";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
|
import { updateCampaignAnalytics } from "./campaign-service";
|
||||||
const STATUS_LIST = Object.values(EmailStatus);
|
import { env } from "~/env";
|
||||||
|
|
||||||
export async function parseSesHook(data: SesEvent) {
|
export async function parseSesHook(data: SesEvent) {
|
||||||
const mailStatus = getEmailStatus(data);
|
const mailStatus = getEmailStatus(data);
|
||||||
@@ -34,14 +34,34 @@ export async function parseSesHook(data: SesEvent) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.email.update({
|
// Update the latest status and to avoid race conditions
|
||||||
where: {
|
await db.$executeRaw`
|
||||||
id: email.id,
|
UPDATE "Email"
|
||||||
},
|
SET "latestStatus" = CASE
|
||||||
data: {
|
WHEN ${mailStatus}::text::\"EmailStatus\" > "latestStatus" OR "latestStatus" IS NULL
|
||||||
latestStatus: getLatestStatus(email.latestStatus, mailStatus),
|
THEN ${mailStatus}::text::\"EmailStatus\"
|
||||||
},
|
ELSE "latestStatus"
|
||||||
});
|
END
|
||||||
|
WHERE id = ${email.id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (email.campaignId) {
|
||||||
|
if (
|
||||||
|
mailStatus !== "CLICKED" ||
|
||||||
|
!(mailData as SesClick).link.startsWith(`${env.NEXTAUTH_URL}/unsubscribe`)
|
||||||
|
) {
|
||||||
|
const mailEvent = await db.emailEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
emailId: email.id,
|
||||||
|
status: mailStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mailEvent) {
|
||||||
|
await updateCampaignAnalytics(email.campaignId, mailStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.emailEvent.create({
|
await db.emailEvent.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -89,12 +109,3 @@ function getEmailData(data: SesEvent) {
|
|||||||
return data[eventType.toLowerCase() as SesEventDataKey];
|
return data[eventType.toLowerCase() as SesEventDataKey];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestStatus(
|
|
||||||
currentEmailStatus: EmailStatus,
|
|
||||||
incomingStatus: EmailStatus
|
|
||||||
) {
|
|
||||||
const index = STATUS_LIST.indexOf(currentEmailStatus);
|
|
||||||
const incomingIndex = STATUS_LIST.indexOf(incomingStatus);
|
|
||||||
return STATUS_LIST[Math.max(index, incomingIndex)] ?? incomingStatus;
|
|
||||||
}
|
|
||||||
|
@@ -52,9 +52,13 @@ export class SesSettingsService {
|
|||||||
public static async createSesSetting({
|
public static async createSesSetting({
|
||||||
region,
|
region,
|
||||||
unsendUrl,
|
unsendUrl,
|
||||||
|
sendingRateLimit,
|
||||||
|
transactionalQuota,
|
||||||
}: {
|
}: {
|
||||||
region: string;
|
region: string;
|
||||||
unsendUrl: string;
|
unsendUrl: string;
|
||||||
|
sendingRateLimit: number;
|
||||||
|
transactionalQuota: number;
|
||||||
}) {
|
}) {
|
||||||
await this.checkInitialized();
|
await this.checkInitialized();
|
||||||
if (this.cache[region]) {
|
if (this.cache[region]) {
|
||||||
@@ -80,12 +84,62 @@ export class SesSettingsService {
|
|||||||
region,
|
region,
|
||||||
callbackUrl: `${parsedUrl}/api/ses_callback`,
|
callbackUrl: `${parsedUrl}/api/ses_callback`,
|
||||||
topic: `${idPrefix}-${region}-unsend`,
|
topic: `${idPrefix}-${region}-unsend`,
|
||||||
|
sesEmailRateLimit: sendingRateLimit,
|
||||||
|
transactionalQuota,
|
||||||
idPrefix,
|
idPrefix,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await createSettingInAws(setting);
|
await createSettingInAws(setting);
|
||||||
EmailQueueService.initializeQueue(region, setting.sesEmailRateLimit);
|
|
||||||
|
EmailQueueService.initializeQueue(
|
||||||
|
region,
|
||||||
|
setting.sesEmailRateLimit,
|
||||||
|
setting.transactionalQuota
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
EmailQueueService.transactionalQueue,
|
||||||
|
EmailQueueService.marketingQueue
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.invalidateCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async updateSesSetting({
|
||||||
|
id,
|
||||||
|
sendingRateLimit,
|
||||||
|
transactionalQuota,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
sendingRateLimit: number;
|
||||||
|
transactionalQuota: number;
|
||||||
|
}) {
|
||||||
|
await this.checkInitialized();
|
||||||
|
|
||||||
|
const setting = await db.sesSetting.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
transactionalQuota,
|
||||||
|
sesEmailRateLimit: sendingRateLimit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
EmailQueueService.transactionalQueue,
|
||||||
|
EmailQueueService.marketingQueue
|
||||||
|
);
|
||||||
|
|
||||||
|
EmailQueueService.initializeQueue(
|
||||||
|
setting.region,
|
||||||
|
setting.sesEmailRateLimit,
|
||||||
|
setting.transactionalQuota
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
EmailQueueService.transactionalQueue,
|
||||||
|
EmailQueueService.marketingQueue
|
||||||
|
);
|
||||||
|
|
||||||
await this.invalidateCache();
|
await this.invalidateCache();
|
||||||
}
|
}
|
||||||
|
@@ -8,6 +8,7 @@ export type EmailContent = {
|
|||||||
cc?: string | string[];
|
cc?: string | string[];
|
||||||
bcc?: string | string[];
|
bcc?: string | string[];
|
||||||
attachments?: Array<EmailAttachment>;
|
attachments?: Array<EmailAttachment>;
|
||||||
|
unsubUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmailAttachment = {
|
export type EmailAttachment = {
|
||||||
|
61
packages/email-editor/package.json
Normal file
61
packages/email-editor/package.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "@unsend/email-editor",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Email editor used by unsend",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist/**"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"build": "tsup"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@unsend/eslint-config": "workspace:*",
|
||||||
|
"@unsend/tailwind-config": "workspace:*",
|
||||||
|
"@unsend/typescript-config": "workspace:*",
|
||||||
|
"@unsend/ui": "workspace:*",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"tsup": "^8.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.3.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^2.4.0",
|
||||||
|
"@tiptap/extension-code-block": "^2.4.0",
|
||||||
|
"@tiptap/extension-color": "^2.4.0",
|
||||||
|
"@tiptap/extension-heading": "^2.4.0",
|
||||||
|
"@tiptap/extension-link": "^2.4.0",
|
||||||
|
"@tiptap/extension-paragraph": "^2.4.0",
|
||||||
|
"@tiptap/extension-placeholder": "^2.4.0",
|
||||||
|
"@tiptap/extension-task-item": "^2.4.0",
|
||||||
|
"@tiptap/extension-task-list": "^2.4.0",
|
||||||
|
"@tiptap/extension-text-align": "^2.4.0",
|
||||||
|
"@tiptap/extension-text-style": "^2.4.0",
|
||||||
|
"@tiptap/extension-underline": "^2.4.0",
|
||||||
|
"@tiptap/pm": "^2.4.0",
|
||||||
|
"@tiptap/react": "^2.4.0",
|
||||||
|
"@tiptap/starter-kit": "^2.4.0",
|
||||||
|
"@tiptap/suggestion": "^2.4.0",
|
||||||
|
"jsx-email": "^1.12.1",
|
||||||
|
"lucide-react": "^0.359.0",
|
||||||
|
"react-colorful": "^5.6.1",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
|
"tiptap-extension-global-drag-handle": "^0.1.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
7
packages/email-editor/postcss.config.cjs
Normal file
7
packages/email-editor/postcss.config.cjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
@@ -0,0 +1,65 @@
|
|||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
export type LinkEditorPanelProps = {
|
||||||
|
initialUrl?: string;
|
||||||
|
onSetLink: (url: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLinkEditorState = ({
|
||||||
|
initialUrl,
|
||||||
|
onSetLink,
|
||||||
|
}: LinkEditorPanelProps) => {
|
||||||
|
const [url, setUrl] = useState(initialUrl || "");
|
||||||
|
|
||||||
|
const onChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setUrl(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSetLink(url);
|
||||||
|
},
|
||||||
|
[url, onSetLink]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
setUrl,
|
||||||
|
onChange,
|
||||||
|
handleSubmit,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinkEditorPanel = ({
|
||||||
|
onSetLink,
|
||||||
|
initialUrl,
|
||||||
|
}: LinkEditorPanelProps) => {
|
||||||
|
const state = useLinkEditorState({
|
||||||
|
onSetLink,
|
||||||
|
initialUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<form
|
||||||
|
onSubmit={state.handleSubmit}
|
||||||
|
className="flex items-center gap-2 justify-between"
|
||||||
|
>
|
||||||
|
<label className="flex items-center gap-2 p-2 rounded-lg cursor-text">
|
||||||
|
<input
|
||||||
|
className="flex-1 bg-transparent outline-none min-w-[12rem] text-black text-sm"
|
||||||
|
placeholder="Enter valid url"
|
||||||
|
value={state.url}
|
||||||
|
onChange={state.onChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button variant="silent" size="sm" className="px-1">
|
||||||
|
<CheckIcon className="h-4 w-4 disabled:opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -0,0 +1,33 @@
|
|||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { Edit2Icon, EditIcon, Trash2Icon } from "lucide-react";
|
||||||
|
|
||||||
|
export type LinkPreviewPanelProps = {
|
||||||
|
url: string;
|
||||||
|
onEdit: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinkPreviewPanel = ({
|
||||||
|
onClear,
|
||||||
|
onEdit,
|
||||||
|
url,
|
||||||
|
}: LinkPreviewPanelProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-2">
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm underline w-[12rem] overflow-hidden text-ellipsis"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
<Button onClick={onEdit} variant="silent" size="sm" className="p-1">
|
||||||
|
<Edit2Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClear} variant="silent" size="sm" className="p-1">
|
||||||
|
<Trash2Icon className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
37
packages/email-editor/src/components/ui/ColorPicker.tsx
Normal file
37
packages/email-editor/src/components/ui/ColorPicker.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { HexAlphaColorPicker, HexColorInput } from "react-colorful";
|
||||||
|
|
||||||
|
type ColorPickerProps = {
|
||||||
|
color: string;
|
||||||
|
onChange?: (color: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ColorPicker(props: ColorPickerProps) {
|
||||||
|
const { color: initialColor, onChange } = props;
|
||||||
|
|
||||||
|
const [color, setColor] = useState(initialColor);
|
||||||
|
|
||||||
|
const handleColorChange = (color: string) => {
|
||||||
|
setColor(color);
|
||||||
|
onChange?.(color);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-[260px] rounded-xl border bg-white p-4">
|
||||||
|
<HexAlphaColorPicker
|
||||||
|
color={color}
|
||||||
|
onChange={handleColorChange}
|
||||||
|
className="flex !w-full flex-col gap-4"
|
||||||
|
/>
|
||||||
|
<HexColorInput
|
||||||
|
alpha={true}
|
||||||
|
color={color}
|
||||||
|
onChange={handleColorChange}
|
||||||
|
className="mt-4 bg-transparent text-black w-full min-w-0 rounded-lg border px-2 py-1.5 text-sm uppercase focus-visible:border-gray-400 focus-visible:outline-none"
|
||||||
|
prefixed
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { SVGProps } from "../../../types";
|
||||||
|
|
||||||
|
export const BorderWidth: React.FC<SVGProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M0 3.5A.5.5 0 0 1 .5 3h15a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H.5a.5.5 0 0 1-.5-.5zm0 5A.5.5 0 0 1 .5 8h15a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5H.5a.5.5 0 0 1-.5-.5zm0 4a.5.5 0 0 1 .5-.5h15a.5.5 0 0 1 0 1H.5a.5.5 0 0 1-.5-.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
111
packages/email-editor/src/editor.tsx
Normal file
111
packages/email-editor/src/editor.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BubbleMenu,
|
||||||
|
EditorContent,
|
||||||
|
EditorProvider,
|
||||||
|
FloatingMenu,
|
||||||
|
useEditor,
|
||||||
|
} from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { TextMenu } from "./menus/TextMenu";
|
||||||
|
import { cn } from "@unsend/ui/lib/utils";
|
||||||
|
|
||||||
|
import { extensions } from "./extensions";
|
||||||
|
import LinkMenu from "./menus/LinkMenu";
|
||||||
|
import { Content, Editor as TipTapEditor } from "@tiptap/core";
|
||||||
|
|
||||||
|
const content = `<h2>Hello World!</h2>
|
||||||
|
|
||||||
|
<h3>Unsend is the best open source resend alternative.</h3>
|
||||||
|
|
||||||
|
<p>Use markdown (<code># </code>, <code>## </code>, <code>### </code>, <code>\`\`</code>, <code>* *</code>, <code>** **</code>) to write your email. </p>
|
||||||
|
<p>You can <b>Bold</b> text.
|
||||||
|
You can <i>Italic</i> text.
|
||||||
|
You can <u>Underline</u> text.
|
||||||
|
You can <del>Delete</del> text.
|
||||||
|
You can <code>Code</code> text.
|
||||||
|
you can change <span style="color: #dc2626;"> color</span> of text. Add <a href="https://unsend.dev" target="_blank">link</a> to text
|
||||||
|
</p>
|
||||||
|
<br>
|
||||||
|
You can create ordered list
|
||||||
|
<ol>
|
||||||
|
<li>Ordered list item</li>
|
||||||
|
<li>Ordered list item</li>
|
||||||
|
<li>Ordered list item</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
You can create unordered list
|
||||||
|
<ul>
|
||||||
|
<li>Unordered list item</li>
|
||||||
|
<li>Unordered list item</li>
|
||||||
|
<li>Unordered list item</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p></p>
|
||||||
|
<p>Add code by typing \`\`\` and enter</p>
|
||||||
|
<pre>
|
||||||
|
<code>
|
||||||
|
const unsend = new Unsend({ apiKey: "us_12345" });
|
||||||
|
|
||||||
|
unsend.emails.send({
|
||||||
|
to: "john@doe.com",
|
||||||
|
from: "john@doe.com",
|
||||||
|
subject: "Hello World!",
|
||||||
|
html: "<p>Hello World!</p>",
|
||||||
|
text: "Hello World!",
|
||||||
|
});
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type EditorProps = {
|
||||||
|
onUpdate?: (content: TipTapEditor) => void;
|
||||||
|
initialContent?: Content;
|
||||||
|
variables?: Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Editor: React.FC<EditorProps> = ({
|
||||||
|
onUpdate,
|
||||||
|
initialContent,
|
||||||
|
variables,
|
||||||
|
}) => {
|
||||||
|
const menuContainerRef = useRef(null);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: cn("unsend-prose w-full"),
|
||||||
|
},
|
||||||
|
handleDOMEvents: {
|
||||||
|
keydown: (_view, event) => {
|
||||||
|
// prevent default event listeners from firing when slash command is active
|
||||||
|
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
||||||
|
const slashCommand = document.querySelector("#slash-command");
|
||||||
|
if (slashCommand) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extensions: extensions({ variables }),
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
onUpdate?.(editor);
|
||||||
|
},
|
||||||
|
content: initialContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-md text-black p-8 unsend-editor light"
|
||||||
|
ref={menuContainerRef}
|
||||||
|
>
|
||||||
|
<EditorContent editor={editor} className="min-h-[50vh]" />
|
||||||
|
{editor ? <TextMenu editor={editor} /> : null}
|
||||||
|
{editor ? <LinkMenu editor={editor} appendTo={menuContainerRef} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
92
packages/email-editor/src/extensions/ButtonExtension.ts
Normal file
92
packages/email-editor/src/extensions/ButtonExtension.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
import { AllowedAlignments } from "../types";
|
||||||
|
|
||||||
|
import { ButtonComponent } from "../nodes/button";
|
||||||
|
// import { AllowedLogoAlignment } from '../nodes/logo';
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
button: {
|
||||||
|
setButton: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ButtonExtension = Node.create({
|
||||||
|
name: "button",
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
draggable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
component: {
|
||||||
|
default: "button",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
default: "Button",
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
alignment: {
|
||||||
|
default: "left",
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
default: "4",
|
||||||
|
},
|
||||||
|
borderWidth: {
|
||||||
|
default: "1",
|
||||||
|
},
|
||||||
|
buttonColor: {
|
||||||
|
default: "rgb(0, 0, 0)",
|
||||||
|
},
|
||||||
|
borderColor: {
|
||||||
|
default: "rgb(0, 0, 0)",
|
||||||
|
},
|
||||||
|
textColor: {
|
||||||
|
default: "rgb(255, 255, 255)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `a[data-unsend-component="${this.name}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"a",
|
||||||
|
mergeAttributes(
|
||||||
|
{
|
||||||
|
"data-unsend-component": this.name,
|
||||||
|
},
|
||||||
|
HTMLAttributes
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setButton:
|
||||||
|
() =>
|
||||||
|
({ commands }) => {
|
||||||
|
return commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: {
|
||||||
|
unsendComponent: this.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(ButtonComponent);
|
||||||
|
},
|
||||||
|
});
|
487
packages/email-editor/src/extensions/SlashCommand.tsx
Normal file
487
packages/email-editor/src/extensions/SlashCommand.tsx
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
import { Editor, Extension, Range, ReactRenderer } from "@tiptap/react";
|
||||||
|
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||||
|
import { cn } from "@unsend/ui/lib/utils";
|
||||||
|
import {
|
||||||
|
CodeIcon,
|
||||||
|
DivideIcon,
|
||||||
|
EraserIcon,
|
||||||
|
Heading1Icon,
|
||||||
|
Heading2Icon,
|
||||||
|
Heading3Icon,
|
||||||
|
ListIcon,
|
||||||
|
ListOrderedIcon,
|
||||||
|
RectangleEllipsisIcon,
|
||||||
|
SquareSplitVerticalIcon,
|
||||||
|
TextIcon,
|
||||||
|
TextQuoteIcon,
|
||||||
|
UserXIcon,
|
||||||
|
VariableIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import tippy, { GetReferenceClientRect } from "tippy.js";
|
||||||
|
|
||||||
|
export interface CommandProps {
|
||||||
|
editor: Editor;
|
||||||
|
range: Range;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandItemProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlashCommandItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
searchTerms: string[];
|
||||||
|
icon: JSX.Element;
|
||||||
|
command: (options: CommandProps) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SlashCommand = Extension.create({
|
||||||
|
name: "slash-command",
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
suggestion: {
|
||||||
|
char: "/",
|
||||||
|
command: ({
|
||||||
|
editor,
|
||||||
|
range,
|
||||||
|
props,
|
||||||
|
}: {
|
||||||
|
editor: Editor;
|
||||||
|
range: Range;
|
||||||
|
props: any;
|
||||||
|
}) => {
|
||||||
|
props.command({ editor, range });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...this.options.suggestion,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_SLASH_COMMANDS: SlashCommandItem[] = [
|
||||||
|
{
|
||||||
|
title: "Text",
|
||||||
|
description: "Just start typing with plain text.",
|
||||||
|
searchTerms: ["p", "paragraph"],
|
||||||
|
icon: <TextIcon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.toggleNode("paragraph", "paragraph")
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heading 1",
|
||||||
|
description: "Big section heading.",
|
||||||
|
searchTerms: ["title", "big", "large"],
|
||||||
|
icon: <Heading1Icon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode("heading", { level: 1 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heading 2",
|
||||||
|
description: "Medium section heading.",
|
||||||
|
searchTerms: ["subtitle", "medium"],
|
||||||
|
icon: <Heading2Icon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode("heading", { level: 2 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heading 3",
|
||||||
|
description: "Small section heading.",
|
||||||
|
searchTerms: ["subtitle", "small"],
|
||||||
|
icon: <Heading3Icon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode("heading", { level: 3 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bullet List",
|
||||||
|
description: "Create a simple bullet list.",
|
||||||
|
searchTerms: ["unordered", "point"],
|
||||||
|
icon: <ListIcon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Numbered List",
|
||||||
|
description: "Create a list with numbering.",
|
||||||
|
searchTerms: ["ordered"],
|
||||||
|
icon: <ListOrderedIcon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: "Image",
|
||||||
|
// description: "Full width image",
|
||||||
|
// searchTerms: ["image"],
|
||||||
|
// icon: <ImageIcon className="h-4 w-4" />,
|
||||||
|
// command: ({ editor, range }: CommandProps) => {
|
||||||
|
// const imageUrl = prompt("Image URL: ") || "";
|
||||||
|
|
||||||
|
// if (!imageUrl) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// editor.chain().focus().deleteRange(range).run();
|
||||||
|
// editor.chain().focus().setImage({ src: imageUrl }).run();
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: "Logo",
|
||||||
|
// description: "Add your brand logo",
|
||||||
|
// searchTerms: ["image", "logo"],
|
||||||
|
// icon: <ImageIcon className="h-4 w-4" />,
|
||||||
|
// command: ({ editor, range }: CommandProps) => {
|
||||||
|
// const logoUrl = prompt("Logo URL: ") || "";
|
||||||
|
|
||||||
|
// if (!logoUrl) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// editor.chain().focus().deleteRange(range).run();
|
||||||
|
// editor.chain().focus().setLogoImage({ src: logoUrl }).run();
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: "Spacer",
|
||||||
|
// description:
|
||||||
|
// "Add a spacer to email. Useful for adding space between sections.",
|
||||||
|
// searchTerms: ["space", "gap", "divider"],
|
||||||
|
// icon: <MoveVertical className="h-4 w-4" />,
|
||||||
|
// command: ({ editor, range }: CommandProps) => {
|
||||||
|
// editor
|
||||||
|
// .chain()
|
||||||
|
// .focus()
|
||||||
|
// .deleteRange(range)
|
||||||
|
// .setSpacer({ height: "sm" })
|
||||||
|
// .run();
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: "Button",
|
||||||
|
// description: "Add a call to action button to email.",
|
||||||
|
// searchTerms: ["link", "button", "cta"],
|
||||||
|
// icon: <MousePointer className="h-4 w-4" />,
|
||||||
|
// command: ({ editor, range }: CommandProps) => {
|
||||||
|
// editor.chain().focus().deleteRange(range).setButton().run();
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: "Link Card",
|
||||||
|
// description: "Add a link card to email.",
|
||||||
|
// searchTerms: ["link", "button", "image"],
|
||||||
|
// icon: <ArrowUpRightSquare className="h-4 w-4" />,
|
||||||
|
// command: ({ editor, range }: CommandProps) => {
|
||||||
|
// editor.chain().focus().deleteRange(range).setLinkCard().run();
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: "Hard Break",
|
||||||
|
description: "Add a break between lines.",
|
||||||
|
searchTerms: ["break", "line"],
|
||||||
|
icon: <DivideIcon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).setHardBreak().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Blockquote",
|
||||||
|
description: "Add blockquote.",
|
||||||
|
searchTerms: ["quote", "blockquote"],
|
||||||
|
icon: <TextQuoteIcon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: "Footer",
|
||||||
|
// description: "Add a footer text to email.",
|
||||||
|
// searchTerms: ["footer", "text"],
|
||||||
|
// icon: <FootprintsIcon className="h-4 w-4" />,
|
||||||
|
// command: ({ editor, range }: CommandProps) => {
|
||||||
|
// editor.chain().focus().deleteRange(range).setFooter().run();
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
|
||||||
|
{
|
||||||
|
title: "Button",
|
||||||
|
description: "Add code.",
|
||||||
|
searchTerms: ["button"],
|
||||||
|
icon: <RectangleEllipsisIcon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).setButton().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Code Block",
|
||||||
|
description: "Add code.",
|
||||||
|
searchTerms: ["code"],
|
||||||
|
icon: <CodeIcon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Horizontal Rule",
|
||||||
|
description: "Add a horizontal rule.",
|
||||||
|
searchTerms: ["horizontal", "rule"],
|
||||||
|
icon: <SquareSplitVerticalIcon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Clear Line",
|
||||||
|
description: "Clear the current line.",
|
||||||
|
searchTerms: ["clear", "line"],
|
||||||
|
icon: <EraserIcon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().selectParentNode().deleteSelection().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Variable",
|
||||||
|
description: "Add a variable.",
|
||||||
|
searchTerms: ["variable"],
|
||||||
|
icon: <VariableIcon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).insertContent("{{").run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Unsubscribe Footer",
|
||||||
|
description: "Add an unsubscribe link.",
|
||||||
|
searchTerms: ["unsubscribe"],
|
||||||
|
icon: <UserXIcon className="h-4 w-4" />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setHorizontalRule()
|
||||||
|
.insertContent(
|
||||||
|
`<unsub data-unsend-component='unsubscribe-footer'><p>You are receiving this email because you opted in via our site.<br/><br/><a href="{{unsend_unsubscribe_url}}">Unsubscribe from the list</a></p><br><br><p>Company name,<br/>00 street name<br/>City, State 000000</p></unsub>`
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||||
|
const containerHeight = container.offsetHeight;
|
||||||
|
const itemHeight = item ? item.offsetHeight : 0;
|
||||||
|
|
||||||
|
const top = item.offsetTop;
|
||||||
|
const bottom = top + itemHeight;
|
||||||
|
|
||||||
|
if (top < container.scrollTop) {
|
||||||
|
container.scrollTop -= container.scrollTop - top + 5;
|
||||||
|
} else if (bottom > containerHeight + container.scrollTop) {
|
||||||
|
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommandList = ({
|
||||||
|
items,
|
||||||
|
command,
|
||||||
|
editor,
|
||||||
|
}: {
|
||||||
|
items: CommandItemProps[];
|
||||||
|
command: (item: CommandItemProps) => void;
|
||||||
|
editor: Editor;
|
||||||
|
range: any;
|
||||||
|
}) => {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const item = items[index];
|
||||||
|
if (item) {
|
||||||
|
command(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[command, editor, items]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (navigationKeys.includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
selectItem(selectedIndex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = commandListContainer?.current;
|
||||||
|
|
||||||
|
const item = container?.children[selectedIndex] as HTMLElement;
|
||||||
|
|
||||||
|
if (item && container) updateScrollView(container, item);
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
return items.length > 0 ? (
|
||||||
|
<div className="z-50 w-52 rounded-md border border-gray-200 bg-white shadow-md transition-all">
|
||||||
|
<div
|
||||||
|
id="slash-command"
|
||||||
|
ref={commandListContainer}
|
||||||
|
className="no-scrollbar h-auto max-h-[330px] overflow-y-auto scroll-smooth px-1 py-2"
|
||||||
|
>
|
||||||
|
{items.map((item: CommandItemProps, index: number) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-gray-900 hover:bg-gray-100 hover:text-gray-900",
|
||||||
|
index === selectedIndex
|
||||||
|
? "bg-gray-100 text-gray-900"
|
||||||
|
: "bg-transparent"
|
||||||
|
)}
|
||||||
|
key={index}
|
||||||
|
onClick={() => selectItem(index)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{item.title}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSlashCommandSuggestions(
|
||||||
|
commands: SlashCommandItem[] = []
|
||||||
|
): Omit<SuggestionOptions, "editor"> {
|
||||||
|
return {
|
||||||
|
items: ({ query }) => {
|
||||||
|
return [...DEFAULT_SLASH_COMMANDS, ...commands].filter((item) => {
|
||||||
|
if (typeof query === "string" && query.length > 0) {
|
||||||
|
const search = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.title.toLowerCase().includes(search) ||
|
||||||
|
item.description.toLowerCase().includes(search) ||
|
||||||
|
(item.searchTerms &&
|
||||||
|
item.searchTerms.some((term: string) => term.includes(search)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
render: () => {
|
||||||
|
let component: ReactRenderer<any>;
|
||||||
|
let popup: InstanceType<any> | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props) => {
|
||||||
|
component = new ReactRenderer(CommandList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
popup = tippy("body", {
|
||||||
|
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: "manual",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onUpdate: (props) => {
|
||||||
|
component?.updateProps(props);
|
||||||
|
|
||||||
|
popup &&
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
@@ -0,0 +1,53 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
import { UnsubscribeFooterComponent } from "../nodes/unsubscribe-footer";
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
unsubscribeFooter: {
|
||||||
|
setUnsubscribeFooter: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnsubscribeFooterExtension = Node.create({
|
||||||
|
name: "unsubscribeFooter",
|
||||||
|
group: "block",
|
||||||
|
content: "inline*",
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
component: {
|
||||||
|
default: "unsubscribeFooter",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `unsub`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"unsub",
|
||||||
|
mergeAttributes(
|
||||||
|
{
|
||||||
|
"data-unsend-component": this.name,
|
||||||
|
class: "footer",
|
||||||
|
contenteditable: "true",
|
||||||
|
},
|
||||||
|
HTMLAttributes
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(UnsubscribeFooterComponent);
|
||||||
|
},
|
||||||
|
});
|
141
packages/email-editor/src/extensions/VariableExtension.ts
Normal file
141
packages/email-editor/src/extensions/VariableExtension.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
import { PluginKey } from "@tiptap/pm/state";
|
||||||
|
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||||
|
import { VariableComponent, VariableOptions } from "../nodes/variable";
|
||||||
|
|
||||||
|
export interface VariableNodeAttrs extends VariableOptions {}
|
||||||
|
|
||||||
|
export type VariableExtensionOptions = {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
suggestion: Omit<SuggestionOptions, "editor">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VariablePluginKey = new PluginKey("variable");
|
||||||
|
|
||||||
|
export const VariableExtension = Node.create<VariableExtensionOptions>({
|
||||||
|
name: "variable",
|
||||||
|
|
||||||
|
group: "inline",
|
||||||
|
|
||||||
|
inline: true,
|
||||||
|
|
||||||
|
selectable: false,
|
||||||
|
|
||||||
|
atom: true,
|
||||||
|
draggable: false,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
deleteTriggerWithBackspace: false,
|
||||||
|
suggestion: {
|
||||||
|
char: "{{",
|
||||||
|
pluginKey: VariablePluginKey,
|
||||||
|
command: ({ editor, range, props }) => {
|
||||||
|
console.log("props: ", props);
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(range, [
|
||||||
|
{
|
||||||
|
type: this.name,
|
||||||
|
attrs: props,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: " ",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
window.getSelection()?.collapseToEnd();
|
||||||
|
},
|
||||||
|
allow: ({ state, range }) => {
|
||||||
|
const $from = state.doc.resolve(range.from);
|
||||||
|
const type = state.schema.nodes[this.name];
|
||||||
|
const allow = type
|
||||||
|
? !!$from.parent.type.contentMatch.matchType(type)
|
||||||
|
: false;
|
||||||
|
console.log("allow: ", allow);
|
||||||
|
|
||||||
|
return allow;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-id"),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.id) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data-id": attributes.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
name: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-name"),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.name) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data-name": attributes.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
fallback: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-fallback"),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.fallback) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data-fallback": attributes.fallback,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `span[data-type="${this.name}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"span",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...this.options.suggestion,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(VariableComponent);
|
||||||
|
},
|
||||||
|
});
|
80
packages/email-editor/src/extensions/index.ts
Normal file
80
packages/email-editor/src/extensions/index.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Underline from "@tiptap/extension-underline";
|
||||||
|
import Link from "@tiptap/extension-link";
|
||||||
|
import TextAlign from "@tiptap/extension-text-align";
|
||||||
|
import Paragraph from "@tiptap/extension-paragraph";
|
||||||
|
import Heading from "@tiptap/extension-heading";
|
||||||
|
import CodeBlock from "@tiptap/extension-code-block";
|
||||||
|
import TextStyle from "@tiptap/extension-text-style";
|
||||||
|
import { Color } from "@tiptap/extension-color";
|
||||||
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||||
|
import { ButtonExtension } from "./ButtonExtension";
|
||||||
|
import { SlashCommand, getSlashCommandSuggestions } from "./SlashCommand";
|
||||||
|
import { VariableExtension } from "./VariableExtension";
|
||||||
|
import { getVariableSuggestions } from "../nodes/variable";
|
||||||
|
import { UnsubscribeFooterExtension } from "./UnsubsubscribeExtension";
|
||||||
|
|
||||||
|
export function extensions({ variables }: { variables?: Array<string> }) {
|
||||||
|
const extensions = [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: {
|
||||||
|
levels: [1, 2, 3],
|
||||||
|
},
|
||||||
|
dropcursor: {
|
||||||
|
color: "#555",
|
||||||
|
width: 3,
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class:
|
||||||
|
"p-0.5 px-2 bg-slate-200 text-black text-sm rounded tracking-normal font-normal",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "not-prose border-l-4 border-gray-300 pl-4 mt-4 mb-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Underline,
|
||||||
|
Link.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "underline cursor-pointer",
|
||||||
|
},
|
||||||
|
openOnClick: false,
|
||||||
|
}),
|
||||||
|
TextAlign.configure({
|
||||||
|
types: [Paragraph.name, Heading.name],
|
||||||
|
}),
|
||||||
|
CodeBlock.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class:
|
||||||
|
"p-4 bg-slate-800 text-gray-100 text-sm rounded-md tracking-normal font-normal",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Heading.configure({
|
||||||
|
levels: [1, 2, 3],
|
||||||
|
}),
|
||||||
|
TextStyle,
|
||||||
|
Color,
|
||||||
|
TaskItem,
|
||||||
|
TaskList,
|
||||||
|
SlashCommand.configure({
|
||||||
|
suggestion: getSlashCommandSuggestions([]),
|
||||||
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: "write something on '/' for commands",
|
||||||
|
}),
|
||||||
|
ButtonExtension,
|
||||||
|
GlobalDragHandle,
|
||||||
|
VariableExtension.configure({
|
||||||
|
suggestion: getVariableSuggestions(variables),
|
||||||
|
}),
|
||||||
|
UnsubscribeFooterExtension,
|
||||||
|
];
|
||||||
|
|
||||||
|
return extensions;
|
||||||
|
}
|
3
packages/email-editor/src/index.ts
Normal file
3
packages/email-editor/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import "./styles/index.css";
|
||||||
|
|
||||||
|
export * from "./editor";
|
81
packages/email-editor/src/menus/LinkMenu.tsx
Normal file
81
packages/email-editor/src/menus/LinkMenu.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { MenuProps } from "../types";
|
||||||
|
import { LinkPreviewPanel } from "../components/panels/LinkPreviewPanel";
|
||||||
|
import { LinkEditorPanel } from "../components/panels/LinkEditorPanel";
|
||||||
|
|
||||||
|
export const LinkMenu = ({ editor, appendTo }: MenuProps): JSX.Element => {
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
|
||||||
|
const shouldShow = useCallback(() => {
|
||||||
|
const isActive = editor.isActive("link");
|
||||||
|
return isActive;
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const { href: link } = editor.getAttributes("link");
|
||||||
|
|
||||||
|
const handleEdit = useCallback(() => {
|
||||||
|
setShowEdit(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSetLink = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.extendMarkRange("link")
|
||||||
|
.setLink({ href: url, target: "_blank" })
|
||||||
|
.run();
|
||||||
|
setShowEdit(false);
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onUnsetLink = useCallback(() => {
|
||||||
|
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||||
|
setShowEdit(false);
|
||||||
|
return null;
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const onShowEdit = useCallback(() => {
|
||||||
|
setShowEdit(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onHideEdit = useCallback(() => {
|
||||||
|
setShowEdit(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
pluginKey="textMenu"
|
||||||
|
shouldShow={shouldShow}
|
||||||
|
updateDelay={0}
|
||||||
|
tippyOptions={{
|
||||||
|
popperOptions: {
|
||||||
|
modifiers: [{ name: "flip", enabled: false }],
|
||||||
|
},
|
||||||
|
appendTo: () => {
|
||||||
|
return appendTo?.current;
|
||||||
|
},
|
||||||
|
onHidden: () => {
|
||||||
|
setShowEdit(false);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className="flex gap-1 rounded-md border border-gray-200 bg-white p-1 shadow-md items-center mt-4"
|
||||||
|
>
|
||||||
|
{showEdit ? (
|
||||||
|
<LinkEditorPanel initialUrl={link} onSetLink={onSetLink} />
|
||||||
|
) : (
|
||||||
|
<LinkPreviewPanel
|
||||||
|
url={link}
|
||||||
|
onClear={onUnsetLink}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LinkMenu;
|
426
packages/email-editor/src/menus/TextMenu.tsx
Normal file
426
packages/email-editor/src/menus/TextMenu.tsx
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
import { BubbleMenu, BubbleMenuProps, isTextSelection } from "@tiptap/react";
|
||||||
|
import {
|
||||||
|
AlignCenterIcon,
|
||||||
|
AlignLeftIcon,
|
||||||
|
AlignRightIcon,
|
||||||
|
BoldIcon,
|
||||||
|
ChevronDown,
|
||||||
|
CodeIcon,
|
||||||
|
Heading1Icon,
|
||||||
|
Heading2Icon,
|
||||||
|
Heading3Icon,
|
||||||
|
ItalicIcon,
|
||||||
|
LinkIcon,
|
||||||
|
ListIcon,
|
||||||
|
ListOrderedIcon,
|
||||||
|
LucideIcon,
|
||||||
|
PilcrowIcon,
|
||||||
|
StrikethroughIcon,
|
||||||
|
TextIcon,
|
||||||
|
TextQuoteIcon,
|
||||||
|
UnderlineIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { TextMenuButton } from "./TextMenuButton";
|
||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@unsend/ui/src/popover";
|
||||||
|
import { Separator } from "@unsend/ui/src/separator";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { LinkEditorPanel } from "../components/panels/LinkEditorPanel";
|
||||||
|
// import { allowedLogoAlignment } from "../nodes/logo";
|
||||||
|
|
||||||
|
export interface TextMenuItem {
|
||||||
|
name: string;
|
||||||
|
isActive: () => boolean;
|
||||||
|
command: () => void;
|
||||||
|
shouldShow?: () => boolean;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextMenuProps = Omit<BubbleMenuProps, "children">;
|
||||||
|
|
||||||
|
export type ContentTypePickerOption = {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
type: "option";
|
||||||
|
disabled: () => boolean | undefined;
|
||||||
|
isActive: () => boolean | undefined;
|
||||||
|
onClick: () => void;
|
||||||
|
icon: LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
const textColors = [
|
||||||
|
{
|
||||||
|
name: "default",
|
||||||
|
value: "#000000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "red",
|
||||||
|
value: "#dc2626",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "green",
|
||||||
|
value: "#16a34a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blue",
|
||||||
|
value: "#2563eb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yellow",
|
||||||
|
value: "#eab308",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "purple",
|
||||||
|
value: "#a855f7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "orange",
|
||||||
|
value: "#f97316",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pink",
|
||||||
|
value: "#db2777",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gray",
|
||||||
|
value: "#6b7280",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TextMenu(props: TextMenuProps) {
|
||||||
|
const { editor } = props;
|
||||||
|
|
||||||
|
const icons = [AlignLeftIcon, AlignCenterIcon, AlignRightIcon];
|
||||||
|
const alignmentItems: TextMenuItem[] = ["left", "center", "right"].map(
|
||||||
|
(alignment, index) => ({
|
||||||
|
name: alignment,
|
||||||
|
isActive: () => editor?.isActive({ textAlign: alignment })!,
|
||||||
|
command: () => {
|
||||||
|
if (props?.editor?.isActive({ textAlign: alignment })) {
|
||||||
|
props?.editor?.chain()?.focus().unsetTextAlign().run();
|
||||||
|
} else {
|
||||||
|
props?.editor?.chain().focus().setTextAlign(alignment).run()!;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: icons[index],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const items: TextMenuItem[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: "bold",
|
||||||
|
isActive: () => editor?.isActive("bold")!,
|
||||||
|
command: () => editor?.chain().focus().toggleBold().run()!,
|
||||||
|
icon: BoldIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "italic",
|
||||||
|
isActive: () => editor?.isActive("italic")!,
|
||||||
|
command: () => editor?.chain().focus().toggleItalic().run()!,
|
||||||
|
icon: ItalicIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "underline",
|
||||||
|
isActive: () => editor?.isActive("underline")!,
|
||||||
|
command: () => editor?.chain().focus().toggleUnderline().run()!,
|
||||||
|
icon: UnderlineIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "strike",
|
||||||
|
isActive: () => editor?.isActive("strike")!,
|
||||||
|
command: () => editor?.chain().focus().toggleStrike().run()!,
|
||||||
|
icon: StrikethroughIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "code",
|
||||||
|
isActive: () => editor?.isActive("code")!,
|
||||||
|
command: () => editor?.chain().focus().toggleCode().run()!,
|
||||||
|
icon: CodeIcon,
|
||||||
|
},
|
||||||
|
...alignmentItems,
|
||||||
|
],
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentTypePickerOptions: ContentTypePickerOption[] = useMemo(
|
||||||
|
() => [
|
||||||
|
// {
|
||||||
|
// label: "Text",
|
||||||
|
// id: "text",
|
||||||
|
// type: "option",
|
||||||
|
// disabled: () => false,
|
||||||
|
// isActive: () => editor?.isActive("text")!,
|
||||||
|
// onClick: () => editor?.chain().focus().setNode("text")?.run()!,
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
icon: TextIcon,
|
||||||
|
onClick: () =>
|
||||||
|
editor
|
||||||
|
?.chain()
|
||||||
|
.focus()
|
||||||
|
.lift("taskItem")
|
||||||
|
.liftListItem("listItem")
|
||||||
|
.setParagraph()
|
||||||
|
.run(),
|
||||||
|
id: "text",
|
||||||
|
disabled: () => !editor?.can().setParagraph(),
|
||||||
|
isActive: () =>
|
||||||
|
editor?.isActive("paragraph") &&
|
||||||
|
!editor?.isActive("orderedList") &&
|
||||||
|
!editor?.isActive("bulletList") &&
|
||||||
|
!editor?.isActive("taskList"),
|
||||||
|
label: "Text",
|
||||||
|
type: "option",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Heading1Icon,
|
||||||
|
onClick: () =>
|
||||||
|
editor
|
||||||
|
?.chain()
|
||||||
|
.focus()
|
||||||
|
.lift("taskItem")
|
||||||
|
.liftListItem("listItem")
|
||||||
|
.setHeading({ level: 1 })
|
||||||
|
.run(),
|
||||||
|
id: "heading1",
|
||||||
|
disabled: () => !editor?.can().setHeading({ level: 1 }),
|
||||||
|
isActive: () => editor?.isActive("heading", { level: 1 }),
|
||||||
|
label: "Heading 1",
|
||||||
|
type: "option",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Heading2Icon,
|
||||||
|
onClick: () =>
|
||||||
|
editor
|
||||||
|
?.chain()
|
||||||
|
?.focus()
|
||||||
|
?.lift("taskItem")
|
||||||
|
.liftListItem("listItem")
|
||||||
|
.setHeading({ level: 2 })
|
||||||
|
.run(),
|
||||||
|
id: "heading2",
|
||||||
|
disabled: () => !editor?.can().setHeading({ level: 2 }),
|
||||||
|
isActive: () => editor?.isActive("heading", { level: 2 }),
|
||||||
|
label: "Heading 2",
|
||||||
|
type: "option",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Heading3Icon,
|
||||||
|
onClick: () =>
|
||||||
|
editor
|
||||||
|
?.chain()
|
||||||
|
?.focus()
|
||||||
|
?.lift("taskItem")
|
||||||
|
.liftListItem("listItem")
|
||||||
|
.setHeading({ level: 3 })
|
||||||
|
.run(),
|
||||||
|
id: "heading3",
|
||||||
|
disabled: () => !editor?.can().setHeading({ level: 3 }),
|
||||||
|
isActive: () => editor?.isActive("heading", { level: 3 }),
|
||||||
|
label: "Heading 3",
|
||||||
|
type: "option",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ListIcon,
|
||||||
|
onClick: () => editor?.chain()?.focus()?.toggleBulletList()?.run(),
|
||||||
|
id: "bulletList",
|
||||||
|
disabled: () => !editor?.can()?.toggleBulletList(),
|
||||||
|
isActive: () => editor?.isActive("bulletList"),
|
||||||
|
label: "Bullet list",
|
||||||
|
type: "option",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ListOrderedIcon,
|
||||||
|
onClick: () => editor?.chain()?.focus()?.toggleOrderedList()?.run(),
|
||||||
|
id: "orderedList",
|
||||||
|
disabled: () => !editor?.can()?.toggleOrderedList(),
|
||||||
|
isActive: () => editor?.isActive("orderedList"),
|
||||||
|
label: "Numbered list",
|
||||||
|
type: "option",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[editor, editor?.state]
|
||||||
|
);
|
||||||
|
|
||||||
|
const bubbleMenuProps: TextMenuProps = {
|
||||||
|
...props,
|
||||||
|
shouldShow: ({ editor, state, from, to }) => {
|
||||||
|
const { doc, selection } = state;
|
||||||
|
const { empty } = selection;
|
||||||
|
|
||||||
|
// Sometime check for `empty` is not enough.
|
||||||
|
// Doubleclick an empty paragraph returns a node size of 2.
|
||||||
|
// So we check also for an empty text size.
|
||||||
|
const isEmptyTextBlock =
|
||||||
|
!doc.textBetween(from, to).length && isTextSelection(state.selection);
|
||||||
|
|
||||||
|
if (
|
||||||
|
empty ||
|
||||||
|
isEmptyTextBlock ||
|
||||||
|
!editor.isEditable ||
|
||||||
|
editor.isActive("image") ||
|
||||||
|
editor.isActive("logo") ||
|
||||||
|
editor.isActive("spacer") ||
|
||||||
|
editor.isActive("variable") ||
|
||||||
|
editor.isActive("link") ||
|
||||||
|
editor.isActive({
|
||||||
|
component: "button",
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
tippyOptions: {
|
||||||
|
maxWidth: "100%",
|
||||||
|
moveTransition: "transform 0.15s ease-out",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedColor = editor?.getAttributes("textStyle")?.color;
|
||||||
|
const activeItem = useMemo(
|
||||||
|
() =>
|
||||||
|
contentTypePickerOptions.find(
|
||||||
|
(option) => option.type === "option" && option.isActive()
|
||||||
|
),
|
||||||
|
[contentTypePickerOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BubbleMenu
|
||||||
|
{...bubbleMenuProps}
|
||||||
|
className="flex gap-1 rounded-md border border-gray-200 bg-white shadow-md items-center"
|
||||||
|
>
|
||||||
|
<ContentTypePicker options={contentTypePickerOptions} />
|
||||||
|
<EditLinkPopover
|
||||||
|
onSetLink={(url) => {
|
||||||
|
editor
|
||||||
|
?.chain()
|
||||||
|
.focus()
|
||||||
|
.setLink({ href: url, target: "_blank" })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// editor?.commands.blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Separator orientation="vertical" className="h-6 bg-slate-300" />
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<TextMenuButton key={index} {...item} />
|
||||||
|
))}
|
||||||
|
<Separator orientation="vertical" className="h-6 bg-slate-300" />
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<span style={{ color: selectedColor }}>A</span>
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1.5 text-gray-800" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="bg-white text-slate-900 w-52 px-1 border border-gray-200"
|
||||||
|
sideOffset={16}
|
||||||
|
>
|
||||||
|
{textColors.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.value}
|
||||||
|
onClick={() => editor?.chain().setColor(color.value).run()}
|
||||||
|
className={`flex gap-2 items-center p-1 px-2 w-full ${
|
||||||
|
selectedColor === color.value ||
|
||||||
|
(selectedColor === undefined && color.value === "#000000")
|
||||||
|
? "bg-gray-200 rounded-md"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span style={{ color: color.value }}>A</span>
|
||||||
|
<span className=" capitalize">{color.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</BubbleMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentTypePickerProps = {
|
||||||
|
options: ContentTypePickerOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function ContentTypePicker({ options }: ContentTypePickerProps) {
|
||||||
|
const activeOption = useMemo(
|
||||||
|
() => options.find((option) => option.isActive()),
|
||||||
|
[options]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="hover:bg-slate-100 hover:text-slate-600 text-slate-600 px-2"
|
||||||
|
>
|
||||||
|
<span>{activeOption?.label || "Text"}</span>
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1.5 text-gray-800" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="bg-white border-gray-200 text-slate-900 w-52 px-1"
|
||||||
|
sideOffset={16}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => {
|
||||||
|
option.onClick();
|
||||||
|
}}
|
||||||
|
className={`flex gap-2 items-center p-1 px-2 w-full ${
|
||||||
|
option.isActive() ? "bg-slate-100 rounded-md" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<option.icon className="h-3.5 w-3.5" />
|
||||||
|
<span className=" capitalize">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditLinkPopoverType = {
|
||||||
|
onSetLink: (url: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function EditLinkPopover({ onSetLink }: EditLinkPopoverType) {
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="hover:bg-slate-100 hover:text-slate-600 text-slate-600 px-2"
|
||||||
|
>
|
||||||
|
<span>Link</span>
|
||||||
|
<LinkIcon className="h-3.5 w-3.5 ml-1.5 text-gray-800" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="bg-white text-slate-900 px-1 w-[17rem] py-1 border border-gray-200"
|
||||||
|
sideOffset={16}
|
||||||
|
>
|
||||||
|
<LinkEditorPanel onSetLink={onSetLink} />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
30
packages/email-editor/src/menus/TextMenuButton.tsx
Normal file
30
packages/email-editor/src/menus/TextMenuButton.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Button } from "@unsend/ui/src/button";
|
||||||
|
import { cn } from "@unsend/ui/lib/utils";
|
||||||
|
|
||||||
|
import { TextMenuItem } from "./TextMenu";
|
||||||
|
|
||||||
|
export function TextMenuButton(item: TextMenuItem) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={item.command}
|
||||||
|
className={cn(
|
||||||
|
"px-2.5 hover:bg-slate-100 hover:text-black",
|
||||||
|
item.isActive() ? "bg-slate-300" : ""
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.icon ? (
|
||||||
|
<item.icon
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5",
|
||||||
|
item.isActive() ? "text-black" : "text-slate-700"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium text-slate-700">{item.name}</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
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>
|
||||||
|
);
|
||||||
|
}
|
765
packages/email-editor/src/renderer.tsx
Normal file
765
packages/email-editor/src/renderer.tsx
Normal file
@@ -0,0 +1,765 @@
|
|||||||
|
import { CSSProperties, Fragment } from "react";
|
||||||
|
import type { JSONContent } from "@tiptap/core";
|
||||||
|
import {
|
||||||
|
Text,
|
||||||
|
Html,
|
||||||
|
Head,
|
||||||
|
Body,
|
||||||
|
Font,
|
||||||
|
Container,
|
||||||
|
Link,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Button,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Row,
|
||||||
|
Column,
|
||||||
|
render,
|
||||||
|
Code,
|
||||||
|
} from "jsx-email";
|
||||||
|
import { AllowedAlignments } from "./types";
|
||||||
|
|
||||||
|
interface NodeOptions {
|
||||||
|
parent?: JSONContent;
|
||||||
|
prev?: JSONContent;
|
||||||
|
next?: JSONContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeOptions {
|
||||||
|
colors?: {
|
||||||
|
heading?: string;
|
||||||
|
paragraph?: string;
|
||||||
|
horizontal?: string;
|
||||||
|
footer?: string;
|
||||||
|
blockquoteBorder?: string;
|
||||||
|
codeBackground?: string;
|
||||||
|
codeText?: string;
|
||||||
|
link?: string;
|
||||||
|
};
|
||||||
|
fontSize?: {
|
||||||
|
paragraph?: string;
|
||||||
|
footer?: {
|
||||||
|
size?: string;
|
||||||
|
lineHeight?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderConfig {
|
||||||
|
/**
|
||||||
|
* The preview text is the snippet of text that is pulled into the inbox
|
||||||
|
* preview of an email client, usually right after the subject line.
|
||||||
|
*
|
||||||
|
* Default: `undefined`
|
||||||
|
*/
|
||||||
|
preview?: string;
|
||||||
|
/**
|
||||||
|
* The theme object allows you to customize the colors and font sizes of the
|
||||||
|
* rendered email.
|
||||||
|
*
|
||||||
|
* Default:
|
||||||
|
* ```js
|
||||||
|
* {
|
||||||
|
* colors: {
|
||||||
|
* heading: 'rgb(17, 24, 39)',
|
||||||
|
* paragraph: 'rgb(55, 65, 81)',
|
||||||
|
* horizontal: 'rgb(234, 234, 234)',
|
||||||
|
* footer: 'rgb(100, 116, 139)',
|
||||||
|
* },
|
||||||
|
* fontSize: {
|
||||||
|
* paragraph: '15px',
|
||||||
|
* footer: {
|
||||||
|
* size: '14px',
|
||||||
|
* lineHeight: '24px',
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
theme?: ThemeOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_THEME: ThemeOptions = {
|
||||||
|
colors: {
|
||||||
|
heading: "rgb(17, 24, 39)",
|
||||||
|
paragraph: "rgb(55, 65, 81)",
|
||||||
|
horizontal: "rgb(234, 234, 234)",
|
||||||
|
footer: "rgb(100, 116, 139)",
|
||||||
|
blockquoteBorder: "rgb(209, 213, 219)",
|
||||||
|
codeBackground: "rgb(239, 239, 239)",
|
||||||
|
codeText: "rgb(17, 24, 39)",
|
||||||
|
link: "rgb(59, 130, 246)",
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
paragraph: "15px",
|
||||||
|
footer: {
|
||||||
|
size: ".8rem",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CODE_FONT_FAMILY =
|
||||||
|
'SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||||
|
|
||||||
|
const allowedHeadings = ["h1", "h2", "h3"] as const;
|
||||||
|
type AllowedHeadings = (typeof allowedHeadings)[number];
|
||||||
|
|
||||||
|
const headings: Record<AllowedHeadings, CSSProperties> = {
|
||||||
|
h1: {
|
||||||
|
fontSize: "36px",
|
||||||
|
lineHeight: "40px",
|
||||||
|
fontWeight: 800,
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: "30px",
|
||||||
|
lineHeight: "36px",
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: "24px",
|
||||||
|
lineHeight: "38px",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowedSpacers = ["sm", "md", "lg", "xl"] as const;
|
||||||
|
export type AllowedSpacers = (typeof allowedSpacers)[number];
|
||||||
|
|
||||||
|
const spacers: Record<AllowedSpacers, string> = {
|
||||||
|
sm: "8px",
|
||||||
|
md: "16px",
|
||||||
|
lg: "32px",
|
||||||
|
xl: "64px",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MarkType {
|
||||||
|
[key: string]: any;
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const antialiased: CSSProperties = {
|
||||||
|
WebkitFontSmoothing: "antialiased",
|
||||||
|
MozOsxFontSmoothing: "grayscale",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateKey() {
|
||||||
|
return Math.random().toString(36).substring(2, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VariableFormatter = (options: {
|
||||||
|
variable: string;
|
||||||
|
fallback?: string;
|
||||||
|
}) => string;
|
||||||
|
|
||||||
|
const allowedLogoSizes = ["sm", "md", "lg"] as const;
|
||||||
|
type AllowedLogoSizes = (typeof allowedLogoSizes)[number];
|
||||||
|
|
||||||
|
const logoSizes: Record<AllowedLogoSizes, string> = {
|
||||||
|
sm: "40px",
|
||||||
|
md: "48px",
|
||||||
|
lg: "64px",
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmailRendererOption = {
|
||||||
|
shouldReplaceVariableValues?: boolean;
|
||||||
|
variableValues?: Record<string, string | null>;
|
||||||
|
linkValues?: Record<string, string | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class EmailRenderer {
|
||||||
|
private config: RenderConfig = {
|
||||||
|
theme: DEFAULT_THEME,
|
||||||
|
};
|
||||||
|
private shouldReplaceVariableValues = false;
|
||||||
|
private variableValues: Record<string, string | null> = {};
|
||||||
|
private linkValues: Record<string, string | null> = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly email: JSONContent = { type: "doc", content: [] },
|
||||||
|
options: EmailRendererOption = {}
|
||||||
|
) {
|
||||||
|
this.shouldReplaceVariableValues =
|
||||||
|
options.shouldReplaceVariableValues || false;
|
||||||
|
this.variableValues = options.variableValues || {};
|
||||||
|
this.linkValues = options.linkValues || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private variableFormatter: VariableFormatter = ({ variable, fallback }) => {
|
||||||
|
return fallback
|
||||||
|
? `{{${variable},fallback=${fallback}}}`
|
||||||
|
: `{{${variable}}}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
public render(options: EmailRendererOption = {}) {
|
||||||
|
this.shouldReplaceVariableValues =
|
||||||
|
options.shouldReplaceVariableValues || false;
|
||||||
|
this.variableValues = options.variableValues || {};
|
||||||
|
this.linkValues = options.linkValues || {};
|
||||||
|
const markup = this.markup();
|
||||||
|
return render(markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
markup() {
|
||||||
|
const nodes = this.email.content || [];
|
||||||
|
|
||||||
|
const jsxNodes = nodes.map((node, index) => {
|
||||||
|
const nodeOptions: NodeOptions = {
|
||||||
|
prev: nodes[index - 1],
|
||||||
|
next: nodes[index + 1],
|
||||||
|
parent: node,
|
||||||
|
};
|
||||||
|
|
||||||
|
const component = this.renderNode(node, nodeOptions);
|
||||||
|
if (!component) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Fragment key={generateKey()}>{component}</Fragment>;
|
||||||
|
});
|
||||||
|
|
||||||
|
const markup = (
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<Font
|
||||||
|
fallbackFontFamily="sans-serif"
|
||||||
|
fontFamily="Inter"
|
||||||
|
fontStyle="normal"
|
||||||
|
fontWeight={400}
|
||||||
|
webFont={{
|
||||||
|
url: "https://rsms.me/inter/font-files/Inter-Regular.woff2?v=3.19",
|
||||||
|
format: "woff2",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `blockquote,h1,h2,h3,img,li,ol,p,ul{margin-top:0;margin-bottom:0} pre{padding:16px;border-radius:6px}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<meta content="width=device-width" name="viewport" />
|
||||||
|
<meta content="IE=edge" httpEquiv="X-UA-Compatible" />
|
||||||
|
<meta name="x-apple-disable-message-reformatting" />
|
||||||
|
<meta
|
||||||
|
// http://www.html-5.com/metatags/format-detection-meta-tag.html
|
||||||
|
// It will prevent iOS from automatically detecting possible phone numbers in a block of text
|
||||||
|
content="telephone=no,address=no,email=no,date=no,url=no"
|
||||||
|
name="format-detection"
|
||||||
|
/>
|
||||||
|
<meta content="light" name="color-scheme" />
|
||||||
|
<meta content="light" name="supported-color-schemes" />
|
||||||
|
</Head>
|
||||||
|
<Body>
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
maxWidth: "600px",
|
||||||
|
minWidth: "300px",
|
||||||
|
width: "100%",
|
||||||
|
marginLeft: "auto",
|
||||||
|
marginRight: "auto",
|
||||||
|
padding: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{jsxNodes}
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
|
||||||
|
return markup;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `renderMark` will call the method of the corresponding mark type
|
||||||
|
private renderMark(node: JSONContent): JSX.Element {
|
||||||
|
// It will wrap the text with the corresponding mark type
|
||||||
|
const text = node.text || <> </>;
|
||||||
|
const marks = node.marks || [];
|
||||||
|
|
||||||
|
return marks.reduce(
|
||||||
|
(acc, mark) => {
|
||||||
|
const type = mark.type;
|
||||||
|
if (type in this) {
|
||||||
|
// @ts-expect-error - `this` is not assignable to type 'never'
|
||||||
|
return this[type]?.(mark, acc) as JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Mark type "${type}" is not supported.`);
|
||||||
|
},
|
||||||
|
<>{text}</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMappedContent(
|
||||||
|
node: JSONContent,
|
||||||
|
options?: NodeOptions
|
||||||
|
): JSX.Element[] {
|
||||||
|
return node.content
|
||||||
|
?.map((childNode) => {
|
||||||
|
const component = this.renderNode(childNode, options);
|
||||||
|
if (!component) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Fragment key={generateKey()}>{component}</Fragment>;
|
||||||
|
})
|
||||||
|
.filter((n) => n !== null) as JSX.Element[];
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderNode(
|
||||||
|
node: JSONContent,
|
||||||
|
options: NodeOptions = {}
|
||||||
|
): JSX.Element | null {
|
||||||
|
const type = node.type || "";
|
||||||
|
|
||||||
|
if (type in this) {
|
||||||
|
// @ts-expect-error - `this` is not assignable to type 'never'
|
||||||
|
return this[type]?.(node, options) as JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Node type "${type}" is not supported.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsubscribeFooter(
|
||||||
|
node: JSONContent,
|
||||||
|
options?: NodeOptions
|
||||||
|
): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
fontSize: this.config.theme?.fontSize?.footer?.size,
|
||||||
|
lineHeight: this.config.theme?.fontSize?.footer?.lineHeight,
|
||||||
|
maxWidth: "100%",
|
||||||
|
color: this.config.theme?.colors?.footer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.getMappedContent(node)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private paragraph(node: JSONContent, options?: NodeOptions): JSX.Element {
|
||||||
|
const { attrs } = node;
|
||||||
|
const alignment = attrs?.textAlign || "left";
|
||||||
|
|
||||||
|
const { parent, next } = options || {};
|
||||||
|
const isParentListItem = parent?.type === "listItem";
|
||||||
|
const isNextSpacer = next?.type === "spacer";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: alignment,
|
||||||
|
marginBottom: isParentListItem || isNextSpacer ? "0px" : "20px",
|
||||||
|
marginTop: "0px",
|
||||||
|
fontSize: this.config.theme?.fontSize?.paragraph,
|
||||||
|
color: this.config.theme?.colors?.paragraph,
|
||||||
|
...antialiased,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node.content ? this.getMappedContent(node) : <> </>}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private text(node: JSONContent, _?: NodeOptions): JSX.Element {
|
||||||
|
const text = node.text || " ";
|
||||||
|
if (node.marks) {
|
||||||
|
return this.renderMark(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{text}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bold(_: MarkType, text: JSX.Element): JSX.Element {
|
||||||
|
return <strong>{text}</strong>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private italic(_: MarkType, text: JSX.Element): JSX.Element {
|
||||||
|
return <em>{text}</em>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private underline(_: MarkType, text: JSX.Element): JSX.Element {
|
||||||
|
return <u>{text}</u>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private strike(_: MarkType, text: JSX.Element): JSX.Element {
|
||||||
|
return <s style={{ textDecoration: "line-through" }}>{text}</s>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private textStyle(mark: MarkType, text: JSX.Element): JSX.Element {
|
||||||
|
const { attrs } = mark;
|
||||||
|
const { fontSize, fontWeight, color } = attrs || {};
|
||||||
|
|
||||||
|
return <span style={{ fontSize, fontWeight, color }}>{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private link(mark: MarkType, text: JSX.Element): JSX.Element {
|
||||||
|
const { attrs } = mark;
|
||||||
|
let href = attrs?.href || "#";
|
||||||
|
const target = attrs?.target || "_blank";
|
||||||
|
const rel = attrs?.rel || "noopener noreferrer nofollow";
|
||||||
|
|
||||||
|
// If the href value is provided, use it to replace the link
|
||||||
|
// Otherwise, use the original link
|
||||||
|
if (
|
||||||
|
typeof this.linkValues === "object" ||
|
||||||
|
typeof this.variableValues === "object"
|
||||||
|
) {
|
||||||
|
href = this.linkValues[href] || this.variableValues[href] || href;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
rel={rel}
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "underline",
|
||||||
|
color: this.config.theme?.colors?.link,
|
||||||
|
}}
|
||||||
|
target={target}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private heading(node: JSONContent, options?: NodeOptions): JSX.Element {
|
||||||
|
const { attrs } = node;
|
||||||
|
const { next, prev } = options || {};
|
||||||
|
|
||||||
|
const level = `h${Number(attrs?.level) || 1}`;
|
||||||
|
const alignment = attrs?.textAlign || "left";
|
||||||
|
const isNextSpacer = next?.type === "spacer";
|
||||||
|
const isPrevSpacer = prev?.type === "spacer";
|
||||||
|
|
||||||
|
const { fontSize, lineHeight, fontWeight } =
|
||||||
|
headings[level as AllowedHeadings];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Heading
|
||||||
|
// @ts-expect-error - `this` is not assignable to type 'never'
|
||||||
|
as={level}
|
||||||
|
style={{
|
||||||
|
textAlign: alignment,
|
||||||
|
color: this.config.theme?.colors?.heading,
|
||||||
|
marginBottom: isNextSpacer ? "0px" : "12px",
|
||||||
|
marginTop: isPrevSpacer ? "0px" : "0px",
|
||||||
|
fontSize,
|
||||||
|
lineHeight,
|
||||||
|
fontWeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.getMappedContent(node)}
|
||||||
|
</Heading>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private variable(node: JSONContent, _?: NodeOptions): JSX.Element {
|
||||||
|
const { id: variable, fallback } = node.attrs || {};
|
||||||
|
|
||||||
|
let formattedVariable = this.variableFormatter({
|
||||||
|
variable,
|
||||||
|
fallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If `shouldReplaceVariableValues` is true, replace the variable values
|
||||||
|
// Otherwise, just return the formatted variable
|
||||||
|
if (this.shouldReplaceVariableValues) {
|
||||||
|
formattedVariable =
|
||||||
|
this.variableValues[variable] || fallback || formattedVariable;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{formattedVariable}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private horizontalRule(_: JSONContent, __?: NodeOptions): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Hr
|
||||||
|
style={{
|
||||||
|
marginTop: "32px",
|
||||||
|
marginBottom: "32px",
|
||||||
|
borderTopWidth: "2px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private orderedList(node: JSONContent, _?: NodeOptions): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Container style={{ maxWidth: "100%" }}>
|
||||||
|
<ol
|
||||||
|
style={{
|
||||||
|
marginTop: "0px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
paddingLeft: "26px",
|
||||||
|
listStyleType: "decimal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.getMappedContent(node)}
|
||||||
|
</ol>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bulletList(node: JSONContent, _?: NodeOptions): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
marginTop: "0px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
paddingLeft: "26px",
|
||||||
|
listStyleType: "disc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.getMappedContent(node)}
|
||||||
|
</ul>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private listItem(node: JSONContent, options?: NodeOptions): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
style={{
|
||||||
|
marginBottom: "8px",
|
||||||
|
paddingLeft: "6px",
|
||||||
|
...antialiased,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.getMappedContent(node, { ...options, parent: node })}
|
||||||
|
</li>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private button(node: JSONContent, options?: NodeOptions): JSX.Element {
|
||||||
|
const { attrs } = node;
|
||||||
|
const {
|
||||||
|
text,
|
||||||
|
url,
|
||||||
|
buttonColor,
|
||||||
|
textColor,
|
||||||
|
borderRadius,
|
||||||
|
borderColor,
|
||||||
|
borderWidth,
|
||||||
|
// @TODO: Update the attribute to `textAlign`
|
||||||
|
alignment = "left",
|
||||||
|
} = attrs || {};
|
||||||
|
|
||||||
|
const { next } = options || {};
|
||||||
|
const isNextSpacer = next?.type === "spacer";
|
||||||
|
|
||||||
|
const href = this.linkValues[url] || this.variableValues[url] || url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
textAlign: alignment,
|
||||||
|
maxWidth: "100%",
|
||||||
|
marginBottom: isNextSpacer ? "0px" : "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
href={href}
|
||||||
|
style={{
|
||||||
|
color: String(textColor),
|
||||||
|
backgroundColor: buttonColor,
|
||||||
|
borderColor: borderColor,
|
||||||
|
padding: "12px 34px",
|
||||||
|
borderWidth,
|
||||||
|
borderStyle: "solid",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: `${borderRadius}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private spacer(node: JSONContent, _?: NodeOptions): JSX.Element {
|
||||||
|
const { attrs } = node;
|
||||||
|
const { height = "auto" } = attrs || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
height: spacers[height as AllowedSpacers] || height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hardBreak(_: JSONContent, __?: NodeOptions): JSX.Element {
|
||||||
|
return <br />;
|
||||||
|
}
|
||||||
|
|
||||||
|
private logo(node: JSONContent, options?: NodeOptions): JSX.Element {
|
||||||
|
const { attrs } = node;
|
||||||
|
const {
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
title,
|
||||||
|
size,
|
||||||
|
// @TODO: Update the attribute to `textAlign`
|
||||||
|
alignment = "left",
|
||||||
|
} = attrs || {};
|
||||||
|
|
||||||
|
const { next } = options || {};
|
||||||
|
const isNextSpacer = next?.type === "spacer";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
style={{
|
||||||
|
marginTop: "0px",
|
||||||
|
marginBottom: isNextSpacer ? "0px" : "32px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Column align={alignment}>
|
||||||
|
<Img
|
||||||
|
alt={alt || title || "Logo"}
|
||||||
|
src={src}
|
||||||
|
style={{
|
||||||
|
width: logoSizes[size as AllowedLogoSizes] || size,
|
||||||
|
height: logoSizes[size as AllowedLogoSizes] || size,
|
||||||
|
}}
|
||||||
|
title={title || alt || "Logo"}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private image(node: JSONContent, options?: NodeOptions): JSX.Element {
|
||||||
|
const { attrs } = node;
|
||||||
|
const {
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
title,
|
||||||
|
width = "auto",
|
||||||
|
height = "auto",
|
||||||
|
alignment = "center",
|
||||||
|
externalLink = "",
|
||||||
|
} = attrs || {};
|
||||||
|
|
||||||
|
const { next } = options || {};
|
||||||
|
const isNextSpacer = next?.type === "spacer";
|
||||||
|
|
||||||
|
const mainImage = (
|
||||||
|
<Img
|
||||||
|
alt={alt || title || "Image"}
|
||||||
|
src={src}
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
maxWidth: "100%",
|
||||||
|
outline: "none",
|
||||||
|
border: "none",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
title={title || alt || "Image"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
style={{
|
||||||
|
marginTop: "0px",
|
||||||
|
marginBottom: isNextSpacer ? "0px" : "32px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Column align={alignment}>
|
||||||
|
{externalLink ? (
|
||||||
|
<a
|
||||||
|
href={externalLink}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
maxWidth: "100%",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{mainImage}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
mainImage
|
||||||
|
)}
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private blockquote(node: JSONContent, options?: NodeOptions): JSX.Element {
|
||||||
|
const { next, prev } = options || {};
|
||||||
|
const isNextSpacer = next?.type === "spacer";
|
||||||
|
const isPrevSpacer = prev?.type === "spacer";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<blockquote
|
||||||
|
style={{
|
||||||
|
borderLeftWidth: "4px",
|
||||||
|
borderLeftStyle: "solid",
|
||||||
|
borderLeftColor: this.config.theme?.colors?.blockquoteBorder,
|
||||||
|
paddingLeft: "16px",
|
||||||
|
marginLeft: "0px",
|
||||||
|
marginRight: "0px",
|
||||||
|
marginTop: isPrevSpacer ? "0px" : "20px",
|
||||||
|
marginBottom: isNextSpacer ? "0px" : "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.getMappedContent(node)}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private code(_: MarkType, text: JSX.Element): JSX.Element {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
backgroundColor: this.config.theme?.colors?.codeBackground,
|
||||||
|
color: this.config.theme?.colors?.codeText,
|
||||||
|
padding: "2px 4px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontFamily: CODE_FONT_FAMILY,
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private codeBlock(node: JSONContent, options?: NodeOptions): JSX.Element {
|
||||||
|
const { attrs } = node;
|
||||||
|
const language = attrs?.language;
|
||||||
|
|
||||||
|
const content = node.content || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Code language={language}>
|
||||||
|
{content
|
||||||
|
.map((n) => {
|
||||||
|
return n.text;
|
||||||
|
})
|
||||||
|
.join("")}
|
||||||
|
</Code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
246
packages/email-editor/src/styles/index.css
Normal file
246
packages/email-editor/src/styles/index.css
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose p:where([class~="text-sm"]) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose h1,
|
||||||
|
.unsend-editor .unsend-prose h2,
|
||||||
|
.unsend-editor .unsend-prose h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose h1 {
|
||||||
|
@apply text-[29px] font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose h2 {
|
||||||
|
@apply text-2xl font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose h3 {
|
||||||
|
@apply text-lg font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose p {
|
||||||
|
font-size: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose h1 + p,
|
||||||
|
.unsend-editor .unsend-prose h2 + p,
|
||||||
|
.unsend-editor .unsend-prose h3 + p,
|
||||||
|
.unsend-editor .unsend-prose hr + p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose ol,
|
||||||
|
.unsend-editor .unsend-prose ul {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose ol {
|
||||||
|
@apply list-decimal pl-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose ul {
|
||||||
|
@apply list-disc pl-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose li:not(:last-child) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose li > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose img {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose hr {
|
||||||
|
margin-block: 32px;
|
||||||
|
border-top-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
position: fixed;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity ease-in 0.2s;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
|
||||||
|
background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
z-index: 50;
|
||||||
|
cursor: grab;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hide {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
display: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose .footer {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: rgb(100, 116, 139);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose .spacer + * {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose p + .spacer {
|
||||||
|
margin-top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose a {
|
||||||
|
@apply text-blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose blockquote + .spacer {
|
||||||
|
margin-top: -16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose h1 + .spacer,
|
||||||
|
.unsend-editor .unsend-prose h2 + .spacer,
|
||||||
|
.unsend-editor .unsend-prose h3 + .spacer {
|
||||||
|
margin-top: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose ol + .spacer,
|
||||||
|
.unsend-editor .unsend-prose ul + .spacer {
|
||||||
|
margin-top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose img + .spacer {
|
||||||
|
margin-top: -32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose .node-button + .spacer,
|
||||||
|
.unsend-editor .unsend-prose .node-linkCard + .spacer,
|
||||||
|
.unsend-editor .unsend-prose footer + .spacer {
|
||||||
|
margin-top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose .node-button,
|
||||||
|
.unsend-editor .unsend-prose .node-linkCard {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose .node-image {
|
||||||
|
line-height: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .unsend-prose .node-image + .spacer {
|
||||||
|
margin-top: -32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove code ::before and ::after */
|
||||||
|
.unsend-editor .unsend-prose code::before,
|
||||||
|
.unsend-editor .unsend-prose code::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
white-space: break-spaces;
|
||||||
|
-webkit-font-variant-ligatures: none;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror .is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: #adb5bd;
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.ProseMirror .is-empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: #adb5bd;
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-selectednode {
|
||||||
|
@apply bg-slate-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chrome, Safari and Opera */
|
||||||
|
.unsend-no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-no-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .react-colorful__alpha {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .react-colorful__saturation,
|
||||||
|
.unsend-editor .react-colorful__hue,
|
||||||
|
.unsend-editor .react-colorful__alpha {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .react-colorful__hue,
|
||||||
|
.unsend-editor .react-colorful__alpha {
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .react-colorful__pointer {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prosemirror-dropcursor-block {
|
||||||
|
height: 1px !important;
|
||||||
|
background-color: #555 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsend-editor .footer {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
23
packages/email-editor/src/types.ts
Normal file
23
packages/email-editor/src/types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
|
||||||
|
export interface MenuProps {
|
||||||
|
editor: Editor;
|
||||||
|
appendTo?: React.RefObject<any>;
|
||||||
|
shouldHide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AllowedAlignments = "left" | "center" | "right";
|
||||||
|
|
||||||
|
export interface ButtonOptions {
|
||||||
|
text: string;
|
||||||
|
url: string;
|
||||||
|
alignment: AllowedAlignments;
|
||||||
|
borderRadius: string;
|
||||||
|
borderColor: string;
|
||||||
|
borderWidth: string;
|
||||||
|
buttonColor: string;
|
||||||
|
textColor: string;
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SVGProps = React.SVGProps<SVGSVGElement>;
|
7
packages/email-editor/tailwind.config.ts
Normal file
7
packages/email-editor/tailwind.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { type Config } from "tailwindcss";
|
||||||
|
import sharedConfig from "@unsend/tailwind-config/tailwind.config";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
...sharedConfig,
|
||||||
|
content: ["./src/**/*.tsx", "./src/**/*.ts"],
|
||||||
|
} satisfies Config;
|
8
packages/email-editor/tsconfig.json
Normal file
8
packages/email-editor/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@unsend/typescript-config/react-library.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["**/*.tsx", "**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
17
packages/email-editor/tsup.config.ts
Normal file
17
packages/email-editor/tsup.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
import { defineConfig, Options } from "tsup";
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default defineConfig((options: Options) => ({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["esm", "cjs"],
|
||||||
|
banner: {
|
||||||
|
js: "'use client'",
|
||||||
|
},
|
||||||
|
dts: true,
|
||||||
|
minify: true,
|
||||||
|
clean: true,
|
||||||
|
external: ["react", "react-dom"],
|
||||||
|
injectStyle: true,
|
||||||
|
...options,
|
||||||
|
}));
|
@@ -7,7 +7,11 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"incremental": false,
|
"incremental": false,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"lib": ["es2022", "DOM", "DOM.Iterable"],
|
"lib": [
|
||||||
|
"es2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
@@ -17,4 +21,4 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "ES2022"
|
"target": "ES2022"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -3,11 +3,15 @@
|
|||||||
"display": "Next.js",
|
"display": "Next.js",
|
||||||
"extends": "./base.json",
|
"extends": "./base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -3,6 +3,8 @@
|
|||||||
"display": "React Library",
|
"display": "React Library",
|
||||||
"extends": "./base.json",
|
"extends": "./base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -33,11 +33,13 @@
|
|||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-separator": "^1.0.3",
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
@@ -19,6 +19,7 @@ const buttonVariants = cva(
|
|||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
icon: "bg-transparent hover:bg-transparent hover:bg-accent hover:text-accent-foreground",
|
icon: "bg-transparent hover:bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
silent: "bg-transparent hover:bg-accent/10 p-1",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 ",
|
default: "h-9 px-4 ",
|
||||||
|
31
packages/ui/src/popover.tsx
Normal file
31
packages/ui/src/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent };
|
@@ -18,7 +18,7 @@ const Separator = React.forwardRef<
|
|||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 bg-border",
|
"shrink-0 bg-border ",
|
||||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
@@ -6,7 +6,7 @@ const Table = React.forwardRef<
|
|||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="relative w-full overflow-auto">
|
<div className="relative w-full overflow-auto ">
|
||||||
<table
|
<table
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
@@ -6,7 +6,8 @@ import { CheckIcon, ClipboardCopy } from "lucide-react";
|
|||||||
export const TextWithCopyButton: React.FC<{
|
export const TextWithCopyButton: React.FC<{
|
||||||
value: string;
|
value: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ value, className }) => {
|
alwaysShowCopy?: boolean;
|
||||||
|
}> = ({ value, className, alwaysShowCopy }) => {
|
||||||
const [isCopied, setIsCopied] = React.useState(false);
|
const [isCopied, setIsCopied] = React.useState(false);
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
const copyToClipboard = async () => {
|
||||||
@@ -24,7 +25,9 @@ export const TextWithCopyButton: React.FC<{
|
|||||||
<div className={className}>{value}</div>
|
<div className={className}>{value}</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="hover:bg-transparent p-0 cursor-pointer text-muted-foreground opacity-0 group-hover:opacity-100"
|
className={`hover:bg-transparent p-0 cursor-pointer text-muted-foreground ${
|
||||||
|
alwaysShowCopy ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||||
|
}`}
|
||||||
onClick={copyToClipboard}
|
onClick={copyToClipboard}
|
||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
|
24
packages/ui/src/textarea.tsx
Normal file
24
packages/ui/src/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
30
packages/ui/src/tooltip.tsx
Normal file
30
packages/ui/src/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
@@ -3,7 +3,8 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root,
|
||||||
|
.light {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214 1% 71%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
@@ -77,3 +78,12 @@
|
|||||||
@apply h-full;
|
@apply h-full;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* .app,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
@apply border-border;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-width: 0;
|
||||||
|
border-style: solid;
|
||||||
|
} */
|
||||||
|
4200
pnpm-lock.yaml
generated
4200
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user