first version able to send

This commit is contained in:
2026-06-11 00:04:00 +02:00
parent be793fb3e7
commit 93fb55273c
16 changed files with 869 additions and 645 deletions

View File

@@ -4,6 +4,7 @@ import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import AdminPlaceholderTable, { type AdminPlaceholderTableConfig } from "./components/AdminPlaceholderTable";
type AdminSection = "overview" | "system" | "tenants" | "users" | "groups" | "roles" | "api-keys" | "mail-servers" | "settings" | "audit";
@@ -32,6 +33,45 @@ const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
}
];
const adminTableConfigs = {
tenants: {
title: "Tenants",
columns: ["Tenant", "Slug", "Users", "Storage", "Status"],
rows: ["Default|default|1|Local dev|Active"],
action: "Create tenant",
note: "Tenant administration is system-wide. Backend list, create and suspension endpoints can be wired here later."
},
users: {
title: "Users",
columns: ["User", "Groups", "Roles", "Status", "Last activity"],
rows: ["admin@example.local|Default administrators|Owner|Active|Development seed"],
action: "Invite user",
note: "Tenant users should support invitations, activation state, group membership and role assignments."
},
groups: {
title: "Groups",
columns: ["Group", "Members", "Campaign access", "Default role", "Status"],
rows: ["Default administrators|1|All campaigns|Owner|Seed data"],
action: "Create group",
note: "Groups should later become the main unit for campaign ownership, sharing and storage spaces."
},
roles: {
title: "Roles",
columns: ["Role", "Permissions", "Scope", "Assignable", "Status"],
rows: ["Owner|All current permissions|Tenant|No|Seed data", "Campaign operator|View/edit/review/send planned|Campaign/group|Yes|Planned"],
action: "Create role",
note: "Role definitions are mocked until permission discovery and assignment endpoints are available."
},
mailServers: {
title: "Mail servers",
columns: ["Profile", "SMTP", "IMAP", "Default", "Status"],
rows: ["Campaign-local settings|Configured per campaign|Optional per campaign|No|Current behavior", "Tenant default SMTP|Planned|Planned|Planned|Mock"],
action: "Add mail server",
note: "Tenant mail server profiles can later prefill campaign Server settings while campaigns remain self-contained."
}
} satisfies Record<"tenants" | "users" | "groups" | "roles" | "mailServers", AdminPlaceholderTableConfig>;
const sectionTitles: Record<AdminSection, { title: string; description: string }> = {
overview: { title: "Overview", description: "Administrative entry point for system-wide and tenant-scoped management." },
system: { title: "System", description: "Instance-wide health, workers, storage and diagnostics." },
@@ -144,19 +184,19 @@ function System() {
}
function Tenants() {
return <PlaceholderAdminTable title="Tenants" columns={["Tenant", "Slug", "Users", "Storage", "Status"]} rows={["Default|default|1|Local dev|Active"]} action="Create tenant" note="Tenant administration is system-wide. Backend list, create and suspension endpoints can be wired here later." />;
return <AdminPlaceholderTable {...adminTableConfigs.tenants} />;
}
function Users() {
return <PlaceholderAdminTable title="Users" columns={["User", "Groups", "Roles", "Status", "Last activity"]} rows={["admin@example.local|Default administrators|Owner|Active|Development seed"]} action="Invite user" note="Tenant users should support invitations, activation state, group membership and role assignments." />;
return <AdminPlaceholderTable {...adminTableConfigs.users} />;
}
function Groups() {
return <PlaceholderAdminTable title="Groups" columns={["Group", "Members", "Campaign access", "Default role", "Status"]} rows={["Default administrators|1|All campaigns|Owner|Seed data"]} action="Create group" note="Groups should later become the main unit for campaign ownership, sharing and storage spaces." />;
return <AdminPlaceholderTable {...adminTableConfigs.groups} />;
}
function Roles() {
return <PlaceholderAdminTable title="Roles" columns={["Role", "Permissions", "Scope", "Assignable", "Status"]} rows={["Owner|All current permissions|Tenant|No|Seed data", "Campaign operator|View/edit/review/send planned|Campaign/group|Yes|Planned"]} action="Create role" note="Role definitions are mocked until permission discovery and assignment endpoints are available." />;
return <AdminPlaceholderTable {...adminTableConfigs.roles} />;
}
function ApiKeys() {
@@ -174,7 +214,7 @@ function ApiKeys() {
}
function MailServers() {
return <PlaceholderAdminTable title="Mail servers" columns={["Profile", "SMTP", "IMAP", "Default", "Status"]} rows={["Campaign-local settings|Configured per campaign|Optional per campaign|No|Current behavior", "Tenant default SMTP|Planned|Planned|Planned|Mock"]} action="Add mail server" note="Tenant mail server profiles can later prefill campaign Server settings while campaigns remain self-contained." />;
return <AdminPlaceholderTable {...adminTableConfigs.mailServers} />;
}
function TenantSettings() {
@@ -208,20 +248,3 @@ function Audit() {
</Card>
);
}
function PlaceholderAdminTable({ title, columns, rows, action, note }: { title: string; columns: string[]; rows: string[]; action: string; note?: string }) {
return (
<Card title={title} actions={<Button disabled>{action}</Button>}>
<div className="alert info">This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.</div>
{note && <p className="muted">{note}</p>}
<div className="app-table-wrap compact-table-wrap">
<table className="app-table module-table">
<thead><tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr></thead>
<tbody>
{rows.map((row) => <tr key={row}>{row.split("|").map((cell, index) => <td key={`${row}-${index}`}>{cell}</td>)}</tr>)}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -0,0 +1,27 @@
import Button from "../../../components/Button";
import Card from "../../../components/Card";
export type AdminPlaceholderTableConfig = {
title: string;
columns: string[];
rows: string[];
action: string;
note?: string;
};
export default function AdminPlaceholderTable({ title, columns, rows, action, note }: AdminPlaceholderTableConfig) {
return (
<Card title={title} actions={<Button disabled>{action}</Button>}>
<div className="alert info">This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.</div>
{note && <p className="muted">{note}</p>}
<div className="app-table-wrap compact-table-wrap">
<table className="app-table module-table">
<thead><tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr></thead>
<tbody>
{rows.map((row) => <tr key={row}>{row.split("|").map((cell, index) => <td key={`${row}-${index}`}>{cell}</td>)}</tr>)}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -8,9 +8,10 @@ import LoadingFrame from "../../components/LoadingFrame";
import ToggleSwitch from "../../components/ToggleSwitch";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { getBool, getText, updateNested } from "./utils/draftEditor";
import { AttachmentRulesTable, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { updateNested } from "./utils/draftEditor";
import { AttachmentRulesTable } from "./components/AttachmentRulesOverlay";
import { countIndividualAttachmentRules, createAttachmentBasePath, createAttachmentRule, ensureAttachmentBasePaths, mockAttachmentPathOptions, normalizeAttachmentBasePaths, normalizeAttachmentRules, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
type PathChooserState = { index: number };
@@ -31,18 +32,16 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
});
const attachments = asRecord(displayDraft.attachments);
const basePaths = useMemo(() => normalizeBasePaths(attachments.base_paths, attachments), [attachments]);
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
const entries = asRecord(displayDraft.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) })));
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
function patchBasePaths(paths: AttachmentBasePath[]) {
if (locked) return;
const normalized = paths.length > 0 ? paths : [createBasePath("Campaign files", ".")];
const normalized = ensureAttachmentBasePaths(paths);
setDraft((current) => {
const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized);
return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || ".");
@@ -55,7 +54,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
}
function addBasePath() {
patchBasePaths([...basePaths, createBasePath("New attachment source", ".")]);
patchBasePaths([...basePaths, createAttachmentBasePath("New attachment source", ".")]);
}
function removeBasePath(index: number) {
@@ -64,18 +63,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
function addGlobalAttachmentRule() {
if (locked) return;
const firstBasePath = basePaths[0]?.path ?? "";
patch(["attachments", "global"], [
...globalRules,
{
id: `attachment-${Date.now()}`,
label: "",
base_dir: firstBasePath,
file_filter: "",
required: true,
include_subdirs: false
}
]);
patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "")]);
}
@@ -165,7 +153,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<dl className="detail-list">
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
<div><dt>Per-recipient patterns</dt><dd>{individualRules.length}</dd></div>
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div>
<div><dt>Upload support</dt><dd>Planned</dd></div>
</dl>
<p className="muted small-note">The current storage browser is a mock. The next backend-alignment step can connect these base paths and file rules to campaign, group and tenant storage.</p>
@@ -191,14 +179,6 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
}
function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; onSelect: (path: Partial<AttachmentBasePath>) => void }) {
const paths: Array<Partial<AttachmentBasePath> & { label: string }> = [
{ label: "Campaign attachments", path: "attachments" },
{ label: "Campaign root", path: "." },
{ label: "Shared group files", path: "group/shared" },
{ label: "Tenant templates", path: "tenant/templates" },
{ label: "Personal upload area", path: "user/uploads" }
];
return createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-path-chooser-title">
<div className="modal-panel attachment-rules-modal">
@@ -209,7 +189,7 @@ function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; on
<div className="modal-body attachment-rules-body">
<p className="muted small-note">Mock chooser for now. Later this will browse uploaded directories in the available campaign, group, tenant or user spaces.</p>
<div className="placeholder-stack">
{paths.map((path) => (
{mockAttachmentPathOptions.map((path) => (
<Button key={path.path} onClick={() => onSelect(path)}>
{path.label}: <code>{path.path}</code>
</Button>
@@ -224,47 +204,3 @@ function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; on
document.body
);
}
function createBasePath(name: string, path: string): AttachmentBasePath {
return {
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name,
path,
allow_individual: false
};
}
function normalizeBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual")
}];
}
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)}`),
type: getText(rule, "type"),
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),
missing_behavior: getText(rule, "missing_behavior", "ask"),
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
}));
}

View File

@@ -10,16 +10,7 @@ import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { getBool, getText, updateNested } from "./utils/draftEditor";
import FieldValueInput from "./components/FieldValueInput";
const fieldTypeOptions = ["string", "integer", "double", "date", "password"];
type FieldDefinition = {
name: string;
label: string;
type: string;
required: boolean;
can_override: boolean;
};
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
@@ -55,7 +46,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
markDirty();
}
function patchFields(nextFields: FieldDefinition[]) {
function patchFields(nextFields: CampaignFieldDefinition[]) {
patchDraft(["fields"], nextFields.map((field) => ({
name: field.name,
type: field.type || "string",
@@ -70,7 +61,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
}
function setField(index: number, patchValue: Partial<FieldDefinition>) {
function setField(index: number, patchValue: Partial<CampaignFieldDefinition>) {
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, ...patchValue } : field);
patchFields(nextFields);
}
@@ -191,7 +182,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
<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 })}>
<select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>
{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</td>
@@ -215,12 +206,12 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
);
}
function normalizeFields(value: unknown): FieldDefinition[] {
function normalizeFields(value: unknown): CampaignFieldDefinition[] {
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",
type: normalizeFieldType(getText(field, "type")),
required: getBool(field, "required"),
can_override: getBool(field, "can_override", true)
}));
@@ -238,7 +229,7 @@ function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState:
return updateNested(draft, ["fields"], fields);
}
function describeFieldNameProblem(fields: FieldDefinition[]): string {
function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
const names = fields.map((field) => field.name.trim());
if (names.some((name) => !name)) {
return "Field IDs must not be empty before saving.";
@@ -255,7 +246,7 @@ function describeFieldNameProblem(fields: FieldDefinition[]): string {
return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`;
}
function uniqueFieldName(fields: FieldDefinition[]): string {
function uniqueFieldName(fields: CampaignFieldDefinition[]): string {
const existing = new Set(fields.map((field) => field.name));
let counter = fields.length + 1;
let name = `field_${counter}`;
@@ -265,8 +256,3 @@ function uniqueFieldName(fields: FieldDefinition[]): string {
}
return name;
}
function humanizeFieldName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}

