1853 lines
69 KiB
TypeScript
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 "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<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 "{DEFAULT_TRACK_NAME}" 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'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>
|
|
</>
|
|
);
|
|
}
|