/* 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('/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>({}); 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('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('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('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 {config.label}; }; if (isLoading) { return (

Loading campaign...

); } if (!campaign) { return (

Campaign not found

); } 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 (
{/* Header */}

{c.name}

Draft

Make changes to your campaign before sending

{!hasChanges && !isSubmitting && ( All changes saved )} {hasChanges && !isSubmitting && ( Unsaved changes )}
setIsTestEmailDialogOpen(true)} className="py-3 cursor-pointer">
Send Test Email Preview in your inbox before sending
setShowSendDialog(true)} className="py-3 cursor-pointer">
Send Now Send immediately to all recipients
setIsScheduleDialogOpen(true)} className="py-3 cursor-pointer">
Schedule for Later Choose a specific date and time
{/* Campaign Settings - Horizontal Layout */}
{/* Campaign Settings */} Campaign Settings Basic information about your campaign
setEditedCampaign({...editedCampaign, name: e.target.value})} required placeholder="Spring Sale Campaign" />
setEditedCampaign({...editedCampaign, description: e.target.value})} placeholder="Optional description for internal use" />
{([ {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}) => ( ))}
{(editedCampaign.type ?? c.type) === TemplateType.HEADLESS && !detectUnsubscribeSignal(editedCampaign.body ?? c.body) && (

No unsubscribe link detected

You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.

{'{{unsubscribeUrl}}'} {'{{manageUrl}}'}
)}
setEditedCampaign({...editedCampaign, subject: e.target.value})} required placeholder="Introducing our Spring Sale!" />
setEditedCampaign({...editedCampaign, from: value})} onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})} onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})} fromNamePlaceholder={activeProject?.name || 'Your Company'} showFromNameHelpText layout="vertical" />
{/* Audience Settings */} Audience Who will receive this campaign
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
{segments && segments.length === 0 && (

Create a segment first to use this option

)}
)} {editedCampaign.audienceType === CampaignAudienceType.FILTERED && (

Filtered audiences are configured with advanced filter conditions

)} {/* Show recipient count */} {draftRecipientCount > 0 && (
{draftRecipientCount.toLocaleString()} recipients

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.' }

)}
{/* Email Editor - Full Width */} Email Content Design your email using the visual editor or paste custom HTML { setEditedCampaign({...editedCampaign, body}); setHasChanges(true); }} /> {/* Test Email Dialog */} Send Test Email 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.

For security reasons, test emails can only be sent to project members.

Note: Variables will not be replaced in test emails. The email will be sent exactly as designed.

{/* Schedule Dialog */} Schedule Campaign Choose when you want this campaign to be sent (times shown in your local timezone: {getUserTimezone()} )
{/* Quick Presets */}
{/* Custom Date/Time */}
setScheduledDateTime(e.target.value)} min={new Date().toISOString().slice(0, 16)} className="mt-2" /> {scheduledDateTime && (

Scheduled for:

{formatFullDateTime(new Date(scheduledDateTime))}

UTC: {formatUTCDateTime(new Date(scheduledDateTime))}

)}
{/* Sticky Save Bar */}
); } // Render stats view for sent/scheduled campaigns return (
{/* Header */}

{c.name}

{getStatusBadge(c.status)}
{c.description &&

{c.description}

}
{/* Actions */} {(c.status === CampaignStatus.SCHEDULED || c.status === CampaignStatus.SENDING) && (
)}
{/* Sending Progress Banner */} {c.status === CampaignStatus.SENDING && s && (

Sending in progress

{s.sentCount.toLocaleString()} of {s.totalRecipients.toLocaleString()} emails sent

{((s.sentCount / s.totalRecipients) * 100).toFixed(0)}%

Complete

This page updates automatically every 5 seconds

)} {/* Stats Cards */} {s && (
Total Recipients
{s.totalRecipients.toLocaleString()}

{s.sentCount.toLocaleString()} sent ({((s.sentCount / s.totalRecipients) * 100).toFixed(1)}%)

Delivery Rate
{s.deliveryRate.toFixed(1)}%

{s.deliveredCount.toLocaleString()} delivered {s.bouncedCount > 0 && `, ${s.bouncedCount} bounced`}

Open Rate
{s.openRate.toFixed(1)}%

{s.openedCount.toLocaleString()} opened

Click Rate
{s.clickRate.toFixed(1)}%

{s.clickedCount.toLocaleString()} clicked

)} {/* Campaign Details in Grid */}
{/* Email Content - Takes 2 columns */} Email Preview How your email will appear to recipients {/* Email Header Info */}

Subject

{c.subject}

From

{c.from}

{c.replyTo && (

Reply-To

{c.replyTo}

)}
{/* Email Body Preview */}

Message Content

{/* Campaign Details */} Campaign Info Configuration and metadata {/* Audience */}

Audience

{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'}

{c.audienceType === CampaignAudienceType.SEGMENT && segments?.find(s => s.id === c.segmentId)?.memberCount && (

{segments.find(s => s.id === c.segmentId)!.memberCount.toLocaleString()} contacts

)}
{/* Scheduling Info */} {c.scheduledFor && (

Scheduled For

{formatFullDateTime(new Date(c.scheduledFor))}

UTC: {formatUTCDateTime(new Date(c.scheduledFor))}

{c.status === CampaignStatus.SCHEDULED && (

Recipient count will be recalculated at send time

)}
)} {/* Sent At */} {c.sentAt && (

Sent On

{formatFullDateTime(new Date(c.sentAt))}

)}
); }