first wokring prototype
This commit is contained in:
305
src/features/campaigns/RecipientDataPage.tsx
Normal file
305
src/features/campaigns/RecipientDataPage.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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 StatusBadge from "../../components/StatusBadge";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, stringifyPreview, versionLockReason } from "./utils/campaignView";
|
||||
import { ensureCampaignDraft, getBool, parseJsonTextarea, stringifyJson, updateNested } from "./utils/draftEditor";
|
||||
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
import {
|
||||
addressesFromValue,
|
||||
collectCampaignAddressSuggestions,
|
||||
type MailboxAddress
|
||||
} from "../../utils/emailAddresses";
|
||||
|
||||
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." }
|
||||
];
|
||||
|
||||
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveState, setSaveState] = useState("Loaded");
|
||||
const [localError, setLocalError] = useState("");
|
||||
const loadedVersionId = useRef<string | null>(null);
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
const recipientsSection = asRecord(draft?.recipients);
|
||||
const entries = asRecord(draft?.entries);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const source = asRecord(entries.source);
|
||||
const fieldNames = useMemo(() => getDraftFieldNames(draft), [draft]);
|
||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
|
||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||
to: addressesFromValue(recipientsSection.to),
|
||||
cc: addressesFromValue(recipientsSection.cc),
|
||||
bcc: addressesFromValue(recipientsSection.bcc)
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!version) return;
|
||||
if (loadedVersionId.current === version.id) return;
|
||||
loadedVersionId.current = version.id;
|
||||
setDraft(ensureCampaignDraft(version));
|
||||
setDirty(false);
|
||||
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
|
||||
}, [version]);
|
||||
|
||||
|
||||
function patch(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
setDraft((current) => updateNested(current ?? {}, path, value));
|
||||
setDirty(true);
|
||||
setLocalError("");
|
||||
}
|
||||
|
||||
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
|
||||
patch(["entries", "inline"], nextEntries);
|
||||
}
|
||||
|
||||
function appendRecipient(address: MailboxAddress) {
|
||||
const nextEntry = {
|
||||
id: `recipient-${inlineEntries.length + 1}`,
|
||||
active: true,
|
||||
to: [address],
|
||||
name: address.name ?? "",
|
||||
email: address.email,
|
||||
fields: {},
|
||||
attachments: []
|
||||
};
|
||||
replaceInlineEntries([...inlineEntries, nextEntry]);
|
||||
}
|
||||
|
||||
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
|
||||
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
|
||||
replaceInlineEntries(nextEntries);
|
||||
}
|
||||
|
||||
function updateEntryRecipient(index: number, addresses: MailboxAddress[]) {
|
||||
const address = addresses[0] ?? { name: "", email: "" };
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
to: address.email ? [address] : [],
|
||||
name: address.name ?? "",
|
||||
email: address.email
|
||||
}));
|
||||
}
|
||||
|
||||
function updateEntryField(index: number, field: string, value: string) {
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
fields: {
|
||||
...asRecord(entry.fields),
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function updateEntryAttachments(index: number, text: string) {
|
||||
const parsed = parseJsonTextarea(text, asArray(inlineEntries[index]?.attachments));
|
||||
if (parsed.error) {
|
||||
setLocalError(`Invalid attachment JSON in row ${index + 1}: ${parsed.error}`);
|
||||
return;
|
||||
}
|
||||
updateEntry(index, (entry) => ({ ...entry, attachments: parsed.value }));
|
||||
}
|
||||
|
||||
function removeEntry(index: number) {
|
||||
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
|
||||
}
|
||||
|
||||
async function saveRecipients(mode: "auto" | "manual" = "manual"): Promise<boolean> {
|
||||
if (!draft || !version || locked) return false;
|
||||
setSaveState("Saving…");
|
||||
setError("");
|
||||
setLocalError("");
|
||||
try {
|
||||
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
|
||||
campaign_json: draft,
|
||||
current_flow: "manual",
|
||||
current_step: "recipient-data",
|
||||
workflow_state: "editing",
|
||||
is_complete: false
|
||||
});
|
||||
setDraft(ensureCampaignDraft(saved));
|
||||
setDirty(false);
|
||||
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err));
|
||||
setSaveState("Save failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
|
||||
title: "Unsaved recipient changes",
|
||||
message: "Recipients or recipient header settings have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
onSave: () => saveRecipients("manual"),
|
||||
onDiscard: () => setDirty(false)
|
||||
} : null);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Recipients</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveRecipients("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{localError && <div className="alert danger">{localError}</div>}
|
||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
||||
|
||||
{draft && (
|
||||
<>
|
||||
<Card title="Global recipient headers">
|
||||
<div className="campaign-header-stack">
|
||||
{recipientHeaderRows.map((row) => (
|
||||
<div className="campaign-header-grid" key={row.key}>
|
||||
<FormField label={row.label}>
|
||||
<EmailAddressInput
|
||||
value={globalRecipientValues[row.key] ?? []}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple
|
||||
disabled={locked}
|
||||
addLabel={row.addLabel}
|
||||
emptyText={row.emptyText}
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", row.key], addresses)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="campaign-header-toggle">
|
||||
<ToggleSwitch
|
||||
label={row.toggleLabel}
|
||||
checked={getBool(recipientsSection, row.toggleKey)}
|
||||
disabled={locked}
|
||||
onChange={(checked) => patch(["recipients", row.toggleKey], checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Recipients" actions={<span className="muted small-note">Editable inline recipients with mail-style address chips, field values and individual attachment config.</span>}>
|
||||
{draft && inlineEntries.length === 0 && !source.type && <p className="muted">No recipient data is stored in the current version yet.</p>}
|
||||
{draft && inlineEntries.length === 0 && Boolean(source.type) && (
|
||||
<div className="alert info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</div>
|
||||
)}
|
||||
{draft && (
|
||||
<div className="app-table-wrap recipient-table-wrap">
|
||||
<table className="app-table recipient-table recipient-editor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Recipient</th>
|
||||
<th>Status</th>
|
||||
{fieldNames.map((field) => <th key={field}>{field}</th>)}
|
||||
<th>Individual attachments</th>
|
||||
<th aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="recipient-add-row">
|
||||
<td className="mono-small">+</td>
|
||||
<td colSpan={Math.max(2, fieldNames.length + 3)}>
|
||||
<EmailAddressInput
|
||||
value={[]}
|
||||
suggestions={addressSuggestions}
|
||||
clearOnAdd
|
||||
disabled={locked || !draft}
|
||||
addLabel="Add recipient"
|
||||
emptyText="Add a new inline recipient."
|
||||
onAddressAdded={appendRecipient}
|
||||
/>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{inlineEntries.slice(0, 100).map((entry, index) => {
|
||||
const recipient = primaryRecipient(entry);
|
||||
const fields = asRecord(entry.fields);
|
||||
const attachments = asArray(entry.attachments);
|
||||
return (
|
||||
<tr key={String(entry.id || index)}>
|
||||
<td className="mono-small">{index + 1}</td>
|
||||
<td>
|
||||
<EmailAddressInput
|
||||
value={recipient.email ? [recipient] : []}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={false}
|
||||
compact
|
||||
disabled={locked}
|
||||
addLabel={recipient.email ? "Replace" : "Add"}
|
||||
emptyText="No recipient address."
|
||||
onChange={(addresses) => updateEntryRecipient(index, addresses)}
|
||||
/>
|
||||
</td>
|
||||
<td><StatusBadge status={String(entry.active === false ? "inactive" : "active")} /></td>
|
||||
{fieldNames.map((field) => (
|
||||
<td key={field}>
|
||||
<input
|
||||
className="recipient-field-input"
|
||||
value={String(fields[field] ?? "")}
|
||||
disabled={locked}
|
||||
onChange={(event) => updateEntryField(index, field, event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<textarea
|
||||
className="recipient-attachments-input"
|
||||
rows={2}
|
||||
value={attachments.length ? stringifyJson(attachments) : "[]"}
|
||||
disabled={locked}
|
||||
title={attachments.length ? stringifyPreview(attachments, 180) : undefined}
|
||||
onChange={(event) => updateEntryAttachments(index, event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveRecipients("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDraftFieldNames(draft: Record<string, unknown> | null): string[] {
|
||||
return asArray(draft?.fields)
|
||||
.map((field) => asRecord(field))
|
||||
.map((field) => String(field.name || field.id || ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function primaryRecipient(entry: Record<string, unknown>): MailboxAddress {
|
||||
const to = addressesFromValue(entry.to)[0];
|
||||
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
|
||||
return to ?? direct ?? { name: "", email: "" };
|
||||
}
|
||||
Reference in New Issue
Block a user