/** * Configure & publish event static microsites (`EventMicrosite` templates + filesystem publish). */ import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { GripVertical, PenSquare, X, Save, Rocket } from "lucide-react"; import { useAuth } from "../../contexts/AuthContext"; import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, SelectWrap, RadioGroup, HelperText, IS, SS, focusIn, focusOut, SectionLabel, } from "./PanelShell"; import { eventsApi, websiteSettingsApi, eventMicrositesApi, formsApi, pagesApi, portfolioListOrganizationId, type WebsiteSettings, type MicrositeUpdatePayload, } from "../../../services/api"; import { MicrositeEditor } from "../website/MicrositeEditor"; import { requestHtmlFromMicrositeBuilderIframe } from "../website/micrositeBuilderBridge"; import type { Event as ApiEvent, FormWithSubmissionCount, MicrositeLayoutTemplate, EventMicrosite } from "../../../services/api/types"; import type { EventPage } from "../../../services/api/pages"; const DEFAULT_SECTIONS = ["header", "banner", "countdown", "about", "agenda", "speakers", "register_links", "sponsors"]; const SECTION_OPTIONS: { id: string; label: string; desc: string }[] = [ { id: "header", label: "Navigation header", desc: "Top nav with event name and links." }, { id: "banner", label: "Hero / banner", desc: "Full-width headline and CTAs." }, { id: "countdown", label: "Countdown strip", desc: "Countdown to event start." }, { id: "gallery", label: "Gallery", desc: "Image grid strip." }, { id: "about", label: "About", desc: "Headline, bullets, and CTA." }, { id: "contact_form", label: "Contact form", desc: "Visitor message form." }, { id: "agenda", label: "Schedule", desc: "Sessions from your event (timeline layout)." }, { id: "register_links", label: "Registration block", desc: "Register CTAs." }, { id: "speakers", label: "Speakers", desc: "Speaker cards from linked sessions." }, { id: "tickets", label: "Tickets / pricing", desc: "Pricing tiers (demo-style cards)." }, { id: "venue", label: "Venue", desc: "Venue copy and map embed." }, { id: "sponsors", label: "Sponsors", desc: "Sponsor row and partner CTA." }, { id: "faq", label: "FAQ", desc: "Accordion questions." }, { id: "cta", label: "Call to action", desc: "Highlighted CTA band." }, { id: "footer", label: "Footer (optional)", desc: "Extra footer row (shell already has EventBuzz footer)." }, { id: "custom_html", label: "Custom HTML", desc: "Rich text / HTML block." }, ]; const SECTION_BY_ID: Record = Object.fromEntries( SECTION_OPTIONS.map((s) => [s.id, s]), ); /** Conference color presets (match microsites/conference/css/palette-*.css primary + accent) */ const COLOR_PRESETS: { id: string; label: string; primary: string; accent: string }[] = [ { id: "violet", label: "Violet", primary: "#5c6ac4", accent: "#00c2d1" }, { id: "ocean", label: "Ocean", primary: "#0e7490", accent: "#22d3ee" }, { id: "forest", label: "Forest", primary: "#15803d", accent: "#84cc16" }, { id: "ember", label: "Ember", primary: "#c2410c", accent: "#fbbf24" }, ]; const FONT_PAIRS = [ "Montserrat + Roboto", "Inter + Noto Sans", "Playfair + Source Sans", "Poppins + Open Sans", "Raleway + Lato", ]; const STATUS_OPTIONS = [{ label: "Draft (not published to URL)", value: "Draft" }, { label: "Live (public site enabled)", value: "Live" }]; interface MSForm { event: string; /** Admin label for the selected `EventMicrosite` row. */ templateName: string; slug: string; description: string; layoutTemplate: MicrositeLayoutTemplate; micrositeSections: string[]; primaryColor: string; accentColor: string; fontPair: string; status: "Draft" | "Live"; customDomain: string; passwordProtect: boolean; password: string; seoTitle: string; seoDescription: string; registrationFormId: string; /** Optional Website Builder page whose sections feed publish for this template. */ sourcePageId: string; } function slugify(s: string) { return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); } function sourcePageIdFromMicrositeField(sp: EventMicrosite["sourcePageId"]): string { if (sp == null) return ""; if (typeof sp === "object" && sp && "_id" in sp) return String(sp._id); return String(sp); } export function CreateMicrositePanel({ onClose, onSuccess, initialEventId, }: { onClose: () => void; onSuccess?: () => void; initialEventId?: string; }) { const { user } = useAuth(); const listOrgId = portfolioListOrganizationId(user); const [micrositeBuilderOpen, setMicrositeBuilderOpen] = useState(false); const [micrositeBuilderPageId, setMicrositeBuilderPageId] = useState(null); const [micrositeBuilderNonce, setMicrositeBuilderNonce] = useState(0); const [micrositeBuilderError, setMicrositeBuilderError] = useState(null); const [micrositeBuilderLoading, setMicrositeBuilderLoading] = useState(false); const builderIframeRef = useRef(null); const [builderBarBusy, setBuilderBarBusy] = useState(false); const [builderBarError, setBuilderBarError] = useState(null); const TOTAL = 4; const acc = useAccordion(TOTAL); const [events, setEvents] = useState([]); const [form, setForm] = useState({ event: "", templateName: "", slug: "", description: "", layoutTemplate: "conference", micrositeSections: [...DEFAULT_SECTIONS], primaryColor: "#5c6ac4", accentColor: "#00c2d1", fontPair: FONT_PAIRS[0], status: "Draft", customDomain: "", passwordProtect: false, password: "", seoTitle: "", seoDescription: "", registrationFormId: "", sourcePageId: "", }); const [microsites, setMicrosites] = useState([]); const [selectedMicrositeId, setSelectedMicrositeId] = useState(null); const [micrositesLoading, setMicrositesLoading] = useState(false); const [eventPages, setEventPages] = useState([]); const [registrationForms, setRegistrationForms] = useState([]); const [submitting, setSubmitting] = useState(false); const [publishing, setPublishing] = useState(false); const [saveError, setSaveError] = useState(null); const [publishError, setPublishError] = useState(null); const [lastPublicUrl, setLastPublicUrl] = useState(null); const [draggingSectionId, setDraggingSectionId] = useState(null); const micrositeSectionsRef = useRef(form.micrositeSections); micrositeSectionsRef.current = form.micrositeSections; /** Cancel in-flight section drag (unmount / new drag). Does not apply a reorder. */ const pointerDragCancelRef = useRef<(() => void) | null>(null); const orderedSectionRows = useMemo(() => { const included = form.micrositeSections .map((id) => SECTION_BY_ID[id]) .filter(Boolean) as typeof SECTION_OPTIONS; const includedSet = new Set(form.micrositeSections); const excluded = SECTION_OPTIONS.filter((s) => !includedSet.has(s.id)); return [...included, ...excluded]; }, [form.micrositeSections]); const set = (k: K, v: MSForm[K]) => setForm((p) => ({ ...p, [k]: v })); useEffect(() => { return () => { pointerDragCancelRef.current?.(); pointerDragCancelRef.current = null; }; }, []); const handleSectionGripPointerDown = useCallback((e: React.PointerEvent, sourceId: string) => { if (e.pointerType === "mouse" && e.button !== 0) return; if (!micrositeSectionsRef.current.includes(sourceId)) return; pointerDragCancelRef.current?.(); pointerDragCancelRef.current = null; e.preventDefault(); e.stopPropagation(); const gripEl = e.currentTarget; const pointerId = e.pointerId; let lastOverId: string | null = null; let finished = false; setDraggingSectionId(sourceId); try { gripEl.setPointerCapture(pointerId); } catch { /* ignore */ } const listenerOpts: AddEventListenerOptions = { capture: true }; const applyReorder = (targetId: string | null) => { if (!targetId || targetId === sourceId) return; setForm((prev) => { const arr = [...prev.micrositeSections]; if (!arr.includes(sourceId) || !arr.includes(targetId)) return prev; const i = arr.indexOf(sourceId); const j = arr.indexOf(targetId); if (i < 0 || j < 0) return prev; const next = [...arr]; next.splice(i, 1); next.splice(j, 0, sourceId); return { ...prev, micrositeSections: next }; }); }; const move = (ev: PointerEvent) => { if (ev.pointerId !== pointerId) return; const el = document.elementFromPoint(ev.clientX, ev.clientY); const row = el?.closest?.("[data-microsite-section-id]") as HTMLElement | null; const overId = row?.getAttribute("data-microsite-section-id"); if (overId && micrositeSectionsRef.current.includes(overId)) { lastOverId = overId; } }; let teardown: (commit: boolean) => void; const up = (ev: PointerEvent) => { if (ev.pointerId !== pointerId) return; teardown(true); }; const onLost = (ev: PointerEvent) => { if (ev.pointerId !== pointerId) return; teardown(true); }; teardown = (commit: boolean) => { if (finished) return; finished = true; document.removeEventListener("pointermove", move, listenerOpts); document.removeEventListener("pointerup", up, listenerOpts); document.removeEventListener("pointercancel", up, listenerOpts); gripEl.removeEventListener("lostpointercapture", onLost); pointerDragCancelRef.current = null; setDraggingSectionId(null); try { gripEl.releasePointerCapture(pointerId); } catch { /* ignore */ } if (commit) applyReorder(lastOverId); }; pointerDragCancelRef.current = () => teardown(false); document.addEventListener("pointermove", move, listenerOpts); document.addEventListener("pointerup", up, listenerOpts); document.addEventListener("pointercancel", up, listenerOpts); gripEl.addEventListener("lostpointercapture", onLost); }, []); useEffect(() => { if (initialEventId) setForm((p) => ({ ...p, event: initialEventId })); }, [initialEventId]); useEffect(() => { if (!listOrgId) return; eventsApi.list({ limit: 100, organization: listOrgId }).then((res) => { if (res.data?.events) { setEvents(res.data.events); setForm((p) => ({ ...p, event: p.event || initialEventId || res.data!.events[0]?._id || "", })); } }); }, [listOrgId, initialEventId]); const applyMicrositeRow = useCallback((m: EventMicrosite) => { const sp = m.sourcePageId; const sid = sp == null ? "" : typeof sp === "object" && sp && "_id" in sp ? String(sp._id) : String(sp); setForm((p) => ({ ...p, templateName: m.name ?? "", slug: m.publishFolder ?? "", description: m.description ?? "", layoutTemplate: "conference", micrositeSections: m.micrositeSections && m.micrositeSections.length > 0 ? [...m.micrositeSections] : [...DEFAULT_SECTIONS], status: m.enabled ? "Live" : "Draft", seoTitle: m.title ?? "", primaryColor: m.primaryColor?.trim() || "#5c6ac4", accentColor: m.accentColor?.trim() || "#00c2d1", fontPair: m.fontPair && FONT_PAIRS.includes(m.fontPair) ? m.fontPair : FONT_PAIRS[0], registrationFormId: m.registrationFormId != null ? String(m.registrationFormId) : "", seoDescription: m.seoDescription ?? "", sourcePageId: sid, })); }, []); const applyLegacyWebsite = useCallback((w: WebsiteSettings) => { setForm((p) => ({ ...p, templateName: "Default", slug: w.folder ?? p.slug, description: w.description ?? "", layoutTemplate: "conference", micrositeSections: w.micrositeSections && w.micrositeSections.length > 0 ? [...w.micrositeSections] : [...DEFAULT_SECTIONS], status: w.enabled ? "Live" : "Draft", seoTitle: w.title ?? "", primaryColor: w.primaryColor?.trim() || "#5c6ac4", accentColor: w.accentColor?.trim() || "#00c2d1", fontPair: w.fontPair && FONT_PAIRS.includes(w.fontPair) ? w.fontPair : FONT_PAIRS[0], registrationFormId: w.registrationFormId ?? "", seoDescription: w.seoDescription ?? "", sourcePageId: "", })); }, []); useEffect(() => { if (!form.event) { setMicrosites([]); setSelectedMicrositeId(null); setEventPages([]); return; } let cancelled = false; (async () => { setMicrositesLoading(true); const [listRes, pagesRes] = await Promise.all([ eventMicrositesApi.list(form.event), pagesApi.list(form.event), ]); if (cancelled) return; setMicrositesLoading(false); setEventPages(pagesRes.data?.pages ?? []); if (listRes.error || !listRes.data) { setMicrosites([]); setSelectedMicrositeId(null); const wRes = await websiteSettingsApi.get(form.event); if (cancelled || wRes.error || !wRes.data) return; applyLegacyWebsite(wRes.data); return; } const list = listRes.data; setMicrosites(list); if (list.length === 0) { setSelectedMicrositeId(null); const wRes = await websiteSettingsApi.get(form.event); if (cancelled || wRes.error || !wRes.data) return; applyLegacyWebsite(wRes.data); return; } setSelectedMicrositeId(list[0]._id); applyMicrositeRow(list[0]); })(); return () => { cancelled = true; }; }, [form.event, applyLegacyWebsite, applyMicrositeRow]); useEffect(() => { if (!form.event) { setRegistrationForms([]); return; } void formsApi .listForEvent(form.event) .then((list) => setRegistrationForms(list.filter((f) => f.type === "registration"))) .catch(() => setRegistrationForms([])); }, [form.event]); const selectedEventName = events.find((e) => e._id === form.event)?.name ?? ""; const micrositeApiPayload = (): MicrositeUpdatePayload => ({ publishFolder: form.slug.trim(), name: form.templateName.trim() || undefined, description: form.description || undefined, title: form.seoTitle.trim() || undefined, layoutTemplate: "conference", micrositeSections: form.micrositeSections, enabled: form.status === "Live", primaryColor: form.primaryColor || undefined, accentColor: form.accentColor || undefined, fontPair: form.fontPair || undefined, registrationFormId: form.registrationFormId.trim() || undefined, seoDescription: form.seoDescription.trim() || undefined, sourcePageId: form.sourcePageId.trim() || null, }); /** Same persistence as the top bar “Save microsite”; used by accordion footers so checklist/order is stored. */ const persistMicrositeSettings = async (): Promise => { setSaveError(null); if (!form.event) { setSaveError("Select an event."); return false; } if (!form.slug.trim()) { setSaveError("URL slug is required."); return false; } setSubmitting(true); if (selectedMicrositeId) { const res = await eventMicrositesApi.update(form.event, selectedMicrositeId, micrositeApiPayload()); setSubmitting(false); if (res.error) { setSaveError(res.error); return false; } if (res.data) { setMicrosites((prev) => prev.map((x) => (x._id === res.data!._id ? { ...x, ...res.data! } : x))); applyMicrositeRow(res.data); } onSuccess?.(); return true; } const res = await eventMicrositesApi.create(form.event, { ...micrositeApiPayload(), publishFolder: form.slug.trim(), name: form.templateName.trim() || "Default", }); setSubmitting(false); if (res.error) { setSaveError(res.error); return false; } if (res.data) { setMicrosites((prev) => [...prev, res.data!]); setSelectedMicrositeId(res.data._id); applyMicrositeRow(res.data); } onSuccess?.(); return true; }; const handleSave = async () => { await persistMicrositeSettings(); }; const handlePublish = async () => { setPublishError(null); if (!form.event) { setPublishError("Select an event."); return; } if (!form.slug.trim()) { setPublishError("Set a URL slug before publishing."); return; } if (!selectedMicrositeId) { setPublishError("Save the microsite as a template first, then publish."); return; } setPublishing(true); const saveRes = await eventMicrositesApi.update(form.event, selectedMicrositeId, micrositeApiPayload()); if (saveRes.error) { setPublishing(false); setPublishError(saveRes.error); return; } const res = await eventMicrositesApi.publish(form.event, selectedMicrositeId); setPublishing(false); if (res.error) { setPublishError(res.error); return; } if (res.data?.publicUrl) setLastPublicUrl(res.data.publicUrl); onSuccess?.(); }; const openMicrositeVisualBuilder = async () => { if (!form.event) return; if (!selectedMicrositeId) { setMicrositeBuilderError("Create or select a microsite template and save it before opening the visual builder."); return; } setMicrositeBuilderError(null); setMicrositeBuilderLoading(true); let resolvedPageId: string | null = null; try { const msRes = await eventMicrositesApi.get(form.event, selectedMicrositeId); if (msRes.error || !msRes.data) { setMicrositeBuilderLoading(false); setMicrositeBuilderError(msRes.error || "Could not load this microsite template."); return; } let list: EventPage[] = []; try { const listRes = await pagesApi.list(form.event); list = listRes.data?.pages ?? []; } catch { list = []; } const savedSourcePageId = sourcePageIdFromMicrositeField(msRes.data.sourcePageId); const formSource = form.sourcePageId.trim(); if (list.length === 0) { resolvedPageId = null; } else { const pick = (id: string) => { if (!id) return false; if (list.some((p) => p._id === id)) { resolvedPageId = id; return true; } return false; }; if (!pick(formSource)) { if (!pick(savedSourcePageId)) { const home = list.find((p) => p.type === "home") ?? list[0]; resolvedPageId = home?._id ?? null; } } } } catch { setMicrositeBuilderLoading(false); setMicrositeBuilderError("Could not load pages or microsite. Check your connection and try again."); return; } setMicrositeBuilderLoading(false); setMicrositeBuilderPageId(resolvedPageId ?? ""); setMicrositeBuilderNonce((n) => n + 1); setBuilderBarError(null); setMicrositeBuilderOpen(true); }; const closeMicrositeBuilder = () => { setMicrositeBuilderOpen(false); setMicrositeBuilderPageId(null); setBuilderBarError(null); setBuilderBarBusy(false); }; /** Persist visual HTML on the selected `EventMicrosite` and write static files to the draft folder only. */ const handleBuilderSaveDraft = async () => { setBuilderBarError(null); if (!form.event || !form.slug.trim() || !selectedMicrositeId) { setBuilderBarError("Select an event, save a microsite template, and set a URL slug."); return; } setBuilderBarBusy(true); try { const html = await requestHtmlFromMicrositeBuilderIframe(builderIframeRef.current); const up = await eventMicrositesApi.update(form.event, selectedMicrositeId, { micrositeHtml: html }); if (up.error) throw new Error(up.error); const pub = await eventMicrositesApi.publish(form.event, selectedMicrositeId, { targets: "draft" }); if (pub.error) throw new Error(pub.error); if (pub.data?.publicUrl) setLastPublicUrl(pub.data.publicUrl); onSuccess?.(); } catch (e) { setBuilderBarError(e instanceof Error ? e.message : "Could not save or publish draft."); } finally { setBuilderBarBusy(false); } }; /** Save HTML, then publish to both `{slug}_draft` and `{slug}` folders. */ const handleBuilderPublishLive = async () => { setBuilderBarError(null); if (!form.event || !form.slug.trim() || !selectedMicrositeId) { setBuilderBarError("Select an event, save a microsite template, and set a URL slug."); return; } setBuilderBarBusy(true); try { const html = await requestHtmlFromMicrositeBuilderIframe(builderIframeRef.current); const up = await eventMicrositesApi.update(form.event, selectedMicrositeId, { micrositeHtml: html }); if (up.error) throw new Error(up.error); const pub = await eventMicrositesApi.publish(form.event, selectedMicrositeId, { targets: "both" }); if (pub.error) throw new Error(pub.error); if (pub.data?.publicUrl) setLastPublicUrl(pub.data.publicUrl); onSuccess?.(); } catch (e) { setBuilderBarError(e instanceof Error ? e.message : "Could not publish."); } finally { setBuilderBarBusy(false); } }; const toggleSection = (id: string, checked: boolean) => { if (checked) { if (form.micrositeSections.includes(id)) return; set("micrositeSections", [...form.micrositeSections, id]); } else { set( "micrositeSections", form.micrositeSections.filter((x) => x !== id), ); } }; return ( <> {saveError && (
{saveError}
)} acc.toggle(1)} onSave={async () => { const ok = await persistMicrositeSettings(); if (ok) acc.advance(1); }} nextDisabled={submitting} nextLabel="Save & next" >