Add unsend campaign feature (#45)

* Add unsend email editor

Add email editor

Add more email editor

Add renderer partial

Add more marketing email features

* Add more campaign feature

* Add variables

* Getting there

* campaign is there mfs

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

View File

@@ -10,6 +10,8 @@
},
"dependencies": {
"@heroicons/react": "^2.1.3",
"@unsend/email-editor": "workspace:*",
"@unsend/ui": "workspace:*",
"date-fns": "^3.6.0",
"framer-motion": "^11.0.24",
"lucide-react": "^0.359.0",
@@ -23,7 +25,6 @@
"@types/react-dom": "^18",
"@unsend/eslint-config": "workspace:*",
"@unsend/tailwind-config": "workspace:*",
"@unsend/ui": "workspace:*",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.1.4",

View 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>
);
}

View File

@@ -3,6 +3,8 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "@unsend/ui";
import Script from "next/script";
import Link from "next/link";
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
const inter = Inter({ subsets: ["latin"] });
@@ -40,7 +42,97 @@ export default function RootLayout({
)}
<body className={inter.className}>
<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>
</body>
</html>

View File

@@ -24,46 +24,6 @@ export default function Home() {
return (
<div className="bg-neutral-950 pb-20">
<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">
<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{" "}
@@ -86,7 +46,7 @@ export default function Home() {
{/* <BackgroundBeams /> */}
</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>
<p className="text-center text-3xl lg:text-6xl ">Reach your users</p>
</div>
@@ -236,47 +196,6 @@ export default function Home() {
{/* </motion.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>
);
}

View File

@@ -20,7 +20,6 @@
"@auth/prisma-adapter": "^1.4.0",
"@aws-sdk/client-sesv2": "^3.535.0",
"@aws-sdk/client-sns": "^3.540.0",
"@hono/node-server": "^1.9.1",
"@hono/swagger-ui": "^0.2.1",
"@hono/zod-openapi": "^0.10.0",
"@hookform/resolvers": "^3.3.4",
@@ -32,6 +31,7 @@
"@trpc/next": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"@unsend/email-editor": "workspace:*",
"@unsend/ui": "workspace:*",
"bullmq": "^5.8.2",
"date-fns": "^3.6.0",
@@ -56,6 +56,7 @@
"tldts": "^6.1.16",
"ua-parser-js": "^1.0.38",
"unsend": "workspace:*",
"use-debounce": "^10.0.2",
"zod": "^3.22.4"
},
"devDependencies": {

View File

@@ -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;

View File

@@ -26,6 +26,7 @@ model SesSetting {
idPrefix String @unique
topic String
topicArn String?
transactionalQuota Int @default(50)
callbackUrl String
callbackSuccess Boolean @default(false)
configGeneral String?
@@ -98,6 +99,8 @@ model Team {
domains Domain[]
apiKeys ApiKey[]
emails Email[]
contactBooks ContactBook[]
campaigns Campaign[]
}
enum Role {
@@ -193,6 +196,8 @@ model Email {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attachments String?
campaignId String?
contactId String?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
emailEvents EmailEvent[]
}
@@ -205,3 +210,67 @@ model EmailEvent {
createdAt DateTime @default(now())
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)
}

View 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>
);
};

View File

@@ -11,6 +11,8 @@ import {
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
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() {
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
@@ -25,6 +27,9 @@ export default function SesConfigurations() {
<TableHead>Callback URL</TableHead>
<TableHead>Callback status</TableHead>
<TableHead>Created at</TableHead>
<TableHead>Send rate</TableHead>
<TableHead>Transactional quota</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -47,13 +52,25 @@ export default function SesConfigurations() {
sesSettingsQuery.data?.map((sesSetting) => (
<TableRow key={sesSetting.id}>
<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>
{sesSetting.callbackSuccess ? "Success" : "Failed"}
</TableCell>
<TableCell>
{formatDistanceToNow(sesSetting.createdAt)} ago
</TableCell>
<TableCell>{sesSetting.sesEmailRateLimit}</TableCell>
<TableCell>{sesSetting.transactionalQuota}%</TableCell>
<TableCell>
<EditSesConfiguration setting={sesSetting} />
</TableCell>
</TableRow>
))
)}

View File

@@ -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>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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;

View File

@@ -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;

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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;

View 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>
);
}

View File

@@ -66,14 +66,14 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
Domains
</NavButton>
<NavButton href="/contacts" comingSoon>
<NavButton href="/contacts">
<BookUser className="h-4 w-4" />
Contacts
</NavButton>
<NavButton href="/contacts" comingSoon>
<NavButton href="/campaigns">
<Volume2 className="h-4 w-4" />
Marketing
Campaigns
</NavButton>
<NavButton href="/api-keys">
@@ -104,7 +104,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
</div>
</div>
<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>
<SheetTrigger asChild>
<Button

View 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",
},
});
}

View File

@@ -25,7 +25,7 @@ export default async function RootLayout({
}) {
return (
<html lang="en">
<body className={`font-sans ${inter.variable}`}>
<body className={`font-sans ${inter.variable} app`}>
<ThemeProvider attribute="class" defaultTheme="dark">
<Toaster />
<TRPCReactProvider>{children}</TRPCReactProvider>

View 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;

View 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>
);
}

View File

@@ -22,6 +22,7 @@ const FormSchema = z.object({
region: z.string(),
unsendUrl: z.string().url(),
sendRate: z.number(),
transactionalQuota: z.number().min(0).max(100),
});
type SesSettingsProps = {
@@ -56,6 +57,7 @@ export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
region: "",
unsendUrl: "",
sendRate: 1,
transactionalQuota: 50,
},
});
@@ -167,6 +169,26 @@ export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
</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={addSesSettings.isPending}

View 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;

View File

@@ -4,6 +4,8 @@ import { apiRouter } from "./routers/api";
import { emailRouter } from "./routers/email";
import { teamRouter } from "./routers/team";
import { adminRouter } from "./routers/admin";
import { contactsRouter } from "./routers/contacts";
import { campaignRouter } from "./routers/campaign";
/**
* This is the primary router for your server.
@@ -16,6 +18,8 @@ export const appRouter = createTRPCRouter({
email: emailRouter,
team: teamRouter,
admin: adminRouter,
contacts: contactsRouter,
campaign: campaignRouter,
});
// export type definition of API

View File

@@ -26,12 +26,32 @@ export const adminRouter = createTRPCRouter({
z.object({
region: z.string(),
unsendUrl: z.string().url(),
sendRate: z.number(),
transactionalQuota: z.number(),
})
)
.mutation(async ({ input }) => {
return SesSettingsService.createSesSetting({
region: input.region,
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,
});
}),

View 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;
}
),
});

View 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);
}),
});

View File

@@ -175,7 +175,7 @@ export const emailRouter = createTRPCRouter({
select: {
emailEvents: {
orderBy: {
createdAt: "asc",
status: "asc",
},
},
id: true,

View File

@@ -9,7 +9,7 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { z, ZodError } from "zod";
import { env } from "~/env";
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
*/

View File

@@ -112,6 +112,7 @@ export async function sendEmailThroughSes({
replyTo,
region,
configurationSetName,
unsubUrl,
}: Partial<EmailContent> & {
region: string;
configurationSetName: string;
@@ -149,6 +150,14 @@ export async function sendEmailThroughSes({
Charset: "UTF-8",
},
},
...(unsubUrl
? {
Headers: [
{ Name: "List-Unsubscribe", Value: `<${unsubUrl}>` },
{ Name: "List-Unsubscribe-Post", Value: "One-Click" },
],
}
: {}),
},
},
ConfigurationSetName: configurationSetName,

View 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;
};

View 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;

View 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;

View File

@@ -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;

View File

@@ -50,7 +50,7 @@ const route = createRoute({
}),
},
},
description: "Retrieve the user",
description: "Retrieve the email",
},
},
});

