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 ( ); } function Field({ children, error }: { children: React.ReactNode; error?: string }) { return (
{children} {error ? (

{error}

) : null}
); } function Row({ children }: { children: React.ReactNode }) { return
{children}
; } function SelectWrap({ children }: { children: React.ReactNode }) { return (
{children}
); } function RadioGroup({ name, value, onChange, options, }: { name: string; value: string; onChange: (v: string) => void; options: { label: string; value: string }[]; }) { return (
{options.map(o => ( ))}
); } function UploadZone({ label, preview, error, inputRef, onFile, }: { label: string; preview: string | null; error?: string; inputRef: React.RefObject; onFile: (dataUrl: string) => void; }) { return (
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", }} > { 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 ? ( ) : ( )}

Drag and drop or select {label}

{error ?

{error}

: null}
); } function StepCircle({ n, state }: { n: number; state: "todo" | "active" | "done" }) { const bg = state === "todo" ? "var(--muted)" : "var(--primary)"; return (
{state === "done" ? ( ) : ( {n} )}
); } 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 (
{open && (

{subtitle}

{children}
{completed && (
Complete
)}
)}
); } 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 "Session notes"); 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 { const e: Record = {}; 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 { const e: Record = {}; 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 { const e: Record = {}; 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 { const e: Record = {}; 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 { return {}; } function validateCustomField(cf: CustomField): Record { const e: Record = {}; 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 { const e: Record = {}; 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[]) { return parts.reduce((acc, p) => ({ ...acc, ...p }), {}); } function stripKeys(prev: Record, 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; 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>(new Set()); const [form, setForm] = useState(INITIAL); const [fieldErrors, setFieldErrors] = useState>({}); const [submitError, setSubmitError] = useState(null); const [submitting, setSubmitting] = useState(false); const logoRef = useRef(null); const bannerRef = useRef(null); const [speakerCatalog, setSpeakerCatalog] = useState([]); const [speakersLoading, setSpeakersLoading] = useState(true); const [selectedSpeakerIds, setSelectedSpeakerIds] = useState([]); 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({}); 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: 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 = {}; 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 ( <>

Create session

One wizard: same fields as creating an event, plus session time and type. Event dates follow your session schedule. A track named "{DEFAULT_TRACK_NAME}" is created automatically.

{completed.size} of {TOTAL_SECTIONS} sections complete {progress}%
{submitError && (

{submitError}

)}
toggle(1)} onNext={() => tryAdvance(1)} nextLabel="Continue to Status & location" nextDisabled={submitting} >