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
+357 -246
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,126 +439,35 @@ 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 className="flex flex-row items-start justify-between gap-4 space-y-0">
<CardHeader> <div>
<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'}
layout="vertical"
/>
</CardContent>
</Card>
{/* Audience Settings */}
<Card>
<CardHeader>
<CardTitle>Audience</CardTitle> <CardTitle>Audience</CardTitle>
<CardDescription>Who will receive this campaign</CardDescription> <CardDescription>Who will receive this campaign when you send</CardDescription>
</CardHeader> </div>
<CardContent className="space-y-4"> {draftRecipientCount > 0 && (
<div> <div className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 shrink-0">
<Label htmlFor="audienceType">Audience Type *</Label> <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>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="audienceType">
Audience Type <span className="text-red-500">*</span>
</Label>
<Select <Select
value={editedCampaign.audienceType ?? c.audienceType} value={editedCampaign.audienceType ?? c.audienceType}
onValueChange={(value: CampaignAudienceType) => { onValueChange={(value: CampaignAudienceType) => {
setEditedCampaign({ setEditedCampaign({
...editedCampaign, ...editedCampaign,
audienceType: value, audienceType: value,
// Clear segmentId if changing away from SEGMENT
segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined, segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined,
}); });
}} }}
@@ -571,7 +483,7 @@ export default function CampaignDetailsPage() {
/> />
<SelectItemWithDescription <SelectItemWithDescription
value={CampaignAudienceType.SEGMENT} value={CampaignAudienceType.SEGMENT}
title="Segment" title="Specific Segment"
description="Target a defined group of contacts" description="Target a defined group of contacts"
/> />
</SelectContent> </SelectContent>
@@ -579,8 +491,10 @@ export default function CampaignDetailsPage() {
</div> </div>
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && ( {(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
<div> <div className="space-y-2">
<Label htmlFor="segment">Select Segment *</Label> <Label htmlFor="segment">
Select Segment <span className="text-red-500">*</span>
</Label>
<Select <Select
value={editedCampaign.segmentId ?? c.segmentId ?? undefined} value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
onValueChange={(value: string) => { onValueChange={(value: string) => {
@@ -610,44 +524,160 @@ export default function CampaignDetailsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
{segments && segments.length === 0 && ( {segments && segments.length === 0 && (
<p className="text-xs text-neutral-500 mt-1">Create a segment first to use this option</p> <p className="text-sm text-neutral-500">
No segments found.{' '}
<Link href="/segments/new" className="underline">
Create one first
</Link>
</p>
)} )}
</div> </div>
)} )}
</div>
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && ( {editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
<p className="text-sm text-neutral-500"> <p className="text-sm text-neutral-500">
Filtered audiences are configured with advanced filter conditions Filtered audiences are configured with advanced filter conditions
</p> </p>
)} )}
{/* Show recipient count */} {draftRecipientCount > 0 && (
{draftRecipientCount > 0 && ( <p className="text-xs text-neutral-500">
<div className="mt-4 p-3 bg-neutral-50 border border-neutral-200 rounded-lg space-y-1.5"> Recalculated at send time. Final count may differ if contacts{' '}
<div className="flex items-center gap-2"> {(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
<Users className="h-4 w-4 text-neutral-400" /> ? 'are added or removed, or segment membership changes.'
<span className="text-sm font-medium text-neutral-900"> : 'subscribe, unsubscribe, or segment membership changes.'
{draftRecipientCount.toLocaleString()} recipients }
</span> </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
id="name"
placeholder="e.g., Spring Sale Announcement"
value={editedCampaign.name || ''}
onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
placeholder="Internal notes about this campaign"
value={editedCampaign.description || ''}
onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})}
/>
</div>
</CardContent>
</Card>
{/* Campaign Type */}
<Card>
<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.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>
<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> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Email Editor - Full Width */} {/* Email Settings */}
<Card>
<CardHeader>
<CardTitle>Email Settings</CardTitle>
<CardDescription>Configure sender information and subject</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<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'}
/>
<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>
</Card>
{/* Email Content */}
<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,37 +692,51 @@ 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 => (
<SelectItem key={member.userId} value={member.email}> <SelectItem key={member.userId} value={member.email}>
{member.email} {member.email}
</SelectItem> </SelectItem>
))} ))}
</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">
type="button" {[
variant="outline" {key: 'in1h', label: 'In 1 hour', getValue: schedulePresets.inOneHour},
size="sm" {key: 'in3h', label: 'In 3 hours', getValue: schedulePresets.inThreeHours},
onClick={() => setScheduledDateTime(schedulePresets.inOneHour())} {key: 'tom9', label: 'Tomorrow, 9 AM', getValue: schedulePresets.tomorrowAt9AM},
> {key: 'tom2', label: 'Tomorrow, 2 PM', getValue: schedulePresets.tomorrowAt2PM},
In 1 hour {key: 'nextMon', label: 'Next Monday', getValue: schedulePresets.nextMonday},
</Button> {key: 'in1w', label: 'In 1 week', getValue: schedulePresets.inOneWeek},
<Button ].map(({key, label, getValue}) => {
type="button" const isActive = selectedPreset === key;
variant="outline" return (
size="sm" <button
onClick={() => setScheduledDateTime(schedulePresets.inThreeHours())} key={key}
> type="button"
In 3 hours onClick={() => {
</Button> setScheduledDateTime(getValue());
<Button setSelectedPreset(key);
type="button" }}
variant="outline" className={`min-h-[40px] px-3 py-2 rounded-lg border text-sm text-left transition-colors ${
size="sm" isActive
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt9AM())} ? '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'
Tomorrow at 9 AM }`}
</Button> >
<Button {label}
type="button" </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"
/> />
{scheduledDateTime && ( </div>
<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> {/* Confirmation preview — date + audience together */}
<p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(scheduledDateTime))}</p> {scheduledDateTime && (
<p className="text-xs text-neutral-500 mt-1"> <div className="rounded-lg border border-neutral-200 bg-neutral-50 divide-y divide-neutral-200">
UTC: {formatUTCDateTime(new Date(scheduledDateTime))} <div className="px-4 py-3">
<div className="flex items-center gap-2 text-neutral-500">
<Calendar className="h-3.5 w-3.5" />
<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> </p>
</div> </div>
)} {draftRecipientCount > 0 && (
</div> <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>
</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'}