View File

@@ -2,6 +2,9 @@ import { getApp } from "./hono";
import getDomains from "./api/domains/get-domains";
import sendEmail from "./api/emails/send-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();
@@ -12,4 +15,9 @@ getDomains(app);
getEmail(app);
sendEmail(app);
/**Contact related APIs */
addContact(app);
updateContactInfo(app);
getContact(app);
export default app;

View 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,
});
}

View 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,
},
});
}

View File

@@ -4,9 +4,45 @@ import * as tldts from "tldts";
import * as ses from "~/server/aws/ses";
import { db } from "~/server/db";
import { SesSettingsService } from "./ses-settings-service";
import { UnsendApiError } from "../public-api/api-error";
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(
teamId: number,
name: string,

View File

@@ -5,54 +5,120 @@ import { getConfigurationSetName } from "~/utils/ses-utils";
import { db } from "../db";
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
import { getRedis } from "../redis";
import { createUnsubUrl } from "./campaign-service";
export class EmailQueueService {
private static initialized = false;
private static regionQueue = new Map<string, Queue>();
private static regionWorker = new Map<string, Worker>();
public static initializeQueue(region: string, quota: number) {
function createQueueAndWorker(region: string, quota: number, suffix: string) {
const connection = getRedis();
console.log(`[EmailQueueService]: Initializing queue for region ${region}`);
const queueName = `${region}-transaction`;
const queueName = `${region}-${suffix}`;
const queue = new Queue(queueName, { connection });
const worker = new Worker(queueName, executeEmail, {
limiter: {
max: quota,
duration: 1000,
},
concurrency: quota,
connection,
});
this.regionQueue.set(region, queue);
this.regionWorker.set(region, worker);
return { queue, worker };
}
public static async queueEmail(emailId: string, region: string) {
export class EmailQueueService {
private static initialized = false;
public static transactionalQueue = new Map<string, Queue>();
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,
transactionalQuotaPercentage: number
) {
console.log(`[EmailQueueService]: Initializing queue for region ${region}`);
const transactionalQuota = Math.floor(
(quota * transactionalQuotaPercentage) / 100
);
const marketingQuota = quota - transactionalQuota;
console.log(
"is transactional queue",
this.transactionalQueue.has(region),
"is marketing queue",
this.marketingQueue.has(region)
);
if (this.transactionalQueue.has(region)) {
console.log(
`[EmailQueueService]: Updating transactional quota for region ${region} to ${transactionalQuota}`
);
const transactionalWorker = this.transactionalWorker.get(region);
if (transactionalWorker) {
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);
}
if (this.marketingQueue.has(region)) {
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,
transactional: boolean,
unsubUrl?: string
) {
if (!this.initialized) {
await this.init();
}
const queue = this.regionQueue.get(region);
const queue = transactional
? this.transactionalQueue.get(region)
: this.marketingQueue.get(region);
if (!queue) {
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() {
const sesSettings = await db.sesSetting.findMany();
for (const sesSetting of sesSettings) {
this.initializeQueue(sesSetting.region, sesSetting.sesEmailRateLimit);
this.initializeQueue(
sesSetting.region,
sesSetting.sesEmailRateLimit,
sesSetting.transactionalQuota
);
}
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(
`[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}`);
const unsubUrl = job.data.unsubUrl;
try {
const messageId = attachments.length
? await sendEmailWithAttachments({
to: email.to,
from: email.from,
subject: email.subject,
text: email.text ?? undefined,
text: email.text ?? "",
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName,
@@ -105,11 +173,12 @@ async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
from: email.from,
subject: email.subject,
replyTo: email.replyTo ?? undefined,
text: email.text ?? undefined,
text: email.text ?? "",
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName,
attachments,
unsubUrl,
});
// Delete attachments after sending the email

View File

@@ -2,7 +2,14 @@ import { EmailContent } from "~/types";
import { db } from "../db";
import { UnsendApiError } from "~/server/public-api/api-error";
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(
emailContent: EmailContent & { teamId: number }
) {
@@ -19,29 +26,7 @@ export async function sendEmail(
bcc,
} = emailContent;
let fromDomain = from.split("@")[1];
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 domain = await validateDomainFromEmail(from, teamId);
const email = await db.email.create({
data: {
@@ -64,7 +49,7 @@ export async function sendEmail(
});
try {
await EmailQueueService.queueEmail(email.id, domain.region);
await EmailQueueService.queueEmail(email.id, domain.region, true);
} catch (error: any) {
await db.emailEvent.create({
data: {

View File

@@ -1,8 +1,8 @@
import { EmailStatus } from "@prisma/client";
import { SesEvent, SesEventDataKey } from "~/types/aws-types";
import { EmailStatus, Prisma } from "@prisma/client";
import { SesClick, SesEvent, SesEventDataKey } from "~/types/aws-types";
import { db } from "../db";
const STATUS_LIST = Object.values(EmailStatus);
import { updateCampaignAnalytics } from "./campaign-service";
import { env } from "~/env";
export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data);
@@ -34,15 +34,35 @@ export async function parseSesHook(data: SesEvent) {
return true;
}
await db.email.update({
// Update the latest status and to avoid race conditions
await db.$executeRaw`
UPDATE "Email"
SET "latestStatus" = CASE
WHEN ${mailStatus}::text::\"EmailStatus\" > "latestStatus" OR "latestStatus" IS NULL
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: {
id: email.id,
},
data: {
latestStatus: getLatestStatus(email.latestStatus, mailStatus),
emailId: email.id,
status: mailStatus,
},
});
if (!mailEvent) {
await updateCampaignAnalytics(email.campaignId, mailStatus);
}
}
}
await db.emailEvent.create({
data: {
emailId: email.id,
@@ -89,12 +109,3 @@ function getEmailData(data: SesEvent) {
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;
}

View File

@@ -52,9 +52,13 @@ export class SesSettingsService {
public static async createSesSetting({
region,
unsendUrl,
sendingRateLimit,
transactionalQuota,
}: {
region: string;
unsendUrl: string;
sendingRateLimit: number;
transactionalQuota: number;
}) {
await this.checkInitialized();
if (this.cache[region]) {
@@ -80,12 +84,62 @@ export class SesSettingsService {
region,
callbackUrl: `${parsedUrl}/api/ses_callback`,
topic: `${idPrefix}-${region}-unsend`,
sesEmailRateLimit: sendingRateLimit,
transactionalQuota,
idPrefix,
},
});
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();
}

View File

@@ -8,6 +8,7 @@ export type EmailContent = {
cc?: string | string[];
bcc?: string | string[];
attachments?: Array<EmailAttachment>;
unsubUrl?: string;
};
export type EmailAttachment = {

View 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"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
tailwindcss: {},
},
};
module.exports = config;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
}

View File

@@ -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>
);
};

View 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>
);
};

View 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);
},
});

View 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();
},
};
},
};
}

View File

@@ -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);
},
});

View 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);
},
});

View 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;
}

View File

@@ -0,0 +1,3 @@
import "./styles/index.css";
export * from "./editor";

View 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;

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View 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 || <>&nbsp;</>;
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) : <>&nbsp;</>}
</Text>
);
}
private text(node: JSONContent, _?: NodeOptions): JSX.Element {
const text = node.text || "&nbsp";
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>
);
}
}

View 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;
}

View 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>;

View 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;

View File

@@ -0,0 +1,8 @@
{
"extends": "@unsend/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["**/*.tsx", "**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View 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,
}));

View File

@@ -7,7 +7,11 @@
"esModuleInterop": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2022", "DOM", "DOM.Iterable"],
"lib": [
"es2022",
"DOM",
"DOM.Iterable"
],
"module": "NodeNext",
"moduleDetection": "force",
"moduleResolution": "NodeNext",

View File

@@ -3,7 +3,11 @@
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"plugins": [
{
"name": "next"
}
],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,

View File

@@ -3,6 +3,8 @@
"display": "React Library",
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx"
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler"
}
}

View File

@@ -33,11 +33,13 @@
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.1.2",
"add": "^2.0.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",

View File

@@ -19,6 +19,7 @@ const buttonVariants = cva(
ghost: "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",
silent: "bg-transparent hover:bg-accent/10 p-1",
},
size: {
default: "h-9 px-4 ",

View 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 };

View File

@@ -6,7 +6,8 @@ import { CheckIcon, ClipboardCopy } from "lucide-react";
export const TextWithCopyButton: React.FC<{
value: string;
className?: string;
}> = ({ value, className }) => {
alwaysShowCopy?: boolean;
}> = ({ value, className, alwaysShowCopy }) => {
const [isCopied, setIsCopied] = React.useState(false);
const copyToClipboard = async () => {
@@ -24,7 +25,9 @@ export const TextWithCopyButton: React.FC<{
<div className={className}>{value}</div>
<Button
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}
>
{isCopied ? (

View 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 };

View 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 };

View File

@@ -3,7 +3,8 @@
@tailwind utilities;
@layer base {
:root {
:root,
.light {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
@@ -28,7 +29,7 @@
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--border: 214 1% 71%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
@@ -77,3 +78,12 @@
@apply h-full;
}
}
/* .app,
::before,
::after {
@apply border-border;
box-sizing: border-box;
border-width: 0;
border-style: solid;
} */

4200
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff