first wokring prototype

This commit is contained in:
2026-06-10 04:10:02 +02:00
parent 50d779a537
commit 7491c0a1b4
90 changed files with 10799 additions and 1 deletions

View File

@@ -0,0 +1,253 @@
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 ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, stringifyPreview, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
type AttachmentRule = Record<string, unknown>;
export default function AttachmentsDataPage({ 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 attachments = asRecord(draft?.attachments);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
const entries = asRecord(draft?.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const individualRules = inlineEntries.flatMap((entry, index) => asArray(entry.attachments).map((rule) => ({ entry: String(entry.id || index + 1), ...asRecord(rule) })));
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 patchGlobalAttachment(index: number, patchValue: Partial<AttachmentRule>) {
const nextRules = globalRules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patchValue } : rule);
patch(["attachments", "global"], nextRules);
}
function addGlobalAttachment() {
const nextRules: AttachmentRule[] = [
...globalRules,
{
id: `global-${Date.now()}`,
label: "",
base_dir: "",
file_filter: "",
required: true,
include_subdirs: false,
allow_multiple: false,
missing_behavior: getText(attachments, "missing_behavior", "ask"),
ambiguous_behavior: getText(attachments, "ambiguous_behavior", "ask")
}
];
patch(["attachments", "global"], nextRules);
}
function removeGlobalAttachment(index: number) {
patch(["attachments", "global"], globalRules.filter((_, currentIndex) => currentIndex !== index));
}
async function saveDraft(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: "files",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved attachment settings",
message: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Attachments</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Manage files</Button>
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("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 && (
<>
<div className="dashboard-grid">
<Card title="Attachment area">
<dl className="detail-list">
<div><dt>Attachment base path</dt><dd>{String(attachments.base_path || ".")}</dd></div>
<div><dt>Global files</dt><dd>{globalRules.length}</dd></div>
<div><dt>Per-recipient patterns</dt><dd>{individualRules.length}</dd></div>
<div><dt>Upload support</dt><dd>Planned</dd></div>
</dl>
</Card>
<Card title="Campaign file storage">
<p className="muted">This section will become the Garage/S3-backed file picker for tenant, group and campaign attachment areas.</p>
<div className="placeholder-stack">
<span>Upload campaign files</span>
<span>Pick files for global attachment rules</span>
<span>Resolve missing or ambiguous individual matches</span>
</div>
</Card>
</div>
<Card title="Global attachment files" actions={<Button onClick={addGlobalAttachment} disabled={locked}>Add file</Button>}>
<div className="attachment-base-stack">
<div className="attachment-base-grid">
<FormField label="Attachment base path"><input value={getText(attachments, "base_path", ".")} disabled={locked} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
<div className="attachment-base-toggle"><ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} disabled={locked} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} /></div>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Default missing behavior"><select value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Default ambiguous behavior"><select value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
</div>
</div>
{globalRules.length === 0 ? (
<p className="muted small-note">No global files selected. Add files here only if every message should include them.</p>
) : (
<div className="app-table-wrap compact-table-wrap">
<table className="app-table direct-attachment-table">
<thead>
<tr>
<th>Label</th>
<th>Base dir</th>
<th>Selected file / pattern</th>
<th>Required</th>
<th>Include subdirs</th>
<th></th>
</tr>
</thead>
<tbody>
{globalRules.map((rule, index) => (
<tr key={String(rule.id ?? index)}>
<td><input value={getText(rule, "label")} disabled={locked} placeholder="Attachment label" onChange={(event) => patchGlobalAttachment(index, { label: event.target.value })} /></td>
<td><input value={getText(rule, "base_dir")} disabled={locked} placeholder="optional/folder" onChange={(event) => patchGlobalAttachment(index, { base_dir: event.target.value })} /></td>
<td><input value={getText(rule, "file_filter")} disabled={locked} placeholder="file.pdf or {{field}}.pdf" onChange={(event) => patchGlobalAttachment(index, { file_filter: event.target.value })} /></td>
<td><ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={locked} onChange={(checked) => patchGlobalAttachment(index, { required: checked })} /></td>
<td><ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={locked} onChange={(checked) => patchGlobalAttachment(index, { include_subdirs: checked })} /></td>
<td className="table-action-cell"><Button variant="danger" onClick={() => removeGlobalAttachment(index)} disabled={locked}>Remove</Button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
<Card title="Per-recipient file patterns" actions={<Link to="../recipients"><Button>Open recipients</Button></Link>}>
<p className="muted small-note">Individual file patterns can be edited on each recipient row. They are summarized here because file matching and upload review also belong to the Attachments workflow.</p>
<div className="app-table-wrap data-table-wrap">
<table className="app-table files-table">
<thead>
<tr>
<th>Recipient / entry</th>
<th>Label</th>
<th>Base dir</th>
<th>Filter</th>
<th>Options</th>
</tr>
</thead>
<tbody>
{individualRules.length === 0 && (
<tr><td colSpan={5} className="muted">No per-recipient file patterns are configured yet.</td></tr>
)}
{individualRules.map((rule, index) => <RuleRow key={`individual-${index}`} scope={`Entry ${rule.entry}`} rule={rule} />)}
</tbody>
</table>
</div>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
)}
</div>
);
}
function RuleRow({ scope, rule }: { scope: string; rule: Record<string, unknown> }) {
return (
<tr>
<td>{scope}</td>
<td>{String(rule.label || rule.id || "—")}</td>
<td><code>{String(rule.base_dir || "—")}</code></td>
<td><code>{String(rule.file_filter || "—")}</code></td>
<td><code>{stringifyPreview({ required: rule.required, allow_multiple: rule.allow_multiple, zip: rule.zip }, 120)}</code></td>
</tr>
);
}
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({
id: getText(rule, "id", `global-${Math.random().toString(36).slice(2)}`),
label: getText(rule, "label"),
base_dir: getText(rule, "base_dir", ""),
file_filter: getText(rule, "file_filter"),
include_subdirs: getBool(rule, "include_subdirs"),
required: getBool(rule, "required", true),
allow_multiple: getBool(rule, "allow_multiple"),
missing_behavior: getText(rule, "missing_behavior", "ask"),
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
}));
}

View File

@@ -0,0 +1,14 @@
import Card from "../../components/Card";
export default function CampaignAuditPage() {
return (
<div className="content-pad workspace-data-page">
<div className="page-heading">
<h1>Audit log</h1>
</div>
<Card title="Recent audit events">
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
</Card>
</div>
);
}

View File

