/** * Email Campaign Composer — modal Dialog + horizontal Stepper. * * GHL-aligned 3-step flow: * 1. Content — template and subject line. * 2. Recipients & Sender — send-to (all / smart list / by tag) + the read-only sender. * 3. Schedule & Review — send now / schedule / recurring, then a summary. * * The sender is **read-only** (one configured identity — not editable per * campaign; deliverability/brand), the unsubscribe footer is always included, * scheduling can't be set to a past date, and the recurring cadence is a plain * day count (7 / 14 / 30 presets provided). * * Fully controlled: templates, smart lists and the sender identity come in as * props, and `onSend` receives the composed campaign payload. */ import { type ReactElement, useEffect, useRef, useState } from "react"; import { ArrowUpRight, CalendarClock, Repeat, Send } from "lucide-react"; import { Button } from "./button"; import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue, } from "./combobox"; import { DatePicker } from "./date-picker"; import { RadioGroup, RadioGroupItem } from "./radio-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./select"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "./dialog"; import { Step, StepItem, Stepper } from "./stepper"; import { TagInput } from "./tag-input"; import { TimePicker } from "./time-picker"; import { Field, FieldError, FieldLabel } from "./field"; import { Input } from "./input"; import { Label } from "./label"; const STEPS = ["Content", "Recipients", "Schedule"] as const; /** Common cadences surfaced as one-click presets over the day-count input. */ const RECURRENCE_PRESETS: { label: string; days: number }[] = [ { label: "Weekly", days: 7 }, { label: "Fortnightly", days: 14 }, { label: "Monthly", days: 30 }, ]; /** Human AM/PM label for a stored 24h "HH:MM" value (any minute). */ const timeLabel = (v: string): string => { const [h, m] = v.split(":").map(Number); if (Number.isNaN(h) || Number.isNaN(m)) return v; const h12 = h % 12 === 0 ? 12 : h % 12; return `${h12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`; }; export interface CampaignTemplateOption { id: string; title: string; subject: string; } export interface CampaignSmartListOption { id: string; name: string; count: number; } export interface CampaignSenderIdentity { fromName: string; fromEmail: string; } export type CampaignRecipientMode = "all" | "smart_list" | "tags"; export type CampaignSendMode = "now" | "one_off" | "recurring"; /** Everything the composer collected, ready for the create-campaign API. */ export interface EmailCampaignPayload { name: string; templateId: string | null; subject: string; recipientMode: CampaignRecipientMode; smartListId: string | null; /** Selected tags when recipientMode is "tags" — a contact matching ANY is included. */ tags: string[]; sendMode: CampaignSendMode; /** Local date+time combined; undefined for send-now. */ scheduledAt?: Date; /** Days between sends; only for recurring. */ recurrenceIntervalDays?: number; } export interface EmailCampaignComposerProps { open: boolean; onOpenChange: (open: boolean) => void; /** Templates the broker can start from — both System and Personal. */ templates: CampaignTemplateOption[]; /** * Typing in the template picker. `templates` is a capped first page, so anything past the cap * is only reachable by searching server-side — the picker filters nothing locally. */ onTemplateSearch?: (term: string) => void; isTemplateLoading?: boolean; /** Saved Contacts segments the composer can send to. */ smartLists: CampaignSmartListOption[]; /** The company's configured sender identity (read-only display). */ sender: CampaignSenderIdentity; onSend?: (payload: EmailCampaignPayload) => void; initialCampaignName?: string; /** Preselect a template (e.g. opened via a template card's "Create campaign"). */ initialTemplateId?: string | null; /** * The selected template's name, for when it is not in `templates` — the list is a searched * page, so an edited campaign's template, or one carried in from a template card, is often * absent from it. Without this the trigger falls back to rendering the raw id. */ initialTemplateName?: string; initialSubject?: string; initialRecipientMode?: CampaignRecipientMode; initialSmartListId?: string | null; /** The broker's tag library, for autocomplete when targeting by tag. */ tagSuggestions?: string[]; initialTags?: string[]; initialSendMode?: CampaignSendMode; /** The campaign's current send time — seeds both the date and the time controls. */ initialScheduledAt?: Date | null; initialRecurrenceIntervalDays?: number; /** Header text; edit flows pass the campaign's own heading. */ title?: string; /** Final-step button; edit flows pass something other than "Confirm & Send". */ submitLabel?: string; /** * Editing an existing campaign. Its content was snapshotted at creation, so the template is * fixed — and a campaign that already exists is saved, not sent. */ isEdit?: boolean; /** Manage-smart-lists affordance (links out to Contacts in the app). */ onManageSmartLists?: () => void; } /** * A send must be far enough out that the schedule is worth having; the backend rejects anything * closer, so the composer refuses it first rather than trading a picker for a 400. */ const MIN_SCHEDULE_LEAD_MS = 30 * 60 * 1000; /** "HH:MM" in local time, snapped up to the next half hour. */ const toTimeValue = (date: Date): string => `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; export function EmailCampaignComposer({ open, onOpenChange, templates, onTemplateSearch, isTemplateLoading = false, smartLists, sender, onSend, initialCampaignName = "", initialTemplateId, initialTemplateName = "", initialSubject = "", initialRecipientMode = "all", initialSmartListId = null, tagSuggestions = [], initialTags = [], initialSendMode = "now", initialScheduledAt = null, initialRecurrenceIntervalDays = 7, title = "New Campaign", submitLabel, isEdit = false, onManageSmartLists, }: EmailCampaignComposerProps): ReactElement { const [activeStep, setActiveStep] = useState(0); // 1 — Content const [campaignName, setCampaignName] = useState(initialCampaignName); const [templateId, setTemplateId] = useState( initialTemplateId ?? (templates.length > 0 ? templates[0].id : null), ); const [templateLabel, setTemplateLabel] = useState(initialTemplateName); const [subject, setSubject] = useState(initialSubject); // A ref, not state: emptying the field must not re-run the effect and refill it under the // cursor. It is read only when the template changes, which is the moment that matters. const isSubjectOwnEdit = useRef(Boolean(initialSubject)); // The one place the subject follows the template. Templates arrive after open, so neither the // initial useState nor the picker's change handler can do it alone: the handler fires only on a // user pick, and by the time the list lands the selection already exists — whether it came from // `initialTemplateId` or from defaulting to the first. The label is remembered separately // because searching replaces `templates` with rows that need not include the selection. useEffect(() => { if (templates.length === 0) return; const selected = templateId ? templates.find((t) => t.id === templateId) : templates[0]; if (!selected) return; if (!templateId) setTemplateId(selected.id); setTemplateLabel(selected.title); if (!isSubjectOwnEdit.current) setSubject(selected.subject); }, [templates, templateId]); // 2 — Recipients (sender is read-only) const [recipientMode, setRecipientMode] = useState(initialRecipientMode); const [smartListId, setSmartListId] = useState( initialSmartListId, ); const [tags, setTags] = useState(initialTags); // 3 — Schedule // An existing campaign is never edited into sending "now" — that is the detail sheet's Send now // button, which dispatches rather than rewriting the schedule to a minute out. A campaign that // still carries `now` while waiting to go out is inconsistent, so it opens as a one-off. const [sendMode, setSendMode] = useState( isEdit && initialSendMode === "now" ? "one_off" : initialSendMode, ); const [scheduledDate, setScheduledDate] = useState( initialScheduledAt ?? undefined, ); const [scheduledTime, setScheduledTime] = useState( initialScheduledAt ? toTimeValue(initialScheduledAt) : "09:00", ); const [recurrenceIntervalDays, setRecurrenceIntervalDays] = useState( initialRecurrenceIntervalDays, ); const [showErrors, setShowErrors] = useState(false); // Scheduling can't be set in the past — block earlier days in the calendar. const today = new Date(); today.setHours(0, 0, 0, 0); const selectedTemplate = templates.find((t) => t.id === templateId); const isLastStep = activeStep === STEPS.length - 1; const goNext = (): void => { setShowErrors(false); setActiveStep((s) => Math.min(s + 1, STEPS.length - 1)); }; const goBack = (): void => { setShowErrors(false); setActiveStep((s) => Math.max(s - 1, 0)); }; const buildScheduledAt = (): Date | undefined => { if (sendMode === "now" || !scheduledDate) return undefined; const [h, m] = scheduledTime.split(":").map(Number); const at = new Date(scheduledDate); at.setHours(Number.isNaN(h) ? 9 : h, Number.isNaN(m) ? 0 : m, 0, 0); return at; }; const scheduledAt = buildScheduledAt(); const stepErrors: Record[] = [ { campaignName: campaignName.trim() ? undefined : "Enter a campaign name.", templateId: templateId ? undefined : "Select a template.", subject: subject.trim() ? undefined : "Enter a subject line.", }, { smartListId: recipientMode === "smart_list" && !smartListId ? "Select a smart list." : undefined, tags: recipientMode === "tags" && tags.length === 0 ? "Select at least one tag." : undefined, }, { scheduledDate: sendMode !== "now" && !scheduledDate ? "Pick a send date." : scheduledAt && scheduledAt.getTime() < Date.now() + MIN_SCHEDULE_LEAD_MS ? "Pick a time at least 30 minutes from now." : undefined, recurrenceIntervalDays: sendMode === "recurring" && !(recurrenceIntervalDays >= 1) ? "Enter how many days between sends." : undefined, }, ]; const activeErrors = stepErrors[activeStep]; const activeHasErrors = Object.values(activeErrors).some(Boolean); const fieldError = (field: string): string | undefined => showErrors ? activeErrors[field] : undefined; const handlePrimaryAction = (): void => { if (activeHasErrors) { setShowErrors(true); return; } if (isLastStep) { onSend?.({ name: campaignName.trim(), templateId, subject: subject.trim(), recipientMode, smartListId: recipientMode === "smart_list" ? smartListId : null, tags: recipientMode === "tags" ? tags : [], sendMode, scheduledAt, recurrenceIntervalDays: sendMode === "recurring" ? recurrenceIntervalDays : undefined, }); onOpenChange(false); setActiveStep(0); setShowErrors(false); return; } goNext(); }; const selectedSmartList = smartLists.find((s) => s.id === smartListId); let recipientSummary = "All clients (not opted out)"; if (recipientMode === "smart_list") { recipientSummary = selectedSmartList ? `Smart list — ${selectedSmartList.name} · ${selectedSmartList.count.toLocaleString()} clients` : "Smart list — none selected"; } else if (recipientMode === "tags") { recipientSummary = tags.length ? `Tags — ${tags.join(", ")} · match any` : "Tags — none selected"; } const startDateLabel = scheduledDate ? scheduledDate.toLocaleDateString() : "the selected date"; const recurrencePresetLabel = RECURRENCE_PRESETS.find( (p) => p.days === recurrenceIntervalDays, )?.label; const recurrenceLabel = recurrencePresetLabel ?? `Every ${recurrenceIntervalDays} days`; let scheduleSummary = "Sends immediately after you confirm"; if (sendMode === "one_off") { scheduleSummary = `Scheduled for ${startDateLabel} at ${timeLabel(scheduledTime)}`; } else if (sendMode === "recurring") { scheduleSummary = `${recurrenceLabel}, from ${startDateLabel} at ${timeLabel(scheduledTime)}`; } const DELIVERY_LABELS: Record = { now: "Send now", one_off: "Scheduled", recurring: "Recurring", }; const deliveryLabel = DELIVERY_LABELS[sendMode]; const reviewRows: { label: string; value: string }[] = [ { label: "Subject", value: subject || "—" }, { label: "Template", value: selectedTemplate?.title ?? "None selected" }, { label: "Recipients", value: recipientSummary }, { label: "Sender", value: `${sender.fromName} · ${sender.fromEmail}`, }, { label: "Schedule", value: scheduleSummary }, ]; return ( {title}
{/* 1 — Content */} {activeStep === 0 && (
Campaign name { setCampaignName(e.target.value); }} placeholder="e.g. Spring Refinance Push" value={campaignName} /> {fieldError("campaignName")} Template t.id)} // Only the selection; the effect above owns the label and the subject. onValueChange={(v) => { setTemplateId((v as string | null) ?? null); }} value={templateId} > {/* The remembered label, not a lookup: the selected template is routinely absent from a searched page, and Value falls back to the raw id. */} {templateLabel} onTemplateSearch?.(e.target.value)} placeholder="Search templates…" /> {(id: string) => { const t = templates.find((x) => x.id === id); if (!t) return null; return ( {t.title} {t.subject} ); }} {isTemplateLoading ? "Searching…" : "No matching template."} {isEdit ? ( The content was snapshotted when this campaign was created, so its template can't be swapped — create a new campaign to use another. ) : null} {fieldError("templateId")} Subject line { setSubject(e.target.value); // Emptying it hands the field back to the template. isSubjectOwnEdit.current = e.target.value.trim().length > 0; }} placeholder="e.g. Your refinance options this month" value={subject} /> {fieldError("subject")}
)} {/* 2 — Recipients & Sender */} {activeStep === 1 && (
Send to { setRecipientMode(v as CampaignRecipientMode); }} value={recipientMode} > {recipientMode === "smart_list" && ( Smart list
Smart lists are saved client segments — create and edit them in Clients.
{fieldError("smartListId")}
)} {recipientMode === "tags" && ( Tags Contacts with any of the selected tags will receive this campaign. {fieldError("tags")} )} {/* Sender is a single configured identity — read-only here, not editable per campaign (deliverability + brand). */} Sender
{sender.fromName} {sender.fromEmail}
Configured in settings
)} {/* 3 — Schedule & Review */} {activeStep === 2 && (
Delivery { setSendMode(v as CampaignSendMode); }} value={sendMode} > {isEdit ? null : ( )} {sendMode === "recurring" && ( Repeat every
{ const days = Number(e.target.value); if (Number.isInteger(days) && days >= 1) { setRecurrenceIntervalDays(days); } }} type="number" value={recurrenceIntervalDays} /> days
{RECURRENCE_PRESETS.map((preset) => ( ))}
{fieldError("recurrenceIntervalDays")}
)} {sendMode !== "now" && (
{sendMode === "one_off" ? "Send date" : "Start date"} {fieldError("scheduledDate")} {sendMode === "one_off" ? "Send time" : "Start time"}
)}
Review
{campaignName || "Untitled campaign"}
{sendMode === "recurring" ? ( ) : ( )} {deliveryLabel}
{reviewRows.map((row) => (
{row.label}
{row.value}
))}
)}
); } export default EmailCampaignComposer;