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

1853 lines
69 KiB
TypeScript

import React, { useState, useRef, useCallback, useEffect } from "react";
import { X, ChevronDown, ChevronUp, Plus, Trash2, Check, Upload, Search, UserPlus } from "lucide-react";
import { eventsApi, sessionsApi, tracksApi, speakersApi } from "../../../services/api";
import type { Speaker } from "../../../services/api";
import {
buildEventCreatePayload,
eventYmdRangeFromSessionInstants,
labelToCode,
needsChoiceOptions,
type CustomFieldForPayload,
} from "./createEventPayload";
import { FlatpickrDatetimeInput } from "../ui/FlatpickrDatetimeInput";
const IS: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
background: "var(--input-background)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
outline: "none",
boxSizing: "border-box",
};
const SS: React.CSSProperties = { ...IS, cursor: "pointer", appearance: "none", paddingRight: "32px" };
function Label({ text, required }: { text: string; required?: boolean }) {
return (
<label
style={{
display: "block",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
color: "var(--card-foreground)",
marginBottom: "5px",
}}
>
{required && <span style={{ color: "var(--destructive)", marginRight: "2px" }}>*</span>}
{text}
</label>
);
}
function Field({ children, error }: { children: React.ReactNode; error?: string }) {
return (
<div style={{ display: "flex", flexDirection: "column" }}>
{children}
{error ? (
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--destructive)",
margin: "5px 0 0",
lineHeight: 1.35,
}}
>
{error}
</p>
) : null}
</div>
);
}
function Row({ children }: { children: React.ReactNode }) {
return <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>{children}</div>;
}
function SelectWrap({ children }: { children: React.ReactNode }) {
return (
<div style={{ position: "relative" }}>
{children}
<ChevronDown
size={14}
style={{
position: "absolute",
right: 10,
top: "50%",
transform: "translateY(-50%)",
color: "var(--muted)",
pointerEvents: "none",
}}
/>
</div>
);
}
function RadioGroup({
name,
value,
onChange,
options,
}: {
name: string;
value: string;
onChange: (v: string) => void;
options: { label: string; value: string }[];
}) {
return (
<div style={{ display: "flex", gap: "20px", alignItems: "center", flexWrap: "wrap" }}>
{options.map(o => (
<label
key={o.value}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
}}
>
<input
type="radio"
name={name}
value={o.value}
checked={value === o.value}
onChange={() => onChange(o.value)}
style={{ accentColor: "var(--primary)", width: "16px", height: "16px", cursor: "pointer" }}
/>
{o.label}
</label>
))}
</div>
);
}
function UploadZone({
label,
preview,
error,
inputRef,
onFile,
}: {
label: string;
preview: string | null;
error?: string;
inputRef: React.RefObject<HTMLInputElement | null>;
onFile: (dataUrl: string) => void;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<div
onClick={() => inputRef.current?.click()}
onDragOver={e => e.preventDefault()}
onDrop={e => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file?.type.startsWith("image/")) {
const r = new FileReader();
r.onload = () => {
if (typeof r.result === "string") onFile(r.result);
};
r.readAsDataURL(file);
}
}}
style={{
border: `1.5px dashed ${error ? "var(--destructive)" : "var(--primary)"}`,
borderRadius: "var(--radius-button)",
background: "var(--secondary)",
padding: "16px 12px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "6px",
cursor: "pointer",
}}
>
<input
ref={inputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={e => {
const file = e.target.files?.[0];
if (file) {
const r = new FileReader();
r.onload = () => {
if (typeof r.result === "string") onFile(r.result);
};
r.readAsDataURL(file);
}
}}
/>
{preview ? (
<img src={preview} alt="" style={{ maxHeight: 72, maxWidth: "100%", objectFit: "contain", borderRadius: 4 }} />
) : (
<Upload size={22} style={{ color: "var(--primary)" }} />
)}
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
textAlign: "center",
margin: 0,
}}
>
Drag and drop or <span style={{ color: "var(--primary)", textDecoration: "underline" }}>select {label}</span>
</p>
</div>
{error ? <p style={{ fontSize: "var(--text-xs)", color: "var(--destructive)", margin: 0 }}>{error}</p> : null}
</div>
);
}
function StepCircle({ n, state }: { n: number; state: "todo" | "active" | "done" }) {
const bg = state === "todo" ? "var(--muted)" : "var(--primary)";
return (
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
background: bg,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{state === "done" ? (
<Check size={13} style={{ color: "#fff" }} />
) : (
<span
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-inter)",
color: "#fff",
fontWeight: "var(--font-weight-medium)",
}}
>
{n}
</span>
)}
</div>
);
}
function AccordionSection({
n,
title,
subtitle,
open,
completed,
onToggle,
children,
onNext,
nextLabel,
nextDisabled,
}: {
n: number;
title: string;
subtitle: string;
open: boolean;
completed: boolean;
onToggle: () => void;
children: React.ReactNode;
onNext: () => void;
nextLabel: string;
nextDisabled?: boolean;
}) {
const state: "todo" | "active" | "done" = open ? "active" : completed ? "done" : "todo";
return (
<div style={{ borderBottom: "1px solid var(--border)" }}>
<button
type="button"
onClick={onToggle}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: "12px",
padding: "14px 20px",
background: "none",
border: "none",
cursor: "pointer",
textAlign: "left",
}}
>
<StepCircle n={n} state={state} />
<div style={{ flex: 1, minWidth: 0 }}>
<p
style={{
fontSize: "var(--text-sm)",
fontFamily: "var(--font-inter)",
fontWeight: "var(--font-weight-medium)",
color: open ? "var(--primary)" : "var(--card-foreground)",
margin: 0,
}}
>
{title}
</p>
{!open && (
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
{subtitle}
</p>
)}
</div>
{open ? <ChevronUp size={18} style={{ color: "var(--muted)" }} /> : <ChevronDown size={18} style={{ color: "var(--muted)" }} />}
</button>
{open && (
<div style={{ padding: "0 20px 16px" }}>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "0 0 14px 40px" }}>
{subtitle}
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>{children}</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: "12px",
marginTop: "18px",
paddingTop: "12px",
borderTop: "1px solid var(--border)",
}}
>
{completed && (
<div style={{ display: "flex", alignItems: "center", gap: "5px", fontSize: "var(--text-xs)", color: "var(--primary)" }}>
<Check size={12} /> Complete
</div>
)}
<button
type="button"
disabled={nextDisabled}
onClick={onNext}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 16px",
borderRadius: "var(--radius-button)",
background: nextDisabled ? "var(--secondary)" : "var(--primary)",
color: nextDisabled ? "var(--muted)" : "var(--primary-foreground)",
border: "none",
cursor: nextDisabled ? "not-allowed" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
}}
>
{nextLabel}
</button>
</div>
</div>
)}
</div>
);
}
const FIELD_TYPE_OPTIONS: { label: string; value: string }[] = [
{ label: "Text", value: "text" },
{ label: "Textarea", value: "textarea" },
{ label: "Dropdown", value: "dropdown" },
{ label: "Checkboxes", value: "checkboxes" },
{ label: "Radio", value: "radio" },
{ label: "Yes / No", value: "yes_no" },
{ label: "Stars (rating)", value: "stars" },
{ label: "Image upload", value: "image_upload" },
{ label: "File upload", value: "file_upload" },
];
const AUTHENTICATION_TYPE_OPTIONS: { label: string; value: string }[] = [
{ label: "OTP on Email", value: "otp_email" },
{ label: "Link on Email", value: "link_email" },
{ label: "None", value: "none" },
];
const REGISTRATION_BASE_FIELDS_OPTIONS: { label: string; value: string }[] = [
{ label: "Name & Email", value: "name_email" },
{ label: "Email Only", value: "email_only" },
{ label: "Name, Email & Phone", value: "name_email_phone" },
{ label: "Anonymous with Name", value: "anonymous_name" },
{ label: "Anonymous", value: "anonymous" },
];
const AUTH_API_VALUES = new Set(AUTHENTICATION_TYPE_OPTIONS.map(o => o.value));
const BASE_FIELDS_API_VALUES = new Set(REGISTRATION_BASE_FIELDS_OPTIONS.map(o => o.value));
const SESSION_TYPES = [
{ label: "Keynote", value: "keynote" },
{ label: "Workshop", value: "workshop" },
{ label: "Panel", value: "panel" },
{ label: "Breakout", value: "breakout" },
{ label: "Networking", value: "networking" },
];
/** Fixed track name for the auto-created track (not user-editable). */
const DEFAULT_TRACK_NAME = "default";
interface FieldOptionRow {
id: string;
value: string;
label: string;
}
interface CustomField {
id: string;
label: string;
code: string;
codeAuto: boolean;
fieldType: string;
options: FieldOptionRow[];
}
interface MergedForm {
eventName: string;
eventType: string;
description: string;
/** Shown on the session only (Agenda &quot;Session notes&quot;); event uses `description`. */
sessionNotes: string;
startLocal: string;
endLocal: string;
sessionType: string;
status: string;
locationType: string;
venue: string;
virtualLink: string;
registrationRequired: "Yes" | "No";
registrationMode: string;
verifyEmail: "Yes" | "No";
registrationDeadline: string;
authenticationType: string;
registrationBaseFields: string;
enableFeedback: "Yes" | "No";
enableRatings: "Yes" | "No";
keyPeople: string;
eventLogo: string | null;
eventBanner: string | null;
customFields: CustomField[];
}
function newCustomField(): CustomField {
return {
id: Math.random().toString(36).slice(2),
label: "",
code: "",
codeAuto: true,
fieldType: "text",
options: [],
};
}
const INITIAL: MergedForm = {
eventName: "",
eventType: "",
description: "",
sessionNotes: "",
startLocal: "",
endLocal: "",
sessionType: "keynote",
status: "",
locationType: "",
venue: "",
virtualLink: "",
registrationRequired: "No",
registrationMode: "",
verifyEmail: "Yes",
registrationDeadline: "",
authenticationType: "otp_email",
registrationBaseFields: "name_email",
enableFeedback: "Yes",
enableRatings: "No",
keyPeople: "",
eventLogo: null,
eventBanner: null,
customFields: [],
};
function validateSection1Basic(form: MergedForm): Record<string, string> {
const e: Record<string, string> = {};
const name = form.eventName.trim();
if (!name) e.eventName = "Event name is required.";
else if (name.length < 3) e.eventName = "Event name must be at least 3 characters.";
if (!form.eventType) e.eventType = "Please select an event type.";
if (!form.description.trim()) e.description = "Description is required.";
return e;
}
function validateSessionTimes(form: MergedForm): Record<string, string> {
const e: Record<string, string> = {};
if (!form.startLocal) e.startLocal = "Session start is required.";
if (!form.endLocal) e.endLocal = "Session end is required.";
if (form.startLocal && form.endLocal) {
const a = new Date(form.startLocal).getTime();
const b = new Date(form.endLocal).getTime();
if (!Number.isNaN(a) && !Number.isNaN(b) && b < a) e.endLocal = "Session end must be on or after start.";
}
if (!form.sessionType) e.sessionType = "Please select a session type.";
return e;
}
function validateStatusLocation(form: MergedForm): Record<string, string> {
const e: Record<string, string> = {};
if (!form.status) e.status = "Please select a status.";
if (!form.locationType) e.locationType = "Please select a location type.";
if (form.locationType === "Physical" || form.locationType === "Hybrid") {
if (!form.venue.trim()) e.venue = "Venue or address is required for this location type.";
}
if (form.locationType === "Virtual" || form.locationType === "Hybrid") {
const u = form.virtualLink.trim();
if (!u) e.virtualLink = "Meeting or stream URL is required.";
else {
try {
new URL(u.startsWith("http") ? u : `https://${u}`);
} catch {
e.virtualLink = "Enter a valid URL (e.g. https://…).";
}
}
}
return e;
}
function validateSection3Media(form: MergedForm): Record<string, string> {
const e: Record<string, string> = {};
if (!form.eventLogo) e.eventLogo = "Event logo is required.";
if (!form.eventBanner) e.eventBanner = "Banner image is required.";
return e;
}
function validateSectionSpeakersNotes(_form: MergedForm): Record<string, string> {
return {};
}
function validateCustomField(cf: CustomField): Record<string, string> {
const e: Record<string, string> = {};
if (!cf.label.trim()) e[`cf_${cf.id}_label`] = "Field label is required.";
if (!cf.fieldType) e[`cf_${cf.id}_type`] = "Select a field type.";
const code = (cf.code.trim() || labelToCode(cf.label)).replace(/[^a-z0-9_]/gi, "_");
if (!code) e[`cf_${cf.id}_code`] = "Field code is required.";
if (needsChoiceOptions(cf.fieldType)) {
const complete = cf.options.filter(o => o.label.trim() && o.value.trim());
if (complete.length === 0) e[`cf_${cf.id}_options`] = "Add at least one option with both Value and Label.";
cf.options.forEach(o => {
const L = o.label.trim();
const V = o.value.trim();
if (L && !V) e[`cf_${cf.id}_opt_${o.id}`] = "Value is required when Label is set.";
if (V && !L) e[`cf_${cf.id}_opt_${o.id}`] = "Label is required when Value is set.";
});
}
return e;
}
function validateSection4Registration(form: MergedForm): Record<string, string> {
const e: Record<string, string> = {};
if (form.registrationRequired === "Yes" && !form.registrationMode) {
e.registrationMode = "Select a registration mode when registration is required.";
}
if (form.registrationRequired === "Yes") {
if (!form.authenticationType || !AUTH_API_VALUES.has(form.authenticationType)) {
e.authenticationType = "Select an authentication type.";
}
if (!form.registrationBaseFields || !BASE_FIELDS_API_VALUES.has(form.registrationBaseFields)) {
e.registrationBaseFields = "Select registration base fields.";
}
}
form.customFields.forEach(cf => Object.assign(e, validateCustomField(cf)));
return e;
}
function mergeErrors(...parts: Record<string, string>[]) {
return parts.reduce((acc, p) => ({ ...acc, ...p }), {});
}
function stripKeys(prev: Record<string, string>, keys: string[]) {
const n = { ...prev };
keys.forEach(k => {
delete n[k];
});
return n;
}
function registrationErrorKeys(form: MergedForm): string[] {
const keys = ["registrationMode", "authenticationType", "registrationBaseFields"];
form.customFields.forEach(cf => {
keys.push(`cf_${cf.id}_label`, `cf_${cf.id}_type`, `cf_${cf.id}_code`, `cf_${cf.id}_options`);
cf.options.forEach(o => keys.push(`cf_${cf.id}_opt_${o.id}`));
});
return keys;
}
const S1_KEYS = ["eventName", "eventType", "description", "startLocal", "endLocal", "sessionType"];
const S2_KEYS = ["status", "locationType", "venue", "virtualLink"];
const S3M_KEYS = ["eventLogo", "eventBanner"];
const TOTAL_SECTIONS = 5;
type SectionErr = Record<string, string>;
export function CreateEventSessionPanel({
onClose,
onSuccess,
onCreated,
organizationId,
}: {
onClose: () => void;
onSuccess?: () => void;
onCreated?: (eventId: string) => void;
/** Scopes faculty directory (same as agenda session panel). */
organizationId?: string | null;
}) {
const [openSection, setOpenSection] = useState(1);
const [completed, setCompleted] = useState<Set<number>>(new Set());
const [form, setForm] = useState<MergedForm>(INITIAL);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const logoRef = useRef<HTMLInputElement>(null);
const bannerRef = useRef<HTMLInputElement>(null);
const [speakerCatalog, setSpeakerCatalog] = useState<Speaker[]>([]);
const [speakersLoading, setSpeakersLoading] = useState(true);
const [selectedSpeakerIds, setSelectedSpeakerIds] = useState<string[]>([]);
const [speakerSearch, setSpeakerSearch] = useState("");
const [showNewSpeaker, setShowNewSpeaker] = useState(false);
const [nsFirst, setNsFirst] = useState("");
const [nsLast, setNsLast] = useState("");
const [nsEmail, setNsEmail] = useState("");
const [nsPhone, setNsPhone] = useState("");
const [nsTitle, setNsTitle] = useState("");
const [nsErr, setNsErr] = useState<SectionErr>({});
const [nsSaving, setNsSaving] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
setSpeakersLoading(true);
const params: { organization?: string; limit: number } = { limit: 500 };
if (organizationId) params.organization = organizationId;
const res = await speakersApi.list(params);
if (!cancelled && res.data?.speakers) setSpeakerCatalog(res.data.speakers);
if (!cancelled) setSpeakersLoading(false);
})();
return () => {
cancelled = true;
};
}, [organizationId]);
const speakerLabel = useCallback(
(id: string) => {
const s = speakerCatalog.find(x => x._id === id);
return s ? `${s.firstName} ${s.lastName}`.trim() : `Speaker…${id.slice(-6)}`;
},
[speakerCatalog],
);
const addSpeakerId = (id: string) => {
if (!id || selectedSpeakerIds.includes(id)) return;
setSelectedSpeakerIds(p => [...p, id]);
};
const removeSpeakerId = (id: string) => setSelectedSpeakerIds(p => p.filter(x => x !== id));
const createSpeakerAndAttach = async () => {
const e: SectionErr = {};
if (!nsFirst.trim()) e.firstName = "First name is required.";
if (!nsLast.trim()) e.lastName = "Last name is required.";
if (!nsEmail.trim()) e.email = "Email is required.";
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(nsEmail.trim())) e.email = "Enter a valid email.";
setNsErr(e);
if (Object.keys(e).length) return;
setNsSaving(true);
const res = await speakersApi.create({
firstName: nsFirst.trim(),
lastName: nsLast.trim(),
email: nsEmail.trim().toLowerCase(),
phone: nsPhone.trim() || undefined,
title: nsTitle.trim() || undefined,
...(organizationId ? { organization: organizationId } : {}),
});
setNsSaving(false);
if (res.error || !res.data) {
setNsErr({ email: res.error || "Could not create speaker" });
return;
}
const created = res.data as Speaker;
setSpeakerCatalog(prev => [created, ...prev.filter(x => x._id !== created._id)]);
addSpeakerId(created._id);
setNsFirst("");
setNsLast("");
setNsEmail("");
setNsPhone("");
setNsTitle("");
setNsErr({});
setShowNewSpeaker(false);
};
const f = useCallback(<K extends keyof MergedForm>(k: K, v: MergedForm[K]) => {
setForm(p => ({ ...p, [k]: v }));
}, []);
const progress = Math.round((completed.size / TOTAL_SECTIONS) * 100);
const addCustomField = () => setForm(p => ({ ...p, customFields: [...p.customFields, newCustomField()] }));
const removeField = (id: string) => {
setForm(p => ({ ...p, customFields: p.customFields.filter(x => x.id !== id) }));
setFieldErrors(prev => {
const n = { ...prev };
Object.keys(n).forEach(k => {
if (k.startsWith(`cf_${id}_`) || k === `cf_${id}_label`) delete n[k];
});
return n;
});
};
const setCfLabel = (id: string, label: string) => {
setForm(p => ({
...p,
customFields: p.customFields.map(cf => {
if (cf.id !== id) return cf;
const next = { ...cf, label };
if (cf.codeAuto) next.code = labelToCode(label);
return next;
}),
}));
};
const setCfCode = (id: string, code: string) => {
setForm(p => ({
...p,
customFields: p.customFields.map(cf => (cf.id === id ? { ...cf, code, codeAuto: false } : cf)),
}));
};
const setCfType = (id: string, fieldType: string) => {
setForm(p => ({
...p,
customFields: p.customFields.map(cf => {
if (cf.id !== id) return cf;
const next = { ...cf, fieldType };
if (needsChoiceOptions(fieldType) && cf.options.length === 0) {
next.options = [{ id: Math.random().toString(36).slice(2), value: "", label: "" }];
}
if (!needsChoiceOptions(fieldType)) next.options = [];
return next;
}),
}));
};
const addOption = (cfId: string) => {
setForm(p => ({
...p,
customFields: p.customFields.map(cf =>
cf.id === cfId
? { ...cf, options: [...cf.options, { id: Math.random().toString(36).slice(2), value: "", label: "" }] }
: cf,
),
}));
};
const removeOption = (cfId: string, optId: string) => {
setForm(p => ({
...p,
customFields: p.customFields.map(cf =>
cf.id === cfId ? { ...cf, options: cf.options.filter(o => o.id !== optId) } : cf,
),
}));
setFieldErrors(prev => {
const n = { ...prev };
delete n[`cf_${cfId}_opt_${optId}`];
return n;
});
};
const setOption = (cfId: string, optId: string, key: "value" | "label", val: string) => {
setForm(p => ({
...p,
customFields: p.customFields.map(cf =>
cf.id === cfId
? { ...cf, options: cf.options.map(o => (o.id === optId ? { ...o, [key]: val } : o)) }
: cf,
),
}));
};
const tryAdvance = (section: number) => {
setSubmitError(null);
let errs: Record<string, string> = {};
if (section === 1) errs = mergeErrors(validateSection1Basic(form), validateSessionTimes(form));
else if (section === 2) errs = validateStatusLocation(form);
else if (section === 3) errs = validateSection3Media(form);
else if (section === 4) errs = validateSectionSpeakersNotes(form);
else if (section === 5) errs = validateSection4Registration(form);
if (Object.keys(errs).length > 0) {
setFieldErrors(prev => {
let next = { ...prev };
if (section === 1) next = stripKeys(next, S1_KEYS);
if (section === 2) next = stripKeys(next, S2_KEYS);
if (section === 3) next = stripKeys(next, S3M_KEYS);
if (section === 5) next = stripKeys(next, registrationErrorKeys(form));
return { ...next, ...errs };
});
setOpenSection(section);
return;
}
if (section === 1) setFieldErrors(p => stripKeys(p, S1_KEYS));
if (section === 2) setFieldErrors(p => stripKeys(p, S2_KEYS));
if (section === 3) setFieldErrors(p => stripKeys(p, S3M_KEYS));
if (section === 5) setFieldErrors(p => stripKeys(p, registrationErrorKeys(form)));
setCompleted(prev => new Set([...prev, section]));
if (section < TOTAL_SECTIONS) setOpenSection(section + 1);
};
const submitAll = async () => {
setSubmitError(null);
const merged = mergeErrors(
validateSection1Basic(form),
validateSessionTimes(form),
validateStatusLocation(form),
validateSection3Media(form),
validateSectionSpeakersNotes(form),
validateSection4Registration(form),
);
if (Object.keys(merged).length > 0) {
setFieldErrors(merged);
for (const s of [1, 2, 3, 4, 5]) {
let keys: string[] = [];
if (s === 1) keys = S1_KEYS;
if (s === 2) keys = S2_KEYS;
if (s === 3) keys = S3M_KEYS;
if (s === 5) keys = registrationErrorKeys(form);
if (keys.some(k => merged[k])) {
setOpenSection(s);
break;
}
}
return;
}
setFieldErrors({});
const startIso = new Date(form.startLocal).toISOString();
const endIso = new Date(form.endLocal).toISOString();
let startYmd: string;
let endYmd: string;
try {
({ startYmd, endYmd } = eventYmdRangeFromSessionInstants(startIso, endIso));
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Invalid session times.");
setOpenSection(1);
return;
}
const eventPayload = buildEventCreatePayload({
eventName: form.eventName.trim(),
description: form.description.trim(),
eventType: form.eventType,
status: form.status,
startDate: startYmd,
endDate: endYmd,
locationType: form.locationType,
venue: form.venue,
virtualLink: form.virtualLink,
registrationRequired: form.registrationRequired,
registrationMode: form.registrationMode,
verifyEmail: form.verifyEmail,
registrationDeadline: form.registrationDeadline,
authenticationType: form.authenticationType,
registrationBaseFields: form.registrationBaseFields,
enableFeedback: form.enableFeedback,
enableRatings: form.enableRatings,
keyPeople: form.keyPeople,
eventLogo: form.eventLogo,
eventBanner: form.eventBanner,
customFields: form.customFields as CustomFieldForPayload[],
});
setSubmitting(true);
const evRes = await eventsApi.create({ ...eventPayload, bootstrapAgenda: false } as never);
if (evRes.error != null || !evRes.data || !(evRes.data as { _id?: string })._id) {
setSubmitting(false);
setSubmitError(evRes.error || "Could not create event. Please try again.");
return;
}
const eventId = (evRes.data as { _id: string })._id;
const trackRes = await tracksApi.create(eventId, {
name: DEFAULT_TRACK_NAME,
trackType: "general",
status: "active",
});
if (trackRes.error != null || !trackRes.data || !(trackRes.data as { _id?: string })._id) {
setSubmitting(false);
setSubmitError(
trackRes.error ||
"Event was created but the track could not be added. Open the event to add a track manually.",
);
return;
}
const trackId = (trackRes.data as { _id: string })._id;
let sessionLocation: string | undefined;
if (form.locationType === "Physical" || form.locationType === "Hybrid") {
sessionLocation = form.venue.trim() || undefined;
}
const sessRes = await sessionsApi.create(eventId, {
title: form.eventName.trim(),
description: form.sessionNotes.trim() || undefined,
type: form.sessionType,
startTime: startIso,
endTime: endIso,
track: trackId,
location: sessionLocation,
status: "scheduled",
...(selectedSpeakerIds.length > 0 ? { speakers: selectedSpeakerIds } : {}),
});
setSubmitting(false);
if (sessRes.error != null || !sessRes.data) {
setSubmitError(
sessRes.error ||
"Event and track were created but the session could not be saved. Add it from the agenda.",
);
return;
}
onSuccess?.();
onCreated?.(eventId);
onClose();
};
const err = (k: string) => fieldErrors[k];
const toggle = (n: number) => setOpenSection(prev => (prev === n ? 0 : n));
return (
<>
<div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 200 }} />
<div
style={{
position: "fixed",
top: 0,
right: 0,
height: "100%",
width: "min(580px, 100vw)",
background: "var(--card)",
zIndex: 201,
display: "flex",
flexDirection: "column",
boxShadow: "-4px 0 32px rgba(0,0,0,0.18)",
animation: "slideInRightMerged 0.25s ease",
}}
>
<style>{`@keyframes slideInRightMerged { from { transform: translateX(100%); } to { transform: translateX(0); } }`}</style>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px", marginBottom: "12px" }}>
<div>
<h3
style={{
fontFamily: "var(--font-inter)",
fontSize: "var(--text-lg)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
margin: 0,
}}
>
Create session
</h3>
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
margin: "4px 0 0",
}}
>
One wizard: same fields as creating an event, plus session time and type. Event dates follow your session
schedule. A track named &quot;{DEFAULT_TRACK_NAME}&quot; is created automatically.
</p>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexShrink: 0 }}>
<button
type="button"
onClick={onClose}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "28px",
height: "28px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: "pointer",
color: "var(--muted)",
}}
>
<X size={14} />
</button>
<button
type="button"
disabled={submitting}
onClick={() => void submitAll()}
style={{
padding: "6px 14px",
borderRadius: "var(--radius-button)",
background: submitting ? "var(--secondary)" : "var(--primary)",
color: submitting ? "var(--muted)" : "var(--primary-foreground)",
border: "none",
cursor: submitting ? "not-allowed" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
}}
>
{submitting ? "Creating…" : "Create"}
</button>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "6px" }}>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>
{completed.size} of {TOTAL_SECTIONS} sections complete
</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--primary)", fontWeight: "var(--font-weight-medium)" }}>
{progress}%
</span>
</div>
<div style={{ height: "6px", borderRadius: "99px", background: "var(--border)", overflow: "hidden" }}>
<div
style={{
height: "100%",
width: `${progress}%`,
background: "#22c55e",
borderRadius: "99px",
transition: "width 0.3s ease",
}}
/>
</div>
{submitError && (
<p style={{ fontSize: "var(--text-xs)", color: "var(--destructive)", margin: "10px 0 0" }}>{submitError}</p>
)}
</div>
<div style={{ flex: 1, overflowY: "auto" }}>
<AccordionSection
n={1}
title="Basic & session"
subtitle="Event name, description, type, session schedule"
open={openSection === 1}
completed={completed.has(1)}
onToggle={() => toggle(1)}
onNext={() => tryAdvance(1)}
nextLabel="Continue to Status & location"
nextDisabled={submitting}
>
<Field error={err("eventName")}>
<Label text="Event & session name" required />
<input
style={{ ...IS, ...(err("eventName") ? { borderColor: "var(--destructive)" } : {}) }}
value={form.eventName}
onChange={e => f("eventName", e.target.value)}
/>
</Field>
<Field error={err("eventType")}>
<Label text="Event type" required />
<SelectWrap>
<select
style={{ ...SS, ...(err("eventType") ? { borderColor: "var(--destructive)" } : {}) }}
value={form.eventType}
onChange={e => f("eventType", e.target.value)}
>
<option value="">Select Type</option>
<option>Conference</option>
<option>Workshop</option>
<option>Summit</option>
<option>CME</option>
<option>Webinar</option>
</select>
</SelectWrap>
</Field>
<Field error={err("description")}>
<Label text="Description" required />
<textarea
style={{
...IS,
resize: "vertical",
minHeight: "80px",
...(err("description") ? { borderColor: "var(--destructive)" } : {}),
}}
value={form.description}
onChange={e => f("description", e.target.value)}
/>
</Field>
<Row>
<Field error={err("startLocal")}>
<Label text="Session start" required />
<FlatpickrDatetimeInput
value={form.startLocal}
onChange={v => f("startLocal", v)}
style={IS}
hasError={!!err("startLocal")}
disabled={submitting}
/>
</Field>
<Field error={err("endLocal")}>
<Label text="Session end" required />
<FlatpickrDatetimeInput
value={form.endLocal}
onChange={v => f("endLocal", v)}
style={IS}
hasError={!!err("endLocal")}
disabled={submitting}
/>
</Field>
</Row>
<Field error={err("sessionType")}>
<Label text="Session type" required />
<SelectWrap>
<select
style={{ ...SS, ...(err("sessionType") ? { borderColor: "var(--destructive)" } : {}) }}
value={form.sessionType}
onChange={e => f("sessionType", e.target.value)}
>
{SESSION_TYPES.map(o => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectWrap>
</Field>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
Event start/end dates sent to the API are the calendar days of this session (same rule as the main Create
Event form).
</p>
</AccordionSection>
<AccordionSection
n={2}
title="Status & location"
subtitle="Status, location type, venue, virtual link"
open={openSection === 2}
completed={completed.has(2)}
onToggle={() => toggle(2)}
onNext={() => tryAdvance(2)}
nextLabel="Continue to Media and feedback"
nextDisabled={submitting}
>
<Row>
<Field error={err("status")}>
<Label text="Status" required />
<SelectWrap>
<select
style={{ ...SS, ...(err("status") ? { borderColor: "var(--destructive)" } : {}) }}
value={form.status}
onChange={e => f("status", e.target.value)}
>
<option value="">Select Status</option>
<option>Draft</option>
<option>Upcoming</option>
<option>Live</option>
<option>Completed</option>
</select>
</SelectWrap>
</Field>
<Field error={err("locationType")}>
<Label text="Location type" required />
<SelectWrap>
<select
style={{ ...SS, ...(err("locationType") ? { borderColor: "var(--destructive)" } : {}) }}
value={form.locationType}
onChange={e => f("locationType", e.target.value)}
>
<option value="">Select</option>
<option>Physical</option>
<option>Virtual</option>
<option>Hybrid</option>
</select>
</SelectWrap>
</Field>
</Row>
{(form.locationType === "Physical" || form.locationType === "Hybrid") && (
<Field error={err("venue")}>
<Label text="Venue / address" required />
<input
style={{ ...IS, ...(err("venue") ? { borderColor: "var(--destructive)" } : {}) }}
value={form.venue}
onChange={e => f("venue", e.target.value)}
/>
</Field>
)}
{(form.locationType === "Virtual" || form.locationType === "Hybrid") && (
<Field error={err("virtualLink")}>
<Label text="Virtual link" required />
<input
style={{ ...IS, ...(err("virtualLink") ? { borderColor: "var(--destructive)" } : {}) }}
placeholder="https://…"
value={form.virtualLink}
onChange={e => f("virtualLink", e.target.value)}
/>
</Field>
)}
</AccordionSection>
<AccordionSection
n={3}
title="Media and feedback"
subtitle="Logo, banner, feedback & ratings"
open={openSection === 3}
completed={completed.has(3)}
onToggle={() => toggle(3)}
onNext={() => tryAdvance(3)}
nextLabel="Continue to Speakers & notes"
nextDisabled={submitting}
>
<Row>
<Field>
<Label text="Event logo" required />
<UploadZone
label="logo"
preview={form.eventLogo}
error={err("eventLogo")}
inputRef={logoRef}
onFile={url => {
f("eventLogo", url);
setFieldErrors(p => {
const n = { ...p };
delete n.eventLogo;
return n;
});
}}
/>
</Field>
<Field>
<Label text="Banner image" required />
<UploadZone
label="banner"
preview={form.eventBanner}
error={err("eventBanner")}
inputRef={bannerRef}
onFile={url => {
f("eventBanner", url);
setFieldErrors(p => {
const n = { ...p };
delete n.eventBanner;
return n;
});
}}
/>
</Field>
</Row>
<Row>
<Field>
<Label text="Enable feedback" required />
<RadioGroup
name="enableFeedback"
value={form.enableFeedback}
onChange={v => f("enableFeedback", v as "Yes" | "No")}
options={[
{ label: "Yes", value: "Yes" },
{ label: "No", value: "No" },
]}
/>
</Field>
<Field>
<Label text="Enable ratings" required />
<RadioGroup
name="enableRatings"
value={form.enableRatings}
onChange={v => f("enableRatings", v as "Yes" | "No")}
options={[
{ label: "Yes", value: "Yes" },
{ label: "No", value: "No" },
]}
/>
</Field>
</Row>
</AccordionSection>
<AccordionSection
n={4}
title="Speakers & notes"
subtitle="Faculty for this session; optional notes stored on the session"
open={openSection === 4}
completed={completed.has(4)}
onToggle={() => toggle(4)}
onNext={() => tryAdvance(4)}
nextLabel="Continue to Registration"
nextDisabled={submitting}
>
<Field>
<Label text="Speakers" />
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px", fontFamily: "var(--font-noto-sans)" }}>
Select from your organization&apos;s directory or add someone new.
</p>
{selectedSpeakerIds.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "10px" }}>
{selectedSpeakerIds.map(id => (
<span
key={id}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "4px 8px 4px 10px",
borderRadius: "var(--radius-button)",
background: "var(--secondary)",
border: "1px solid var(--border)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
}}
>
{speakerLabel(id)}
<button
type="button"
onClick={() => removeSpeakerId(id)}
style={{
display: "flex",
padding: 0,
border: "none",
background: "none",
cursor: "pointer",
color: "var(--muted)",
}}
aria-label="Remove"
>
<X size={12} />
</button>
</span>
))}
</div>
)}
{speakersLoading ? (
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>Loading faculty…</p>
) : (
<>
<div style={{ position: "relative", marginBottom: "8px" }}>
<Search size={14} style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", color: "var(--muted)" }} />
<input
value={speakerSearch}
onChange={e => setSpeakerSearch(e.target.value)}
placeholder="Search by name or email…"
style={{ ...IS, paddingLeft: "32px" }}
/>
</div>
<div
style={{
maxHeight: "160px",
overflowY: "auto",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
background: "var(--input-background)",
}}
>
{(() => {
const available = speakerCatalog.filter(s => !selectedSpeakerIds.includes(s._id));
const q = speakerSearch.trim().toLowerCase();
const filtered = available.filter(s => {
if (!q) return true;
return (
`${s.firstName} ${s.lastName}`.toLowerCase().includes(q) ||
(s.email || "").toLowerCase().includes(q)
);
});
if (speakerCatalog.length === 0) {
return (
<p style={{ padding: "12px", fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
No speakers in the directory yet. Add one below.
</p>
);
}
if (available.length === 0) {
return (
<p style={{ padding: "12px", fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
Everyone listed is already selected. Remove a chip above or add a new speaker.
</p>
);
}
if (filtered.length === 0) {
return (
<p style={{ padding: "12px", fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
No matches. Try another search.
</p>
);
}
return filtered.slice(0, 80).map(s => (
<button
key={s._id}
type="button"
onClick={() => addSpeakerId(s._id)}
style={{
width: "100%",
textAlign: "left",
padding: "8px 12px",
border: "none",
borderBottom: "1px solid var(--border)",
background: "transparent",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
}}
onMouseEnter={e => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--secondary)";
}}
onMouseLeave={e => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
}}
>
<span style={{ fontWeight: "var(--font-weight-medium)" }}>
{s.firstName} {s.lastName}
</span>
{s.title && (
<span style={{ color: "var(--muted)", fontSize: "var(--text-xs)", marginLeft: "6px" }}>{s.title}</span>
)}
<span style={{ display: "block", fontSize: "var(--text-xs)", color: "var(--muted)" }}>{s.email}</span>
</button>
));
})()}
</div>
</>
)}
<button
type="button"
onClick={() => {
setShowNewSpeaker(v => !v);
setNsErr({});
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
marginTop: "4px",
padding: "6px 12px",
borderRadius: "var(--radius-button)",
border: "1px dashed var(--border)",
background: "transparent",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--primary)",
}}
>
<UserPlus size={14} /> {showNewSpeaker ? "Hide new speaker form" : "Add new speaker"}
</button>
{showNewSpeaker && (
<div
style={{
marginTop: "10px",
padding: "14px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-card)",
background: "var(--secondary)",
display: "flex",
flexDirection: "column",
gap: "10px",
}}
>
<p style={{ fontSize: "var(--text-xs)", fontWeight: "600", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
New faculty member (saved to your organization)
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" }}>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>
First name *
</label>
<input
value={nsFirst}
onChange={e => {
setNsFirst(e.target.value);
if (nsErr.firstName) setNsErr({});
}}
style={IS}
/>
{nsErr.firstName && (
<p style={{ fontSize: "var(--text-xs)", color: "var(--destructive)", margin: "4px 0 0" }}>{nsErr.firstName}</p>
)}
</div>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>
Last name *
</label>
<input
value={nsLast}
onChange={e => {
setNsLast(e.target.value);
if (nsErr.lastName) setNsErr({});
}}
style={IS}
/>
{nsErr.lastName && (
<p style={{ fontSize: "var(--text-xs)", color: "var(--destructive)", margin: "4px 0 0" }}>{nsErr.lastName}</p>
)}
</div>
</div>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Email *</label>
<input
type="email"
value={nsEmail}
onChange={e => {
setNsEmail(e.target.value);
if (nsErr.email) setNsErr({});
}}
style={IS}
/>
{nsErr.email && <p style={{ fontSize: "var(--text-xs)", color: "var(--destructive)", margin: "4px 0 0" }}>{nsErr.email}</p>}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" }}>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Phone</label>
<input value={nsPhone} onChange={e => setNsPhone(e.target.value)} style={IS} />
</div>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Title / role</label>
<input
value={nsTitle}
onChange={e => setNsTitle(e.target.value)}
placeholder="e.g. Prof. of Medicine"
style={IS}
/>
</div>
</div>
<button
type="button"
disabled={nsSaving}
onClick={() => void createSpeakerAndAttach()}
style={{
alignSelf: "flex-start",
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 14px",
borderRadius: "var(--radius-button)",
background: "var(--primary)",
color: "var(--primary-foreground)",
border: "none",
cursor: nsSaving ? "wait" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
}}
>
<Plus size={14} /> {nsSaving ? "Saving…" : "Create & attach to session"}
</button>
</div>
)}
</Field>
<Field>
<Label text="Session notes" />
<textarea
value={form.sessionNotes}
onChange={e => f("sessionNotes", e.target.value)}
rows={3}
style={{ ...IS, resize: "vertical" }}
placeholder="Optional; shown on this session only (event description is in Basic & session)"
/>
</Field>
</AccordionSection>
<AccordionSection
n={5}
title="Registration & form fields"
subtitle="Same options as Create Event"
open={openSection === 5}
completed={completed.has(5)}
onToggle={() => toggle(5)}
onNext={() => void submitAll()}
nextLabel="Create event, track & session"
nextDisabled={submitting}
>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
Registration settings
</p>
<Row>
<Field>
<Label text="Registration required" required />
<RadioGroup
name="regRequired"
value={form.registrationRequired}
onChange={v => f("registrationRequired", v as "Yes" | "No")}
options={[
{ label: "Yes", value: "Yes" },
{ label: "No", value: "No" },
]}
/>
</Field>
<Field error={err("registrationMode")}>
<Label text="Registration mode" required={form.registrationRequired === "Yes"} />
<SelectWrap>
<select
style={{ ...SS, ...(err("registrationMode") ? { borderColor: "var(--destructive)" } : {}) }}
value={form.registrationMode}
onChange={e => f("registrationMode", e.target.value)}
disabled={form.registrationRequired !== "Yes"}
>
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
<option>Open</option>
<option>Invite Only</option>
<option>Approval Required</option>
</select>
</SelectWrap>
</Field>
</Row>
<Row>
<Field>
<Label text="Verify email" required />
<RadioGroup
name="verifyEmail"
value={form.verifyEmail}
onChange={v => f("verifyEmail", v as "Yes" | "No")}
options={[
{ label: "Yes", value: "Yes" },
{ label: "No", value: "No" },
]}
/>
</Field>
<Field>
<Label text="Registration deadline" />
<input
type="date"
style={IS}
value={form.registrationDeadline}
onChange={e => f("registrationDeadline", e.target.value)}
/>
</Field>
</Row>
<Row>
<Field error={err("authenticationType")}>
<Label text="Authentication type" required={form.registrationRequired === "Yes"} />
<SelectWrap>
<select
style={{ ...SS, ...(err("authenticationType") ? { borderColor: "var(--destructive)" } : {}) }}
value={form.authenticationType}
onChange={e => f("authenticationType", e.target.value)}
disabled={form.registrationRequired !== "Yes"}
>
{AUTHENTICATION_TYPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectWrap>
</Field>
<Field error={err("registrationBaseFields")}>
<Label text="Registration base fields" required={form.registrationRequired === "Yes"} />
<SelectWrap>
<select
style={{ ...SS, ...(err("registrationBaseFields") ? { borderColor: "var(--destructive)" } : {}) }}
value={form.registrationBaseFields}
onChange={e => f("registrationBaseFields", e.target.value)}
disabled={form.registrationRequired !== "Yes"}
>
{REGISTRATION_BASE_FIELDS_OPTIONS.map(o => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectWrap>
</Field>
</Row>
<div style={{ borderTop: "1px solid var(--border)", paddingTop: "12px", marginTop: "4px" }}>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 10px", fontFamily: "var(--font-noto-sans)" }}>
Custom registration fields (optional)
</p>
{form.customFields.map((cf, i) => (
<div
key={cf.id}
style={{
background: "var(--secondary)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-card)",
padding: "12px",
marginBottom: "10px",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "10px" }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", fontFamily: "var(--font-noto-sans)" }}>
Field {i + 1}
</span>
<button type="button" onClick={() => removeField(cf.id)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--destructive)" }}>
<Trash2 size={14} />
</button>
</div>
<Row>
<Field error={err(`cf_${cf.id}_label`)}>
<Label text="Field label" required />
<input
style={{ ...IS, ...(err(`cf_${cf.id}_label`) ? { borderColor: "var(--destructive)" } : {}) }}
placeholder="e.g. Dietary preference"
value={cf.label}
onChange={e => setCfLabel(cf.id, e.target.value)}
/>
</Field>
<Field error={err(`cf_${cf.id}_code`)}>
<Label text="Field code" required />
<input
style={{
...IS,
...(err(`cf_${cf.id}_code`) ? { borderColor: "var(--destructive)" } : {}),
opacity: cf.codeAuto ? 0.92 : 1,
}}
placeholder="auto from label"
value={cf.code || (cf.codeAuto ? labelToCode(cf.label) : "")}
onChange={e => setCfCode(cf.id, e.target.value)}
/>
<p style={{ fontSize: "10px", color: "var(--muted)", margin: "4px 0 0" }}>Auto-updates from label until you edit it.</p>
</Field>
</Row>
<Field error={err(`cf_${cf.id}_type`)}>
<Label text="Field type" required />
<SelectWrap>
<select
style={{ ...SS, ...(err(`cf_${cf.id}_type`) ? { borderColor: "var(--destructive)" } : {}) }}
value={cf.fieldType}
onChange={e => setCfType(cf.id, e.target.value)}
>
{FIELD_TYPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectWrap>
</Field>
{needsChoiceOptions(cf.fieldType) && (
<div style={{ marginTop: "10px" }}>
<Label text="Options (value & label)" required />
{err(`cf_${cf.id}_options`) && (
<p style={{ fontSize: "var(--text-xs)", color: "var(--destructive)", margin: "0 0 6px" }}>{err(`cf_${cf.id}_options`)}</p>
)}
{cf.options.map(o => (
<div
key={o.id}
style={{ display: "grid", gridTemplateColumns: "1fr 1fr auto", gap: "8px", alignItems: "start", marginBottom: "8px" }}
>
<Field error={err(`cf_${cf.id}_opt_${o.id}`)}>
<input
style={{ ...IS, ...(err(`cf_${cf.id}_opt_${o.id}`) ? { borderColor: "var(--destructive)" } : {}) }}
placeholder="Value"
value={o.value}
onChange={e => setOption(cf.id, o.id, "value", e.target.value)}
/>
</Field>
<Field error={err(`cf_${cf.id}_opt_${o.id}`)}>
<input
style={{ ...IS, ...(err(`cf_${cf.id}_opt_${o.id}`) ? { borderColor: "var(--destructive)" } : {}) }}
placeholder="Label"
value={o.label}
onChange={e => setOption(cf.id, o.id, "label", e.target.value)}
/>
</Field>
<button
type="button"
onClick={() => removeOption(cf.id, o.id)}
style={{
padding: "8px",
border: "none",
background: "transparent",
cursor: "pointer",
color: "var(--destructive)",
marginTop: "4px",
}}
aria-label="Remove option"
>
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
onClick={() => addOption(cf.id)}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "4px 10px",
borderRadius: "var(--radius-button)",
border: "1px dashed var(--primary)",
background: "var(--card)",
color: "var(--primary)",
cursor: "pointer",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
}}
>
<Plus size={12} /> Add option
</button>
</div>
)}
</div>
))}
<button
type="button"
onClick={addCustomField}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 14px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--primary)",
background: "var(--secondary)",
color: "var(--primary)",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
}}
>
<Plus size={13} /> Add custom field
</button>
</div>
</AccordionSection>
</div>
<div
style={{
padding: "14px 20px",
borderTop: "1px solid var(--border)",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: "12px",
background: "var(--card)",
}}
>
{submitError && <span style={{ fontSize: "var(--text-xs)", color: "var(--destructive)", flex: 1 }}>{submitError}</span>}
<button
type="button"
disabled={submitting}
onClick={() => void submitAll()}
style={{
padding: "8px 20px",
borderRadius: "var(--radius-button)",
background: submitting ? "var(--secondary)" : "var(--primary)",
color: submitting ? "var(--muted)" : "var(--primary-foreground)",
border: "none",
cursor: submitting ? "wait" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
}}
>
{submitting ? "Creating…" : "Create event, track & session"}
</button>
</div>
</div>
</>
);
}