first version able to send
This commit is contained in:
@@ -9,22 +9,11 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
import { cloneJson, getBool, getText } from "./utils/draftEditor";
|
||||
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";
|
||||
type TemplateNamespace = "global" | "local";
|
||||
|
||||
type TemplatePlaceholder = {
|
||||
raw: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
validNamespace: boolean;
|
||||
display: string;
|
||||
};
|
||||
|
||||
type UndefinedPlaceholder = TemplatePlaceholder & {
|
||||
reason: "missing-field" | "invalid-namespace";
|
||||
};
|
||||
|
||||
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
@@ -65,16 +54,11 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
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(() => uniquePlaceholders(usedPlaceholders
|
||||
.filter((field) => !field.validNamespace || !allAvailableNames.has(field.name))
|
||||
.map((field): UndefinedPlaceholder => ({
|
||||
...field,
|
||||
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
|
||||
}))), [usedPlaceholders, allAvailableNames]);
|
||||
const previewContext = useMemo(() => buildPreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]);
|
||||
const previewSubject = renderPreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
|
||||
const previewText = renderPreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
|
||||
const previewHtml = renderPreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
|
||||
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);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -382,121 +366,6 @@ function TemplatePreviewOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
function extractTemplatePlaceholders(text: string): TemplatePlaceholder[] {
|
||||
const placeholders = new Map<string, TemplatePlaceholder>();
|
||||
const patterns = [/\$\{\s*([^}]+?)\s*\}/g, /\{\{\s*([^}]+?)\s*\}\}/g];
|
||||
for (const pattern of patterns) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(text))) {
|
||||
const raw = match[1].trim();
|
||||
if (!raw || placeholders.has(raw)) continue;
|
||||
const parsed = parseTemplatePlaceholder(raw);
|
||||
placeholders.set(raw, parsed);
|
||||
}
|
||||
}
|
||||
return [...placeholders.values()].sort((a, b) => a.display.localeCompare(b.display));
|
||||
}
|
||||
|
||||
function parseTemplatePlaceholder(raw: string): TemplatePlaceholder {
|
||||
const cleaned = raw.trim().replace(/^fields\./, "local:").replace(/^local\./, "local:").replace(/^global\./, "global:").replace(/^local::/, "local:").replace(/^global::/, "global:");
|
||||
const separator = cleaned.indexOf(":");
|
||||
const namespace = separator > -1 ? cleaned.slice(0, separator).trim() : "";
|
||||
const name = separator > -1 ? cleaned.slice(separator + 1).trim() : cleaned.trim();
|
||||
const validNamespace = namespace === "global" || namespace === "local";
|
||||
return {
|
||||
raw,
|
||||
namespace,
|
||||
name,
|
||||
validNamespace,
|
||||
display: validNamespace ? `${namespace}:${name}` : raw
|
||||
};
|
||||
}
|
||||
|
||||
function uniquePlaceholders<T extends TemplatePlaceholder>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
const result: T[] = [];
|
||||
for (const item of items) {
|
||||
const key = item.raw;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildPreviewContext(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, string> {
|
||||
const context: Record<string, string> = {};
|
||||
const globalValues = asRecord(draft?.global_values);
|
||||
const entryFields = asRecord(entry.fields);
|
||||
const overridePolicy = fieldOverridePolicy(draft);
|
||||
|
||||
for (const [key, value] of Object.entries(globalValues)) {
|
||||
addContextValue(context, key, "global", value);
|
||||
addContextValue(context, key, "local", value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(entryFields)) {
|
||||
if (canOverrideField(overridePolicy, key) && hasPreviewOverrideValue(value)) {
|
||||
addContextValue(context, key, "local", value);
|
||||
}
|
||||
}
|
||||
if (entry.name) addContextValue(context, "name", "local", entry.name);
|
||||
if (entry.email) addContextValue(context, "email", "local", entry.email);
|
||||
return context;
|
||||
}
|
||||
|
||||
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
|
||||
const policy = new Map<string, boolean>();
|
||||
for (const field of asArray(draft?.fields).map(asRecord)) {
|
||||
const name = String(field.name || field.id || "").trim();
|
||||
if (!name) continue;
|
||||
policy.set(name, getBool(field, "can_override", true));
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
function canOverrideField(policy: Map<string, boolean>, name: string): boolean {
|
||||
if (!policy.has(name)) return true;
|
||||
return policy.get(name) !== false;
|
||||
}
|
||||
|
||||
function addContextValue(context: Record<string, string>, key: string, namespace: TemplateNamespace, value: unknown) {
|
||||
const text = valueToPreview(value);
|
||||
context[key] = text;
|
||||
context[`${namespace}:${key}`] = text;
|
||||
context[`${namespace}::${key}`] = text;
|
||||
}
|
||||
|
||||
function hasPreviewOverrideValue(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderPreviewText(text: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
||||
if (!text) return "";
|
||||
return text
|
||||
.replace(/\$\{\s*([^}]+?)\s*\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields))
|
||||
.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields));
|
||||
}
|
||||
|
||||
function previewValueFor(raw: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
||||
const key = normalizePreviewKey(raw);
|
||||
const value = context[key];
|
||||
if (value !== undefined) return value;
|
||||
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
|
||||
}
|
||||
|
||||
function normalizePreviewKey(raw: string): string {
|
||||
return raw.trim().replace(/^fields\./, "local:").replace(/^local\./, "local:").replace(/^global\./, "global:").replace(/^local::/, "local:").replace(/^global::/, "global:");
|
||||
}
|
||||
|
||||
function valueToPreview(value: unknown): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||
const name = valueToPreview(entry.name).trim();
|
||||
const email = valueToPreview(entry.email).trim();
|
||||
@@ -509,17 +378,3 @@ function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||
function uniqueSorted(values: string[]): string[] {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
function humanizeFieldName(value: string): string {
|
||||
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function removePlaceholderFromText(text: string, raw: string): string {
|
||||
if (!text) return text;
|
||||
const escaped = escapeRegExp(raw.trim());
|
||||
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user