EventBuzz_Console/src/app/components/panels/CreateEmailerPanel.tsx

932 lines
38 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.

/**
* CreateEmailerPanel — Create/Edit Emailer. Persists via design-templates API.
*/
import React, { useState, useEffect, useCallback } from "react";
import { Plus, X, AlignLeft, Image as ImageIcon, MousePointer, Minus } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
import {
PanelShell, AccordionSection, useAccordion,
Label, Field, Grid, SelectWrap, HelperText,
IS, SS, focusIn, focusOut, SectionLabel, Divider,
} from "./PanelShell";
import {
designTemplatesApi,
eventsApi,
portfolioListOrganizationId,
uploadMediaFile,
campaignsApi,
participantListsApi,
} from "../../../services/api";
import type { Event as ApiEvent, ParticipantListRecord } from "../../../services/api";
import { RichEmailEditor } from "../messaging/RichEmailEditor";
import { EMAIL_PLACEHOLDERS } from "../messaging/mergePlaceholders";
const CAMPAIGN_TYPES = ["Invitation", "Save the Date", "Registration Confirmation", "Reminder", "Post-Event Thank You", "Newsletter", "Custom"];
const FONT_PAIRS = ["Inter + Noto Sans", "Merriweather + Open Sans", "Poppins + Lato", "Georgia + Source Sans"];
const LAYOUTS = [
{ id: "e1", label: "Classic", accent: "#0B4DD1" },
{ id: "e2", label: "Bold Hero", accent: "#1E293B" },
{ id: "e3", label: "Minimal", accent: "#F1F5F9" },
{ id: "e4", label: "Magazine", accent: "#D97706" },
];
const FONT_STACK: Record<string, string> = {
"Inter + Noto Sans": "system-ui, -apple-system, 'Segoe UI', sans-serif",
"Merriweather + Open Sans": "Georgia, 'Merriweather', serif",
"Poppins + Lato": "'Poppins', 'Lato', system-ui, sans-serif",
"Georgia + Source Sans": "Georgia, 'Source Sans Pro', sans-serif",
};
type LinkKind = "form" | "microsite" | "custom";
export type EmailerContentBlock =
| { id: string; type: "text"; html: string }
| { id: string; type: "cta"; label: string; linkKind: LinkKind; formId?: string; micrositeId?: string; customUrl?: string }
| { id: string; type: "image"; src: string }
| { id: string; type: "divider" };
const CONTENT_BLOCK_TYPES: { type: EmailerContentBlock["type"]; icon: React.ReactNode; label: string }[] = [
{ type: "text", icon: <AlignLeft size={13} />, label: "Text" },
{ type: "image", icon: <ImageIcon size={13} />, label: "Image" },
{ type: "cta", icon: <MousePointer size={13} />, label: "CTA Button" },
{ type: "divider", icon: <Minus size={13} />, label: "Divider" },
];
interface EForm {
name: string;
emailerType: string;
associatedEvent: string;
description: string;
layoutTemplateId: string;
headerImageUrl: string;
primaryColor: string;
fontPair: string;
previewText: string;
subjectLine: string;
blocks: EmailerContentBlock[];
footerText: string;
}
let blockCounter = 0;
function makeBlock(type: EmailerContentBlock["type"]): EmailerContentBlock {
const id = `blk-${++blockCounter}`;
if (type === "text") return { id, type: "text", html: "<p><br></p>" };
if (type === "cta") return { id, type: "cta", label: "Register Now", linkKind: "custom", customUrl: "" };
if (type === "image") return { id, type: "image", src: "" };
return { id, type: "divider" };
}
function escapeHtml(s: string) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function publicFormHref(formId: string, eventId?: string): string {
const fid = String(formId || "").trim();
if (!fid) return "";
const base =
(typeof import.meta.env.VITE_PUBLIC_WEB_BASE === "string" && import.meta.env.VITE_PUBLIC_WEB_BASE.trim()) ||
(typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === "string" && import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim()) ||
(typeof window !== "undefined" ? window.location.origin : "http://localhost:5173");
const root = base.replace(/\/$/, "");
const eid = eventId != null ? String(eventId).trim() : "";
if (eid) return `${root}/event/${eid}/form/${fid}`;
return `${root}/form/${fid}`;
}
function resolveCtaHref(
b: Extract<EmailerContentBlock, { type: "cta" }>,
targets: { forms: { id: string; href: string }[]; microsites: { id: string; href: string }[] },
): string {
if (b.linkKind === "custom") return String(b.customUrl || "").trim() || "#";
if (b.linkKind === "form") {
const f = targets.forms.find((x) => x.id === b.formId);
const href = (f?.href && f.href.trim()) || (b.formId ? publicFormHref(b.formId) : "");
return href || "#";
}
if (b.linkKind === "microsite") {
const m = targets.microsites.find((x) => x.id === b.micrositeId);
return (m?.href && m.href.trim()) || "#";
}
return "#";
}
function buildEmailerHtml(
form: EForm,
targets: { forms: { id: string; href: string }[]; microsites: { id: string; href: string }[] },
): string {
const accent = form.primaryColor?.trim() || LAYOUTS.find((t) => t.id === form.layoutTemplateId)?.accent || "#0B4DD1";
const font = FONT_STACK[form.fontPair] || FONT_STACK[FONT_PAIRS[0]];
const headerImg = form.headerImageUrl?.trim();
const blocksHtml = form.blocks
.map((blk) => {
if (blk.type === "text") {
const inner = blk.html?.trim() ? blk.html : "";
return `<div style="margin:12px 0;font-size:15px;line-height:1.55;color:#334155;">${inner}</div>`;
}
if (blk.type === "image" && blk.src?.trim()) {
return `<p style="text-align:center;margin:16px 0;"><img src="${escapeHtml(blk.src.trim())}" alt="" style="max-width:100%;border-radius:8px;" /></p>`;
}
if (blk.type === "cta") {
const url = escapeHtml(resolveCtaHref(blk, targets));
const label = escapeHtml(blk.label || "Register");
return `<p style="text-align:center;margin:20px 0;"><a href="${url}" style="display:inline-block;padding:12px 24px;background:${escapeHtml(accent)};color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">${label}</a></p>`;
}
if (blk.type === "divider") return `<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;" />`;
return "";
})
.join("\n");
const pre = form.previewText?.trim();
const preHidden = pre
? `<div style="display:none;font-size:1px;color:#fff;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">${escapeHtml(pre)}</div>`
: "";
return `<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/></head>
<body style="margin:0;padding:0;background:#f8fafc;font-family:${font};">
${preHidden}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);">
${headerImg ? `<tr><td><img src="${escapeHtml(headerImg)}" alt="" width="600" style="display:block;width:100%;height:auto;"/></td></tr>` : ""}
<tr><td style="padding:24px 28px 8px;">
${pre ? `<p style="margin:0 0 8px;font-size:12px;color:#64748b;">${escapeHtml(pre)}</p>` : ""}
</td></tr>
<tr><td style="padding:0 28px 24px;">${blocksHtml}</td></tr>
<tr><td style="padding:16px 28px 24px;border-top:1px solid #f1f5f9;">
<p style="margin:0;font-size:12px;color:#94a3b8;">${escapeHtml(form.footerText)}</p>
</td></tr>
</table>
</td></tr></table>
</body></html>`;
}
function migrateBlocksFromContent(c: Record<string, unknown>): EmailerContentBlock[] {
const raw = c.blocks;
const legacyCtaUrl = typeof c.ctaUrl === "string" ? c.ctaUrl : "";
let headerPrefix = "";
if (typeof c.headerText === "string" && c.headerText.trim()) {
headerPrefix = `<h1 style="margin:0 0 16px;font-size:22px;color:#0f172a;">${escapeHtml(c.headerText.trim())}</h1>`;
}
if (!Array.isArray(raw) || raw.length === 0) {
const t = makeBlock("text");
const tb = t.type === "text" ? { ...t, html: headerPrefix + t.html } : t;
return [tb, makeBlock("cta")];
}
const out: EmailerContentBlock[] = [];
for (let i = 0; i < raw.length; i++) {
const b = raw[i] as Record<string, unknown>;
const id = typeof b.id === "string" ? b.id : `blk-${i}-${Date.now()}`;
if (b.type === "text") {
let html = typeof b.html === "string" ? b.html : "";
if (!html && typeof b.text === "string") {
html = `<p style="margin:12px 0;font-size:15px;line-height:1.5;color:#334155;">${escapeHtml(String(b.text)).replace(/\n/g, "<br/>")}</p>`;
}
if (i === 0 && headerPrefix) html = headerPrefix + (html || "");
out.push({ id, type: "text", html: html || "<p><br></p>" });
} else if (b.type === "cta") {
out.push({
id,
type: "cta",
label: String(b.label ?? b.text ?? "Register"),
linkKind: (["form", "microsite", "custom"].includes(String(b.linkKind)) ? b.linkKind : "custom") as LinkKind,
formId: typeof b.formId === "string" ? b.formId : undefined,
micrositeId: typeof b.micrositeId === "string" ? b.micrositeId : undefined,
customUrl: String(b.customUrl ?? legacyCtaUrl ?? ""),
});
} else if (b.type === "image") {
const src = typeof b.src === "string" ? b.src : String(b.text ?? "");
out.push({ id, type: "image", src });
} else if (b.type === "divider") {
out.push({ id, type: "divider" });
}
}
const maxId = out.map((x) => parseInt(String(x.id).replace(/\D/g, ""), 10) || 0);
blockCounter = Math.max(blockCounter, ...maxId, 0);
return out.length ? out : [makeBlock("text"), makeBlock("cta")];
}
export function CreateEmailerPanel({
onClose,
templateId,
onSuccess,
}: {
onClose: () => void;
templateId?: string;
onSuccess?: () => void;
}) {
const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user);
const isEdit = !!templateId;
const TOTAL = 3;
const acc = useAccordion(TOTAL);
const [events, setEvents] = useState<ApiEvent[]>([]);
const [linkTargets, setLinkTargets] = useState<{ forms: { id: string; name: string; href: string }[]; microsites: { id: string; name: string; href: string }[] }>({ forms: [], microsites: [] });
const [lists, setLists] = useState<ParticipantListRecord[]>([]);
const [form, setForm] = useState<EForm>({
name: "",
emailerType: CAMPAIGN_TYPES[0],
associatedEvent: "",
description: "",
layoutTemplateId: "e1",
headerImageUrl: "",
primaryColor: "#0B4DD1",
fontPair: FONT_PAIRS[0],
previewText: "",
subjectLine: "",
blocks: [makeBlock("text"), makeBlock("cta")],
footerText: "© 2026 EventBuzz · Unsubscribe",
});
const [submitting, setSubmitting] = useState(false);
const [uploadingHeader, setUploadingHeader] = useState(false);
const [uploadingBlockId, setUploadingBlockId] = useState<string | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewHtml, setPreviewHtml] = useState("");
const [createCampaign, setCreateCampaign] = useState(false);
const [campaignEventId, setCampaignEventId] = useState("");
const [participantListId, setParticipantListId] = useState("");
const [campaignErr, setCampaignErr] = useState("");
const set = useCallback(<K extends keyof EForm>(k: K, v: EForm[K]) => {
setForm((p) => ({ ...p, [k]: v }));
}, []);
useEffect(() => {
if (!listOrgId) return;
eventsApi.list({ limit: 200, organization: listOrgId }).then((res) => {
if (res.data?.events) setEvents(res.data.events);
});
participantListsApi.list({ organization: listOrgId }).then((r) => {
if (r.data?.lists) setLists(r.data.lists);
});
}, [listOrgId]);
useEffect(() => {
const eid = form.associatedEvent?.trim();
if (!eid) {
setLinkTargets({ forms: [], microsites: [] });
return;
}
void eventsApi.emailerLinkTargets(eid).then((r) => {
if (r.data) setLinkTargets(r.data);
});
}, [form.associatedEvent]);
useEffect(() => {
if (form.associatedEvent && !campaignEventId) setCampaignEventId(form.associatedEvent);
}, [form.associatedEvent, campaignEventId]);
useEffect(() => {
if (!templateId) return;
void designTemplatesApi.get(templateId).then((res) => {
if (!res.data) return;
const t = res.data;
const c = (t.content || {}) as Record<string, unknown>;
const migrated = migrateBlocksFromContent(c);
const rawAssoc = (t as { associatedEvent?: string | { _id?: string } }).associatedEvent;
const assoc = typeof rawAssoc === "string" ? rawAssoc : rawAssoc?._id ?? "";
setForm((p) => ({
...p,
name: t.name,
emailerType: t.category || CAMPAIGN_TYPES[0],
associatedEvent: assoc,
description: typeof c.description === "string" ? c.description : "",
layoutTemplateId: String(c.layoutTemplateId || c.templateId || "e1"),
headerImageUrl: String(c.headerImageUrl || ""),
primaryColor: String(c.primaryColor || LAYOUTS.find((x) => x.id === c.templateId)?.accent || "#0B4DD1"),
fontPair: FONT_PAIRS.includes(String(c.fontPair)) ? String(c.fontPair) : FONT_PAIRS[0],
previewText: String(c.previewText || ""),
subjectLine: String(c.subjectLine || c.subject || c.headerText || ""),
footerText: String(c.footerText || p.footerText),
blocks: migrated,
}));
});
}, [templateId]);
const updateBlock = useCallback((id: string, patch: Partial<EmailerContentBlock>) => {
setForm((p) => ({
...p,
blocks: p.blocks.map((b) => (b.id === id ? ({ ...b, ...patch } as EmailerContentBlock) : b)),
}));
}, []);
const addBlock = useCallback((type: EmailerContentBlock["type"]) => {
setForm((p) => ({ ...p, blocks: [...p.blocks, makeBlock(type)] }));
}, []);
const removeBlock = useCallback((id: string) => {
setForm((p) => ({ ...p, blocks: p.blocks.filter((b) => b.id !== id) }));
}, []);
const openPreview = useCallback(() => {
setPreviewHtml(buildEmailerHtml(form, linkTargets));
setPreviewOpen(true);
}, [form, linkTargets]);
const handleUploadHeader = async (file: File | null) => {
if (!file || !listOrgId) return;
setUploadingHeader(true);
try {
const r = await uploadMediaFile(file, {
eventId: form.associatedEvent?.trim() || undefined,
module: "emailer",
});
if (r.url) set("headerImageUrl", r.url);
} finally {
setUploadingHeader(false);
}
};
const handleUploadImageBlock = async (blockId: string, file: File | null) => {
if (!file || !listOrgId) return;
setUploadingBlockId(blockId);
try {
const r = await uploadMediaFile(file, {
eventId: form.associatedEvent?.trim() || undefined,
module: "emailer",
});
if (r.url) updateBlock(blockId, { src: r.url } as Partial<EmailerContentBlock>);
} finally {
setUploadingBlockId(null);
}
};
const insertTokenInTextBlock = (blockId: string, token: string) => {
const b = form.blocks.find((x) => x.id === blockId);
if (!b || b.type !== "text") return;
const html = (b.html || "") + token;
updateBlock(blockId, { html } as Partial<EmailerContentBlock>);
};
const handleSubmit = async () => {
setCampaignErr("");
if (createCampaign && !isEdit) {
const ev = campaignEventId.trim() || form.associatedEvent.trim();
if (!ev) {
setCampaignErr("Select an event for the draft campaign.");
return;
}
if (!participantListId.trim()) {
setCampaignErr("Select a contact list.");
return;
}
}
setSubmitting(true);
const html = buildEmailerHtml(form, linkTargets);
const content = {
html,
version: 2,
blocks: form.blocks,
subjectLine: form.subjectLine,
previewText: form.previewText,
footerText: form.footerText,
primaryColor: form.primaryColor,
fontPair: form.fontPair,
headerImageUrl: form.headerImageUrl,
layoutTemplateId: form.layoutTemplateId,
description: form.description || undefined,
};
const payload = {
name: form.name,
type: "emailer" as const,
category: form.emailerType || undefined,
content,
associatedEvent: form.associatedEvent.trim() || null,
};
const res = isEdit ? await designTemplatesApi.update(templateId!, payload) : await designTemplatesApi.create(payload);
setSubmitting(false);
if (res.error) return;
const saved = res.data as { _id?: string } | undefined;
const savedId = saved?._id || templateId;
if (createCampaign && !isEdit && savedId) {
const ev = campaignEventId.trim() || form.associatedEvent.trim();
const cr = await campaignsApi.create(ev, {
name: form.name.trim(),
channel: "email",
type: "broadcast",
recipients: { type: "participant_list", participantList: participantListId.trim() },
content: {
template: savedId,
subject: form.subjectLine.trim(),
body: "",
},
});
if (cr.error) {
const msg = typeof cr.error === "string" ? cr.error : "Template saved but campaign could not be created.";
setCampaignErr(msg);
onSuccess?.();
return;
}
}
onSuccess?.();
onClose();
};
return (
<>
<PanelShell
onClose={onClose}
title={isEdit ? "Edit Emailer" : "Create Emailer"}
breadcrumb="Design · Emailers"
description="Design a branded HTML emailer and optional draft campaign."
completedCount={acc.done.size}
totalSections={TOTAL}
ctaLabel={isEdit ? "Save changes" : "Save emailer"}
ctaDisabled={submitting || !form.name.trim()}
onCtaClick={handleSubmit}
>
<AccordionSection
n={1}
title="Emailer details"
subtitle="Name, type, optional event"
open={acc.open === 1}
completed={acc.done.has(1)}
onToggle={() => acc.toggle(1)}
onSave={() => acc.advance(1)}
nextLabel="Design"
>
<Field>
<Label text="Emailer name" required />
<input
style={IS}
placeholder="e.g. NAPCON 2026 — Registration invite"
value={form.name}
onChange={(e) => set("name", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<HelperText text="Internal name — not shown to recipients." />
</Field>
<Grid cols={2}>
<Field>
<Label text="Type" required />
<SelectWrap>
<select style={SS} value={form.emailerType} onChange={(e) => set("emailerType", e.target.value)} onFocus={focusIn} onBlur={focusOut}>
{CAMPAIGN_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</SelectWrap>
</Field>
<Field>
<Label text="Associated event" />
<SelectWrap>
<select style={SS} value={form.associatedEvent} onChange={(e) => set("associatedEvent", e.target.value)} onFocus={focusIn} onBlur={focusOut}>
<option value="">None</option>
{events.map((ev) => (
<option key={ev._id} value={ev._id}>
{ev.name}
</option>
))}
</select>
</SelectWrap>
<HelperText text="Used for media uploads, form/microsite CTAs, and default campaign event." />
</Field>
</Grid>
<Field>
<Label text="Description" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "60px" }}
placeholder="Optional notes (stored in template content)…"
value={form.description}
onChange={(e) => set("description", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
</AccordionSection>
<AccordionSection
n={2}
title="Design"
subtitle="Layout, colour, font, header image"
open={acc.open === 2}
completed={acc.done.has(2)}
onToggle={() => acc.toggle(2)}
onSave={() => acc.advance(2)}
nextLabel="Content"
>
<Field>
<Label text="Base template" required />
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "8px" }}>
{LAYOUTS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => {
set("layoutTemplateId", t.id);
set("primaryColor", t.accent);
}}
style={{
borderRadius: "var(--radius-button)",
border: `2px solid ${form.layoutTemplateId === t.id ? "var(--primary)" : "var(--border)"}`,
overflow: "hidden",
cursor: "pointer",
background: "none",
padding: 0,
}}
>
<div style={{ height: "56px", background: t.accent, display: "flex", flexDirection: "column", justifyContent: "flex-end", padding: "6px" }}>
<div style={{ height: "4px", borderRadius: "2px", background: "rgba(255,255,255,0.5)", marginBottom: "3px" }} />
<div style={{ height: "3px", borderRadius: "2px", background: "rgba(255,255,255,0.3)" }} />
</div>
<p style={{ margin: "5px 0", fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: form.layoutTemplateId === t.id ? "var(--primary)" : "var(--muted)" }}>{t.label}</p>
</button>
))}
</div>
</Field>
<Grid cols={2}>
<Field>
<Label text="Primary colour" />
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "6px 10px", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", background: "var(--input-background)" }}>
<input
type="color"
value={form.primaryColor}
onChange={(e) => set("primaryColor", e.target.value)}
style={{ width: "24px", height: "24px", border: "none", background: "none", cursor: "pointer", padding: 0 }}
/>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{form.primaryColor}</span>
</div>
</Field>
<Field>
<Label text="Font" />
<SelectWrap>
<select style={SS} value={form.fontPair} onChange={(e) => set("fontPair", e.target.value)} onFocus={focusIn} onBlur={focusOut}>
{FONT_PAIRS.map((fp) => (
<option key={fp} value={fp}>
{fp}
</option>
))}
</select>
</SelectWrap>
</Field>
</Grid>
<Field>
<Label text="Header image" />
<input
style={IS}
placeholder="https://…"
value={form.headerImageUrl}
onChange={(e) => set("headerImageUrl", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<div style={{ marginTop: 8 }}>
<input
type="file"
accept="image/*"
disabled={uploadingHeader || !listOrgId}
onChange={(e) => void handleUploadHeader(e.target.files?.[0] ?? null)}
/>
{uploadingHeader && <span style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginLeft: 8 }}>Uploading…</span>}
</div>
<HelperText text="Recommended: 600×200px. Uses org library upload." />
</Field>
</AccordionSection>
<AccordionSection
n={3}
title="Content blocks"
subtitle="Preview text, subject, body"
open={acc.open === 3}
completed={acc.done.has(3)}
onToggle={() => acc.toggle(3)}
onSave={() => acc.advance(3)}
nextLabel="Done"
>
<Grid cols={2}>
<Field span2>
<Label text="Preview text" />
<input
style={IS}
placeholder="Short preview shown in inbox…"
value={form.previewText}
onChange={(e) => set("previewText", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<HelperText text={`${form.previewText.length}/90 chars`} />
</Field>
<Field span2>
<Label text="Subject line" />
<input
style={IS}
placeholder="Supports {{event.name}} etc."
value={form.subjectLine}
onChange={(e) => set("subjectLine", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
</Grid>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 10 }}>
{EMAIL_PLACEHOLDERS.map((p) => (
<button key={p.token} type="button" onClick={() => set("subjectLine", form.subjectLine + p.token)} style={chipBtn}>
{p.label}
</button>
))}
</div>
<Divider />
<SectionLabel text="Body blocks" />
{!form.associatedEvent.trim() && (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px" }}>
Select an associated event to load forms and microsites for CTA buttons.
</p>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{form.blocks.map((blk, idx) => (
<div key={blk.id} style={{ border: "1px solid var(--border)", borderRadius: "var(--radius-button)", overflow: "hidden", background: "var(--card)" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px 12px",
background: "var(--secondary)",
borderBottom: blk.type !== "divider" ? "1px solid var(--border)" : "transparent",
}}
>
<span style={{ color: "var(--muted)", display: "flex" }}>{CONTENT_BLOCK_TYPES.find((t) => t.type === blk.type)?.icon}</span>
<span style={{ flex: 1, fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--card-foreground)" }}>
{CONTENT_BLOCK_TYPES.find((t) => t.type === blk.type)?.label} #{idx + 1}
</span>
<button
type="button"
onClick={() => removeBlock(blk.id)}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "2px" }}
>
<X size={12} />
</button>
</div>
{blk.type === "text" && (
<div style={{ padding: "10px 12px" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 8 }}>
{EMAIL_PLACEHOLDERS.map((p) => (
<button key={p.token} type="button" onClick={() => insertTokenInTextBlock(blk.id, p.token)} style={chipBtn}>
{p.label}
</button>
))}
</div>
<RichEmailEditor value={blk.html} onChange={(html) => updateBlock(blk.id, { html } as Partial<EmailerContentBlock>)} placeholder="Compose…" />
</div>
)}
{blk.type === "cta" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8, padding: "10px 12px" }}>
<input style={IS} placeholder="Button label" value={blk.label} onChange={(e) => updateBlock(blk.id, { label: e.target.value } as Partial<EmailerContentBlock>)} onFocus={focusIn} onBlur={focusOut} />
<SelectWrap>
<select
style={SS}
value={blk.linkKind}
onChange={(e) =>
updateBlock(blk.id, {
linkKind: e.target.value as LinkKind,
formId: undefined,
micrositeId: undefined,
} as Partial<EmailerContentBlock>)
}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="form">Form</option>
<option value="microsite">Event microsite</option>
<option value="custom">Custom URL</option>
</select>
</SelectWrap>
{blk.linkKind === "form" && (
<SelectWrap>
<select
style={SS}
value={blk.formId || ""}
onChange={(e) => updateBlock(blk.id, { formId: e.target.value || undefined } as Partial<EmailerContentBlock>)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">Select form…</option>
{linkTargets.forms.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
{!f.href ? " (configure registration URL)" : ""}
</option>
))}
</select>
</SelectWrap>
)}
{blk.linkKind === "microsite" && (
<SelectWrap>
<select
style={SS}
value={blk.micrositeId || ""}
onChange={(e) => updateBlock(blk.id, { micrositeId: e.target.value || undefined } as Partial<EmailerContentBlock>)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">Select microsite…</option>
{linkTargets.microsites.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</SelectWrap>
)}
{blk.linkKind === "custom" && (
<input
style={IS}
placeholder="https://…"
value={blk.customUrl || ""}
onChange={(e) => updateBlock(blk.id, { customUrl: e.target.value } as Partial<EmailerContentBlock>)}
onFocus={focusIn}
onBlur={focusOut}
/>
)}
</div>
)}
{blk.type === "image" && (
<div style={{ padding: "10px 12px" }}>
<input style={IS} placeholder="Image URL" value={blk.src} onChange={(e) => updateBlock(blk.id, { src: e.target.value } as Partial<EmailerContentBlock>)} onFocus={focusIn} onBlur={focusOut} />
<div style={{ marginTop: 8 }}>
<input
type="file"
accept="image/*"
disabled={uploadingBlockId === blk.id || !listOrgId}
onChange={(e) => void handleUploadImageBlock(blk.id, e.target.files?.[0] ?? null)}
/>
</div>
</div>
)}
{blk.type === "divider" && (
<div style={{ padding: "10px 12px" }}>
<div style={{ height: "1px", background: "var(--border)" }} />
</div>
)}
</div>
))}
</div>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap", marginTop: 8 }}>
{CONTENT_BLOCK_TYPES.map((bt) => (
<button key={bt.type} type="button" onClick={() => addBlock(bt.type)} style={addBlockBtn}>
<Plus size={11} /> {bt.label}
</button>
))}
</div>
<Field>
<Label text="Footer text" />
<input style={IS} value={form.footerText} onChange={(e) => set("footerText", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
</AccordionSection>
<div
style={{
padding: "16px 20px 24px",
borderTop: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
{campaignErr && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: 0 }}>{campaignErr}</p>}
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<button type="button" onClick={openPreview} style={btnSecondary}>
Preview emailer
</button>
</div>
{!isEdit && (
<>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
<input type="checkbox" checked={createCampaign} onChange={(e) => setCreateCampaign(e.target.checked)} />
Create a campaign (draft)
</label>
{createCampaign && (
<Grid cols={2}>
<Field>
<Label text="Campaign event" required />
<SelectWrap>
<select style={SS} value={campaignEventId} onChange={(e) => setCampaignEventId(e.target.value)} onFocus={focusIn} onBlur={focusOut}>
<option value="">Select…</option>
{events.map((ev) => (
<option key={ev._id} value={ev._id}>
{ev.name}
</option>
))}
</select>
</SelectWrap>
</Field>
<Field>
<Label text="Contact list" required />
<SelectWrap>
<select style={SS} value={participantListId} onChange={(e) => setParticipantListId(e.target.value)} onFocus={focusIn} onBlur={focusOut}>
<option value="">Select…</option>
{lists.map((l) => (
<option key={l._id} value={l._id}>
{l.name}
</option>
))}
</select>
</SelectWrap>
</Field>
</Grid>
)}
</>
)}
</div>
</PanelShell>
{previewOpen && (
<div
role="presentation"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
zIndex: 400,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
onClick={() => setPreviewOpen(false)}
>
<div
role="dialog"
aria-label="Email preview"
style={{
background: "var(--card)",
borderRadius: "var(--radius-card)",
width: "100%",
maxWidth: 720,
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
border: "1px solid var(--border)",
boxShadow: "0 20px 40px rgba(0,0,0,0.2)",
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "12px 16px", borderBottom: "1px solid var(--border)" }}>
<span style={{ fontWeight: 600 }}>Preview</span>
<button type="button" aria-label="Close" onClick={() => setPreviewOpen(false)} style={{ background: "none", border: "none", cursor: "pointer" }}>
<X size={18} />
</button>
</div>
<iframe title="Email preview" srcDoc={previewHtml} sandbox="allow-same-origin" style={{ flex: 1, minHeight: 420, border: "none", width: "100%" }} />
</div>
</div>
)}
</>
);
}
const chipBtn: React.CSSProperties = {
fontSize: "10px",
padding: "4px 8px",
borderRadius: 99,
border: "1px solid var(--border)",
background: "var(--secondary)",
cursor: "pointer",
fontFamily: "var(--font-noto-sans)",
};
const addBlockBtn: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 5,
padding: "5px 10px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
color: "var(--card-foreground)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
cursor: "pointer",
};
const btnSecondary: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
borderRadius: "var(--radius-button)",
background: "var(--secondary)",
color: "var(--card-foreground)",
border: "1px solid var(--border)",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
};