first wokring prototype
This commit is contained in:
15
src/features/PlaceholderPage.tsx
Normal file
15
src/features/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Card from "../components/Card";
|
||||
|
||||
export default function PlaceholderPage({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>{title}</h1>
|
||||
<p>This module is prepared but not implemented yet.</p>
|
||||
</div>
|
||||
<Card>
|
||||
<p className="muted">Next passes will add functionality here.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/features/admin/AdminPage.tsx
Normal file
131
src/features/admin/AdminPage.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from "react";
|
||||
import Card from "../../components/Card";
|
||||
import Button from "../../components/Button";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
|
||||
type AdminSection = "overview" | "users" | "groups" | "roles" | "tenants" | "api-keys" | "audit" | "system";
|
||||
|
||||
const sections: { id: AdminSection; label: string }[] = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "users", label: "Users" },
|
||||
{ id: "groups", label: "Groups" },
|
||||
{ id: "roles", label: "Roles" },
|
||||
{ id: "tenants", label: "Tenants" },
|
||||
{ id: "api-keys", label: "API keys" },
|
||||
{ id: "audit", label: "Audit" },
|
||||
{ id: "system", label: "System" }
|
||||
];
|
||||
|
||||
export default function AdminPage() {
|
||||
const [active, setActive] = useState<AdminSection>("overview");
|
||||
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<aside className="section-sidebar">
|
||||
<div className="section-title">ADMIN</div>
|
||||
{sections.map((section) => (
|
||||
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Administration</PageTitle>
|
||||
<p>Tenant, user, role and operational administration surfaces.</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions"><Button disabled>Refresh</Button></div>
|
||||
</div>
|
||||
|
||||
{active === "overview" && <Overview />}
|
||||
{active === "users" && <PlaceholderAdminTable title="Users" columns={["User", "Tenant admin", "Status", "Last activity"]} rows={["admin@example.local|Yes|Active|Development seed"]} action="Create user" />}
|
||||
{active === "groups" && <PlaceholderAdminTable title="Groups" columns={["Group", "Members", "Campaign access", "Status"]} rows={["Default administrators|1|All campaigns|Seed data"]} action="Create group" />}
|
||||
{active === "roles" && <PlaceholderAdminTable title="Roles and permissions" columns={["Role", "Permissions", "Scope", "Status"]} rows={["Owner|All current permissions|Tenant|Seed data", "Campaign operator|View/edit/review/send planned|Campaign/group|Planned"]} action="Create role" />}
|
||||
{active === "tenants" && <PlaceholderAdminTable title="Tenants" columns={["Tenant", "Slug", "Users", "Status"]} rows={["Default|default|1|Active"]} action="Create tenant" />}
|
||||
{active === "api-keys" && <ApiKeys />}
|
||||
{active === "audit" && <Audit />}
|
||||
{active === "system" && <System />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Overview() {
|
||||
return (
|
||||
<>
|
||||
<div className="metric-grid">
|
||||
<Card title="Users"><strong className="module-big-number">—</strong><p className="muted">Backend list endpoint pending.</p></Card>
|
||||
<Card title="Groups"><strong className="module-big-number">—</strong><p className="muted">Backend list endpoint pending.</p></Card>
|
||||
<Card title="API keys"><strong className="module-big-number">Create-only</strong><p className="muted">Creation endpoint exists; listing/revocation UI pending.</p></Card>
|
||||
<Card title="Audit"><strong className="module-big-number">Available</strong><p className="muted">Audit search can be wired next.</p></Card>
|
||||
</div>
|
||||
<Card title="Administration roadmap">
|
||||
<div className="placeholder-stack">
|
||||
<span>User and invitation management</span>
|
||||
<span>Group and campaign sharing permissions</span>
|
||||
<span>Role assignment and tenant administration</span>
|
||||
<span>API key lifecycle: create, label, revoke, rotate</span>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholderAdminTable({ title, columns, rows, action }: { title: string; columns: string[]; rows: string[]; action: 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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeys() {
|
||||
return (
|
||||
<Card title="API keys" actions={<Button disabled>Create API key</Button>}>
|
||||
<p className="muted">The backend has API-key support, but a complete key lifecycle UI needs list, revoke and rotate endpoints before this can be safely exposed.</p>
|
||||
<div className="app-table-wrap compact-table-wrap">
|
||||
<table className="app-table module-table">
|
||||
<thead><tr><th>Name</th><th>Scope</th><th>Status</th><th>Last used</th></tr></thead>
|
||||
<tbody><tr><td>Development key</td><td>Automation</td><td><StatusBadge status="dev" /></td><td>Local only</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Audit() {
|
||||
return (
|
||||
<Card title="Administrative audit">
|
||||
<p className="muted">Administrative audit filtering will reuse the audit backend. Campaign-specific audit remains inside each campaign workspace.</p>
|
||||
<div className="placeholder-stack"><span>User changes</span><span>Role changes</span><span>API key lifecycle</span><span>Tenant settings</span></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function System() {
|
||||
return (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="System health">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Backend</dt><dd>Check via Settings → Connection</dd></div>
|
||||
<div><dt>Queue</dt><dd>Planned</dd></div>
|
||||
<div><dt>Storage</dt><dd>Planned</dd></div>
|
||||
<div><dt>Mail tests</dt><dd>Planned</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Operational actions"><div className="button-row compact-actions stacked-actions"><Button disabled>Run health check</Button><Button disabled>View worker status</Button><Button disabled>Export diagnostics</Button></div></Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/features/auth/LoginModal.tsx
Normal file
60
src/features/auth/LoginModal.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import type { ApiSettings, LoginResponse } from "../../types";
|
||||
import { login } from "../../api/auth";
|
||||
import Button from "../../components/Button";
|
||||
import FormField from "../../components/FormField";
|
||||
|
||||
export default function LoginModal({
|
||||
settings,
|
||||
onClose,
|
||||
onLogin
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
onClose: () => void;
|
||||
onLogin: (response: LoginResponse) => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState("admin@example.local");
|
||||
const [password, setPassword] = useState("dev-admin");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await login(settings, { email, password });
|
||||
onLogin(response);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true">
|
||||
<form className="modal-panel" onSubmit={submit}>
|
||||
<header className="modal-header">
|
||||
<h2>Sign in</h2>
|
||||
<button className="modal-close" type="button" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="modal-body form-grid">
|
||||
<div className="login-hint">Development default: user <strong>admin@example.local</strong>, password <strong>dev-admin</strong>.</div>
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
<FormField label="Email">
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Password">
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<Button type="button" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/features/auth/PublicLandingPage.tsx
Normal file
46
src/features/auth/PublicLandingPage.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState } from "react";
|
||||
import type { ApiSettings, LoginResponse } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import LoginModal from "./LoginModal";
|
||||
|
||||
export default function PublicLandingPage({
|
||||
settings,
|
||||
onLogin
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
onLogin: (response: LoginResponse) => void;
|
||||
}) {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="public-landing">
|
||||
<section className="public-card">
|
||||
<div className="public-kicker">MultiMailer</div>
|
||||
<h1>Controlled bulk mail campaigns with guided review.</h1>
|
||||
<p>
|
||||
Build structured campaigns, validate recipient data and attachments,
|
||||
review issues, and send messages through controlled queues with reporting.
|
||||
</p>
|
||||
|
||||
<div className="public-actions">
|
||||
<Button variant="primary" onClick={() => setLoginOpen(true)}>Sign in</Button>
|
||||
</div>
|
||||
|
||||
<div className="public-footnote">
|
||||
Access is restricted. Sign in to open campaigns, templates, reports and administration.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loginOpen && (
|
||||
<LoginModal
|
||||
settings={settings}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onLogin={(response) => {
|
||||
onLogin(response);
|
||||
setLoginOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
src/features/campaigns/AttachmentsDataPage.tsx
Normal file
253
src/features/campaigns/AttachmentsDataPage.tsx
Normal 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 } : {})
|
||||
}));
|
||||
}
|
||||
14
src/features/campaigns/CampaignAuditPage.tsx
Normal file
14
src/features/campaigns/CampaignAuditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
src/features/campaigns/CampaignDataPage.tsx
Normal file
204
src/features/campaigns/CampaignDataPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
src/features/campaigns/CampaignFieldsPage.tsx
Normal file
308
src/features/campaigns/CampaignFieldsPage.tsx
Normal 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);
|
||||
}
|
||||
32
src/features/campaigns/CampaignJsonView.tsx
Normal file
32
src/features/campaigns/CampaignJsonView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
src/features/campaigns/CampaignListPage.tsx
Normal file
150
src/features/campaigns/CampaignListPage.tsx
Normal 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"
|
||||
});
|
||||
}
|
||||
340
src/features/campaigns/CampaignOverviewPage.tsx
Normal file
340
src/features/campaigns/CampaignOverviewPage.tsx
Normal 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;
|
||||
}
|
||||
38
src/features/campaigns/CampaignReportPage.tsx
Normal file
38
src/features/campaigns/CampaignReportPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/features/campaigns/CampaignWorkspace.tsx
Normal file
112
src/features/campaigns/CampaignWorkspace.tsx
Normal 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";
|
||||
}
|
||||
178
src/features/campaigns/GlobalSettingsPage.tsx
Normal file
178
src/features/campaigns/GlobalSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
src/features/campaigns/MailSettingsPage.tsx
Normal file
291
src/features/campaigns/MailSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
305
src/features/campaigns/RecipientDataPage.tsx
Normal file
305
src/features/campaigns/RecipientDataPage.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, stringifyPreview, versionLockReason } from "./utils/campaignView";
|
||||
import { ensureCampaignDraft, getBool, parseJsonTextarea, stringifyJson, updateNested } from "./utils/draftEditor";
|
||||
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
import {
|
||||
addressesFromValue,
|
||||
collectCampaignAddressSuggestions,
|
||||
type MailboxAddress
|
||||
} from "../../utils/emailAddresses";
|
||||
|
||||
const recipientHeaderRows = [
|
||||
{ key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." },
|
||||
{ key: "cc", label: "CC", toggleKey: "allow_individual_cc", toggleLabel: "Allow individual CC", addLabel: "Add CC", emptyText: "No global CC recipients configured." },
|
||||
{ key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." }
|
||||
];
|
||||
|
||||
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveState, setSaveState] = useState("Loaded");
|
||||
const [localError, setLocalError] = useState("");
|
||||
const loadedVersionId = useRef<string | null>(null);
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
const recipientsSection = asRecord(draft?.recipients);
|
||||
const entries = asRecord(draft?.entries);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const source = asRecord(entries.source);
|
||||
const fieldNames = useMemo(() => getDraftFieldNames(draft), [draft]);
|
||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
|
||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||
to: addressesFromValue(recipientsSection.to),
|
||||
cc: addressesFromValue(recipientsSection.cc),
|
||||
bcc: addressesFromValue(recipientsSection.bcc)
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!version) return;
|
||||
if (loadedVersionId.current === version.id) return;
|
||||
loadedVersionId.current = version.id;
|
||||
setDraft(ensureCampaignDraft(version));
|
||||
setDirty(false);
|
||||
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
|
||||
}, [version]);
|
||||
|
||||
|
||||
function patch(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
setDraft((current) => updateNested(current ?? {}, path, value));
|
||||
setDirty(true);
|
||||
setLocalError("");
|
||||
}
|
||||
|
||||
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
|
||||
patch(["entries", "inline"], nextEntries);
|
||||
}
|
||||
|
||||
function appendRecipient(address: MailboxAddress) {
|
||||
const nextEntry = {
|
||||
id: `recipient-${inlineEntries.length + 1}`,
|
||||
active: true,
|
||||
to: [address],
|
||||
name: address.name ?? "",
|
||||
email: address.email,
|
||||
fields: {},
|
||||
attachments: []
|
||||
};
|
||||
replaceInlineEntries([...inlineEntries, nextEntry]);
|
||||
}
|
||||
|
||||
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
|
||||
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
|
||||
replaceInlineEntries(nextEntries);
|
||||
}
|
||||
|
||||
function updateEntryRecipient(index: number, addresses: MailboxAddress[]) {
|
||||
const address = addresses[0] ?? { name: "", email: "" };
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
to: address.email ? [address] : [],
|
||||
name: address.name ?? "",
|
||||
email: address.email
|
||||
}));
|
||||
}
|
||||
|
||||
function updateEntryField(index: number, field: string, value: string) {
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
fields: {
|
||||
...asRecord(entry.fields),
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function updateEntryAttachments(index: number, text: string) {
|
||||
const parsed = parseJsonTextarea(text, asArray(inlineEntries[index]?.attachments));
|
||||
if (parsed.error) {
|
||||
setLocalError(`Invalid attachment JSON in row ${index + 1}: ${parsed.error}`);
|
||||
return;
|
||||
}
|
||||
updateEntry(index, (entry) => ({ ...entry, attachments: parsed.value }));
|
||||
}
|
||||
|
||||
function removeEntry(index: number) {
|
||||
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
|
||||
}
|
||||
|
||||
async function saveRecipients(mode: "auto" | "manual" = "manual"): Promise<boolean> {
|
||||
if (!draft || !version || locked) return false;
|
||||
setSaveState("Saving…");
|
||||
setError("");
|
||||
setLocalError("");
|
||||
try {
|
||||
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
|
||||
campaign_json: draft,
|
||||
current_flow: "manual",
|
||||
current_step: "recipient-data",
|
||||
workflow_state: "editing",
|
||||
is_complete: false
|
||||
});
|
||||
setDraft(ensureCampaignDraft(saved));
|
||||
setDirty(false);
|
||||
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err));
|
||||
setSaveState("Save failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
|
||||
title: "Unsaved recipient changes",
|
||||
message: "Recipients or recipient header settings have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
onSave: () => saveRecipients("manual"),
|
||||
onDiscard: () => setDirty(false)
|
||||
} : null);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Recipients</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveRecipients("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{localError && <div className="alert danger">{localError}</div>}
|
||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
||||
|
||||
{draft && (
|
||||
<>
|
||||
<Card title="Global recipient headers">
|
||||
<div className="campaign-header-stack">
|
||||
{recipientHeaderRows.map((row) => (
|
||||
<div className="campaign-header-grid" key={row.key}>
|
||||
<FormField label={row.label}>
|
||||
<EmailAddressInput
|
||||
value={globalRecipientValues[row.key] ?? []}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple
|
||||
disabled={locked}
|
||||
addLabel={row.addLabel}
|
||||
emptyText={row.emptyText}
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", row.key], addresses)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="campaign-header-toggle">
|
||||
<ToggleSwitch
|
||||
label={row.toggleLabel}
|
||||
checked={getBool(recipientsSection, row.toggleKey)}
|
||||
disabled={locked}
|
||||
onChange={(checked) => patch(["recipients", row.toggleKey], checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Recipients" actions={<span className="muted small-note">Editable inline recipients with mail-style address chips, field values and individual attachment config.</span>}>
|
||||
{draft && inlineEntries.length === 0 && !source.type && <p className="muted">No recipient data is stored in the current version yet.</p>}
|
||||
{draft && inlineEntries.length === 0 && Boolean(source.type) && (
|
||||
<div className="alert info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</div>
|
||||
)}
|
||||
{draft && (
|
||||
<div className="app-table-wrap recipient-table-wrap">
|
||||
<table className="app-table recipient-table recipient-editor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Recipient</th>
|
||||
<th>Status</th>
|
||||
{fieldNames.map((field) => <th key={field}>{field}</th>)}
|
||||
<th>Individual attachments</th>
|
||||
<th aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="recipient-add-row">
|
||||
<td className="mono-small">+</td>
|
||||
<td colSpan={Math.max(2, fieldNames.length + 3)}>
|
||||
<EmailAddressInput
|
||||
value={[]}
|
||||
suggestions={addressSuggestions}
|
||||
clearOnAdd
|
||||
disabled={locked || !draft}
|
||||
addLabel="Add recipient"
|
||||
emptyText="Add a new inline recipient."
|
||||
onAddressAdded={appendRecipient}
|
||||
/>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{inlineEntries.slice(0, 100).map((entry, index) => {
|
||||
const recipient = primaryRecipient(entry);
|
||||
const fields = asRecord(entry.fields);
|
||||
const attachments = asArray(entry.attachments);
|
||||
return (
|
||||
<tr key={String(entry.id || index)}>
|
||||
<td className="mono-small">{index + 1}</td>
|
||||
<td>
|
||||
<EmailAddressInput
|
||||
value={recipient.email ? [recipient] : []}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={false}
|
||||
compact
|
||||
disabled={locked}
|
||||
addLabel={recipient.email ? "Replace" : "Add"}
|
||||
emptyText="No recipient address."
|
||||
onChange={(addresses) => updateEntryRecipient(index, addresses)}
|
||||
/>
|
||||
</td>
|
||||
<td><StatusBadge status={String(entry.active === false ? "inactive" : "active")} /></td>
|
||||
{fieldNames.map((field) => (
|
||||
<td key={field}>
|
||||
<input
|
||||
className="recipient-field-input"
|
||||
value={String(fields[field] ?? "")}
|
||||
disabled={locked}
|
||||
onChange={(event) => updateEntryField(index, field, event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<textarea
|
||||
className="recipient-attachments-input"
|
||||
rows={2}
|
||||
value={attachments.length ? stringifyJson(attachments) : "[]"}
|
||||
disabled={locked}
|
||||
title={attachments.length ? stringifyPreview(attachments, 180) : undefined}
|
||||
onChange={(event) => updateEntryAttachments(index, event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveRecipients("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDraftFieldNames(draft: Record<string, unknown> | null): string[] {
|
||||
return asArray(draft?.fields)
|
||||
.map((field) => asRecord(field))
|
||||
.map((field) => String(field.name || field.id || ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function primaryRecipient(entry: Record<string, unknown>): MailboxAddress {
|
||||
const to = addressesFromValue(entry.to)[0];
|
||||
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
|
||||
return to ?? direct ?? { name: "", email: "" };
|
||||
}
|
||||
96
src/features/campaigns/ReviewDataPage.tsx
Normal file
96
src/features/campaigns/ReviewDataPage.tsx
Normal 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) })));
|
||||
}
|
||||
66
src/features/campaigns/SendDataPage.tsx
Normal file
66
src/features/campaigns/SendDataPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
558
src/features/campaigns/TemplateDataPage.tsx
Normal file
558
src/features/campaigns/TemplateDataPage.tsx
Normal 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, "\\$&");
|
||||
}
|
||||
20
src/features/campaigns/components/AttachmentRuleCard.tsx
Normal file
20
src/features/campaigns/components/AttachmentRuleCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/features/campaigns/components/FieldMappingTable.tsx
Normal file
24
src/features/campaigns/components/FieldMappingTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
src/features/campaigns/context/UnsavedChangesContext.tsx
Normal file
177
src/features/campaigns/context/UnsavedChangesContext.tsx
Normal 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]);
|
||||
}
|
||||
61
src/features/campaigns/hooks/useCampaignWorkspaceData.ts
Normal file
61
src/features/campaigns/hooks/useCampaignWorkspaceData.ts
Normal 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 };
|
||||
}
|
||||
155
src/features/campaigns/utils/campaignView.ts
Normal file
155
src/features/campaigns/utils/campaignView.ts
Normal 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, "");
|
||||
}
|
||||
107
src/features/campaigns/utils/draftEditor.ts
Normal file
107
src/features/campaigns/utils/draftEditor.ts
Normal 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";
|
||||
}
|
||||
405
src/features/campaigns/wizard/CreateWizard.tsx
Normal file
405
src/features/campaigns/wizard/CreateWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/features/campaigns/wizard/ReviewWizard.tsx
Normal file
23
src/features/campaigns/wizard/ReviewWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/features/campaigns/wizard/SendWizard.tsx
Normal file
22
src/features/campaigns/wizard/SendWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/features/campaigns/wizard/steps/AttachmentsStep.tsx
Normal file
22
src/features/campaigns/wizard/steps/AttachmentsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/features/campaigns/wizard/steps/BasicsStep.tsx
Normal file
24
src/features/campaigns/wizard/steps/BasicsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/features/campaigns/wizard/steps/FieldsStep.tsx
Normal file
18
src/features/campaigns/wizard/steps/FieldsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/features/campaigns/wizard/steps/RecipientsStep.tsx
Normal file
29
src/features/campaigns/wizard/steps/RecipientsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/features/campaigns/wizard/steps/ReviewStep.tsx
Normal file
19
src/features/campaigns/wizard/steps/ReviewStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/features/campaigns/wizard/steps/SendStep.tsx
Normal file
22
src/features/campaigns/wizard/steps/SendStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/features/campaigns/wizard/steps/SenderStep.tsx
Normal file
18
src/features/campaigns/wizard/steps/SenderStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/features/campaigns/wizard/steps/TemplateStep.tsx
Normal file
21
src/features/campaigns/wizard/steps/TemplateStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/features/dashboard/DashboardPage.tsx
Normal file
22
src/features/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Card from "../../components/Card";
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Campaigns" value="0" detail="Connect the API to load data" />
|
||||
<MetricCard label="Queued" value="0" tone="info" />
|
||||
<MetricCard label="Needs review" value="0" tone="warning" />
|
||||
<MetricCard label="Failed" value="0" tone="danger" />
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Recommended next action"><p className="muted">Create or open a campaign to continue.</p></Card>
|
||||
<Card title="System status"><p className="muted">API health and queue metrics will appear here.</p></Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
282
src/features/files/FilesPage.tsx
Normal file
282
src/features/files/FilesPage.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
|
||||
type StorageRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
scope: string;
|
||||
status: string;
|
||||
files: number;
|
||||
used: string;
|
||||
retention: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type StorageSection = "browse" | "upload" | "settings" | "retention" | "bulk" | "activity";
|
||||
|
||||
const storages: StorageRecord[] = [
|
||||
{
|
||||
id: "campaign-files",
|
||||
name: "Campaign files",
|
||||
description: "Files uploaded or referenced for campaign attachments.",
|
||||
type: "Local path / planned object storage",
|
||||
scope: "Campaigns",
|
||||
status: "available",
|
||||
files: 124,
|
||||
used: "2.4 GB",
|
||||
retention: "Keep sent evidence",
|
||||
updatedAt: "2026-06-08 15:36"
|
||||
},
|
||||
{
|
||||
id: "template-assets",
|
||||
name: "Template assets",
|
||||
description: "Images and reusable assets for message templates.",
|
||||
type: "Planned object storage",
|
||||
scope: "Templates",
|
||||
status: "planned",
|
||||
files: 8,
|
||||
used: "34 MB",
|
||||
retention: "Manual cleanup",
|
||||
updatedAt: "2026-06-06 10:12"
|
||||
},
|
||||
{
|
||||
id: "shared-library",
|
||||
name: "Shared library",
|
||||
description: "Tenant or group-wide files available to multiple campaigns.",
|
||||
type: "Planned object storage",
|
||||
scope: "Tenant / groups",
|
||||
status: "planned",
|
||||
files: 0,
|
||||
used: "0 MB",
|
||||
retention: "Policy pending",
|
||||
updatedAt: "Not connected"
|
||||
}
|
||||
];
|
||||
|
||||
const storageSections: { id: StorageSection; label: string }[] = [
|
||||
{ id: "browse", label: "Browse" },
|
||||
{ id: "upload", label: "Upload" },
|
||||
{ id: "settings", label: "Settings" },
|
||||
{ id: "retention", label: "Retention" },
|
||||
{ id: "bulk", label: "Bulk actions" },
|
||||
{ id: "activity", label: "Activity" }
|
||||
];
|
||||
|
||||
const demoFiles = [
|
||||
{ name: "statement_1001.pdf", path: "/2026/05/statement_1001.pdf", size: "142 KB", updatedAt: "2026-06-08 15:36", status: "ready" },
|
||||
{ name: "statement_1002.pdf", path: "/2026/05/statement_1002.pdf", size: "148 KB", updatedAt: "2026-06-08 15:36", status: "ready" },
|
||||
{ name: "global_notice.pdf", path: "/shared/global_notice.pdf", size: "81 KB", updatedAt: "2026-06-06 09:44", status: "ready" }
|
||||
];
|
||||
|
||||
export default function FilesPage() {
|
||||
const [selectedStorageId, setSelectedStorageId] = useState<string | null>(null);
|
||||
const [active, setActive] = useState<StorageSection>("browse");
|
||||
const selectedStorage = useMemo(
|
||||
() => storages.find((storage) => storage.id === selectedStorageId) ?? null,
|
||||
[selectedStorageId]
|
||||
);
|
||||
|
||||
function openStorage(storageId: string) {
|
||||
setSelectedStorageId(storageId);
|
||||
setActive("browse");
|
||||
}
|
||||
|
||||
if (selectedStorage) {
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<aside className="section-sidebar">
|
||||
<button className="section-link section-link-primary" onClick={() => setSelectedStorageId(null)}>
|
||||
← File storages
|
||||
</button>
|
||||
<div className="section-title section-title-lower">STORAGE</div>
|
||||
{storageSections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
className={`section-link ${active === section.id ? "active" : ""}`}
|
||||
onClick={() => setActive(section.id)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>{selectedStorage.name}</PageTitle>
|
||||
<p>{selectedStorage.description}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Upload</Button>
|
||||
<Button disabled>Download</Button>
|
||||
<Button variant="danger" disabled>Delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{active === "browse" && <StorageBrowse storage={selectedStorage} />}
|
||||
{active === "upload" && <StorageUpload storage={selectedStorage} />}
|
||||
{active === "settings" && <StorageSettings storage={selectedStorage} />}
|
||||
{active === "retention" && <StorageRetention storage={selectedStorage} />}
|
||||
{active === "bulk" && <StorageBulkActions />}
|
||||
{active === "activity" && <StorageActivity />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page module-entry-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Files</PageTitle>
|
||||
<p>Manage file storages first. Open a storage to browse content, upload files and configure retention.</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Refresh</Button>
|
||||
<Button variant="primary" disabled>Add storage</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div className="module-card-heading">
|
||||
<h2>File storages</h2>
|
||||
<span>Storage endpoints are placeholders until the backend model is added</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="app-table-wrap compact-table-wrap module-table-wrap">
|
||||
<table className="app-table module-table module-entry-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Storage</th>
|
||||
<th>Type</th>
|
||||
<th>Scope</th>
|
||||
<th>Files</th>
|
||||
<th>Used</th>
|
||||
<th>Retention</th>
|
||||
<th>Updated</th>
|
||||
<th aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{storages.map((storage) => (
|
||||
<tr key={storage.id}>
|
||||
<td>
|
||||
<div className="module-title-cell">
|
||||
<strong>{storage.name}</strong>
|
||||
<span>{storage.description}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{storage.type}</td>
|
||||
<td>{storage.scope}</td>
|
||||
<td>{storage.files}</td>
|
||||
<td>{storage.used}</td>
|
||||
<td>{storage.retention}</td>
|
||||
<td><span className="muted small-text">{storage.updatedAt}</span></td>
|
||||
<td className="table-action-cell"><Button onClick={() => openStorage(storage.id)}>Open</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageBrowse({ storage }: { storage: StorageRecord }) {
|
||||
return (
|
||||
<Card
|
||||
title="Browse content"
|
||||
actions={<div className="button-row compact-actions"><Button disabled>Upload</Button><Button disabled>Download selected</Button><Button variant="danger" disabled>Delete selected</Button></div>}
|
||||
>
|
||||
<div className="app-table-wrap compact-table-wrap module-table-wrap">
|
||||
<table className="app-table module-table">
|
||||
<thead><tr><th>Name</th><th>Path</th><th>Size</th><th>Updated</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(storage.id === "campaign-files" ? demoFiles : []).map((file) => (
|
||||
<tr key={file.path}>
|
||||
<td>{file.name}</td>
|
||||
<td><code>{file.path}</code></td>
|
||||
<td>{file.size}</td>
|
||||
<td><span className="muted small-text">{file.updatedAt}</span></td>
|
||||
<td><StatusBadge status={file.status} /></td>
|
||||
<td className="table-action-cell"><Button disabled>Download</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
{storage.id !== "campaign-files" && <tr><td colSpan={6} className="muted">Files will appear here when this storage is connected.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageUpload({ storage }: { storage: StorageRecord }) {
|
||||
return (
|
||||
<Card title="Upload files" actions={<Button variant="primary" disabled>Select files…</Button>}>
|
||||
<p className="muted">Upload will target <strong>{storage.name}</strong>. The backend will later provide chunked upload, duplicate handling and progress state.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Drag and drop upload area</span>
|
||||
<span>Duplicate handling: ask, replace, keep both</span>
|
||||
<span>Optional file tagging after upload</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageSettings({ storage }: { storage: StorageRecord }) {
|
||||
return (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Storage settings">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Type</dt><dd>{storage.type}</dd></div>
|
||||
<div><dt>Scope</dt><dd>{storage.scope}</dd></div>
|
||||
<div><dt>Status</dt><dd><StatusBadge status={storage.status} /></dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Backend requirements">
|
||||
<p className="muted">This view is prepared for local path, Garage/S3 and tenant/group/user storage settings.</p>
|
||||
<div className="placeholder-stack"><span>Storage backend</span><span>Access policy</span><span>Quota</span><span>Encryption / lifecycle</span></div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageRetention({ storage }: { storage: StorageRecord }) {
|
||||
return (
|
||||
<Card title="Retention policy" actions={<Button disabled>Save policy</Button>}>
|
||||
<p className="muted">Current policy: {storage.retention}. Retention must respect audit-safe campaigns and sent attachments.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Keep files for sent campaigns</span>
|
||||
<span>Prune unused draft uploads after a configurable period</span>
|
||||
<span>Export manifest before destructive cleanup</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageBulkActions() {
|
||||
return (
|
||||
<Card title="Bulk actions">
|
||||
<p className="muted">Bulk download and delete should be available from the Browse view as well as from a dedicated filtered action view.</p>
|
||||
<div className="button-row page-bottom-actions"><Button disabled>Download filtered files</Button><Button variant="danger" disabled>Delete filtered files</Button></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageActivity() {
|
||||
return (
|
||||
<Card title="Activity">
|
||||
<p className="muted">Storage activity will show uploads, downloads, deletions and retention cleanup runs once backend audit events are available.</p>
|
||||
<div className="placeholder-stack"><span>Last upload</span><span>Last bulk delete</span><span>Retention cleanup result</span></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
190
src/features/settings/SettingsPage.tsx
Normal file
190
src/features/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import Button from "../../components/Button";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { apiFetch } from "../../api/client";
|
||||
|
||||
type SettingsSection = "connection" | "mail-accounts" | "address-book" | "storage" | "retention" | "notifications";
|
||||
|
||||
const sections: { id: SettingsSection; label: string }[] = [
|
||||
{ id: "connection", label: "Connection" },
|
||||
{ id: "mail-accounts", label: "Mail accounts" },
|
||||
{ id: "address-book", label: "Address book" },
|
||||
{ id: "storage", label: "Storage" },
|
||||
{ id: "retention", label: "Retention" },
|
||||
{ id: "notifications", label: "Notifications" }
|
||||
];
|
||||
|
||||
export default function SettingsPage({ settings, onSettingsChange }: { settings: ApiSettings; onSettingsChange: (settings: ApiSettings) => void }) {
|
||||
const [active, setActive] = useState<SettingsSection>("connection");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState("");
|
||||
const [rememberAddresses, setRememberAddresses] = useState(false);
|
||||
const [addressBookSync, setAddressBookSync] = useState(false);
|
||||
|
||||
async function testConnection() {
|
||||
setTesting(true);
|
||||
setTestResult("");
|
||||
try {
|
||||
await apiFetch<unknown>(settings, "/health");
|
||||
setTestResult("Connection successful. The backend health endpoint responded.");
|
||||
} catch (err) {
|
||||
setTestResult(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<aside className="section-sidebar">
|
||||
<div className="section-title">SETTINGS</div>
|
||||
{sections.map((section) => (
|
||||
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Settings</PageTitle>
|
||||
<p>Personal, local and tenant-level settings for the WebUI.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{active === "connection" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="API connection / automation key">
|
||||
<div className="form-grid">
|
||||
<FormField label="API base URL" help="Leave empty to use the same origin. In Vite dev, /api is proxied to the FastAPI backend.">
|
||||
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
|
||||
</FormField>
|
||||
<FormField label="Automation API key" help="Used only when there is no browser session token. Browser login remains the preferred interactive mode.">
|
||||
<input type="password" value={settings.apiKey} onChange={(e) => onSettingsChange({ ...settings, apiKey: e.target.value })} />
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>
|
||||
</div>
|
||||
{testResult && <div className={`alert ${testResult.startsWith("Connection successful") ? "success" : "warning"}`}>{testResult}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Session state">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Browser token</dt><dd>{settings.accessToken ? "Stored" : "Not stored"}</dd></div>
|
||||
<div><dt>Automation key</dt><dd>{settings.apiKey ? "Configured" : "Not configured"}</dd></div>
|
||||
<div><dt>Backend mode</dt><dd>{settings.apiBaseUrl ? "Explicit API URL" : "Same-origin / proxied"}</dd></div>
|
||||
</dl>
|
||||
<p className="muted small-note">Logout and tenant switching are handled in the title bar. More session-management controls can be added when backend endpoints exist.</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "mail-accounts" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Reusable mail accounts">
|
||||
<p className="muted">Campaigns can currently keep SMTP/IMAP data in their working draft. Later, reusable encrypted mail accounts should live here and be shared per user, group or tenant.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Personal SMTP/IMAP accounts</span>
|
||||
<span>Group sender identities</span>
|
||||
<span>Tenant-wide defaults</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Planned account actions">
|
||||
<div className="button-row compact-actions stacked-actions">
|
||||
<Button disabled>Add mail account</Button>
|
||||
<Button disabled>Test selected account</Button>
|
||||
<Button disabled>Share with group…</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "address-book" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Address book and suggestions">
|
||||
<div className="form-grid">
|
||||
<ToggleSwitch
|
||||
label="Remember previously used addresses"
|
||||
help="Planned opt-in collection for autocomplete across campaigns. Currently autocomplete is campaign-local only."
|
||||
checked={rememberAddresses}
|
||||
onChange={setRememberAddresses}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="External address-book sync"
|
||||
help="Placeholder for CardDAV/Google/LDAP-style address sources. No external address book is connected yet."
|
||||
checked={addressBookSync}
|
||||
onChange={setAddressBookSync}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Address sources">
|
||||
<div className="app-table-wrap compact-table-wrap">
|
||||
<table className="app-table module-table">
|
||||
<thead><tr><th>Source</th><th>Status</th><th>Scope</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Campaign-local addresses</td><td>Active</td><td>Current campaign</td></tr>
|
||||
<tr><td>Previously used addresses</td><td>Planned</td><td>User</td></tr>
|
||||
<tr><td>External address books</td><td>Planned</td><td>User / tenant</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "storage" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Storage backends">
|
||||
<p className="muted">Campaign files are still represented by paths and draft JSON. This area is prepared for Garage/S3-backed tenant, group and campaign storage.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Local development storage</span>
|
||||
<span>Garage/S3 tenant bucket</span>
|
||||
<span>Per-campaign file area</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Quotas and cleanup">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>User quota</dt><dd>Planned</dd></div>
|
||||
<div><dt>Campaign file quota</dt><dd>Planned</dd></div>
|
||||
<div><dt>Orphan cleanup</dt><dd>Planned</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "retention" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Draft and version retention">
|
||||
<p className="muted">Old editable versions can later be pruned unless they have been sent, partially sent or locked for audit. This needs backend support before destructive actions are exposed.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Prune unsent autosave drafts</span>
|
||||
<span>Keep locked/sent versions</span>
|
||||
<span>Export audit-safe campaign package</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Backup hooks"><p className="muted">Backup settings for campaign JSON, reports and audit data will be added once storage and retention backends are implemented.</p></Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "notifications" && (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Notification preferences">
|
||||
<p className="muted">Prepared for later background notifications: queue complete, send failures, IMAP append failures and report delivery.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>In-app notifications</span>
|
||||
<span>Email summary after campaign completion</span>
|
||||
<span>Failure alerts</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
285
src/features/templates/TemplatesPage.tsx
Normal file
285
src/features/templates/TemplatesPage.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import FieldLabel from "../../components/help/FieldLabel";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
|
||||
type TemplateRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
status: string;
|
||||
fields: string[];
|
||||
updatedAt: string;
|
||||
usedBy: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
versions: number;
|
||||
};
|
||||
|
||||
type TemplateDetailSection = "overview" | "content" | "fields" | "preview" | "usage" | "versions";
|
||||
|
||||
const templateRecords: TemplateRecord[] = [
|
||||
{
|
||||
id: "monthly-statement",
|
||||
name: "Monthly statement",
|
||||
description: "Reusable subject and body for monthly statement mailings.",
|
||||
type: "Plain text",
|
||||
status: "ready",
|
||||
fields: ["recipient_name", "period", "amount"],
|
||||
updatedAt: "2026-06-08 16:42",
|
||||
usedBy: "2 campaigns",
|
||||
subject: "Your statement for {{period}}",
|
||||
body: "Hello {{recipient_name}},\n\nplease find your statement for {{period}} attached.",
|
||||
versions: 4
|
||||
},
|
||||
{
|
||||
id: "deadline-reminder",
|
||||
name: "Deadline reminder",
|
||||
description: "Short reminder template with one deadline field.",
|
||||
type: "Plain text",
|
||||
status: "draft",
|
||||
fields: ["recipient_name", "deadline"],
|
||||
updatedAt: "2026-06-07 11:18",
|
||||
usedBy: "Not used yet",
|
||||
subject: "Reminder: {{deadline}}",
|
||||
body: "Hello {{recipient_name}},\n\nthis is a reminder that the deadline is {{deadline}}.",
|
||||
versions: 1
|
||||
},
|
||||
{
|
||||
id: "attachment-notice",
|
||||
name: "Attachment notice",
|
||||
description: "Generic note for campaigns where every recipient receives a file.",
|
||||
type: "HTML-ready",
|
||||
status: "ready",
|
||||
fields: ["recipient_name", "file_label", "contact_email"],
|
||||
updatedAt: "2026-06-05 09:05",
|
||||
usedBy: "1 campaign",
|
||||
subject: "Documents for {{recipient_name}}",
|
||||
body: "Hello {{recipient_name}},\n\nyour {{file_label}} is attached. Please contact {{contact_email}} if anything is missing.",
|
||||
versions: 3
|
||||
}
|
||||
];
|
||||
|
||||
const templateSections: { id: TemplateDetailSection; label: string }[] = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "content", label: "Content" },
|
||||
{ id: "fields", label: "Fields" },
|
||||
{ id: "preview", label: "Preview" },
|
||||
{ id: "usage", label: "Usage" },
|
||||
{ id: "versions", label: "Versions" }
|
||||
];
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [active, setActive] = useState<TemplateDetailSection>("overview");
|
||||
const selectedTemplate = useMemo(
|
||||
() => templateRecords.find((template) => template.id === selectedId) ?? null,
|
||||
[selectedId]
|
||||
);
|
||||
|
||||
function openTemplate(templateId: string) {
|
||||
setSelectedId(templateId);
|
||||
setActive("overview");
|
||||
}
|
||||
|
||||
if (selectedTemplate) {
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<aside className="section-sidebar">
|
||||
<button className="section-link section-link-primary" onClick={() => setSelectedId(null)}>
|
||||
← Template library
|
||||
</button>
|
||||
<div className="section-title section-title-lower">TEMPLATE</div>
|
||||
{templateSections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
className={`section-link ${active === section.id ? "active" : ""}`}
|
||||
onClick={() => setActive(section.id)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>{selectedTemplate.name}</PageTitle>
|
||||
<p>{selectedTemplate.description}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Duplicate</Button>
|
||||
<Button variant="primary" disabled>Use in campaign</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{active === "overview" && <TemplateOverview template={selectedTemplate} />}
|
||||
{active === "content" && <TemplateContent template={selectedTemplate} />}
|
||||
{active === "fields" && <TemplateFields template={selectedTemplate} />}
|
||||
{active === "preview" && <TemplatePreview template={selectedTemplate} />}
|
||||
{active === "usage" && <TemplateUsage template={selectedTemplate} />}
|
||||
{active === "versions" && <TemplateVersions template={selectedTemplate} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page module-entry-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Templates</PageTitle>
|
||||
<p>Reusable message templates. Open a template to edit content, fields, preview and usage.</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Import</Button>
|
||||
<Button variant="primary" disabled>Export</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div className="module-card-heading">
|
||||
<h2>All templates</h2>
|
||||
<span>Last loaded: local demo data</span>
|
||||
</div>
|
||||
}
|
||||
actions={<Button disabled>Refresh</Button>}
|
||||
>
|
||||
<div className="app-table-wrap compact-table-wrap module-table-wrap">
|
||||
<table className="app-table module-table module-entry-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Template</th>
|
||||
<th>Type</th>
|
||||
<th>Fields</th>
|
||||
<th>Updated</th>
|
||||
<th>Status</th>
|
||||
<th aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templateRecords.map((template) => (
|
||||
<tr key={template.id}>
|
||||
<td>
|
||||
<div className="module-title-cell">
|
||||
<strong>{template.name}</strong>
|
||||
<span>{template.description}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{template.type}</td>
|
||||
<td>
|
||||
<div className="chip-row compact-chip-row">
|
||||
{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}
|
||||
</div>
|
||||
</td>
|
||||
<td><span className="muted small-text">{template.updatedAt}</span></td>
|
||||
<td><StatusBadge status={template.status} /></td>
|
||||
<td className="table-action-cell"><Button onClick={() => openTemplate(template.id)}>Open</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateOverview({ template }: { template: TemplateRecord }) {
|
||||
return (
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Template status">
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Status</dt><dd><StatusBadge status={template.status} /></dd></div>
|
||||
<div><dt>Type</dt><dd>{template.type}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{template.updatedAt}</dd></div>
|
||||
<div><dt>Versions</dt><dd>{template.versions}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Compatibility notes">
|
||||
<p className="muted">Campaigns can reuse this template, but each campaign still needs a field matching check because campaign fields and template placeholders can drift.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Subject placeholders are detected</span>
|
||||
<span>Body placeholders are compared with campaign fields</span>
|
||||
<span>A mapping wizard can be added later</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateContent({ template }: { template: TemplateRecord }) {
|
||||
return (
|
||||
<Card title="Template content" actions={<Button disabled>Save changes</Button>}>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<label className="form-field"><FieldLabel className="form-label" help="Read-only subject stored in this reusable template record.">Subject</FieldLabel><input value={template.subject} readOnly /></label>
|
||||
<label className="form-field full-span"><FieldLabel className="form-label" help="Read-only body stored in this reusable template record.">Body</FieldLabel><textarea rows={10} value={template.body} readOnly /></label>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateFields({ template }: { template: TemplateRecord }) {
|
||||
return (
|
||||
<Card title="Template fields" actions={<Button disabled>Add field hint</Button>}>
|
||||
<div className="app-table-wrap compact-table-wrap module-table-wrap">
|
||||
<table className="app-table module-table">
|
||||
<thead><tr><th>Field</th><th>Source</th><th>Required</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{template.fields.map((field) => (
|
||||
<tr key={field}><td><code>{field}</code></td><td>Detected placeholder</td><td>Yes</td><td><Button disabled>Configure</Button></td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatePreview({ template }: { template: TemplateRecord }) {
|
||||
const preview = template.body
|
||||
.replace(/\{\{recipient_name\}\}/g, "Jane Example")
|
||||
.replace(/\{\{period\}\}/g, "May 2026")
|
||||
.replace(/\{\{amount\}\}/g, "123.45 EUR")
|
||||
.replace(/\{\{deadline\}\}/g, "30 June 2026")
|
||||
.replace(/\{\{file_label\}\}/g, "statement")
|
||||
.replace(/\{\{contact_email\}\}/g, "support@example.org");
|
||||
|
||||
return (
|
||||
<Card title="Preview" actions={<Button disabled>Change sample data</Button>}>
|
||||
<div className="message-preview">
|
||||
<strong>{template.subject.replace(/\{\{period\}\}/g, "May 2026").replace(/\{\{deadline\}\}/g, "30 June 2026").replace(/\{\{recipient_name\}\}/g, "Jane Example")}</strong>
|
||||
<pre>{preview}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateUsage({ template }: { template: TemplateRecord }) {
|
||||
return (
|
||||
<Card title="Usage">
|
||||
<p className="muted">This view will list campaigns using the template once the backend template model is available.</p>
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Currently used by</dt><dd>{template.usedBy}</dd></div>
|
||||
<div><dt>Safe to edit</dt><dd>Requires versioning once templates are shared</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateVersions({ template }: { template: TemplateRecord }) {
|
||||
return (
|
||||
<Card title="Versions and import/export" actions={<div className="button-row compact-actions"><Button disabled>Import</Button><Button disabled>Export</Button></div>}>
|
||||
<div className="placeholder-stack">
|
||||
<span>{template.versions} local versions in the planned model</span>
|
||||
<span>Sent campaigns should keep a fixed template snapshot</span>
|
||||
<span>Draft campaigns can update to a newer template version later</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user