Review/Send redesign
This commit is contained in:
@@ -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";
|
||||
|
||||
628
src/features/campaigns/ReviewSendDevelopmentPage.tsx
Normal file
628
src/features/campaigns/ReviewSendDevelopmentPage.tsx
Normal 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 & 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 & 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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user