438 lines
21 KiB
TypeScript
438 lines
21 KiB
TypeScript
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<BodyMode>("text");
|
||
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
|
||
const [previewOpen, setPreviewOpen] = useState(false);
|
||
const [previewIndex, setPreviewIndex] = useState(0);
|
||
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
|
||
const subjectRef = useRef<HTMLInputElement | null>(null);
|
||
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const htmlRef = useRef<HTMLTextAreaElement | null>(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 (
|
||
<div className="content-pad workspace-data-page">
|
||
<div className="page-heading split workspace-heading">
|
||
<div>
|
||
<PageTitle loading={loading}>Template</PageTitle>
|
||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||
</div>
|
||
<div className="button-row compact-actions">
|
||
<Button disabled>Manage templates</Button>
|
||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <div className="alert danger">{error}</div>}
|
||
{localError && <div className="alert danger">{localError}</div>}
|
||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing the template." />}
|
||
|
||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||
<>
|
||
<div className="dashboard-grid template-editor-grid">
|
||
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}>
|
||
<div className="form-grid">
|
||
<FormField label="Subject">
|
||
<input
|
||
ref={subjectRef}
|
||
value={getText(template, "subject")}
|
||
disabled={locked}
|
||
onFocus={() => setActiveEditor("subject")}
|
||
onChange={(event) => patchTemplateText("subject", event.target.value)}
|
||
/>
|
||
</FormField>
|
||
<div className="template-body-mode" role="tablist" aria-label="Template body mode">
|
||
<button type="button" className={bodyMode === "text" ? "active" : ""} onClick={() => { setBodyMode("text"); setActiveEditor("text"); }}>Plain text</button>
|
||
<button type="button" className={bodyMode === "html" ? "active" : ""} onClick={() => { setBodyMode("html"); setActiveEditor("html"); }}>HTML</button>
|
||
</div>
|
||
{bodyMode === "text" && (
|
||
<FormField label="Plain text body">
|
||
<textarea
|
||
ref={textRef}
|
||
rows={16}
|
||
value={getText(template, "text")}
|
||
disabled={locked}
|
||
onFocus={() => setActiveEditor("text")}
|
||
onChange={(event) => patchTemplateText("text", event.target.value)}
|
||
/>
|
||
</FormField>
|
||
)}
|
||
{bodyMode === "html" && (
|
||
<FormField label="HTML body">
|
||
<textarea
|
||
ref={htmlRef}
|
||
rows={16}
|
||
value={getText(template, "html")}
|
||
disabled={locked}
|
||
onFocus={() => setActiveEditor("html")}
|
||
onChange={(event) => patchTemplateText("html", event.target.value)}
|
||
/>
|
||
</FormField>
|
||
)}
|
||
<div className="button-row template-editor-actions">
|
||
<Button disabled>Load from library</Button>
|
||
<Button disabled>Save to library</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<div className="template-side-stack">
|
||
<Card title="Fields">
|
||
{invalidNamespacePlaceholders.length > 0 && (
|
||
<div className="alert warning">Undefined placeholder namespace detected: {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</div>
|
||
)}
|
||
{usedPlaceholders.length === 0 && <p className="muted">No template placeholders detected yet.</p>}
|
||
<p className="muted small-note">Click a field to insert it at the current cursor position as a namespaced placeholder.</p>
|
||
|
||
<h3 className="section-mini-heading">Global fields</h3>
|
||
<TemplateFieldChipList
|
||
namespace="global"
|
||
names={globalFieldNames}
|
||
usedPlaceholders={usedPlaceholders}
|
||
empty="No campaign fields or global values defined."
|
||
onInsert={insertPlaceholder}
|
||
/>
|
||
|
||
<h3 className="section-mini-heading">Local fields</h3>
|
||
<TemplateFieldChipList
|
||
namespace="local"
|
||
names={localFieldNames}
|
||
usedPlaceholders={usedPlaceholders}
|
||
empty="No campaign fields defined."
|
||
onInsert={insertPlaceholder}
|
||
/>
|
||
|
||
<h3 className="section-mini-heading">Used in template, but undefined</h3>
|
||
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="button-row page-bottom-actions">
|
||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||
</div>
|
||
</>
|
||
</LoadingFrame>
|
||
|
||
{previewOpen && (
|
||
<TemplatePreviewOverlay
|
||
bodyMode={bodyMode}
|
||
entry={previewEntry}
|
||
index={Math.min(previewIndex, previewEntries.length - 1)}
|
||
total={previewEntries.length}
|
||
subject={previewSubject}
|
||
text={previewText}
|
||
html={previewHtml}
|
||
hasRealRecipients={inlineEntries.length > 0}
|
||
attachments={previewAttachments}
|
||
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)}
|
||
/>
|
||
)}
|
||
|
||
{undefinedDialog && (
|
||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="undefined-template-field-title">
|
||
<div className="modal-panel template-action-dialog">
|
||
<header className="modal-header">
|
||
<h2 id="undefined-template-field-title">Undefined template field</h2>
|
||
<button className="modal-close" onClick={() => setUndefinedDialog(null)}>×</button>
|
||
</header>
|
||
<div className="modal-body">
|
||
<p>The template uses <code>{`{{${undefinedDialog.raw}}}`}</code>, but it cannot be matched to a known field.</p>
|
||
{undefinedDialog.reason === "invalid-namespace" && <div className="alert warning">Use the namespace <code>global:</code> or <code>local:</code>.</div>}
|
||
{undefinedDialog.reason === "missing-field" && <p className="muted">You can add the name <strong>{undefinedDialog.name}</strong> as a campaign field, or remove this placeholder from subject, plain text and HTML.</p>}
|
||
</div>
|
||
<footer className="modal-footer">
|
||
<Button onClick={() => setUndefinedDialog(null)}>Cancel</Button>
|
||
<Button onClick={() => removePlaceholder(undefinedDialog)}>Remove from template</Button>
|
||
<Button variant="primary" onClick={() => addUndefinedField(undefinedDialog)} disabled={!undefinedDialog.name}>Add field</Button>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TemplateFieldChipList({
|
||
namespace,
|
||
names,
|
||
usedPlaceholders,
|
||
empty,
|
||
onInsert
|
||
}: {
|
||
namespace: TemplateNamespace;
|
||
names: string[];
|
||
usedPlaceholders: TemplatePlaceholder[];
|
||
empty: string;
|
||
onInsert: (namespace: TemplateNamespace, name: string) => void;
|
||
}) {
|
||
if (names.length === 0) return <p className="muted">{empty}</p>;
|
||
return (
|
||
<div className="field-chip-list">
|
||
{names.map((name) => {
|
||
const used = usedPlaceholders.some((field) => field.validNamespace && field.namespace === namespace && field.name === name);
|
||
return (
|
||
<button type="button" className={`field-chip field-chip-button ${used ? "used" : ""}`} key={`${namespace}:${name}`} onClick={() => onInsert(namespace, name)}>
|
||
<span className="field-chip-namespace">{namespace}</span>{name}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UndefinedPlaceholderList({ items, onSelect }: { items: UndefinedPlaceholder[]; onSelect: (item: UndefinedPlaceholder) => void }) {
|
||
if (items.length === 0) return <p className="muted">No undefined placeholders detected.</p>;
|
||
return (
|
||
<div className="field-chip-list">
|
||
{items.map((item) => (
|
||
<button type="button" className="field-chip field-chip-button undefined" key={`${item.raw}:${item.reason}`} onClick={() => onSelect(item)}>
|
||
{item.display}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TemplatePreviewOverlay({
|
||
bodyMode,
|
||
entry,
|
||
index,
|
||
total,
|
||
subject,
|
||
text,
|
||
html,
|
||
hasRealRecipients,
|
||
attachments,
|
||
onClose,
|
||
onFirst,
|
||
onPrevious,
|
||
onNext,
|
||
onLast
|
||
}: {
|
||
bodyMode: BodyMode;
|
||
entry: Record<string, unknown>;
|
||
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 (
|
||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="template-preview-title">
|
||
<div className="modal-panel template-preview-modal">
|
||
<header className="modal-header">
|
||
<h2 id="template-preview-title">Template preview</h2>
|
||
<button className="modal-close" onClick={onClose}>×</button>
|
||
</header>
|
||
<div className="modal-body">
|
||
<div className="template-preview-toolbar">
|
||
<div>
|
||
<strong>{hasRealRecipients ? recipientLabel(entry, index) : "Global preview"}</strong>
|
||
<p className="muted small-note">{hasRealRecipients ? `${index + 1} of ${total}` : "No inline recipients are available yet."}</p>
|
||
</div>
|
||
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
|
||
<button type="button" className="version-arrow" onClick={onFirst} disabled={index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
|
||
<button type="button" className="version-arrow" onClick={onPrevious} disabled={index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
|
||
<span className="template-preview-count">{index + 1} / {total}</span>
|
||
<button type="button" className="version-arrow" onClick={onNext} disabled={index >= total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
|
||
<button type="button" className="version-arrow" onClick={onLast} disabled={index >= total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
|
||
</div>
|
||
</div>
|
||
<div className="template-preview-box">
|
||
<h3>{subject || "No subject"}</h3>
|
||
{bodyMode === "html" ? (
|
||
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={html || "<p>No HTML body to preview.</p>"} />
|
||
) : (
|
||
<pre>{text || "No plain-text body to preview."}</pre>
|
||
)}
|
||
</div>
|
||
<EffectiveAttachmentPreviewTable attachments={attachments} />
|
||
</div>
|
||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function EffectiveAttachmentPreviewTable({ attachments }: { attachments: EffectiveAttachmentPreview[] }) {
|
||
return (
|
||
<div className="template-preview-attachments">
|
||
<h3>Effective attachments</h3>
|
||
{attachments.length === 0 ? (
|
||
<p className="muted small-note">No global or recipient attachments are effective for this message.</p>
|
||
) : (
|
||
<div className="app-table-wrap">
|
||
<table className="app-table compact-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Scope</th>
|
||
<th>Label</th>
|
||
<th>Base path</th>
|
||
<th>File / pattern</th>
|
||
<th>Options</th>
|
||
<th>Preview match</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{attachments.map((attachment, index) => (
|
||
<tr key={`${attachment.scope}:${attachment.basePath}:${attachment.fileFilter}:${index}`}>
|
||
<td>{attachment.scope === "global" ? "Global" : "Recipient"}</td>
|
||
<td>{attachment.label}</td>
|
||
<td>{attachment.basePathName}<br /><span className="muted"><code>{attachment.basePath}</code></span></td>
|
||
<td><code>{attachment.fileFilter || "—"}</code></td>
|
||
<td>{attachment.required ? "required" : "optional"}{attachment.includeSubdirs ? ", subdirs" : ""}</td>
|
||
<td>{attachment.matches.length > 0 ? attachment.matches.join(", ") : "—"}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||
const name = valueToPreview(entry.name).trim();
|
||
const email = valueToPreview(entry.email).trim();
|
||
if (name && email) return `${name} <${email}>`;
|
||
if (name) return name;
|
||
if (email) return email;
|
||
return `Recipient ${index + 1}`;
|
||
}
|
||
|
||
function uniqueSorted(values: string[]): string[] {
|
||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
||
}
|