@@ -0,0 +1,204 @@
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 ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../utils/emailAddresses";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const campaignModeOptions = ["draft", "test", "send"];
export default function CampaignDataPage({ 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 campaign = asRecord(draft?.campaign);
const recipients = asRecord(draft?.recipients);
const from = asRecord(recipients.from);
const defaultFrom = addressesFromValue(from);
const globalReplyTo = addressesFromValue(recipients.reply_to);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
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("");
}
async function saveDraft(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: "campaign-settings",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved general campaign data",
message: "General campaign data has unsaved changes. Save it before leaving, or discard it and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>General</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={() => saveDraft("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)} Copy the campaign before editing general campaign data.</div>}
{draft && (
<>
<div className="campaign-settings-stack">
<Card title="Campaign identity">
<div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID">
<input value={getText(campaign, "id")} disabled={locked} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
</FormField>
<FormField label="Mode">
<select value={getText(campaign, "mode", "draft")} disabled={locked} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</FormField>
<FormField label="Name">
<input value={getText(campaign, "name")} disabled={locked} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
</FormField>
<FormField label="Description">
<textarea rows={4} value={getText(campaign, "description")} disabled={locked} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
</FormField>
</div>
</Card>
<Card title="Campaign sender">
<div className="campaign-header-stack">
<div className="campaign-header-grid">
<FormField label="Default From address">
<EmailAddressInput
value={defaultFrom}
suggestions={addressSuggestions}
allowMultiple={false}
showAddButton={false}
disabled={locked}
addLabel={getText(from, "email") ? "Replace" : "Add sender"}
emptyText="No default sender configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual senders"
checked={getBool(recipients, "allow_individual_from")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_from"], checked)}
/>
</div>
</div>
<div className="campaign-header-grid">
<FormField label="Global Reply-To address">
<EmailAddressInput
value={globalReplyTo.slice(0, 1)}
suggestions={addressSuggestions}
allowMultiple={false}
showAddButton={false}
disabled={locked}
addLabel={globalReplyTo.length ? "Replace" : "Add Reply-To"}
emptyText="No Reply-To address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual Reply-To"
checked={getBool(recipients, "allow_individual_reply_to")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_reply_to"], checked)}
/>
</div>
</div>
</div>
</Card>
<Card title="Related campaign areas">
<div className="related-link-grid">
<Link to="../recipients" className="related-link-card">
<strong>Recipients</strong>
<span>Recipient rows, global recipient headers and recipient-specific header overrides.</span>
</Link>
<Link to="../global-settings" className="related-link-card">
<strong>Global settings</strong>
<span>Policies, attachment defaults, delivery defaults and opt-ins.</span>
</Link>
<Link to="../fields" className="related-link-card">
<strong>Fields</strong>
<span>Define fields, global values and recipient override behavior.</span>
</Link>
<Link to="../files" className="related-link-card">
<strong>Attachments</strong>
<span>Configure global attachments and per-recipient file patterns.</span>
</Link>
</div>
</Card>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,308 @@
import { useEffect, useMemo, useRef, useState } 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 ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const fieldTypeOptions = ["string", "integer", "double", "date", "password"];
type FieldDefinition = {
name: string;
label: string;
type: string;
required: boolean;
can_override: boolean;
};
export default function CampaignFieldsPage({ 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 fieldValueKeys = useRef<string[]>([]);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const fields = useMemo(() => normalizeFields(draft?.fields), [draft?.fields]);
const globalValues = asRecord(draft?.global_values);
const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]);
const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning;
useEffect(() => {
if (!version) return;
const nextDraft = migrateFieldOverridePolicy(ensureCampaignDraft(version), asRecord(version.editor_state));
fieldValueKeys.current = normalizeFields(nextDraft.fields).map((field) => field.name);
setDraft(nextDraft);
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patchDraft(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchFields(nextFields: FieldDefinition[]) {
patchDraft(["fields"], nextFields.map((field) => ({
name: field.name,
type: field.type || "string",
label: field.label,
required: field.required,
can_override: field.can_override
})));
}
function patchGlobalValues(nextValues: Record<string, unknown>) {
patchDraft(["global_values"], nextValues);
}
function setField(index: number, patchValue: Partial<FieldDefinition>) {
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, ...patchValue } : field);
patchFields(nextFields);
}
function renameField(index: number, nextName: string) {
const oldName = fields[index]?.name ?? "";
const cleanedName = nextName.trim();
const duplicate = Boolean(cleanedName) && fields.some((field, currentIndex) => currentIndex !== index && field.name === cleanedName);
const valueKey = fieldValueKeys.current[index] ?? oldName;
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, name: cleanedName } : field);
const nextGlobalValues = { ...globalValues };
if (!duplicate && cleanedName) {
if (valueKey && valueKey !== cleanedName && Object.prototype.hasOwnProperty.call(nextGlobalValues, valueKey)) {
nextGlobalValues[cleanedName] = nextGlobalValues[valueKey];
delete nextGlobalValues[valueKey];
}
fieldValueKeys.current[index] = cleanedName;
}
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
}
function addField() {
const name = uniqueFieldName(fields);
const nextFields = [...fields, { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }];
fieldValueKeys.current = [...fieldValueKeys.current, name];
const nextGlobalValues = { ...globalValues, [name]: "" };
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
}
function deleteField(index: number) {
const field = fields[index];
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index);
fieldValueKeys.current = fieldValueKeys.current.filter((_, currentIndex) => currentIndex !== index);
const nextGlobalValues = { ...globalValues };
if (field?.name) {
delete nextGlobalValues[field.name];
}
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
}
function setGlobalValue(key: string, value: string) {
patchGlobalValues({ ...globalValues, [key]: value });
}
function setOverrideAllowed(index: number, allowed: boolean) {
setField(index, { can_override: allowed });
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
const fieldProblem = describeFieldNameProblem(fields);
if (fieldProblem) {
setLocalError(fieldProblem);
setSaveState("Save blocked");
return false;
}
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "campaign-fields",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved fields",
message: "Fields have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Fields</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={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{fieldNameWarning && <div className="alert warning">{fieldNameWarning}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing fields.</div>}
{draft && (
<>
<Card
title="Fields and global values"
actions={<Button variant="primary" onClick={addField} disabled={locked}>Add field</Button>}
>
<div className="app-table-wrap field-editor-table-wrap">
<table className="app-table field-editor-table">
<thead>
<tr>
<th>Field ID</th>
<th>Label</th>
<th>Type</th>
<th>Required</th>
<th>Global value</th>
<th>Recipient override</th>
<th></th>
</tr>
</thead>
<tbody>
{fields.length === 0 ? (
<tr>
<td colSpan={7} className="empty-table-cell">No campaign fields configured yet.</td>
</tr>
) : fields.map((field, index) => (
<tr key={`field-row-${index}`}>
<td><input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} /></td>
<td><input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} /></td>
<td>
<select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: event.target.value })}>
{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</td>
<td><ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} /></td>
<td><input value={valueToText(globalValues[field.name])} disabled={locked || !field.name} placeholder="Optional default" onChange={(event) => setGlobalValue(field.name, event.target.value)} /></td>
<td><ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} /></td>
<td className="table-action-cell"><Button variant="danger" disabled={locked} onClick={() => deleteField(index)}>Delete</Button></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</>
)}
</div>
);
}
function normalizeFields(value: unknown): FieldDefinition[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((field) => ({
name: getText(field, "name"),
label: getText(field, "label"),
type: fieldTypeOptions.includes(getText(field, "type")) ? getText(field, "type") : "string",
required: getBool(field, "required"),
can_override: getBool(field, "can_override", true)
}));
}
function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState: Record<string, unknown>): Record<string, unknown> {
const overridePolicy = asRecord(editorState.field_overrides);
if (Object.keys(overridePolicy).length === 0) return draft;
const fields = normalizeFields(draft.fields).map((field) => {
if (!field.name || !Object.prototype.hasOwnProperty.call(overridePolicy, field.name)) return field;
return { ...field, can_override: getBool(overridePolicy, field.name, true) };
});
return updateNested(draft, ["fields"], fields);
}
function describeFieldNameProblem(fields: FieldDefinition[]): string {
const names = fields.map((field) => field.name.trim());
if (names.some((name) => !name)) {
return "Field IDs must not be empty before saving.";
}
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const name of names) {
if (seen.has(name)) duplicates.add(name);
seen.add(name);
}
if (duplicates.size === 0) return "";
return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`;
}
function uniqueFieldName(fields: FieldDefinition[]): string {
const existing = new Set(fields.map((field) => field.name));
let counter = fields.length + 1;
let name = `field_${counter}`;
while (existing.has(name)) {
counter += 1;
name = `field_${counter}`;
}
return name;
}
function humanizeFieldName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
function valueToText(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}

View File

@@ -0,0 +1,32 @@
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, getCampaignJson } from "./utils/campaignView";
import { downloadJson, safeFileStem } from "./utils/draftEditor";
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const campaignJson = getCampaignJson(data.currentVersion);
const campaign = asRecord(campaignJson.campaign);
const filename = `${safeFileStem(String(campaign.id || data.campaign?.external_id || campaignId))}.json`;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>JSON</PageTitle>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!data.currentVersion}>Download JSON</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
<Card>
{!loading || data.currentVersion ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
</Card>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import StatusBadge from "../../components/StatusBadge";
import LoadingIndicator from "../../components/LoadingIndicator";
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
import type { CampaignListItem } from "../../types";
export default function CampaignListPage({ settings }: { settings: ApiSettings }) {
const navigate = useNavigate();
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
const hasAuth = Boolean(settings.accessToken || settings.apiKey);
async function load() {
if (!hasAuth) return;
setLoading(true);
setError("");
try {
const data = await listCampaigns(settings);
setCampaigns(data);
setLastLoadedAt(formatLoadedAt(new Date()));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
async function create() {
setCreating(true);
setError("");
try {
const created = await createNewCampaign(settings);
navigate(`/campaigns/${created.campaign.id}/wizard/create`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCreating(false);
}
}
useEffect(() => {
load();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
return (
<div className="content-pad campaigns-page">
{!hasAuth && (
<div className="alert warning">Sign in with your user account or configure an automation API key under Settings to load campaigns.</div>
)}
{error && <div className="alert danger">{error}</div>}
<Card
title={<span className="card-heading-with-loader">All campaigns {loading && <LoadingIndicator label="Loading campaigns" />}</span>}
actions={
<div className="campaign-card-actions">
{lastLoadedAt && <span className="last-loaded">Last loaded: {lastLoadedAt}</span>}
<div className="button-row compact-actions">
<Button onClick={load} disabled={!hasAuth || loading}>Reload</Button>
<Button variant="primary" onClick={create} disabled={!hasAuth || creating}>
{creating ? "Creating…" : "New campaign"}
</Button>
</div>
</div>
}
>
{!loading && campaigns.length === 0 && (
<div className="empty-state">
<h2>No campaigns yet</h2>
<p>Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.</p>
<Button variant="primary" onClick={create} disabled={!hasAuth || creating}>
Create first campaign
</Button>
</div>
)}
{!loading && campaigns.length > 0 && (
<div className="app-table-wrap campaign-table-wrap">
<table className="app-table campaign-table">
<thead>
<tr>
<th>Campaign</th>
<th>Status</th>
<th>Current version</th>
<th>Updated</th>
<th aria-label="Open"></th>
</tr>
</thead>
<tbody>
{campaigns.map((campaign) => (
<tr key={campaign.id}>
<td>
<Link className="table-primary-link" to={`/campaigns/${campaign.id}`}>
{campaign.name || campaign.external_id || campaign.id}
</Link>
<div className="table-subline">{campaign.description || campaign.external_id || campaign.id}</div>
</td>
<td><StatusBadge status={campaign.status || "draft"} /></td>
<td className="version-cell mono-small" title={campaign.current_version_id || undefined}>
{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}
</td>
<td className="updated-cell">{formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at)}</td>
<td><Link className="table-action-link" to={`/campaigns/${campaign.id}`}>View</Link></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}
function shortId(value: string): string {
if (value.length <= 20) return value;
return `${value.slice(0, 12)}${value.slice(-6)}`;
}
function formatDateTime(value?: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function formatLoadedAt(value: Date): string {
return value.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}

View File

@@ -0,0 +1,340 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import PageTitle from "../../components/PageTitle";
import { createNewCampaign, publishCampaignVersion, updateCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import {
asArray,
asRecord,
cloneCampaignJsonForCopy,
getCampaignJson,
getString,
isAuditLockedVersion,
summaryValue,
timestampSlug,
versionLockReason
} from "./utils/campaignView";
import { addressesFromValue } from "../../utils/emailAddresses";
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const navigate = useNavigate();
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [copying, setCopying] = useState(false);
const [locking, setLocking] = useState(false);
const [message, setMessage] = useState("");
const campaign = data.campaign;
const currentVersion = data.currentVersion;
const campaignJson = getCampaignJson(currentVersion);
const locked = isAuditLockedVersion(currentVersion);
const cards = data.summary?.cards;
const overviewFacts = getOverviewFacts(campaignJson, campaign);
async function copyCampaign() {
if (!currentVersion) return;
setCopying(true);
setMessage("");
setError("");
try {
const copy = cloneCampaignJsonForCopy(campaignJson, campaign, timestampSlug());
const created = await createNewCampaign(settings, {
external_id: copy.externalId,
name: copy.name,
description: copy.description,
current_flow: "manual",
current_step: "copied"
});
await updateCampaignVersion(settings, created.campaign.id, created.version.id, {
campaign_json: copy.rawJson,
current_flow: "manual",
current_step: null,
workflow_state: "editing",
is_complete: false,
editor_state: {
copied_from_campaign_id: campaignId,
copied_from_version_id: currentVersion.id
}
});
navigate(`/campaigns/${created.campaign.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCopying(false);
}
}
async function lockCampaign() {
if (!currentVersion || locked) return;
const confirmed = window.confirm(
"Lock this campaign version for audit-safe use? The current version should no longer be edited afterwards; create a copy if you need a new working version."
);
if (!confirmed) return;
setLocking(true);
setMessage("");
setError("");
try {
await publishCampaignVersion(settings, campaignId, currentVersion.id);
setMessage("Campaign version locked as the current audit-safe version.");
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLocking(false);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
<p className="mono-small">{campaign?.external_id || campaignId}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={copyCampaign} disabled={!currentVersion || copying}>{copying ? "Copying…" : "Copy campaign"}</Button>
<Button variant="primary" onClick={lockCampaign} disabled={!currentVersion || locked || locking}>
{locking ? "Locking…" : locked ? "Locked" : "Lock campaign"}
</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{message && <div className="alert success">{message}</div>}
{locked && (
<div className="alert info">
This version is audit-safe and should be treated as read-only. {versionLockReason(currentVersion)} Only workflow state should change from here.
</div>
)}
<div className="metric-grid">
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Built and ready or warning" />
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review before sending" />
<MetricCard label="Sent" value={cards?.sent ?? "—"} tone="info" detail="SMTP success" />
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Guided actions" actions={<span className="muted small-note">Wizards change or advance the campaign; data pages display and edit the current working draft.</span>}>
<div className="wizard-action-grid">
<WizardAction
title={locked ? "Create a new working copy" : "Edit campaign structure"}
description={locked ? "This version is locked. Copy the campaign before editing structural data." : "Open the structured create/edit wizard for overview, recipients, template and attachments."}
to="wizard/create"
label={locked ? "Open wizard read-only" : "Open Create Campaign"}
/>
<WizardAction
title="Resolve review issues"
description="Use a guided flow for validation issues, missing recipients or attachment decisions."
to="wizard/review"
label="Open Review Wizard"
/>
<WizardAction
title="Prepare sending"
description="Use the sending wizard for dry runs, rate limits, test sending and queue preparation."
to="wizard/send"
label="Open Send Wizard"
/>
</div>
</Card>
<div className="overview-config-grid">
<ConfigShortcutCard
title="General"
description="Name, sender and global recipients."
facts={overviewFacts.campaignSettings}
actions={[{ to: "data", label: "General" }]}
/>
<ConfigShortcutCard
title="Global settings"
description="Policies, opt-ins and delivery defaults."
facts={overviewFacts.globalSettings}
actions={[{ to: "global-settings", label: "Global settings" }]}
/>
<ConfigShortcutCard
title="Fields"
description="Field definitions and global values."
facts={overviewFacts.fields}
actions={[{ to: "fields", label: "Fields" }]}
/>
<ConfigShortcutCard
title="Recipients"
description="Recipient list and per-recipient values."
facts={overviewFacts.recipients}
actions={[{ to: "recipients", label: "Recipients" }]}
/>
<ConfigShortcutCard
title="Template"
description="Message content, preview and field usage."
facts={overviewFacts.template}
actions={[{ to: "template", label: "Template" }]}
/>
<ConfigShortcutCard
title="Attachments"
description="Global attachments and per-recipient rules."
facts={overviewFacts.files}
actions={[{ to: "files", label: "Attachments" }]}
/>
</div>
<Card title="Validation and build state">
<div className="summary-grid overview-summary-grid">
<SummaryTile label="Validation errors" value={summaryValue(currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Built messages" value={summaryValue(currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Jobs total" value={cards?.jobs_total ?? "—"} />
</div>
<p className="muted">Review and sending are displayed on their own data pages; use the guided buttons above to change state.</p>
</Card>
</div>
);
}
type OverviewFact = {
label: string;
value: string | number;
};
function ConfigShortcutCard({
title,
description,
facts,
actions
}: {
title: string;
description: string;
facts: OverviewFact[];
actions: Array<{ to: string; label: string }>;
}) {
return (
<section className="overview-config-card">
<h3>{title}</h3>
<p>{description}</p>
<dl className="overview-config-facts">
{facts.map((fact) => (
<div key={fact.label}>
<dt>{fact.label}</dt>
<dd>{fact.value}</dd>
</div>
))}
</dl>
<div className="overview-config-actions">
{actions.map((action) => (
<Link key={action.to} to={action.to}>
<Button>{action.label}</Button>
</Link>
))}
</div>
</section>
);
}
function SummaryTile({ label, value }: { label: string; value: string | number }) {
return (
<div className="summary-tile">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function WizardAction({ title, description, to, label }: { title: string; description: string; to: string; label: string }) {
return (
<section className="wizard-action-card">
<h3>{title}</h3>
<p>{description}</p>
<Link to={to}><Button>{label}</Button></Link>
</section>
);
}
function getOverviewFacts(rawJson: Record<string, unknown>, campaign: { name?: string; external_id?: string; id?: string; status?: string } | null) {
const campaignSection = asRecord(rawJson.campaign);
const recipients = asRecord(rawJson.recipients);
const attachments = asRecord(rawJson.attachments);
const template = asRecord(rawJson.template);
const entries = asRecord(rawJson.entries);
const validationPolicy = asRecord(rawJson.validation_policy);
const delivery = asRecord(rawJson.delivery);
const fields = asArray(rawJson.fields).map(asRecord);
const globalValues = asRecord(rawJson.global_values);
const inlineEntries = asArray(entries.inline).map(asRecord);
const entrySource = asRecord(entries.source);
const globalAttachmentRules = asArray(attachments.global).map(asRecord);
const individualAttachmentRules = inlineEntries.reduce((count, entry) => count + asArray(entry.attachments).length, 0);
const globalRecipients = ["to", "cc", "bcc"].reduce((count, key) => count + addressesFromValue(recipients[key]).length, 0);
return {
campaignSettings: [
{ label: "Name", value: getString(campaignSection, "name", campaign?.name || "—") },
{ label: "Campaign ID", value: getString(campaignSection, "id", campaign?.external_id || campaign?.id || "—") },
{ label: "Sender", value: formatMailbox(recipients.from) }
],
globalSettings: [
{ label: "Mode", value: getString(campaignSection, "mode", campaign?.status || "draft") },
{ label: "Attachment policy", value: `${getString(attachments, "missing_behavior", "ask")} / ${getString(attachments, "ambiguous_behavior", "ask")}` },
{ label: "Delivery", value: getString(delivery, "mode", getString(validationPolicy, "send_without_attachments", "standard")) }
],
fields: [
{ label: "Fields", value: fields.length },
{ label: "Global values", value: Object.keys(globalValues).length },
{ label: "Required", value: fields.filter((field) => field.required === true).length }
],
recipients: [
{ label: "Recipients", value: recipientSummary(inlineEntries, entrySource) },
{ label: "Global recipients", value: globalRecipients },
{ label: "Source", value: sourceSummary(entrySource) }
],
template: [
{ label: "Subject", value: getString(template, "subject", "Not configured") },
{ label: "Source", value: templateSourceSummary(template) },
{ label: "Placeholders", value: countTemplatePlaceholders(template) }
],
files: [
{ label: "Base path", value: getString(attachments, "base_path", ".") },
{ label: "Global files", value: globalAttachmentRules.length },
{ label: "Individual rules", value: individualAttachmentRules }
]
};
}
function formatMailbox(value: unknown): string {
const [address] = addressesFromValue(value);
if (!address) return "Not configured";
return address.name ? `${address.name} <${address.email}>` : address.email;
}
function recipientSummary(inlineEntries: Record<string, unknown>[], source: Record<string, unknown>): string {
if (inlineEntries.length) return `${inlineEntries.length} inline`;
if (Object.keys(source).length) return "External source";
return "Not configured";
}
function sourceSummary(source: Record<string, unknown>): string {
if (!Object.keys(source).length) return "Inline / manual";
return getString(source, "type", getString(source, "path", "External"));
}
function templateSourceSummary(template: Record<string, unknown>): string {
const libraryId = getString(template, "library_id", "");
const templateId = getString(template, "template_id", "");
const source = getString(template, "source", "");
if (libraryId) return `Library: ${libraryId}`;
if (templateId) return `Library: ${templateId}`;
if (source) return source;
return "Inline campaign template";
}
function countTemplatePlaceholders(template: Record<string, unknown>): number {
const text = `${getString(template, "subject", "")}
${getString(template, "text", "")}
${getString(template, "html", "")}`;
const matches = text.match(/\{\{\s*[\w.-]+\s*\}\}/g) ?? [];
return new Set(matches.map((item) => item.replace(/[{}\s]/g, ""))).size;
}

View File

@@ -0,0 +1,38 @@
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { formatDateTime } from "./utils/campaignView";
export default function CampaignReportPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const cards = data.summary?.cards;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Report</PageTitle>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
<div className="dashboard-grid">
<Card title="Report summary">
<dl className="detail-list">
<div><dt>Generated</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
<div><dt>Jobs total</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
<div><dt>Sent</dt><dd>{cards?.sent ?? "—"}</dd></div>
<div><dt>Failed</dt><dd>{cards?.failed ?? "—"}</dd></div>
</dl>
</Card>
<Card title="Exports">
<p className="muted">CSV export and report-emailing buttons will be added once the report section is reviewed.</p>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import type { ApiSettings, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar";
import CampaignOverviewPage from "./CampaignOverviewPage";
import CampaignDataPage from "./CampaignDataPage";
import CampaignFieldsPage from "./CampaignFieldsPage";
import GlobalSettingsPage from "./GlobalSettingsPage";
import RecipientDataPage from "./RecipientDataPage";
import TemplateDataPage from "./TemplateDataPage";
import AttachmentsDataPage from "./AttachmentsDataPage";
import MailSettingsPage from "./MailSettingsPage";
import ReviewDataPage from "./ReviewDataPage";
import SendDataPage from "./SendDataPage";
import CreateWizard from "./wizard/CreateWizard";
import ReviewWizard from "./wizard/ReviewWizard";
import SendWizard from "./wizard/SendWizard";
import CampaignJsonView from "./CampaignJsonView";
import CampaignReportPage from "./CampaignReportPage";
import CampaignAuditPage from "./CampaignAuditPage";
import { CampaignUnsavedChangesProvider, useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
overview: "",
campaign: "data",
"global-settings": "global-settings",
fields: "fields",
recipients: "recipients",
template: "template",
files: "files",
"mail-settings": "mail-settings",
review: "review",
send: "send",
report: "report",
audit: "audit",
json: "json"
};
export default function CampaignWorkspace({ settings }: { settings: ApiSettings }) {
return (
<CampaignUnsavedChangesProvider>
<CampaignWorkspaceInner settings={settings} />
</CampaignUnsavedChangesProvider>
);
}
function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
const { campaignId } = useParams();
const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
const location = useLocation();
const active = sectionFromPath(location.pathname);
function select(section: CampaignWorkspaceSection) {
const path = sectionPaths[section];
const target = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
if (location.pathname === target) return;
requestNavigation(() => navigate(target));
}
return (
<div className="workspace">
<SectionSidebar active={active} onSelect={select} />
<section className="workspace-content">
<Routes>
<Route index element={<CampaignOverviewPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="data" element={<CampaignDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="attachments" element={<Navigate to="../files" replace />} />
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="review" element={<ReviewDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="send" element={<SendDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="reports" element={<Navigate to="../report" replace />} />
<Route path="audit" element={<CampaignAuditPage />} />
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/create" element={<CreateWizard settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/review" element={<ReviewWizard />} />
<Route path="wizard/send" element={<SendWizard />} />
<Route path="create" element={<Navigate to="../wizard/create" replace />} />
<Route path="campaign" element={<Navigate to="../data" replace />} />
<Route path="mail" element={<Navigate to="../mail-settings" replace />} />
<Route path="settings" element={<Navigate to="../global-settings" replace />} />
</Routes>
</section>
</div>
);
}
function sectionFromPath(pathname: string): CampaignWorkspaceSection {
const segments = pathname.split("/").filter(Boolean);
const section = segments[2];
if (!section || section === "wizard" || section === "create") return "overview";
if (section === "data" || section === "campaign") return "campaign";
if (section === "global-settings" || section === "settings") return "global-settings";
if (section === "fields") return "fields";
if (section === "recipients") return "recipients";
if (section === "template") return "template";
if (section === "files" || section === "attachments") return "files";
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
if (section === "review") return "review";
if (section === "send") return "send";
if (section === "report" || section === "reports") return "report";
if (section === "audit") return "audit";
if (section === "json") return "json";
return "overview";
}

View File

@@ -0,0 +1,178 @@
import { useEffect, useRef, useState } 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 ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { cloneJson, ensureCampaignDraft, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
type EditorState = Record<string, unknown>;
export default function GlobalSettingsPage({ 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 [editorState, setEditorState] = useState<EditorState>({});
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 validationPolicy = asRecord(draft?.validation_policy);
const attachments = asRecord(draft?.attachments);
const delivery = asRecord(draft?.delivery);
const rateLimit = asRecord(delivery.rate_limit);
const retry = asRecord(delivery.retry);
const statusTracking = asRecord(draft?.status_tracking);
const optIns = asRecord(editorState.opt_ins);
useEffect(() => {
if (!version) return;
if (loadedVersionId.current === version.id) return;
loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setEditorState(cloneJson(version.editor_state ?? {}));
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 patchEditor(path: string[], value: unknown) {
if (locked) return;
setEditorState((current) => updateNested(current, path, value));
setDirty(true);
setLocalError("");
}
async function saveDraft(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,
editor_state: editorState,
current_flow: "manual",
current_step: "global-settings",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setEditorState(cloneJson(saved.editor_state ?? editorState));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved global settings",
message: "Global settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Global settings</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={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</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)} Copy the campaign before editing global settings.</div>}
{draft && (
<>
<div className="dashboard-grid">
<Card title="Validation policy">
<PolicySelect label="Missing required attachment" value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />
<PolicySelect label="Missing optional attachment" value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />
<PolicySelect label="Ambiguous attachment match" value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />
<PolicySelect label="Missing email address" value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />
<PolicySelect label="Template error" value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />
<ToggleSwitch label="Ignore empty fields" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />
</Card>
<Card title="Attachment defaults">
<div className="form-grid compact responsive-form-grid">
<FormField label="Missing behavior">
<select value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
</FormField>
<FormField label="Ambiguous behavior">
<select value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
</FormField>
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} disabled={locked} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
<ToggleSwitch label="Send without attachments" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />
</div>
<p className="muted small-note">The actual global and per-recipient attachment rules live in Files. These settings define the campaign-wide defaults used by validation and review.</p>
</Card>
</div>
<div className="dashboard-grid below-grid">
<Card title="Delivery defaults">
<div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div>
</Card>
<Card title="Opt-ins and local assistance">
<div className="toggle-grid">
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
</div>
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p>
</Card>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save current draft</Button>
</div>
</>
)}
</div>
);
}
function PolicySelect({ label, value, disabled, onChange, options = behaviorOptions }: { label: string; value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</FormField>
);
}

View File

@@ -0,0 +1,291 @@
import { useEffect, useRef, useState } 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 ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const securityOptions = ["plain", "tls", "starttls"];
export default function MailSettingsPage({ 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 [smtpTestResult, setSmtpTestResult] = useState<MailConnectionTestResponse | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailConnectionTestResponse | null>(null);
const [folderResult, setFolderResult] = useState<MailImapFolderListResponse | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
const loadedVersionId = useRef<string | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const server = asRecord(draft?.server);
const smtp = asRecord(server.smtp);
const imap = asRecord(server.imap);
const delivery = asRecord(draft?.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
const imapEnabled = getBool(imap, "enabled");
const imapDisabled = locked || !imapEnabled;
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("");
}
async function saveDraft(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: "mail-settings",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
function toggleImap(enabled: boolean) {
patch(["server", "imap", "enabled"], enabled);
if (!enabled) {
patch(["delivery", "imap_append_sent", "enabled"], false);
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved server settings",
message: "Server settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
function emptyToNull(value: string, trim = true): string | null {
const normalized = trim ? value.trim() : value;
return normalized ? normalized : null;
}
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
return securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
}
function smtpPayload() {
return {
host: emptyToNull(getText(smtp, "host")),
port: getNumber(smtp, "port", 587),
username: emptyToNull(getText(smtp, "username")),
password: emptyToNull(getText(smtp, "password"), false),
security: readSecurity(getText(smtp, "security", "starttls"), "starttls"),
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
};
}
function imapPayload() {
return {
enabled: true,
host: emptyToNull(getText(imap, "host")),
port: getNumber(imap, "port", 993),
username: emptyToNull(getText(imap, "username")),
password: emptyToNull(getText(imap, "password"), false),
security: readSecurity(getText(imap, "security", "tls"), "tls"),
sent_folder: emptyToNull(getText(imap, "sent_folder", "auto")),
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
};
}
async function runSmtpTest() {
if (locked) return;
setMailActionState("smtp");
setLocalError("");
try {
setSmtpTestResult(await testSmtpSettings(settings, smtpPayload()));
} catch (err) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runImapTest() {
if (imapDisabled) return;
setMailActionState("imap");
setLocalError("");
try {
setImapTestResult(await testImapSettings(settings, imapPayload()));
} catch (err) {
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runFolderLookup() {
if (imapDisabled) return;
setMailActionState("folders");
setLocalError("");
try {
setFolderResult(await listImapFolders(settings, imapPayload()));
} catch (err) {
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
} finally {
setMailActionState(null);
}
}
function useDetectedSentFolder() {
const folder = folderResult?.detected_sent_folder;
if (!folder || imapDisabled) return;
patch(["server", "imap", "sent_folder"], folder);
if (!getText(imapAppend, "folder") || getText(imapAppend, "folder") === "auto") {
patch(["delivery", "imap_append_sent", "folder"], folder);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Server settings</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={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</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)} Copy the campaign before editing server settings.</div>}
{draft && (
<>
<Card title="Mail server settings">
<div className="mail-server-settings-grid">
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>SMTP login</h3>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Host"><input value={getText(smtp, "host")} disabled={locked} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="Port"><input type="number" value={getNumber(smtp, "port", 587)} disabled={locked} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(smtp, "username")} disabled={locked} onChange={(event) => patch(["server", "smtp", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(smtp, "password")} disabled={locked} onChange={(event) => patch(["server", "smtp", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(smtp, "security", "starttls")} disabled={locked} onChange={(event) => patch(["server", "smtp", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Timeout seconds"><input type="number" value={getNumber(smtp, "timeout_seconds", 30)} disabled={locked} onChange={(event) => patch(["server", "smtp", "timeout_seconds"], Number(event.target.value || 0))} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runSmtpTest} disabled={locked || mailActionState === "smtp"}>{mailActionState === "smtp" ? "Testing…" : "Test SMTP login"}</Button>
</div>
<MailActionResult result={smtpTestResult} />
</section>
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>IMAP sent-folder append</h3>
</div>
<div className="form-grid compact responsive-form-grid">
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Enable IMAP" checked={imapEnabled} disabled={locked} onChange={toggleImap} />
</div>
<FormField label="Host"><input value={getText(imap, "host")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "host"], event.target.value)} /></FormField>
<FormField label="Port"><input type="number" value={getNumber(imap, "port", 993)} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(imap, "username")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(imap, "password")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(imap, "security", "tls")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Detected/saved sent folder"><input value={getText(imap, "sent_folder", "auto")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "sent_folder"], event.target.value)} /></FormField>
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Append successfully sent messages to Sent" checked={getBool(imapAppend, "enabled")} disabled={imapDisabled} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>
<FormField label="Append folder"><input value={getText(imapAppend, "folder", getText(imap, "sent_folder", "auto"))} disabled={imapDisabled || !getBool(imapAppend, "enabled")} onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runImapTest} disabled={imapDisabled || mailActionState === "imap"}>{mailActionState === "imap" ? "Testing…" : "Test IMAP login"}</Button>
<Button variant="primary" onClick={runFolderLookup} disabled={imapDisabled || mailActionState === "folders"}>{mailActionState === "folders" ? "Looking up…" : "Folders…"}</Button>
</div>
<p className="muted small-note">Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.</p>
<MailActionResult result={imapTestResult} />
<FolderLookupResult result={folderResult} disabled={imapDisabled} onUseDetected={useDetectedSentFolder} />
</section>
</div>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
)}
</div>
);
}
function MailActionResult({ result }: { result: MailConnectionTestResponse | null }) {
if (!result) return null;
const authenticated = result.details?.authenticated;
return (
<div className={`alert ${result.ok ? "success" : "danger"}`}>
{result.message}
{result.ok && typeof authenticated === "boolean" && (
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
)}
</div>
);
}
function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailImapFolderListResponse | null; disabled?: boolean; onUseDetected: () => void }) {
if (!result) return null;
if (!result.ok) {
return <div className="alert danger">{result.message}</div>;
}
return (
<div className="alert success">
<p>{result.message}</p>
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "—"}</strong></p>
{result.detected_sent_folder && <Button onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
{result.folders.length > 0 && (
<div className="field-chip-list">
{result.folders.slice(0, 12).map((folder) => (
<span className="field-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
))}
{result.folders.length > 12 && <span className="field-chip">+{result.folders.length - 12} more</span>}
</div>
)}
</div>
);
}

View 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: "" };
}

View File

@@ -0,0 +1,96 @@
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import Card from "../../components/Card";
import StatusBadge from "../../components/StatusBadge";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, stringifyPreview, summaryValue } from "./utils/campaignView";
export default function ReviewDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const issues = collectIssues(data.summary?.issues);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Review</PageTitle>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Link to="../wizard/review"><Button variant="primary">Open Review Wizard</Button></Link>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
<div className="dashboard-grid">
<Card title="Validation summary">
<div className="summary-grid">
<SummaryTile label="Errors" value={summaryValue(version?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(version?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Info" value={summaryValue(version?.validation_summary, ["info_count", "info"])} />
<SummaryTile label="Validated" value={formatDateTime(version?.updated_at)} />
</div>
{!version?.validation_summary && <p className="muted">No validation summary is stored yet.</p>}
</Card>
<Card title="Build summary">
<div className="summary-grid">
<SummaryTile label="Built" value={summaryValue(version?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Blocked" value={summaryValue(version?.build_summary, ["blocked_count", "blocked"])} />
<SummaryTile label="Needs review" value={summaryValue(version?.build_summary, ["needs_review_count", "needs_review"])} />
<SummaryTile label="Warnings" value={summaryValue(version?.build_summary, ["warning_count", "warnings"])} />
</div>
{!version?.build_summary && <p className="muted">No build summary is stored yet.</p>}
</Card>
</div>
<Card title="Review issues" actions={<span className="muted small-note">Grouped issue display will be expanded in the next review pass.</span>}>
{issues.length === 0 && <p className="muted">No stored issues were returned for this campaign summary.</p>}
{issues.length > 0 && (
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table">
<thead>
<tr>
<th>Severity</th>
<th>Section</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{issues.map((issue, index) => (
<tr key={index}>
<td><StatusBadge status={String(issue.severity || "info")} /></td>
<td>{String(issue.section || issue.field || "—")}</td>
<td>{String(issue.message || issue.code || stringifyPreview(issue, 180))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}
function SummaryTile({ label, value }: { label: string; value: string | number }) {
return (
<div className="summary-tile">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function collectIssues(raw: unknown): Record<string, unknown>[] {
if (Array.isArray(raw)) return raw.map(asRecord);
if (!raw || typeof raw !== "object") return [];
const record = raw as Record<string, unknown>;
const direct = asArray(record.items ?? record.issues ?? record.results);
if (direct.length) return direct.map(asRecord);
return Object.entries(record).flatMap(([section, value]) => asArray(value).map((item) => ({ section, ...asRecord(item) })));
}

View File

@@ -0,0 +1,66 @@
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, getDeliverySection, getNestedString } from "./utils/campaignView";
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const cards = data.summary?.cards;
const delivery = getDeliverySection(data.currentVersion);
const rateLimit = asRecord(delivery.rate_limit);
const imapAppend = asRecord(delivery.imap_append_sent);
const retry = asRecord(delivery.retry);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Send</PageTitle>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Link to="../wizard/send"><Button variant="primary">Open Send Wizard</Button></Link>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
<div className="metric-grid">
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
<MetricCard label="Sent" value={cards?.sent ?? "—"} tone="info" detail="SMTP success" />
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<div className="dashboard-grid">
<Card title="Delivery rate limit">
<dl className="detail-list">
<div><dt>Messages/minute</dt><dd>{String(rateLimit.messages_per_minute ?? "—")}</dd></div>
<div><dt>Concurrency</dt><dd>{String(rateLimit.concurrency ?? "—")}</dd></div>
<div><dt>Max attempts</dt><dd>{String(retry.max_attempts ?? "—")}</dd></div>
<div><dt>Backoff</dt><dd>{getNestedString(delivery, ["retry", "backoff_seconds"])}</dd></div>
</dl>
</Card>
<Card title="Sent-folder append">
<dl className="detail-list">
<div><dt>Enabled</dt><dd>{String(Boolean(imapAppend.enabled))}</dd></div>
<div><dt>Folder</dt><dd>{String(imapAppend.folder || "auto")}</dd></div>
<div><dt>Appended</dt><dd>{cards?.imap_appended ?? "—"}</dd></div>
<div><dt>Append failed</dt><dd>{cards?.imap_failed ?? "—"}</dd></div>
</dl>
</Card>
</div>
<Card title="Sending rule">
<p className="muted">
SMTP sending and IMAP append-to-Sent remain separate states. A successful SMTP send is still successful even if appending to Sent fails.
</p>
</Card>
</div>
);
}

View File

@@ -0,0 +1,558 @@
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 { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, getTemplateSection, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { cloneJson, ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
type BodyMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html";
type TemplateNamespace = "global" | "local";
type TemplatePlaceholder = {
raw: string;
namespace: string;
name: string;
validNamespace: boolean;
display: string;
};
type UndefinedPlaceholder = TemplatePlaceholder & {
reason: "missing-field" | "invalid-namespace";
};
export default function TemplateDataPage({ 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 [bodyMode, setBodyMode] = useState<BodyMode>("text");
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
const [previewOpen, setPreviewOpen] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const loadedVersionId = useRef<string | null>(null);
const subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<HTMLTextAreaElement | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const template = draft ? asRecord(draft.template) : getTemplateSection(version);
const fields = useMemo(() => asArray(draft?.fields).map(asRecord), [draft]);
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(draft?.global_values))]), [draft?.global_values, localFieldNames]);
const allAvailableNames = useMemo(() => new Set([...localFieldNames, ...globalFieldNames]), [localFieldNames, globalFieldNames]);
const entries = asRecord(draft?.entries);
const inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]);
const previewEntries = inlineEntries.length > 0 ? inlineEntries : [{}];
const previewEntry = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? {};
const ignoreEmptyFields = getBool(asRecord(draft?.validation_policy), "ignore_empty_fields", false);
const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`;
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
const undefinedPlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders
.filter((field) => !field.validNamespace || !allAvailableNames.has(field.name))
.map((field): UndefinedPlaceholder => ({
...field,
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
}))), [usedPlaceholders, allAvailableNames]);
const previewContext = useMemo(() => buildPreviewContext(draft, previewEntry), [draft, previewEntry]);
const previewSubject = renderPreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
const previewText = renderPreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
const previewHtml = renderPreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
useEffect(() => {
if (!version) return;
if (loadedVersionId.current === version.id) return;
loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setPreviewIndex(0);
setSaveState(version.autosaved_at ? `Loaded autosave ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
useEffect(() => {
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
}, [previewIndex, previewEntries.length]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value);
}
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
if (locked) return;
const target = bodyMode === "html" && activeEditor !== "subject" ? "html" : activeEditor;
const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current;
const token = `{{${namespace}:${name}}}`;
const currentText = getText(template, target);
const start = element?.selectionStart ?? currentText.length;
const end = element?.selectionEnd ?? currentText.length;
const nextText = `${currentText.slice(0, start)}${token}${currentText.slice(end)}`;
patchTemplateText(target, nextText);
window.requestAnimationFrame(() => {
element?.focus();
const cursor = start + token.length;
element?.setSelectionRange(cursor, cursor);
});
}
function addUndefinedField(field: UndefinedPlaceholder) {
if (!draft || locked || !field.name) return;
const existingFields = asArray(draft.fields).map(asRecord);
const alreadyDefined = existingFields.some((item) => String(item.name || item.id || "") === field.name);
if (!alreadyDefined) {
patch(["fields"], [
...existingFields,
{
name: field.name,
label: humanizeFieldName(field.name),
type: "string",
required: false,
can_override: true
}
]);
}
setUndefinedDialog(null);
}
function removePlaceholder(field: UndefinedPlaceholder) {
if (locked) return;
setDraft((current) => {
const next = cloneJson(current ?? {});
const nextTemplate = { ...asRecord(next.template) };
nextTemplate.subject = removePlaceholderFromText(getText(nextTemplate, "subject"), field.raw);
nextTemplate.text = removePlaceholderFromText(getText(nextTemplate, "text"), field.raw);
nextTemplate.html = removePlaceholderFromText(getText(nextTemplate, "html"), field.raw);
next.template = nextTemplate;
return next;
});
setDirty(true);
setLocalError("");
setUndefinedDialog(null);
}
async function saveTemplate(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: "template",
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 template changes",
message: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveTemplate("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Template</PageTitle>
<p className="mono-small">{saveState}</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Select template</Button>
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveTemplate("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 && (
<>
<div className="dashboard-grid template-editor-grid">
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}>
<div className="form-grid">
<FormField label="Subject">
<input
ref={subjectRef}
value={getText(template, "subject")}
disabled={locked}
onFocus={() => setActiveEditor("subject")}
onChange={(event) => patchTemplateText("subject", event.target.value)}
/>
</FormField>
<div className="template-body-mode" role="tablist" aria-label="Template body mode">
<button type="button" className={bodyMode === "text" ? "active" : ""} onClick={() => { setBodyMode("text"); setActiveEditor("text"); }}>Plain text</button>
<button type="button" className={bodyMode === "html" ? "active" : ""} onClick={() => { setBodyMode("html"); setActiveEditor("html"); }}>HTML</button>
</div>
{bodyMode === "text" && (
<FormField label="Plain text body">
<textarea
ref={textRef}
rows={16}
value={getText(template, "text")}
disabled={locked}
onFocus={() => setActiveEditor("text")}
onChange={(event) => patchTemplateText("text", event.target.value)}
/>
</FormField>
)}
{bodyMode === "html" && (
<FormField label="HTML body">
<textarea
ref={htmlRef}
rows={16}
value={getText(template, "html")}
disabled={locked}
onFocus={() => setActiveEditor("html")}
onChange={(event) => patchTemplateText("html", event.target.value)}
/>
</FormField>
)}
<div className="button-row template-editor-actions">
<Button disabled>Save to library</Button>
</div>
</div>
</Card>
<div className="template-side-stack">
<Card title="Fields">
{invalidNamespacePlaceholders.length > 0 && (
<div className="alert warning">Undefined placeholder namespace detected: {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</div>
)}
{usedPlaceholders.length === 0 && <p className="muted">No template placeholders detected yet.</p>}
<p className="muted small-note">Click a field to insert it at the current cursor position as a namespaced placeholder.</p>
<h3 className="section-mini-heading">Global fields</h3>
<TemplateFieldChipList
namespace="global"
names={globalFieldNames}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values defined."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Local fields</h3>
<TemplateFieldChipList
namespace="local"
names={localFieldNames}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields defined."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Used in template, but undefined</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Card>
</div>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</>
)}
{previewOpen && (
<TemplatePreviewOverlay
bodyMode={bodyMode}
entry={previewEntry}
index={Math.min(previewIndex, previewEntries.length - 1)}
total={previewEntries.length}
subject={previewSubject}
text={previewText}
html={previewHtml}
hasRealRecipients={inlineEntries.length > 0}
onClose={() => setPreviewOpen(false)}
onPrevious={() => setPreviewIndex((value) => Math.max(0, value - 1))}
onNext={() => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1))}
/>
)}
{undefinedDialog && (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="undefined-template-field-title">
<div className="modal-panel template-action-dialog">
<header className="modal-header">
<h2 id="undefined-template-field-title">Undefined template field</h2>
<button className="modal-close" onClick={() => setUndefinedDialog(null)}>×</button>
</header>
<div className="modal-body">
<p>The template uses <code>{`{{${undefinedDialog.raw}}}`}</code>, but it cannot be matched to a known field.</p>
{undefinedDialog.reason === "invalid-namespace" && <div className="alert warning">Use the namespace <code>global:</code> or <code>local:</code>.</div>}
{undefinedDialog.reason === "missing-field" && <p className="muted">You can add the name <strong>{undefinedDialog.name}</strong> as a campaign field, or remove this placeholder from subject, plain text and HTML.</p>}
</div>
<footer className="modal-footer">
<Button onClick={() => setUndefinedDialog(null)}>Cancel</Button>
<Button onClick={() => removePlaceholder(undefinedDialog)}>Remove from template</Button>
<Button variant="primary" onClick={() => addUndefinedField(undefinedDialog)} disabled={!undefinedDialog.name}>Add field</Button>
</footer>
</div>
</div>
)}
</div>
);
}
function TemplateFieldChipList({
namespace,
names,
usedPlaceholders,
empty,
onInsert
}: {
namespace: TemplateNamespace;
names: string[];
usedPlaceholders: TemplatePlaceholder[];
empty: string;
onInsert: (namespace: TemplateNamespace, name: string) => void;
}) {
if (names.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
{names.map((name) => {
const used = usedPlaceholders.some((field) => field.validNamespace && field.namespace === namespace && field.name === name);
return (
<button type="button" className={`field-chip field-chip-button ${used ? "used" : ""}`} key={`${namespace}:${name}`} onClick={() => onInsert(namespace, name)}>
<span className="field-chip-namespace">{namespace}</span>{name}
</button>
);
})}
</div>
);
}
function UndefinedPlaceholderList({ items, onSelect }: { items: UndefinedPlaceholder[]; onSelect: (item: UndefinedPlaceholder) => void }) {
if (items.length === 0) return <p className="muted">No undefined placeholders detected.</p>;
return (
<div className="field-chip-list">
{items.map((item) => (
<button type="button" className="field-chip field-chip-button undefined" key={`${item.raw}:${item.reason}`} onClick={() => onSelect(item)}>
{item.display}
</button>
))}
</div>
);
}
function TemplatePreviewOverlay({
bodyMode,
entry,
index,
total,
subject,
text,
html,
hasRealRecipients,
onClose,
onPrevious,
onNext
}: {
bodyMode: BodyMode;
entry: Record<string, unknown>;
index: number;
total: number;
subject: string;
text: string;
html: string;
hasRealRecipients: boolean;
onClose: () => void;
onPrevious: () => void;
onNext: () => void;
}) {
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="template-preview-title">
<div className="modal-panel template-preview-modal">
<header className="modal-header">
<h2 id="template-preview-title">Template preview</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<div className="template-preview-toolbar">
<div>
<strong>{hasRealRecipients ? recipientLabel(entry, index) : "Global preview"}</strong>
<p className="muted small-note">{hasRealRecipients ? `${index + 1} of ${total}` : "No inline recipients are available yet."}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={onPrevious} disabled={index <= 0}>Previous</Button>
<Button onClick={onNext} disabled={index >= total - 1}>Next</Button>
</div>
</div>
<div className="template-preview-box">
<h3>{subject || "No subject"}</h3>
{bodyMode === "html" ? (
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={html || "<p>No HTML body to preview.</p>"} />
) : (
<pre>{text || "No plain-text body to preview."}</pre>
)}
</div>
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
);
}
function extractTemplatePlaceholders(text: string): TemplatePlaceholder[] {
const placeholders = new Map<string, TemplatePlaceholder>();
const patterns = [/\$\{\s*([^}]+?)\s*\}/g, /\{\{\s*([^}]+?)\s*\}\}/g];
for (const pattern of patterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(text))) {
const raw = match[1].trim();
if (!raw || placeholders.has(raw)) continue;
const parsed = parseTemplatePlaceholder(raw);
placeholders.set(raw, parsed);
}
}
return [...placeholders.values()].sort((a, b) => a.display.localeCompare(b.display));
}
function parseTemplatePlaceholder(raw: string): TemplatePlaceholder {
const cleaned = raw.trim().replace(/^fields\./, "local:").replace(/^local\./, "local:").replace(/^global\./, "global:").replace(/^local::/, "local:").replace(/^global::/, "global:");
const separator = cleaned.indexOf(":");
const namespace = separator > -1 ? cleaned.slice(0, separator).trim() : "";
const name = separator > -1 ? cleaned.slice(separator + 1).trim() : cleaned.trim();
const validNamespace = namespace === "global" || namespace === "local";
return {
raw,
namespace,
name,
validNamespace,
display: validNamespace ? `${namespace}:${name}` : raw
};
}
function uniquePlaceholders<T extends TemplatePlaceholder>(items: T[]): T[] {
const seen = new Set<string>();
const result: T[] = [];
for (const item of items) {
const key = item.raw;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function buildPreviewContext(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, string> {
const context: Record<string, string> = {};
const globalValues = asRecord(draft?.global_values);
const entryFields = asRecord(entry.fields);
const overridePolicy = fieldOverridePolicy(draft);
for (const [key, value] of Object.entries(globalValues)) {
addContextValue(context, key, "global", value);
addContextValue(context, key, "local", value);
}
for (const [key, value] of Object.entries(entryFields)) {
if (canOverrideField(overridePolicy, key)) {
addContextValue(context, key, "local", value);
}
}
if (entry.name) addContextValue(context, "name", "local", entry.name);
if (entry.email) addContextValue(context, "email", "local", entry.email);
return context;
}
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
const policy = new Map<string, boolean>();
for (const field of asArray(draft?.fields).map(asRecord)) {
const name = String(field.name || field.id || "").trim();
if (!name) continue;
policy.set(name, getBool(field, "can_override", true));
}
return policy;
}
function canOverrideField(policy: Map<string, boolean>, name: string): boolean {
if (!policy.has(name)) return true;
return policy.get(name) !== false;
}
function addContextValue(context: Record<string, string>, key: string, namespace: TemplateNamespace, value: unknown) {
const text = valueToPreview(value);
context[key] = text;
context[`${namespace}:${key}`] = text;
context[`${namespace}::${key}`] = text;
}
function renderPreviewText(text: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
if (!text) return "";
return text
.replace(/\$\{\s*([^}]+?)\s*\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields))
.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields));
}
function previewValueFor(raw: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
const key = normalizePreviewKey(raw);
const value = context[key];
if (value !== undefined) return value;
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
}
function normalizePreviewKey(raw: string): string {
return raw.trim().replace(/^fields\./, "local:").replace(/^local\./, "local:").replace(/^global\./, "global:").replace(/^local::/, "local:").replace(/^global::/, "global:");
}
function valueToPreview(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function recipientLabel(entry: Record<string, unknown>, index: number): string {
const name = valueToPreview(entry.name).trim();
const email = valueToPreview(entry.email).trim();
if (name && email) return `${name} <${email}>`;
if (name) return name;
if (email) return email;
return `Recipient ${index + 1}`;
}
function uniqueSorted(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
}
function humanizeFieldName(value: string): string {
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
function removePlaceholderFromText(text: string, raw: string): string {
if (!text) return text;
const escaped = escapeRegExp(raw.trim());
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,20 @@
import FormField from "../../../components/FormField";
export default function AttachmentRuleCard() {
return (
<div className="attachment-rule-card">
<div className="attachment-rule-header">
<strong>Attachment rule</strong>
<span className="muted">Personalized documents</span>
</div>
<div className="form-grid compact">
<FormField label="Base directory"><input placeholder="xls/" /></FormField>
<FormField label="File filter"><input placeholder="ab????-${local::number}-*.XLSX" /></FormField>
<FormField label="Include subdirectories"><select><option>No</option><option>Yes</option></select></FormField>
<FormField label="Allow multiple matches"><select><option>Yes</option><option>No</option></select></FormField>
<FormField label="Missing behavior"><select><option>Ask</option><option>Block</option><option>Warn</option><option>Drop</option><option>Continue</option></select></FormField>
<FormField label="Ambiguous behavior"><select><option>Ask</option><option>Block</option><option>Warn</option><option>Drop</option><option>Continue</option></select></FormField>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
export default function FieldMappingTable() {
const rows = [
["email", "E-Mail", "required", "person@example.org", "ok"],
["fields.number", "Dienststelle", "required", "123456", "ok"],
["fields.password", "Passwort", "optional", "••••••", "ok"]
];
return (
<div className="mapping-table">
<div className="mapping-header">
<span>Campaign field</span>
<span>Source column</span>
<span>Required</span>
<span>Preview</span>
<span>Status</span>
</div>
{rows.map((row) => (
<div className="mapping-row" key={row[0]}>
{row.map((cell, index) => <span key={index}>{cell}</span>)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import Button from "../../../components/Button";
type NavigationAction = () => void;
type UnsavedChangesRegistration = {
title?: string;
message?: string;
onSave: () => boolean | Promise<boolean>;
onDiscard?: () => void;
};
type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: NavigationAction) => void;
};
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
export function CampaignUnsavedChangesProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const [registration, setRegistration] = useState<UnsavedChangesRegistration | null>(null);
const [pendingAction, setPendingAction] = useState<NavigationAction | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const registrationRef = useRef<UnsavedChangesRegistration | null>(null);
useEffect(() => {
registrationRef.current = registration;
}, [registration]);
const hasUnsavedChanges = Boolean(registration);
const registerUnsavedChanges = useCallback((next: UnsavedChangesRegistration | null) => {
setRegistration(next);
return () => {
setRegistration((current) => current === next ? null : current);
};
}, []);
const proceed = useCallback((action: NavigationAction) => {
setPendingAction(null);
setSaveError("");
action();
}, []);
const requestNavigation = useCallback((action: NavigationAction) => {
const active = registrationRef.current;
if (!active) {
action();
return;
}
setSaveError("");
setPendingAction(() => action);
}, []);
useEffect(() => {
function onBeforeUnload(event: BeforeUnloadEvent) {
if (!registrationRef.current) return;
event.preventDefault();
event.returnValue = "";
}
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
useEffect(() => {
function onDocumentClick(event: MouseEvent) {
if (!registrationRef.current) return;
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
const target = event.target as Element | null;
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
if (!anchor) return;
if (anchor.target && anchor.target !== "_self") return;
if (anchor.hasAttribute("download")) return;
if (anchor.getAttribute("href")?.startsWith("#")) return;
const destination = new URL(anchor.href, window.location.href);
const current = new URL(window.location.href);
if (destination.href === current.href) return;
event.preventDefault();
event.stopPropagation();
requestNavigation(() => {
if (destination.origin === current.origin) {
navigate(`${destination.pathname}${destination.search}${destination.hash}`);
} else {
window.location.assign(destination.href);
}
});
}
document.addEventListener("click", onDocumentClick, true);
return () => document.removeEventListener("click", onDocumentClick, true);
}, [navigate, requestNavigation]);
async function handleSaveAndLeave() {
const action = pendingAction;
const active = registrationRef.current;
if (!action || !active) return;
setSaving(true);
setSaveError("");
try {
const ok = await active.onSave();
if (!ok) {
setSaveError("The changes could not be saved. Please review the page message and try again.");
return;
}
proceed(action);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
function handleDiscardAndLeave() {
const action = pendingAction;
const active = registrationRef.current;
if (!action) return;
active?.onDiscard?.();
proceed(action);
}
const value = useMemo<UnsavedChangesContextValue>(() => ({
hasUnsavedChanges,
registerUnsavedChanges,
requestNavigation
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
return (
<UnsavedChangesContext.Provider value={value}>
{children}
{pendingAction && registration && (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<div className="modal-panel unsaved-changes-dialog">
<header className="modal-header">
<h2>{registration.title ?? "Unsaved campaign changes"}</h2>
<button className="modal-close" onClick={() => setPendingAction(null)} disabled={saving}>×</button>
</header>
<div className="modal-body">
<p>{registration.message ?? "This campaign page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
{saveError && <div className="alert danger">{saveError}</div>}
</div>
<footer className="modal-footer unsaved-changes-actions">
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
<Button variant="primary" onClick={handleSaveAndLeave} disabled={saving}>{saving ? "Saving…" : "Save and leave"}</Button>
</footer>
</div>
</div>
)}
</UnsavedChangesContext.Provider>
);
}
export function useCampaignUnsavedChanges() {
const context = useContext(UnsavedChangesContext);
if (!context) {
throw new Error("useCampaignUnsavedChanges must be used inside CampaignUnsavedChangesProvider");
}
return context;
}
export function useRegisterCampaignUnsavedChanges(registration: UnsavedChangesRegistration | null) {
const { registerUnsavedChanges } = useCampaignUnsavedChanges();
useEffect(() => {
return registerUnsavedChanges(registration);
}, [registerUnsavedChanges, registration]);
}

View File

@@ -0,0 +1,61 @@
import { useCallback, useEffect, useState } from "react";
import type { ApiSettings } from "../../../types";
import {
getCampaign,
getCampaignSummary,
getCampaignVersion,
listCampaignVersions,
type CampaignSummary,
type CampaignVersionDetail,
type CampaignVersionListItem
} from "../../../api/campaigns";
import type { CampaignWorkspaceData } from "../utils/campaignView";
const initialData: CampaignWorkspaceData = {
campaign: null,
versions: [],
currentVersion: null,
summary: null
};
export function useCampaignWorkspaceData(settings: ApiSettings, campaignId: string) {
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const reload = useCallback(async () => {
if (!campaignId) return;
setLoading(true);
setError("");
try {
const [campaign, versions] = await Promise.all([
getCampaign(settings, campaignId),
listCampaignVersions(settings, campaignId)
]);
const selectedVersionId = campaign.current_version_id ?? versions[0]?.id;
const [versionResult, summaryResult] = await Promise.allSettled([
selectedVersionId ? getCampaignVersion(settings, campaignId, selectedVersionId) : Promise.resolve(null),
getCampaignSummary(settings, campaignId)
]);
setData({
campaign,
versions,
currentVersion: versionResult.status === "fulfilled" ? (versionResult.value as CampaignVersionDetail | null) : null,
summary: summaryResult.status === "fulfilled" ? (summaryResult.value as CampaignSummary | null) : null
});
} catch (err) {
setData(initialData);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [settings, campaignId]);
useEffect(() => {
reload();
}, [reload]);
return { data, loading, error, reload, setError };
}

View File

@@ -0,0 +1,155 @@
import type { CampaignListItem } from "../../../types";
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
export type CampaignWorkspaceData = {
campaign: CampaignListItem | null;
versions: CampaignVersionListItem[];
currentVersion: CampaignVersionDetail | null;
summary: CampaignSummary | null;
};
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
export function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
return version?.raw_json ?? version?.campaign_json ?? {};
}
export function getCampaignSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).campaign);
}
export function getRecipientsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).recipients);
}
export function getTemplateSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).template);
}
export function getDeliverySection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).delivery);
}
export function getEntriesSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).entries);
}
export function getAttachmentsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).attachments);
}
export function getFields(version: CampaignVersionDetail | null): unknown[] {
return asArray(getCampaignJson(version).fields);
}
export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version) return false;
if (version.locked_at || version.published_at) return true;
return ["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "");
}
export function versionLockReason(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
if (!version) return "No campaign version is loaded.";
if (version.locked_at) return `Locked at ${formatDateTime(version.locked_at)}.`;
if (version.published_at) return `Published at ${formatDateTime(version.published_at)}.`;
if (["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "")) {
return `Workflow state is ${humanize(version.workflow_state ?? "locked")}.`;
}
return "Editable working version.";
}
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
if (!version) return "—";
const flow = version.current_flow || "manual";
const step = version.current_step || "not set";
return `${humanize(flow)} / ${humanize(step)}`;
}
export function formatDateTime(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
export function humanize(value?: string | null): string {
if (!value) return "—";
return value.replace(/_/g, " ").replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
export function summaryValue(summary: Record<string, unknown> | null | undefined, keys: string[]): string | number {
if (!summary) return "—";
for (const key of keys) {
const value = summary[key];
if (typeof value === "number" || typeof value === "string") return value;
}
return "—";
}
export function getString(record: Record<string, unknown>, key: string, fallback = "—"): string {
const value = record[key];
if (typeof value === "string" && value.trim()) return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return fallback;
}
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
let current: unknown = record;
for (const part of path) {
if (!isRecord(current)) return fallback;
current = current[part];
}
if (typeof current === "string" && current.trim()) return current;
if (typeof current === "number" || typeof current === "boolean") return String(current);
if (Array.isArray(current)) return current.length ? current.join(", ") : fallback;
if (isRecord(current)) return stringifyPreview(current, 120);
return fallback;
}
export function stringifyPreview(value: unknown, maxLength = 220): string {
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? "";
return text.length > maxLength ? `${text.slice(0, maxLength)}` : text;
}
export function cloneCampaignJsonForCopy(
source: Record<string, unknown>,
campaign: CampaignListItem | null,
stamp: string
): { externalId: string; name: string; description: string; rawJson: Record<string, unknown> } {
const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
const campaignSection = asRecord(rawJson.campaign);
const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign");
const baseName = String(campaignSection.name || campaign?.name || "Campaign");
const description = String(campaignSection.description || campaign?.description || "");
const externalId = `${baseId}-copy-${stamp}`.replace(/[^a-zA-Z0-9_.-]+/g, "-").toLowerCase();
const name = `${baseName} (copy)`;
rawJson.campaign = {
...campaignSection,
id: externalId,
name,
description
};
return { externalId, name, description, rawJson };
}
export function timestampSlug(date = new Date()): string {
return date.toISOString().slice(0, 19).replace(/[-:T]/g, "");
}

View File

@@ -0,0 +1,107 @@
import type { CampaignVersionDetail } from "../../../api/campaigns";
import { asRecord, getCampaignJson, isRecord } from "./campaignView";
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
export function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? {})) as T;
}
export function ensureCampaignDraft(version: CampaignVersionDetail | null): Record<string, unknown> {
const raw = cloneJson(getCampaignJson(version));
raw.version = typeof raw.version === "string" ? raw.version : "1";
raw.campaign = {
id: "",
name: "",
description: "",
mode: "draft",
...asRecord(raw.campaign)
};
raw.fields = Array.isArray(raw.fields) ? raw.fields : [];
raw.global_values = isRecord(raw.global_values) ? raw.global_values : {};
raw.server = isRecord(raw.server) ? raw.server : {};
raw.recipients = isRecord(raw.recipients) ? raw.recipients : {};
raw.template = isRecord(raw.template) ? raw.template : { subject: "", text: "" };
raw.attachments = {
base_path: ".",
allow_individual: false,
send_without_attachments: true,
global: [],
missing_behavior: "ask",
ambiguous_behavior: "ask",
...asRecord(raw.attachments)
};
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
raw.validation_policy = isRecord(raw.validation_policy) ? raw.validation_policy : {};
raw.delivery = isRecord(raw.delivery) ? raw.delivery : {};
raw.status_tracking = isRecord(raw.status_tracking) ? raw.status_tracking : { enabled: true };
return raw;
}
export function updateNested(
draft: Record<string, unknown>,
path: string[],
value: unknown
): Record<string, unknown> {
const next = cloneJson(draft);
let current: Record<string, unknown> = next;
path.forEach((segment, index) => {
if (index === path.length - 1) {
current[segment] = value;
return;
}
const existing = current[segment];
if (!isRecord(existing)) {
current[segment] = {};
}
current = current[segment] as Record<string, unknown>;
});
return next;
}
export function parseJsonTextarea<T>(text: string, fallback: T): { value: T; error: string } {
if (!text.trim()) return { value: fallback, error: "" };
try {
return { value: JSON.parse(text) as T, error: "" };
} catch (error) {
return { value: fallback, error: error instanceof Error ? error.message : String(error) };
}
}
export function stringifyJson(value: unknown): string {
return JSON.stringify(value ?? {}, null, 2);
}
export function getBool(record: Record<string, unknown>, key: string, fallback = false): boolean {
const value = record[key];
return typeof value === "boolean" ? value : fallback;
}
export function getNumber(record: Record<string, unknown>, key: string, fallback = 0): number {
const value = record[key];
return typeof value === "number" ? value : fallback;
}
export function getText(record: Record<string, unknown>, key: string, fallback = ""): string {
const value = record[key];
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return fallback;
}
export function downloadJson(filename: string, data: Record<string, unknown>) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export function safeFileStem(value?: string | null): string {
const stem = (value || "campaign").replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
return stem || "campaign";
}

