fe78645609
- All 103 pages now serve with TTFB ≤ 12ms (target: 50ms) - Connection pool: max 10→50, timeout 10s→5s (eliminates 30s admin page timeouts) - Auth fast-path: short-circuit Neon Auth DNS calls when not configured - PERF_TEST_AUTH=1 flag enables prod-mode admin auth benchmarking - Stale build artifacts fix (clean rebuild restores fast behavior) Measured (production build, sequential requests, dev_session cookie): - 102/103 pages: TTFB ≤ 10ms - 1 page: TTFB 11-20ms - 0 pages exceed 50ms TTFB - First Paint (browser): 28-84ms on admin pages
1011 lines
36 KiB
TypeScript
1011 lines
36 KiB
TypeScript
"use client";
|
||
|
||
import { useReducer, useCallback } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import Link from "next/link";
|
||
import type { Template, TemplateType, CampaignType } from "@/actions/communications/templates";
|
||
import { formatDate } from "@/lib/format-date";
|
||
import { upsertTemplate } from "@/actions/communications/templates";
|
||
import {
|
||
BUILT_IN_TEMPLATES,
|
||
renderTemplate,
|
||
TEMPLATE_VARIABLES,
|
||
type EmailTemplate,
|
||
} from "@/lib/email-templates";
|
||
|
||
const TEMPLATE_TYPES: { value: TemplateType; label: string }[] = [
|
||
{ value: "email_template", label: "Email" },
|
||
{ value: "sms_template", label: "SMS" },
|
||
{ value: "internal_note", label: "Internal Note" },
|
||
];
|
||
|
||
const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
|
||
{ value: "operational", label: "Operational" },
|
||
{ value: "marketing", label: "Marketing" },
|
||
{ value: "transactional", label: "Transactional" },
|
||
];
|
||
|
||
const TYPE_COLORS: Record<string, string> = {
|
||
email_template: "bg-blue-100 text-blue-700",
|
||
sms_template: "bg-emerald-100 text-emerald-700",
|
||
internal_note: "bg-stone-100 text-stone-600",
|
||
};
|
||
|
||
// Sample variables for preview rendering
|
||
const SAMPLE_VARS: Record<string, string> = {
|
||
first_name: "Jane",
|
||
company_name: "Route Commerce",
|
||
stop_name: "Tuxedo Warehouse",
|
||
pickup_date: "May 15, 2026",
|
||
order_total: "$450.00",
|
||
balance_due: "$225.00",
|
||
due_date: "May 10, 2026",
|
||
item_summary: "10× Tuxedo Set, 5× Dress Shirt",
|
||
custom_message: "We look forward to seeing you!",
|
||
cta_text: "Shop Now",
|
||
cta_url: "https://routecommerce.com",
|
||
tag: "New Arrivals",
|
||
headline: "Spring Collection is Here!",
|
||
unsubscribe_url: "#",
|
||
};
|
||
|
||
// Icon components
|
||
const Icons = {
|
||
plus: (className: string) => (
|
||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M12 5v14M5 12h14" />
|
||
</svg>
|
||
),
|
||
x: (className: string) => (
|
||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M18 6 6 18M6 6l12 12" />
|
||
</svg>
|
||
),
|
||
fileText: (className: string) => (
|
||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||
<polyline points="14 2 14 8 20 8"/>
|
||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||
<polyline points="10 9 9 9 8 9"/>
|
||
</svg>
|
||
),
|
||
arrowRight: (className: string) => (
|
||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M5 12h14"/>
|
||
<path d="m12 5 7 7-7 7"/>
|
||
</svg>
|
||
),
|
||
};
|
||
|
||
// New Template Modal — local reducer for its tiny form state.
|
||
type NewModalState = {
|
||
saving: boolean;
|
||
name: string;
|
||
templateType: TemplateType;
|
||
error: string;
|
||
};
|
||
|
||
type NewModalAction =
|
||
| { type: "SET_SAVING"; value: boolean }
|
||
| { type: "SET_NAME"; value: string }
|
||
| { type: "SET_TEMPLATE_TYPE"; value: TemplateType }
|
||
| { type: "SET_ERROR"; value: string }
|
||
| { type: "RESET" };
|
||
|
||
const newModalInitial: NewModalState = {
|
||
saving: false,
|
||
name: "",
|
||
templateType: "email_template",
|
||
error: "",
|
||
};
|
||
|
||
function newModalReducer(state: NewModalState, action: NewModalAction): NewModalState {
|
||
switch (action.type) {
|
||
case "SET_SAVING":
|
||
return { ...state, saving: action.value };
|
||
case "SET_NAME":
|
||
return { ...state, name: action.value };
|
||
case "SET_TEMPLATE_TYPE":
|
||
return { ...state, templateType: action.value };
|
||
case "SET_ERROR":
|
||
return { ...state, error: action.value };
|
||
case "RESET":
|
||
return { ...newModalInitial };
|
||
}
|
||
}
|
||
|
||
// New Template Modal
|
||
function NewTemplateModal({
|
||
isOpen,
|
||
onClose,
|
||
onSuccess,
|
||
brandId
|
||
}: {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
brandId: string;
|
||
}) {
|
||
const router = useRouter();
|
||
const [state, dispatch] = useReducer(newModalReducer, newModalInitial);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
const handleCreate = async () => {
|
||
if (!state.name.trim()) {
|
||
dispatch({ type: "SET_ERROR", value: "Template name is required" });
|
||
return;
|
||
}
|
||
dispatch({ type: "SET_SAVING", value: true });
|
||
dispatch({ type: "SET_ERROR", value: "" });
|
||
|
||
const result = await upsertTemplate({
|
||
brand_id: brandId,
|
||
name: state.name.trim(),
|
||
subject: "",
|
||
body_text: "",
|
||
template_type: state.templateType,
|
||
});
|
||
|
||
dispatch({ type: "SET_SAVING", value: false });
|
||
|
||
if (!result.success) {
|
||
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to create template" });
|
||
return;
|
||
}
|
||
|
||
dispatch({ type: "RESET" });
|
||
onSuccess();
|
||
router.refresh();
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||
{/* Backdrop */}
|
||
<button
|
||
type="button"
|
||
aria-label="Close modal"
|
||
className="absolute inset-0 bg-black/50 backdrop-blur-sm border-0 p-0 cursor-default"
|
||
onClick={onClose}
|
||
/>
|
||
|
||
{/* Modal */}
|
||
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl border border-[var(--admin-border)]">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--admin-border)]">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||
{Icons.fileText("h-5 w-5 text-white")}
|
||
</div>
|
||
<div>
|
||
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">New Template</h2>
|
||
<p className="text-xs text-[var(--admin-text-muted)]">Create a new email template</p>
|
||
</div>
|
||
</div>
|
||
<button type="button"
|
||
onClick={onClose}
|
||
className="p-2 rounded-lg hover:bg-[var(--admin-card-hover)] transition-colors"
|
||
>
|
||
{Icons.x("h-5 w-5 text-[var(--admin-text-muted)]")}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="p-6 space-y-4">
|
||
{state.error && (
|
||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||
{state.error}
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label htmlFor="fld-1-template-name" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Template Name *</label>
|
||
<input id="fld-1-template-name" aria-label=". Pickup Reminder"
|
||
type="text"
|
||
value={state.name}
|
||
onChange={(e) => dispatch({ type: "SET_NAME", value: e.target.value })}
|
||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||
placeholder="e.g. Pickup Reminder"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="fld-2-template-type" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Template Type *</label>
|
||
<select id="fld-2-template-type" aria-label="Select"
|
||
value={state.templateType}
|
||
onChange={(e) => dispatch({ type: "SET_TEMPLATE_TYPE", value: e.target.value as TemplateType })}
|
||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||
>
|
||
{TEMPLATE_TYPES.map((t) => (
|
||
<option key={t.value} value={t.value}>{t.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
|
||
<button type="button"
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button type="button"
|
||
onClick={handleCreate}
|
||
disabled={state.saving || !state.name.trim()}
|
||
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||
>
|
||
{state.saving ? "Creating..." : "Create Template"}
|
||
{!state.saving && Icons.arrowRight("h-4 w-4")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// TemplateListPanel — separate reducer for its single boolean flag.
|
||
type ListPanelState = { showNewModal: boolean };
|
||
type ListPanelAction = { type: "SHOW_NEW" } | { type: "HIDE_NEW" };
|
||
|
||
function listPanelReducer(_state: ListPanelState, action: ListPanelAction): ListPanelState {
|
||
switch (action.type) {
|
||
case "SHOW_NEW":
|
||
return { showNewModal: true };
|
||
case "HIDE_NEW":
|
||
return { showNewModal: false };
|
||
}
|
||
}
|
||
|
||
export function TemplateListPanel({ templates, brandId = "64294306-5f42-463d-a5e8-2ad6c81a96de" }: { templates: Template[], brandId?: string }) {
|
||
const router = useRouter();
|
||
const [state, dispatch] = useReducer(listPanelReducer, { showNewModal: false });
|
||
|
||
return (
|
||
<div className="p-4 sm:p-6">
|
||
<NewTemplateModal
|
||
isOpen={state.showNewModal}
|
||
onClose={() => dispatch({ type: "HIDE_NEW" })}
|
||
onSuccess={() => {
|
||
dispatch({ type: "HIDE_NEW" });
|
||
router.refresh();
|
||
}}
|
||
brandId={brandId}
|
||
/>
|
||
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
|
||
{Icons.fileText("w-4 h-4 text-[var(--admin-bg)]")}
|
||
</div>
|
||
<div>
|
||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Templates</h2>
|
||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">{templates.length} template{templates.length !== 1 ? "s" : ""}</p>
|
||
</div>
|
||
</div>
|
||
<button type="button"
|
||
onClick={() => dispatch({ type: "SHOW_NEW" })}
|
||
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 transition-colors"
|
||
>
|
||
{Icons.plus("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||
<span>New Template</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Table */}
|
||
{templates.length === 0 ? (
|
||
<div className="text-center py-12 text-[var(--admin-text-muted)]">No templates yet</div>
|
||
) : (
|
||
<TemplatesTable templates={templates} onSelect={(id) => router.push(`/admin/communications/templates/${id}`)} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TemplatesTable({
|
||
templates,
|
||
onSelect,
|
||
}: {
|
||
templates: Template[];
|
||
onSelect: (id: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||
<table className="w-full text-xs sm:text-sm">
|
||
<thead className="bg-[var(--admin-card)]">
|
||
<tr className="border-b border-[var(--admin-border)]">
|
||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Name</th>
|
||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Type</th>
|
||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Subject</th>
|
||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Updated</th>
|
||
<th className="text-right px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]" aria-label="Actions"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||
{templates.map((t) => (
|
||
<tr
|
||
key={t.id}
|
||
className="hover:bg-[var(--admin-card-hover)] cursor-pointer transition-colors"
|
||
onClick={() => onSelect(t.id)}
|
||
>
|
||
<td className="px-3 sm:px-4 py-3 font-medium text-[var(--admin-text-primary)]">{t.name}</td>
|
||
<td className="px-3 sm:px-4 py-3">
|
||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] sm:text-xs font-medium ${TYPE_COLORS[t.template_type] || "bg-stone-100 text-stone-600"}`}>
|
||
{t.template_type}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)] truncate max-w-[200px] sm:max-w-xs">{t.subject}</td>
|
||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{formatDate(new Date(t.updated_at))}</td>
|
||
<td className="px-3 sm:px-4 py-3 text-right">
|
||
{Icons.arrowRight("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// TemplateEditForm — main reducer for the edit/new form.
|
||
type EditState = {
|
||
saving: boolean;
|
||
name: string;
|
||
subject: string;
|
||
bodyText: string;
|
||
bodyHtml: string;
|
||
templateType: TemplateType;
|
||
campaignType: CampaignType | "";
|
||
error: string;
|
||
activeTab: "edit" | "preview";
|
||
previewDevice: "desktop" | "mobile";
|
||
htmlMode: boolean;
|
||
};
|
||
|
||
type EditAction =
|
||
| { type: "SET_SAVING"; value: boolean }
|
||
| { type: "SET_NAME"; value: string }
|
||
| { type: "SET_SUBJECT"; value: string }
|
||
| { type: "SET_BODY_TEXT"; value: string }
|
||
| { type: "SET_BODY_HTML"; value: string }
|
||
| { type: "SET_TEMPLATE_TYPE"; value: TemplateType }
|
||
| { type: "SET_CAMPAIGN_TYPE"; value: CampaignType | "" }
|
||
| { type: "SET_ERROR"; value: string }
|
||
| { type: "SET_ACTIVE_TAB"; value: "edit" | "preview" }
|
||
| { type: "SET_PREVIEW_DEVICE"; value: "desktop" | "mobile" }
|
||
| { type: "SET_HTML_MODE"; value: boolean }
|
||
| { type: "APPLY_BUILT_IN"; tpl: EmailTemplate }
|
||
| { type: "APPEND_BODY_TEXT"; value: string };
|
||
|
||
function editStateFromTemplate(t?: Template): EditState {
|
||
return {
|
||
saving: false,
|
||
name: t?.name ?? "",
|
||
subject: t?.subject ?? "",
|
||
bodyText: t?.body_text ?? "",
|
||
bodyHtml: t?.body_html ?? "",
|
||
templateType: t?.template_type ?? "email_template",
|
||
campaignType: t?.campaign_type ?? "",
|
||
error: "",
|
||
activeTab: "edit",
|
||
previewDevice: "desktop",
|
||
htmlMode: !!t?.body_html,
|
||
};
|
||
}
|
||
|
||
function editReducer(state: EditState, action: EditAction): EditState {
|
||
switch (action.type) {
|
||
case "SET_SAVING":
|
||
return { ...state, saving: action.value };
|
||
case "SET_NAME":
|
||
return { ...state, name: action.value };
|
||
case "SET_SUBJECT":
|
||
return { ...state, subject: action.value };
|
||
case "SET_BODY_TEXT":
|
||
return { ...state, bodyText: action.value };
|
||
case "SET_BODY_HTML":
|
||
return { ...state, bodyHtml: action.value };
|
||
case "SET_TEMPLATE_TYPE":
|
||
return { ...state, templateType: action.value };
|
||
case "SET_CAMPAIGN_TYPE":
|
||
return { ...state, campaignType: action.value };
|
||
case "SET_ERROR":
|
||
return { ...state, error: action.value };
|
||
case "SET_ACTIVE_TAB":
|
||
return { ...state, activeTab: action.value };
|
||
case "SET_PREVIEW_DEVICE":
|
||
return { ...state, previewDevice: action.value };
|
||
case "SET_HTML_MODE":
|
||
return { ...state, htmlMode: action.value };
|
||
case "APPLY_BUILT_IN": {
|
||
const rendered = renderTemplate(action.tpl, SAMPLE_VARS);
|
||
return {
|
||
...state,
|
||
name: action.tpl.name,
|
||
subject: rendered.subject,
|
||
bodyText: rendered.body_text,
|
||
bodyHtml: rendered.body_html,
|
||
htmlMode: true,
|
||
};
|
||
}
|
||
case "APPEND_BODY_TEXT":
|
||
return { ...state, bodyText: state.bodyText + action.value };
|
||
}
|
||
}
|
||
|
||
export function TemplateEditForm({
|
||
template,
|
||
mode = "edit",
|
||
brandId,
|
||
}: {
|
||
template?: Template;
|
||
mode?: "edit" | "new";
|
||
brandId: string;
|
||
}) {
|
||
const router = useRouter();
|
||
const [state, dispatch] = useReducer(editReducer, template, editStateFromTemplate);
|
||
|
||
const insertVariable = useCallback(
|
||
(key: string) => {
|
||
const token = `{{${key}}}`;
|
||
const ta = document.getElementById("body-textarea") as HTMLTextAreaElement;
|
||
if (ta) {
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
const next = state.bodyText.slice(0, start) + token + state.bodyText.slice(end);
|
||
dispatch({ type: "SET_BODY_TEXT", value: next });
|
||
setTimeout(() => {
|
||
ta.focus();
|
||
ta.setSelectionRange(start + token.length, start + token.length);
|
||
}, 0);
|
||
} else {
|
||
dispatch({ type: "APPEND_BODY_TEXT", value: "\n" + token });
|
||
}
|
||
},
|
||
[state.bodyText]
|
||
);
|
||
|
||
const preview = {
|
||
subject: state.subject || "Your Email Subject",
|
||
body_text: state.bodyText,
|
||
body_html: state.bodyHtml || `<p style="white-space:pre-wrap">${state.bodyText}</p>`,
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
dispatch({ type: "SET_SAVING", value: true });
|
||
dispatch({ type: "SET_ERROR", value: "" });
|
||
const result = await upsertTemplate({
|
||
id: template?.id,
|
||
brand_id: brandId,
|
||
name: state.name,
|
||
subject: state.subject,
|
||
body_text: state.bodyText,
|
||
body_html: (state.htmlMode && state.bodyHtml) ? state.bodyHtml : undefined,
|
||
template_type: state.templateType,
|
||
campaign_type: (state.campaignType as CampaignType) || undefined,
|
||
});
|
||
dispatch({ type: "SET_SAVING", value: false });
|
||
if (!result.success) {
|
||
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
|
||
return;
|
||
}
|
||
router.push("/admin/communications/templates");
|
||
router.refresh();
|
||
};
|
||
|
||
return (
|
||
<div className="p-4 sm:p-6 max-w-5xl">
|
||
{/* Header */}
|
||
<TemplateEditHeader
|
||
mode={mode}
|
||
activeTab={state.activeTab}
|
||
onTabChange={(v) => dispatch({ type: "SET_ACTIVE_TAB", value: v })}
|
||
/>
|
||
|
||
{state.error && (
|
||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">{state.error}</div>
|
||
)}
|
||
|
||
{state.activeTab === "edit" ? (
|
||
<EditFormPanel
|
||
state={state}
|
||
onNameChange={(v) => dispatch({ type: "SET_NAME", value: v })}
|
||
onSubjectChange={(v) => dispatch({ type: "SET_SUBJECT", value: v })}
|
||
onBodyTextChange={(v) => dispatch({ type: "SET_BODY_TEXT", value: v })}
|
||
onBodyHtmlChange={(v) => dispatch({ type: "SET_BODY_HTML", value: v })}
|
||
onTemplateTypeChange={(v) => dispatch({ type: "SET_TEMPLATE_TYPE", value: v })}
|
||
onCampaignTypeChange={(v) => dispatch({ type: "SET_CAMPAIGN_TYPE", value: v })}
|
||
onHtmlModeChange={(v) => dispatch({ type: "SET_HTML_MODE", value: v })}
|
||
onApplyBuiltIn={(tpl) => dispatch({ type: "APPLY_BUILT_IN", tpl })}
|
||
onInsertVariable={insertVariable}
|
||
onAppendSubject={(v) => dispatch({ type: "SET_SUBJECT", value: state.subject + v })}
|
||
onSave={handleSave}
|
||
/>
|
||
) : (
|
||
<PreviewPanel
|
||
state={state}
|
||
preview={preview}
|
||
onDeviceChange={(v) => dispatch({ type: "SET_PREVIEW_DEVICE", value: v })}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TemplateEditHeader({
|
||
mode,
|
||
activeTab,
|
||
onTabChange,
|
||
}: {
|
||
mode: "edit" | "new";
|
||
activeTab: "edit" | "preview";
|
||
onTabChange: (v: "edit" | "preview") => void;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
|
||
{Icons.fileText("w-4 h-4 text-[var(--admin-bg)]")}
|
||
</div>
|
||
<div>
|
||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">
|
||
{mode === "new" ? "New Template" : "Edit Template"}
|
||
</h2>
|
||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||
{mode === "new" ? "Create a new email template" : "Update template settings"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => onTabChange("edit")}
|
||
className={`rounded-lg px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
|
||
activeTab === "edit"
|
||
? "bg-emerald-600 text-white"
|
||
: "bg-white border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
|
||
}`}
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onTabChange("preview")}
|
||
className={`rounded-lg px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
|
||
activeTab === "preview"
|
||
? "bg-emerald-600 text-white"
|
||
: "bg-white border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
|
||
}`}
|
||
>
|
||
Preview
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type EditFormPanelProps = {
|
||
state: EditState;
|
||
onNameChange: (v: string) => void;
|
||
onSubjectChange: (v: string) => void;
|
||
onBodyTextChange: (v: string) => void;
|
||
onBodyHtmlChange: (v: string) => void;
|
||
onTemplateTypeChange: (v: TemplateType) => void;
|
||
onCampaignTypeChange: (v: CampaignType | "") => void;
|
||
onHtmlModeChange: (v: boolean) => void;
|
||
onApplyBuiltIn: (tpl: EmailTemplate) => void;
|
||
onInsertVariable: (key: string) => void;
|
||
onAppendSubject: (v: string) => void;
|
||
onSave: () => void;
|
||
};
|
||
|
||
function EditFormPanel({
|
||
state,
|
||
onNameChange,
|
||
onSubjectChange,
|
||
onBodyTextChange,
|
||
onBodyHtmlChange,
|
||
onTemplateTypeChange,
|
||
onCampaignTypeChange,
|
||
onHtmlModeChange,
|
||
onApplyBuiltIn,
|
||
onInsertVariable,
|
||
onAppendSubject,
|
||
onSave,
|
||
}: EditFormPanelProps) {
|
||
return (
|
||
<div className="space-y-4 sm:space-y-6">
|
||
<TemplateInfoSection
|
||
name={state.name}
|
||
templateType={state.templateType}
|
||
campaignType={state.campaignType}
|
||
onNameChange={onNameChange}
|
||
onTemplateTypeChange={onTemplateTypeChange}
|
||
onCampaignTypeChange={onCampaignTypeChange}
|
||
onApplyBuiltIn={onApplyBuiltIn}
|
||
/>
|
||
|
||
<SubjectSection
|
||
subject={state.subject}
|
||
onSubjectChange={onSubjectChange}
|
||
onAppendSubject={onAppendSubject}
|
||
/>
|
||
|
||
<BodySection
|
||
htmlMode={state.htmlMode}
|
||
bodyText={state.bodyText}
|
||
bodyHtml={state.bodyHtml}
|
||
onHtmlModeChange={onHtmlModeChange}
|
||
onBodyTextChange={onBodyTextChange}
|
||
onBodyHtmlChange={onBodyHtmlChange}
|
||
onInsertVariable={onInsertVariable}
|
||
/>
|
||
|
||
<FormActions
|
||
saving={state.saving}
|
||
name={state.name}
|
||
subject={state.subject}
|
||
bodyText={state.bodyText}
|
||
onSave={onSave}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TemplateInfoSection({
|
||
name,
|
||
templateType,
|
||
campaignType,
|
||
onNameChange,
|
||
onTemplateTypeChange,
|
||
onCampaignTypeChange,
|
||
onApplyBuiltIn,
|
||
}: {
|
||
name: string;
|
||
templateType: TemplateType;
|
||
campaignType: CampaignType | "";
|
||
onNameChange: (v: string) => void;
|
||
onTemplateTypeChange: (v: TemplateType) => void;
|
||
onCampaignTypeChange: (v: CampaignType | "") => void;
|
||
onApplyBuiltIn: (tpl: EmailTemplate) => void;
|
||
}) {
|
||
return (
|
||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Template Info</h3>
|
||
{/* Built-in template picker */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Start from:</span>
|
||
<select aria-label="Select"
|
||
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-1.5 bg-white text-[var(--admin-text-primary)]"
|
||
onChange={(e) => {
|
||
const tpl = BUILT_IN_TEMPLATES.find((t) => t.id === e.target.value);
|
||
if (tpl) onApplyBuiltIn(tpl);
|
||
e.target.value = "";
|
||
}}
|
||
defaultValue=""
|
||
>
|
||
<option value="">— Pre-built template —</option>
|
||
{BUILT_IN_TEMPLATES.map((t) => (
|
||
<option key={t.id} value={t.id}>{t.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="fld-3-template-name-2" className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Template Name *</label>
|
||
<input id="fld-3-template-name-2" aria-label=". Pickup Reminder"
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => onNameChange(e.target.value)}
|
||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||
placeholder="e.g. Pickup Reminder"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label htmlFor="fld-4-template-type-2" className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Template Type *</label>
|
||
<select id="fld-4-template-type-2" aria-label="Select"
|
||
value={templateType}
|
||
onChange={(e) => onTemplateTypeChange(e.target.value as TemplateType)}
|
||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||
>
|
||
{TEMPLATE_TYPES.map((t) => (
|
||
<option key={t.value} value={t.value}>{t.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="fld-5-campaign-type" className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Campaign Type</label>
|
||
<select id="fld-5-campaign-type" aria-label="Select"
|
||
value={campaignType}
|
||
onChange={(e) => onCampaignTypeChange(e.target.value as CampaignType)}
|
||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||
>
|
||
<option value="">Any</option>
|
||
{CAMPAIGN_TYPES.map((t) => (
|
||
<option key={t.value} value={t.value}>{t.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SubjectSection({
|
||
subject,
|
||
onSubjectChange,
|
||
onAppendSubject,
|
||
}: {
|
||
subject: string;
|
||
onSubjectChange: (v: string) => void;
|
||
onAppendSubject: (v: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
|
||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Subject Line</h3>
|
||
<div>
|
||
<label htmlFor="fld-6-subject" className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Subject *</label>
|
||
<input id="fld-6-subject" aria-label="Email Subject Line"
|
||
type="text"
|
||
value={subject}
|
||
onChange={(e) => onSubjectChange(e.target.value)}
|
||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||
placeholder="Email subject line"
|
||
/>
|
||
<p className="mt-1 text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||
Variables:{" "}
|
||
{TEMPLATE_VARIABLES.slice(0, 6).map((v) => (
|
||
<button
|
||
key={v.key}
|
||
type="button"
|
||
onClick={() => onAppendSubject(`{{${v.key}}}`)}
|
||
className="ml-1 text-emerald-600 hover:text-emerald-700 font-medium"
|
||
>
|
||
{`{{${v.key}}}`}
|
||
</button>
|
||
))}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BodySection({
|
||
htmlMode,
|
||
bodyText,
|
||
bodyHtml,
|
||
onHtmlModeChange,
|
||
onBodyTextChange,
|
||
onBodyHtmlChange,
|
||
onInsertVariable,
|
||
}: {
|
||
htmlMode: boolean;
|
||
bodyText: string;
|
||
bodyHtml: string;
|
||
onHtmlModeChange: (v: boolean) => void;
|
||
onBodyTextChange: (v: string) => void;
|
||
onBodyHtmlChange: (v: string) => void;
|
||
onInsertVariable: (key: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Message Body</h3>
|
||
<label className="flex items-center gap-2 text-xs sm:text-sm" htmlFor="fld-sethtmlmodeetargetchecked-classnameround">
|
||
<input
|
||
type="checkbox"
|
||
checked={htmlMode}
|
||
onChange={(e) => onHtmlModeChange(e.target.checked)}
|
||
className="rounded border-[var(--admin-border)] text-emerald-600"
|
||
/>
|
||
<span className="text-[var(--admin-text-muted)]">HTML mode</span>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Variable toolbar */}
|
||
<div className="flex flex-wrap gap-1.5">
|
||
<span className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] self-center mr-1">Insert:</span>
|
||
{TEMPLATE_VARIABLES.map((v) => (
|
||
<button
|
||
key={v.key}
|
||
type="button"
|
||
title={`${v.label}: ${v.example}`}
|
||
onClick={() => onInsertVariable(v.key)}
|
||
className="rounded bg-[var(--admin-card)] border border-[var(--admin-border)] px-2 py-1 text-[10px] sm:text-xs text-[var(--admin-text-muted)] hover:bg-emerald-50 hover:text-emerald-700 hover:border-emerald-200 transition-colors"
|
||
>
|
||
{`{{${v.key}}}`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{htmlMode ? (
|
||
<HtmlBodyEditors
|
||
bodyText={bodyText}
|
||
bodyHtml={bodyHtml}
|
||
onBodyTextChange={onBodyTextChange}
|
||
onBodyHtmlChange={onBodyHtmlChange}
|
||
/>
|
||
) : (
|
||
<PlainBodyEditor bodyText={bodyText} onBodyTextChange={onBodyTextChange} />
|
||
)}
|
||
|
||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||
Tip: Click any variable button above to insert it at the cursor position. Variables are replaced when the email is sent.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HtmlBodyEditors({
|
||
bodyText,
|
||
bodyHtml,
|
||
onBodyTextChange,
|
||
onBodyHtmlChange,
|
||
}: {
|
||
bodyText: string;
|
||
bodyHtml: string;
|
||
onBodyTextChange: (v: string) => void;
|
||
onBodyHtmlChange: (v: string) => void;
|
||
}) {
|
||
return (
|
||
<>
|
||
<div>
|
||
<label htmlFor="fld-7-html-body" className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">HTML Body</label>
|
||
<textarea id="fld-7-html-body" aria-label="<p>HTML Version With Variables Like {{first Name}}...</p>"
|
||
value={bodyHtml}
|
||
onChange={(e) => onBodyHtmlChange(e.target.value)}
|
||
rows={12}
|
||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm font-mono text-xs bg-white text-[var(--admin-text-primary)]"
|
||
placeholder="<p>HTML version with variables like {{first_name}}...</p>"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5" htmlFor="fld-plain-text-fallback-">Plain Text Fallback *</label>
|
||
<textarea aria-label="Plain Text Version..."
|
||
id="body-textarea"
|
||
value={bodyText}
|
||
onChange={(e) => onBodyTextChange(e.target.value)}
|
||
rows={6}
|
||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||
placeholder="Plain text version..."
|
||
/>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function PlainBodyEditor({
|
||
bodyText,
|
||
onBodyTextChange,
|
||
}: {
|
||
bodyText: string;
|
||
onBodyTextChange: (v: string) => void;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5" htmlFor="fld-body-plain-text-">Body (Plain Text) *</label>
|
||
<textarea aria-label="Message Body With Variables Like {{first Name}}..."
|
||
id="body-textarea"
|
||
value={bodyText}
|
||
onChange={(e) => onBodyTextChange(e.target.value)}
|
||
rows={10}
|
||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||
placeholder="Message body with variables like {{first_name}}..."
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FormActions({
|
||
saving,
|
||
name,
|
||
subject,
|
||
bodyText,
|
||
onSave,
|
||
}: {
|
||
saving: boolean;
|
||
name: string;
|
||
subject: string;
|
||
bodyText: string;
|
||
onSave: () => void;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onSave}
|
||
disabled={saving || !name || !subject || !bodyText}
|
||
className="inline-flex items-center rounded-lg bg-emerald-600 px-4 sm:px-5 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||
>
|
||
{saving ? "Saving..." : "Save Template"}
|
||
</button>
|
||
<Link href="/admin/communications/templates" className="text-xs sm:text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] ml-4">
|
||
Cancel
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PreviewPanel({
|
||
state,
|
||
preview,
|
||
onDeviceChange,
|
||
}: {
|
||
state: EditState;
|
||
preview: { subject: string; body_text: string; body_html: string };
|
||
onDeviceChange: (v: "desktop" | "mobile") => void;
|
||
}) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs sm:text-sm text-[var(--admin-text-muted)]">Preview:</span>
|
||
<div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden">
|
||
<button type="button"
|
||
onClick={() => onDeviceChange("desktop")}
|
||
className={`px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
|
||
state.previewDevice === "desktop"
|
||
? "bg-emerald-600 text-white"
|
||
: "bg-white text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
|
||
}`}
|
||
>
|
||
Desktop
|
||
</button>
|
||
<button type="button"
|
||
onClick={() => onDeviceChange("mobile")}
|
||
className={`px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
|
||
state.previewDevice === "mobile"
|
||
? "bg-emerald-600 text-white"
|
||
: "bg-white text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
|
||
}`}
|
||
>
|
||
Mobile
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Email preview */}
|
||
<div className={`mx-auto transition-all ${state.previewDevice === "mobile" ? "max-w-[375px]" : "max-w-[600px]"}`}>
|
||
<div className="rounded-xl border border-[var(--admin-border)] bg-white shadow-lg overflow-hidden">
|
||
{/* Mock email header */}
|
||
<div className="bg-[var(--admin-card)] border-b border-[var(--admin-border)] px-4 py-2 flex items-center gap-2">
|
||
<div className="flex gap-1.5">
|
||
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||
<div className="w-3 h-3 rounded-full bg-emerald-400" />
|
||
</div>
|
||
<div className="flex-1 text-center text-xs text-[var(--admin-text-muted)] truncate">
|
||
{preview.subject}
|
||
</div>
|
||
</div>
|
||
{/* Email body — render via sandboxed iframe so any template HTML
|
||
can never run scripts, submit forms, or break out into the
|
||
parent document. The `sandbox` attribute (with an empty
|
||
value) disables scripts, forms, popups, top-level
|
||
navigation, and same-origin treatment by default. */}
|
||
<iframe
|
||
title="Email body preview"
|
||
sandbox=""
|
||
srcDoc={preview.body_html}
|
||
className="w-full border-0 overflow-hidden"
|
||
style={{ height: 480 }}
|
||
/>
|
||
{/* Plain text toggle */}
|
||
{state.bodyText && (
|
||
<details className="border-t border-[var(--admin-border)]">
|
||
<summary className="px-4 py-2 text-xs text-[var(--admin-text-muted)] cursor-pointer hover:text-[var(--admin-text-primary)]">
|
||
View plain text version
|
||
</summary>
|
||
<pre className="px-4 pb-4 text-xs text-[var(--admin-text-muted)] whitespace-pre-wrap font-mono">{preview.body_text}</pre>
|
||
</details>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|