diff --git a/src/features/campaigns/CampaignWorkspace.tsx b/src/features/campaigns/CampaignWorkspace.tsx index 367d4e3..f93ac3c 100644 --- a/src/features/campaigns/CampaignWorkspace.tsx +++ b/src/features/campaigns/CampaignWorkspace.tsx @@ -9,7 +9,6 @@ import RecipientDetailsPage from "./RecipientDetailsPage"; import TemplateDataPage from "./TemplateDataPage"; import AttachmentsDataPage from "./AttachmentsDataPage"; import MailSettingsPage from "./MailSettingsPage"; -import ReviewDataPage from "./ReviewDataPage"; import SendDataPage from "./SendDataPage"; import CreateWizard from "./wizard/CreateWizard"; import ReviewWizard from "./wizard/ReviewWizard"; @@ -74,7 +73,6 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) { } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/features/campaigns/MailSettingsPage.tsx b/src/features/campaigns/MailSettingsPage.tsx index f512fd5..ddb0083 100644 --- a/src/features/campaigns/MailSettingsPage.tsx +++ b/src/features/campaigns/MailSettingsPage.tsx @@ -7,6 +7,7 @@ import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; +import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import ToggleSwitch from "../../components/ToggleSwitch"; import { clearMockMailboxMessages, getMockMailboxMessage, listImapFolders, listMockMailboxMessages, testImapSettings, testSmtpSettings, updateMockMailboxFailures, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity, type MockMailboxMessage } from "../../api/mail"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; @@ -358,22 +359,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A - {selectedMockMessage && ( -
-
-

{selectedMockMessage.subject || "Mock message"}

- -
-
-
From{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}
-
To{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}
-
Size{selectedMockMessage.size_bytes || 0} bytes
-
Folder{selectedMockMessage.folder || "—"}
-
- {selectedMockMessage.body_preview &&
{selectedMockMessage.body_preview}
} - {selectedMockMessage.raw_eml &&
Raw MIME
{selectedMockMessage.raw_eml}
} -
- )} +
@@ -381,11 +367,46 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
+ {selectedMockMessage && ( + setSelectedMockMessage(null)} + /> + )} ); } + +function mockMessageMetaItems(message: MockMailboxMessage) { + return [ + { label: "From", value: message.from_header || message.envelope_from || "—" }, + { label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" }, + { label: "Kind", value: message.kind || "—" }, + { label: "Folder", value: message.folder || "—" }, + { label: "Message-ID", value: message.message_id || "—" }, + { label: "Size", value: `${message.size_bytes || 0} bytes` } + ]; +} + +function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] { + return (message.attachments ?? []).map((attachment, index) => ({ + filename: attachment.filename || `Attachment ${index + 1}`, + contentType: attachment.content_type || undefined, + sizeBytes: attachment.size_bytes ?? undefined + })); +} + function formatMockDate(value: string): string { if (!value) return "—"; const date = new Date(value); diff --git a/src/features/campaigns/ReviewDataPage.tsx b/src/features/campaigns/ReviewDataPage.tsx deleted file mode 100644 index e5e90ab..0000000 --- a/src/features/campaigns/ReviewDataPage.tsx +++ /dev/null @@ -1,426 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; -import type { ApiSettings } from "../../types"; -import Button from "../../components/Button"; -import PageTitle from "../../components/PageTitle"; -import LoadingFrame from "../../components/LoadingFrame"; -import LockedVersionNotice from "./components/LockedVersionNotice"; -import VersionLine from "./components/VersionLine"; -import Card from "../../components/Card"; -import StatusBadge from "../../components/StatusBadge"; -import { buildVersion, validateVersion } from "../../api/campaigns"; -import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { - asArray, - asRecord, - formatDateTime, - isAuditLockedVersion, - isFinalLockedVersion, - isUserLockedVersion, - isVersionReadyForDelivery, - stringifyPreview, - summaryValue, - versionLockReason, -} from "./utils/campaignView"; - -export default function ReviewDataPage({ - settings, - campaignId, -}: { - settings: ApiSettings; - campaignId: string; -}) { - const { data, loading, error, reload, setError } = useCampaignWorkspaceData( - settings, - campaignId, - { includeSummary: true }, - ); - const version = data.currentVersion; - const locked = isAuditLockedVersion(version); - const auditSafe = - isUserLockedVersion(version) || isFinalLockedVersion(version); - const validationSummary = asRecord(version?.validation_summary); - const buildSummary = asRecord(version?.build_summary); - const validationOk = validationSummary.ok === true; - const readyForDelivery = isVersionReadyForDelivery(version); - const [actionBusy, setActionBusy] = useState<"validate" | "build" | "">(""); - const [actionMessage, setActionMessage] = useState(""); - const [lastValidationResult, setLastValidationResult] = useState | null>(null); - - useEffect(() => { - setLastValidationResult(null); - }, [version?.id]); - - const issues = useMemo( - () => - collectIssues( - lastValidationResult, - validationSummary, - data.summary?.issues, - ), - [lastValidationResult, validationSummary, data.summary?.issues], - ); - - async function runValidate() { - if (!version || actionBusy) return; - setActionBusy("validate"); - setActionMessage("Validating campaign and locking the version on success…"); - setError(""); - try { - const result = await validateVersion(settings, version.id, false); - setLastValidationResult(result); - setActionMessage( - result.ok - ? "Validation passed. This version is now locked but can still be unlocked before sending." - : "Validation finished with issues. See the validation issues below, fix the campaign, and validate again.", - ); - await reload(); - } catch (err) { - setActionMessage(""); - setError(err instanceof Error ? err.message : String(err)); - } finally { - setActionBusy(""); - } - } - - async function runBuild() { - if (!version || actionBusy) return; - setActionBusy("build"); - setActionMessage("Building the queue for the locked, validated version…"); - setError(""); - try { - const result = await buildVersion(settings, version.id, true); - setActionMessage( - `Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`, - ); - await reload(); - } catch (err) { - setActionMessage(""); - setError(err instanceof Error ? err.message : String(err)); - } finally { - setActionBusy(""); - } - } - - return ( -
-
-
- Review - -
-
- - - - -
-
- - {error &&
{error}
} - {actionMessage &&
{actionMessage}
} - {locked && ( - - )} - - - - Validation locks this version; unlocking invalidates validation - before sending. - - } - > -
- - -
-
-
-
Version state
-
{version?.workflow_state ?? "—"}
-
-
-
Lock
-
{versionLockReason(version)}
-
-
-
Validation
-
- {validationOk - ? "Passed" - : version?.validation_summary - ? "Needs attention" - : "Not validated"} -
-
-
-
Build
-
- {String( - buildSummary.built_count ?? - buildSummary.ready_count ?? - "Not built", - )} -
-
-
-
- -
- -
- - - - -
- {!version?.validation_summary && ( -

No validation summary is stored yet.

- )} -
- - -
- - - - -
- {!version?.build_summary && ( -

No build summary is stored yet.

- )} -
-
- - 0 ? ( - {issues.length} issue(s) - ) : undefined - } - > - {issues.length === 0 && ( -

- No validation issues are stored for this version. Run validation - to populate this list. -

- )} - {issues.length > 0 && ( -
- - - - - - - - - - - {issues.map((issue, index) => ( - - - - - - - ))} - -
SeverityLocationCodeMessage
- - - {String( - issue.path || - issue.source || - issue.section || - issue.field || - "—", - )} - {String(issue.code || "—")} - {String(issue.message || stringifyPreview(issue, 220))} -
-
- )} -
-
-
- ); -} - -function SummaryTile({ - label, - value, -}: { - label: string; - value: string | number; -}) { - return ( -
- {label} - {value} -
- ); -} - -function collectIssues(...sources: unknown[]): Record[] { - const byKey = new Map>(); - - for (const source of sources) { - for (const issue of collectIssueSource(source)) { - const normalized = normalizeIssue(issue); - const key = - String(normalized.severity ?? "") + - "|" + - String( - normalized.path ?? - normalized.source ?? - normalized.section ?? - normalized.field ?? - "", - ) + - "|" + - String(normalized.code ?? "") + - "|" + - String(normalized.message ?? ""); - byKey.set(key, { ...normalized, issueKey: key }); - } - } - - return Array.from(byKey.values()).sort( - (left, right) => severityRank(left.severity) - severityRank(right.severity), - ); -} - -function collectIssueSource(raw: unknown): Record[] { - if (Array.isArray(raw)) return raw.map(asRecord); - if (!raw || typeof raw !== "object") return []; - const record = raw as Record; - const direct = asArray(record.items ?? record.issues ?? record.results); - if (direct.length) return direct.map(asRecord); - return Object.entries(record).flatMap(([section, value]) => - asArray(value).map((item) => ({ section, ...asRecord(item) })), - ); -} - -function normalizeIssue( - issue: Record, -): Record { - return { - ...issue, - severity: issue.severity ?? issue.level ?? issue.tone ?? "info", - path: issue.path ?? issue.source ?? issue.location ?? issue.field, - code: issue.code ?? issue.type, - message: issue.message ?? issue.detail ?? issue.description, - }; -} - -function severityRank(value: unknown): number { - switch (String(value || "info").toLowerCase()) { - case "error": - case "danger": - case "blocked": - return 0; - case "warning": - case "warn": - return 1; - default: - return 2; - } -} diff --git a/src/features/campaigns/SendDataPage.tsx b/src/features/campaigns/SendDataPage.tsx index e682bbe..142db66 100644 --- a/src/features/campaigns/SendDataPage.tsx +++ b/src/features/campaigns/SendDataPage.tsx @@ -7,6 +7,8 @@ import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; +import ReviewWorkflowCards from "./components/ReviewWorkflowCards"; +import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import Card from "../../components/Card"; import MetricCard from "../../components/MetricCard"; import StatusBadge from "../../components/StatusBadge"; @@ -157,6 +159,15 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe + + Temporary sandbox. It never uses the real SMTP/IMAP server and never marks this version sent.}>
@@ -254,22 +265,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
- {selectedMockMessage && ( -
-
-

{selectedMockMessage.subject || "Mock message"}

- -
-
-
From{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}
-
To{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}
-
Size{selectedMockMessage.size_bytes || 0} bytes
-
Folder{selectedMockMessage.folder || "—"}
-
- {selectedMockMessage.body_preview &&
{selectedMockMessage.body_preview}
} - {selectedMockMessage.raw_eml &&
Raw MIME
{selectedMockMessage.raw_eml}
} -
- )} + )}
@@ -343,6 +339,21 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe

