From 38314a35dca50509dd96bc7db3d50cc04747a185 Mon Sep 17 00:00:00 2001 From: Ganapathy S Date: Fri, 7 Mar 2025 00:50:25 +0530 Subject: [PATCH] feat: add templates for transactional emails (#103) * add template migration & router * template CRUD * templated transactional emails API * zod schema fix & rearranging template columns --- .../20250207104036_add_template/migration.sql | 19 ++ apps/web/prisma/schema.prisma | 15 ++ .../src/app/(dashboard)/dasboard-layout.tsx | 6 + .../templates/[templateId]/page.tsx | 218 ++++++++++++++++++ .../(dashboard)/templates/create-template.tsx | 145 ++++++++++++ .../(dashboard)/templates/delete-template.tsx | 134 +++++++++++ .../templates/duplicate-template.tsx | 78 +++++++ .../src/app/(dashboard)/templates/page.tsx | 16 ++ .../(dashboard)/templates/template-list.tsx | 113 +++++++++ apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/campaign.ts | 3 +- apps/web/src/server/api/routers/template.ts | 170 ++++++++++++++ apps/web/src/server/api/trpc.ts | 21 ++ .../public-api/api/emails/send-email.ts | 9 +- apps/web/src/server/service/email-service.ts | 36 ++- apps/web/src/types/index.ts | 4 +- 16 files changed, 981 insertions(+), 8 deletions(-) create mode 100644 apps/web/prisma/migrations/20250207104036_add_template/migration.sql create mode 100644 apps/web/src/app/(dashboard)/templates/[templateId]/page.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/create-template.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/delete-template.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/duplicate-template.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/page.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/template-list.tsx create mode 100644 apps/web/src/server/api/routers/template.ts diff --git a/apps/web/prisma/migrations/20250207104036_add_template/migration.sql b/apps/web/prisma/migrations/20250207104036_add_template/migration.sql new file mode 100644 index 0000000..1511e89 --- /dev/null +++ b/apps/web/prisma/migrations/20250207104036_add_template/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "Template" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "teamId" INTEGER NOT NULL, + "subject" TEXT NOT NULL, + "html" TEXT, + "content" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Template_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Template_createdAt_idx" ON "Template"("createdAt" DESC); + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_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 5594c93..d301219 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -101,6 +101,7 @@ model Team { emails Email[] contactBooks ContactBook[] campaigns Campaign[] + templates Template[] dailyEmailUsages DailyEmailUsage[] } @@ -286,6 +287,20 @@ model Campaign { @@index([createdAt(sort: Desc)]) } +model Template { + id String @id @default(cuid()) + name String + teamId Int + subject String + html String? + content String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + @@index([createdAt(sort: Desc)]) +} + enum EmailUsageType { TRANSACTIONAL MARKETING diff --git a/apps/web/src/app/(dashboard)/dasboard-layout.tsx b/apps/web/src/app/(dashboard)/dasboard-layout.tsx index 40d4384..13e9d49 100644 --- a/apps/web/src/app/(dashboard)/dasboard-layout.tsx +++ b/apps/web/src/app/(dashboard)/dasboard-layout.tsx @@ -11,6 +11,7 @@ import { Globe, Home, LayoutDashboard, + LayoutTemplate, LineChart, Mail, Menu, @@ -67,6 +68,11 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) { Contacts + + + Templates + + Campaigns diff --git a/apps/web/src/app/(dashboard)/templates/[templateId]/page.tsx b/apps/web/src/app/(dashboard)/templates/[templateId]/page.tsx new file mode 100644 index 0000000..bc467fc --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[templateId]/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { api } from "~/trpc/react"; +import { Spinner } from "@unsend/ui/src/spinner"; +import { Input } from "@unsend/ui/src/input"; +import { Editor } from "@unsend/email-editor"; +import { useState } from "react"; +import { Template } from "@prisma/client"; +import { toast } from "@unsend/ui/src/toaster"; +import { useDebouncedCallback } from "use-debounce"; +import { formatDistanceToNow } from "date-fns"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +const IMAGE_SIZE_LIMIT = 10 * 1024 * 1024; + +export default function EditTemplatePage({ + params, +}: { + params: { templateId: string }; +}) { + const { + data: template, + isLoading, + error, + } = api.template.getTemplate.useQuery( + { templateId: params.templateId }, + { + enabled: !!params.templateId, + } + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

Failed to load template

+
+ ); + } + + if (!template) { + return
Template not found
; + } + + return ; +} + +function TemplateEditor({ + template, +}: { + template: Template & { imageUploadSupported: boolean }; +}) { + const utils = api.useUtils(); + + const [json, setJson] = useState | undefined>( + template.content ? JSON.parse(template.content) : undefined + ); + const [isSaving, setIsSaving] = useState(false); + const [name, setName] = useState(template.name); + const [subject, setSubject] = useState(template.subject); + + const updateTemplateMutation = api.template.updateTemplate.useMutation({ + onSuccess: () => { + utils.template.getTemplate.invalidate(); + setIsSaving(false); + }, + }); + const getUploadUrl = api.template.generateImagePresignedUrl.useMutation(); + + + function updateEditorContent() { + updateTemplateMutation.mutate({ + templateId: template.id, + content: JSON.stringify(json), + }); + } + + const deboucedUpdateTemplate = useDebouncedCallback( + updateEditorContent, + 1000 + ); + + const handleFileChange = async (file: File) => { + if (file.size > IMAGE_SIZE_LIMIT) { + throw new Error( + `File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB` + ); + } + + console.log("file type: ", file.type); + + const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({ + name: file.name, + type: file.type, + templateId: template.id, + }); + + const response = await fetch(uploadUrl, { + method: "PUT", + body: file, + }); + + if (!response.ok) { + throw new Error("Failed to upload file"); + } + + return imageUrl; + }; + + return ( +
+
+
+
+ + + + setName(e.target.value)} + className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]" + onBlur={() => { + if (name === template.name || !name) { + return; + } + updateTemplateMutation.mutate( + { + templateId: template.id, + name, + }, + { + onError: (e) => { + toast.error(`${e.message}. Reverting changes.`); + setName(template.name); + }, + } + ); + }} + /> +
+ +
+
+ {isSaving ? ( +
+ ) : ( +
+ )} + {formatDistanceToNow(template.updatedAt) === "less than a minute" + ? "just now" + : `${formatDistanceToNow(template.updatedAt)} ago`} +
+
+
+ +
+
+ + { + setSubject(e.target.value); + }} + onBlur={() => { + if (subject === template.subject || !subject) { + return; + } + updateTemplateMutation.mutate( + { + templateId: template.id, + subject, + }, + { + onError: (e) => { + toast.error(`${e.message}. Reverting changes.`); + setSubject(template.subject); + }, + } + ); + }} + className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent" + /> +
+
+ + +
+
+ { + setJson(content.getJSON()); + setIsSaving(true); + deboucedUpdateTemplate(); + }} + variables={["email", "firstName", "lastName"]} + uploadImage={ + template.imageUploadSupported ? handleFileChange : undefined + } + /> +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/templates/create-template.tsx b/apps/web/src/app/(dashboard)/templates/create-template.tsx new file mode 100644 index 0000000..647cedd --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/create-template.tsx @@ -0,0 +1,145 @@ +"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 templateSchema = z.object({ + name: z.string({ required_error: "Name is required" }).min(1, { + message: "Name is required", + }), + subject: z.string({ required_error: "Subject is required" }).min(1, { + message: "Subject is required", + }), +}); + +export default function CreateTemplate() { + const router = useRouter(); + const [open, setOpen] = useState(false); + + const createTemplateMutation = api.template.createTemplate.useMutation(); + + const templateForm = useForm>({ + resolver: zodResolver(templateSchema), + defaultValues: { + name: "", + subject: "", + }, + }); + + const utils = api.useUtils(); + + async function onTemplateCreate(values: z.infer) { + createTemplateMutation.mutate( + { + name: values.name, + subject: values.subject, + }, + { + onSuccess: async (data) => { + utils.template.getTemplates.invalidate(); + router.push(`/templates/${data.id}/edit`); + toast.success("Template created successfully"); + setOpen(false); + }, + onError: async (error) => { + toast.error(error.message); + }, + } + ); + } + + return ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Create new template + +
+
+ + ( + + Name + + + + {formState.errors.name ? : null} + + )} + /> + ( + + Subject + + + + {formState.errors.subject ? : null} + + )} + /> +

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

+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/templates/delete-template.tsx b/apps/web/src/app/(dashboard)/templates/delete-template.tsx new file mode 100644 index 0000000..de07ece --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/delete-template.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 { Template } from "@prisma/client"; + +const templateSchema = z.object({ + name: z.string(), +}); + +export const DeleteTemplate: React.FC<{ + template: Partial