From ed8341b8c1bf5a0ae63c7fbbb60bae0098f423ed Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 13 Jun 2026 17:43:43 +0200 Subject: [PATCH] Review/Send redesign --- src/features/campaigns/CampaignWorkspace.tsx | 5 +- .../campaigns/ReviewSendDevelopmentPage.tsx | 628 ++++++++++++++++++ src/layout/BreadcrumbBar.tsx | 4 +- src/layout/SectionSidebar.tsx | 1 + src/styles/campaign-workspace.css | 380 +++++++++++ src/utils/helpContext.ts | 4 +- 6 files changed, 1016 insertions(+), 6 deletions(-) create mode 100644 src/features/campaigns/ReviewSendDevelopmentPage.tsx diff --git a/src/features/campaigns/CampaignWorkspace.tsx b/src/features/campaigns/CampaignWorkspace.tsx index 86f6441..2b6fdf0 100644 --- a/src/features/campaigns/CampaignWorkspace.tsx +++ b/src/features/campaigns/CampaignWorkspace.tsx @@ -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 }) { } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -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"; diff --git a/src/features/campaigns/ReviewSendDevelopmentPage.tsx b/src/features/campaigns/ReviewSendDevelopmentPage.tsx new file mode 100644 index 0000000..029e6b2 --- /dev/null +++ b/src/features/campaigns/ReviewSendDevelopmentPage.tsx @@ -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 = { + 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 | 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 ( +
+
+
+ Review & Send workflow preview + +
+
+ + Open current page +
+
+ + {error && {error}} + {message && {message}} + + + This is a development layout using the current campaign data and existing actions. The established Review & Send page remains available for comparison. + + + + + +
+ +
+ + + + +
+ {validationStale &&

The stored validation result is no longer an active delivery lock. Run validation again.

} +
+ +
+
+ + +
+ + + + +
+ {validationPresent && validationErrors === 0 && ( +

+ )} + {validationErrors > 0 && ( +

Resolve the blocking entries in the current issue table, then validate again.

+ )} +
+ Open current issue table + Review attachment rules +
+
+ + +
+ + + + +
+

Building resolves recipient values and attachment patterns into the exact message queue used by the later stages.

+
+ +
+
+ + +
+ + + + +
+

Use the Template preview to inspect rendered content and the actual managed files matched for individual recipients.

+
+ Open template review + +
+
+ + +
+ + + + +
+

This uses the existing mock mailbox and failure-safe delivery path. It never marks the campaign as sent.

+
+ + Review server settings +
+
+ + +
+
Recipients{jobsTotal || "—"}
+
Messages to send{builtCount || jobsTotal || "—"}
+
Matched attachments{matchedAttachments || "—"}
+
Missing / ambiguous{missingAttachments} / {ambiguousAttachments}
+
Rate limit{messagesPerMinute > 0 ? `${messagesPerMinute}/min` : "Not set"}
+
Minimum duration{estimatedMinutes ? `about ${estimatedMinutes} min` : "—"}
+
IMAP append{Boolean(imapAppend.enabled) ? "Enabled" : "Disabled"}
+
Version{version ? `v${version.version_number}` : "—"}
+
+

The development page intentionally delegates the real-send action to the established page while this layout is evaluated.

+
+ Open final send controls +
+
+ + +
+ + + + +
+
+ + {deliveryStarted ? "Delivery activity is available in the report and audit views." : "No real delivery has started for this campaign version."} +
+
+ Open report + Open audit log +
+
+
+
+
+ ); +} + +function WorkflowNavigation({ stages, onSelect }: { stages: FlowStageDefinition[]; onSelect: (id: string) => void }) { + return ( + + ); +} + +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 ( +
+
+ ); +} + +function WorkflowFact({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function numberFrom(record: Record, 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); + } +} diff --git a/src/layout/BreadcrumbBar.tsx b/src/layout/BreadcrumbBar.tsx index 34c1c87..5f1f567 100644 --- a/src/layout/BreadcrumbBar.tsx +++ b/src/layout/BreadcrumbBar.tsx @@ -36,8 +36,8 @@ const campaignRouteLabels: Record = { 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", diff --git a/src/layout/SectionSidebar.tsx b/src/layout/SectionSidebar.tsx index fadd3a5..a489b05 100644 --- a/src/layout/SectionSidebar.tsx +++ b/src/layout/SectionSidebar.tsx @@ -20,6 +20,7 @@ const campaignSubnav: ModuleSubnavGroup[] = [ 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" } diff --git a/src/styles/campaign-workspace.css b/src/styles/campaign-workspace.css index 89d8bcd..de389f6 100644 --- a/src/styles/campaign-workspace.css +++ b/src/styles/campaign-workspace.css @@ -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; + } +} diff --git a/src/utils/helpContext.ts b/src/utils/helpContext.ts index 0f8809b..90a0fd1 100644 --- a/src/utils/helpContext.ts +++ b/src/utils/helpContext.ts @@ -18,8 +18,8 @@ const campaignSectionContexts: Record> = { 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" },