Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add templates for transactional emails #103

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ model Team {
emails Email[]
contactBooks ContactBook[]
campaigns Campaign[]
templates Template[]
dailyEmailUsages DailyEmailUsage[]
}

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/app/(dashboard)/dasboard-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Globe,
Home,
LayoutDashboard,
LayoutTemplate,
LineChart,
Mail,
Menu,
Expand Down Expand Up @@ -67,6 +68,11 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
Contacts
</NavButton>

<NavButton href="/templates">
<LayoutTemplate className="h-4 w-4" />
Templates
</NavButton>

<NavButton href="/campaigns">
<Volume2 className="h-4 w-4" />
Campaigns
Expand Down
218 changes: 218 additions & 0 deletions apps/web/src/app/(dashboard)/templates/[templateId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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 template</p>
</div>
);
}

if (!template) {
return <div>Template not found</div>;
}

return <TemplateEditor template={template} />;
}

function TemplateEditor({
template,
}: {
template: Template & { imageUploadSupported: boolean };
}) {
const utils = api.useUtils();

const [json, setJson] = useState<Record<string, any> | 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 (
<div className="p-4 container mx-auto">
<div className="mx-auto">
<div className="mb-4 flex justify-between items-center w-[700px] mx-auto">
<div className="flex items-center gap-3">
<Link href="/templates">
<ArrowLeft className="h-4 w-4" />
</Link>
<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 === template.name || !name) {
return;
}
updateTemplateMutation.mutate(
{
templateId: template.id,
name,
},
{
onError: (e) => {
toast.error(`${e.message}. Reverting changes.`);
setName(template.name);
},
}
);
}}
/>
</div>

<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(template.updatedAt) === "less than a minute"
? "just now"
: `${formatDistanceToNow(template.updatedAt)} ago`}
</div>
</div>
</div>

<div className="flex flex-col mt-4 mb-4 p-4 w-[700px] mx-auto z-50">
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
Subject
</label>
<input
type="text"
value={subject}
onChange={(e) => {
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"
/>
</div>
</div>


<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto">
<Editor
initialContent={json}
onUpdate={(content) => {
setJson(content.getJSON());
setIsSaving(true);
deboucedUpdateTemplate();
}}
variables={["email", "firstName", "lastName"]}
uploadImage={
template.imageUploadSupported ? handleFileChange : undefined
}
/>
</div>
</div>
</div>
</div>
);
}
Loading