Files
plunk/apps/web/src/pages/campaigns/[id].tsx
T

1086 lines
46 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectItemWithDescription,
SelectTrigger,
SelectValue,
StickySaveBar,
} from '@plunk/ui';
import type {Campaign, Segment} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
import {CampaignSchemas, detectUnsubscribeSignal} from '@plunk/shared';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor';
import {network} from '../../lib/network';
import {formatFullDateTime, formatUTCDateTime, getUserTimezone, schedulePresets} from '../../lib/dateUtils';
import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
import {
ArrowLeft,
Calendar,
ChevronDown,
Mail,
MousePointer,
Save,
Send,
TestTube,
Trash2,
TrendingUp,
TriangleAlert,
Users,
XCircle,
} from 'lucide-react';
import DOMPurify from 'dompurify';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
interface CampaignStats {
totalRecipients: number;
sentCount: number;
deliveredCount: number;
openedCount: number;
clickedCount: number;
bouncedCount: number;
openRate: number;
clickRate: number;
bounceRate: number;
deliveryRate: number;
}
export default function CampaignDetailsPage() {
const router = useRouter();
const {id} = router.query;
const {activeProject} = useActiveProject();
const {
data: campaign,
mutate,
isLoading,
} = useSWR<{data: Campaign}>(id ? `/campaigns/${id}` : null, {revalidateOnFocus: false});
const {data: stats} = useSWR<{data: CampaignStats}>(
id && campaign?.data.status !== CampaignStatus.DRAFT ? `/campaigns/${id}/stats` : null,
{
revalidateOnFocus: false,
refreshInterval: campaign?.data.status === CampaignStatus.SENDING ? 15000 : 0, // Refresh every 15s while sending
},
);
// Fetch segments for audience selection
const {data: segments} = useSWR<Segment[]>('/segments', {
revalidateOnFocus: false,
});
// Fetch project members for test email
const {data: projectMembers} = useSWR<{data: Array<{userId: string; email: string; role: string}>}>(
id && campaign?.data.projectId ? `/projects/${campaign.data.projectId}/members` : null,
{revalidateOnFocus: false},
);
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false);
const [scheduledDateTime, setScheduledDateTime] = useState('');
const [isTestEmailDialogOpen, setIsTestEmailDialogOpen] = useState(false);
const [testEmailAddress, setTestEmailAddress] = useState('');
const [sendingTestEmail, setSendingTestEmail] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [showSendDialog, setShowSendDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Automatically initialize edit fields when campaign is loaded and is a draft
const isEditMode = campaign?.data.status === CampaignStatus.DRAFT;
const handleCancel = async () => {
try {
await network.fetch('POST', `/campaigns/${id}/cancel`);
toast.success('Campaign cancelled successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to cancel campaign');
}
};
const handleDelete = async () => {
try {
await network.fetch('DELETE', `/campaigns/${id}`);
toast.success('Campaign deleted successfully');
void router.push('/campaigns');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete campaign');
}
};
const handleSend = async () => {
try {
await network.fetch<void>('POST', `/campaigns/${id}/send`);
toast.success('Campaign is being sent!');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send campaign');
}
};
const handleSchedule = async () => {
if (!scheduledDateTime) {
toast.error('Please select a date and time');
return;
}
// Parse the datetime-local value as local time, then convert to UTC
const scheduledDate = new Date(scheduledDateTime);
const now = new Date();
if (scheduledDate.getTime() <= now.getTime()) {
toast.error('Scheduled time must be in the future');
return;
}
try {
// Send as ISO string (UTC)
await network.fetch<void, typeof CampaignSchemas.schedule>('POST', `/campaigns/${id}/send`, {
scheduledFor: scheduledDate.toISOString(),
});
// Show confirmation with user's local time
const localTimeString = formatFullDateTime(scheduledDate);
toast.success(`Campaign scheduled for ${localTimeString}`);
setIsScheduleDialogOpen(false);
setScheduledDateTime('');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign');
}
};
const handleSendTestEmail = async () => {
if (!testEmailAddress) {
toast.error('Please select a project member');
return;
}
setSendingTestEmail(true);
try {
await network.fetch<{success: boolean; message: string}>('POST', `/campaigns/${id}/test`, {
email: testEmailAddress,
} as any);
toast.success(`Test email sent to ${testEmailAddress}`);
setIsTestEmailDialogOpen(false);
setTestEmailAddress('');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send test email');
} finally {
setSendingTestEmail(false);
}
};
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const handleSave = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
setIsSubmitting(true);
try {
await network.fetch<Campaign, typeof CampaignSchemas.update>('PUT', `/campaigns/${id}`, {
name: editedCampaign.name,
description: editedCampaign.description || undefined,
subject: editedCampaign.subject,
body: editedCampaign.body,
from: editedCampaign.from,
fromName: editedCampaign.fromName || null,
replyTo: editedCampaign.replyTo || null,
type: editedCampaign.type,
audienceType: editedCampaign.audienceType,
segmentId: editedCampaign.segmentId || undefined,
});
// Silent save - no toast notification
setHasChanges(false);
// Refetch and re-sync the edited campaign with fresh data
const updated = await mutate();
if (updated?.data) {
setEditedCampaign({
name: updated.data.name,
description: updated.data.description || '',
subject: updated.data.subject,
body: updated.data.body,
from: updated.data.from,
fromName: updated.data.fromName || '',
replyTo: updated.data.replyTo || '',
type: updated.data.type,
audienceType: updated.data.audienceType,
segmentId: updated.data.segmentId || undefined,
});
}
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update campaign');
} finally {
setIsSubmitting(false);
}
};
// Initialize edit fields when campaign loads and is a draft
useEffect(() => {
if (campaign?.data && isEditMode && Object.keys(editedCampaign).length === 0) {
setEditedCampaign({
name: campaign.data.name,
description: campaign.data.description || '',
subject: campaign.data.subject,
body: campaign.data.body,
from: campaign.data.from,
fromName: campaign.data.fromName || '',
replyTo: campaign.data.replyTo || '',
type: campaign.data.type,
audienceType: campaign.data.audienceType,
segmentId: campaign.data.segmentId || undefined,
});
// Reset hasChanges when loading fresh data
setHasChanges(false);
}
}, [campaign, isEditMode, editedCampaign]);
// Track changes
useEffect(() => {
if (!campaign?.data || Object.keys(editedCampaign).length === 0) return;
const changed =
editedCampaign.name !== campaign.data.name ||
(editedCampaign.description || '') !== (campaign.data.description || '') ||
editedCampaign.subject !== campaign.data.subject ||
editedCampaign.body !== campaign.data.body ||
editedCampaign.from !== campaign.data.from ||
(editedCampaign.fromName || '') !== (campaign.data.fromName || '') ||
(editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') ||
editedCampaign.type !== campaign.data.type ||
editedCampaign.audienceType !== campaign.data.audienceType ||
(editedCampaign.segmentId || null) !== (campaign.data.segmentId || null);
setHasChanges(changed);
}, [editedCampaign, campaign]);
// Warn before leaving page with unsaved changes (only in edit mode)
useChangeTracking(hasChanges, isEditMode);
const getStatusBadge = (status: CampaignStatus) => {
const variants: Record<
CampaignStatus,
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string}
> = {
DRAFT: {variant: 'secondary', label: 'Draft'},
SCHEDULED: {variant: 'default', label: 'Scheduled'},
SENDING: {variant: 'default', label: 'Sending'},
SENT: {variant: 'default', label: 'Sent'},
CANCELLED: {variant: 'destructive', label: 'Cancelled'},
};
const config = variants[status];
return <Badge variant={config.variant}>{config.label}</Badge>;
};
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="h-8 w-8 animate-spin mx-auto border-4 border-neutral-200 border-t-neutral-900 rounded-full" />
<p className="mt-2 text-sm text-neutral-500">Loading campaign...</p>
</div>
</div>
</DashboardLayout>
);
}
if (!campaign) {
return (
<DashboardLayout>
<Card>
<CardContent className="py-12 text-center">
<p className="text-neutral-500">Campaign not found</p>
</CardContent>
</Card>
</DashboardLayout>
);
}
const c = campaign.data;
const s = stats?.data;
// Get recipient count for draft campaigns from the campaign's totalRecipients field
// The backend calculates this for all audience types when the campaign is created/updated
const draftRecipientCount = isEditMode && campaign?.data ? campaign.data.totalRecipients : 0;
// Render edit form for drafts
if (isEditMode) {
return (
<DashboardLayout>
<form onSubmit={handleSave} className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
{/* Header */}
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/campaigns">
<Button type="button" variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 truncate">{c.name}</h1>
<Badge variant="secondary">Draft</Badge>
</div>
<p className="text-neutral-500 mt-1 text-sm sm:text-base">
Make changes to your campaign before sending
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex-1">
{!hasChanges && !isSubmitting && (
<span className="text-xs sm:text-sm text-neutral-500">All changes saved</span>
)}
{hasChanges && !isSubmitting && (
<span className="text-xs sm:text-sm text-amber-600">Unsaved changes</span>
)}
</div>
<div className="flex gap-2">
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
className="flex-1 sm:flex-none"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Delete</span>
</Button>
<Button
type="submit"
disabled={!hasChanges || isSubmitting}
variant="outline"
className="flex-1 sm:flex-none"
>
<Save className="h-4 w-4" />
<span className="hidden sm:inline">{isSubmitting ? 'Saving...' : 'Save'}</span>
<span className="sm:hidden">Save</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" className="flex-1 sm:flex-none">
<Send className="h-4 w-4" />
<span className="hidden sm:inline">Send</span>
<ChevronDown className="h-4 w-4 sm:ml-1" />
</Button>
</DropdownMenuTrigger>
<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)} 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)} 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>
</div>
</div>
</div>
{/* Campaign Settings - Horizontal Layout */}
<div className="grid gap-6 md:grid-cols-2">
{/* Campaign Settings */}
<Card>
<CardHeader>
<CardTitle>Campaign Settings</CardTitle>
<CardDescription>Basic information about your campaign</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Campaign Name *</Label>
<Input
id="name"
type="text"
value={editedCampaign.name || ''}
onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})}
required
placeholder="Spring Sale Campaign"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={editedCampaign.description || ''}
onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})}
placeholder="Optional description for internal use"
/>
</div>
<div>
<Label>Campaign Type</Label>
<div className="flex flex-col gap-2 mt-2">
{([
{value: TemplateType.MARKETING, label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
{value: TemplateType.TRANSACTIONAL, label: 'Transactional', description: 'All contacts, no subscription check or footer'},
{value: TemplateType.HEADLESS, label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
] as const).map(({value, label, description}) => (
<button
key={value}
type="button"
onClick={() => setEditedCampaign({...editedCampaign, type: value})}
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
(editedCampaign.type ?? c.type) === value
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
<span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
</button>
))}
</div>
{(editedCampaign.type ?? c.type) === TemplateType.HEADLESS &&
!detectUnsubscribeSignal(editedCampaign.body ?? c.body) && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
</div>
<div className="px-3 py-2.5 space-y-2">
<p className="text-xs text-amber-800 leading-relaxed">
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
</p>
<div className="flex flex-wrap gap-1.5">
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
{'{{unsubscribeUrl}}'}
</code>
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
{'{{manageUrl}}'}
</code>
</div>
</div>
</div>
)}
</div>
<div>
<Label htmlFor="subject">Subject Line *</Label>
<Input
id="subject"
type="text"
value={editedCampaign.subject || ''}
onChange={e => setEditedCampaign({...editedCampaign, subject: e.target.value})}
required
placeholder="Introducing our Spring Sale!"
/>
</div>
<EmailSettings
from={editedCampaign.from || ''}
fromName={editedCampaign.fromName || ''}
replyTo={editedCampaign.replyTo || ''}
onFromChange={value => setEditedCampaign({...editedCampaign, from: value})}
onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})}
onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})}
fromNamePlaceholder={activeProject?.name || 'Your Company'}
showFromNameHelpText
layout="vertical"
/>
</CardContent>
</Card>
{/* Audience Settings */}
<Card>
<CardHeader>
<CardTitle>Audience</CardTitle>
<CardDescription>Who will receive this campaign</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="audienceType">Audience Type *</Label>
<Select
value={editedCampaign.audienceType ?? c.audienceType}
onValueChange={(value: CampaignAudienceType) => {
setEditedCampaign({
...editedCampaign,
audienceType: value,
// Clear segmentId if changing away from SEGMENT
segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined,
});
}}
>
<SelectTrigger id="audienceType">
<SelectValue placeholder="Select audience type" />
</SelectTrigger>
<SelectContent>
<SelectItemWithDescription
value={CampaignAudienceType.ALL}
title={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
description={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
/>
<SelectItemWithDescription
value={CampaignAudienceType.SEGMENT}
title="Segment"
description="Target a defined group of contacts"
/>
</SelectContent>
</Select>
</div>
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
<div>
<Label htmlFor="segment">Select Segment *</Label>
<Select
value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
onValueChange={(value: string) => {
setEditedCampaign({
...editedCampaign,
segmentId: value,
});
}}
disabled={!segments || segments.length === 0}
>
<SelectTrigger id="segment">
<SelectValue
placeholder={segments && segments.length > 0 ? 'Choose a segment' : 'No segments available'}
/>
</SelectTrigger>
<SelectContent>
{segments &&
segments.length > 0 &&
segments.map(segment => (
<SelectItemWithDescription
key={segment.id}
value={segment.id}
title={segment.name}
description={`${segment.memberCount.toLocaleString()} contacts`}
/>
))}
</SelectContent>
</Select>
{segments && segments.length === 0 && (
<p className="text-xs text-neutral-500 mt-1">Create a segment first to use this option</p>
)}
</div>
)}
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
<p className="text-sm text-neutral-500">
Filtered audiences are configured with advanced filter conditions
</p>
)}
{/* Show recipient count */}
{draftRecipientCount > 0 && (
<div className="mt-4 p-3 bg-neutral-50 border border-neutral-200 rounded-lg space-y-1.5">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-900">
{draftRecipientCount.toLocaleString()} recipients
</span>
</div>
<p className="text-xs text-neutral-500 pl-6">
Recalculated at send time. Final count may differ if contacts{' '}
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'are added or removed, or segment membership changes.'
: 'subscribe, unsubscribe, or segment membership changes.'
}
</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Email Editor - Full Width */}
<Card className="overflow-visible">
<CardHeader>
<CardTitle>Email Content</CardTitle>
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor
value={editedCampaign.body || ''}
onChange={body => {
setEditedCampaign({...editedCampaign, body});
setHasChanges(true);
}}
/>
</CardContent>
</Card>
{/* Test Email Dialog */}
<Dialog open={isTestEmailDialogOpen} onOpenChange={setIsTestEmailDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Send Test Email</DialogTitle>
<DialogDescription>
Send a test version of this campaign to a project member to verify how it looks. The test email will
be prefixed with [TEST] in the subject line.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="testEmail">Project Member</Label>
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
<SelectTrigger id="testEmail" className="mt-2">
<SelectValue placeholder="Select a project member..." />
</SelectTrigger>
<SelectContent>
{projectMembers?.data.map(member => (
<SelectItem key={member.userId} value={member.email}>
{member.email}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-neutral-500 mt-2">
For security reasons, test emails can only be sent to project members.
</p>
<p className="text-xs text-neutral-500 mt-1">
Note: Variables will not be replaced in test emails. The email will be sent exactly as designed.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setIsTestEmailDialogOpen(false);
setTestEmailAddress('');
}}
>
Cancel
</Button>
<Button type="button" onClick={handleSendTestEmail} disabled={sendingTestEmail || !testEmailAddress}>
{sendingTestEmail ? 'Sending...' : 'Send Test Email'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Schedule Dialog */}
<Dialog open={isScheduleDialogOpen} onOpenChange={setIsScheduleDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Schedule Campaign</DialogTitle>
<DialogDescription>
Choose when you want this campaign to be sent (times shown in your local timezone: {getUserTimezone()}
)
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Quick Presets */}
<div>
<Label>Quick Schedule</Label>
<div className="grid grid-cols-2 gap-2 mt-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.inOneHour())}
>
In 1 hour
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.inThreeHours())}
>
In 3 hours
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt9AM())}
>
Tomorrow at 9 AM
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt2PM())}
>
Tomorrow at 2 PM
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.nextMonday())}
>
Next Monday
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.inOneWeek())}
>
In 1 week
</Button>
</div>
</div>
{/* Custom Date/Time */}
<div>
<Label htmlFor="scheduledDateTime">Or choose a specific time</Label>
<Input
id="scheduledDateTime"
type="datetime-local"
value={scheduledDateTime}
onChange={e => setScheduledDateTime(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
className="mt-2"
/>
{scheduledDateTime && (
<div className="mt-2 p-3 bg-neutral-50 border border-neutral-200 rounded-lg">
<p className="text-xs font-medium text-neutral-500 mb-1">Scheduled for:</p>
<p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(scheduledDateTime))}</p>
<p className="text-xs text-neutral-500 mt-1">
UTC: {formatUTCDateTime(new Date(scheduledDateTime))}
</p>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setIsScheduleDialogOpen(false);
setScheduledDateTime('');
}}
>
Cancel
</Button>
<Button type="button" onClick={handleSchedule}>
Schedule Campaign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
{/* Sticky Save Bar */}
<StickySaveBar hasChanges={hasChanges} isSubmitting={isSubmitting} onSave={handleSave} />
<ConfirmDialog
open={showSendDialog}
onOpenChange={setShowSendDialog}
onConfirm={handleSend}
title="Send Campaign"
description="Are you sure you want to send this campaign now? This action cannot be undone."
confirmText="Send Now"
variant="default"
/>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Campaign"
description="Are you sure you want to delete this draft campaign? This action cannot be undone."
confirmText="Delete Campaign"
variant="destructive"
/>
</DashboardLayout>
);
}
// Render stats view for sent/scheduled campaigns
return (
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/campaigns">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3 mb-2">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 truncate">{c.name}</h1>
{getStatusBadge(c.status)}
</div>
{c.description && <p className="text-neutral-500 text-sm sm:text-base">{c.description}</p>}
</div>
</div>
{/* Actions */}
{(c.status === CampaignStatus.SCHEDULED || c.status === CampaignStatus.SENDING) && (
<div className="flex justify-end">
<Button variant="destructive" onClick={() => setShowCancelDialog(true)} className="w-full sm:w-auto">
<XCircle className="h-4 w-4" />
<span className="hidden sm:inline">Cancel Campaign</span>
<span className="sm:hidden">Cancel</span>
</Button>
</div>
)}
</div>
{/* Sending Progress Banner */}
{c.status === CampaignStatus.SENDING && s && (
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-neutral-900 text-lg">Sending in progress</h3>
<p className="text-sm text-neutral-500 mt-1">
{s.sentCount.toLocaleString()} of {s.totalRecipients.toLocaleString()} emails sent
</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-neutral-900">
{((s.sentCount / s.totalRecipients) * 100).toFixed(0)}%
</div>
<p className="text-xs text-neutral-500 mt-1">Complete</p>
</div>
</div>
<div className="w-full bg-neutral-100 rounded-full h-2">
<div
className="bg-neutral-900 h-2 rounded-full transition-all duration-500"
style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}}
/>
</div>
<p className="text-xs text-neutral-400">This page updates automatically every 5 seconds</p>
</div>
</CardContent>
</Card>
)}
{/* Stats Cards */}
{s && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-500">Total Recipients</CardTitle>
<Users className="h-4 w-4 text-neutral-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.totalRecipients.toLocaleString()}</div>
<p className="text-xs text-neutral-500 mt-2">
{s.sentCount.toLocaleString()} sent ({((s.sentCount / s.totalRecipients) * 100).toFixed(1)}%)
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-500">Delivery Rate</CardTitle>
<Mail className="h-4 w-4 text-neutral-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.deliveryRate.toFixed(1)}%</div>
<p className="text-xs text-neutral-500 mt-2">
{s.deliveredCount.toLocaleString()} delivered
{s.bouncedCount > 0 && `, ${s.bouncedCount} bounced`}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-500">Open Rate</CardTitle>
<TrendingUp className="h-4 w-4 text-neutral-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.openRate.toFixed(1)}%</div>
<p className="text-xs text-neutral-500 mt-2">{s.openedCount.toLocaleString()} opened</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-500">Click Rate</CardTitle>
<MousePointer className="h-4 w-4 text-neutral-400" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.clickRate.toFixed(1)}%</div>
<p className="text-xs text-neutral-500 mt-2">{s.clickedCount.toLocaleString()} clicked</p>
</CardContent>
</Card>
</div>
)}
{/* Campaign Details in Grid */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Email Content - Takes 2 columns */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Email Preview</CardTitle>
<CardDescription>How your email will appear to recipients</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Email Header Info */}
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-4 space-y-2">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-xs text-neutral-500 uppercase tracking-wide font-medium">Subject</p>
<p className="text-base font-semibold text-neutral-900 mt-1">{c.subject}</p>
</div>
</div>
<div className="flex gap-6 pt-2 border-t border-neutral-200">
<div>
<p className="text-xs text-neutral-500">From</p>
<p className="text-sm text-neutral-900 mt-0.5">{c.from}</p>
</div>
{c.replyTo && (
<div>
<p className="text-xs text-neutral-500">Reply-To</p>
<p className="text-sm text-neutral-900 mt-0.5">{c.replyTo}</p>
</div>
)}
</div>
</div>
{/* Email Body Preview */}
<div>
<p className="text-sm font-medium text-neutral-700 mb-3">Message Content</p>
<div className="border-2 border-neutral-200 rounded-lg overflow-hidden bg-white">
<div className="p-6 max-h-96 overflow-y-auto">
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(c.body)}} />
</div>
</div>
</div>
</CardContent>
</Card>
{/* Campaign Details */}
<Card>
<CardHeader>
<CardTitle>Campaign Info</CardTitle>
<CardDescription>Configuration and metadata</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Audience */}
<div className="pb-3 border-b border-neutral-100">
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Audience</p>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-neutral-400" />
<div>
<p className="text-sm font-medium text-neutral-900">
{c.audienceType === CampaignAudienceType.ALL && 'All Subscribed Contacts'}
{c.audienceType === CampaignAudienceType.SEGMENT &&
(segments?.find(s => s.id === c.segmentId)?.name || 'Selected Segment')}
{c.audienceType === CampaignAudienceType.FILTERED && 'Filtered Contacts'}
</p>
{c.audienceType === CampaignAudienceType.SEGMENT &&
segments?.find(s => s.id === c.segmentId)?.memberCount && (
<p className="text-xs text-neutral-500">
{segments.find(s => s.id === c.segmentId)!.memberCount.toLocaleString()} contacts
</p>
)}
</div>
</div>
</div>
{/* Scheduling Info */}
{c.scheduledFor && (
<div className="pb-3 border-b border-neutral-100">
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Scheduled For</p>
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-neutral-400 mt-0.5" />
<div className="space-y-2">
<div>
<p className="text-sm font-medium text-neutral-900">
{formatFullDateTime(new Date(c.scheduledFor))}
</p>
<p className="text-xs text-neutral-500 mt-1">
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
</p>
</div>
{c.status === CampaignStatus.SCHEDULED && (
<p className="text-xs text-neutral-500">Recipient count will be recalculated at send time</p>
)}
</div>
</div>
</div>
)}
{/* Sent At */}
{c.sentAt && (
<div className="pb-3 border-b border-neutral-100">
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent On</p>
<div className="flex items-center gap-2">
<Send className="h-4 w-4 text-neutral-400" />
<p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(c.sentAt))}</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
<ConfirmDialog
open={showCancelDialog}
onOpenChange={setShowCancelDialog}
onConfirm={handleCancel}
title="Cancel Campaign"
description="Are you sure you want to cancel this campaign?"
confirmText="Cancel Campaign"
variant="destructive"
/>
</DashboardLayout>
);
}