import { useEffect, useMemo, useRef, useState } from "react"; import type { ApiSettings } from "../../types"; import { listCampaignContentLibrary, listCampaignPrintTemplates, previewCampaignAttachments, saveCampaignContentLibraryItem, type CampaignAttachmentPreviewRule, type CampaignContentLibraryItem, type CampaignContentLibraryResponse, type CampaignContentLibraryTarget, type CampaignPrintTemplate } from "../../api/campaigns"; import { FormGrid, ContentGrid, Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { ConfirmDialog, Dialog } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; import { FieldLabel } from "@govoplan/core-webui"; import { PageActionBar, PageLayout } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui"; import { DismissibleAlert, SegmentedControl, ToggleSwitch, i18nMessage } from "@govoplan/core-webui"; import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg"; 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"; import { campaignContentFieldType, checkContentLibraryCompatibility, type ContentLibraryCompatibility } from "./utils/contentLibraryCompatibility"; type TemplateBodyMode = "text" | "html" | "both"; type BodyEditorMode = "text" | "html"; type EditorTarget = "subject" | "text" | "html"; type ContentLibrarySaveKind = "fragment" | "campaign_part"; type PendingContentApply = { item: CampaignContentLibraryItem; target?: CampaignContentLibraryTarget; value?: string; overwrites: boolean; compatibility: ContentLibraryCompatibility; }; 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 [printTemplatesAvailable, setPrintTemplatesAvailable] = useState(false); const [printTemplates, setPrintTemplates] = useState([]); const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true); const [printTemplatesError, setPrintTemplatesError] = useState(""); const [contentLibraryOpen, setContentLibraryOpen] = useState(false); const [contentLibraryQuery, setContentLibraryQuery] = useState(""); const [contentLibrary, setContentLibrary] = useState(null); const [contentLibraryLoading, setContentLibraryLoading] = useState(false); const [contentLibraryError, setContentLibraryError] = useState(""); const [contentSaveOpen, setContentSaveOpen] = useState(false); const [contentSaveBusy, setContentSaveBusy] = useState(false); const [contentSaveName, setContentSaveName] = useState(""); const [contentSaveDescription, setContentSaveDescription] = useState(""); const [contentSaveKind, setContentSaveKind] = useState("fragment"); const [contentSaveTarget, setContentSaveTarget] = useState("text"); const [contentSaveVisibility, setContentSaveVisibility] = useState<"personal" | "tenant">("personal"); const [contentLibraryNotice, setContentLibraryNotice] = useState(""); const [pendingContentApply, setPendingContentApply] = useState(null); 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, discardDraft, 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 delivery = asRecord(displayDraft.delivery); const printConfig = asRecord(delivery.print); const selectedPrintTemplate = printTemplates.find((item) => item.id === getText(printConfig, "template_id")) ?? null; const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both")); const contentSaveSelectedValue = getText(template, contentSaveTarget); const contentSaveHasBody = Boolean(getText(template, "text").trim() || getText(template, "html").trim()); 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 contentAvailableFields = useMemo(() => { const available = new Map(); for (const field of fields) { const name = String(field.name || field.id || "").trim(); if (!name) continue; const valueType = campaignFieldDefinitionType(String(field.type || "string")); available.set(name, valueType); available.set(`local:${name}`, valueType); available.set(`global:${name}`, valueType); } for (const [name, value] of Object.entries(asRecord(displayDraft.global_values))) { const valueType = available.get(name) ?? campaignContentFieldType(value); available.set(name, valueType); available.set(`local:${name}`, valueType); available.set(`global:${name}`, valueType); } for (const name of builtInAddressNames) available.set(`local:${name}`, "string"); return available; }, [builtInAddressNames, displayDraft.global_values, fields]); 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, include_unlinked_candidates: true, 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]); useEffect(() => { let cancelled = false; setPrintTemplatesLoading(true); setPrintTemplatesError(""); void listCampaignPrintTemplates(settings, campaignId) .then((response) => { if (cancelled) return; setPrintTemplatesAvailable(response.available); setPrintTemplates(response.templates); }) .catch((reason: unknown) => { if (cancelled) return; setPrintTemplatesAvailable(false); setPrintTemplates([]); setPrintTemplatesError(reason instanceof Error ? reason.message : String(reason)); }) .finally(() => { if (!cancelled) setPrintTemplatesLoading(false); }); return () => { cancelled = true; }; }, [campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); useEffect(() => { if (!contentLibraryOpen) return; let cancelled = false; setContentLibraryLoading(true); setContentLibraryError(""); const handle = window.setTimeout(() => { void listCampaignContentLibrary(settings, campaignId, contentLibraryQuery) .then((response) => { if (!cancelled) setContentLibrary(response); }) .catch((reason: unknown) => { if (!cancelled) { setContentLibrary(null); setContentLibraryError(reason instanceof Error ? reason.message : String(reason)); } }) .finally(() => { if (!cancelled) setContentLibraryLoading(false); }); }, 180); return () => { cancelled = true; window.clearTimeout(handle); }; }, [campaignId, contentLibraryOpen, contentLibraryQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); 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 patchPrintConfig(values: Record) { if (locked) return; patch(["delivery", "print"], { ...printConfig, ...values }); } function selectPrintTemplate(templateId: string) { const selected = printTemplates.find((item) => item.id === templateId) ?? null; const profile = selected?.revision?.output_profiles[0] ?? null; patchPrintConfig({ template_id: selected?.id ?? null, template_revision: selected?.revision?.revision ?? selected?.current_revision ?? null, output_format: profile?.output_format ?? getText(printConfig, "output_format", "html"), profile_id: profile?.id ?? null }); } function insertPlaceholder(namespace: TemplateNamespace, name: string) { if (locked) return; const target = activeEditor === "subject" ? "subject" : visibleBodyEditor; const token = `{{${namespace}:${name}}}`; if (target === "html") { htmlRef.current?.insertToken({ value: token, label: `${namespace}:${name}` }); return; } const element = target === "subject" ? subjectRef.current : textRef.current; 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); } function openContentSaveDialog() { setContentSaveName(""); setContentSaveDescription(""); setContentSaveKind("fragment"); setContentSaveTarget(activeEditor === "subject" ? "subject" : visibleBodyEditor); setContentSaveVisibility("personal"); setContentLibraryError(""); setContentSaveOpen(true); } function insertContentFragment(target: CampaignContentLibraryTarget, value: string) { if (locked || !value) return; if (target === "html") { const inserted = htmlRef.current?.insertText(value) ?? false; if (!inserted) patchTemplateText("html", `${getText(template, "html")}${value}`); setActiveBodyEditor("html"); setActiveEditor("html"); setContentLibraryOpen(false); return; } const element = target === "subject" ? subjectRef.current : textRef.current; const currentText = getText(template, target); const start = element?.selectionStart ?? currentText.length; const end = element?.selectionEnd ?? currentText.length; patchTemplateText(target, `${currentText.slice(0, start)}${value}${currentText.slice(end)}`); window.requestAnimationFrame(() => { element?.focus(); const cursor = start + value.length; element?.setSelectionRange(cursor, cursor); }); if (target === "text") setActiveBodyEditor("text"); setActiveEditor(target); setContentLibraryOpen(false); } function requestContentFragment(item: CampaignContentLibraryItem, target: CampaignContentLibraryTarget, value: string) { const compatibility = checkContentLibraryCompatibility(item.required_fields, contentAvailableFields); if (compatibility.compatible) { insertContentFragment(target, value); return; } setPendingContentApply({ item, target, value, overwrites: false, compatibility }); } function applyCampaignPart(item: CampaignContentLibraryItem) { if (locked) return; setDraft((current) => { const next = cloneJson(current ?? {}); next.template = { ...asRecord(next.template), subject: item.subject ?? "", text: item.text ?? "", html: item.html ?? "", body_mode: item.body_mode }; return next; }); markDirty(); setActiveBodyEditor(item.body_mode === "html" ? "html" : "text"); setActiveEditor(item.body_mode === "html" ? "html" : "text"); setContentLibraryOpen(false); } function requestCampaignPart(item: CampaignContentLibraryItem) { const compatibility = checkContentLibraryCompatibility(item.required_fields, contentAvailableFields); const overwrites = ["subject", "text", "html"].some((field) => { const current = getText(template, field); const replacement = field === "subject" ? item.subject : field === "text" ? item.text : item.html; return Boolean(current.trim()) && current !== (replacement ?? ""); }); if (!overwrites && compatibility.compatible) { applyCampaignPart(item); return; } setPendingContentApply({ item, overwrites, compatibility }); } function confirmContentApply() { const pending = pendingContentApply; if (!pending) return; setPendingContentApply(null); if (pending.target && pending.value !== undefined) { insertContentFragment(pending.target, pending.value); } else { applyCampaignPart(pending.item); } } async function saveReusableContent() { if (contentSaveBusy || !contentSaveName.trim()) return; setContentSaveBusy(true); setContentLibraryError(""); try { const result = await saveCampaignContentLibraryItem(settings, campaignId, { name: contentSaveName.trim(), description: contentSaveDescription.trim() || null, kind: contentSaveKind, target: contentSaveKind === "fragment" ? contentSaveTarget : null, subject: getText(template, "subject"), text: getText(template, "text"), html: getText(template, "html"), body_mode: templateBodyMode, locale: "de", visibility: contentSaveVisibility }); setContentSaveOpen(false); setContentLibraryNotice(`${result.template.name} was saved as an unpublished Templates draft.`); setContentLibrary(null); } catch (reason) { setContentLibraryError(reason instanceof Error ? reason.message : String(reason)); } finally { setContentSaveBusy(false); } } return ( } headerLoading={loading} error={error} success={contentLibraryNotice} actions={ window.location.assign("/templates")}>i18n:govoplan-campaign.manage_templates.23688071} discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }} saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }} />} notices={(localError || locked) ? <> {localError && {localError}} {locked && } : undefined} > <> 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" &&