feat: ability to create new campaigns based on templates or previous campaigns
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user