);
}
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}%