feat: ability to create new campaigns based on templates or previous campaigns

This commit is contained in:
Dries Augustyns
2025-12-10 21:04:51 +01:00
parent b9758149f5
commit 6b25bbe2f8
5 changed files with 1191 additions and 29 deletions
+25 -10
View File
@@ -391,18 +391,33 @@ export default function CampaignDetailsPage() {
<ChevronDown className="h-4 w-4 sm:ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)}>
<TestTube className="h-4 w-4 mr-2" />
Send Test Email
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<TestTube className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Send Test Email</span>
<span className="text-xs text-neutral-500 leading-snug">Preview in your inbox before sending</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowSendDialog(true)}>
<Send className="h-4 w-4 mr-2" />
Send Now
<DropdownMenuItem onClick={() => setShowSendDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<Send className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Send Now</span>
<span className="text-xs text-neutral-500 leading-snug">Send immediately to all recipients</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsScheduleDialogOpen(true)}>
<Calendar className="h-4 w-4 mr-2" />
Schedule for Later
<DropdownMenuItem onClick={() => setIsScheduleDialogOpen(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<Calendar className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Schedule for Later</span>
<span className="text-xs text-neutral-500 leading-snug">Choose a specific date and time</span>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+104 -1
View File
@@ -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>(CampaignAudienceType.ALL);
const [segmentId, setSegmentId] = useState('');
const [saving, setSaving] = useState(false);
const [loadingTemplate, setLoadingTemplate] = useState(false);
const {data: segments} = useSWR<Segment[]>('/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() {
<>
<NextSeo title="Create Campaign" />
<DashboardLayout>
{loadingTemplate && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900 mb-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="text-sm text-neutral-700">Loading template...</p>
</div>
</div>
)}
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3 sm:gap-4">
+211 -18
View File
@@ -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<string>('ALL');
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [campaignToDelete, setCampaignToDelete] = useState<string | null>(null);
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
const {data, mutate, isLoading} = useSWR<PaginatedCampaigns>(
`/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<string, string> = {
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<string, string> = {
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 (
<>
<NextSeo title="Campaigns" />
@@ -116,13 +213,51 @@ export default function CampaignsPage() {
Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''}
</p>
</div>
<Link href="/campaigns/create" className="w-full sm:w-auto">
<Button className="w-full sm:w-auto">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create Campaign</span>
<span className="sm:hidden">Create</span>
</Button>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="w-full sm:w-auto">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create Campaign</span>
<span className="sm:hidden">Create</span>
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuItem asChild className="py-3 cursor-pointer">
<Link href="/campaigns/create" className="flex items-start gap-3">
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Empty Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Start from scratch with a blank canvas
</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Template</span>
<span className="text-xs text-neutral-500 leading-snug">
Use an existing template as a starting point
</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Previous Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Copy content and settings from an existing campaign
</span>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 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.'}
</p>
<Link href="/campaigns/create">
<Button size="lg">
<Plus className="h-4 w-4" />
Create Your First Campaign
</Button>
</Link>
{statusFilter === 'ALL' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="lg">
<Plus className="h-4 w-4" />
Create Your First Campaign
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-80">
<DropdownMenuItem asChild className="py-3 cursor-pointer">
<Link href="/campaigns/create" className="flex items-start gap-3">
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Empty Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Start from scratch with a blank canvas
</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Template</span>
<span className="text-xs text-neutral-500 leading-snug">
Use an existing template as a starting point
</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Previous Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Copy content and settings from an existing campaign
</span>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{statusFilter !== 'ALL' && (
<Link href="/campaigns/create">
<Button size="lg">
<Plus className="h-4 w-4" />
Create Campaign
</Button>
</Link>
)}
</div>
</CardContent>
</Card>
@@ -229,9 +412,7 @@ export default function CampaignsPage() {
{campaign.totalRecipients > 0 && campaign.status !== 'DRAFT' && (
<p className="text-xs text-blue-700 mt-1">{deliveryProgress.toFixed(0)}% sent</p>
)}
{campaign.status === 'DRAFT' && (
<p className="text-xs text-blue-600 mt-1">Estimated</p>
)}
{campaign.status === 'DRAFT' && <p className="text-xs text-blue-600 mt-1">Estimated</p>}
</div>
{/* Open Rate */}
@@ -367,6 +548,18 @@ export default function CampaignsPage() {
confirmText="Delete Campaign"
variant="destructive"
/>
<TemplateSelectionDialog
open={showTemplateDialog}
onOpenChange={setShowTemplateDialog}
onSelectTemplate={handleSelectTemplate}
/>
<CampaignSelectionDialog
open={showCampaignDialog}
onOpenChange={setShowCampaignDialog}
onSelectCampaign={handleSelectCampaign}
/>
</DashboardLayout>
</>
);