Files
plunk/apps/web/src/pages/campaigns/index.tsx
T

537 lines
23 KiB
TypeScript

import {
Badge,
Button,
Card,
CardContent,
ConfirmDialog,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import type {Campaign, Template} from '@plunk/db';
import {CampaignStatus} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {EmptyState} from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout';
import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog';
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Trash2} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
export default function CampaignsPage() {
const router = useRouter();
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>('ALL');
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [campaignToDelete, setCampaignToDelete] = useState<string | null>(null);
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
const {data, mutate, isLoading} = useSWR<PaginatedResponse<Campaign>>(
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
{revalidateOnFocus: false},
);
const getStatusBadge = (status: CampaignStatus) => {
const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = {
DRAFT: {label: 'Draft', variant: 'neutral'},
SCHEDULED: {label: 'Scheduled', variant: 'default'},
SENDING: {label: 'Sending', variant: 'default'},
SENT: {label: 'Sent', variant: 'success'},
CANCELLED: {label: 'Cancelled', variant: 'neutral'},
};
const {label, variant} = config[status];
return <Badge variant={variant} className="shrink-0">{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');
}
};
const handleDelete = async () => {
if (!campaignToDelete) return;
try {
await network.fetch('DELETE', `/campaigns/${campaignToDelete}`);
toast.success('Campaign deleted successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete campaign');
} finally {
setCampaignToDelete(null);
}
};
const handleSelectTemplate = (
template: Template,
selectedFields: {
subject: boolean;
body: boolean;
from: boolean;
fromName: boolean;
replyTo: boolean;
},
) => {
// Navigate to create page with template data as query params
const query: Record<string, string> = {
name: `${template.name}`,
};
// Only include templateId if body is selected (needed to fetch body content)
if (selectedFields.body) {
query.templateId = template.id;
}
// Add selected fields to query params
if (selectedFields.subject) {
query.subject = template.subject;
}
if (selectedFields.from) {
query.from = template.from;
}
if (selectedFields.fromName && template.fromName) {
query.fromName = template.fromName;
}
if (selectedFields.replyTo && template.replyTo) {
query.replyTo = template.replyTo;
}
void router.push({
pathname: '/campaigns/create',
query,
});
};
const handleSelectCampaign = (
campaign: Campaign,
selectedFields: {
subject: boolean;
body: boolean;
from: boolean;
fromName: boolean;
replyTo: boolean;
audience: boolean;
},
) => {
// Navigate to create page with campaign data as query params
const query: Record<string, string> = {
name: `${campaign.name}`,
};
// Only include campaignId if body is selected (needed to fetch body content)
if (selectedFields.body) {
query.campaignId = campaign.id;
}
// Add selected fields to query params
if (selectedFields.subject) {
query.subject = campaign.subject;
}
if (selectedFields.from) {
query.from = campaign.from;
}
if (selectedFields.fromName && campaign.fromName) {
query.fromName = campaign.fromName;
}
if (selectedFields.replyTo && campaign.replyTo) {
query.replyTo = campaign.replyTo;
}
if (selectedFields.audience) {
query.audienceType = campaign.audienceType;
if (campaign.segmentId) {
query.segmentId = campaign.segmentId;
}
}
void router.push({
pathname: '/campaigns/create',
query,
});
};
return (
<>
<NextSeo title="Campaigns" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Campaigns</h1>
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="w-full sm:w-auto">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create Campaign</span>
<span className="sm:hidden">Create</span>
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" 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>
</div>
{/* Filters */}
<div className="w-56">
<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>
{/* Campaigns List */}
<div className="space-y-4">
{isLoading && (
<Card>
<CardContent className="py-8 text-center text-neutral-500">Loading campaigns...</CardContent>
</Card>
)}
{!isLoading && data?.data.length === 0 && (
<Card>
<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 Campaign
</Button>
</Link>
)
}
/>
</CardContent>
</Card>
)}
{data?.data.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 deliveryPct = campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0;
return (
<Card key={campaign.id} className="transition-colors hover:border-neutral-300 flex flex-col">
<Link
href={`/campaigns/${campaign.id}`}
className="flex-1 block p-6 pb-4 hover:bg-neutral-50/50 transition-colors rounded-t-xl"
aria-label={`Open ${campaign.name}`}
>
<div className="flex items-start justify-between gap-3 mb-3">
<h3 className="font-semibold text-neutral-900 leading-snug truncate">{campaign.name}</h3>
{getStatusBadge(campaign.status)}
</div>
<div className="flex items-center gap-3 text-sm flex-wrap">
{campaign.status === 'DRAFT' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{campaign.totalRecipients.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">estimated recipients</span>
</span>
</>
)}
{campaign.status === 'SCHEDULED' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{campaign.totalRecipients.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">recipients</span>
</span>
{campaign.scheduledFor && (
<>
<span className="h-3 w-px bg-neutral-200" />
<span className="text-xs text-neutral-500">
Sending {dayjs(campaign.scheduledFor).format('MMM D, YYYY [at] h:mm A')}
</span>
</>
)}
</>
)}
{campaign.status === 'SENDING' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{deliveryPct.toFixed(0)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">delivered</span>
</span>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{openRate.toFixed(1)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">opens</span>
</span>
</>
)}
{campaign.status === 'SENT' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{campaign.sentCount.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">sent</span>
</span>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{openRate.toFixed(1)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">opens</span>
</span>
{clickRate > 0 && (
<>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{clickRate.toFixed(1)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">clicks</span>
</span>
</>
)}
</>
)}
{campaign.status === 'CANCELLED' && (
<span>
<strong className="font-semibold text-neutral-900">{campaign.totalRecipients.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">recipients</span>
</span>
)}
</div>
</Link>
<div className="px-6 py-3 border-t border-neutral-100 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-neutral-400">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Updated {formatRelativeTime(campaign.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(campaign.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Link href={`/campaigns/${campaign.id}`} aria-label={campaign.status === 'DRAFT' ? 'Edit campaign' : 'View campaign'}>
<Button variant="ghost" size="sm" title={campaign.status === 'DRAFT' ? 'Edit campaign' : 'View campaign'}>
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="sm" title="Duplicate campaign" onClick={() => handleDuplicate(campaign.id)}>
<Copy className="h-4 w-4" />
</Button>
{campaign.status === 'DRAFT' && (
<Button
variant="ghost"
size="sm"
title="Delete campaign"
onClick={() => {
setCampaignToDelete(campaign.id);
setShowDeleteDialog(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
{(campaign.status === 'SCHEDULED' || campaign.status === 'SENDING') && (
<Button
variant="ghost"
size="sm"
title="Cancel campaign"
onClick={() => {
setCampaignToCancel(campaign.id);
setShowCancelDialog(true);
}}
>
<Ban className="h-4 w-4" />
</Button>
)}
</div>
</div>
</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"
/>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Campaign"
description="Are you sure you want to delete this draft campaign? This action cannot be undone."
confirmText="Delete Campaign"
variant="destructive"
/>
<TemplateSelectionDialog
open={showTemplateDialog}
onOpenChange={setShowTemplateDialog}
onSelectTemplate={handleSelectTemplate}
/>
<CampaignSelectionDialog
open={showCampaignDialog}
onOpenChange={setShowCampaignDialog}
onSelectCampaign={handleSelectCampaign}
/>
</DashboardLayout>
</>
);
}