feat: enhance campaign scheduling and audience settings UI for better clarity and usability
This commit is contained in:
@@ -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'}
|
||||||
|
|||||||
Reference in New Issue
Block a user