import { useEffect, useMemo, useRef, useState } from "react"; import type { ApiSettings } from "../../types"; import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui"; import { DismissibleAlert, SegmentedControl, i18nMessage } from "@govoplan/core-webui"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asArray, asRecord, formatDateTime, isAuditLockedVersion } from "./utils/campaignView"; import { cloneJson, getBool, getText } from "./utils/draftEditor"; import { humanizeFieldName } from "./utils/fieldDefinitions"; import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft"; import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, replacePlaceholderInText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders"; type TemplateBodyMode = "text" | "html" | "both"; type BodyEditorMode = "text" | "html"; type EditorTarget = "subject" | "text" | "html"; export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const [activeBodyEditor, setActiveBodyEditor] = useState("text"); const [activeEditor, setActiveEditor] = useState("text"); const [previewOpen, setPreviewOpen] = useState(false); const [previewIndex, setPreviewIndex] = useState(0); const [undefinedDialog, setUndefinedDialog] = useState(null); const [attachmentPreviewRules, setAttachmentPreviewRules] = useState([]); const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false); const [attachmentPreviewError, setAttachmentPreviewError] = useState(""); const subjectRef = useRef(null); const textRef = useRef(null); const htmlRef = useRef(null); const version = data.currentVersion; const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ settings, campaignId, version, locked, reload, setError, currentStep: "template", unsavedTitle: "i18n:govoplan-campaign.unsaved_template_changes.4209cdec", unsavedMessage: "i18n:govoplan-campaign.the_template_has_unsaved_changes_save_them_befor.89f74fc1", loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a", onLoaded: () => setPreviewIndex(0) }); const template = asRecord(displayDraft.template); const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both")); const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor; const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]); const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]); const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(displayDraft.global_values))]), [displayDraft.global_values, localFieldNames]); const builtInAddressNames = useMemo(() => recipientAddressTemplateFieldOptions().map((field) => field.name), []); const localAvailableNames = useMemo(() => new Set([...localFieldNames, ...builtInAddressNames]), [builtInAddressNames, localFieldNames]); const globalAvailableNames = useMemo(() => new Set(globalFieldNames), [globalFieldNames]); const allAvailableNames = useMemo(() => new Set([...localAvailableNames, ...globalAvailableNames]), [globalAvailableNames, localAvailableNames]); const localFieldOptions = useMemo(() => { const options = [...recipientAddressTemplateFieldOptions(), ...localFieldNames.map((name) => ({ name, label: name }))]; return options.filter((option, index) => options.findIndex((candidate) => candidate.name === option.name) === index); }, [localFieldNames]); const globalFieldOptions = useMemo(() => globalFieldNames.map((name) => ({ name, label: name })), [globalFieldNames]); const entries = asRecord(displayDraft.entries); const inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]); const activePreviewEntries = useMemo( () => inlineEntries.map((entry, sourceIndex) => ({ entry, sourceIndex: sourceIndex + 1 })).filter(({ entry }) => entry.active !== false), [inlineEntries] ); const previewEntries = activePreviewEntries.length > 0 ? activePreviewEntries : [{ entry: {}, sourceIndex: 0 }]; const previewSelection = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? previewEntries[0]; const previewEntry = previewSelection.entry; const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false); const templateText = [ getText(template, "subject"), templateBodyMode !== "html" ? getText(template, "text") : "", templateBodyMode !== "text" ? getText(template, "html") : ""]. join("\n"); const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]); const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]); const undefinedPlaceholders = useMemo( () => buildUndefinedPlaceholders(usedPlaceholders, allAvailableNames, { local: localAvailableNames, global: globalAvailableNames }), [usedPlaceholders, allAvailableNames, globalAvailableNames, localAvailableNames] ); const previewContext = useMemo(() => buildTemplatePreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]); const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields); const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields); const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields); const selectedPreviewEntryIndex = previewSelection.sourceIndex; const previewAttachmentRules = useMemo( () => attachmentPreviewRules.filter((rule) => rule.entry_index === selectedPreviewEntryIndex), [attachmentPreviewRules, selectedPreviewEntryIndex] ); const previewAttachments = useMemo( () => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError, displayDraft), [attachmentPreviewError, attachmentPreviewLoading, displayDraft, previewAttachmentRules] ); useEffect(() => { if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1)); }, [previewIndex, previewEntries.length]); useEffect(() => { if (templateBodyMode === "text" && activeBodyEditor !== "text") setActiveBodyEditor("text"); if (templateBodyMode === "html" && activeBodyEditor !== "html") setActiveBodyEditor("html"); if (activeEditor !== "subject" && activeEditor !== visibleBodyEditor) setActiveEditor(visibleBodyEditor); }, [activeBodyEditor, activeEditor, templateBodyMode, visibleBodyEditor]); useEffect(() => { if (!previewOpen || !version?.id || !draft) return; let cancelled = false; setAttachmentPreviewLoading(true); setAttachmentPreviewError(""); const handle = window.setTimeout(() => { void previewCampaignAttachments(settings, campaignId, version.id, { include_unmatched: false, campaign_json: campaignJsonForAttachmentPreview(displayDraft) }). then((response) => { if (!cancelled) setAttachmentPreviewRules(response.rules); }). catch((reason: unknown) => { if (!cancelled) { setAttachmentPreviewRules([]); setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason)); } }). finally(() => { if (!cancelled) setAttachmentPreviewLoading(false); }); }, 120); return () => { cancelled = true; window.clearTimeout(handle); }; }, [campaignId, displayDraft, draft, previewOpen, settings.apiBaseUrl, settings.apiKey, settings.accessToken, version?.id]); function patchTemplateText(target: EditorTarget, value: string) { patch(["template", target], value); } function patchTemplateBodyMode(mode: TemplateBodyMode) { if (locked) return; patch(["template", "body_mode"], mode); if (mode !== "both") { setActiveBodyEditor(mode); if (activeEditor !== "subject") setActiveEditor(mode); } } function insertPlaceholder(namespace: TemplateNamespace, name: string) { if (locked) return; const target = activeEditor === "subject" ? "subject" : visibleBodyEditor; const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current; const token = `{{${namespace}:${name}}}`; const currentText = getText(template, target); const start = element?.selectionStart ?? currentText.length; const end = element?.selectionEnd ?? currentText.length; const nextText = `${currentText.slice(0, start)}${token}${currentText.slice(end)}`; patchTemplateText(target, nextText); window.requestAnimationFrame(() => { element?.focus(); const cursor = start + token.length; element?.setSelectionRange(cursor, cursor); }); } function addUndefinedField(field: UndefinedPlaceholder) { if (!draft || locked || !field.name) return; const existingFields = asArray(draft.fields).map(asRecord); const alreadyDefined = existingFields.some((item) => String(item.name || item.id || "") === field.name); if (!alreadyDefined) { patch(["fields"], [ ...existingFields, { name: field.name, label: humanizeFieldName(field.name), type: "string", required: false, can_override: true }] ); } setUndefinedDialog(null); } function removePlaceholder(field: UndefinedPlaceholder) { if (locked) return; setDraft((current) => { const next = cloneJson(current ?? {}); const nextTemplate = { ...asRecord(next.template) }; nextTemplate.subject = removePlaceholderFromText(getText(nextTemplate, "subject"), field.raw); nextTemplate.text = removePlaceholderFromText(getText(nextTemplate, "text"), field.raw); nextTemplate.html = removePlaceholderFromText(getText(nextTemplate, "html"), field.raw); next.template = nextTemplate; return next; }); markDirty(); setUndefinedDialog(null); } function replacePlaceholder(field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) { if (locked) return; const replacement = `{{${namespace}:${name}}}`; setDraft((current) => { const next = cloneJson(current ?? {}); const nextTemplate = { ...asRecord(next.template) }; nextTemplate.subject = replacePlaceholderInText(getText(nextTemplate, "subject"), field.raw, replacement); nextTemplate.text = replacePlaceholderInText(getText(nextTemplate, "text"), field.raw, replacement); nextTemplate.html = replacePlaceholderInText(getText(nextTemplate, "html"), field.raw, replacement); next.template = nextTemplate; return next; }); markDirty(); setUndefinedDialog(null); } return (
i18n:govoplan-campaign.template.3ec1ae06
{error && {error}} {localError && {localError}} {locked && } <>
setPreviewOpen(true)}>i18n:govoplan-campaign.preview.f1fbb2b4}>
setActiveEditor("subject")} onChange={(event) => patchTemplateText("subject", event.target.value)} /> {templateBodyMode === "both" && {setActiveBodyEditor(mode);setActiveEditor(mode);}} options={[ { id: "text", label: "i18n:govoplan-campaign.plain_text.9580fcbc" }, { id: "html", label: "i18n:govoplan-campaign.html.9f738ce8" } ]} /> } {visibleBodyEditor === "text" &&