View File

@@ -7,18 +7,13 @@ import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { getBool, getText } from "./utils/draftEditor";
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import { getDraftFields } from "./utils/fieldDefinitions";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, type AttachmentRule } from "./utils/attachments";
import { addressesFromValue } from "../../utils/emailAddresses";
type FieldDefinition = {
name: string;
label: string;
type: string;
can_override: boolean;
};
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
@@ -42,10 +37,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
const attachmentSection = asRecord(displayDraft.attachments);
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
const individualAttachmentBasePaths = useMemo(() => {
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
return enabled.length > 0 ? enabled : attachmentBasePaths;
}, [attachmentBasePaths]);
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
@@ -159,22 +151,6 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
);
}
function getDraftFields(draft: Record<string, unknown> | null): FieldDefinition[] {
return asArray(draft?.fields)
.map((field) => asRecord(field))
.map((field) => ({
name: getText(field, "name") || getText(field, "id"),
label: getText(field, "label"),
type: normalizeFieldType(getText(field, "type", "string")),
can_override: getBool(field, "can_override", true)
}))
.filter((field) => Boolean(field.name));
}
function normalizeFieldType(value: string): string {
return ["integer", "double", "date", "password"].includes(value) ? value : "string";
}
function firstRecipientEmail(entry: Record<string, unknown>): string {
return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? "";
}
@@ -183,27 +159,3 @@ function extraRecipientCount(entry: Record<string, unknown>): number {
const count = addressesFromValue(entry.to).length;
return Math.max(0, count - 1);
}
function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", true)
}];
}
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({ ...rule }));
}

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
@@ -5,30 +6,88 @@ import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import Card from "../../components/Card";
import StatusBadge from "../../components/StatusBadge";
import { buildVersion, validateVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, stringifyPreview, summaryValue } from "./utils/campaignView";
import { asArray, asRecord, formatDateTime, stringifyPreview, summaryValue, versionLockReason } from "./utils/campaignView";
export default function ReviewDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const version = data.currentVersion;
const issues = collectIssues(data.summary?.issues);
const validationSummary = asRecord(version?.validation_summary);
const buildSummary = asRecord(version?.build_summary);
const validationOk = validationSummary.ok === true;
const [actionBusy, setActionBusy] = useState<"validate" | "build" | "" >("");
const [actionMessage, setActionMessage] = useState("");
async function runValidate() {
if (!version || actionBusy) return;
setActionBusy("validate");
setActionMessage("Validating campaign and locking the version on success…");
setError("");
try {
const result = await validateVersion(settings, version.id, false);
setActionMessage(result.ok ? "Validation passed. This version is now locked." : "Validation finished with issues. Fix the campaign and validate again.");
await reload();
} catch (err) {
setActionMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setActionBusy("");
}
}
async function runBuild() {
if (!version || actionBusy) return;
setActionBusy("build");
setActionMessage("Building messages for the locked version…");
setError("");
try {
const result = await buildVersion(settings, version.id, true);
setActionMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`);
await reload();
} catch (err) {
setActionMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setActionBusy("");
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Review</PageTitle>
<PageTitle loading={loading || Boolean(actionBusy)}>Review</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={reload} disabled={loading || Boolean(actionBusy)}>Reload</Button>
<Link to="../wizard/review"><Button variant="primary">Open Review Wizard</Button></Link>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{actionMessage && <div className="alert info">{actionMessage}</div>}
<LoadingFrame loading={loading} label="Loading review data…">
<Card title="Review actions" actions={<span className="muted small-note">Validation locks the exact version that was checked.</span>}>
<div className="button-row compact-actions">
<Button variant="primary" onClick={runValidate} disabled={!version || loading || Boolean(actionBusy)}>
{actionBusy === "validate" ? "Validating…" : validationOk ? "Validate again" : "Validate and lock"}
</Button>
<Button onClick={runBuild} disabled={!version || loading || Boolean(actionBusy) || !validationOk}>
{actionBusy === "build" ? "Building…" : "Build messages"}
</Button>
</div>
<dl className="detail-list compact-detail-list">
<div><dt>Version state</dt><dd>{version?.workflow_state ?? "—"}</dd></div>
<div><dt>Lock</dt><dd>{versionLockReason(version)}</dd></div>
<div><dt>Validation</dt><dd>{validationOk ? "Passed" : version?.validation_summary ? "Needs attention" : "Not validated"}</dd></div>
<div><dt>Build</dt><dd>{String(buildSummary.built_count ?? buildSummary.ready_count ?? "Not built")}</dd></div>
</dl>
</Card>
<div className="dashboard-grid">
<Card title="Validation summary">
<div className="summary-grid">

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
@@ -5,34 +6,82 @@ import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import StatusBadge from "../../components/StatusBadge";
import { sendCampaignNow } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getDeliverySection, getNestedString } from "./utils/campaignView";
import { asArray, asRecord, formatDateTime, getDeliverySection, getNestedString, stringifyPreview, versionLockReason } from "./utils/campaignView";
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const version = data.currentVersion;
const cards = data.summary?.cards;
const delivery = getDeliverySection(version);
const rateLimit = asRecord(delivery.rate_limit);
const imapAppend = asRecord(delivery.imap_append_sent);
const retry = asRecord(delivery.retry);
const [sendBusy, setSendBusy] = useState(false);
const [sendMessage, setSendMessage] = useState("");
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
async function runSendNow(dryRun = false) {
if (!version || sendBusy) return;
if (!dryRun && !window.confirm("Send this campaign version now? The validated version will remain locked as the sent audit snapshot.")) return;
setSendBusy(true);
setSendMessage(dryRun ? "Checking what would be sent…" : "Validating, building and sending campaign…");
setSendResult(null);
setError("");
try {
const response = await sendCampaignNow(settings, campaignId, {
version_id: version.id,
include_warnings: true,
check_files: false,
validate_before_send: true,
build_before_send: true,
dry_run: dryRun,
use_rate_limit: true,
enqueue_imap_task: false
});
const result = asRecord(response.result ?? response);
setSendResult(result);
const sent = result.sent_count ?? 0;
const failed = result.failed_count ?? 0;
setSendMessage(dryRun ? "Dry run finished." : `Send finished. Sent ${String(sent)} message(s), failed ${String(failed)}.`);
await reload();
} catch (err) {
setSendMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setSendBusy(false);
}
}
const queuedOrSending = ["queued", "sending"].includes(data.campaign?.status ?? "") || ["queued", "sending"].includes(version?.workflow_state ?? "");
useEffect(() => {
if (!queuedOrSending || loading || sendBusy) return;
const handle = window.setTimeout(() => { void reload(); }, 3000);
return () => window.clearTimeout(handle);
}, [queuedOrSending, loading, reload, sendBusy]);
const resultRows = asArray(sendResult?.results).map(asRecord);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Send</PageTitle>
<PageTitle loading={loading || sendBusy}>Send</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={reload} disabled={loading || sendBusy}>Reload</Button>
<Link to="../wizard/send"><Button variant="primary">Open Send Wizard</Button></Link>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{sendMessage && <div className="alert info">{sendMessage}</div>}
<LoadingFrame loading={loading} label="Loading send data…">
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
<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" />
@@ -40,6 +89,47 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Send campaign" actions={<span className="muted small-note">Small-campaign synchronous send; larger campaigns can use queue workers later.</span>}>
<div className="button-row compact-actions">
<Button onClick={() => void runSendNow(true)} disabled={!version || loading || sendBusy}>Dry run</Button>
<Button variant="primary" onClick={() => void runSendNow(false)} disabled={!version || loading || sendBusy}>
{sendBusy ? "Sending…" : "Send now"}
</Button>
</div>
<dl className="detail-list compact-detail-list">
<div><dt>Campaign status</dt><dd><StatusBadge status={data.campaign?.status ?? "unknown"} /></dd></div>
<div><dt>Version state</dt><dd>{version?.workflow_state ?? "—"}</dd></div>
<div><dt>Version lock</dt><dd>{versionLockReason(version)}</dd></div>
<div><dt>Validation/build</dt><dd>{version?.validation_summary ? "Validation available" : "Will validate before send"} · {version?.build_summary ? "Build available" : "Will build before send"}</dd></div>
</dl>
{sendResult && (
<div className="send-result-panel">
<strong>Last send result</strong>
<p className="muted small-note">
Attempted {String(sendResult.attempted_count ?? "—")}, sent {String(sendResult.sent_count ?? "—")}, failed {String(sendResult.failed_count ?? "—")}, skipped {String(sendResult.skipped_count ?? "—")}.
</p>
{resultRows.length > 0 && (
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table compact-table">
<thead>
<tr><th>Status</th><th>Job</th><th>Message</th></tr>
</thead>
<tbody>
{resultRows.slice(0, 10).map((row, index) => (
<tr key={index}>
<td><StatusBadge status={String(row.status ?? "info")} /></td>
<td>{String(row.job_id ?? row.version_id ?? "—")}</td>
<td>{String(row.message ?? stringifyPreview(row, 180))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</Card>
<div className="dashboard-grid">
<Card title="Delivery rate limit">
<dl className="detail-list">

View File

@@ -9,22 +9,11 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
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);
@@ -65,16 +54,11 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
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(displayDraft, previewEntry), [displayDraft, 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);
const undefinedPlaceholders = useMemo(() => buildUndefinedPlaceholders(usedPlaceholders, allAvailableNames), [usedPlaceholders, allAvailableNames]);
const previewContext = useMemo(() => buildTemplatePreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]);
const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
useEffect(() => {
@@ -382,121 +366,6 @@ function TemplatePreviewOverlay({
);
}
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) && hasPreviewOverrideValue(value)) {
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 hasPreviewOverrideValue(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value === "string") return value.trim() !== "";
return true;
}
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();
@@ -509,17 +378,3 @@ function recipientLabel(entry: Record<string, unknown>, index: number): string {
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

@@ -3,15 +3,9 @@ import { createPortal } from "react-dom";
import Button from "../../../components/Button";
import ToggleSwitch from "../../../components/ToggleSwitch";
import { getBool, getText } from "../utils/draftEditor";
import { createAttachmentRule, mockAttachmentFiles, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments";
export type AttachmentRule = Record<string, unknown>;
export type AttachmentBasePath = {
id: string;
name: string;
path: string;
source?: string;
allow_individual?: boolean;
};
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
type AttachmentRulesOverlayProps = {
title: string;
@@ -146,18 +140,7 @@ export function AttachmentRulesTable({
}
function addRule() {
const firstBasePath = basePaths[0]?.path ?? "";
onChange([
...rules,
{
id: `attachment-${Date.now()}`,
label: "",
base_dir: firstBasePath,
file_filter: "",
required: true,
include_subdirs: false
}
]);
onChange([...rules, createAttachmentRule(basePaths[0]?.path ?? "")]);
}
function removeRule(index: number) {
@@ -287,19 +270,11 @@ function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: str
}
function MockFileChooserContent({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
const files = [
"welcome.pdf",
"terms-and-conditions.pdf",
"invoice_{{local:invoice_number}}.pdf",
"{{local:recipient_id}}/certificate.pdf",
"attachments/{{local:email}}/*.pdf"
];
return (
<div className="attachment-file-browser-content" aria-label="Choose file or pattern">
<p className="muted small-note">Mock browser below <code>{basePath || "."}</code>. Later this will browse uploaded files and directories.</p>
<div className="placeholder-stack attachment-file-browser-list">
{files.map((file) => (
{mockAttachmentFiles.map((file) => (
<Button key={file} onClick={() => onSelect(file)}>
<code>{file}</code>
</Button>
@@ -311,23 +286,3 @@ function MockFileChooserContent({ basePath, onSelect, onClose }: { basePath: str
</div>
);
}
export function summarizeAttachmentRules(rules: AttachmentRule[]): { direct: number; rules: number } {
return rules.reduce<{ direct: number; rules: number }>((summary, rule) => {
if (isDirectAttachmentRule(rule)) {
summary.direct += 1;
} else {
summary.rules += 1;
}
return summary;
}, { direct: 0, rules: 0 });
}
function isDirectAttachmentRule(rule: AttachmentRule): boolean {
const explicitType = getText(rule, "type");
if (explicitType === "direct") return true;
if (explicitType === "pattern") return false;
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
if (!fileFilter) return false;
return !/[{}*?\[\]]/.test(fileFilter);
}

View File

@@ -1,4 +1,6 @@
type FieldValueInputProps = {
import { inputValueToFieldValue, normalizeFieldType, valueToInputText } from "../utils/fieldDefinitions";
export type FieldValueInputProps = {
fieldType?: string;
value: unknown;
disabled?: boolean;
@@ -26,34 +28,9 @@ export default function FieldValueInput({ fieldType = "string", value, disabled
);
}
function normalizeFieldType(fieldType: string): string {
return ["integer", "double", "date", "password"].includes(fieldType) ? fieldType : "string";
}
function inputTypeForField(fieldType: string): string {
if (fieldType === "integer" || fieldType === "double") return "number";
if (fieldType === "date") return "date";
if (fieldType === "password") return "password";
return "text";
}
function valueToInputText(value: unknown, fieldType: string): string {
if (value === undefined || value === null) return "";
if (fieldType === "date" && typeof value === "string") return value.slice(0, 10);
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function inputValueToFieldValue(fieldType: string, value: string): unknown {
if (value === "") return "";
if (fieldType === "integer") {
const numberValue = Number.parseInt(value, 10);
return Number.isFinite(numberValue) ? numberValue : value;
}
if (fieldType === "double") {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : value;
}
return value;
}

View File

@@ -0,0 +1,129 @@
import { asArray, asRecord, isRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor";
export type AttachmentRule = Record<string, unknown>;
export type AttachmentBasePath = {
id: string;
name: string;
path: string;
source?: string;
allow_individual?: boolean;
};
export type AttachmentSummary = {
direct: number;
rules: number;
};
export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
label: string;
};
export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
{ label: "Campaign attachments", path: "attachments" },
{ label: "Campaign root", path: "." },
{ label: "Shared group files", path: "group/shared" },
{ label: "Tenant templates", path: "tenant/templates" },
{ label: "Personal upload area", path: "user/uploads" }
];
export const mockAttachmentFiles = [
"welcome.pdf",
"terms-and-conditions.pdf",
"invoice_{{local:invoice_number}}.pdf",
"{{local:recipient_id}}/certificate.pdf",
"attachments/{{local:email}}/*.pdf"
];
export function createAttachmentBasePath(name = "New attachment source", path = "."): AttachmentBasePath {
return {
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name,
path,
allow_individual: false
};
}
export function createAttachmentRule(baseDir = ""): AttachmentRule {
return {
id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label: "",
base_dir: baseDir,
file_filter: "",
required: true,
include_subdirs: false
};
}
export function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>, fallbackAllowIndividual = false): AttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual)
}];
}
export function ensureAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
return paths.length > 0 ? paths : [createAttachmentBasePath("Campaign files", ".")];
}
export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
const enabled = paths.filter((basePath) => basePath.allow_individual);
return enabled.length > 0 ? enabled : paths;
}
export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({
id: getText(rule, "id", `attachment-${Math.random().toString(36).slice(2)}`),
type: getText(rule, "type"),
label: getText(rule, "label"),
base_dir: getText(rule, "base_dir", ""),
file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"),
include_subdirs: getBool(rule, "include_subdirs"),
required: getBool(rule, "required", true),
missing_behavior: getText(rule, "missing_behavior", "ask"),
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
}));
}
export function summarizeAttachmentRules(rules: AttachmentRule[]): AttachmentSummary {
return rules.reduce<AttachmentSummary>((summary, rule) => {
if (isDirectAttachmentRule(rule)) {
summary.direct += 1;
} else {
summary.rules += 1;
}
return summary;
}, { direct: 0, rules: 0 });
}
export function countIndividualAttachmentRules(entriesValue: unknown): number {
const entries = asRecord(entriesValue);
return asArray(entries.inline)
.map(asRecord)
.flatMap((entry) => asArray(entry.attachments))
.length;
}
export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
const explicitType = getText(rule, "type");
if (explicitType === "direct") return true;
if (explicitType === "pattern") return false;
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
if (!fileFilter) return false;
return !/[{}*?\[\]]/.test(fileFilter);
}

View File

@@ -0,0 +1,56 @@
import { asArray, asRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor";
export const fieldTypeOptions = ["string", "integer", "double", "date", "password"] as const;
export type CampaignFieldType = typeof fieldTypeOptions[number];
export type CampaignFieldDefinition = {
name: string;
label: string;
type: CampaignFieldType;
required: boolean;
can_override: boolean;
};
export function normalizeFieldType(value: string | undefined | null): CampaignFieldType {
const normalized = String(value || "string").trim();
return fieldTypeOptions.includes(normalized as CampaignFieldType) ? normalized as CampaignFieldType : "string";
}
export function getDraftFields(draft: Record<string, unknown> | null | undefined): CampaignFieldDefinition[] {
return asArray(draft?.fields)
.map(asRecord)
.map((field) => ({
name: getText(field, "name") || getText(field, "id"),
label: getText(field, "label"),
type: normalizeFieldType(getText(field, "type", "string")),
required: getBool(field, "required"),
can_override: getBool(field, "can_override", true)
}))
.filter((field) => Boolean(field.name));
}
export function humanizeFieldName(value: string): string {
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
export function valueToInputText(value: unknown, fieldType: CampaignFieldType): string {
if (value === undefined || value === null) return "";
if (fieldType === "date" && typeof value === "string") return value.slice(0, 10);
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
export function inputValueToFieldValue(fieldType: CampaignFieldType, value: string): unknown {
if (value === "") return "";
if (fieldType === "integer") {
const numberValue = Number.parseInt(value, 10);
return Number.isFinite(numberValue) ? numberValue : value;
}
if (fieldType === "double") {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : value;
}
return value;
}

View File

@@ -0,0 +1,154 @@
import { asArray, asRecord } from "./campaignView";
import { getBool } from "./draftEditor";
export type TemplateNamespace = "global" | "local";
export type TemplatePlaceholder = {
raw: string;
namespace: string;
name: string;
validNamespace: boolean;
display: string;
};
export type UndefinedPlaceholder = TemplatePlaceholder & {
reason: "missing-field" | "invalid-namespace";
};
export 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;
placeholders.set(raw, parseTemplatePlaceholder(raw));
}
}
return [...placeholders.values()].sort((a, b) => a.display.localeCompare(b.display));
}
export function parseTemplatePlaceholder(raw: string): TemplatePlaceholder {
const cleaned = normalizeTemplatePlaceholderKey(raw);
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
};
}
export function normalizeTemplatePlaceholderKey(raw: string): string {
return raw.trim()
.replace(/^fields\./, "local:")
.replace(/^local\./, "local:")
.replace(/^global\./, "global:")
.replace(/^local::/, "local:")
.replace(/^global::/, "global:");
}
export 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;
}
export function buildUndefinedPlaceholders(placeholders: TemplatePlaceholder[], availableNames: Set<string>): UndefinedPlaceholder[] {
return uniquePlaceholders(placeholders
.filter((field) => !field.validNamespace || !availableNames.has(field.name))
.map((field): UndefinedPlaceholder => ({
...field,
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
})));
}
export function buildTemplatePreviewContext(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)) {
addPreviewContextValue(context, key, "global", value);
addPreviewContextValue(context, key, "local", value);
}
for (const [key, value] of Object.entries(entryFields)) {
if (canOverrideField(overridePolicy, key) && hasPreviewOverrideValue(value)) {
addPreviewContextValue(context, key, "local", value);
}
}
if (entry.name) addPreviewContextValue(context, "name", "local", entry.name);
if (entry.email) addPreviewContextValue(context, "email", "local", entry.email);
return context;
}
export function renderTemplatePreviewText(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));
}
export 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);
}
export 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 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 addPreviewContextValue(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 hasPreviewOverrideValue(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value === "string") return value.trim() !== "";
return true;
}
function previewValueFor(raw: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
const key = normalizeTemplatePlaceholderKey(raw);
const value = context[key];
if (value !== undefined) return value;
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -1,20 +1,15 @@
import { useEffect, useState } from "react";
import { 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 { validatePartial } from "../../../api/campaigns";
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
import { asArray, asRecord, isAuditLockedVersion, stringifyPreview, summaryValue, versionLockReason } from "../utils/campaignView";
import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../utils/draftEditor";
import { isAuditLockedVersion, versionLockReason } from "../utils/campaignView";
import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
import { AttachmentsStep, BasicsStep, FieldsStep, RecipientsStep, ReviewStep, SenderStep, SendStep, TemplateStep } from "./steps/CreateWizardSteps";
const steps: WizardStep[] = [
{ id: "basics", label: "Basics", description: "Name and scenario" },
@@ -27,8 +22,6 @@ const steps: WizardStep[] = [
{ 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 [localError, setLocalError] = useState("");
@@ -55,12 +48,10 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
}
});
function patchRoot(key: string, value: unknown) {
patch([key], value);
}
function selectStep(stepId: string) {
setActiveStep(stepId);
}
@@ -84,7 +75,6 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
}
}
if (locked) {
return (
<div className="wizard-page">
@@ -124,7 +114,6 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
{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} />}
@@ -145,226 +134,3 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
</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,236 @@
import { useEffect, useState } from "react";
import Button from "../../../../components/Button";
import FormField from "../../../../components/FormField";
import ToggleSwitch from "../../../../components/ToggleSwitch";
import EmailAddressInput from "../../../../components/email/EmailAddressInput";
import MetricCard from "../../../../components/MetricCard";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../../../utils/emailAddresses";
import { asArray, asRecord, stringifyPreview, summaryValue } from "../../utils/campaignView";
import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../../utils/draftEditor";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
export type WizardStepPatch = (path: string[], value: unknown) => void;
export type WizardStepProps = {
draft: Record<string, unknown>;
patch: WizardStepPatch;
};
export function BasicsStep({ draft, patch }: WizardStepProps) {
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>
);
}
export function SenderStep({ draft, patch }: WizardStepProps) {
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>
);
}
export 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>
);
}
export 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>
);
}
export function TemplateStep({ draft, patch }: WizardStepProps) {
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>
);
}
export function AttachmentsStep({ draft, patch }: WizardStepProps) {
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>
);
}
export 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>
);
}
export function SendStep({ draft, patch }: WizardStepProps) {
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>
);
}
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>
);
}