ui: Clean up empty states, modals, animations
This commit is contained in:
@@ -41,7 +41,6 @@ import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Info,
|
||||
Mail,
|
||||
MousePointer,
|
||||
Save,
|
||||
@@ -622,24 +621,20 @@ export default function CampaignDetailsPage() {
|
||||
|
||||
{/* Show recipient count */}
|
||||
{draftRecipientCount > 0 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-2">
|
||||
<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-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">
|
||||
<Users className="h-4 w-4 text-neutral-400" />
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{draftRecipientCount.toLocaleString()} recipients
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-blue-800">
|
||||
This count will be recalculated right before sending to ensure accuracy. The final number 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>
|
||||
<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>
|
||||
@@ -792,12 +787,10 @@ export default function CampaignDetailsPage() {
|
||||
className="mt-2"
|
||||
/>
|
||||
{scheduledDateTime && (
|
||||
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-xs font-medium text-blue-900 mb-1">Scheduled for:</p>
|
||||
<p className="text-sm text-blue-800">
|
||||
<span className="font-medium">{formatFullDateTime(new Date(scheduledDateTime))}</span>
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
<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>
|
||||
<p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(scheduledDateTime))}</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
UTC: {formatUTCDateTime(new Date(scheduledDateTime))}
|
||||
</p>
|
||||
</div>
|
||||
@@ -884,30 +877,30 @@ export default function CampaignDetailsPage() {
|
||||
|
||||
{/* Sending Progress Banner */}
|
||||
{c.status === CampaignStatus.SENDING && s && (
|
||||
<Card className="bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-900 text-lg">Sending in progress</h3>
|
||||
<p className="text-sm text-neutral-600 mt-1">
|
||||
<p className="text-sm text-neutral-500 mt-1">
|
||||
{s.sentCount.toLocaleString()} of {s.totalRecipients.toLocaleString()} emails sent
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
<div className="text-3xl font-bold text-neutral-900">
|
||||
{((s.sentCount / s.totalRecipients) * 100).toFixed(0)}%
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">Complete</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-3">
|
||||
<div className="w-full bg-neutral-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-3 rounded-full transition-all duration-500"
|
||||
className="bg-neutral-900 h-2 rounded-full transition-all duration-500"
|
||||
style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">This page updates automatically every 5 seconds</p>
|
||||
<p className="text-xs text-neutral-400">This page updates automatically every 5 seconds</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -916,12 +909,10 @@ export default function CampaignDetailsPage() {
|
||||
{/* Stats Cards */}
|
||||
{s && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-neutral-600">Total Recipients</CardTitle>
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Users className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-medium text-neutral-500">Total Recipients</CardTitle>
|
||||
<Users className="h-4 w-4 text-neutral-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.totalRecipients.toLocaleString()}</div>
|
||||
@@ -931,12 +922,10 @@ export default function CampaignDetailsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-green-500">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-neutral-600">Delivery Rate</CardTitle>
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<Mail className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-medium text-neutral-500">Delivery Rate</CardTitle>
|
||||
<Mail className="h-4 w-4 text-neutral-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.deliveryRate.toFixed(1)}%</div>
|
||||
@@ -947,12 +936,10 @@ export default function CampaignDetailsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-purple-500">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-neutral-600">Open Rate</CardTitle>
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<TrendingUp className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-medium text-neutral-500">Open Rate</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-neutral-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.openRate.toFixed(1)}%</div>
|
||||
@@ -960,12 +947,10 @@ export default function CampaignDetailsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-orange-500">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-neutral-600">Click Rate</CardTitle>
|
||||
<div className="p-2 bg-orange-100 rounded-lg">
|
||||
<MousePointer className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-medium text-neutral-500">Click Rate</CardTitle>
|
||||
<MousePointer className="h-4 w-4 text-neutral-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.clickRate.toFixed(1)}%</div>
|
||||
@@ -1063,10 +1048,7 @@ export default function CampaignDetailsPage() {
|
||||
</p>
|
||||
</div>
|
||||
{c.status === CampaignStatus.SCHEDULED && (
|
||||
<div className="flex items-start gap-1.5 p-2 bg-blue-50 border border-blue-200 rounded">
|
||||
<Info className="h-3 w-3 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-blue-800">Recipient count will be recalculated at send time</p>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">Recipient count will be recalculated at send time</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
SelectItemWithDescription,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@plunk/ui';
|
||||
import type {Segment, Template} from '@plunk/db';
|
||||
import {CampaignAudienceType, TemplateType} from '@plunk/db';
|
||||
@@ -20,10 +20,9 @@ import {NextSeo} from 'next-seo';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {EmailSettings} from '../../components/EmailSettings';
|
||||
import {EmailEditor} from '../../components/EmailEditor';
|
||||
import {StepHeader} from '../../components/StepHeader';
|
||||
import {network} from '../../lib/network';
|
||||
import {EmailFormValidator} from '../../lib/validation';
|
||||
import {ArrowLeft, Save, TriangleAlert, Users} from 'lucide-react';
|
||||
import {ArrowLeft, TriangleAlert} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
@@ -50,7 +49,6 @@ export default function CreateCampaignPage() {
|
||||
|
||||
const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false});
|
||||
|
||||
// Load template or campaign data if provided in query params
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const {
|
||||
@@ -65,36 +63,26 @@ export default function CreateCampaignPage() {
|
||||
segmentId: querySegmentId,
|
||||
} = router.query;
|
||||
|
||||
// Handle template loading
|
||||
if (templateId && typeof templateId === 'string') {
|
||||
setLoadingTemplate(true);
|
||||
try {
|
||||
// Fetch the full template to get the body content
|
||||
const template = await network.fetch<Template>('GET', `/templates/${templateId}`);
|
||||
|
||||
// Pre-fill form with template data
|
||||
if (queryName && typeof queryName === 'string') setName(queryName);
|
||||
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
|
||||
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
|
||||
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
|
||||
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
|
||||
setBody(template.body);
|
||||
|
||||
toast.success('Template loaded successfully');
|
||||
} catch {
|
||||
toast.error('Failed to load template');
|
||||
} finally {
|
||||
setLoadingTemplate(false);
|
||||
}
|
||||
}
|
||||
// Handle campaign loading
|
||||
else if (campaignId && typeof campaignId === 'string') {
|
||||
} else if (campaignId && typeof campaignId === 'string') {
|
||||
setLoadingTemplate(true);
|
||||
try {
|
||||
// Fetch the full campaign to get the body content
|
||||
const campaign = await network.fetch<{data: {body: string}}>('GET', `/campaigns/${campaignId}`);
|
||||
|
||||
// Pre-fill form with campaign data
|
||||
if (queryName && typeof queryName === 'string') setName(queryName);
|
||||
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
|
||||
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
|
||||
@@ -105,16 +93,13 @@ export default function CreateCampaignPage() {
|
||||
}
|
||||
if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId);
|
||||
setBody(campaign.data.body);
|
||||
|
||||
toast.success('Campaign loaded successfully');
|
||||
} catch {
|
||||
toast.error('Failed to load campaign');
|
||||
} finally {
|
||||
setLoadingTemplate(false);
|
||||
}
|
||||
}
|
||||
// Handle query params without template/campaign ID (direct field values)
|
||||
else {
|
||||
} else {
|
||||
if (queryName && typeof queryName === 'string') setName(queryName);
|
||||
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
|
||||
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
|
||||
@@ -166,13 +151,12 @@ export default function CreateCampaignPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate estimated recipients
|
||||
const getEstimatedRecipients = () => {
|
||||
if (audienceType === CampaignAudienceType.SEGMENT && segmentId && segments) {
|
||||
const segment = segments.find(s => s.id === segmentId);
|
||||
return segment?.memberCount || 0;
|
||||
}
|
||||
return 0; // We don't have total contact count here, but in a real scenario you'd fetch it
|
||||
return 0;
|
||||
};
|
||||
|
||||
const estimatedRecipients = getEstimatedRecipients();
|
||||
@@ -217,19 +201,15 @@ export default function CreateCampaignPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Left Column - Settings (2/3 width) */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: Basic Info + Campaign Type */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader
|
||||
stepNumber={1}
|
||||
title="Basic Information"
|
||||
description="Name and describe your campaign"
|
||||
/>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
<CardDescription>Name and describe your campaign</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -246,14 +226,12 @@ export default function CreateCampaignPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
placeholder="Internal notes about this campaign"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -262,56 +240,30 @@ export default function CreateCampaignPage() {
|
||||
{/* Campaign Type */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader
|
||||
stepNumber={2}
|
||||
title="Campaign Type"
|
||||
description="Choose how this campaign should be treated"
|
||||
/>
|
||||
<CardTitle>Campaign Type</CardTitle>
|
||||
<CardDescription>Choose how this campaign should be treated</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCampaignType(TemplateType.MARKETING)}
|
||||
className={`text-left p-4 rounded-lg border-2 transition-colors ${
|
||||
campaignType === TemplateType.MARKETING
|
||||
? 'border-neutral-900 bg-neutral-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium text-sm text-neutral-900">Marketing</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
Sent to subscribed contacts only. Includes unsubscribe link.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCampaignType(TemplateType.TRANSACTIONAL)}
|
||||
className={`text-left p-4 rounded-lg border-2 transition-colors ${
|
||||
campaignType === TemplateType.TRANSACTIONAL
|
||||
? 'border-neutral-900 bg-neutral-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium text-sm text-neutral-900">Transactional</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
Sent to all contacts regardless of subscription status. No unsubscribe footer.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCampaignType(TemplateType.HEADLESS)}
|
||||
className={`text-left p-4 rounded-lg border-2 transition-colors ${
|
||||
campaignType === TemplateType.HEADLESS
|
||||
? 'border-neutral-900 bg-neutral-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium text-sm text-neutral-900">Headless</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
Sent to subscribed contacts only. No Plunk footer — you provide the unsubscribe link.
|
||||
</p>
|
||||
</button>
|
||||
<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={() => setCampaignType(value)}
|
||||
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
|
||||
campaignType === 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>
|
||||
{campaignType === TemplateType.HEADLESS && !detectUnsubscribeSignal(body) && (
|
||||
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
|
||||
@@ -336,249 +288,131 @@ export default function CreateCampaignPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader
|
||||
stepNumber={3}
|
||||
title="Email Settings"
|
||||
description="Configure sender information and subject"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<EmailSettings
|
||||
from={from}
|
||||
fromName={fromName}
|
||||
replyTo={replyTo}
|
||||
onFromChange={setFrom}
|
||||
onFromNameChange={setFromName}
|
||||
onReplyToChange={setReplyTo}
|
||||
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={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Content */}
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader>
|
||||
<StepHeader stepNumber={4} title="Email Content" description="Design your email message" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="body">
|
||||
Email Body <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<EmailEditor value={body} onChange={setBody} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audience Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader stepNumber={5} title="Audience" description="Choose who will receive this campaign" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audienceType">
|
||||
Audience Type <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={audienceType}
|
||||
onValueChange={value => setAudienceType(value as CampaignAudienceType)}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select audience type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItemWithDescription
|
||||
value={CampaignAudienceType.ALL}
|
||||
title={campaignType === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
|
||||
description={campaignType === 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>
|
||||
|
||||
{audienceType === CampaignAudienceType.SEGMENT && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="segment">
|
||||
Select Segment <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={segmentId} onValueChange={setSegmentId} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a segment" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{segments?.map(segment => (
|
||||
<SelectItemWithDescription
|
||||
key={segment.id}
|
||||
value={segment.id}
|
||||
title={segment.name}
|
||||
description={`${segment.memberCount.toLocaleString()} contacts`}
|
||||
/>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{segments?.length === 0 && (
|
||||
<p className="text-sm text-neutral-500 mt-2">
|
||||
No segments found.{' '}
|
||||
<Link href="/segments/new" className="text-primary hover:underline">
|
||||
Create one first
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audienceType === CampaignAudienceType.SEGMENT && estimatedRecipients > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-900">
|
||||
{estimatedRecipients.toLocaleString()} recipients
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
This campaign will be sent to all contacts in the selected segment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audienceType === CampaignAudienceType.ALL && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-900">
|
||||
{campaignType === TemplateType.TRANSACTIONAL ? 'All contacts' : 'All subscribed contacts'}
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
{campaignType === TemplateType.TRANSACTIONAL
|
||||
? 'This campaign will be sent to all contacts regardless of subscription status'
|
||||
: "This campaign will be sent to all contacts who haven't unsubscribed"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Summary & Actions (1/3 width) */}
|
||||
<div className="space-y-6">
|
||||
{/* Campaign Summary */}
|
||||
<Card className="sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Campaign Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between py-2 border-b border-neutral-100">
|
||||
<span className="text-neutral-500">Status</span>
|
||||
<span className="font-medium">Draft</span>
|
||||
</div>
|
||||
{/* Email Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Settings</CardTitle>
|
||||
<CardDescription>Configure sender information and subject</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<EmailSettings
|
||||
from={from}
|
||||
fromName={fromName}
|
||||
replyTo={replyTo}
|
||||
onFromChange={setFrom}
|
||||
onFromNameChange={setFromName}
|
||||
onReplyToChange={setReplyTo}
|
||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||
/>
|
||||
|
||||
{name && (
|
||||
<div className="flex justify-between py-2 border-b border-neutral-100">
|
||||
<span className="text-neutral-500">Name</span>
|
||||
<span className="font-medium text-right truncate ml-2" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<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={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Content */}
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader>
|
||||
<CardTitle>Email Content</CardTitle>
|
||||
<CardDescription>Design your email message</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmailEditor value={body} onChange={setBody} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audience */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audience</CardTitle>
|
||||
<CardDescription>Choose who will receive this campaign</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audienceType">
|
||||
Audience Type <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={audienceType}
|
||||
onValueChange={value => setAudienceType(value as CampaignAudienceType)}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select audience type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItemWithDescription
|
||||
value={CampaignAudienceType.ALL}
|
||||
title={campaignType === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
|
||||
description={campaignType === 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>
|
||||
|
||||
{audienceType === CampaignAudienceType.SEGMENT && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="segment">
|
||||
Select Segment <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={segmentId} onValueChange={setSegmentId} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a segment" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{segments?.map(segment => (
|
||||
<SelectItemWithDescription
|
||||
key={segment.id}
|
||||
value={segment.id}
|
||||
title={segment.name}
|
||||
description={`${segment.memberCount.toLocaleString()} contacts`}
|
||||
/>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{segments?.length === 0 && (
|
||||
<p className="text-sm text-neutral-500">
|
||||
No segments found.{' '}
|
||||
<Link href="/segments/new" className="underline">
|
||||
Create one first
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{subject && (
|
||||
<div className="py-2 border-b border-neutral-100">
|
||||
<span className="text-neutral-500 block mb-1">Subject</span>
|
||||
<span className="font-medium text-sm">{subject}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{from && (
|
||||
<div className="flex justify-between py-2 border-b border-neutral-100">
|
||||
<span className="text-neutral-500">From</span>
|
||||
<span className="font-medium text-right truncate ml-2" title={from}>
|
||||
{from}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between py-2 border-b border-neutral-100">
|
||||
<span className="text-neutral-500">Type</span>
|
||||
<span className="font-medium">
|
||||
{campaignType === TemplateType.MARKETING ? 'Marketing' : campaignType === TemplateType.HEADLESS ? 'Headless' : 'Transactional'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between py-2 border-b border-neutral-100">
|
||||
<span className="text-neutral-500">Audience</span>
|
||||
<span className="font-medium">
|
||||
{audienceType === CampaignAudienceType.ALL ? 'All Contacts' : 'Segment'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{audienceType === CampaignAudienceType.SEGMENT && estimatedRecipients > 0 && (
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-neutral-500">Recipients</span>
|
||||
<span className="font-medium">{estimatedRecipients.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audienceType === CampaignAudienceType.ALL && (
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-neutral-500">Recipients</span>
|
||||
<span className="font-medium">All subscribed</span>
|
||||
</div>
|
||||
{estimatedRecipients > 0 && (
|
||||
<p className="text-sm text-neutral-500">
|
||||
<span className="font-medium text-neutral-900">{estimatedRecipients.toLocaleString()} recipients</span> in this segment
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info Note */}
|
||||
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-3 mt-4">
|
||||
<p className="text-xs text-neutral-600 leading-relaxed">
|
||||
After creating this campaign, you'll be able to review it and choose when to send it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<Button type="submit" disabled={saving} className="w-full">
|
||||
{saving ? (
|
||||
<>Creating...</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Create Campaign
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Link href="/campaigns" className="w-full">
|
||||
<Button type="button" variant="outline" className="w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/campaigns">
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? 'Creating...' : 'Create Campaign'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {Campaign, Template} from '@plunk/db';
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import type {PaginatedResponse} from '@plunk/types';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {EmptyState} from '../../components/EmptyState';
|
||||
import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog';
|
||||
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
|
||||
import {network} from '../../lib/network';
|
||||
@@ -301,74 +302,71 @@ export default function CampaignsPage() {
|
||||
|
||||
{!isLoading && data?.data.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Mail className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">
|
||||
{statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
|
||||
</h3>
|
||||
<p className="text-neutral-500 mb-6">
|
||||
{statusFilter !== 'ALL'
|
||||
? 'Try adjusting your filters or create a new campaign.'
|
||||
: 'Create your first campaign to send emails to your contacts.'}
|
||||
</p>
|
||||
{statusFilter === 'ALL' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="lg">
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={Mail}
|
||||
title={statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
|
||||
description={
|
||||
statusFilter !== 'ALL'
|
||||
? 'Adjust your filters or create a new campaign.'
|
||||
: 'Send one-off emails to groups of contacts.'
|
||||
}
|
||||
action={
|
||||
statusFilter === 'ALL' ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Campaign
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-80">
|
||||
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
||||
<Link href="/campaigns/create" className="flex items-start gap-3">
|
||||
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">Empty Campaign</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Start from scratch with a blank canvas
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">From Template</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Use an existing template as a starting point
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">From Previous Campaign</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Copy content and settings from an existing campaign
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Link href="/campaigns/create">
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Your First Campaign
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
Create Campaign
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-80">
|
||||
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
||||
<Link href="/campaigns/create" className="flex items-start gap-3">
|
||||
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">Empty Campaign</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Start from scratch with a blank canvas
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">From Template</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Use an existing template as a starting point
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">From Previous Campaign</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Copy content and settings from an existing campaign
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{statusFilter !== 'ALL' && (
|
||||
<Link href="/campaigns/create">
|
||||
<Button size="lg">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Campaign
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -380,7 +378,7 @@ export default function CampaignsPage() {
|
||||
campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Card key={campaign.id} className="hover:shadow-lg transition-all hover:border-primary/20">
|
||||
<Card key={campaign.id} className="transition-colors hover:border-neutral-300">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
Reference in New Issue
Block a user