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

1408 lines
50 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.

/**
* 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<string, (typeof SECTION_OPTIONS)[number]> = 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<string | null>(null);
const [micrositeBuilderNonce, setMicrositeBuilderNonce] = useState(0);
const [micrositeBuilderError, setMicrositeBuilderError] = useState<string | null>(null);
const [micrositeBuilderLoading, setMicrositeBuilderLoading] = useState(false);
const builderIframeRef = useRef<HTMLIFrameElement>(null);
const [builderBarBusy, setBuilderBarBusy] = useState(false);
const [builderBarError, setBuilderBarError] = useState<string | null>(null);
const TOTAL = 4;
const acc = useAccordion(TOTAL);
const [events, setEvents] = useState<ApiEvent[]>([]);
const [form, setForm] = useState<MSForm>({
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<EventMicrosite[]>([]);
const [selectedMicrositeId, setSelectedMicrositeId] = useState<string | null>(null);
const [micrositesLoading, setMicrositesLoading] = useState(false);
const [eventPages, setEventPages] = useState<EventPage[]>([]);
const [registrationForms, setRegistrationForms] = useState<FormWithSubmissionCount[]>([]);
const [submitting, setSubmitting] = useState(false);
const [publishing, setPublishing] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [publishError, setPublishError] = useState<string | null>(null);
const [lastPublicUrl, setLastPublicUrl] = useState<string | null>(null);
const [draggingSectionId, setDraggingSectionId] = useState<string | null>(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 extends keyof MSForm>(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<boolean> => {
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 (
<>
<PanelShell
onClose={onClose}
title="Configure event microsite"
breadcrumb="Design · Microsite Templates"
description="Set URL slug, layout shell, sections, and publish static HTML to your web host."
completedCount={acc.done.size}
totalSections={TOTAL}
ctaLabel="Save microsite"
ctaDisabled={submitting || !form.event || !form.slug.trim()}
onCtaClick={handleSave}
>
{saveError && (
<div
style={{
padding: "8px 12px",
borderRadius: "var(--radius-button)",
background: "#fef2f2",
color: "#b91c1c",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
marginBottom: "8px",
}}
>
{saveError}
</div>
)}
<AccordionSection
n={1}
title="Event & URL"
subtitle="Associate an event and public folder slug"
open={acc.open === 1}
completed={acc.done.has(1)}
onToggle={() => acc.toggle(1)}
onSave={async () => {
const ok = await persistMicrositeSettings();
if (ok) acc.advance(1);
}}
nextDisabled={submitting}
nextLabel="Save & next"
>
<Grid cols={2}>
<Field span2={false}>
<Label text="Event" required />
<SelectWrap>
<select
style={SS}
value={form.event}
onChange={(e) => set("event", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">Select event…</option>
{events.map((ev) => (
<option key={ev._id} value={ev._id}>
{ev.name}
</option>
))}
</select>
</SelectWrap>
{selectedEventName && (
<HelperText text={`Editing: ${selectedEventName}`} />
)}
</Field>
</Grid>
<Field>
<Label text="Microsite template" />
<SelectWrap>
<select
style={SS}
disabled={!form.event || micrositesLoading}
value={selectedMicrositeId ?? "__new__"}
onChange={(e) => {
const v = e.target.value;
if (v === "__new__") {
setSelectedMicrositeId(null);
const ev = events.find((x) => x._id === form.event);
setForm((p) => ({
...p,
templateName: "",
slug: ev ? slugify(ev.name) : p.slug,
sourcePageId: "",
}));
return;
}
setSelectedMicrositeId(v);
const row = microsites.find((x) => x._id === v);
if (row) applyMicrositeRow(row);
}}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="__new__">+ New template…</option>
{microsites.map((m) => (
<option key={m._id} value={m._id}>
{(m.name || m.publishFolder || "Untitled").trim()}
{m.publishFolder ? ` · /${m.publishFolder}` : ""}
</option>
))}
</select>
</SelectWrap>
{micrositesLoading ? (
<HelperText text="Loading templates…" />
) : (
<HelperText text="Each template has its own URL slug and static output folder." />
)}
</Field>
<Field>
<Label text="Template label" />
<input
style={IS}
placeholder="e.g. Main site, Sponsor landing"
value={form.templateName}
onChange={(e) => set("templateName", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<HelperText text="Admin use only; not shown on the public site." />
</Field>
<Field>
<Label text="Section source page" />
<SelectWrap>
<select
style={SS}
value={form.sourcePageId}
onChange={(e) => set("sourcePageId", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">Default (Home or first page)</option>
{eventPages.map((p) => (
<option key={p._id} value={p._id}>
{p.title} ({p.type})
</option>
))}
</select>
</SelectWrap>
<HelperText text="Website Builder page whose sections drive generated blocks for publish and preview." />
</Field>
<Field>
<Label text="URL slug" required />
<div
style={{
display: "flex",
alignItems: "center",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
overflow: "hidden",
background: "var(--input-background)",
}}
>
<span
style={{
padding: "8px 10px",
background: "var(--secondary)",
borderRight: "1px solid var(--border)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
whiteSpace: "nowrap",
}}
>
https://web.eventbuzz.live/
</span>
<input
style={{ ...IS, border: "none", borderRadius: 0 }}
placeholder="your-event-2026"
value={form.slug}
onChange={(e) => set("slug", slugify(e.target.value))}
onFocus={focusIn}
onBlur={focusOut}
/>
</div>
<HelperText text="Slug becomes the folder name for the published site." />
</Field>
<Field>
<Label text="Description (LLM / generation brief)" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "80px" }}
placeholder="Describe the event and microsite goals for future AI-generated customisation…"
value={form.description}
onChange={(e) => set("description", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
</AccordionSection>
<AccordionSection
n={2}
title="Layout & sections"
subtitle="Conference template sections and order"
open={acc.open === 2}
completed={acc.done.has(2)}
onToggle={() => acc.toggle(2)}
onSave={async () => {
const ok = await persistMicrositeSettings();
if (ok) acc.advance(2);
}}
nextDisabled={submitting}
nextLabel="Save & next"
>
<Field>
<Label text="Color preset" />
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px", fontFamily: "var(--font-noto-sans)" }}>
Conference microsite only. Presets set Primary and Accent; you can fine-tune them under Branding. Published CSS uses those colours plus your font pairing.
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "8px" }}>
{COLOR_PRESETS.map((p) => {
const match = form.primaryColor === p.primary && form.accentColor === p.accent;
return (
<button
key={p.id}
type="button"
onClick={() => {
set("primaryColor", p.primary);
set("accentColor", p.accent);
}}
style={{
borderRadius: "var(--radius-button)",
border: `2px solid ${match ? "var(--primary)" : "var(--border)"}`,
overflow: "hidden",
cursor: "pointer",
background: "none",
padding: 0,
transition: "border-color .12s",
}}
>
<div style={{ height: "36px", background: `linear-gradient(135deg, ${p.primary} 0%, ${p.accent} 100%)` }} />
<p
style={{
margin: "5px 0",
fontSize: "10px",
fontFamily: "var(--font-noto-sans)",
color: match ? "var(--primary)" : "var(--muted)",
}}
>
{p.label}
</p>
</button>
);
})}
</div>
</Field>
<Field>
<Label text="Sections" />
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px", fontFamily: "var(--font-noto-sans)" }}>
If you have a <strong>Home</strong> page in Website Builder with sections, publish uses that page&apos;s sections and order (and your edits). Otherwise these checkboxes define what goes out. Included sections are listed first; drag the grip to reorder.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
{orderedSectionRows.map((s) => {
const on = form.micrositeSections.includes(s.id);
const orderIndex = form.micrositeSections.indexOf(s.id);
const isDragging = draggingSectionId === s.id;
return (
<div
key={s.id}
data-microsite-section-id={s.id}
style={{
display: "flex",
alignItems: "center",
gap: "10px",
padding: "9px 12px",
borderRadius: "var(--radius-button)",
border: `1px solid ${on ? "var(--primary)" : "var(--border)"}`,
background: on ? "var(--accent)" : "var(--card)",
transition: "opacity .12s, border-color .12s",
opacity: isDragging ? 0.55 : 1,
}}
>
<input
type="checkbox"
checked={on}
onChange={(e) => toggleSection(s.id, e.target.checked)}
style={{ width: "18px", height: "18px", cursor: "pointer", accentColor: "var(--primary)", flexShrink: 0 }}
aria-label={`Include ${s.label}`}
/>
<button
type="button"
disabled={!on}
onPointerDown={on ? (e) => handleSectionGripPointerDown(e, s.id) : undefined}
title={on ? "Drag to reorder" : undefined}
aria-label={on ? `Reorder section ${s.label}` : "Enable section to reorder"}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
touchAction: "none",
userSelect: "none",
cursor: on ? (isDragging ? "grabbing" : "grab") : "default",
padding: "6px",
minWidth: "36px",
minHeight: "36px",
borderRadius: "4px",
border: "none",
background: "transparent",
color: "var(--muted)",
opacity: on ? 1 : 0.35,
}}
>
<GripVertical size={16} aria-hidden />
</button>
<div style={{ flex: 1 }}>
<p
style={{
margin: 0,
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
color: "var(--card-foreground)",
}}
>
{s.label}
{on && (
<span style={{ marginLeft: "8px", fontSize: "10px", fontWeight: "normal", color: "var(--muted)" }}>
Order {orderIndex + 1}
</span>
)}
</p>
<p
style={{
margin: "1px 0 0",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
}}
>
{s.desc}
</p>
</div>
</div>
);
})}
</div>
<HelperText
text={`Publish order: ${form.micrositeSections.length} section${form.micrositeSections.length !== 1 ? "s" : ""} (saved checklist). Matching blocks on the section source page (or Home) supply custom content; others use event defaults.`}
/>
</Field>
</AccordionSection>
<AccordionSection
n={3}
title="Branding"
subtitle="Colours and fonts used for the Conference layout published microsite"
open={acc.open === 3}
completed={acc.done.has(3)}
onToggle={() => acc.toggle(3)}
onSave={async () => {
const ok = await persistMicrositeSettings();
if (ok) acc.advance(3);
}}
nextDisabled={submitting}
nextLabel="Save & next"
>
<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="Accent 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.accentColor}
onChange={(e) => set("accentColor", 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.accentColor}
</span>
</div>
</Field>
</Grid>
<Field>
<Label text="Font pairing" />
<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>
<Field>
<Label text="Registration form" />
<SelectWrap>
<select
style={SS}
value={form.registrationFormId}
onChange={(e) => set("registrationFormId", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">None (use #register on page)</option>
{registrationForms.map((f) => (
<option key={f._id} value={f._id}>
{f.name}
</option>
))}
</select>
</SelectWrap>
<HelperText text="Register links use MICROSITE_REGISTRATION_URL_TEMPLATE on the server with {eventId} and {formId} when a form is selected." />
</Field>
<HelperText text="Static site uses the conference shell, conference-layout.css, conference-static.css, and inline theme variables." />
</AccordionSection>
<AccordionSection
n={4}
title="Publishing"
subtitle="Enable site and write static files"
open={acc.open === 4}
completed={acc.done.has(4)}
onToggle={() => acc.toggle(4)}
onSave={async () => {
const ok = await persistMicrositeSettings();
if (ok) acc.advance(4);
}}
nextDisabled={submitting}
nextLabel="Save microsite"
>
<Field>
<Label text="Status" required />
<RadioGroup
name="msStatus"
value={form.status}
onChange={(v) => set("status", v as "Draft" | "Live")}
options={STATUS_OPTIONS}
/>
<HelperText text="Draft writes files to {slug}_draft; Live writes to {slug}. The event’s canonical slug is unchanged." />
</Field>
<SectionLabel text="SEO" />
<Field>
<Label text="Page title (HTML &lt;title&gt;)" />
<input
style={IS}
placeholder="e.g. NAPCON 2026"
value={form.seoTitle}
onChange={(e) => set("seoTitle", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
<Field>
<Label text="SEO description (meta)" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "64px" }}
placeholder="Search snippet for published index.html."
value={form.seoDescription}
onChange={(e) => set("seoDescription", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
<Field>
<Label text="Visual editor" />
<button
type="button"
disabled={!form.event || micrositeBuilderLoading}
onClick={() => void openMicrositeVisualBuilder()}
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "10px 18px",
borderRadius: "var(--radius-button)",
background: "var(--secondary)",
color: "var(--foreground)",
border: "1px solid var(--border)",
cursor: !form.event || micrositeBuilderLoading ? "not-allowed" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
opacity: !form.event || micrositeBuilderLoading ? 0.5 : 1,
}}
>
<PenSquare size={18} /> {micrositeBuilderLoading ? "Opening…" : "Edit page with Microsite Builder"}
</button>
{micrositeBuilderError && (
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)", margin: "8px 0 0" }}>
{micrositeBuilderError}
</p>
)}
<HelperText text="Opens the GrapesJS Microsite Builder full screen on top of this panel (same event: Home page, or first page if there is no Home)." />
</Field>
<Field>
<Label text="Custom domain (optional)" />
<input
style={IS}
placeholder="e.g. conference.example.com"
value={form.customDomain}
onChange={(e) => set("customDomain", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<HelperText text="DNS setup is separate from this publish step." />
</Field>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
}}
>
<div>
<p
style={{
margin: 0,
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
color: "var(--card-foreground)",
}}
>
Password protection
</p>
<p
style={{
margin: "2px 0 0",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
}}
>
Not applied to static export yet.
</p>
</div>
<ToggleSwitch value={form.passwordProtect} onChange={(v) => set("passwordProtect", v)} />
</div>
{form.passwordProtect && (
<Field>
<Label text="Access password" required />
<input
type="password"
style={IS}
placeholder="Set password…"
value={form.password}
onChange={(e) => set("password", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
)}
{publishError && (
<div
style={{
padding: "8px 12px",
borderRadius: "var(--radius-button)",
background: "#fef2f2",
color: "#b91c1c",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
marginBottom: "8px",
}}
>
{publishError}
</div>
)}
{lastPublicUrl && (
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", marginTop: 0 }}>
Last published:{" "}
<a href={lastPublicUrl} target="_blank" rel="noopener noreferrer">
{lastPublicUrl}
</a>
</p>
)}
<button
type="button"
disabled={publishing || !form.event || !form.slug.trim()}
onClick={() => void handlePublish()}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "10px 18px",
borderRadius: "var(--radius-button)",
background: "var(--primary)",
color: "var(--primary-foreground)",
border: "none",
cursor: publishing ? "wait" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
marginTop: "8px",
}}
>
{publishing ? "Publishing…" : "Publish microsite"}
</button>
<HelperText text="Saves settings first, then writes to MICROSITE_STATIC_ROOT. Set MICROSITE_REGISTRATION_URL_TEMPLATE for registration form links." />
</AccordionSection>
</PanelShell>
{micrositeBuilderOpen && selectedMicrositeId && (
<div
role="dialog"
aria-modal="true"
aria-label="Microsite visual editor"
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
background: "var(--background)",
display: "flex",
flexDirection: "column",
}}
>
<div style={{ flexShrink: 0, borderBottom: "1px solid var(--border)", background: "var(--card)" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
padding: "12px 16px",
flexWrap: "wrap",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<button
type="button"
disabled={builderBarBusy || !form.slug.trim()}
onClick={() => void handleBuilderSaveDraft()}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "8px 14px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--secondary)",
color: "var(--foreground)",
cursor: builderBarBusy || !form.slug.trim() ? "not-allowed" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
opacity: builderBarBusy ? 0.75 : 1,
}}
>
<Save size={16} aria-hidden /> {builderBarBusy ? "Working…" : "Save (draft site)"}
</button>
<button
type="button"
disabled={builderBarBusy || !form.slug.trim()}
onClick={() => void handleBuilderPublishLive()}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "8px 14px",
borderRadius: "var(--radius-button)",
border: "none",
background: "var(--primary)",
color: "var(--primary-foreground)",
cursor: builderBarBusy || !form.slug.trim() ? "not-allowed" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
opacity: builderBarBusy ? 0.75 : 1,
}}
>
<Rocket size={16} aria-hidden /> Publish
</button>
</div>
<button
type="button"
onClick={closeMicrositeBuilder}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "8px 14px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--secondary)",
color: "var(--foreground)",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
flexShrink: 0,
}}
>
<X size={16} /> Close builder
</button>
</div>
{builderBarError ? (
<p style={{ margin: 0, padding: "0 16px 12px", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)" }}>
{builderBarError}
</p>
) : null}
</div>
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<MicrositeEditor
ref={builderIframeRef}
pageId={micrositeBuilderPageId ?? ""}
eventId={form.event}
micrositeId={selectedMicrositeId ?? undefined}
variant="fullscreen"
mountNonce={micrositeBuilderNonce}
/>
</div>
</div>
)}
</>
);
}
function ToggleSwitch({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!value)}
style={{
width: "36px",
height: "20px",
borderRadius: "99px",
border: "none",
cursor: "pointer",
flexShrink: 0,
background: value ? "var(--primary)" : "var(--border)",
position: "relative",
transition: "background .2s",
}}
>
<span
style={{
position: "absolute",
top: "3px",
left: value ? "19px" : "3px",
width: "14px",
height: "14px",
borderRadius: "50%",
background: "#fff",
transition: "left .2s",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
/>
</button>
);
}