From 5ddc0a7bb9faaddd9da82e5e49768489b8962586 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sat, 10 Aug 2024 10:09:10 +1000 Subject: [PATCH] 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 --- apps/marketing/package.json | 3 +- apps/marketing/src/app/editor/page.tsx | 38 + apps/marketing/src/app/layout.tsx | 94 +- apps/marketing/src/app/page.tsx | 83 +- apps/web/package.json | 3 +- .../20240809235907_add_campaign/migration.sql | 81 + apps/web/prisma/schema.prisma | 87 +- .../admin/edit-ses-configuration.tsx | 168 + .../(dashboard)/admin/ses-configurations.tsx | 19 +- .../campaigns/[campaignId]/edit/page.tsx | 347 ++ .../campaigns/[campaignId]/page.tsx | 194 + .../(dashboard)/campaigns/campaign-list.tsx | 150 + .../(dashboard)/campaigns/create-campaign.tsx | 166 + .../(dashboard)/campaigns/delete-campaign.tsx | 134 + .../campaigns/duplicate-campaign.tsx | 78 + .../src/app/(dashboard)/campaigns/page.tsx | 16 + .../contacts/[contactBookId]/add-contact.tsx | 136 + .../contacts/[contactBookId]/contact-list.tsx | 145 + .../[contactBookId]/delete-contact.tsx | 138 + .../contacts/[contactBookId]/edit-contact.tsx | 171 + .../contacts/[contactBookId]/page.tsx | 96 + .../(dashboard)/contacts/add-contact-book.tsx | 124 + .../contacts/contact-books-list.tsx | 79 + .../contacts/delete-contact-book.tsx | 142 + .../contacts/edit-contact-book.tsx | 122 + .../web/src/app/(dashboard)/contacts/page.tsx | 16 + .../src/app/(dashboard)/dasboard-layout.tsx | 8 +- apps/web/src/app/api/to-html/route.ts | 50 + apps/web/src/app/layout.tsx | 2 +- apps/web/src/app/unsubscribe/page.tsx | 51 + apps/web/src/app/unsubscribe/re-subscribe.tsx | 60 + .../components/settings/AddSesSettings.tsx | 22 + apps/web/src/hooks/useInterval.ts | 25 + apps/web/src/server/api/root.ts | 4 + apps/web/src/server/api/routers/admin.ts | 20 + apps/web/src/server/api/routers/campaign.ts | 183 + apps/web/src/server/api/routers/contacts.ts | 168 + apps/web/src/server/api/routers/email.ts | 2 +- apps/web/src/server/api/trpc.ts | 56 +- apps/web/src/server/aws/ses.ts | 9 + apps/web/src/server/public-api/api-utils.ts | 27 + .../public-api/api/contacts/add-contact.ts | 65 + .../public-api/api/contacts/get-contact.ts | 82 + .../public-api/api/contacts/update-contact.ts | 66 + .../server/public-api/api/emails/get-email.ts | 2 +- apps/web/src/server/public-api/index.ts | 8 + .../src/server/service/campaign-service.ts | 309 ++ .../web/src/server/service/contact-service.ts | 92 + apps/web/src/server/service/domain-service.ts | 36 + .../src/server/service/email-queue-service.ts | 115 +- apps/web/src/server/service/email-service.ts | 33 +- .../web/src/server/service/ses-hook-parser.ts | 53 +- .../server/service/ses-settings-service.ts | 56 +- apps/web/src/types/index.ts | 1 + packages/email-editor/package.json | 61 + packages/email-editor/postcss.config.cjs | 7 + .../src/components/panels/LinkEditorPanel.tsx | 65 + .../components/panels/LinkPreviewPanel.tsx | 33 + .../src/components/ui/ColorPicker.tsx | 37 + .../src/components/ui/icons/BorderWidth.tsx | 18 + packages/email-editor/src/editor.tsx | 111 + .../src/extensions/ButtonExtension.ts | 92 + .../src/extensions/SlashCommand.tsx | 487 ++ .../extensions/UnsubsubscribeExtension.tsx | 53 + .../src/extensions/VariableExtension.ts | 141 + packages/email-editor/src/extensions/index.ts | 80 + packages/email-editor/src/index.ts | 3 + packages/email-editor/src/menus/LinkMenu.tsx | 81 + packages/email-editor/src/menus/TextMenu.tsx | 426 ++ .../email-editor/src/menus/TextMenuButton.tsx | 30 + packages/email-editor/src/nodes/button.tsx | 304 ++ .../src/nodes/unsubscribe-footer.tsx | 14 + packages/email-editor/src/nodes/variable.tsx | 225 + packages/email-editor/src/renderer.tsx | 765 +++ packages/email-editor/src/styles/index.css | 246 + packages/email-editor/src/types.ts | 23 + packages/email-editor/tailwind.config.ts | 7 + packages/email-editor/tsconfig.json | 8 + packages/email-editor/tsup.config.ts | 17 + packages/typescript-config/base.json | 8 +- packages/typescript-config/nextjs.json | 8 +- packages/typescript-config/react-library.json | 6 +- packages/ui/package.json | 2 + packages/ui/src/button.tsx | 1 + packages/ui/src/popover.tsx | 31 + packages/ui/src/separator.tsx | 2 +- packages/ui/src/table.tsx | 2 +- packages/ui/src/text-with-copy.tsx | 7 +- packages/ui/src/textarea.tsx | 24 + packages/ui/src/tooltip.tsx | 30 + packages/ui/styles/globals.css | 14 +- pnpm-lock.yaml | 4200 ++++++++++++++++- 92 files changed, 11766 insertions(+), 338 deletions(-) create mode 100644 apps/marketing/src/app/editor/page.tsx create mode 100644 apps/web/prisma/migrations/20240809235907_add_campaign/migration.sql create mode 100644 apps/web/src/app/(dashboard)/admin/edit-ses-configuration.tsx create mode 100644 apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx create mode 100644 apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx create mode 100644 apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx create mode 100644 apps/web/src/app/(dashboard)/campaigns/create-campaign.tsx create mode 100644 apps/web/src/app/(dashboard)/campaigns/delete-campaign.tsx create mode 100644 apps/web/src/app/(dashboard)/campaigns/duplicate-campaign.tsx create mode 100644 apps/web/src/app/(dashboard)/campaigns/page.tsx create mode 100644 apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx create mode 100644 apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx create mode 100644 apps/web/src/app/(dashboard)/contacts/[contactBookId]/delete-contact.tsx create mode 100644 apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx create mode 100644 apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx create mode 100644 apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx create mode 100644 apps/web/src/app/(dashboard)/contacts/contact-books-list.tsx create mode 100644 apps/web/src/app/(dashboard)/contacts/delete-contact-book.tsx create mode 100644 apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx create mode 100644 apps/web/src/app/(dashboard)/contacts/page.tsx create mode 100644 apps/web/src/app/api/to-html/route.ts create mode 100644 apps/web/src/app/unsubscribe/page.tsx create mode 100644 apps/web/src/app/unsubscribe/re-subscribe.tsx create mode 100644 apps/web/src/hooks/useInterval.ts create mode 100644 apps/web/src/server/api/routers/campaign.ts create mode 100644 apps/web/src/server/api/routers/contacts.ts create mode 100644 apps/web/src/server/public-api/api-utils.ts create mode 100644 apps/web/src/server/public-api/api/contacts/add-contact.ts create mode 100644 apps/web/src/server/public-api/api/contacts/get-contact.ts create mode 100644 apps/web/src/server/public-api/api/contacts/update-contact.ts create mode 100644 apps/web/src/server/service/campaign-service.ts create mode 100644 apps/web/src/server/service/contact-service.ts create mode 100644 packages/email-editor/package.json create mode 100644 packages/email-editor/postcss.config.cjs create mode 100644 packages/email-editor/src/components/panels/LinkEditorPanel.tsx create mode 100644 packages/email-editor/src/components/panels/LinkPreviewPanel.tsx create mode 100644 packages/email-editor/src/components/ui/ColorPicker.tsx create mode 100644 packages/email-editor/src/components/ui/icons/BorderWidth.tsx create mode 100644 packages/email-editor/src/editor.tsx create mode 100644 packages/email-editor/src/extensions/ButtonExtension.ts create mode 100644 packages/email-editor/src/extensions/SlashCommand.tsx create mode 100644 packages/email-editor/src/extensions/UnsubsubscribeExtension.tsx create mode 100644 packages/email-editor/src/extensions/VariableExtension.ts create mode 100644 packages/email-editor/src/extensions/index.ts create mode 100644 packages/email-editor/src/index.ts create mode 100644 packages/email-editor/src/menus/LinkMenu.tsx create mode 100644 packages/email-editor/src/menus/TextMenu.tsx create mode 100644 packages/email-editor/src/menus/TextMenuButton.tsx create mode 100644 packages/email-editor/src/nodes/button.tsx create mode 100644 packages/email-editor/src/nodes/unsubscribe-footer.tsx create mode 100644 packages/email-editor/src/nodes/variable.tsx create mode 100644 packages/email-editor/src/renderer.tsx create mode 100644 packages/email-editor/src/styles/index.css create mode 100644 packages/email-editor/src/types.ts create mode 100644 packages/email-editor/tailwind.config.ts create mode 100644 packages/email-editor/tsconfig.json create mode 100644 packages/email-editor/tsup.config.ts create mode 100644 packages/ui/src/popover.tsx create mode 100644 packages/ui/src/textarea.tsx create mode 100644 packages/ui/src/tooltip.tsx diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 0bb7890..b8fec75 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -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", diff --git a/apps/marketing/src/app/editor/page.tsx b/apps/marketing/src/app/editor/page.tsx new file mode 100644 index 0000000..b7de3bb --- /dev/null +++ b/apps/marketing/src/app/editor/page.tsx @@ -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>({ + 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 ( +
+

