EventBuzz_Console/src/app/components/activities/ActivityEventSettings.tsx

741 lines
29 KiB
TypeScript

/**
* Activity Settings tab — event fields in the same section layout as Create Event (Basic, Schedule & Location, Media & Tags, Registration).
*/
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Plus, Trash2, Search } from "lucide-react";
import { eventsApi } from "../../../services/api/events";
import { sessionsApi, type ApiSession } from "../../../services/api/sessions";
import type { Event } from "../../../services/api/types";
import { effectiveEnableQnA, effectiveEnableQnP, sessionToPayload } from "../../utils/sessionFlags";
import { buildEventCreatePayload, labelToCode, needsChoiceOptions, type EventCreateFormInput } from "../panels/createEventPayload";
import {
IS,
SS,
Label,
Field,
Row,
SelectWrap,
RadioGroup,
UploadZone,
AccordionSection,
} from "../panels/eventFormFields";
import {
FIELD_TYPE_OPTIONS,
AUTHENTICATION_TYPE_OPTIONS,
REGISTRATION_BASE_FIELDS_OPTIONS,
apiEventToForm,
newCustomField,
mergeActivitySettingsFormWithStored,
validateActivitySettingsForSave,
TOTAL_SECTIONS,
type EventForm,
} from "../panels/eventFormModel";
export function ActivityEventSettings({
event,
session,
selectedEventId,
onSaved,
}: {
event: Event;
session: ApiSession;
selectedEventId: string;
onSaved: () => void | Promise<void>;
}) {
const [openSection, setOpenSection] = useState(1);
const [completed, setCompleted] = useState<Set<number>>(() => new Set([1, 2, 3, 4]));
const [form, setForm] = useState<EventForm>(() => apiEventToForm(event as unknown as Record<string, unknown>));
const [enableQnA, setEnableQnA] = useState(() => effectiveEnableQnA(session));
const [enableQnP, setEnableQnP] = useState(() => effectiveEnableQnP(session));
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const logoRef = useRef<HTMLInputElement>(null);
const bannerRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setForm(apiEventToForm(event as unknown as Record<string, unknown>));
setEnableQnA(effectiveEnableQnA(session));
setEnableQnP(effectiveEnableQnP(session));
setFieldErrors({});
setMsg(null);
}, [event._id, event.updatedAt, session._id, session]);
const f = useCallback(<K extends keyof EventForm>(k: K, v: EventForm[K]) => {
setForm((p) => ({ ...p, [k]: v }));
}, []);
const err = (k: string) => fieldErrors[k];
const toggle = (n: number) => setOpenSection((prev) => (prev === n ? 0 : n));
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) => {
setMsg(null);
setFieldErrors({});
setCompleted((prev) => new Set([...prev, section]));
if (section < TOTAL_SECTIONS) setOpenSection(section + 1);
};
const saveAll = async () => {
setMsg(null);
const mergedForm = mergeActivitySettingsFormWithStored(form, event as unknown as Record<string, unknown>);
const customFields = mergedForm.customFields.filter((cf) => {
if (!cf.label.trim()) return false;
if (needsChoiceOptions(cf.fieldType)) {
const complete = cf.options.filter((o) => o.label.trim() && o.value.trim());
if (complete.length === 0) return false;
}
return true;
});
const merged = { ...mergedForm, customFields };
const softErrs = validateActivitySettingsForSave(merged);
if (Object.keys(softErrs).length > 0) {
setFieldErrors(softErrs);
if (softErrs.virtualLink) setOpenSection(2);
else if (softErrs.endDate) setOpenSection(2);
setMsg("Fix the highlighted fields, then save again.");
return;
}
setFieldErrors({});
setSubmitting(true);
try {
const base = buildEventCreatePayload(merged as EventCreateFormInput);
const payload = {
...base,
recordKind: event.recordKind ?? "activity",
activitySubtype: event.activitySubtype,
};
const evRes = await eventsApi.update(selectedEventId, payload as never);
if (evRes.error) {
setMsg(evRes.error);
return;
}
const sesRes = await sessionsApi.update(session._id, {
...sessionToPayload(session),
enableQnA,
enableQnP,
});
if (sesRes.error) {
setMsg(sesRes.error);
return;
}
setMsg("Saved.");
await onSaved();
} finally {
setSubmitting(false);
}
};
const progress = Math.round((completed.size / TOTAL_SECTIONS) * 100);
return (
<div
style={{
background: "var(--card)",
borderRadius: "var(--radius-card)",
border: "1px solid var(--border)",
overflow: "hidden",
}}
>
<div style={{ padding: "14px 20px", borderBottom: "1px solid var(--border)" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "6px", flexWrap: "wrap", gap: "10px" }}>
<div>
<h3
style={{
fontFamily: "var(--font-inter)",
fontSize: "var(--text-base)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
margin: 0,
}}
>
Activity settings
</h3>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "4px 0 0" }}>
Empty fields keep existing saved values. Session Q&amp;A and quiz toggles are under Basic.
</p>
</div>
<button
type="button"
disabled={submitting}
onClick={() => void saveAll()}
style={{
padding: "8px 18px",
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 ? "Saving…" : "Save settings"}
</button>
</div>
{msg && (
<p
style={{
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: msg === "Saved." ? "#15803d" : "#b91c1c",
margin: "8px 0 0",
}}
>
{msg}
</p>
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "10px", 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>
</div>
<div style={{ maxHeight: "min(70vh, 640px)", overflowY: "auto" }}>
<AccordionSection
n={1}
title="Basic"
subtitle="Name, description, and session features"
open={openSection === 1}
completed={completed.has(1)}
onToggle={() => toggle(1)}
onNext={() => tryAdvance(1)}
nextLabel="Continue to Schedule & Location"
nextDisabled={submitting}
>
<Field error={err("eventName")}>
<Label text="Activity name" />
<input
style={{ ...IS, ...(err("eventName") ? { borderColor: "var(--destructive)" } : {}) }}
value={form.eventName}
onChange={(e) => f("eventName", e.target.value)}
/>
</Field>
<Field error={err("description")}>
<Label text="Description" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "80px", ...(err("description") ? { borderColor: "var(--destructive)" } : {}) }}
value={form.description}
onChange={(e) => f("description", e.target.value)}
/>
</Field>
<label
style={{
display: "flex",
alignItems: "center",
gap: 10,
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--foreground)",
cursor: "pointer",
}}
>
<input type="checkbox" checked={enableQnA} onChange={(e) => setEnableQnA(e.target.checked)} />
Enable Q&amp;A (Questions tab)
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 10,
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--foreground)",
cursor: "pointer",
}}
>
<input type="checkbox" checked={enableQnP} onChange={(e) => setEnableQnP(e.target.checked)} />
Enable quiz &amp; polls (Quiz &amp; Polls tab)
</label>
</AccordionSection>
<AccordionSection
n={2}
title="Schedule & Location"
subtitle="When and where"
open={openSection === 2}
completed={completed.has(2)}
onToggle={() => toggle(2)}
onNext={() => tryAdvance(2)}
nextLabel="Continue to Media & Tags"
nextDisabled={submitting}
>
<Row>
<Field error={err("status")}>
<Label text="Status" />
<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" />
<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" />
<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" />
<input
style={{ ...IS, ...(err("virtualLink") ? { borderColor: "var(--destructive)" } : {}) }}
placeholder="https://…"
value={form.virtualLink}
onChange={(e) => f("virtualLink", e.target.value)}
/>
</Field>
)}
<Row>
<Field error={err("startDate")}>
<Label text="Start Date" />
<input type="date" style={{ ...IS, ...(err("startDate") ? { borderColor: "var(--destructive)" } : {}) }} value={form.startDate} onChange={(e) => f("startDate", e.target.value)} />
</Field>
<Field error={err("endDate")}>
<Label text="End Date" />
<input type="date" style={{ ...IS, ...(err("endDate") ? { borderColor: "var(--destructive)" } : {}) }} value={form.endDate} onChange={(e) => f("endDate", e.target.value)} />
</Field>
</Row>
</AccordionSection>
<AccordionSection
n={3}
title="Media & Tags"
subtitle="Logo, banner, and tags"
open={openSection === 3}
completed={completed.has(3)}
onToggle={() => toggle(3)}
onNext={() => tryAdvance(3)}
nextLabel="Continue to Registration & Form Fields"
nextDisabled={submitting}
>
<Field>
<Label text="Key People (tags)" />
<div style={{ position: "relative" }}>
<Search size={13} style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", color: "var(--muted)", pointerEvents: "none" }} />
<input style={{ ...IS, paddingLeft: "30px" }} placeholder="Comma-separated" value={form.keyPeople} onChange={(e) => f("keyPeople", e.target.value)} />
</div>
</Field>
<Row>
<Field>
<Label text="Event Logo" />
<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" />
<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" />
<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" />
<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="Registration & Form Fields"
subtitle="Registration rules and custom registration fields"
open={openSection === 4}
completed={completed.has(4)}
onToggle={() => toggle(4)}
onNext={() => void saveAll()}
nextLabel="Save settings"
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" />
<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" />
<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>
</select>
</SelectWrap>
</Field>
</Row>
<Row>
<Field>
<Label text="Registration Approval" />
<RadioGroup
name="registrationApproval"
value={form.registrationApproval}
onChange={(v) => f("registrationApproval", v as "auto" | "manual")}
options={[
{ label: "Auto Confirm", value: "auto" },
{ label: "Manual Confirm", value: "manual" },
]}
disabled={form.registrationRequired !== "Yes"}
/>
</Field>
<Field>
<Label text="Verify Email" />
<RadioGroup
name="verifyEmail"
value={form.verifyEmail}
onChange={(v) => f("verifyEmail", v as "Yes" | "No")}
options={[
{ label: "Yes", value: "Yes" },
{ label: "No", value: "No" },
]}
/>
</Field>
</Row>
<Row>
<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" />
<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" />
<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" />
<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" />
<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" />
<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)" />
{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>
);
}