View File

@@ -0,0 +1,405 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings, WizardStep } from "../../../types";
import Stepper from "../../../components/Stepper";
import Card from "../../../components/Card";
import Button from "../../../components/Button";
import FormField from "../../../components/FormField";
import PageTitle from "../../../components/PageTitle";
import ToggleSwitch from "../../../components/ToggleSwitch";
import EmailAddressInput from "../../../components/email/EmailAddressInput";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../../utils/emailAddresses";
import MetricCard from "../../../components/MetricCard";
import { autosaveCampaignVersion, validatePartial } from "../../../api/campaigns";
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, stringifyPreview, summaryValue, versionLockReason } from "../utils/campaignView";
import { ensureCampaignDraft, getBool, getNumber, getText, parseJsonTextarea, stringifyJson, updateNested } from "../utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
const steps: WizardStep[] = [
{ id: "basics", label: "Basics", description: "Name and scenario" },
{ id: "sender", label: "Sender", description: "Mail account and headers" },
{ id: "fields", label: "Fields", description: "Define campaign data" },
{ id: "recipients", label: "Recipients", description: "Import and map source data" },
{ id: "template", label: "Template", description: "Subject and body" },
{ id: "attachments", label: "Attachments", description: "Rules and ZIP options" },
{ id: "review", label: "Review", description: "Validate before build" },
{ id: "send", label: "Send", description: "Test and queue" }
];
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
export default function CreateWizard({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const [activeStep, setActiveStep] = useState("basics");
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loading…");
const [localError, setLocalError] = useState("");
const [validationMessage, setValidationMessage] = useState("");
const loadedVersionId = useRef<string | null>(null);
const index = steps.findIndex((s) => s.id === activeStep);
const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
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");
if (version.current_step && steps.some((step) => step.id === version.current_step)) {
setActiveStep(version.current_step);
}
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchRoot(key: string, value: unknown) {
patch([key], value);
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "create",
current_step: activeStep,
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;
}
}
function selectStep(stepId: string) {
setActiveStep(stepId);
}
function nextStep() {
setActiveStep(steps[Math.min(steps.length - 1, index + 1)].id);
}
function previousStep() {
setActiveStep(steps[Math.max(0, index - 1)].id);
}
async function validateCurrentStep() {
if (!version || !draft) return;
setValidationMessage("Validating…");
try {
const result = await validatePartial(settings, campaignId, version.id, { campaign_json: draft, section: activeStep });
setValidationMessage(`${result.error_count} errors, ${result.warning_count} warnings, ${result.info_count} info messages.`);
} catch (err) {
setValidationMessage(err instanceof Error ? err.message : String(err));
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved wizard changes",
message: "This campaign wizard has unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
if (locked) {
return (
<div className="wizard-page">
<div className="wizard-card locked-wizard-card">
<div className="wizard-body standalone-wizard-body">
<div className="wizard-heading">
<div>
<PageTitle>Create campaign</PageTitle>
</div>
<div className="save-state">Locked</div>
</div>
<Card>
<div className="alert info">
{versionLockReason(data.currentVersion)} Create or copy a working version before editing campaign data, recipients, template or attachment rules.
</div>
<div className="button-row">
<Link to="../.."><Button variant="primary">Back to overview</Button></Link>
</div>
</Card>
</div>
</div>
</div>
);
}
return (
<div className="wizard-page">
<div className="wizard-card">
<Stepper steps={steps} activeStep={activeStep} onSelect={selectStep} />
<div className="wizard-body">
<div className="wizard-heading">
<div>
<PageTitle loading={loading}>Create campaign</PageTitle>
</div>
<div className="save-state">{saveState}</div>
</div>
{localError && <div className="alert danger">{localError}</div>}
{validationMessage && <div className="alert info">{validationMessage}</div>}
<Card>
{draft && activeStep === "basics" && <BasicsStep draft={draft} patch={patch} />}
{draft && activeStep === "sender" && <SenderStep draft={draft} patch={patch} />}
{draft && activeStep === "fields" && <FieldsStep draft={draft} patchRoot={patchRoot} />}
{draft && activeStep === "recipients" && <RecipientsStep draft={draft} patchRoot={patchRoot} />}
{draft && activeStep === "template" && <TemplateStep draft={draft} patch={patch} />}
{draft && activeStep === "attachments" && <AttachmentsStep draft={draft} patch={patch} />}
{draft && activeStep === "review" && <ReviewStep version={version} onValidate={validateCurrentStep} />}
{draft && activeStep === "send" && <SendStep draft={draft} patch={patch} />}
</Card>
<div className="wizard-footer">
<Button onClick={previousStep}>Back</Button>
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "Save now" : "Saved"}</Button>
<Button onClick={validateCurrentStep}>Validate step</Button>
<Button variant="primary" onClick={nextStep}>Continue</Button>
</div>
</div>
</div>
</div>
);
}
function BasicsStep({ draft, patch }: StepProps) {
const campaign = asRecord(draft.campaign);
return (
<div className="form-grid">
<FormField label="Campaign name" help="A human-readable name shown in lists and reports.">
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
</FormField>
<FormField label="Campaign ID" help="Stable technical identifier.">
<input value={getText(campaign, "id")} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
</FormField>
<FormField label="Mode">
<select value={getText(campaign, "mode", "draft")} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
<option value="draft">Draft</option>
<option value="test">Test</option>
<option value="send">Send</option>
</select>
</FormField>
<FormField label="Description">
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
</FormField>
</div>
);
}
function SenderStep({ draft, patch }: StepProps) {
const recipients = asRecord(draft.recipients);
const from = asRecord(recipients.from);
const suggestions = collectCampaignAddressSuggestions(draft);
const globalTo = addressesFromValue(recipients.to);
const globalCc = addressesFromValue(recipients.cc);
const globalBcc = addressesFromValue(recipients.bcc);
const globalReplyTo = addressesFromValue(recipients.reply_to);
const server = asRecord(draft.server);
const smtp = asRecord(server.smtp);
const delivery = asRecord(draft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
return (
<div className="form-grid">
<FormField label="Default From address">
<EmailAddressInput
value={addressesFromValue(from)}
suggestions={suggestions}
allowMultiple={false}
showAddButton={false}
addLabel={getText(from, "email") ? "Replace" : "Add sender"}
emptyText="No default sender configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })}
/>
</FormField>
<FormField label="Global recipients">
<EmailAddressInput
value={globalTo}
suggestions={suggestions}
allowMultiple
addLabel="Add recipient"
emptyText="No global recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)}
/>
</FormField>
<FormField label="CC">
<EmailAddressInput
value={globalCc}
suggestions={suggestions}
allowMultiple
addLabel="Add CC"
emptyText="No global CC recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)}
/>
</FormField>
<FormField label="BCC">
<EmailAddressInput
value={globalBcc}
suggestions={suggestions}
allowMultiple
addLabel="Add BCC"
emptyText="No global BCC recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)}
/>
</FormField>
<FormField label="Reply-To">
<EmailAddressInput
value={globalReplyTo.slice(0, 1)}
suggestions={suggestions}
allowMultiple={false}
showAddButton={false}
addLabel={globalReplyTo.length ? "Replace" : "Add Reply-To"}
emptyText="No Reply-To address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))}
/>
</FormField>
<FormField label="SMTP host"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="SMTP port"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<ToggleSwitch label="Append successful messages to Sent via IMAP" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>
);
}
function FieldsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
return (
<div>
<div className="step-intro">
<h2>Campaign fields</h2>
<p>Define reusable fields for templates, attachment rules, ZIP passwords and recipient data.</p>
</div>
<JsonEditor value={draft.fields ?? []} onValid={(value) => patchRoot("fields", value)} />
</div>
);
}
function RecipientsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
return (
<div>
<div className="step-intro">
<h2>Recipients</h2>
<p>Store inline recipients or source/mapping configuration. A table editor will replace this JSON editor in the recipient section pass.</p>
</div>
<JsonEditor value={draft.entries ?? { inline: [] }} onValid={(value) => patchRoot("entries", value)} />
</div>
);
}
function TemplateStep({ draft, patch }: StepProps) {
const template = asRecord(draft.template);
return (
<div>
<div className="step-intro">
<h2>Template</h2>
<p>Compose the subject and body. Merge fields can later be inserted from the field picker.</p>
</div>
<div className="form-grid">
<FormField label="Subject"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
<FormField label="Plain text body"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
<FormField label="HTML body"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField>
</div>
</div>
);
}
function AttachmentsStep({ draft, patch }: StepProps) {
const attachments = asRecord(draft.attachments);
return (
<div>
<div className="step-intro">
<h2>Attachments</h2>
<p>Configure campaign-wide attachment behavior and global matching rules.</p>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Campaign attachment base path"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
<FormField label="Missing behavior"><select value={getText(attachments, "missing_behavior", "ask")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Ambiguous behavior"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
</div>
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
</div>
);
}
function ReviewStep({ version, onValidate }: { version: unknown; onValidate: () => void }) {
const record = asRecord(version);
return (
<div>
<div className="step-intro">
<h2>Review setup</h2>
<p>Validate the campaign definition before building message drafts.</p>
</div>
<div className="metric-grid inside">
<MetricCard label="Errors" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
<MetricCard label="Warnings" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
<MetricCard label="Built" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
</div>
<Button variant="primary" onClick={onValidate}>Validate campaign</Button>
</div>
);
}
function SendStep({ draft, patch }: StepProps) {
const delivery = asRecord(draft.delivery);
const rateLimit = asRecord(delivery.rate_limit);
return (
<div>
<div className="step-intro">
<h2>Send preparation</h2>
<p>Configure rate limits and prepare the final send workflow.</p>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
</div>
<p className="muted">Test send and queue actions remain in the Send Wizard for now.</p>
</div>
);
}
type StepProps = {
draft: Record<string, unknown>;
patch: (path: string[], value: unknown) => void;
};
function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unknown) => void }) {
const [text, setText] = useState(stringifyJson(value));
const [error, setError] = useState("");
useEffect(() => {
setText(stringifyJson(value));
setError("");
}, [value]);
function change(nextText: string) {
setText(nextText);
const parsed = parseJsonTextarea(nextText, value);
setError(parsed.error);
if (!parsed.error) onValid(parsed.value);
}
return (
<div className="json-edit-block">
<textarea rows={12} value={text} onChange={(event) => change(event.target.value)} />
{error ? <p className="form-help danger-text">Invalid JSON: {error}</p> : <p className="form-help">Valid JSON is saved with the wizard draft.</p>}
{Array.isArray(value) && value.length > 0 && <p className="form-help">Preview: {stringifyPreview(asArray(value)[0], 140)}</p>}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import Card from "../../../components/Card";
import MetricCard from "../../../components/MetricCard";
import Button from "../../../components/Button";
export default function ReviewWizard() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Review Wizard</h1>
</div>
<div className="metric-grid">
<MetricCard label="Needs review" value="—" tone="warning" />
<MetricCard label="Missing attachments" value="—" tone="warning" />
<MetricCard label="Ambiguous matches" value="—" tone="info" />
<MetricCard label="Blocked" value="—" tone="danger" />
</div>
<Card title="Resolution workflow">
<p className="muted">This wizard will guide users through issues one class at a time.</p>
<Button variant="primary">Start review</Button>
</Card>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import Card from "../../../components/Card";
import Button from "../../../components/Button";
export default function SendWizard() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Send Wizard</h1>
</div>
<div className="dashboard-grid">
<Card title="Test send">
<p className="muted">Send one generated message to a test address.</p>
<Button>Open test-send dialog</Button>
</Card>
<Card title="Queue estimate">
<p className="muted">Estimated duration will be based on ready jobs and rate limits.</p>
<Button variant="primary">Queue dry run</Button>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import FormField from "../../../../components/FormField";
import Button from "../../../../components/Button";
import AttachmentRuleCard from "../../components/AttachmentRuleCard";
export default function AttachmentsStep() {
return (
<div>
<div className="step-intro">
<h2>Attachments</h2>
<p>Configure the campaign base path and one or more attachment matching rules.</p>
</div>
<FormField label="Campaign attachment base path">
<input placeholder="./data/attachments" />
</FormField>
<AttachmentRuleCard />
<div className="button-row">
<Button>Add attachment rule</Button>
<Button variant="primary">Resolve attachments</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import FormField from "../../../../components/FormField";
export default function BasicsStep() {
return (
<div className="form-grid">
<FormField label="Campaign name" help="A human-readable name shown in lists and reports.">
<input placeholder="Rechnungslegung 2026-05" />
</FormField>
<FormField label="Campaign ID" help="Stable technical identifier.">
<input placeholder="rechnungslegung-2026-05" />
</FormField>
<FormField label="Scenario">
<select>
<option>Personalized documents with attachments</option>
<option>Simple bulk message</option>
<option>Recurring monthly campaign</option>
</select>
</FormField>
<FormField label="Description">
<textarea rows={5} placeholder="Describe the purpose of this campaign…" />
</FormField>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import FieldMappingTable from "../../components/FieldMappingTable";
import Button from "../../../../components/Button";
export default function FieldsStep() {
return (
<div>
<div className="step-intro">
<h2>Campaign fields</h2>
<p>Define reusable fields for templates, attachment rules, ZIP passwords and recipient data.</p>
</div>
<div className="button-row">
<Button variant="primary">Add field wizard</Button>
<Button>Add manually</Button>
</div>
<FieldMappingTable />
</div>
);
}

View File

@@ -0,0 +1,29 @@
import FieldMappingTable from "../../components/FieldMappingTable";
import Button from "../../../../components/Button";
import FormField from "../../../../components/FormField";
export default function RecipientsStep() {
return (
<div>
<div className="step-intro">
<h2>Recipients</h2>
<p>Upload or reference a recipient source, then map source columns to campaign fields.</p>
</div>
<div className="form-grid compact">
<FormField label="Source type">
<select>
<option>CSV file</option>
<option>Inline recipients</option>
<option>JSON file</option>
</select>
</FormField>
<FormField label="Source path"><input placeholder="./data/recipients.csv" /></FormField>
</div>
<div className="button-row">
<Button>Preview source</Button>
<Button variant="primary">Auto-map columns</Button>
</div>
<FieldMappingTable />
</div>
);
}

View File

@@ -0,0 +1,19 @@
import MetricCard from "../../../../components/MetricCard";
import Button from "../../../../components/Button";
export default function ReviewStep() {
return (
<div>
<div className="step-intro">
<h2>Review setup</h2>
<p>Validate the campaign definition before building message drafts.</p>
</div>
<div className="metric-grid inside">
<MetricCard label="Ready" value="—" tone="good" />
<MetricCard label="Warnings" value="—" tone="warning" />
<MetricCard label="Needs review" value="—" tone="info" />
</div>
<Button variant="primary">Validate campaign</Button>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import FormField from "../../../../components/FormField";
import Button from "../../../../components/Button";
export default function SendStep() {
return (
<div>
<div className="step-intro">
<h2>Send preparation</h2>
<p>Configure rate limits and prepare the final send workflow.</p>
</div>
<div className="form-grid compact">
<FormField label="Messages per minute"><input type="number" defaultValue={5} min={1} /></FormField>
<FormField label="Concurrency"><input type="number" defaultValue={1} min={1} /></FormField>
</div>
<div className="button-row">
<Button>Send test</Button>
<Button>Queue dry run</Button>
<Button variant="primary">Open Send Wizard</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import FormField from "../../../../components/FormField";
export default function SenderStep() {
return (
<div className="form-grid">
<FormField label="From name"><input placeholder="Office" /></FormField>
<FormField label="From email"><input placeholder="office@example.org" /></FormField>
<FormField label="Reply-To"><input placeholder="reply@example.org" /></FormField>
<FormField label="IMAP append to Sent">
<select>
<option>Enabled, auto-detect Sent folder</option>
<option>Disabled</option>
<option>Enabled, manual folder</option>
</select>
</FormField>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import FormField from "../../../../components/FormField";
import Button from "../../../../components/Button";
export default function TemplateStep() {
return (
<div>
<div className="step-intro">
<h2>Template</h2>
<p>Compose the subject and body. Merge fields can be inserted from the field picker.</p>
</div>
<div className="button-row">
<Button>Insert merge field</Button>
<Button>Preview recipient</Button>
</div>
<div className="form-grid">
<FormField label="Subject"><input placeholder="Ihre Unterlagen für ${global::monthyear}" /></FormField>
<FormField label="Plain text body"><textarea rows={12} placeholder="Sehr geehrte/r ${local::name}, …" /></FormField>
</div>
</div>
);
}