import { Button, DismissibleAlert, FormField, i18nMessage } from "@govoplan/core-webui"; import { useState } from "react"; import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "../components/MessagePreviewOverlay"; import { asArray, asRecord, humanize } from "../utils/campaignView"; import { getBool, getText } from "../utils/draftEditor"; import { buildTemplatePreviewContext, renderTemplatePreviewText } from "../utils/templatePlaceholders"; import { formatAddressList, formatSingleAddress, numberFrom, numberOrUndefined, stringOrUndefined } from "./reviewFormatters"; import { messageNeedsExplicitReview, messageRequiresAttachmentOverrideReason } from "./builtMessageQuery"; export default function BuiltMessagePreview({ campaignJson, entries, rows, index, canStartSingleMessageSend, singleMessageSendBusy, reviewed, reviewReason, reviewSaving = false, reviewDisabled = false, reviewError = "", onReviewReasonChange, onAcceptReview, onSelect, onSendSingle, onClose }: { campaignJson: Record; entries: Record[]; rows: Record[]; index: number; canStartSingleMessageSend: boolean; singleMessageSendBusy: boolean; reviewed: boolean; reviewReason: string; reviewSaving?: boolean; reviewDisabled?: boolean; reviewError?: string; onReviewReasonChange: (value: string) => void; onAcceptReview: (reasonRequired: boolean, reason: string) => Promise; onSelect: (index: number) => void; onSendSingle: (index: number) => void; onClose: () => void; }) { const row = rows[index] ?? {}; const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1); const entry = entries[entryIndex] ?? {}; const template = asRecord(campaignJson.template); const context = buildTemplatePreviewContext(campaignJson, entry); const ignoreEmptyFields = getBool( asRecord(campaignJson.validation_policy), "ignore_empty_fields", false ); const html = renderTemplatePreviewText( getText(template, "html"), context, ignoreEmptyFields ); const text = renderTemplatePreviewText( getText(template, "text"), context, ignoreEmptyFields ); const subject = String( row.subject || renderTemplatePreviewText( getText(template, "subject"), context, ignoreEmptyFields ) || "i18n:govoplan-campaign.no_subject.7b4e8035" ); const issues = asArray(row.issues).map(asRecord); const resolvedRecipients = asRecord(row.resolved_recipients); const singleSendDisabledReason = singleMessageSendDisabledReason( row, canStartSingleMessageSend ); const explicitReview = messageNeedsExplicitReview(row); const reasonRequired = messageRequiresAttachmentOverrideReason(row); return ( 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues .map((issue) => String( issue.message ?? issue.code ?? "i18n:govoplan-campaign.issue.73781a12" ) ) .join(" · ")}` : "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6" } metaItems={builtMessageMetaItems(row)} attachments={builtMessageAttachments(row)} navigation={{ index, total: rows.length, onFirst: () => onSelect(0), onPrevious: () => onSelect(Math.max(0, index - 1)), onNext: () => onSelect(Math.min(rows.length - 1, index + 1)), onLast: () => onSelect(rows.length - 1) }} actions={
{reviewError && {reviewError}} {explicitReview && !reviewed ? ( onAcceptReview(reasonRequired, reason)} /> ) : null} {reviewed &&

i18n:govoplan-campaign.review_decision_saved

}
} closeDisabled={reviewSaving} onClose={onClose} /> ); } // Keystrokes rerender only this small form, not the campaign table, template // rendering and message body. The parent retains a draft ref for navigation. function ReviewDecisionForm({ initialReason, reasonRequired, saving, disabled, onReasonChange, onAccept }: { initialReason: string; reasonRequired: boolean; saving: boolean; disabled: boolean; onReasonChange: (value: string) => void; onAccept: (reason: string) => Promise; }) { const [reason, setReason] = useState(initialReason); return <> { setReason(event.target.value); onReasonChange(event.target.value); }} /> ; } function singleMessageSendDisabledReason( row: Record, canStartSingleMessageSend: boolean ): string { if (!canStartSingleMessageSend) { return "Validate, build, and complete the required review/mock gate before sending individual messages."; } const jobId = String(row.id ?? ""); if (!jobId) return "This preview row has no delivery job id."; const buildStatus = String(row.build_status ?? ""); if (buildStatus !== "built") return "This message has not been built."; const validationStatus = String(row.validation_status ?? ""); if (["blocked", "excluded", "inactive"].includes(validationStatus)) { return `This message is ${humanize(validationStatus)}.`; } const sendStatus = String(row.send_status ?? "not_queued"); if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) { return `This message is in delivery state ${humanize(sendStatus)}.`; } return ""; } function builtMessageMetaItems(row: Record) { const recipients = asRecord(row.resolved_recipients); const postboxTargets = asArray(row.resolved_postbox_targets).map(asRecord); return [ { label: "i18n:govoplan-campaign.from.3f66052a", value: formatSingleAddress(recipients.from) || "—" }, { label: "i18n:govoplan-campaign.to.ae79ea1e", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") }, { label: "i18n:govoplan-campaign.cc.c5a976de", value: formatAddressList(recipients.cc) || null }, { label: "i18n:govoplan-campaign.bcc.4c0145a3", value: formatAddressList(recipients.bcc) || null }, { label: "i18n:govoplan-campaign.validation.dd74d182", value: String(row.validation_status ?? "—") }, { label: "Frozen Postbox targets", value: postboxTargets.length ? postboxTargets .map((target) => { const label = String( target.name ?? target.address ?? target.postbox_id ?? "Postbox" ); const address = String(target.address ?? ""); const context = target.context_key ? ` [${String(target.context_key)}]` : ""; return `${label}${address && address !== label ? ` <${address}>` : ""}${context}`; }) .join(", ") : null }, { label: "i18n:govoplan-campaign.mime_size.c8b9d519", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" } ]; } function builtMessageAttachments( row: Record ): CampaignMessagePreviewAttachment[] { return asArray(row.attachments).flatMap((value, index) => { const attachment = asRecord(value); const zipProtection = zipProtectionFromBuiltAttachment(attachment); const managedMatches = asArray(attachment.managed_matches).map(asRecord); if (managedMatches.length > 0) { return managedMatches.map((match, matchIndex) => ({ filename: String( match.filename ?? match.display_path ?? `Attachment ${index + 1}.${matchIndex + 1}` ), detail: String( match.display_path ?? match.relative_path ?? attachment.label ?? "" ), contentType: stringOrUndefined(match.content_type), sizeBytes: numberOrUndefined(match.size_bytes), archiveGroup: stringOrUndefined(attachment.zip_filename), archiveLabel: stringOrUndefined(attachment.zip_filename), protected: zipProtection.protected, protectionNote: zipProtection.note })); } const matches = asArray(attachment.matches).filter( (match): match is string => typeof match === "string" && Boolean(match.trim()) ); if (matches.length > 0) { return matches.map((match) => ({ filename: match.split(/[\\/]/).pop() || String(attachment.label ?? `Attachment ${index + 1}`), detail: match, contentType: stringOrUndefined(attachment.content_type), sizeBytes: numberOrUndefined(attachment.size_bytes), archiveGroup: stringOrUndefined(attachment.zip_filename), archiveLabel: stringOrUndefined(attachment.zip_filename), protected: zipProtection.protected, protectionNote: zipProtection.note })); } return [{ filename: String( attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? attachment.label ?? `Attachment ${index + 1}` ), detail: String( attachment.display_path ?? attachment.source_path ?? attachment.file_filter ?? "" ), contentType: stringOrUndefined(attachment.content_type), sizeBytes: numberOrUndefined(attachment.size_bytes), archiveGroup: null, archiveLabel: null, protected: false, protectionNote: null }]; }); } function zipProtectionFromBuiltAttachment( attachment: Record ): { protected: boolean; note: string | null } { const zipFilename = stringOrUndefined(attachment.zip_filename); if (!zipFilename) return { protected: false, note: null }; const legacyMode = String( attachment.password_mode ?? attachment.zip_password_mode ?? "" ).trim(); const protectedArchive = getBool( attachment, "password_enabled", getBool( attachment, "zip_password_protected", getBool( attachment, "zip_protected", ["direct", "field", "template"].includes(legacyMode) ) ) ); if (!protectedArchive) return { protected: false, note: null }; const field = stringOrUndefined(attachment.password_field) ?? stringOrUndefined(attachment.zip_password_field); const rawScope = String( attachment.password_scope ?? attachment.zip_password_scope ?? "local" ); const scope = rawScope === "global" ? "global" : "local"; const method = String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6"; const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : ""; return { protected: true, note: source ? i18nMessage("i18n:govoplan-campaign.value_encryption_value", { value0: source, value1: method }) : i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method }) }; } function humanizeScope(scope: string): string { return scope === "global" ? "i18n:govoplan-campaign.global.5f1184f7" : "i18n:govoplan-campaign.local.dc99d54d"; }