+ Try out unsend's email editor +

+
+ + + setJson(editor.getJSON())} /> +
+
+ ); +} diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx index c543471..4b926aa 100644 --- a/apps/marketing/src/app/layout.tsx +++ b/apps/marketing/src/app/layout.tsx @@ -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({ )} - {children} +
+
+ +
+
{children}
+
+
+ +
+
+ + + + + + + + + + + + + + + + {/* Github */} +
+
+
diff --git a/apps/marketing/src/app/page.tsx b/apps/marketing/src/app/page.tsx index 639a5ed..0cc6ef9 100644 --- a/apps/marketing/src/app/page.tsx +++ b/apps/marketing/src/app/page.tsx @@ -24,46 +24,6 @@ export default function Home() { return (
-

Open source sending infrastructure for{" "} @@ -86,7 +46,7 @@ export default function Home() { {/* */}

-
+

Reach your users

@@ -236,47 +196,6 @@ export default function Home() { {/* */}
- -
-
- -
-
- - - - - - - - - - - - - - - - {/* Github */} -
-
); } diff --git a/apps/web/package.json b/apps/web/package.json index 753f15d..f690147 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/prisma/migrations/20240809235907_add_campaign/migration.sql b/apps/web/prisma/migrations/20240809235907_add_campaign/migration.sql new file mode 100644 index 0000000..32415b0 --- /dev/null +++ b/apps/web/prisma/migrations/20240809235907_add_campaign/migration.sql @@ -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; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 201529f..8595acb 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -2,7 +2,7 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" previewFeatures = ["tracing"] } @@ -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? @@ -90,14 +91,16 @@ model User { } model Team { - id Int @id @default(autoincrement()) - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - teamUsers TeamUser[] - domains Domain[] - apiKeys ApiKey[] - emails Email[] + id Int @id @default(autoincrement()) + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + teamUsers TeamUser[] + 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) +} diff --git a/apps/web/src/app/(dashboard)/admin/edit-ses-configuration.tsx b/apps/web/src/app/(dashboard)/admin/edit-ses-configuration.tsx new file mode 100644 index 0000000..a37ba93 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/edit-ses-configuration.tsx @@ -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 ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Edit SES configuration + +
+ setOpen(false)} + /> +
+
+
+ ); +} + +type SesSettingsProps = { + setting: SesSetting; + onSuccess?: () => void; +}; + +export const EditSesSettingsForm: React.FC = ({ + setting, + onSuccess, +}) => { + const updateSesSettings = api.admin.updateSesSettings.useMutation(); + + const utils = api.useUtils(); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + settingsId: setting.id, + sendRate: setting.sesEmailRateLimit, + transactionalQuota: setting.transactionalQuota, + }, + }); + + function onSubmit(data: z.infer) { + updateSesSettings.mutate(data, { + onSuccess: () => { + utils.admin.invalidate(); + onSuccess?.(); + }, + onError: (e) => { + toast.error("Failed to update", { + description: e.message, + }); + }, + }); + } + + return ( +
+ + ( + + Send Rate + + + + {formState.errors.sendRate ? ( + + ) : ( + + The number of emails to send per second. + + )} + + )} + /> + ( + + Transactional Quota + + + + {formState.errors.transactionalQuota ? ( + + ) : ( + + The percentage of the quota to be used for transactional + emails (0-100%). + + )} + + )} + /> + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/ses-configurations.tsx b/apps/web/src/app/(dashboard)/admin/ses-configurations.tsx index 16fd684..97f1bbd 100644 --- a/apps/web/src/app/(dashboard)/admin/ses-configurations.tsx +++ b/apps/web/src/app/(dashboard)/admin/ses-configurations.tsx @@ -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() { Callback URL Callback status Created at + Send rate + Transactional quota + Actions @@ -47,13 +52,25 @@ export default function SesConfigurations() { sesSettingsQuery.data?.map((sesSetting) => ( {sesSetting.region} - {sesSetting.callbackUrl} + +
+ +
+
{sesSetting.callbackSuccess ? "Success" : "Failed"} {formatDistanceToNow(sesSetting.createdAt)} ago + {sesSetting.sesEmailRateLimit} + {sesSetting.transactionalQuota}% + + +
)) )} diff --git a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx new file mode 100644 index 0000000..f066e13 --- /dev/null +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx @@ -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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+

