741 lines
29 KiB
TypeScript
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&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&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 & polls (Quiz & 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>
|
|
);
|
|
}
|