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"; import FormField from "../../components/FormField"; import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { cloneJson, getBool, getText } from "./utils/draftEditor"; import { buildEffectiveAttachmentPreviews, type EffectiveAttachmentPreview } from "./utils/attachments"; import { humanizeFieldName } from "./utils/fieldDefinitions"; import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders"; type BodyMode = "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 [bodyMode, setBodyMode] = useState("text"); const [activeEditor, setActiveEditor] = useState("text"); const [previewOpen, setPreviewOpen] = useState(false); const [previewIndex, setPreviewIndex] = useState(0); const [undefinedDialog, setUndefinedDialog] = useState(null); const subjectRef = useRef(null); const textRef = useRef(null); const htmlRef = useRef(null); const version = data.currentVersion; const locked = isAuditLockedVersion(version); const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ settings, campaignId, version, locked, reload, setError, currentStep: "template", unsavedTitle: "Unsaved template changes", unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.", loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${new Date(loadedVersion.autosaved_at).toLocaleString()}` : "Loaded", onLoaded: () => setPreviewIndex(0) }); const template = asRecord(displayDraft.template); 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 allAvailableNames = useMemo(() => new Set([...localFieldNames, ...globalFieldNames]), [localFieldNames, globalFieldNames]); const entries = asRecord(displayDraft.entries); const inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]); const previewEntries = inlineEntries.length > 0 ? inlineEntries : [{}]; const previewEntry = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? {}; const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false); const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`; const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]); const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]); const undefinedPlaceholders = useMemo(() => buildUndefinedPlaceholders(usedPlaceholders, allAvailableNames), [usedPlaceholders, allAvailableNames]); 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 previewAttachments = useMemo(() => buildEffectiveAttachmentPreviews(displayDraft, previewEntry), [displayDraft, previewEntry]); useEffect(() => { if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1)); }, [previewIndex, previewEntries.length]); function patchTemplateText(target: EditorTarget, value: string) { patch(["template", target], value); } function insertPlaceholder(namespace: TemplateNamespace, name: string) { if (locked) return; const target = bodyMode === "html" && activeEditor !== "subject" ? "html" : activeEditor; 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); } return (
Template
{error &&
{error}
} {localError &&
{localError}
} {locked && } <>
setPreviewOpen(true)}>Preview}>
setActiveEditor("subject")} onChange={(event) => patchTemplateText("subject", event.target.value)} />
{bodyMode === "text" && (