+ {selectedMockMessage && ( + setSelectedMockMessage(null)} + /> + )} ); } + + +function mockMessageMetaItems(message: MockMailboxMessage) { + return [ + { label: "From", value: message.from_header || message.envelope_from || "—" }, + { label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" }, + { label: "Kind", value: message.kind || "—" }, + { label: "Folder", value: message.folder || "—" }, + { label: "Message-ID", value: message.message_id || "—" }, + { label: "Size", value: `${message.size_bytes || 0} bytes` } + ]; +} + +function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] { + return (message.attachments ?? []).map((attachment, index) => ({ + filename: attachment.filename || `Attachment ${index + 1}`, + contentType: attachment.content_type || undefined, + sizeBytes: attachment.size_bytes ?? undefined + })); +} diff --git a/src/features/campaigns/TemplateDataPage.tsx b/src/features/campaigns/TemplateDataPage.tsx index 3833e6a..4ff2683 100644 --- a/src/features/campaigns/TemplateDataPage.tsx +++ b/src/features/campaigns/TemplateDataPage.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; @@ -8,6 +7,7 @@ import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; +import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; @@ -233,21 +233,24 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A {previewOpen && ( - 0} - attachments={previewAttachments} + recipientLabel={inlineEntries.length > 0 ? recipientLabel(previewEntry, Math.min(previewIndex, previewEntries.length - 1)) : "Global preview"} + recipientNote={inlineEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : "No inline recipients are available yet."} + attachments={mapEffectiveAttachmentsToPreviewBoxes(previewAttachments)} + navigation={{ + index: Math.min(previewIndex, previewEntries.length - 1), + total: previewEntries.length, + onFirst: () => setPreviewIndex(0), + onPrevious: () => setPreviewIndex((value) => Math.max(0, value - 1)), + onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)), + onLast: () => setPreviewIndex(previewEntries.length - 1) + }} onClose={() => setPreviewOpen(false)} - onFirst={() => setPreviewIndex(0)} - onPrevious={() => setPreviewIndex((value) => Math.max(0, value - 1))} - onNext={() => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1))} - onLast={() => setPreviewIndex(previewEntries.length - 1)} /> )} @@ -316,113 +319,31 @@ function UndefinedPlaceholderList({ items, onSelect }: { items: UndefinedPlaceho ); } -function TemplatePreviewOverlay({ - bodyMode, - entry, - index, - total, - subject, - text, - html, - hasRealRecipients, - attachments, - onClose, - onFirst, - onPrevious, - onNext, - onLast -}: { - bodyMode: BodyMode; - entry: Record; - index: number; - total: number; - subject: string; - text: string; - html: string; - hasRealRecipients: boolean; - attachments: EffectiveAttachmentPreview[]; - onClose: () => void; - onFirst: () => void; - onPrevious: () => void; - onNext: () => void; - onLast: () => void; -}) { - return ( -
-
-
-

Template preview

- -
-
-
-
- {hasRealRecipients ? recipientLabel(entry, index) : "Global preview"} -

{hasRealRecipients ? `${index + 1} of ${total}` : "No inline recipients are available yet."}

-
-
- - - {index + 1} / {total} - - -
-
-
-

{subject || "No subject"}

- {bodyMode === "html" ? ( -