Initial push of Plunk Next
This commit is contained in:
@@ -0,0 +1,934 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
StickySaveBar,
|
||||
} from '@plunk/ui';
|
||||
import type {Campaign, Segment} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {CampaignSchemas} from '@plunk/shared';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {EmailSettings} from '../../components/EmailSettings';
|
||||
import {EmailEditor} from '../../components/EmailEditor';
|
||||
import {network} from '../../lib/network';
|
||||
import {formatFullDateTime, formatUTCDateTime, getUserTimezone, schedulePresets} from '../../lib/dateUtils';
|
||||
import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
|
||||
import {ArrowLeft, Calendar, Mail, MousePointer, Save, Send, TestTube, TrendingUp, Users, XCircle} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
|
||||
interface CampaignStats {
|
||||
totalRecipients: number;
|
||||
sentCount: number;
|
||||
deliveredCount: number;
|
||||
openedCount: number;
|
||||
clickedCount: number;
|
||||
bouncedCount: number;
|
||||
openRate: number;
|
||||
clickRate: number;
|
||||
bounceRate: number;
|
||||
deliveryRate: number;
|
||||
}
|
||||
|
||||
export default function CampaignDetailsPage() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
const {activeProject} = useActiveProject();
|
||||
|
||||
const {
|
||||
data: campaign,
|
||||
mutate,
|
||||
isLoading,
|
||||
} = useSWR<{data: Campaign}>(id ? `/campaigns/${id}` : null, {revalidateOnFocus: false});
|
||||
|
||||
const {data: stats} = useSWR<{data: CampaignStats}>(
|
||||
id && campaign?.data.status !== CampaignStatus.DRAFT ? `/campaigns/${id}/stats` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: campaign?.data.status === CampaignStatus.SENDING ? 5000 : 0, // Refresh every 5s if sending
|
||||
},
|
||||
);
|
||||
|
||||
// Fetch segments for audience selection
|
||||
const {data: segments} = useSWR<Segment[]>('/segments', {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
// Fetch project members for test email
|
||||
const {data: projectMembers} = useSWR<{data: Array<{userId: string; email: string; role: string}>}>(
|
||||
id ? `/projects/${campaign?.data.projectId}/members` : null,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
|
||||
const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false);
|
||||
const [scheduledDateTime, setScheduledDateTime] = useState('');
|
||||
const [isTestEmailDialogOpen, setIsTestEmailDialogOpen] = useState(false);
|
||||
const [testEmailAddress, setTestEmailAddress] = useState('');
|
||||
const [sendingTestEmail, setSendingTestEmail] = useState(false);
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [showSendDialog, setShowSendDialog] = useState(false);
|
||||
|
||||
// Automatically initialize edit fields when campaign is loaded and is a draft
|
||||
const isEditMode = campaign?.data.status === CampaignStatus.DRAFT;
|
||||
|
||||
const handleCancel = async () => {
|
||||
try {
|
||||
await network.fetch('POST', `/campaigns/${id}/cancel`);
|
||||
toast.success('Campaign cancelled successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to cancel campaign');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
await network.fetch<void>('POST', `/campaigns/${id}/send`);
|
||||
toast.success('Campaign is being sent!');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to send campaign');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchedule = async () => {
|
||||
if (!scheduledDateTime) {
|
||||
toast.error('Please select a date and time');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the datetime-local value as local time, then convert to UTC
|
||||
const scheduledDate = new Date(scheduledDateTime);
|
||||
const now = new Date();
|
||||
|
||||
if (scheduledDate.getTime() <= now.getTime()) {
|
||||
toast.error('Scheduled time must be in the future');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send as ISO string (UTC)
|
||||
await network.fetch<void, typeof CampaignSchemas.schedule>('POST', `/campaigns/${id}/send`, {
|
||||
scheduledFor: scheduledDate.toISOString(),
|
||||
});
|
||||
|
||||
// Show confirmation with user's local time
|
||||
const localTimeString = formatFullDateTime(scheduledDate);
|
||||
toast.success(`Campaign scheduled for ${localTimeString}`);
|
||||
setIsScheduleDialogOpen(false);
|
||||
setScheduledDateTime('');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTestEmail = async () => {
|
||||
if (!testEmailAddress) {
|
||||
toast.error('Please select a project member');
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingTestEmail(true);
|
||||
|
||||
try {
|
||||
await network.fetch<{success: boolean; message: string}>('POST', `/campaigns/${id}/test`, {
|
||||
email: testEmailAddress,
|
||||
} as any);
|
||||
|
||||
toast.success(`Test email sent to ${testEmailAddress}`);
|
||||
setIsTestEmailDialogOpen(false);
|
||||
setTestEmailAddress('');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to send test email');
|
||||
} finally {
|
||||
setSendingTestEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const handleSave = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await network.fetch<Campaign, typeof CampaignSchemas.update>('PUT', `/campaigns/${id}`, {
|
||||
name: editedCampaign.name,
|
||||
description: editedCampaign.description || undefined,
|
||||
subject: editedCampaign.subject,
|
||||
body: editedCampaign.body,
|
||||
from: editedCampaign.from,
|
||||
fromName: editedCampaign.fromName || undefined,
|
||||
replyTo: editedCampaign.replyTo || undefined,
|
||||
audienceType: editedCampaign.audienceType,
|
||||
segmentId: editedCampaign.segmentId || undefined,
|
||||
});
|
||||
// Silent save - no toast notification
|
||||
setHasChanges(false);
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update campaign');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize edit fields when campaign loads and is a draft
|
||||
useEffect(() => {
|
||||
if (campaign?.data && isEditMode && Object.keys(editedCampaign).length === 0) {
|
||||
setEditedCampaign({
|
||||
name: campaign.data.name,
|
||||
description: campaign.data.description || '',
|
||||
subject: campaign.data.subject,
|
||||
body: campaign.data.body,
|
||||
from: campaign.data.from,
|
||||
fromName: campaign.data.fromName || '',
|
||||
replyTo: campaign.data.replyTo || '',
|
||||
audienceType: campaign.data.audienceType,
|
||||
segmentId: campaign.data.segmentId || undefined,
|
||||
});
|
||||
// Reset hasChanges when loading fresh data
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [campaign, isEditMode, editedCampaign]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
if (!campaign?.data || Object.keys(editedCampaign).length === 0) return;
|
||||
|
||||
const changed =
|
||||
editedCampaign.name !== campaign.data.name ||
|
||||
(editedCampaign.description || '') !== (campaign.data.description || '') ||
|
||||
editedCampaign.subject !== campaign.data.subject ||
|
||||
editedCampaign.body !== campaign.data.body ||
|
||||
editedCampaign.from !== campaign.data.from ||
|
||||
(editedCampaign.fromName || '') !== (campaign.data.fromName || '') ||
|
||||
(editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') ||
|
||||
editedCampaign.audienceType !== campaign.data.audienceType ||
|
||||
(editedCampaign.segmentId || null) !== (campaign.data.segmentId || null);
|
||||
|
||||
setHasChanges(changed);
|
||||
}, [editedCampaign, campaign]);
|
||||
|
||||
// Warn before leaving page with unsaved changes (only in edit mode)
|
||||
useChangeTracking(hasChanges, isEditMode);
|
||||
|
||||
const getStatusBadge = (status: CampaignStatus) => {
|
||||
const variants: Record<
|
||||
CampaignStatus,
|
||||
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string}
|
||||
> = {
|
||||
DRAFT: {variant: 'secondary', label: 'Draft'},
|
||||
SCHEDULED: {variant: 'default', label: 'Scheduled'},
|
||||
SENDING: {variant: 'default', label: 'Sending'},
|
||||
SENT: {variant: 'default', label: 'Sent'},
|
||||
CANCELLED: {variant: 'destructive', label: 'Cancelled'},
|
||||
};
|
||||
|
||||
const config = variants[status];
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="h-8 w-8 animate-spin mx-auto border-4 border-neutral-200 border-t-neutral-900 rounded-full" />
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading campaign...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!campaign) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-neutral-500">Campaign not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const c = campaign.data;
|
||||
const s = stats?.data;
|
||||
|
||||
// Get recipient count for draft campaigns
|
||||
const getDraftRecipientCount = () => {
|
||||
if (!campaign?.data) return 0;
|
||||
const c = campaign.data;
|
||||
|
||||
if (c.audienceType === CampaignAudienceType.SEGMENT && c.segmentId && segments) {
|
||||
const segment = segments.find(s => s.id === c.segmentId);
|
||||
return segment?.memberCount || 0;
|
||||
}
|
||||
return 0; // We'd need total contact count for ALL audience type
|
||||
};
|
||||
|
||||
const draftRecipientCount = isEditMode ? getDraftRecipientCount() : 0;
|
||||
|
||||
// Render edit form for drafts
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<form onSubmit={handleSave} className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/campaigns">
|
||||
<Button type="button" variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-neutral-900">{c.name}</h1>
|
||||
<Badge variant="secondary">Draft</Badge>
|
||||
</div>
|
||||
<p className="text-neutral-500 mt-1">Make changes to your campaign before sending</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!hasChanges && !isSubmitting && <span className="text-sm text-neutral-500">All changes saved</span>}
|
||||
{hasChanges && !isSubmitting && <span className="text-sm text-amber-600">Unsaved changes</span>}
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={!hasChanges || isSubmitting} variant="outline">
|
||||
<Save className="h-4 w-4" />
|
||||
{isSubmitting ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setIsTestEmailDialogOpen(true)}>
|
||||
<TestTube className="h-4 w-4" />
|
||||
Send Test
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setShowSendDialog(true)}>
|
||||
<Send className="h-4 w-4" />
|
||||
Send Now
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setIsScheduleDialogOpen(true)}>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign Settings - Horizontal Layout */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Campaign Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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 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'}
|
||||
showFromNameHelpText
|
||||
layout="vertical"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audience Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audience</CardTitle>
|
||||
<CardDescription>Who will receive this campaign</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="audienceType">Audience Type *</Label>
|
||||
<Select
|
||||
value={editedCampaign.audienceType ?? c.audienceType}
|
||||
onValueChange={(value: CampaignAudienceType) => {
|
||||
setEditedCampaign({
|
||||
...editedCampaign,
|
||||
audienceType: value,
|
||||
// Clear segmentId if changing away from SEGMENT
|
||||
segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="audienceType">
|
||||
<SelectValue placeholder="Select audience type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CampaignAudienceType.ALL}>All Subscribed Contacts</SelectItem>
|
||||
<SelectItem value={CampaignAudienceType.SEGMENT}>Segment</SelectItem>
|
||||
<SelectItem value={CampaignAudienceType.FILTERED}>Filtered</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
|
||||
<div>
|
||||
<Label htmlFor="segment">Select Segment *</Label>
|
||||
<Select
|
||||
value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
|
||||
onValueChange={(value: string) => {
|
||||
setEditedCampaign({
|
||||
...editedCampaign,
|
||||
segmentId: value,
|
||||
});
|
||||
}}
|
||||
disabled={!segments || segments.length === 0}
|
||||
>
|
||||
<SelectTrigger id="segment">
|
||||
<SelectValue
|
||||
placeholder={segments && segments.length > 0 ? 'Choose a segment' : 'No segments available'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{segments &&
|
||||
segments.length > 0 &&
|
||||
segments.map(segment => (
|
||||
<SelectItem key={segment.id} value={segment.id}>
|
||||
{segment.name} ({segment.memberCount.toLocaleString()} contacts)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{segments && segments.length === 0 && (
|
||||
<p className="text-xs text-neutral-500 mt-1">Create a segment first to use this option</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
|
||||
<p className="text-sm text-neutral-500">
|
||||
Filtered audiences are configured with advanced filter conditions
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Show recipient count */}
|
||||
{draftRecipientCount > 0 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<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">
|
||||
{draftRecipientCount.toLocaleString()} recipients
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Email Editor - Full Width */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Content</CardTitle>
|
||||
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmailEditor
|
||||
value={editedCampaign.body || ''}
|
||||
onChange={body => {
|
||||
setEditedCampaign({...editedCampaign, body});
|
||||
setHasChanges(true);
|
||||
}}
|
||||
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
|
||||
canUploadImages={true}
|
||||
subject={editedCampaign.subject}
|
||||
from={editedCampaign.from}
|
||||
replyTo={editedCampaign.replyTo || undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Test Email Dialog */}
|
||||
<Dialog open={isTestEmailDialogOpen} onOpenChange={setIsTestEmailDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Test Email</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="testEmail">Project Member</Label>
|
||||
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
|
||||
<SelectTrigger id="testEmail" className="mt-2">
|
||||
<SelectValue placeholder="Select a project member..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectMembers?.data.map(member => (
|
||||
<SelectItem key={member.userId} value={member.email}>
|
||||
{member.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
For security reasons, test emails can only be sent to project members.
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
Note: Variables will not be replaced in test emails. The email will be sent exactly as designed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsTestEmailDialogOpen(false);
|
||||
setTestEmailAddress('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSendTestEmail} disabled={sendingTestEmail || !testEmailAddress}>
|
||||
{sendingTestEmail ? 'Sending...' : 'Send Test Email'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Schedule Dialog */}
|
||||
<Dialog open={isScheduleDialogOpen} onOpenChange={setIsScheduleDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Campaign</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose when you want this campaign to be sent (times shown in your local timezone: {getUserTimezone()}
|
||||
)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<Label>Quick Schedule</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.inOneHour())}
|
||||
>
|
||||
In 1 hour
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.inThreeHours())}
|
||||
>
|
||||
In 3 hours
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt9AM())}
|
||||
>
|
||||
Tomorrow at 9 AM
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt2PM())}
|
||||
>
|
||||
Tomorrow at 2 PM
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.nextMonday())}
|
||||
>
|
||||
Next Monday
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.inOneWeek())}
|
||||
>
|
||||
In 1 week
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Date/Time */}
|
||||
<div>
|
||||
<Label htmlFor="scheduledDateTime">Or choose a specific time</Label>
|
||||
<Input
|
||||
id="scheduledDateTime"
|
||||
type="datetime-local"
|
||||
value={scheduledDateTime}
|
||||
onChange={e => setScheduledDateTime(e.target.value)}
|
||||
min={new Date().toISOString().slice(0, 16)}
|
||||
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">
|
||||
UTC: {formatUTCDateTime(new Date(scheduledDateTime))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsScheduleDialogOpen(false);
|
||||
setScheduledDateTime('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSchedule}>
|
||||
Schedule Campaign
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
|
||||
{/* Sticky Save Bar */}
|
||||
<StickySaveBar hasChanges={hasChanges} isSubmitting={isSubmitting} onSave={handleSave} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={showSendDialog}
|
||||
onOpenChange={setShowSendDialog}
|
||||
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"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Render stats view for sent/scheduled campaigns
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/campaigns">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-neutral-900">{c.name}</h1>
|
||||
{getStatusBadge(c.status)}
|
||||
</div>
|
||||
{c.description && <p className="text-neutral-500">{c.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(c.status === CampaignStatus.SCHEDULED || c.status === CampaignStatus.SENDING) && (
|
||||
<Button variant="destructive" onClick={() => setShowCancelDialog(true)}>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Cancel Campaign
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sending Progress Banner */}
|
||||
{c.status === CampaignStatus.SENDING && s && (
|
||||
<Card className="bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200">
|
||||
<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">
|
||||
{s.sentCount.toLocaleString()} of {s.totalRecipients.toLocaleString()} emails sent
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{((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="bg-gradient-to-r from-blue-500 to-indigo-500 h-3 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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.totalRecipients.toLocaleString()}</div>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
{s.sentCount.toLocaleString()} sent ({((s.sentCount / s.totalRecipients) * 100).toFixed(1)}%)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-green-500">
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.deliveryRate.toFixed(1)}%</div>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
{s.deliveredCount.toLocaleString()} delivered
|
||||
{s.bouncedCount > 0 && `, ${s.bouncedCount} bounced`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-purple-500">
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.openRate.toFixed(1)}%</div>
|
||||
<p className="text-xs text-neutral-500 mt-2">{s.openedCount.toLocaleString()} opened</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-orange-500">
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.clickRate.toFixed(1)}%</div>
|
||||
<p className="text-xs text-neutral-500 mt-2">{s.clickedCount.toLocaleString()} clicked</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign Details in Grid */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Email Content - Takes 2 columns */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Email Preview</CardTitle>
|
||||
<CardDescription>How your email will appear to recipients</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Email Header Info */}
|
||||
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-neutral-500 uppercase tracking-wide font-medium">Subject</p>
|
||||
<p className="text-base font-semibold text-neutral-900 mt-1">{c.subject}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-6 pt-2 border-t border-neutral-200">
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500">From</p>
|
||||
<p className="text-sm text-neutral-900 mt-0.5">{c.from}</p>
|
||||
</div>
|
||||
{c.replyTo && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500">Reply-To</p>
|
||||
<p className="text-sm text-neutral-900 mt-0.5">{c.replyTo}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Body Preview */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-700 mb-3">Message Content</p>
|
||||
<div className="border-2 border-neutral-200 rounded-lg overflow-hidden bg-white">
|
||||
<div className="p-6 max-h-96 overflow-y-auto">
|
||||
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{__html: c.body}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Campaign Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Info</CardTitle>
|
||||
<CardDescription>Configuration and metadata</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Audience */}
|
||||
<div className="pb-3 border-b border-neutral-100">
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Audience</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-neutral-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{c.audienceType === CampaignAudienceType.ALL && 'All Subscribed Contacts'}
|
||||
{c.audienceType === CampaignAudienceType.SEGMENT &&
|
||||
(segments?.find(s => s.id === c.segmentId)?.name || 'Selected Segment')}
|
||||
{c.audienceType === CampaignAudienceType.FILTERED && 'Filtered Contacts'}
|
||||
</p>
|
||||
{c.audienceType === CampaignAudienceType.SEGMENT &&
|
||||
segments?.find(s => s.id === c.segmentId)?.memberCount && (
|
||||
<p className="text-xs text-neutral-500">
|
||||
{segments.find(s => s.id === c.segmentId)!.memberCount.toLocaleString()} contacts
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scheduling Info */}
|
||||
{c.scheduledFor && (
|
||||
<div className="pb-3 border-b border-neutral-100">
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Scheduled For</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="h-4 w-4 text-neutral-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{formatFullDateTime(new Date(c.scheduledFor))}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sent At */}
|
||||
{c.sentAt && (
|
||||
<div className="pb-3 border-b border-neutral-100">
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent At</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="h-4 w-4 text-neutral-400" />
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{new Date(c.sentAt).toLocaleDateString()} at {new Date(c.sentAt).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Created */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Created</p>
|
||||
<p className="text-sm text-neutral-900">{new Date(c.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showCancelDialog}
|
||||
onOpenChange={setShowCancelDialog}
|
||||
onConfirm={handleCancel}
|
||||
title="Cancel Campaign"
|
||||
description="Are you sure you want to cancel this campaign?"
|
||||
confirmText="Cancel Campaign"
|
||||
variant="destructive"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import type {Segment} from '@plunk/db';
|
||||
import {CampaignAudienceType} from '@plunk/db';
|
||||
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, Users} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
|
||||
export default function CreateCampaignPage() {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [from, setFrom] = useState('');
|
||||
const [fromName, setFromName] = useState('');
|
||||
const [replyTo, setReplyTo] = useState('');
|
||||
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
|
||||
const [segmentId, setSegmentId] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validationError = EmailFormValidator.validateCampaign({name, subject, body, from, segmentId}, audienceType);
|
||||
if (validationError) {
|
||||
toast.error(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const response = await network.fetch<{data: {id: string}}>('POST', '/campaigns', {
|
||||
name,
|
||||
description: description || undefined,
|
||||
subject,
|
||||
body,
|
||||
from,
|
||||
fromName: fromName || undefined,
|
||||
replyTo: replyTo || undefined,
|
||||
audienceType,
|
||||
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
|
||||
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
|
||||
} as any);
|
||||
|
||||
toast.success('Campaign created successfully');
|
||||
void router.push(`/campaigns/${response.data.id}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create campaign');
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
const estimatedRecipients = getEstimatedRecipients();
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Create Campaign" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/campaigns">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Create Campaign</h1>
|
||||
<p className="text-neutral-500 mt-1">Create a new email campaign to send to your contacts</p>
|
||||
</div>
|
||||
</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">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader stepNumber={1} title="Basic Information" description="Name and describe your campaign" />
|
||||
</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={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description (Optional)</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
placeholder="Internal notes about this campaign"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-neutral-200 rounded-lg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader
|
||||
stepNumber={2}
|
||||
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>
|
||||
<CardHeader>
|
||||
<StepHeader stepNumber={3} 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}
|
||||
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
|
||||
canUploadImages={true}
|
||||
subject={subject}
|
||||
from={from}
|
||||
replyTo={replyTo}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audience Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader stepNumber={4} 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>
|
||||
<SelectItem value={CampaignAudienceType.ALL}>All Subscribed Contacts</SelectItem>
|
||||
<SelectItem value={CampaignAudienceType.SEGMENT}>Specific Segment</SelectItem>
|
||||
</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 => (
|
||||
<SelectItem key={segment.id} value={segment.id}>
|
||||
{segment.name} ({segment.memberCount.toLocaleString()} contacts)
|
||||
</SelectItem>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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">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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import type {Campaign} from '@plunk/db';
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {network} from '../../lib/network';
|
||||
import {Calendar, Copy, Mail, Plus, Users} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface PaginatedCampaigns {
|
||||
campaigns: Campaign[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
||||
|
||||
const {data, mutate, isLoading} = useSWR<PaginatedCampaigns>(
|
||||
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
const getStatusBadge = (status: CampaignStatus) => {
|
||||
const variants: Record<
|
||||
CampaignStatus,
|
||||
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className?: string}
|
||||
> = {
|
||||
DRAFT: {variant: 'secondary', label: 'Draft', className: 'bg-neutral-100 text-neutral-700'},
|
||||
SCHEDULED: {variant: 'default', label: 'Scheduled', className: 'bg-blue-100 text-blue-700'},
|
||||
SENDING: {variant: 'default', label: 'Sending', className: 'bg-purple-100 text-purple-700'},
|
||||
SENT: {variant: 'default', label: 'Sent', className: 'bg-green-100 text-green-700'},
|
||||
CANCELLED: {variant: 'destructive', label: 'Cancelled', className: 'bg-red-100 text-red-700'},
|
||||
};
|
||||
|
||||
const config = variants[status];
|
||||
return (
|
||||
<Badge variant={config.variant} className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!campaignToCancel) return;
|
||||
|
||||
try {
|
||||
await network.fetch('POST', `/campaigns/${campaignToCancel}/cancel`);
|
||||
toast.success('Campaign cancelled successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to cancel campaign');
|
||||
} finally {
|
||||
setCampaignToCancel(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (campaignId: string) => {
|
||||
try {
|
||||
await network.fetch('POST', `/campaigns/${campaignId}/duplicate`);
|
||||
toast.success('Campaign duplicated successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to duplicate campaign');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Campaigns" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Campaigns</h1>
|
||||
<p className="text-neutral-500 mt-2">
|
||||
Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/campaigns/create">
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Campaign
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All Statuses</SelectItem>
|
||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
|
||||
<SelectItem value="SENDING">Sending</SelectItem>
|
||||
<SelectItem value="SENT">Sent</SelectItem>
|
||||
<SelectItem value="CANCELLED">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Campaigns List */}
|
||||
<div className="space-y-4">
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-neutral-500">Loading campaigns...</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && data?.campaigns.length === 0 && (
|
||||
<Card className="border-2 border-dashed">
|
||||
<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>
|
||||
<Link href="/campaigns/create">
|
||||
<Button size="lg">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Your First Campaign
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data?.campaigns.map(campaign => {
|
||||
const openRate = campaign.sentCount > 0 ? (campaign.openedCount / campaign.sentCount) * 100 : 0;
|
||||
const clickRate = campaign.sentCount > 0 ? (campaign.clickedCount / campaign.sentCount) * 100 : 0;
|
||||
const deliveryProgress =
|
||||
campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Card key={campaign.id} className="hover:shadow-lg transition-all hover:border-primary/20">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Link
|
||||
href={`/campaigns/${campaign.id}`}
|
||||
className="hover:text-primary transition-colors flex-1 min-w-0"
|
||||
>
|
||||
<CardTitle className="text-xl truncate">{campaign.name}</CardTitle>
|
||||
</Link>
|
||||
{getStatusBadge(campaign.status)}
|
||||
</div>
|
||||
{campaign.description && (
|
||||
<CardDescription className="line-clamp-2">{campaign.description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{/* Recipients */}
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Users className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span className="text-xs font-medium text-blue-900">Recipients</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-blue-900">{campaign.totalRecipients.toLocaleString()}</p>
|
||||
{campaign.totalRecipients > 0 && (
|
||||
<p className="text-xs text-blue-700 mt-1">{deliveryProgress.toFixed(0)}% sent</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open Rate */}
|
||||
{campaign.sentCount > 0 && (
|
||||
<div className="bg-purple-50 border border-purple-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Mail className="h-3.5 w-3.5 text-purple-600" />
|
||||
<span className="text-xs font-medium text-purple-900">Opens</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-purple-900">{openRate.toFixed(1)}%</p>
|
||||
<p className="text-xs text-purple-700 mt-1">{campaign.openedCount.toLocaleString()} opened</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Click Rate */}
|
||||
{campaign.clickedCount > 0 && (
|
||||
<div className="bg-orange-50 border border-orange-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Mail className="h-3.5 w-3.5 text-orange-600" />
|
||||
<span className="text-xs font-medium text-orange-900">Clicks</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-orange-900">{clickRate.toFixed(1)}%</p>
|
||||
<p className="text-xs text-orange-700 mt-1">{campaign.clickedCount.toLocaleString()} clicked</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scheduled For */}
|
||||
{campaign.scheduledFor && (
|
||||
<div className="bg-green-50 border border-green-100 rounded-lg p-3 md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-xs font-medium text-green-900">Scheduled</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-green-900">
|
||||
{new Date(campaign.scheduledFor).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-green-700 mt-1">
|
||||
{new Date(campaign.scheduledFor).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2 border-t border-neutral-100">
|
||||
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
{campaign.status === 'DRAFT' ? 'Edit Campaign' : 'View Details'}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => handleDuplicate(campaign.id)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{(campaign.status === 'SCHEDULED' || campaign.status === 'SENDING') && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCampaignToCancel(campaign.id);
|
||||
setShowCancelDialog(true);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="flex items-center px-4 text-sm text-neutral-600">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.min(data.totalPages, p + 1))}
|
||||
disabled={page === data.totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showCancelDialog}
|
||||
onOpenChange={setShowCancelDialog}
|
||||
onConfirm={handleCancel}
|
||||
title="Cancel Campaign"
|
||||
description="Are you sure you want to cancel this campaign?"
|
||||
confirmText="Cancel Campaign"
|
||||
variant="destructive"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user