Files
route-commerce/src/components/admin/TemplateEditForm.tsx
T
Tyler fe78645609 perf: optimize admin pages for <50ms TTFB
- 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
2026-06-26 18:55:46 -06:00

1011 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}