/** * Campaign detail — a right-side Sheet that adapts to campaign status * (BUILD-2285). Modelled on GoHighLevel's campaign Summary page. * * - sent / sending / paused → **Statistics**: meta + KPI tiles + engagement * chart + delivery health, and a Recipients tab (recipient-level table). * Header actions vary by status (Pause / Resume / Duplicate, Export CSV). * - scheduled → **Schedule info**: nothing has sent yet, so no stats — shows * the schedule, an email preview, and Edit / Cancel / Send-now actions. * * Rates are numeric percentages; pass `formatRate` to change the default * one-decimal "42.1%" rendering. */ import { type ReactElement, useMemo, useState } from "react"; import { CalendarClock, Download, Pause, Play, Send, TriangleAlert, } from "lucide-react"; import type { ColumnDef, ColumnFiltersState, PaginationState, } from "@tanstack/react-table"; import { Badge } from "./badge"; import { Button } from "./button"; import { DataTable } from "./data-table"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "./sheet"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; import { ToggleGroup, ToggleGroupItem } from "./toggle-group"; import { EmailEngagementChart, type EmailEngagementPoint, } from "./email-engagement-chart"; import { EmailStatusBadge, type EmailStatus } from "./email-marketing-primitives"; import { EmailStatTile, EmailDeliveryHealth, type EmailDeliverySegment, type EmailOverviewStat, } from "./email-stat-widgets"; export type CampaignRecipientStatus = | "queued" | "delivered" | "opened" | "clicked" | "bounced" | "complained" | "unsubscribed" | "failed"; export interface CampaignRecipientRow { id: string; name: string; email: string; status: CampaignRecipientStatus; timestamp: string; } export interface CampaignDetail { id: string; name: string; status: EmailStatus; templateName?: string; scheduleSummary?: string; /** Undefined while the count is still being fetched. */ recipientCount?: number; /** Engagement + delivery rates as percentages (e.g. 42.1). */ openRate?: number; clickRate?: number; /** SES-tracked delivery / bounce rates. */ deliveredRate?: number; bounceRate?: number; complaintRate?: number; unsubscribeRate?: number; /** The template's body as it stands now; rendered sandboxed in the preview. */ htmlContent?: string; /** Why the campaign errored; present only for `status: "error"`. */ errorReason?: string; /** Recurring series — gates whether a "sending" campaign can be paused. */ isRecurring?: boolean; /** * Whether the campaign has ever completed a run. A recurring campaign sits at `scheduled` * between runs, so status alone cannot tell "waiting for its first send" from "waiting for * its next one" — and the second has real statistics to show. */ hasSent?: boolean; } /** * Server-side recipient totals. Supplied by a backend that reduces the roster itself, so the * chips and the "all" total describe the whole campaign rather than whatever page is loaded. */ export interface CampaignRecipientCounts { /** Rows matching EVERY active filter, the status chip included — this is what paging counts. */ total: number; /** Per-status totals for the whole roster, narrowed by search but never by the chip. */ byStatus: Partial>; } /** * Server-driven recipient table. Supply it and the roster is filtered, searched and paged by the * backend: the component renders exactly the rows it is given and reports intent through the * callbacks. Omit it and the table filters and pages the rows it already holds, which is only * correct when those rows are the entire roster. */ export interface CampaignRecipientQuery { status: CampaignRecipientStatus | "all"; onStatusChange: (status: CampaignRecipientStatus | "all") => void; search: string; onSearchChange: (search: string) => void; pagination: PaginationState; onPaginationChange: (pagination: PaginationState) => void; /** Rows matching the active filters, across every page. */ rowCount: number; isLoading?: boolean; /** * The campaign has not sent yet, so these rows are who it WOULD reach, resolved live rather * than read from a roster. Status filtering is meaningless (everyone is queued) so the chips * are hidden; search still applies. */ isAudiencePreview?: boolean; } export interface CampaignDetailSheetProps { /** The campaign to show; `null` closes the sheet. */ campaign: CampaignDetail | null; onOpenChange: (open: boolean) => void; recipients: CampaignRecipientRow[]; /** Omit for a single-run campaign; the label then shows one number. */ /** Omit to count the loaded rows instead — correct only when they are the whole roster. */ recipientCounts?: CampaignRecipientCounts; /** Omit for the client-side table; supply for server-driven filtering/search/paging. */ recipientQuery?: CampaignRecipientQuery; engagement: EmailEngagementPoint[]; deliveryBreakdown: EmailDeliverySegment[]; /** Edit a scheduled campaign (opens the composer). */ onEdit?: (id: string) => void; /** Pause a sending recurring campaign / resume a paused one. */ onTogglePause?: (id: string) => void; onExport?: (id: string) => void; /** Cancel a scheduled campaign before it sends. */ onCancelSchedule?: (id: string) => void; /** Dispatch a scheduled campaign immediately. */ onSendNow?: (id: string) => void; /** Renders every rate value; defaults to one-decimal percent. */ formatRate?: (value: number) => string; } const defaultFormatRate = (value: number): string => `${value.toFixed(1)}%`; const RECIPIENT_BADGE: Record< CampaignRecipientStatus, "default" | "secondary" | "destructive" | "outline" > = { queued: "outline", clicked: "default", opened: "secondary", delivered: "outline", bounced: "destructive", complained: "destructive", unsubscribed: "outline", failed: "destructive", }; const RECIPIENT_STATUS_FILTERS: (CampaignRecipientStatus | "all")[] = [ "all", "queued", "delivered", "opened", "clicked", "bounced", "complained", "unsubscribed", "failed", ]; /** * Summary KPI tiles for a sent campaign — mirrors the metrics AWS SES reports * (Deliveries, Opens, Clicks, Bounces, Complaints) plus the app-level * Unsubscribes, so the numbers map 1:1 to what the sending stack can track. */ function buildTiles( campaign: CampaignDetail, formatRate: (value: number) => string, ): EmailOverviewStat[] { const rate = (value: number | undefined): string => value === undefined ? "—" : formatRate(value); return [ { label: "Recipients", value: formatRecipientCount(campaign.recipientCount) }, { label: "Delivered", value: rate(campaign.deliveredRate) }, { label: "Opened", value: rate(campaign.openRate) }, { label: "Clicked", value: rate(campaign.clickRate) }, { label: "Bounced", value: rate(campaign.bounceRate) }, { label: "Complaints", value: rate(campaign.complaintRate) }, { label: "Unsubscribed", value: rate(campaign.unsubscribeRate) }, ]; } /** Shared meta list — label/value rows, used by both detail views so their * layout stays identical. */ function MetaList({ rows, }: { rows: { label: string; value: string }[]; }): ReactElement { return (
{rows.map((r) => (
{r.label}
{r.value}
))}
); } /** Both detail views show the same meta rows. */ function metaRows( campaign: CampaignDetail, ): { label: string; value: string }[] { return [ { label: "Template", value: campaign.templateName ?? "—" }, { label: "Recipients", value: formatRecipientCount(campaign.recipientCount) }, { label: "Schedule", value: campaign.scheduleSummary ?? "—" }, ]; } const RECIPIENT_COLUMNS: ColumnDef[] = [ { accessorKey: "name", header: "Recipient", cell: ({ row }) => (

{row.original.name}

{row.original.email}

), }, { accessorKey: "status", header: "Status", cell: ({ row }) => ( {row.original.status} ), }, { accessorKey: "timestamp", header: "Last activity", cell: ({ row }) => ( {row.original.timestamp} ), }, ]; // Scheduled campaigns haven't sent, so the audience shows who *will* receive // it — name/email + a "Queued" status (no engagement events yet). const AUDIENCE_COLUMNS: ColumnDef[] = [ RECIPIENT_COLUMNS[0], { id: "queued", header: "Status", cell: () => Queued, }, ]; /** * What was (or will be) sent. Shown for every status: a campaign that has already gone out is * the one you most often need to read back, and a recurring series is about to send it again. */ function EmailPreview({ campaign }: { campaign: CampaignDetail }): ReactElement { return (
Preview {campaign.htmlContent ? (