Failed to load campaign

+
+ ); + } + + if (!campaign) { + return
Campaign not found
; + } + + return ; +} + +function CampaignEditor({ campaign }: { campaign: Campaign }) { + const contactBooksQuery = api.contacts.getContactBooks.useQuery(); + const utils = api.useUtils(); + + const [json, setJson] = useState | 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>({ + 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) { + 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 ( +
+
+
+ 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); + }, + } + ); + }} + /> +
+
+ {isSaving ? ( +
+ ) : ( +
+ )} + {formatDistanceToNow(campaign.updatedAt) === "less than a minute" + ? "just now" + : `${formatDistanceToNow(campaign.updatedAt)} ago`} +
+ + + + + + + Send Campaign + + Are you sure you want to send this campaign? This action + cannot be undone. + + +
+
+ + ( + + Type 'Send' to confirm + + + + + + )} + /> +
+ +
+ + +
+
+
+
+
+
+ + { + 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" + /> +
+
+ + { + setFrom(e.target.value); + }} + className="mt-1 block w-full rounded-md shadow-sm" + placeholder="Friendly name" + onBlur={() => { + if (from === campaign.from) { + return; + } + updateCampaignMutation.mutate( + { + campaignId: campaign.id, + from, + }, + { + onError: (e) => { + toast.error(`${e.message}. Reverting changes.`); + setFrom(campaign.from); + }, + } + ); + }} + /> +
+
+ + {contactBooksQuery.isLoading ? ( + + ) : ( + + )} +
+ + { + setJson(content.getJSON()); + setIsSaving(true); + deboucedUpdateCampaign(); + }} + variables={["email", "firstName", "lastName"]} + /> +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx new file mode 100644 index 0000000..d23781a --- /dev/null +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx @@ -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 ( +
+ +
+ ); + } + + if (!campaign) { + return
Campaign not found
; + } + + 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 ( +
+ + + + + + Campaigns + + + + + + + {campaign.name} + + + + +
+

Statistics

+
+ {statusCards.map((card) => ( +
+
+ {card.status !== "total" ? ( + + ) : null} +
{card.status.toLowerCase()}
+
+
+
+ {card.count} +
+ {card.status !== "total" ? ( +
+ {card.percentage.toFixed(1)}% +
+ ) : null} +
+
+ ))} +
+
+ + {campaign.html && ( +
+

Email

+ +
+
+ From + {campaign.from} +
+ +
+ To + {campaign.contactBookId ? ( + + {campaign.contactBook?.name} + + + ) : ( +
No one
+ )} +
+ +
+ Subject + {campaign.subject} +
+
+
+
+
+
+ )} +
+ ); +} + +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 ( +
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx b/apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx new file mode 100644 index 0000000..1666352 --- /dev/null +++ b/apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx @@ -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 ( +
+
+ {/* */} +
+
+ + + + Name + Status + Created At + Actions + + + + {campaignsQuery.isLoading ? ( + + + + + + ) : campaignsQuery.data?.campaigns.length ? ( + campaignsQuery.data?.campaigns.map((campaign) => ( + + + + {campaign.name} + + + +
+ {campaign.status.toLowerCase()} +
+
+ + {formatDistanceToNow(new Date(campaign.createdAt), { + addSuffix: true, + })} + + +
+ + +
+
+
+ )) + ) : ( + + + No campaigns found + + + )} +
+
+
+
+ + +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/campaigns/create-campaign.tsx b/apps/web/src/app/(dashboard)/campaigns/create-campaign.tsx new file mode 100644 index 0000000..db7dee7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/campaigns/create-campaign.tsx @@ -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>({ + resolver: zodResolver(campaignSchema), + defaultValues: { + name: "", + from: "", + subject: "", + }, + }); + + const utils = api.useUtils(); + + async function onCampaignCreate(values: z.infer) { + 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 ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Create new campaign + +
+
+ + ( + + Name + + + + {formState.errors.name ? : null} + + )} + /> + ( + + From + + + + {formState.errors.from ? : null} + + )} + /> + ( + + Subject + + + + {formState.errors.subject ? : null} + + )} + /> +

