From 25c810e290a58d73b88895452438351052f8c5a3 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Mon, 4 May 2026 10:57:29 +0530 Subject: [PATCH] Add saved templates library with template management Generated-By: PostHog Code Task-Id: ab97aa97-9a0e-45d7-940f-4f98a170b51b --- CHANGELOG.md | 1 + README.md | 1 + ROADMAP.md | 2 +- app/(dashboard)/editor/[campaignId]/page.tsx | 92 ++ app/(dashboard)/templates/[id]/edit/page.tsx | 345 +++++++ app/(dashboard)/templates/page.tsx | 519 ++++++++++ .../campaigns/[id]/save-as-template/route.ts | 59 ++ app/api/internal/templates/[id]/route.ts | 92 ++ .../templates/[id]/test-send/route.ts | 165 ++++ app/api/internal/templates/[id]/use/route.ts | 76 ++ app/api/internal/templates/route.ts | 77 ++ components/dashboard/sidebar.tsx | 11 + drizzle/migrations/0002_old_starbolt.sql | 12 + drizzle/migrations/meta/0002_snapshot.json | 901 ++++++++++++++++++ drizzle/migrations/meta/_journal.json | 7 + lib/db/schema.ts | 14 + lib/validations/templates.ts | 38 + 17 files changed, 2411 insertions(+), 1 deletion(-) create mode 100644 app/(dashboard)/templates/[id]/edit/page.tsx create mode 100644 app/(dashboard)/templates/page.tsx create mode 100644 app/api/internal/campaigns/[id]/save-as-template/route.ts create mode 100644 app/api/internal/templates/[id]/route.ts create mode 100644 app/api/internal/templates/[id]/test-send/route.ts create mode 100644 app/api/internal/templates/[id]/use/route.ts create mode 100644 app/api/internal/templates/route.ts create mode 100644 drizzle/migrations/0002_old_starbolt.sql create mode 100644 drizzle/migrations/meta/0002_snapshot.json create mode 100644 lib/validations/templates.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8623fdf..432d65b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Contact list management with CSV/XLSX import and column mapping - Email provider integration (Resend and Amazon SES) with encrypted credential storage - Visual block email editor with drag-and-drop, live preview, and merge tags +- Saved templates library with iframe thumbnail previews. Save campaigns as reusable templates, edit template content in place, start new campaigns prefilled from any template - Campaign creation, sending, scheduling, and cancellation - Open tracking (pixel) and click tracking (link wrapping) with per-campaign analytics - Unsubscribe page with one-click opt-out and List-Unsubscribe header support diff --git a/README.md b/README.md index 8e07a6c..274bbc2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Self-hostable broadcast email tool. Manage contact lists, design emails with a v - **Contact Management** - Import contacts via CSV/XLSX upload, organize into lists, track subscription status - **Visual Email Editor** - Drag-and-drop block editor with live preview, mobile/desktop toggle, and merge tag support +- **Saved Templates Library** - Save any campaign as a reusable template, browse with live thumbnail previews, and start new campaigns from any template - **Multiple Email Providers** - Send through Resend or Amazon SES with encrypted credential storage - **Campaign Sending** - Queue-based sending via pg-boss, with scheduling, cancellation, and per-provider rate limiting - **Open and Click Tracking** - Tracking pixel for opens, link wrapping for clicks, per-campaign analytics with charts diff --git a/ROADMAP.md b/ROADMAP.md index ad9cd7f..f2edf48 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,7 +31,7 @@ The features below build out the API surface so it's safe, observable, and compl - [ ] **Segments** [Effort: M]: Saved filter queries against a list (e.g. "opened anything in the last 30 days, country = US"). Used as a campaign target instead of an entire list. Can be combined with tags, fields, and engagement signals. - [ ] **Double opt-in** [Effort: M]: Per-list toggle that requires new contacts to confirm via emailed link before being marked active. Confirmation page and token-based confirmation flow. - [ ] **Embeddable signup forms** [Effort: M]: Form builder, hosted form pages, and a JS embed snippet. Submissions flow into a list and respect double opt-in if enabled. -- [ ] **Saved templates library** [Effort: S]: Reusable templates with thumbnails. Save any campaign as a template, start new campaigns from any template. +- [x] **Saved templates library** [Effort: S]: Reusable templates with thumbnails. Save any campaign as a template, start new campaigns from any template. - [ ] **Asset library** [Effort: M]: Central image and file manager. Upload once, browse and drop into any campaign editor. Replaces per-campaign image uploads. - [ ] **A/B testing** [Effort: M]: Subject line and content variants on a campaign. Send variants to a test sample, pick the winner by open or click rate, and send the winner to the rest. - [ ] **Reply-to and reply forwarding** [Effort: S]: Configurable reply-to address per campaign with optional forwarding to a real mailbox. Avoids no-reply senders without building a full inbox. diff --git a/app/(dashboard)/editor/[campaignId]/page.tsx b/app/(dashboard)/editor/[campaignId]/page.tsx index f3afbe5..a0c77ec 100644 --- a/app/(dashboard)/editor/[campaignId]/page.tsx +++ b/app/(dashboard)/editor/[campaignId]/page.tsx @@ -77,6 +77,12 @@ export default function EditorPage() { const [testEmail, setTestEmail] = useState("") const [sendingTest, setSendingTest] = useState(false) + // Save as template + const [saveTemplateOpen, setSaveTemplateOpen] = useState(false) + const [templateNameInput, setTemplateNameInput] = useState("") + const [templateDescriptionInput, setTemplateDescriptionInput] = useState("") + const [savingTemplate, setSavingTemplate] = useState(false) + // Merge tags const [mergeTags, setMergeTags] = useState<{ tag: string; description: string }[]>([]) @@ -227,6 +233,46 @@ export default function EditorPage() { } } + const openSaveTemplate = () => { + setTemplateNameInput(name || "") + setTemplateDescriptionInput("") + setSaveTemplateOpen(true) + } + + const handleSaveAsTemplate = async () => { + if (!templateNameInput.trim()) { + toast({ title: "Error", description: "Template name is required", variant: "destructive" }) + return + } + setSavingTemplate(true) + try { + // Save current edits to the campaign first so the template snapshots the latest content + await saveDraft() + const res = await fetch(`/api/internal/campaigns/${campaignId}/save-as-template`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: templateNameInput.trim(), + description: templateDescriptionInput.trim() || null, + }), + }) + const data = await res.json() + if (!res.ok) { + toast({ title: "Error", description: data.error || "Failed to save template", variant: "destructive" }) + return + } + toast({ + title: "Saved as template", + description: `Template "${data.name}" created. Find it in the Templates library.`, + }) + setSaveTemplateOpen(false) + } catch { + toast({ title: "Error", description: "Failed to save template", variant: "destructive" }) + } finally { + setSavingTemplate(false) + } + } + const copyMergeTag = (tag: string) => { navigator.clipboard.writeText(tag) toast({ title: "Copied", description: `${tag} copied to clipboard` }) @@ -302,12 +348,58 @@ export default function EditorPage() { + + + + + Save as Template + +
+
+ + setTemplateNameInput(e.target.value)} + placeholder="e.g. Monthly Newsletter Layout" + /> +
+
+ + setTemplateDescriptionInput(e.target.value)} + placeholder="Short note about this template" + /> +
+

