diff --git a/apps/web/src/pages/campaigns/[id].tsx b/apps/web/src/pages/campaigns/[id].tsx index adf65fd..056b9ac 100644 --- a/apps/web/src/pages/campaigns/[id].tsx +++ b/apps/web/src/pages/campaigns/[id].tsx @@ -42,6 +42,7 @@ import { ArrowLeft, Calendar, ChevronDown, + Info, Mail, MousePointer, Save, @@ -107,6 +108,7 @@ export default function CampaignDetailsPage() { const [editedCampaign, setEditedCampaign] = useState>({}); const [scheduledDateTime, setScheduledDateTime] = useState(''); + const [selectedPreset, setSelectedPreset] = useState(null); const [testEmailAddress, setTestEmailAddress] = useState(''); type CampaignDialog = @@ -178,6 +180,7 @@ export default function CampaignDetailsPage() { toast.success(`Campaign scheduled for ${localTimeString}`); setDialog({type: 'none'}); setScheduledDateTime(''); + setSelectedPreset(null); void mutate(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign'); @@ -436,126 +439,35 @@ export default function CampaignDetailsPage() { - {/* 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'} - layout="vertical" - /> -
-
- - {/* Audience Settings */} - - + {/* Audience — surfaced first because Send lives in the header. + Users need to see who/how many before pressing Send. */} + + +
Audience - Who will receive this campaign - - -
- + Who will receive this campaign when you send +
+ {draftRecipientCount > 0 && ( +
+ + + {draftRecipientCount.toLocaleString()} {draftRecipientCount === 1 ? 'recipient' : 'recipients'} + +
+ )} + + +
+
+ { @@ -610,44 +524,160 @@ export default function CampaignDetailsPage() { {segments && segments.length === 0 && ( -

Create a segment first to use this option

+

+ No segments found.{' '} + + Create one first + +

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

- Filtered audiences are configured with advanced filter conditions -

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

+ Filtered audiences are configured with advanced filter conditions +

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

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

+ )} + + + + {/* Row 1: Basic Info + Campaign Type */} +
+ {/* Basic Information */} + + + Basic Information + Name and describe your campaign + + +
+ + setEditedCampaign({...editedCampaign, name: e.target.value})} + required + /> +
+ +
+ + setEditedCampaign({...editedCampaign, description: e.target.value})} + /> +
+
+
+ + {/* Campaign Type */} + + + Campaign Type + Choose how this campaign should be treated + + +
+ {([ + {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}}'} + +
-

- 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 Settings */} + + + Email Settings + Configure sender information and subject + + + setEditedCampaign({...editedCampaign, from: value})} + onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})} + onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})} + fromNamePlaceholder={activeProject?.name || 'Your Company'} + /> + +
+ + setEditedCampaign({...editedCampaign, subject: e.target.value})} + required + /> +
+
+
+ + {/* Email Content */} Email Content - Design your email using the visual editor or paste custom HTML + Design your email message !open && setDialog({type: 'none'})}> - + - Send Test Email + Send a preview - 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. + Get a copy of this campaign in your inbox before sending it for real. -
-
- - -

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

+ +
+ + +
+ + {/* Preview of how the email will arrive */} +
+ +
+
+ From + {editedCampaign.from || c.from} +
+
+ Subject + + [TEST] {editedCampaign.subject || c.subject} + +
+ +

+ Variables like {'{{firstName}}'} aren{"'"}t replaced in previews. You{"'"}ll see them as written. +

+ @@ -717,92 +762,105 @@ export default function CampaignDetailsPage() { {/* Schedule Dialog */} !open && setDialog({type: 'none'})}> - + - Schedule Campaign + Schedule for later - 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()}. -
- {/* Quick Presets */} -
- -
- - - - - - + +
+ {/* Quick presets */} +
+ +
+ {[ + {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 ( + + ); + })}
{/* Custom Date/Time */} -
- +
+ setScheduledDateTime(e.target.value)} + onChange={e => { + setScheduledDateTime(e.target.value); + setSelectedPreset(null); + }} min={new Date().toISOString().slice(0, 16)} - className="mt-2" /> - {scheduledDateTime && ( -
-

Scheduled for:

-

{formatFullDateTime(new Date(scheduledDateTime))}

-

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

+ + {/* Confirmation preview — date + audience together */} + {scheduledDateTime && ( +
+
+
+ + Sending on +
+

+ {formatFullDateTime(new Date(scheduledDateTime))}

- )} -
+ {draftRecipientCount > 0 && ( +
+
+ + To +
+

+ {draftRecipientCount.toLocaleString()} + + {draftRecipientCount === 1 ? ' recipient in ' : ' recipients in '} + + {(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'} +

+
+ )} +
+ )}
+ +

+ You can edit or cancel this campaign anytime before it sends. +

+ - @@ -825,15 +885,66 @@ export default function CampaignDetailsPage() { {/* Sticky Save Bar */} - !open && setDialog({type: 'none'})} - 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" - /> + !open && setDialog({type: 'none'})}> + + + Ready to send? + Review the details below, then send when you{"'"}re ready. + + +
+ {/* Hero: recipient count */} +
+
+ + Recipients +
+
+ {draftRecipientCount.toLocaleString()} +
+
+ {(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'} +
+
+ + {/* Compact summary */} +
+
+ From + {editedCampaign.from || c.from} +
+
+ Subject + {editedCampaign.subject || c.subject} +
+
+ + {/* Reassurance */} +
+ +

+ Sending takes a few minutes. You can cancel the campaign at any time while it{"'"}s still sending. +

+
+
+ + + + + +
+