932 lines
38 KiB
TypeScript
932 lines
38 KiB
TypeScript
/**
|
||
* 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, "&")
|
||
.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<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)",
|
||
};
|