+ Snapshots the current subject, sender info, and email content. Saves your draft first. +

+
+ + + + +
+
+ {/* Editor mode toggle + Subject and From fields */}
+ + + + Send Test Email + +
+
+ + setTestEmail(e.target.value)} + placeholder="test@example.com, another@example.com" + /> +

+ Separate multiple addresses with commas. Up to 10 recipients. Uses the default provider. +

+
+
+ + + +
+ + +
+ + +
+ + +
+
+
+ + setDescription(e.target.value)} + placeholder="Short note" + className="h-8 text-sm" + /> +
+
+ + setSubject(e.target.value)} + placeholder="Email subject" + className="h-8 text-sm" + /> +
+
+ + setFromName(e.target.value)} + placeholder="Your Name" + className="h-8 text-sm" + /> +
+
+ + setFromEmail(e.target.value)} + placeholder="you@example.com" + className="h-8 text-sm" + /> +
+
+ + +
+ {editorMode === "visual" ? ( + + ) : ( + + )} +
+ +
+
+ Merge tags: + {DEFAULT_MERGE_TAGS.map((item) => ( + + ))} +
+
+ + ) +} diff --git a/app/(dashboard)/templates/page.tsx b/app/(dashboard)/templates/page.tsx new file mode 100644 index 0000000..019b295 --- /dev/null +++ b/app/(dashboard)/templates/page.tsx @@ -0,0 +1,519 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { format } from "date-fns" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useToast } from "@/components/ui/use-toast" +import { Skeleton } from "@/components/ui/skeleton" +import type { Block } from "@/lib/db/schema" + +interface Template { + id: string + name: string + description: string | null + subject: string + fromName: string + fromEmail: string + templateJson: Block[] + templateHtml: string | null + createdAt: string + updatedAt: string +} + +interface ListOption { + id: string + name: string +} + +// Minimal client-side block renderer for thumbnail previews. Mirrors lib/renderer +// but avoids pulling Handlebars and juice into the client bundle. +function renderBlocksForPreview(blocks: Block[]): string { + return blocks + .map((block) => { + const p = (block.props || {}) as Record + switch (block.type) { + case "heading": + return `

${p.text || ""}

` + case "text": + return `

${p.text || ""}

` + case "button": + return `
${p.text || "Click here"}
` + case "image": + return `${p.alt || ` + case "divider": + return `
` + case "spacer": + return `
` + default: + return "" + } + }) + .join("\n") +} + +function buildPreviewSrcDoc(template: Template): string { + const inner = + template.templateHtml && template.templateHtml.trim().length > 0 + ? template.templateHtml + : renderBlocksForPreview(template.templateJson || []) + if (!inner.trim()) { + return `Empty template` + } + return `${inner}` +} + +function TemplateThumbnail({ template }: { template: Template }) { + const srcDoc = useMemo(() => buildPreviewSrcDoc(template), [template]) + return ( +
+