1408 lines
50 KiB
TypeScript
1408 lines
50 KiB
TypeScript
/**
|
||
* 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'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 <title>)" />
|
||
<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>
|
||
);
|
||
}
|