Review/Send redesign

This commit is contained in:
2026-06-13 17:43:43 +02:00
parent 884ba51432
commit ed8341b8c1
6 changed files with 1016 additions and 6 deletions

View File

@@ -10,6 +10,7 @@ import TemplateDataPage from "./TemplateDataPage";
import AttachmentsDataPage from "./AttachmentsDataPage";
import MailSettingsPage from "./MailSettingsPage";
import SendDataPage from "./SendDataPage";
import ReviewSendDevelopmentPage from "./ReviewSendDevelopmentPage";
import CreateWizard from "./wizard/CreateWizard";
import ReviewWizard from "./wizard/ReviewWizard";
import SendWizard from "./wizard/SendWizard";
@@ -73,7 +74,7 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
<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={<Navigate to="../send" replace />} />
<Route path="review" element={<ReviewSendDevelopmentPage 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 />} />
@@ -107,7 +108,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
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 "send";
if (section === "review") return "review";
if (section === "send") return "send";
if (section === "report" || section === "reports") return "report";
if (section === "audit") return "audit";

View File

@@ -0,0 +1,628 @@
import { useMemo, useState, type CSSProperties } from "react";
import { Link } from "react-router-dom";
import {
AlertTriangle,
BarChart3,
Check,
FlaskConical,
LockKeyhole,
MailSearch,
PackageCheck,
RefreshCw,
Send,
ShieldCheck,
type LucideIcon,
} from "lucide-react";
import type { ApiSettings } from "../../types";
import { buildVersion, mockSendCampaign, validateVersion } from "../../api/campaigns";
import Button from "../../components/Button";
import DismissibleAlert from "../../components/DismissibleAlert";
import LoadingFrame from "../../components/LoadingFrame";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import InlineHelp from "../../components/help/InlineHelp";
import VersionLine from "./components/VersionLine";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import {
asArray,
asRecord,
formatDateTime,
getCampaignJson,
getDeliverySection,
humanize,
isFinalLockedVersion,
isUserLockedVersion,
isVersionReadyForDelivery,
} from "./utils/campaignView";
type FlowState =
| "complete"
| "warning"
| "danger"
| "active"
| "locked"
| "running"
| "partial"
| "stale"
| "pending";
type FlowStageDefinition = {
id: string;
title: string;
shortTitle: string;
description: string;
icon: LucideIcon;
state: FlowState;
stateLabel: string;
lockReason?: string;
};
const stateColors: Record<FlowState, string> = {
complete: "var(--green)",
warning: "var(--amber)",
danger: "var(--red)",
active: "var(--blue)",
locked: "var(--line-dark)",
running: "var(--blue)",
partial: "#9b86c7",
stale: "#9b86c7",
pending: "var(--muted)",
};
export default function ReviewSendDevelopmentPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const version = data.currentVersion;
const campaignJson = getCampaignJson(version);
const entries = asRecord(campaignJson.entries);
const inlineEntries = asArray(entries.inline);
const validation = asRecord(version?.validation_summary);
const build = asRecord(version?.build_summary);
const cards = data.summary?.cards;
const attachmentSummary = asRecord(data.summary?.attachments);
const delivery = getDeliverySection(version);
const rateLimit = asRecord(delivery.rate_limit);
const imapAppend = asRecord(delivery.imap_append_sent);
const [busy, setBusy] = useState<"validate" | "build" | "mock" | "">("");
const [message, setMessage] = useState("");
const [messageReviewComplete, setMessageReviewComplete] = useState(false);
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
const validationPresent = Object.keys(validation).length > 0;
const validationOk = validation.ok === true;
const validationErrors = numberFrom(validation, ["error_count", "errors", "blocked"]);
const validationWarnings = numberFrom(validation, ["warning_count", "warnings"]);
const readyForDelivery = isVersionReadyForDelivery(version);
const validationStale = validationOk && !readyForDelivery;
const buildPresent = Object.keys(build).length > 0;
const builtCount = numberFrom(build, ["built_count", "ready_count", "built", "messages_built"]);
const buildBlocked = numberFrom(build, ["blocked_count", "blocked"]);
const buildNeedsReview = numberFrom(build, ["needs_review_count", "needs_review"]);
const buildWarnings = numberFrom(build, ["warning_count", "warnings"]);
const hasBuild = buildPresent && (builtCount > 0 || version?.workflow_state === "built");
const jobsTotal = cards?.jobs_total ?? inlineEntries.length;
const sentCount = cards?.sent ?? 0;
const failedCount = cards?.failed ?? 0;
const imapAppended = cards?.imap_appended ?? 0;
const imapFailed = cards?.imap_failed ?? 0;
const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase();
const deliveryQueued = currentWorkflowState === "queued";
const deliveryStarted = ["sending", "sent", "completed", "partially_sent", "failed_partial"].includes(currentWorkflowState);
const finalVersion = isFinalLockedVersion(version);
const userLockedVersion = isUserLockedVersion(version);
const mockSend = asRecord(mockResult?.send);
const mockSent = numberFrom(mockSend, ["sent_count", "attempted_count"]);
const mockFailed = numberFrom(mockSend, ["failed_count"]);
const mockComplete = Boolean(mockResult) && mockFailed === 0;
const mockPartial = Boolean(mockResult) && mockSent > 0 && mockFailed > 0;
const validationState: FlowState = busy === "validate"
? "running"
: validationStale
? "stale"
: validationPresent && !validationOk
? "danger"
: readyForDelivery && validationWarnings > 0
? "warning"
: readyForDelivery
? "complete"
: "active";
const exceptionState: FlowState = !validationPresent
? "locked"
: validationErrors > 0
? "danger"
: validationWarnings > 0 || (cards?.needs_attention ?? 0) > 0
? "warning"
: validationOk
? "complete"
: "active";
const buildState: FlowState = !readyForDelivery
? "locked"
: busy === "build"
? "running"
: hasBuild && buildBlocked > 0
? "danger"
: hasBuild && (buildNeedsReview > 0 || buildWarnings > 0)
? "warning"
: hasBuild
? "complete"
: "active";
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
const inspectionSatisfied = messageReviewComplete || Boolean(mockResult) || downstreamDeliveryActivity;
const inspectState: FlowState = !hasBuild
? "locked"
: inspectionSatisfied
? "complete"
: "active";
const mockState: FlowState = !inspectionSatisfied
? "locked"
: busy === "mock"
? "running"
: mockPartial
? "partial"
: mockFailed > 0
? "danger"
: mockComplete
? "complete"
: downstreamDeliveryActivity
? "warning"
: "active";
const mockGateSatisfied = mockComplete || downstreamDeliveryActivity;
const sendState: FlowState = !mockGateSatisfied
? "locked"
: deliveryQueued || currentWorkflowState === "sending"
? "running"
: ["partially_sent", "failed_partial"].includes(currentWorkflowState)
? "partial"
: ["sent", "completed"].includes(currentWorkflowState)
? "complete"
: "active";
const resultState: FlowState = !deliveryStarted
? "locked"
: currentWorkflowState === "sending"
? "running"
: ["partially_sent", "failed_partial"].includes(currentWorkflowState)
? "partial"
: ["sent", "completed"].includes(currentWorkflowState)
? "complete"
: "danger";
const stages: FlowStageDefinition[] = useMemo(() => [
{
id: "workflow-validate",
title: "Validate campaign",
shortTitle: "Validate",
description: "Check campaign structure, recipients, templates and managed attachment matches.",
icon: ShieldCheck,
state: validationState,
stateLabel: stateLabel(validationState),
},
{
id: "workflow-exceptions",
title: "Review exceptions",
shortTitle: "Exceptions",
description: "Resolve blocking errors and make warnings visible before building messages.",
icon: AlertTriangle,
state: exceptionState,
stateLabel: stateLabel(exceptionState),
lockReason: "Run validation first to discover campaign exceptions.",
},
{
id: "workflow-build",
title: "Build exact messages",
shortTitle: "Build",
description: "Freeze the current recipients, rendered content and resolved attachment files.",
icon: PackageCheck,
state: buildState,
stateLabel: stateLabel(buildState),
lockReason: validationErrors > 0
? `Resolve ${validationErrors} blocking validation issue${validationErrors === 1 ? "" : "s"} first.`
: "Complete validation and lock the version first.",
},
{
id: "workflow-inspect",
title: "Inspect built messages",
shortTitle: "Inspect",
description: "Review rendered content and the actual files attached to representative recipients.",
icon: MailSearch,
state: inspectState,
stateLabel: stateLabel(inspectState),
lockReason: "Build the exact messages before inspecting them.",
},
{
id: "workflow-mock",
title: "Run mock delivery",
shortTitle: "Mock send",
description: "Exercise the delivery path without contacting the real SMTP or IMAP server.",
icon: FlaskConical,
state: mockState,
stateLabel: stateLabel(mockState),
lockReason: "Complete the message inspection step first.",
},
{
id: "workflow-send",
title: "Confirm and send",
shortTitle: "Real send",
description: "Review the final execution summary before opening the current real-send controls.",
icon: Send,
state: sendState,
stateLabel: stateLabel(sendState),
lockReason: "Complete a successful mock delivery first.",
},
{
id: "workflow-results",
title: "Delivery results",
shortTitle: "Results",
description: "Separate SMTP outcomes, IMAP append results and partial failures.",
icon: BarChart3,
state: resultState,
stateLabel: stateLabel(resultState),
lockReason: "Delivery results become available after the real send starts.",
},
], [
validationState,
exceptionState,
buildState,
inspectState,
mockState,
sendState,
resultState,
validationErrors,
]);
async function runValidation() {
if (!version || busy || userLockedVersion || finalVersion || readyForDelivery) return;
setBusy("validate");
setMessage("Validating the campaign, including managed attachment matches…");
setError("");
try {
const result = await validateVersion(settings, version.id, true);
setMessage(result.ok ? "Validation passed." : "Validation finished with issues. Review the exceptions below.");
setMessageReviewComplete(false);
setMockResult(null);
await reload();
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function runBuild() {
if (!version || busy || !readyForDelivery || deliveryQueued || deliveryStarted) return;
setBusy("build");
setMessage("Building exact messages and resolving managed attachment versions…");
setError("");
try {
const result = await buildVersion(settings, version.id, true);
setMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`);
setMessageReviewComplete(false);
setMockResult(null);
await reload();
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function runMockSend() {
if (!version || busy || !messageReviewComplete || deliveryQueued || deliveryStarted) return;
setBusy("mock");
setMessage("Running the complete mock-delivery flow…");
setError("");
try {
const response = await mockSendCampaign(settings, campaignId, {
version_id: version.id,
send: true,
include_warnings: true,
include_needs_review: false,
append_sent: true,
clear_mailbox: true,
check_files: true,
});
const result = asRecord(response.result ?? response);
setMockResult(result);
const sendResult = asRecord(result.send);
setMessage(`Mock delivery finished. Captured ${String(sendResult.sent_count ?? 0)} message(s), failed ${String(sendResult.failed_count ?? 0)}.`);
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
function scrollToStage(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
const matchedAttachments = numberFrom(attachmentSummary, ["total_matched_files"]);
const missingAttachments = numberFrom(attachmentSummary, ["missing_configs"]);
const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]);
const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]);
const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null;
return (
<div className="content-pad workspace-data-page review-flow-development-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading || Boolean(busy)}>Review &amp; Send workflow preview</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading || Boolean(busy)}>
<RefreshCw size={16} aria-hidden="true" /> Reload
</Button>
<Link className="btn btn-secondary" to="../send">Open current page</Link>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="info" resetKey={message} floating>{message}</DismissibleAlert>}
<DismissibleAlert tone="info">
This is a development layout using the current campaign data and existing actions. The established Review &amp; Send page remains available for comparison.
</DismissibleAlert>
<LoadingFrame loading={loading} label="Loading workflow state…">
<WorkflowNavigation stages={stages} onSelect={scrollToStage} />
<div className="review-flow-timeline" aria-label="Campaign review and sending workflow">
<WorkflowStage stage={stages[0]} nextState={stages[1].state}>
<div className="review-flow-fact-grid">
<WorkflowFact label="Status" value={validationPresent ? (validationOk ? "Passed" : "Needs attention") : "Not run"} />
<WorkflowFact label="Errors" value={validationPresent ? validationErrors : "—"} />
<WorkflowFact label="Warnings" value={validationPresent ? validationWarnings : "—"} />
<WorkflowFact label="Last change" value={formatDateTime(version?.updated_at)} />
</div>
{validationStale && <p className="review-flow-inline-note is-stale">The stored validation result is no longer an active delivery lock. Run validation again.</p>}
<div className="button-row compact-actions">
<Button
variant="primary"
onClick={() => void runValidation()}
disabled={!version || Boolean(busy) || userLockedVersion || finalVersion || readyForDelivery}
>
{busy === "validate"
? "Validating…"
: readyForDelivery
? "Validated and locked"
: validationPresent
? "Validate again"
: "Validate campaign"}
</Button>
</div>
</WorkflowStage>
<WorkflowStage stage={stages[1]} nextState={stages[2].state}>
<div className="review-flow-fact-grid">
<WorkflowFact label="Blocking" value={validationErrors} />
<WorkflowFact label="Warnings" value={validationWarnings} />
<WorkflowFact label="Jobs needing attention" value={cards?.needs_attention ?? "—"} />
<WorkflowFact label="Delivery readiness" value={readyForDelivery ? "Ready" : "Not ready"} />
</div>
{validationPresent && validationErrors === 0 && (
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> No blocking validation exceptions remain.</p>
)}
{validationErrors > 0 && (
<p className="review-flow-inline-note is-danger">Resolve the blocking entries in the current issue table, then validate again.</p>
)}
<div className="button-row compact-actions">
<Link className="btn btn-secondary" to="../send">Open current issue table</Link>
<Link className="btn btn-secondary" to="../files">Review attachment rules</Link>
</div>
</WorkflowStage>
<WorkflowStage stage={stages[2]} nextState={stages[3].state}>
<div className="review-flow-fact-grid">
<WorkflowFact label="Built" value={hasBuild ? builtCount : "—"} />
<WorkflowFact label="Blocked" value={hasBuild ? buildBlocked : "—"} />
<WorkflowFact label="Needs review" value={hasBuild ? buildNeedsReview : "—"} />
<WorkflowFact label="Warnings" value={hasBuild ? buildWarnings : "—"} />
</div>
<p className="muted">Building resolves recipient values and attachment patterns into the exact message queue used by the later stages.</p>
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => void runBuild()} disabled={!version || Boolean(busy) || !readyForDelivery || deliveryQueued || deliveryStarted}>
{busy === "build" ? "Building…" : hasBuild ? "Build again" : "Build exact messages"}
</Button>
</div>
</WorkflowStage>
<WorkflowStage stage={stages[3]} nextState={stages[4].state}>
<div className="review-flow-fact-grid">
<WorkflowFact label="Messages" value={hasBuild ? builtCount : "—"} />
<WorkflowFact label="Recipients" value={jobsTotal || "—"} />
<WorkflowFact label="Matched files" value={matchedAttachments || "—"} />
<WorkflowFact label="Attachment issues" value={missingAttachments + ambiguousAttachments} />
</div>
<p className="muted">Use the Template preview to inspect rendered content and the actual managed files matched for individual recipients.</p>
<div className="button-row compact-actions">
<Link className="btn btn-secondary" to="../template">Open template review</Link>
<Button variant="primary" onClick={() => setMessageReviewComplete((value) => !value)} disabled={!hasBuild}>
{messageReviewComplete ? "Message review completed" : "Mark message review complete"}
</Button>
</div>
</WorkflowStage>
<WorkflowStage stage={stages[4]} nextState={stages[5].state}>
<div className="review-flow-fact-grid">
<WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} />
<WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} />
<WorkflowFact label="Real server contacted" value="No" />
<WorkflowFact label="Mock IMAP" value={mockResult ? "Included" : "Not run"} />
</div>
<p className="muted">This uses the existing mock mailbox and failure-safe delivery path. It never marks the campaign as sent.</p>
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !messageReviewComplete || deliveryQueued || deliveryStarted}>
{busy === "mock" ? "Running mock delivery…" : mockResult ? "Run mock delivery again" : "Run mock delivery"}
</Button>
<Link className="btn btn-secondary" to="../mail-settings">Review server settings</Link>
</div>
</WorkflowStage>
<WorkflowStage stage={stages[5]} nextState={stages[6].state}>
<div className="review-flow-execution-summary">
<div><span>Recipients</span><strong>{jobsTotal || "—"}</strong></div>
<div><span>Messages to send</span><strong>{builtCount || jobsTotal || "—"}</strong></div>
<div><span>Matched attachments</span><strong>{matchedAttachments || "—"}</strong></div>
<div><span>Missing / ambiguous</span><strong>{missingAttachments} / {ambiguousAttachments}</strong></div>
<div><span>Rate limit</span><strong>{messagesPerMinute > 0 ? `${messagesPerMinute}/min` : "Not set"}</strong></div>
<div><span>Minimum duration</span><strong>{estimatedMinutes ? `about ${estimatedMinutes} min` : "—"}</strong></div>
<div><span>IMAP append</span><strong>{Boolean(imapAppend.enabled) ? "Enabled" : "Disabled"}</strong></div>
<div><span>Version</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
</div>
<p className="review-flow-inline-note is-warning">The development page intentionally delegates the real-send action to the established page while this layout is evaluated.</p>
<div className="button-row compact-actions">
<Link className="btn btn-primary" to="../send">Open final send controls</Link>
</div>
</WorkflowStage>
<WorkflowStage stage={stages[6]}>
<div className="review-flow-fact-grid">
<WorkflowFact label="SMTP accepted" value={sentCount} />
<WorkflowFact label="SMTP failed" value={failedCount} />
<WorkflowFact label="IMAP appended" value={imapAppended} />
<WorkflowFact label="IMAP failed" value={imapFailed} />
</div>
<div className="review-flow-result-line">
<StatusBadge status={data.campaign?.status ?? version?.workflow_state ?? "not_started"} />
<span>{deliveryStarted ? "Delivery activity is available in the report and audit views." : "No real delivery has started for this campaign version."}</span>
</div>
<div className="button-row compact-actions">
<Link className="btn btn-secondary" to="../report">Open report</Link>
<Link className="btn btn-secondary" to="../audit">Open audit log</Link>
</div>
</WorkflowStage>
</div>
</LoadingFrame>
</div>
);
}
function WorkflowNavigation({ stages, onSelect }: { stages: FlowStageDefinition[]; onSelect: (id: string) => void }) {
return (
<nav className="review-flow-navigation" aria-label="Review and send workflow steps">
<div className="review-flow-navigation-track">
{stages.map((stage, index) => {
const Icon = stage.icon;
const nextStage = stages[index + 1];
const style = {
"--review-nav-color": stateColors[stage.state],
"--review-nav-next-color": stateColors[nextStage?.state ?? stage.state],
} as CSSProperties;
return (
<div className="review-flow-navigation-group" key={stage.id} style={style}>
<button
type="button"
className="review-flow-navigation-item"
data-state={stage.state}
onClick={() => onSelect(stage.id)}
title={`${stage.title}: ${stage.stateLabel}`}
>
<span className="review-flow-navigation-icon"><Icon size={17} strokeWidth={1.8} aria-hidden="true" /></span>
<span className="review-flow-navigation-copy">
<strong>{stage.shortTitle}</strong>
<small>{stage.state === "locked" ? <LockKeyhole size={13} aria-label="Locked" /> : stage.stateLabel}</small>
</span>
</button>
{nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />}
</div>
);
})}
</div>
</nav>
);
}
function WorkflowStage({
stage,
nextState,
children,
}: {
stage: FlowStageDefinition;
nextState?: FlowState;
children: React.ReactNode;
}) {
const Icon = stage.icon;
const locked = stage.state === "locked";
const style = {
"--review-stage-color": stateColors[stage.state],
"--review-next-stage-color": stateColors[nextState ?? stage.state],
} as CSSProperties;
return (
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}>
<div className="review-flow-stage-marker" aria-hidden="true">
<div className="review-flow-stage-node"><Icon size={20} strokeWidth={1.8} /></div>
{nextState && <div className="review-flow-stage-line" />}
</div>
<article className={`card review-flow-stage-card${locked ? " is-locked" : ""}`} aria-disabled={locked || undefined}>
<header className="card-header review-flow-stage-header">
<h2>
<span>{stage.title}</span>
<InlineHelp>{stage.description}</InlineHelp>
</h2>
<span
className="review-flow-state-badge"
data-state={stage.state}
aria-label={stage.stateLabel}
title={stage.stateLabel}
>
{stage.state === "locked" ? <LockKeyhole size={15} aria-hidden="true" /> : stage.stateLabel}
</span>
</header>
<div className="card-body review-flow-stage-content">{children}</div>
{locked && (
<div className="review-flow-lock-message">
<span className="review-flow-lock-icon"><LockKeyhole size={20} aria-hidden="true" /></span>
<span>{stage.lockReason}</span>
</div>
)}
</article>
</section>
);
}
function WorkflowFact({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="review-flow-fact">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function numberFrom(record: Record<string, unknown>, keys: string[]): number {
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
}
return 0;
}
function stateLabel(state: FlowState): string {
switch (state) {
case "complete": return "Passed";
case "warning": return "Warnings";
case "danger": return "Blocked";
case "active": return "Next";
case "locked": return "Locked";
case "running": return "Running";
case "partial": return "Partial";
case "stale": return "Stale";
default: return humanize(state);
}
}

View File

@@ -36,8 +36,8 @@ const campaignRouteLabels: Record<string, string> = {
mail: "Server settings",
"mail-settings": "Server settings",
"server-settings": "Server settings",
review: "Review",
send: "Send",
review: "Workflow preview",
send: "Review & Send",
report: "Report",
reports: "Report",
audit: "Audit log",

View File

@@ -20,6 +20,7 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
items: [
{ id: "mail-settings", label: "Server settings" },
{ id: "global-settings", label: "Global settings" },
{ id: "review", label: "Workflow preview" },
{ id: "send", label: "Review & Send" },
{ id: "report", label: "Report" },
{ id: "audit", label: "Audit log" }

View File

@@ -1593,3 +1593,383 @@
height: calc(100vh - 20px);
}
}
/* Review & Send workflow development page. */
.review-flow-development-page {
--review-flow-purple: #9b86c7;
}
.review-flow-navigation {
position: sticky;
top: 0;
z-index: 35;
margin: 0 0 24px;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(247, 246, 244, .96);
box-shadow: 0 8px 24px rgba(48, 49, 53, .10);
backdrop-filter: blur(12px);
overflow-x: auto;
}
.review-flow-navigation-track {
display: flex;
align-items: flex-start;
min-width: max-content;
}
.review-flow-navigation-group {
display: flex;
align-items: flex-start;
}
.review-flow-navigation-item {
display: grid;
justify-items: center;
gap: 6px;
min-width: 96px;
padding: 3px 5px;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
text-align: center;
cursor: pointer;
}
.review-flow-navigation-item:hover .review-flow-navigation-icon,
.review-flow-navigation-item:focus-visible .review-flow-navigation-icon {
background: color-mix(in srgb, var(--review-nav-color) 15%, #fff);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--review-nav-color) 12%, transparent);
}
.review-flow-navigation-item:focus-visible {
outline: none;
}
.review-flow-navigation-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid var(--review-nav-color);
border-radius: 50%;
color: color-mix(in srgb, var(--review-nav-color) 82%, var(--text));
background: color-mix(in srgb, var(--review-nav-color) 8%, #fff);
transition: background .16s ease, box-shadow .16s ease;
}
.review-flow-navigation-copy {
display: grid;
justify-items: center;
min-width: 0;
}
.review-flow-navigation-copy strong {
color: var(--text-strong);
font-size: 12px;
white-space: nowrap;
}
.review-flow-navigation-copy small {
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.review-flow-navigation-copy small {
min-height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.review-flow-navigation-line {
width: 42px;
height: 1px;
margin: 22px 2px 0;
flex: 0 0 auto;
background: linear-gradient(to right, var(--review-nav-color), var(--review-nav-next-color));
opacity: .82;
}
.review-flow-timeline {
width: 100%;
max-width: none;
margin: 0;
}
.review-flow-stage {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
scroll-margin-top: 92px;
}
.review-flow-stage-marker {
display: flex;
flex-direction: column;
align-items: center;
min-height: 100%;
padding-top: 14px;
box-sizing: border-box;
}
.review-flow-stage-node {
position: relative;
z-index: 2;
width: 40px;
height: 40px;
display: grid;
place-items: center;
flex: 0 0 auto;
border: 1px solid var(--review-stage-color);
border-radius: 50%;
color: color-mix(in srgb, var(--review-stage-color) 82%, var(--text));
background: color-mix(in srgb, var(--review-stage-color) 9%, #fff);
box-shadow: 0 0 0 4px var(--bg), 0 4px 12px rgba(48, 49, 53, .10);
}
.review-flow-stage[data-state="running"] .review-flow-stage-node {
animation: review-flow-pulse 1.5s ease-in-out infinite;
}
.review-flow-stage-line {
width: 1px;
flex: 1 1 auto;
min-height: 54px;
margin: 4px 0;
border-radius: 999px;
background: linear-gradient(to bottom, var(--review-stage-color), var(--review-next-stage-color));
opacity: .82;
}
.review-flow-stage-card {
position: relative;
min-width: 0;
margin: 0 0 22px;
overflow: hidden;
border-color: color-mix(in srgb, var(--review-stage-color) 42%, var(--line));
}
.review-flow-stage-header {
min-height: 74px;
gap: 18px;
border-left: 2px solid var(--review-stage-color);
}
.review-flow-stage-header h2 {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
margin: 0;
}
.review-flow-state-badge {
--review-badge-color: var(--line-dark);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
min-height: 28px;
margin-left: auto;
padding: 4px 9px;
border: 1px solid color-mix(in srgb, var(--review-badge-color) 70%, var(--line));
border-radius: var(--radius-pill);
color: color-mix(in srgb, var(--review-badge-color) 70%, #242424);
background: color-mix(in srgb, var(--review-badge-color) 18%, #fff);
font-size: 11px;
font-weight: 800;
letter-spacing: .03em;
text-transform: uppercase;
white-space: nowrap;
}
.review-flow-state-badge[data-state="complete"] { --review-badge-color: var(--green); }
.review-flow-state-badge[data-state="warning"] { --review-badge-color: var(--amber); }
.review-flow-state-badge[data-state="danger"] { --review-badge-color: var(--red); }
.review-flow-state-badge[data-state="active"],
.review-flow-state-badge[data-state="running"] { --review-badge-color: var(--blue); }
.review-flow-state-badge[data-state="partial"],
.review-flow-state-badge[data-state="stale"] { --review-badge-color: var(--review-flow-purple); }
.review-flow-stage-content {
min-height: 150px;
}
.review-flow-stage-card.is-locked .review-flow-stage-header,
.review-flow-stage-card.is-locked .review-flow-stage-content {
opacity: .38;
filter: grayscale(.35);
pointer-events: none;
user-select: none;
}
.review-flow-lock-message {
position: absolute;
inset: 74px 0 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
padding: 28px;
color: var(--text);
text-align: center;
background: rgba(247, 246, 244, .46);
}
.review-flow-lock-message::before {
position: absolute;
inset: 50% auto auto 50%;
width: min(430px, calc(100% - 44px));
height: 110px;
border: 1px solid rgba(189, 184, 176, .8);
border-radius: 10px;
content: "";
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, .91);
box-shadow: 0 12px 32px rgba(48, 49, 53, .14);
}
.review-flow-lock-message > * {
position: relative;
z-index: 1;
}
.review-flow-lock-message strong {
color: var(--text-strong);
}
.review-flow-lock-message > span:last-child {
max-width: 390px;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.review-flow-lock-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid var(--line-dark);
border-radius: 50%;
color: var(--muted);
background: #fff;
}
.review-flow-fact-grid,
.review-flow-execution-summary {
display: grid;
grid-template-columns: repeat(4, minmax(130px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.review-flow-fact,
.review-flow-execution-summary > div {
display: grid;
gap: 5px;
min-width: 0;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-soft);
}
.review-flow-fact span,
.review-flow-execution-summary span {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
}
.review-flow-fact strong,
.review-flow-execution-summary strong {
min-width: 0;
overflow: hidden;
color: var(--text-strong);
font-size: 16px;
text-overflow: ellipsis;
}
.review-flow-inline-note {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
padding: 10px 12px;
border-left: 3px solid var(--blue);
background: color-mix(in srgb, var(--blue) 10%, #fff);
color: var(--text);
font-size: 13px;
}
.review-flow-inline-note.is-complete {
border-left-color: var(--green);
background: color-mix(in srgb, var(--green) 12%, #fff);
}
.review-flow-inline-note.is-warning {
border-left-color: var(--amber);
background: color-mix(in srgb, var(--amber) 16%, #fff);
}
.review-flow-inline-note.is-danger {
border-left-color: var(--red);
background: color-mix(in srgb, var(--red) 10%, #fff);
}
.review-flow-inline-note.is-stale {
border-left-color: var(--review-flow-purple);
background: color-mix(in srgb, var(--review-flow-purple) 11%, #fff);
}
.review-flow-result-line {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
color: var(--muted);
}
@keyframes review-flow-pulse {
0%, 100% { box-shadow: 0 0 0 4px var(--bg), 0 4px 12px rgba(48, 49, 53, .10); }
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--review-stage-color) 18%, transparent), 0 4px 12px rgba(48, 49, 53, .10); }
}
@media (max-width: 980px) {
.review-flow-fact-grid,
.review-flow-execution-summary {
grid-template-columns: repeat(2, minmax(130px, 1fr));
}
}
@media (max-width: 680px) {
.review-flow-stage {
grid-template-columns: 36px minmax(0, 1fr);
gap: 8px;
}
.review-flow-stage-node {
width: 34px;
height: 34px;
}
.review-flow-stage-header {
align-items: flex-start;
flex-direction: column;
padding: 14px 18px;
}
.review-flow-state-badge {
margin-left: 0;
}
.review-flow-lock-message {
inset: 100px 0 0;
}
.review-flow-fact-grid,
.review-flow-execution-summary {
grid-template-columns: 1fr;
}
}

View File

@@ -18,8 +18,8 @@ const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
mail: { id: "campaign.server-settings", title: "Server settings" },
"global-settings": { id: "campaign.global-settings", title: "Global settings" },
settings: { id: "campaign.global-settings", title: "Global settings" },
review: { id: "campaign.review", title: "Review" },
send: { id: "campaign.send", title: "Send" },
review: { id: "campaign.review-preview", title: "Review & Send workflow preview" },
send: { id: "campaign.send", title: "Review & Send" },
report: { id: "campaign.report", title: "Report" },
reports: { id: "campaign.report", title: "Report" },
audit: { id: "campaign.audit", title: "Audit log" },