+ Don't worry, you can change it later. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/campaigns/delete-campaign.tsx b/apps/web/src/app/(dashboard)/campaigns/delete-campaign.tsx new file mode 100644 index 0000000..8a9aaa3 --- /dev/null +++ b/apps/web/src/app/(dashboard)/campaigns/delete-campaign.tsx @@ -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 & { id: string }; +}> = ({ campaign }) => { + const [open, setOpen] = useState(false); + const deleteCampaignMutation = api.campaign.deleteCampaign.useMutation(); + + const utils = api.useUtils(); + + const campaignForm = useForm>({ + resolver: zodResolver(campaignSchema), + }); + + async function onCampaignDelete(values: z.infer) { + 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 ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Delete Campaign + + Are you sure you want to delete{" "} + {campaign.name}? + You can't reverse this. + + +
+
+ + ( + + name + + + + {formState.errors.name ? ( + + ) : ( + + . + + )} + + )} + /> +
+ +
+ + +
+
+
+ ); +}; + +export default DeleteCampaign; diff --git a/apps/web/src/app/(dashboard)/campaigns/duplicate-campaign.tsx b/apps/web/src/app/(dashboard)/campaigns/duplicate-campaign.tsx new file mode 100644 index 0000000..a5738d9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/campaigns/duplicate-campaign.tsx @@ -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 & { 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 ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Duplicate Campaign + + Are you sure you want to duplicate{" "} + {campaign.name}? + + +
+
+ +
+
+
+
+ ); +}; + +export default DuplicateCampaign; diff --git a/apps/web/src/app/(dashboard)/campaigns/page.tsx b/apps/web/src/app/(dashboard)/campaigns/page.tsx new file mode 100644 index 0000000..1a833f7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/campaigns/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import CampaignList from "./campaign-list"; +import CreateCampaign from "./create-campaign"; + +export default function ContactsPage() { + return ( +
+
+

Campaigns

+ +
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx new file mode 100644 index 0000000..dc85a5a --- /dev/null +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx @@ -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>({ + resolver: zodResolver(contactsSchema), + defaultValues: { + contacts: "", + }, + }); + + const utils = api.useUtils(); + + async function onContactsAdd(values: z.infer) { + 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 ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Add new contacts + +
+
+ + ( + + Contacts + +