import { useMemo } from "react"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; import FormField from "../../components/FormField"; import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; import ToggleSwitch from "../../components/ToggleSwitch"; import EmailAddressInput from "../../components/email/EmailAddressInput"; import DismissibleAlert from "../../components/DismissibleAlert"; import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { getBool } from "./utils/draftEditor"; import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../utils/emailAddresses"; import { insertAfter, moveArrayItem } from "../../utils/arrayOrder"; const recipientHeaderRows = [ { key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." }, { key: "cc", label: "CC", toggleKey: "allow_individual_cc", toggleLabel: "Allow individual CC", addLabel: "Add CC", emptyText: "No global CC recipients configured." }, { key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." } ] as const; type RecipientAddressKey = "to" | "cc" | "bcc"; type EntryAddressColumn = { key: RecipientAddressKey | "from" | "reply_to"; label: string; allowMultiple: boolean; addLabel: string; emptyText: string; }; export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const version = data.currentVersion; const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({ settings, campaignId, version, locked, reload, setError, currentStep: "recipients", unsavedTitle: "Unsaved recipient changes", unsavedMessage: "Recipient address data has unsaved changes. Save it before leaving, or discard it and continue." }); const recipientsSection = asRecord(displayDraft.recipients); const entries = asRecord(displayDraft.entries); const inlineEntries = asArray(entries.inline).map(asRecord); const source = asRecord(entries.source); const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]); const defaultFrom = addressesFromValue(recipientsSection.from); const globalReplyTo = addressesFromValue(recipientsSection.reply_to); const globalRecipientValues: Record = { to: addressesFromValue(recipientsSection.to), cc: addressesFromValue(recipientsSection.cc), bcc: addressesFromValue(recipientsSection.bcc) }; const entryAddressColumns = useMemo(() => { const columns: EntryAddressColumn[] = [{ key: "to", label: "To", allowMultiple: true, addLabel: "Add To", emptyText: "No recipient address." }]; if (getBool(recipientsSection, "allow_individual_cc")) columns.push({ key: "cc", label: "CC", allowMultiple: true, addLabel: "Add CC", emptyText: "No CC." }); if (getBool(recipientsSection, "allow_individual_bcc")) columns.push({ key: "bcc", label: "BCC", allowMultiple: true, addLabel: "Add BCC", emptyText: "No BCC." }); if (getBool(recipientsSection, "allow_individual_from")) columns.push({ key: "from", label: "Sender", allowMultiple: false, addLabel: "Set sender", emptyText: "Uses default sender." }); if (getBool(recipientsSection, "allow_individual_reply_to")) columns.push({ key: "reply_to", label: "Reply-To", allowMultiple: false, addLabel: "Set Reply-To", emptyText: "Uses global Reply-To." }); return columns; }, [recipientsSection]); function replaceInlineEntries(nextEntries: Record[]) { patch(["entries", "inline"], nextEntries); } function addRecipient(afterIndex = inlineEntries.length - 1) { const nextIndex = inlineEntries.length + 1; const newEntry = { id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`), active: true, to: [], cc: [], bcc: [], name: "", email: "", from: {}, reply_to: [] }; replaceInlineEntries(insertAfter(inlineEntries, afterIndex, newEntry)); } function updateEntry(index: number, updater: (entry: Record) => Record) { const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry); replaceInlineEntries(nextEntries); } function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) { updateEntry(index, (entry) => { if (key === "from") { return { ...entry, from: addresses[0] ?? { name: "", email: "" } }; } const nextEntry = { ...entry, [key]: key === "reply_to" ? addresses.slice(0, 1) : addresses }; if (key === "to") { const address = addresses[0] ?? { name: "", email: "" }; return { ...nextEntry, name: address.name ?? "", email: address.email }; } return nextEntry; }); } function removeEntry(index: number) { replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index)); } function moveEntry(index: number, targetIndex: number) { if (locked || index === targetIndex) return; replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex)); } return (
Sender & Recipients
{error && {error}} {localError && {localError}} {locked && } <>
patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })} />
patch(["recipients", "allow_individual_from"], checked)} />
patch(["recipients", "reply_to"], addresses.slice(0, 1))} />
patch(["recipients", "allow_individual_reply_to"], checked)} />
{recipientHeaderRows.map((row) => (
patch(["recipients", row.key], addresses)} />
patch(["recipients", row.toggleKey], checked)} />
))}
Import} > {inlineEntries.length === 0 && Boolean(source.type) && ( This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented. )} {!source.type && ( String(entry.id || index)} emptyText="No recipient profiles are stored in the current version yet." emptyAction={ addRecipient(-1)} disabled={locked} label="Add first recipient" />} className="recipient-table-wrap recipient-address-table" /> )}
); } type RecipientProfileColumnContext = { locked: boolean; entries: Record[]; entryAddressColumns: EntryAddressColumn[]; addressSuggestions: MailboxAddress[]; updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void; updateEntry: (index: number, updater: (entry: Record) => Record) => void; addRecipient: (afterIndex?: number) => void; moveEntry: (index: number, targetIndex: number) => void; removeEntry: (index: number) => void; }; function recipientProfileColumns({ locked, entries, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn>[] { return [ { id: "number", header: "#", width: 70, sortable: true, sticky: "start", render: (_entry, index) => {index + 1}, value: (_entry, index) => index + 1 }, ...entryAddressColumns.map((column): DataGridColumn> => ({ id: column.key, header: column.label, width: column.key === "to" ? "minmax(260px, 1.2fr)" : 250, resizable: true, filterable: true, render: (entry, index) => ( updateEntryAddressList(index, column.key, addresses)} /> ), value: (entry) => getEntryAddresses(entry, column.key).map((address) => `${address.name ?? ""} ${address.email ?? ""}`).join(", ") })), { id: "active", header: "Active", width: 130, sortable: true, filterable: true, render: (entry, index) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" }, { id: "actions", header: "Actions", width: 180, sticky: "end", render: (_entry, index) => ( addRecipient(index)} onRemove={() => removeEntry(index)} onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined} onMoveDown={index < entries.length - 1 ? () => moveEntry(index, index + 1) : undefined} addLabel="Add recipient below" removeLabel="Remove recipient" moveUpLabel="Move recipient up" moveDownLabel="Move recipient down" /> ) } ]; } function getEntryAddresses(entry: Record, key: EntryAddressColumn["key"]): MailboxAddress[] { if (key === "to") { const explicit = addressesFromValue(entry.to); return explicit.length ? explicit : fallbackRecipientAddress(entry); } if (key === "from") return addressesFromValue(entry.from).slice(0, 1); if (key === "reply_to") return addressesFromValue(entry.reply_to).slice(0, 1); return addressesFromValue(entry[key]); } function fallbackRecipientAddress(entry: Record): MailboxAddress[] { const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0]; return direct?.email ? [direct] : []; } function uniqueEntryId(entries: Record[], preferred: string): string { const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean)); if (!existing.has(preferred)) return preferred; let counter = entries.length + 1; let candidate = `recipient-${counter}`; while (existing.has(candidate)) { counter += 1; candidate = `recipient-${counter}`; } return candidate; }