Review/Send redesign
This commit is contained in:
@@ -10,6 +10,7 @@ import TemplateDataPage from "./TemplateDataPage";
|
|||||||
import AttachmentsDataPage from "./AttachmentsDataPage";
|
import AttachmentsDataPage from "./AttachmentsDataPage";
|
||||||
import MailSettingsPage from "./MailSettingsPage";
|
import MailSettingsPage from "./MailSettingsPage";
|
||||||
import SendDataPage from "./SendDataPage";
|
import SendDataPage from "./SendDataPage";
|
||||||
|
import ReviewSendDevelopmentPage from "./ReviewSendDevelopmentPage";
|
||||||
import CreateWizard from "./wizard/CreateWizard";
|
import CreateWizard from "./wizard/CreateWizard";
|
||||||
import ReviewWizard from "./wizard/ReviewWizard";
|
import ReviewWizard from "./wizard/ReviewWizard";
|
||||||
import SendWizard from "./wizard/SendWizard";
|
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="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
|
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
|
||||||
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} campaignId={campaignId || ""} />} />
|
<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="send" element={<SendDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="reports" element={<Navigate to="../report" replace />} />
|
<Route path="reports" element={<Navigate to="../report" replace />} />
|
||||||
@@ -107,7 +108,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
|||||||
if (section === "template") return "template";
|
if (section === "template") return "template";
|
||||||
if (section === "files" || section === "attachments") return "files";
|
if (section === "files" || section === "attachments") return "files";
|
||||||
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
|
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 === "send") return "send";
|
||||||
if (section === "report" || section === "reports") return "report";
|
if (section === "report" || section === "reports") return "report";
|
||||||
if (section === "audit") return "audit";
|
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: "Server settings",
|
||||||
"mail-settings": "Server settings",
|
"mail-settings": "Server settings",
|
||||||
"server-settings": "Server settings",
|
"server-settings": "Server settings",
|
||||||
review: "Review",
|
review: "Workflow preview",
|
||||||
send: "Send",
|
send: "Review & Send",
|
||||||
report: "Report",
|
report: "Report",
|
||||||
reports: "Report",
|
reports: "Report",
|
||||||
audit: "Audit log",
|
audit: "Audit log",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ id: "mail-settings", label: "Server settings" },
|
{ id: "mail-settings", label: "Server settings" },
|
||||||
{ id: "global-settings", label: "Global settings" },
|
{ id: "global-settings", label: "Global settings" },
|
||||||
|
{ id: "review", label: "Workflow preview" },
|
||||||
{ id: "send", label: "Review & Send" },
|
{ id: "send", label: "Review & Send" },
|
||||||
{ id: "report", label: "Report" },
|
{ id: "report", label: "Report" },
|
||||||
{ id: "audit", label: "Audit log" }
|
{ id: "audit", label: "Audit log" }
|
||||||
|
|||||||
@@ -1593,3 +1593,383 @@
|
|||||||
height: calc(100vh - 20px);
|
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" },
|
mail: { id: "campaign.server-settings", title: "Server settings" },
|
||||||
"global-settings": { id: "campaign.global-settings", title: "Global settings" },
|
"global-settings": { id: "campaign.global-settings", title: "Global settings" },
|
||||||
settings: { id: "campaign.global-settings", title: "Global settings" },
|
settings: { id: "campaign.global-settings", title: "Global settings" },
|
||||||
review: { id: "campaign.review", title: "Review" },
|
review: { id: "campaign.review-preview", title: "Review & Send workflow preview" },
|
||||||
send: { id: "campaign.send", title: "Send" },
|
send: { id: "campaign.send", title: "Review & Send" },
|
||||||
report: { id: "campaign.report", title: "Report" },
|
report: { id: "campaign.report", title: "Report" },
|
||||||
reports: { id: "campaign.report", title: "Report" },
|
reports: { id: "campaign.report", title: "Report" },
|
||||||
audit: { id: "campaign.audit", title: "Audit log" },
|
audit: { id: "campaign.audit", title: "Audit log" },
|
||||||
|
|||||||
Reference in New Issue
Block a user