/** * 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 = { "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: , label: "Text" }, { type: "image", icon: , label: "Image" }, { type: "cta", icon: , label: "CTA Button" }, { type: "divider", icon: , 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: "


" }; 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, "&") .replace(//g, ">") .replace(/"/g, """); } 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, 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 `
${inner}
`; } if (blk.type === "image" && blk.src?.trim()) { return `

`; } if (blk.type === "cta") { const url = escapeHtml(resolveCtaHref(blk, targets)); const label = escapeHtml(blk.label || "Register"); return `

${label}

`; } if (blk.type === "divider") return `
`; return ""; }) .join("\n"); const pre = form.previewText?.trim(); const preHidden = pre ? `
${escapeHtml(pre)}
` : ""; return ` ${preHidden}
${headerImg ? `` : ""}
${pre ? `

${escapeHtml(pre)}

` : ""}
${blocksHtml}

${escapeHtml(form.footerText)}

`; } function migrateBlocksFromContent(c: Record): EmailerContentBlock[] { const raw = c.blocks; const legacyCtaUrl = typeof c.ctaUrl === "string" ? c.ctaUrl : ""; let headerPrefix = ""; if (typeof c.headerText === "string" && c.headerText.trim()) { headerPrefix = `

${escapeHtml(c.headerText.trim())}

`; } 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; 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 = `

${escapeHtml(String(b.text)).replace(/\n/g, "
")}

`; } if (i === 0 && headerPrefix) html = headerPrefix + (html || ""); out.push({ id, type: "text", html: html || "


" }); } 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([]); const [linkTargets, setLinkTargets] = useState<{ forms: { id: string; name: string; href: string }[]; microsites: { id: string; name: string; href: string }[] }>({ forms: [], microsites: [] }); const [lists, setLists] = useState([]); const [form, setForm] = useState({ 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(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: 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; 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) => { 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); } 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); }; 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 ( <> acc.toggle(1)} onSave={() => acc.advance(1)} nextLabel="Design" >