feat: enhance campaign scheduling and audience settings UI for better clarity and usability

This commit is contained in:
Dries Augustyns
2026-05-10 09:52:25 +02:00
parent aaf5ac6530
commit 7658a59b5d
+337 -226
View File
@@ -42,6 +42,7 @@ import {
ArrowLeft, ArrowLeft,
Calendar, Calendar,
ChevronDown, ChevronDown,
Info,
Mail, Mail,
MousePointer, MousePointer,
Save, Save,
@@ -107,6 +108,7 @@ export default function CampaignDetailsPage() {
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({}); const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
const [scheduledDateTime, setScheduledDateTime] = useState(''); const [scheduledDateTime, setScheduledDateTime] = useState('');
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [testEmailAddress, setTestEmailAddress] = useState(''); const [testEmailAddress, setTestEmailAddress] = useState('');
type CampaignDialog = type CampaignDialog =
@@ -178,6 +180,7 @@ export default function CampaignDetailsPage() {
toast.success(`Campaign scheduled for ${localTimeString}`); toast.success(`Campaign scheduled for ${localTimeString}`);
setDialog({type: 'none'}); setDialog({type: 'none'});
setScheduledDateTime(''); setScheduledDateTime('');
setSelectedPreset(null);
void mutate(); void mutate();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign'); toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign');
@@ -436,41 +439,162 @@ export default function CampaignDetailsPage() {
</div> </div>
</div> </div>
{/* Campaign Settings - Horizontal Layout */} {/* Audience — surfaced first because Send lives in the header.
<div className="grid gap-6 md:grid-cols-2"> Users need to see who/how many before pressing Send. */}
{/* Campaign Settings */}
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<CardTitle>Campaign Settings</CardTitle> <div>
<CardDescription>Basic information about your campaign</CardDescription> <CardTitle>Audience</CardTitle>
<CardDescription>Who will receive this campaign when you send</CardDescription>
</div>
{draftRecipientCount > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 shrink-0">
<Users className="h-4 w-4 text-neutral-500" />
<span className="text-sm font-semibold text-neutral-900 tabular-nums">
{draftRecipientCount.toLocaleString()} {draftRecipientCount === 1 ? 'recipient' : 'recipients'}
</span>
</div>
)}
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div className="grid gap-4 md:grid-cols-2">
<Label htmlFor="name">Campaign Name *</Label> <div className="space-y-2">
<Label htmlFor="audienceType">
Audience Type <span className="text-red-500">*</span>
</Label>
<Select
value={editedCampaign.audienceType ?? c.audienceType}
onValueChange={(value: CampaignAudienceType) => {
setEditedCampaign({
...editedCampaign,
audienceType: value,
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="Specific Segment"
description="Target a defined group of contacts"
/>
</SelectContent>
</Select>
</div>
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
<div className="space-y-2">
<Label htmlFor="segment">
Select Segment <span className="text-red-500">*</span>
</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-sm text-neutral-500">
No segments found.{' '}
<Link href="/segments/new" className="underline">
Create one first
</Link>
</p>
)}
</div>
)}
</div>
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
<p className="text-sm text-neutral-500">
Filtered audiences are configured with advanced filter conditions
</p>
)}
{draftRecipientCount > 0 && (
<p className="text-xs text-neutral-500">
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>
)}
</CardContent>
</Card>
{/* Row 1: Basic Info + Campaign Type */}
<div className="grid gap-6 md:grid-cols-2">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>Name and describe your campaign</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">
Campaign Name <span className="text-red-500">*</span>
</Label>
<Input <Input
id="name" id="name"
type="text" placeholder="e.g., Spring Sale Announcement"
value={editedCampaign.name || ''} value={editedCampaign.name || ''}
onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})} onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})}
required required
placeholder="Spring Sale Campaign"
/> />
</div> </div>
<div> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<Input <Input
id="description" id="description"
type="text" placeholder="Internal notes about this campaign"
value={editedCampaign.description || ''} value={editedCampaign.description || ''}
onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})} onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})}
placeholder="Optional description for internal use"
/> />
</div> </div>
</CardContent>
</Card>
<div> {/* Campaign Type */}
<Label>Campaign Type</Label> <Card>
<div className="flex flex-col gap-2 mt-2"> <CardHeader>
<CardTitle>Campaign Type</CardTitle>
<CardDescription>Choose how this campaign should be treated</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
{([ {([
{value: TemplateType.MARKETING, label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'}, {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.TRANSACTIONAL, label: 'Transactional', description: 'All contacts, no subscription check or footer'},
@@ -513,20 +637,17 @@ export default function CampaignDetailsPage() {
</div> </div>
</div> </div>
)} )}
</CardContent>
</Card>
</div> </div>
<div> {/* Email Settings */}
<Label htmlFor="subject">Subject Line *</Label> <Card>
<Input <CardHeader>
id="subject" <CardTitle>Email Settings</CardTitle>
type="text" <CardDescription>Configure sender information and subject</CardDescription>
value={editedCampaign.subject || ''} </CardHeader>
onChange={e => setEditedCampaign({...editedCampaign, subject: e.target.value})} <CardContent className="space-y-4">
required
placeholder="Introducing our Spring Sale!"
/>
</div>
<EmailSettings <EmailSettings
from={editedCampaign.from || ''} from={editedCampaign.from || ''}
fromName={editedCampaign.fromName || ''} fromName={editedCampaign.fromName || ''}
@@ -535,119 +656,28 @@ export default function CampaignDetailsPage() {
onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})} onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})}
onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})} onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})}
fromNamePlaceholder={activeProject?.name || 'Your Company'} fromNamePlaceholder={activeProject?.name || 'Your Company'}
layout="vertical"
/> />
<div className="space-y-2">
<Label htmlFor="subject">
Email Subject <span className="text-red-500">*</span>
</Label>
<Input
id="subject"
placeholder="e.g., Introducing our Spring Sale!"
value={editedCampaign.subject || ''}
onChange={e => setEditedCampaign({...editedCampaign, subject: e.target.value})}
required
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Audience Settings */} {/* Email Content */}
<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"> <Card className="overflow-visible">
<CardHeader> <CardHeader>
<CardTitle>Email Content</CardTitle> <CardTitle>Email Content</CardTitle>
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription> <CardDescription>Design your email message</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<EmailEditor <EmailEditor
@@ -662,20 +692,19 @@ export default function CampaignDetailsPage() {
{/* Test Email Dialog */} {/* Test Email Dialog */}
<Dialog open={dialog.type === 'testEmail'} onOpenChange={open => !open && setDialog({type: 'none'})}> <Dialog open={dialog.type === 'testEmail'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Send Test Email</DialogTitle> <DialogTitle>Send a preview</DialogTitle>
<DialogDescription> <DialogDescription>
Send a test version of this campaign to a project member to verify how it looks. The test email will Get a copy of this campaign in your inbox before sending it for real.
be prefixed with [TEST] in the subject line.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4">
<div> <div className="space-y-2">
<Label htmlFor="testEmail">Project Member</Label> <Label htmlFor="testEmail">Send to</Label>
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}> <Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
<SelectTrigger id="testEmail" className="mt-2"> <SelectTrigger id="testEmail">
<SelectValue placeholder="Select a project member..." /> <SelectValue placeholder="Choose a teammate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{projectMembers?.data.map(member => ( {projectMembers?.data.map(member => (
@@ -685,14 +714,29 @@ export default function CampaignDetailsPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-neutral-500 mt-2"> </div>
For security reasons, test emails can only be sent to project members.
</p> {/* Preview of how the email will arrive */}
<p className="text-xs text-neutral-500 mt-1"> <div className="space-y-2">
Note: Variables will not be replaced in test emails. The email will be sent exactly as designed. <Label className="text-neutral-500">Will arrive as</Label>
</p> <div className="rounded-lg border border-neutral-200 bg-neutral-50 divide-y divide-neutral-200 text-sm">
<div className="grid grid-cols-[64px_1fr] gap-3 px-3 py-2.5">
<span className="text-neutral-500">From</span>
<span className="text-neutral-900 truncate">{editedCampaign.from || c.from}</span>
</div>
<div className="grid grid-cols-[64px_1fr] gap-3 px-3 py-2.5">
<span className="text-neutral-500">Subject</span>
<span className="text-neutral-900 truncate">
<span className="font-medium">[TEST]</span> {editedCampaign.subject || c.subject}
</span>
</div> </div>
</div> </div>
</div>
<p className="text-xs text-neutral-500 leading-relaxed">
Variables like {'{{firstName}}'} aren{"'"}t replaced in previews. You{"'"}ll see them as written.
</p>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -709,7 +753,8 @@ export default function CampaignDetailsPage() {
onClick={handleSendTestEmail} onClick={handleSendTestEmail}
disabled={(dialog.type === 'testEmail' && dialog.sending) || !testEmailAddress} disabled={(dialog.type === 'testEmail' && dialog.sending) || !testEmailAddress}
> >
{dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send Test Email'} <TestTube className="h-4 w-4" />
{dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send preview'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -717,92 +762,105 @@ export default function CampaignDetailsPage() {
{/* Schedule Dialog */} {/* Schedule Dialog */}
<Dialog open={dialog.type === 'schedule'} onOpenChange={open => !open && setDialog({type: 'none'})}> <Dialog open={dialog.type === 'schedule'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Schedule Campaign</DialogTitle> <DialogTitle>Schedule for later</DialogTitle>
<DialogDescription> <DialogDescription>
Choose when you want this campaign to be sent (times shown in your local timezone: {getUserTimezone()} Pick a time and Plunk will send it for you. Times shown in {getUserTimezone()}.
)
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4">
{/* Quick Presets */} <div className="space-y-5 py-2">
<div> {/* Quick presets */}
<Label>Quick Schedule</Label> <div className="space-y-2">
<div className="grid grid-cols-2 gap-2 mt-2"> <Label>Quick options</Label>
<Button <div className="grid grid-cols-2 gap-2">
{[
{key: 'in1h', label: 'In 1 hour', getValue: schedulePresets.inOneHour},
{key: 'in3h', label: 'In 3 hours', getValue: schedulePresets.inThreeHours},
{key: 'tom9', label: 'Tomorrow, 9 AM', getValue: schedulePresets.tomorrowAt9AM},
{key: 'tom2', label: 'Tomorrow, 2 PM', getValue: schedulePresets.tomorrowAt2PM},
{key: 'nextMon', label: 'Next Monday', getValue: schedulePresets.nextMonday},
{key: 'in1w', label: 'In 1 week', getValue: schedulePresets.inOneWeek},
].map(({key, label, getValue}) => {
const isActive = selectedPreset === key;
return (
<button
key={key}
type="button" type="button"
variant="outline" onClick={() => {
size="sm" setScheduledDateTime(getValue());
onClick={() => setScheduledDateTime(schedulePresets.inOneHour())} setSelectedPreset(key);
}}
className={`min-h-[40px] px-3 py-2 rounded-lg border text-sm text-left transition-colors ${
isActive
? 'border-neutral-900 bg-neutral-50 text-neutral-900 font-medium'
: 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:text-neutral-900'
}`}
> >
In 1 hour {label}
</Button> </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>
</div> </div>
{/* Custom Date/Time */} {/* Custom Date/Time */}
<div> <div className="space-y-2">
<Label htmlFor="scheduledDateTime">Or choose a specific time</Label> <Label htmlFor="scheduledDateTime">Or pick an exact time</Label>
<Input <Input
id="scheduledDateTime" id="scheduledDateTime"
type="datetime-local" type="datetime-local"
value={scheduledDateTime} value={scheduledDateTime}
onChange={e => setScheduledDateTime(e.target.value)} onChange={e => {
setScheduledDateTime(e.target.value);
setSelectedPreset(null);
}}
min={new Date().toISOString().slice(0, 16)} min={new Date().toISOString().slice(0, 16)}
className="mt-2"
/> />
</div>
{/* Confirmation preview — date + audience together */}
{scheduledDateTime && ( {scheduledDateTime && (
<div className="mt-2 p-3 bg-neutral-50 border border-neutral-200 rounded-lg"> <div className="rounded-lg border border-neutral-200 bg-neutral-50 divide-y divide-neutral-200">
<p className="text-xs font-medium text-neutral-500 mb-1">Scheduled for:</p> <div className="px-4 py-3">
<p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(scheduledDateTime))}</p> <div className="flex items-center gap-2 text-neutral-500">
<p className="text-xs text-neutral-500 mt-1"> <Calendar className="h-3.5 w-3.5" />
UTC: {formatUTCDateTime(new Date(scheduledDateTime))} <span className="text-xs font-medium uppercase tracking-wide">Sending on</span>
</div>
<p className="mt-1 text-base font-semibold text-neutral-900">
{formatFullDateTime(new Date(scheduledDateTime))}
</p>
</div>
{draftRecipientCount > 0 && (
<div className="px-4 py-3">
<div className="flex items-center gap-2 text-neutral-500">
<Users className="h-3.5 w-3.5" />
<span className="text-xs font-medium uppercase tracking-wide">To</span>
</div>
<p className="mt-1 text-sm text-neutral-900">
<span className="font-semibold tabular-nums">{draftRecipientCount.toLocaleString()}</span>
<span className="text-neutral-600">
{draftRecipientCount === 1 ? ' recipient in ' : ' recipients in '}
</span>
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.ALL &&
((editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'all contacts'
: 'all subscribed contacts')}
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT &&
(segments?.find(s => s.id === (editedCampaign.segmentId ?? c.segmentId))?.name ?? 'the selected segment')}
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.FILTERED && 'filtered contacts'}
</p> </p>
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
<p className="text-xs text-neutral-500 leading-relaxed">
You can edit or cancel this campaign anytime before it sends.
</p>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -810,12 +868,14 @@ export default function CampaignDetailsPage() {
onClick={() => { onClick={() => {
setDialog({type: 'none'}); setDialog({type: 'none'});
setScheduledDateTime(''); setScheduledDateTime('');
setSelectedPreset(null);
}} }}
> >
Cancel Not yet
</Button> </Button>
<Button type="button" onClick={handleSchedule}> <Button type="button" onClick={handleSchedule} disabled={!scheduledDateTime}>
Schedule Campaign <Calendar className="h-4 w-4" />
Schedule send
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -825,15 +885,66 @@ export default function CampaignDetailsPage() {
{/* Sticky Save Bar */} {/* Sticky Save Bar */}
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} /> <StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
<ConfirmDialog <Dialog open={dialog.type === 'send'} onOpenChange={open => !open && setDialog({type: 'none'})}>
open={dialog.type === 'send'} <DialogContent className="sm:max-w-md">
onOpenChange={open => !open && setDialog({type: 'none'})} <DialogHeader>
onConfirm={handleSend} <DialogTitle>Ready to send?</DialogTitle>
title="Send Campaign" <DialogDescription>Review the details below, then send when you{"'"}re ready.</DialogDescription>
description="Are you sure you want to send this campaign now? This action cannot be undone." </DialogHeader>
confirmText="Send Now"
variant="default" <div className="space-y-4 py-2">
/> {/* Hero: recipient count */}
<div className="rounded-xl border border-neutral-200 bg-neutral-50 px-5 py-6 text-center">
<div className="flex items-center justify-center gap-2 text-neutral-500">
<Users className="h-4 w-4" />
<span className="text-xs font-medium uppercase tracking-wide">Recipients</span>
</div>
<div className="mt-1.5 text-4xl font-bold text-neutral-900 tabular-nums">
{draftRecipientCount.toLocaleString()}
</div>
<div className="mt-1 text-xs text-neutral-500">
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.ALL &&
((editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'All contacts'
: 'All subscribed contacts')}
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT &&
(segments?.find(s => s.id === (editedCampaign.segmentId ?? c.segmentId))?.name ?? 'Selected segment')}
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.FILTERED && 'Filtered contacts'}
</div>
</div>
{/* Compact summary */}
<div className="rounded-lg border border-neutral-200 divide-y divide-neutral-200 text-sm">
<div className="grid grid-cols-[80px_1fr] gap-3 px-3 py-2.5">
<span className="text-neutral-500">From</span>
<span className="text-neutral-900 truncate">{editedCampaign.from || c.from}</span>
</div>
<div className="grid grid-cols-[80px_1fr] gap-3 px-3 py-2.5">
<span className="text-neutral-500">Subject</span>
<span className="text-neutral-900 truncate">{editedCampaign.subject || c.subject}</span>
</div>
</div>
{/* Reassurance */}
<div className="flex items-start gap-2 rounded-lg bg-neutral-50 px-3 py-2.5">
<Info className="h-4 w-4 text-neutral-500 mt-0.5 shrink-0" />
<p className="text-xs text-neutral-600 leading-relaxed">
Sending takes a few minutes. You can cancel the campaign at any time while it{"'"}s still sending.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialog({type: 'none'})}>
Not yet
</Button>
<Button onClick={async () => { await handleSend(); setDialog({type: 'none'}); }}>
<Send className="h-4 w-4" />
Send to {draftRecipientCount.toLocaleString()} {draftRecipientCount === 1 ? 'contact' : 'contacts'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog <ConfirmDialog
open={dialog.type === 'delete'} open={dialog.type === 'delete'}