diff --git a/src/features/campaigns/GlobalSettingsPage.tsx b/src/features/campaigns/GlobalSettingsPage.tsx index 84835ec..412f473 100644 --- a/src/features/campaigns/GlobalSettingsPage.tsx +++ b/src/features/campaigns/GlobalSettingsPage.tsx @@ -33,7 +33,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings: setError, currentStep: "global-settings", unsavedTitle: "Unsaved global settings", - unsavedMessage: "Global settings have unsaved changes. Save them before leaving, or discard them and continue.", + unsavedMessage: "Policies have unsaved changes. Save them before leaving, or discard them and continue.", extraPayload: () => ({ editor_state: editorState }), onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})), onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState)) @@ -59,7 +59,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
- Global settings + Policies
diff --git a/src/features/campaigns/ReviewSendDevelopmentPage.tsx b/src/features/campaigns/ReviewSendDevelopmentPage.tsx index 029e6b2..8bdce0b 100644 --- a/src/features/campaigns/ReviewSendDevelopmentPage.tsx +++ b/src/features/campaigns/ReviewSendDevelopmentPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type CSSProperties } from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { Link } from "react-router-dom"; import { AlertTriangle, @@ -15,12 +15,15 @@ import { } from "lucide-react"; import type { ApiSettings } from "../../types"; import { buildVersion, mockSendCampaign, validateVersion } from "../../api/campaigns"; +import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; import Button from "../../components/Button"; +import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; 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 MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import VersionLine from "./components/VersionLine"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { @@ -34,6 +37,8 @@ import { isUserLockedVersion, isVersionReadyForDelivery, } from "./utils/campaignView"; +import { getBool, getText } from "./utils/draftEditor"; +import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./utils/templatePlaceholders"; type FlowState = | "complete" @@ -57,6 +62,8 @@ type FlowStageDefinition = { lockReason?: string; }; +type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | ""; + const stateColors: Record = { complete: "var(--green)", warning: "var(--amber)", @@ -74,7 +81,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se const version = data.currentVersion; const campaignJson = getCampaignJson(version); const entries = asRecord(campaignJson.entries); - const inlineEntries = asArray(entries.inline); + const inlineEntries = asArray(entries.inline).map(asRecord); const validation = asRecord(version?.validation_summary); const build = asRecord(version?.build_summary); const cards = data.summary?.cards; @@ -83,10 +90,23 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se const rateLimit = asRecord(delivery.rate_limit); const imapAppend = asRecord(delivery.imap_append_sent); - const [busy, setBusy] = useState<"validate" | "build" | "mock" | "">(""); + const [busy, setBusy] = useState(""); const [message, setMessage] = useState(""); const [messageReviewComplete, setMessageReviewComplete] = useState(false); + const [reviewResult, setReviewResult] = useState | null>(null); + const [reviewedMessageKeys, setReviewedMessageKeys] = useState>(() => new Set()); + const [selectedBuiltIndex, setSelectedBuiltIndex] = useState(null); const [mockResult, setMockResult] = useState | null>(null); + const [selectedMockMessage, setSelectedMockMessage] = useState(null); + + useEffect(() => { + setMessageReviewComplete(false); + setReviewResult(null); + setReviewedMessageKeys(new Set()); + setSelectedBuiltIndex(null); + setMockResult(null); + setSelectedMockMessage(null); + }, [version?.id]); const validationPresent = Object.keys(validation).length > 0; const validationOk = validation.ok === true; @@ -113,11 +133,20 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se const finalVersion = isFinalLockedVersion(version); const userLockedVersion = isUserLockedVersion(version); + const reviewBuild = asRecord(reviewResult?.build); + const builtReviewRows = asArray(reviewBuild.messages).map(asRecord); + const reviewedCount = builtReviewRows.reduce((count, row, index) => count + (reviewedMessageKeys.has(builtMessageKey(row, index)) ? 1 : 0), 0); + const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null; + 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 mockSkipped = numberFrom(mockSend, ["skipped_count"]); + const mockComplete = Boolean(mockResult) && mockSent > 0 && mockFailed === 0 && mockSkipped === 0; + const mockPartial = Boolean(mockResult) && mockSent > 0 && (mockFailed > 0 || mockSkipped > 0); + const mockRows = asArray(mockSend.results).map(asRecord); + const mockMailbox = asRecord(mockResult?.mailbox); + const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord); const validationState: FlowState = busy === "validate" ? "running" @@ -154,17 +183,19 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se : "active"; const downstreamDeliveryActivity = deliveryQueued || deliveryStarted; - const inspectionSatisfied = messageReviewComplete || Boolean(mockResult) || downstreamDeliveryActivity; + const inspectionSatisfied = messageReviewComplete || downstreamDeliveryActivity; const inspectState: FlowState = !hasBuild ? "locked" - : inspectionSatisfied - ? "complete" - : "active"; + : busy === "inspect" + ? "running" + : inspectionSatisfied + ? "complete" + : "active"; const mockState: FlowState = !inspectionSatisfied ? "locked" - : busy === "mock" + : busy === "mock" || busy === "mailbox" ? "running" : mockPartial ? "partial" @@ -233,7 +264,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se id: "workflow-inspect", title: "Inspect built messages", shortTitle: "Inspect", - description: "Review rendered content and the actual files attached to representative recipients.", + description: "Review rendered content, recipients, validation state and the exact managed files attached to individual messages.", icon: MailSearch, state: inspectState, stateLabel: stateLabel(inspectState), @@ -243,7 +274,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se id: "workflow-mock", title: "Run mock delivery", shortTitle: "Mock send", - description: "Exercise the delivery path without contacting the real SMTP or IMAP server.", + description: "Exercise the delivery path, inspect recipient outcomes and open the captured MIME messages without contacting the real SMTP or IMAP server.", icon: FlaskConical, state: mockState, stateLabel: stateLabel(mockState), @@ -288,8 +319,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se 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); + resetDownstreamReview(); await reload(); } catch (err) { setMessage(""); @@ -306,9 +336,13 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se setError(""); try { const result = await buildVersion(settings, version.id, true); - setMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`); + const review = await requestBuiltMessageReview(); + setReviewResult(review); + setMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s). The message review is ready below.`); setMessageReviewComplete(false); + setReviewedMessageKeys(new Set()); setMockResult(null); + setSelectedMockMessage(null); await reload(); } catch (err) { setMessage(""); @@ -318,11 +352,43 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se } } + async function loadBuiltMessages() { + if (!version || busy || !hasBuild) return; + setBusy("inspect"); + setMessage("Loading the built-message review…"); + setError(""); + try { + const result = await requestBuiltMessageReview(); + setReviewResult(result); + setMessage(`Loaded ${asArray(asRecord(result.build).messages).length} built message(s) for inspection.`); + } catch (err) { + setMessage(""); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(""); + } + } + + async function requestBuiltMessageReview(): Promise> { + if (!version) throw new Error("No campaign version is available."); + const response = await mockSendCampaign(settings, campaignId, { + version_id: version.id, + send: false, + include_warnings: true, + include_needs_review: true, + append_sent: false, + clear_mailbox: false, + check_files: true, + }); + return asRecord(response.result ?? response); + } + async function runMockSend() { if (!version || busy || !messageReviewComplete || deliveryQueued || deliveryStarted) return; setBusy("mock"); setMessage("Running the complete mock-delivery flow…"); setError(""); + setSelectedMockMessage(null); try { const response = await mockSendCampaign(settings, campaignId, { version_id: version.id, @@ -336,7 +402,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se 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)}.`); + setMessage(`Mock delivery finished. Captured ${String(sendResult.sent_count ?? 0)} message(s), failed ${String(sendResult.failed_count ?? 0)}, skipped ${String(sendResult.skipped_count ?? 0)}.`); } catch (err) { setMessage(""); setError(err instanceof Error ? err.message : String(err)); @@ -345,6 +411,40 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se } } + async function openMockMessage(id: string) { + if (!id || busy === "mailbox") return; + setBusy("mailbox"); + setError(""); + try { + const response = await getMockMailboxMessage(settings, id); + setSelectedMockMessage(response.message); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(""); + } + } + + function openBuiltMessage(index: number) { + const row = builtReviewRows[index]; + if (!row) return; + setSelectedBuiltIndex(index); + setReviewedMessageKeys((current) => { + const next = new Set(current); + next.add(builtMessageKey(row, index)); + return next; + }); + } + + function resetDownstreamReview() { + setMessageReviewComplete(false); + setReviewResult(null); + setReviewedMessageKeys(new Set()); + setSelectedBuiltIndex(null); + setMockResult(null); + setSelectedMockMessage(null); + } + function scrollToStage(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); } @@ -354,6 +454,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]); const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]); const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null; + const canCompleteInspection = builtReviewRows.length > 0 && reviewedCount > 0; return (
@@ -374,7 +475,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se {message && {message}} - This is a development layout using the current campaign data and existing actions. The established Review & Send page remains available for comparison. + This development layout uses the current campaign data and existing actions. The established Review & Send page remains available for comparison. @@ -443,33 +544,77 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
- - + +
-

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

-
- Open template review - + Open template editor +
+ {builtReviewRows.length > 0 && ( +
+ +

Open at least one message before completing the inspection step. The preview includes rendered content, recipients, issues and exact attachment metadata.

+
+ )}
- - + +
-

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

-
+
Review server settings
+ {mockResult && ( +
+
+

Recipient outcomes

+ String(row.entry_id ?? row.entry_index ?? index)} + emptyText="No mock delivery results were returned." + className="data-table-wrap data-table compact-table" + /> +
+
+

Captured mock messages

+ String(row.id ?? index)} + emptyText="No mock messages were captured in this run." + className="data-table-wrap data-table compact-table" + /> +
+
+ )} @@ -507,6 +652,33 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
+ + {selectedBuiltMessage && selectedBuiltIndex !== null && ( + setSelectedBuiltIndex(null)} + /> + )} + + {selectedMockMessage && ( + setSelectedMockMessage(null)} + /> + )}
); } @@ -522,6 +694,7 @@ function WorkflowNavigation({ stages, onSelect }: { stages: FlowStageDefinition[ "--review-nav-color": stateColors[stage.state], "--review-nav-next-color": stateColors[nextStage?.state ?? stage.state], } as CSSProperties; + const showSecondaryState = !["active", "locked"].includes(stage.state); return (
{nextStage &&