From 6b25bbe2f86e5dd6946ea38a9f34e9c7bdb5fb0f Mon Sep 17 00:00:00 2001
From: Dries Augustyns
Date: Wed, 10 Dec 2025 21:04:51 +0100
Subject: [PATCH] feat: ability to create new campaigns based on templates or
previous campaigns
---
.../components/CampaignSelectionDialog.tsx | 424 +++++++++++++++++
.../components/TemplateSelectionDialog.tsx | 427 ++++++++++++++++++
apps/web/src/pages/campaigns/[id].tsx | 35 +-
apps/web/src/pages/campaigns/create.tsx | 105 ++++-
apps/web/src/pages/campaigns/index.tsx | 229 +++++++++-
5 files changed, 1191 insertions(+), 29 deletions(-)
create mode 100644 apps/web/src/components/CampaignSelectionDialog.tsx
create mode 100644 apps/web/src/components/TemplateSelectionDialog.tsx
diff --git a/apps/web/src/components/CampaignSelectionDialog.tsx b/apps/web/src/components/CampaignSelectionDialog.tsx
new file mode 100644
index 0000000..e482c80
--- /dev/null
+++ b/apps/web/src/components/CampaignSelectionDialog.tsx
@@ -0,0 +1,424 @@
+import {
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Checkbox,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ Label,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@plunk/ui';
+import type {Campaign} from '@plunk/db';
+import {CampaignStatus} from '@plunk/db';
+import {ArrowLeft, Calendar, Mail, Users} from 'lucide-react';
+import {useState} from 'react';
+import useSWR from 'swr';
+
+interface PaginatedCampaigns {
+ campaigns: Campaign[];
+ total: number;
+ page: number;
+ pageSize: number;
+ totalPages: number;
+}
+
+interface CampaignSelectionDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSelectCampaign: (campaign: Campaign, selectedFields: SelectedFields) => void;
+}
+
+interface SelectedFields {
+ subject: boolean;
+ body: boolean;
+ from: boolean;
+ fromName: boolean;
+ replyTo: boolean;
+ audience: boolean;
+}
+
+export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: CampaignSelectionDialogProps) {
+ const [page, setPage] = useState(1);
+ const [statusFilter, setStatusFilter] = useState('ALL');
+ const [step, setStep] = useState<'select' | 'configure'>('select');
+ const [selectedCampaign, setSelectedCampaign] = useState(null);
+ const [selectedFields, setSelectedFields] = useState({
+ subject: true,
+ body: true,
+ from: true,
+ fromName: true,
+ replyTo: true,
+ audience: true,
+ });
+
+ const {data, isLoading} = useSWR(
+ open
+ ? `/campaigns?page=${page}&pageSize=10${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`
+ : null,
+ {revalidateOnFocus: false},
+ );
+
+ const handleCampaignClick = (campaign: Campaign) => {
+ setSelectedCampaign(campaign);
+ setStep('configure');
+ };
+
+ const handleBack = () => {
+ setStep('select');
+ setSelectedCampaign(null);
+ };
+
+ const handleConfirm = () => {
+ if (selectedCampaign) {
+ onSelectCampaign(selectedCampaign, selectedFields);
+ onOpenChange(false);
+ // Reset state
+ setPage(1);
+ setStatusFilter('ALL');
+ setStep('select');
+ setSelectedCampaign(null);
+ setSelectedFields({
+ subject: true,
+ body: true,
+ from: true,
+ fromName: true,
+ replyTo: true,
+ audience: true,
+ });
+ }
+ };
+
+ const toggleField = (field: keyof SelectedFields) => {
+ setSelectedFields(prev => ({...prev, [field]: !prev[field]}));
+ };
+
+ const getStatusBadge = (status: CampaignStatus) => {
+ const variants: Record<
+ CampaignStatus,
+ {variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className?: string}
+ > = {
+ DRAFT: {variant: 'secondary', label: 'Draft', className: 'bg-neutral-100 text-neutral-700'},
+ SCHEDULED: {variant: 'default', label: 'Scheduled', className: 'bg-blue-100 text-blue-700'},
+ SENDING: {variant: 'default', label: 'Sending', className: 'bg-purple-100 text-purple-700'},
+ SENT: {variant: 'default', label: 'Sent', className: 'bg-green-100 text-green-700'},
+ CANCELLED: {variant: 'destructive', label: 'Cancelled', className: 'bg-red-100 text-red-700'},
+ };
+
+ const config = variants[status];
+ return (
+
+ {config.label}
+
+ );
+ };
+
+ const getAudienceLabel = (campaign: Campaign) => {
+ if (campaign.audienceType === 'ALL') {
+ return 'All Contacts';
+ } else if (campaign.audienceType === 'SEGMENT') {
+ return `Segment (${campaign.totalRecipients.toLocaleString()} contacts)`;
+ } else {
+ return `Filtered (${campaign.totalRecipients.toLocaleString()} contacts)`;
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/TemplateSelectionDialog.tsx b/apps/web/src/components/TemplateSelectionDialog.tsx
new file mode 100644
index 0000000..cc2b08e
--- /dev/null
+++ b/apps/web/src/components/TemplateSelectionDialog.tsx
@@ -0,0 +1,427 @@
+import {
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Checkbox,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ Input,
+ Label,
+} from '@plunk/ui';
+import type {Template} from '@plunk/db';
+import {network} from '../lib/network';
+import {ArrowLeft, FileText, Search} from 'lucide-react';
+import {useState} from 'react';
+import useSWR from 'swr';
+
+interface PaginatedTemplates {
+ templates: Template[];
+ total: number;
+ page: number;
+ pageSize: number;
+ totalPages: number;
+}
+
+interface TemplateSelectionDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSelectTemplate: (template: Template, selectedFields: SelectedFields) => void;
+}
+
+interface SelectedFields {
+ subject: boolean;
+ body: boolean;
+ from: boolean;
+ fromName: boolean;
+ replyTo: boolean;
+}
+
+export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}: TemplateSelectionDialogProps) {
+ const [search, setSearch] = useState('');
+ const [searchInput, setSearchInput] = useState('');
+ const [page, setPage] = useState(1);
+ const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING'>('ALL');
+ const [step, setStep] = useState<'select' | 'configure'>('select');
+ const [selectedTemplate, setSelectedTemplate] = useState(null);
+ const [selectedFields, setSelectedFields] = useState({
+ subject: true,
+ body: true,
+ from: true,
+ fromName: true,
+ replyTo: true,
+ });
+
+ const {data, isLoading} = useSWR(
+ open
+ ? `/templates?page=${page}&pageSize=10${search ? `&search=${search}` : ''}${typeFilter !== 'ALL' ? `&type=${typeFilter}` : ''}`
+ : null,
+ {revalidateOnFocus: false},
+ );
+
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ setSearch(searchInput);
+ setPage(1);
+ };
+
+ const handleTemplateClick = (template: Template) => {
+ setSelectedTemplate(template);
+ setStep('configure');
+ };
+
+ const handleBack = () => {
+ setStep('select');
+ setSelectedTemplate(null);
+ };
+
+ const handleConfirm = () => {
+ if (selectedTemplate) {
+ onSelectTemplate(selectedTemplate, selectedFields);
+ onOpenChange(false);
+ // Reset state
+ setSearch('');
+ setSearchInput('');
+ setPage(1);
+ setTypeFilter('ALL');
+ setStep('select');
+ setSelectedTemplate(null);
+ setSelectedFields({
+ subject: true,
+ body: true,
+ from: true,
+ fromName: true,
+ replyTo: true,
+ });
+ }
+ };
+
+ const toggleField = (field: keyof SelectedFields) => {
+ setSelectedFields(prev => ({...prev, [field]: !prev[field]}));
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/pages/campaigns/[id].tsx b/apps/web/src/pages/campaigns/[id].tsx
index c5d0215..b466f86 100644
--- a/apps/web/src/pages/campaigns/[id].tsx
+++ b/apps/web/src/pages/campaigns/[id].tsx
@@ -391,18 +391,33 @@ export default function CampaignDetailsPage() {
-
- setIsTestEmailDialogOpen(true)}>
-
- Send Test Email
+
+ setIsTestEmailDialogOpen(true)} className="py-3 cursor-pointer">
+
+
+
+ Send Test Email
+ Preview in your inbox before sending
+
+
- setShowSendDialog(true)}>
-
- Send Now
+ setShowSendDialog(true)} className="py-3 cursor-pointer">
+
+
+
+ Send Now
+ Send immediately to all recipients
+
+
- setIsScheduleDialogOpen(true)}>
-
- Schedule for Later
+ setIsScheduleDialogOpen(true)} className="py-3 cursor-pointer">
+
+
+
+ Schedule for Later
+ Choose a specific date and time
+
+
diff --git a/apps/web/src/pages/campaigns/create.tsx b/apps/web/src/pages/campaigns/create.tsx
index c3aeef8..8b8ba4e 100644
--- a/apps/web/src/pages/campaigns/create.tsx
+++ b/apps/web/src/pages/campaigns/create.tsx
@@ -25,7 +25,7 @@ import {EmailFormValidator} from '../../lib/validation';
import {ArrowLeft, Save, Users} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
-import {useState} from 'react';
+import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
@@ -43,9 +43,92 @@ export default function CreateCampaignPage() {
const [audienceType, setAudienceType] = useState(CampaignAudienceType.ALL);
const [segmentId, setSegmentId] = useState('');
const [saving, setSaving] = useState(false);
+ const [loadingTemplate, setLoadingTemplate] = useState(false);
const {data: segments} = useSWR('/segments', {revalidateOnFocus: false});
+ // Load template or campaign data if provided in query params
+ useEffect(() => {
+ const loadData = async () => {
+ const {
+ templateId,
+ campaignId,
+ name: queryName,
+ subject: querySubject,
+ from: queryFrom,
+ fromName: queryFromName,
+ replyTo: queryReplyTo,
+ audienceType: queryAudienceType,
+ segmentId: querySegmentId,
+ } = router.query;
+
+ // Handle template loading
+ if (templateId && typeof templateId === 'string') {
+ setLoadingTemplate(true);
+ try {
+ // Fetch the full template to get the body content
+ const template = await network.fetch<{data: {body: string}}>('GET', `/templates/${templateId}`);
+
+ // Pre-fill form with template data
+ if (queryName && typeof queryName === 'string') setName(queryName);
+ if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
+ if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
+ if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
+ if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
+ setBody(template.data.body);
+
+ toast.success('Template loaded successfully');
+ } catch (error) {
+ toast.error('Failed to load template');
+ } finally {
+ setLoadingTemplate(false);
+ }
+ }
+ // Handle campaign loading
+ else if (campaignId && typeof campaignId === 'string') {
+ setLoadingTemplate(true);
+ try {
+ // Fetch the full campaign to get the body content
+ const campaign = await network.fetch<{data: {body: string}}>('GET', `/campaigns/${campaignId}`);
+
+ // Pre-fill form with campaign data
+ if (queryName && typeof queryName === 'string') setName(queryName);
+ if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
+ if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
+ if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
+ if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
+ if (queryAudienceType && typeof queryAudienceType === 'string') {
+ setAudienceType(queryAudienceType as CampaignAudienceType);
+ }
+ if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId);
+ setBody(campaign.data.body);
+
+ toast.success('Campaign loaded successfully');
+ } catch (error) {
+ toast.error('Failed to load campaign');
+ } finally {
+ setLoadingTemplate(false);
+ }
+ }
+ // Handle query params without template/campaign ID (direct field values)
+ else {
+ if (queryName && typeof queryName === 'string') setName(queryName);
+ if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
+ if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
+ if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
+ if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
+ if (queryAudienceType && typeof queryAudienceType === 'string') {
+ setAudienceType(queryAudienceType as CampaignAudienceType);
+ }
+ if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId);
+ }
+ };
+
+ if (router.isReady) {
+ void loadData();
+ }
+ }, [router.isReady, router.query]);
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -94,6 +177,26 @@ export default function CreateCampaignPage() {
<>
+ {loadingTemplate && (
+
+
+
+
Loading template...
+
+
+ )}
{/* Header */}
diff --git a/apps/web/src/pages/campaigns/index.tsx b/apps/web/src/pages/campaigns/index.tsx
index b4fee82..5183364 100644
--- a/apps/web/src/pages/campaigns/index.tsx
+++ b/apps/web/src/pages/campaigns/index.tsx
@@ -7,19 +7,26 @@ import {
CardHeader,
CardTitle,
ConfirmDialog,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
-import type {Campaign} from '@plunk/db';
+import type {Campaign, Template} from '@plunk/db';
import {CampaignStatus} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
+import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog';
+import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
import {network} from '../../lib/network';
-import {Calendar, Copy, Info, Mail, Plus, Trash2, Users} from 'lucide-react';
+import {Calendar, ChevronDown, Copy, FileText, Info, Mail, Plus, RefreshCw, Trash2, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
+import {useRouter} from 'next/router';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
@@ -33,12 +40,15 @@ interface PaginatedCampaigns {
}
export default function CampaignsPage() {
+ const router = useRouter();
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState
('ALL');
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [campaignToCancel, setCampaignToCancel] = useState(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [campaignToDelete, setCampaignToDelete] = useState(null);
+ const [showTemplateDialog, setShowTemplateDialog] = useState(false);
+ const [showCampaignDialog, setShowCampaignDialog] = useState(false);
const {data, mutate, isLoading} = useSWR(
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
@@ -103,6 +113,93 @@ export default function CampaignsPage() {
}
};
+ const handleSelectTemplate = (
+ template: Template,
+ selectedFields: {
+ subject: boolean;
+ body: boolean;
+ from: boolean;
+ fromName: boolean;
+ replyTo: boolean;
+ },
+ ) => {
+ // Navigate to create page with template data as query params
+ const query: Record = {
+ name: `${template.name}`,
+ };
+
+ // Only include templateId if body is selected (needed to fetch body content)
+ if (selectedFields.body) {
+ query.templateId = template.id;
+ }
+
+ // Add selected fields to query params
+ if (selectedFields.subject) {
+ query.subject = template.subject;
+ }
+ if (selectedFields.from) {
+ query.from = template.from;
+ }
+ if (selectedFields.fromName && template.fromName) {
+ query.fromName = template.fromName;
+ }
+ if (selectedFields.replyTo && template.replyTo) {
+ query.replyTo = template.replyTo;
+ }
+
+ void router.push({
+ pathname: '/campaigns/create',
+ query,
+ });
+ };
+
+ const handleSelectCampaign = (
+ campaign: Campaign,
+ selectedFields: {
+ subject: boolean;
+ body: boolean;
+ from: boolean;
+ fromName: boolean;
+ replyTo: boolean;
+ audience: boolean;
+ },
+ ) => {
+ // Navigate to create page with campaign data as query params
+ const query: Record = {
+ name: `${campaign.name}`,
+ };
+
+ // Only include campaignId if body is selected (needed to fetch body content)
+ if (selectedFields.body) {
+ query.campaignId = campaign.id;
+ }
+
+ // Add selected fields to query params
+ if (selectedFields.subject) {
+ query.subject = campaign.subject;
+ }
+ if (selectedFields.from) {
+ query.from = campaign.from;
+ }
+ if (selectedFields.fromName && campaign.fromName) {
+ query.fromName = campaign.fromName;
+ }
+ if (selectedFields.replyTo && campaign.replyTo) {
+ query.replyTo = campaign.replyTo;
+ }
+ if (selectedFields.audience) {
+ query.audienceType = campaign.audienceType;
+ if (campaign.segmentId) {
+ query.segmentId = campaign.segmentId;
+ }
+ }
+
+ void router.push({
+ pathname: '/campaigns/create',
+ query,
+ });
+ };
+
return (
<>
@@ -116,13 +213,51 @@ export default function CampaignsPage() {
Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''}
-
-
-
+
+
+
+
+
+
+
+
+
+ Empty Campaign
+
+ Start from scratch with a blank canvas
+
+
+
+
+ setShowTemplateDialog(true)} className="py-3 cursor-pointer">
+
+
+
+ From Template
+
+ Use an existing template as a starting point
+
+
+
+
+ setShowCampaignDialog(true)} className="py-3 cursor-pointer">
+
+
+
+ From Previous Campaign
+
+ Copy content and settings from an existing campaign
+
+
+
+
+
+
{/* Filters */}
@@ -171,12 +306,60 @@ export default function CampaignsPage() {
? 'Try adjusting your filters or create a new campaign.'
: 'Create your first campaign to send emails to your contacts.'}
-
-
-
+ {statusFilter === 'ALL' && (
+
+
+
+
+
+
+
+
+
+ Empty Campaign
+
+ Start from scratch with a blank canvas
+
+
+
+
+ setShowTemplateDialog(true)} className="py-3 cursor-pointer">
+
+
+
+ From Template
+
+ Use an existing template as a starting point
+
+
+
+
+ setShowCampaignDialog(true)} className="py-3 cursor-pointer">
+
+
+
+ From Previous Campaign
+
+ Copy content and settings from an existing campaign
+
+
+
+
+
+
+ )}
+ {statusFilter !== 'ALL' && (
+
+
+
+ )}
@@ -229,9 +412,7 @@ export default function CampaignsPage() {
{campaign.totalRecipients > 0 && campaign.status !== 'DRAFT' && (
{deliveryProgress.toFixed(0)}% sent
)}
- {campaign.status === 'DRAFT' && (
- Estimated
- )}
+ {campaign.status === 'DRAFT' && Estimated
}
{/* Open Rate */}
@@ -367,6 +548,18 @@ export default function CampaignsPage() {
confirmText="Delete Campaign"
variant="destructive"
/>
+
+
+
+
>
);