EventBuzz_Console/src/app/components/panels/CreateFormPanel.tsx

1158 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* CreateFormPanel — shared by Feedback, Registration, Other, and Web check-in forms.
* Pass mode="feedback" | "registration" | "other" | "web-check-in", eventId, optional formId, onSuccess.
*/
import React, { useState, useEffect, useCallback } from "react";
import { Plus, Trash2, GripVertical } from "lucide-react";
import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell";
import { useAuth } from "../../contexts/AuthContext";
import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api";
import type { Form, FormField, ParticipantListRecord } from "../../../services/api";
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
import { DropdownOtherConfigEditor } from "../forms/DropdownOtherConfigEditor";
type FormMode = "feedback" | "registration" | "other" | "web-check-in";
function eventIdFromFormRef(ev: Form["event"] | undefined): string {
if (!ev) return "";
if (typeof ev === "string") return ev;
return String((ev as { _id?: string })._id ?? "");
}
const FEEDBACK_Q_TYPES = ["Star Rating", "NPS (0–10)", "Multiple Choice", "Radio", "Short Text", "Long Text", "Checkboxes", "Yes / No", "Section"];
const REG_FIELD_TYPES = ["Text", "Email", "Phone", "Dropdown", "Radio", "Checkboxes", "Yes / No", "Date", "Time", "File Upload", "Country State", "Section"];
/** Other forms: union of feedback + registration field labels (order preserved, no duplicates). */
const OTHER_Q_TYPES = [...new Set([...FEEDBACK_Q_TYPES, ...REG_FIELD_TYPES])];
const SECTION_FIELD_TYPE = "Section";
function isSectionFieldType(type: string): boolean {
return type === SECTION_FIELD_TYPE;
}
function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
const nextPatch: Partial<Question> = { type: next };
if (isSectionFieldType(next)) {
nextPatch.required = "No";
nextPatch.options = [];
nextPatch.mapsTo = "";
nextPatch.customMapsKey = "";
return nextPatch;
}
if (OPTION_FIELD_TYPES.has(next)) {
nextPatch.options = q.options?.length ? q.options : defaultChoiceOptions();
} else {
nextPatch.options = [];
}
if (next !== "Dropdown") {
nextPatch.allowOther = false;
nextPatch.otherLabel = undefined;
}
return nextPatch;
}
/** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */
const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes"]);
const FORM_Q_DRAG = "application/x-eventbuzz-form-q";
function reorderList<T>(items: T[], from: number, to: number): T[] {
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) return items;
const next = [...items];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
}
function DragGrip({ index, disabled }: { index: number; disabled?: boolean }) {
return (
<div
draggable={!disabled}
onDragStart={(e) => {
if (disabled) return;
e.stopPropagation();
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData(FORM_Q_DRAG, String(index));
e.dataTransfer.setData("text/plain", String(index));
}}
style={{
color: "var(--muted)",
paddingTop: "6px",
flexShrink: 0,
cursor: disabled ? "default" : "grab",
opacity: disabled ? 0.35 : 1,
touchAction: "none",
}}
title={disabled ? undefined : "Drag to reorder"}
aria-label={disabled ? undefined : "Drag to reorder"}
>
<GripVertical size={14} />
</div>
);
}
function DraggableRowShell({
index,
onReorder,
disabled,
children,
style,
}: {
index: number;
onReorder: (from: number, to: number) => void;
disabled?: boolean;
children: React.ReactNode;
style?: React.CSSProperties;
}) {
const [dragOver, setDragOver] = useState(false);
return (
<div
onDragOver={(e) => {
if (disabled) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
if (disabled) return;
e.preventDefault();
setDragOver(false);
const raw = e.dataTransfer.getData(FORM_Q_DRAG) || e.dataTransfer.getData("text/plain");
const from = parseInt(raw, 10);
if (Number.isNaN(from) || from === index) return;
onReorder(from, index);
}}
style={{
...style,
outline: dragOver && !disabled ? "2px solid var(--primary)" : undefined,
outlineOffset: dragOver && !disabled ? 2 : undefined,
}}
>
{children}
</div>
);
}
const REG_TYPE_TO_API: Record<string, FormField["type"]> = {
Text: "text",
Email: "email",
Phone: "phone",
Dropdown: "select",
Radio: "radio",
Checkboxes: "checkboxes",
"Yes / No": "checkbox",
Date: "date",
Time: "time",
"File Upload": "file",
"Country State": "country_state",
};
const REG_EXTRA_MAP_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "Don't map (submission only)" },
{ value: "phone", label: "Phone" },
{ value: "organization", label: "Organization" },
{ value: "designation", label: "Designation" },
{ value: "notes", label: "Notes" },
{ value: "displayName", label: "Display name" },
{ value: "custom", label: "Custom field (key below)" },
];
interface Question {
id: string;
label: string;
/** Unique submission key (derived from label; not shown to respondents). */
name?: string;
type: string;
required: "Yes" | "No";
/** Choice labels for Multiple Choice, Dropdown, Radio, Checkboxes. */
options?: string[];
/** Dropdown only: include an Other option with a custom label. */
allowOther?: boolean;
otherLabel?: string;
mapsTo?: string;
customMapsKey?: string;
fixed?: boolean;
/** Optional note shown below the field on the public form. */
remarks?: string;
}
function normalizedOptions(q: Question): string[] {
return (q.options ?? []).map((s) => String(s).trim()).filter(Boolean);
}
function fieldChoicesAreValid(q: Question): boolean {
if (!OPTION_FIELD_TYPES.has(q.type)) return true;
return normalizedOptions(q).length >= 2;
}
function defaultChoiceOptions(): string[] {
return ["Option 1", "Option 2"];
}
function FieldRemarksEditor({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<span style={{ fontSize: "var(--text-xs)", fontWeight: 600, fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>Remarks</span>
<input
style={IS}
placeholder="Optional note shown below this field (small text)"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<HelperText text="Respondents see this as small helper text under the field." />
</div>
);
}
function FieldOptionsEditor({ options, onChange }: { options: string[]; onChange: (opts: string[]) => void }) {
const rows = options.length ? options : ["", ""];
const setAt = (i: number, v: string) => {
const next = [...rows];
next[i] = v;
onChange(next);
};
const addRow = () => onChange([...rows, ""]);
const removeRow = (i: number) => {
if (rows.length <= 1) return;
onChange(rows.filter((_, j) => j !== i));
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "6px", paddingTop: "4px" }}>
<span style={{ fontSize: "var(--text-xs)", fontWeight: 600, fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>Choices</span>
{rows.map((opt, i) => (
<div key={i} style={{ display: "flex", gap: "6px", alignItems: "center" }}>
<input style={{ ...IS, flex: 1 }} placeholder={`Choice ${i + 1}`} value={opt} onChange={(e) => setAt(i, e.target.value)} onFocus={focusIn} onBlur={focusOut} />
<button type="button" onClick={() => removeRow(i)} disabled={rows.length <= 1} title="Remove choice"
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "28px", height: "28px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: rows.length <= 1 ? "not-allowed" : "pointer", opacity: rows.length <= 1 ? 0.45 : 1, flexShrink: 0 }}>
<Trash2 size={13} />
</button>
</div>
))}
<button type="button" onClick={addRow}
style={{ alignSelf: "flex-start", display: "flex", alignItems: "center", gap: "6px", padding: "5px 10px", borderRadius: "var(--radius-button)", border: "1px dashed var(--border)", background: "transparent", color: "var(--primary)", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>
<Plus size={12} /> Add choice
</button>
<HelperText text="At least two non-empty choices are required." />
</div>
);
}
function initialRegQuestions(): Question[] {
return [
{ id: "reg-core-fn", label: "First name", type: "Text", required: "Yes", mapsTo: "firstName", fixed: true },
{ id: "reg-core-ln", label: "Last name", type: "Text", required: "Yes", mapsTo: "lastName", fixed: true },
{ id: "reg-core-em", label: "Email", type: "Email", required: "Yes", mapsTo: "email", fixed: true },
];
}
function initialWebCheckInQuestions(): Question[] {
return [
{ id: "wc-core-fn", label: "First name", type: "Text", required: "Yes", mapsTo: "firstName", fixed: true },
{ id: "wc-core-ln", label: "Last name", type: "Text", required: "Yes", mapsTo: "lastName", fixed: true },
{ id: "wc-core-em", label: "Email", type: "Email", required: "Yes", mapsTo: "email", fixed: true },
{ id: "wc-doc", label: "Upload document", type: "File Upload", required: "Yes", fixed: true },
];
}
function QuestionRow({ q, types, onPatch, onRemove, idx, canRemove, dragIndex, onReorder, onLabelCommit }: {
q: Question;
types: string[];
onPatch: (patch: Partial<Question>) => void;
onRemove: () => void;
idx: number;
canRemove: boolean;
dragIndex: number;
onReorder: (from: number, to: number) => void;
onLabelCommit: () => void;
}) {
const showChoices = OPTION_FIELD_TYPES.has(q.type);
const isSection = isSectionFieldType(q.type);
const rowStyle = {
display: "flex" as const,
gap: "8px",
alignItems: "flex-start" as const,
padding: "10px 12px",
background: isSection ? "var(--accent)" : "var(--secondary)",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
};
return (
<DraggableRowShell index={dragIndex} onReorder={onReorder} style={rowStyle}>
<DragGrip index={dragIndex} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "8px" }}>
{isSection ? (
<span style={{ fontSize: "10px", fontWeight: 600, fontFamily: "var(--font-noto-sans)", color: "var(--primary)", textTransform: "uppercase" }}>Section divider · not submitted</span>
) : null}
<div style={{ display: "grid", gridTemplateColumns: canRemove ? "1fr auto auto auto" : "1fr auto auto", gap: "8px", alignItems: "center" }}>
<input style={IS} placeholder={isSection ? "Section title" : `Question ${idx + 1}`} value={q.label}
onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={() => { focusOut(); onLabelCommit(); }} />
<div style={{ position: "relative" }}>
<select style={{ ...SS, width: "auto", minWidth: "130px" }} value={q.type} onChange={(e) => onPatch(patchForFieldTypeChange(e.target.value, q))} onFocus={focusIn} onBlur={focusOut}>
{types.map(t => <option key={t}>{t}</option>)}
</select>
</div>
{canRemove && (
<button onClick={onRemove} type="button" style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "28px", height: "28px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer", flexShrink: 0 }}>
<Trash2 size={13} />
</button>
)}
</div>
{showChoices && (
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
)}
{q.type === "Dropdown" && (
<DropdownOtherConfigEditor
allowOther={q.allowOther}
otherLabel={q.otherLabel}
onChange={(patch) => onPatch(patch)}
/>
)}
{!isSection ? (
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
<input type="checkbox" checked={q.required === "Yes"} onChange={e => onPatch({ required: e.target.checked ? "Yes" : "No" })}
style={{ accentColor: "var(--primary)", width: "13px", height: "13px" }} />
Required field
</label>
</div>
) : null}
{!isSection ? (
<FieldRemarksEditor value={q.remarks} onChange={(remarks) => onPatch({ remarks })} />
) : null}
</div>
</DraggableRowShell>
);
}
function RegCoreFieldRow({ q, idx, onPatch, onLabelCommit }: { q: Question; idx: number; onPatch: (patch: Partial<Question>) => void; onLabelCommit: () => void }) {
return (
<div style={{ display: "flex", gap: "8px", alignItems: "flex-start", padding: "10px 12px", background: "var(--accent)", borderRadius: "var(--radius-button)", border: "1px solid var(--border)" }}>
<DragGrip index={idx} disabled />
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<span style={{ fontSize: "10px", fontWeight: 600, fontFamily: "var(--font-noto-sans)", color: "var(--primary)", textTransform: "uppercase" }}>Core · maps to {q.mapsTo}</span>
</div>
<input style={IS} placeholder={q.mapsTo === "email" ? "Email address" : "Label shown to registrants"} value={q.label}
onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={() => { focusOut(); onLabelCommit(); }} />
<FieldRemarksEditor value={q.remarks} onChange={(remarks) => onPatch({ remarks })} />
</div>
</div>
);
}
function RegExtraFieldRow({ q, types, onPatch, onRemove, idx, dragIndex, onReorder, onLabelCommit }: {
q: Question;
types: string[];
onPatch: (patch: Partial<Question>) => void;
onRemove: () => void;
idx: number;
dragIndex: number;
onReorder: (from: number, to: number) => void;
onLabelCommit: () => void;
}) {
const showChoices = OPTION_FIELD_TYPES.has(q.type);
const isSection = isSectionFieldType(q.type);
const rowStyle = {
display: "flex" as const,
flexDirection: "column" as const,
gap: "8px",
padding: "10px 12px",
background: isSection ? "var(--accent)" : "var(--secondary)",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
};
return (
<DraggableRowShell index={dragIndex} onReorder={onReorder} style={rowStyle}>
<div style={{ display: "flex", gap: "8px", alignItems: "flex-start" }}>
<DragGrip index={dragIndex} />
<div style={{ flex: 1, display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px", alignItems: "start" }}>
{isSection ? (
<Field span2>
<span style={{ fontSize: "10px", fontWeight: 600, fontFamily: "var(--font-noto-sans)", color: "var(--primary)", textTransform: "uppercase" }}>Section divider · not submitted</span>
</Field>
) : null}
<Field>
<Label text={isSection ? "Section title" : "Field label"} />
<input style={IS} placeholder={isSection ? "e.g. Personal details" : `Field ${idx + 1}`} value={q.label} onChange={e => onPatch({ label: e.target.value })} onFocus={focusIn} onBlur={() => { focusOut(); onLabelCommit(); }} />
</Field>
<Field>
<Label text="Field type" />
<div style={{ position: "relative" }}>
<select style={SS} value={q.type} onChange={(e) => onPatch(patchForFieldTypeChange(e.target.value, q))} onFocus={focusIn} onBlur={focusOut}>
{types.map(t => <option key={t}>{t}</option>)}
</select>
</div>
</Field>
{showChoices && (
<Field span2>
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
</Field>
)}
{q.type === "Dropdown" && (
<Field span2>
<DropdownOtherConfigEditor
allowOther={q.allowOther}
otherLabel={q.otherLabel}
onChange={(patch) => onPatch(patch)}
/>
</Field>
)}
{!isSection ? (
<>
<Field>
<Label text="Map to participant" />
<div style={{ position: "relative" }}>
<select style={SS} value={q.mapsTo ?? ""} onChange={e => onPatch({ mapsTo: e.target.value })} onFocus={focusIn} onBlur={focusOut}>
{REG_EXTRA_MAP_OPTIONS.map(o => <option key={o.value || "—"} value={o.value}>{o.label}</option>)}
</select>
</div>
</Field>
<Field>
<Label text="Required" />
<RadioGroup name={`req-${q.id}`} value={q.required} onChange={v => onPatch({ required: v as Question["required"] })} options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
</Field>
{q.mapsTo === "custom" && (
<Field span2>
<Label text="Custom field key" required />
<input style={IS} placeholder="e.g. dietaryPreference" value={q.customMapsKey ?? ""} onChange={e => onPatch({ customMapsKey: e.target.value })} onFocus={focusIn} onBlur={focusOut} />
<HelperText text="Stored under Participant.customFields[key]." />
</Field>
)}
</>
) : null}
{!isSection ? (
<Field span2>
<FieldRemarksEditor value={q.remarks} onChange={(remarks) => onPatch({ remarks })} />
</Field>
) : null}
</div>
<button onClick={onRemove} type="button" title="Remove field"
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "28px", height: "28px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer", flexShrink: 0 }}>
<Trash2 size={13} />
</button>
</div>
</DraggableRowShell>
);
}
interface FForm {
name: string;
description: string;
questions: Question[];
showThankYou: "Yes" | "No";
thankYouMsg: string;
collectEmail: "Yes" | "No";
anonymous: "Yes" | "No";
publish: "Draft" | "Published";
paymentEnabled: "Yes" | "No";
amount: string;
confirmationEmail: "Yes" | "No";
onSubmitTags: string;
onSubmitListId: string;
}
const newQ = (types: string[]): Question => {
const first = types[0];
return {
id: Math.random().toString(36).slice(2),
label: "",
type: first,
required: "No",
options: OPTION_FIELD_TYPES.has(first) ? defaultChoiceOptions() : [],
mapsTo: "",
customMapsKey: "",
fixed: false,
};
};
const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
name: "",
description: "",
questions: [newQ(qTypes)],
showThankYou: "Yes",
thankYouMsg: "Thank you for your feedback!",
collectEmail: "No",
anonymous: isFeedback ? "Yes" : "No",
publish: "Draft",
paymentEnabled: "No",
amount: "",
confirmationEmail: "Yes",
onSubmitTags: "",
onSubmitListId: "",
});
function typeFromApi(
t: string,
opts: string[] | undefined,
qTypes: string[],
mode: FormMode,
uiType?: string,
): string {
const savedUi = uiType?.trim();
if (savedUi && qTypes.includes(savedUi)) return savedUi;
if (t === "stars") return "Star Rating";
if (t === "select" && opts?.length === 5 && opts.every((v, i) => v === String(i + 1))) return "Star Rating";
if (t === "select" && opts?.length === 11) return "NPS (0–10)";
if (t === "radio") return "Radio";
if (t === "checkboxes") return "Checkboxes";
if (t === "select") {
if (mode === "registration") return "Dropdown";
if (mode === "feedback") return "Multiple Choice";
return qTypes.includes("Dropdown") ? "Dropdown" : "Multiple Choice";
}
if (t === "checkbox") return qTypes.includes("Yes / No") ? "Yes / No" : "Checkboxes";
if (t === "textarea") return mode === "registration" ? "Text" : "Long Text";
if (t === "text") {
if (mode === "registration" || mode === "other") return qTypes.includes("Text") ? "Text" : "Short Text";
return "Short Text";
}
if (t === "email") return "Email";
if (t === "phone") return "Phone";
if (t === "date") return "Date";
if (t === "time") return "Time";
if (t === "file") return "File Upload";
if (t === "section") return "Section";
if (t === "country_state") return "Country State";
return qTypes[0] ?? "Short Text";
}
function optionsFromApi(f: FormField): string[] {
if (!Array.isArray(f.options)) return [];
return f.options.map((s) => String(s));
}
function withUiType(field: FormField, uiType: string | undefined, persistUiType: boolean): FormField {
if (!persistUiType || !uiType?.trim()) return field;
return { ...field, uiType: uiType.trim() };
}
function withFieldRemarks(field: FormField, remarks?: string): FormField {
const trimmed = remarks?.trim();
return trimmed ? { ...field, remarks: trimmed } : field;
}
function withSelectOther(field: FormField, q: Question): FormField {
if (q.type !== "Dropdown") return field;
if (!q.allowOther) return field;
const otherLabel = q.otherLabel?.trim() || "Other";
return { ...field, allowOther: true, otherLabel };
}
function toApiField(q: Question, mode: FormMode): FormField {
const label = q.label?.trim() || "Untitled";
const name = q.name?.trim() || label;
const required = q.required === "Yes";
const choiceOpts = normalizedOptions(q);
const persistUiType = mode === "other";
if (isSectionFieldType(q.type)) {
return withUiType({ label, type: "section", required: false }, q.type, persistUiType);
}
const withName = (field: FormField) => withFieldRemarks({ ...field, name }, q.remarks);
const isFeedbackOrOther = mode === "feedback" || mode === "other";
if (isFeedbackOrOther) {
if (q.type === "Star Rating") {
return withName(withUiType({ label, type: "stars", required, options: ["1", "2", "3", "4", "5"] }, q.type, persistUiType));
}
if (q.type === "NPS (0–10)") {
return withName(withUiType(
{ label, type: "select", required, options: Array.from({ length: 11 }, (_, i) => String(i)) },
q.type,
persistUiType,
));
}
if (q.type === "Multiple Choice") {
return withName(withUiType({ label, type: "select", required, options: choiceOpts }, q.type, persistUiType));
}
if (q.type === "Dropdown") {
return withName(
withSelectOther(
withUiType({ label, type: "select", required, options: choiceOpts }, q.type, persistUiType),
q,
),
);
}
if (q.type === "Radio") {
return withName(withUiType({ label, type: "radio", required, options: choiceOpts }, q.type, persistUiType));
}
if (q.type === "Checkboxes") {
return withName(withUiType({ label, type: "checkboxes", required, options: choiceOpts }, q.type, persistUiType));
}
if (q.type === "Short Text") {
return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
}
if (q.type === "Long Text") {
return withName(withUiType({ label, type: "textarea", required }, q.type, persistUiType));
}
if (q.type === "Yes / No") {
return withName(withUiType({ label, type: "checkbox", required }, q.type, persistUiType));
}
if (q.type === "Country State") {
return withName(withUiType({ label, type: "country_state", required }, q.type, persistUiType));
}
const regType = REG_TYPE_TO_API[q.type];
if (regType) {
if (regType === "select" || regType === "radio" || regType === "checkboxes") {
const base = withUiType({ label, type: regType, required, options: choiceOpts }, q.type, persistUiType);
return withName(q.type === "Dropdown" ? withSelectOther(base, q) : base);
}
return withName(withUiType({ label, type: regType, required }, q.type, persistUiType));
}
return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
}
const type = (REG_TYPE_TO_API[q.type] ?? "text") as FormField["type"];
if (type === "select" || type === "radio" || type === "checkboxes") {
const base = { label, name, type, required, options: choiceOpts };
return withFieldRemarks(q.type === "Dropdown" ? withSelectOther(base, q) : base, q.remarks);
}
return withFieldRemarks({ label, name, type, required }, q.remarks);
}
function isCoreRegFieldMapsTo(mapsTo: string | undefined): boolean {
return mapsTo === "firstName" || mapsTo === "lastName" || mapsTo === "email";
}
function toApiFieldReg(q: Question): FormField {
const base = toApiField(q, "registration");
const required = q.fixed === true || isCoreRegFieldMapsTo(q.mapsTo) ? true : base.required;
return {
...base,
required,
mapsTo: q.mapsTo || undefined,
customMapsKey: q.mapsTo === "custom" && q.customMapsKey?.trim() ? q.customMapsKey.trim() : undefined,
isFixed: q.fixed === true,
};
}
function requiredFromApiRegField(f: FormField): "Yes" | "No" {
if (f.isFixed === true || isCoreRegFieldMapsTo(f.mapsTo)) return "Yes";
return f.required ? "Yes" : "No";
}
function fromApiFields(fields: FormField[] | undefined, qTypes: string[], mode: FormMode): Question[] {
if (!fields?.length) return [newQ(qTypes)];
const questions = fields.map((f, i) => {
const typ = typeFromApi(f.type ?? "text", f.options, qTypes, mode, f.uiType);
return {
id: Math.random().toString(36).slice(2) + String(i),
label: f.label ?? "",
name: f.name ?? f.label ?? "",
type: typ,
required: f.required ? "Yes" : "No",
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
allowOther: typ === "Dropdown" ? !!f.allowOther : undefined,
otherLabel: typ === "Dropdown" ? (f.otherLabel?.trim() || "Other") : undefined,
mapsTo: f.mapsTo,
customMapsKey: f.customMapsKey,
fixed: f.isFixed,
remarks: f.remarks ?? "",
};
});
return assignUniqueFieldNames(questions);
}
function fromApiFieldSingle(f: FormField, i: number, qTypes: string[], mode: FormMode): Question {
const typ = typeFromApi(f.type ?? "text", f.options, qTypes, mode, f.uiType);
return {
id: `imp-${i}-${Math.random().toString(36).slice(2)}`,
label: f.label ?? "",
name: f.name ?? f.label ?? "",
type: typ,
required: requiredFromApiRegField(f),
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
allowOther: typ === "Dropdown" ? !!f.allowOther : undefined,
otherLabel: typ === "Dropdown" ? (f.otherLabel?.trim() || "Other") : undefined,
mapsTo: f.mapsTo,
customMapsKey: f.customMapsKey,
fixed: f.isFixed === true,
remarks: f.remarks ?? "",
};
}
function mergeRegFieldsFromApi(fields: FormField[] | undefined, qTypes: string[]): Question[] {
const base = initialRegQuestions();
if (!fields?.length) return base;
const converted = fields.map((f, i) => fromApiFieldSingle(f, i, qTypes, "registration"));
const merged = !converted.some((q) => q.mapsTo === "firstName") ? [...base, ...converted] : converted;
return assignUniqueFieldNames(merged);
}
export function CreateFormPanel({
eventId,
formId,
mode,
defaultFormName,
onClose,
onSuccess,
}: {
eventId?: string;
formId?: string;
mode: FormMode;
/** Prefill name when creating a new form (no formId). */
defaultFormName?: string;
onClose: () => void;
onSuccess: () => void;
}) {
const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user);
const isFeedback = mode === "feedback";
const isReg = mode === "registration";
const isOther = mode === "other";
const isWebCheckIn = mode === "web-check-in";
const TOTAL = isReg ? 5 : 3;
const qTypes = isReg || isWebCheckIn ? OTHER_Q_TYPES : isOther ? OTHER_Q_TYPES : FEEDBACK_Q_TYPES;
const isFeedbackOrOther = isFeedback || isOther || isWebCheckIn;
const acc = useAccordion(TOTAL);
const [form, setForm] = useState<FForm>(() => {
const base = initialForm(qTypes, isFeedbackOrOther);
if (isReg) return { ...base, questions: initialRegQuestions() };
if (isWebCheckIn) return { ...base, questions: initialWebCheckInQuestions() };
return base;
});
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({});
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(!!formId);
const [orgEvents, setOrgEvents] = useState<{ _id: string; name: string }[]>([]);
const [selectedEventId, setSelectedEventId] = useState("");
useEffect(() => {
if (!listOrgId) {
setOrgEvents([]);
return;
}
let cancelled = false;
void eventsApi
.list({ organization: listOrgId, recordKind: "event", limit: 500 })
.then((res) => {
if (cancelled) return;
const evs = res.data?.events ?? [];
setOrgEvents(evs.map((e) => ({ _id: e._id, name: e.name?.trim() ? e.name : "Untitled" })));
})
.catch(() => {
if (!cancelled) setOrgEvents([]);
});
return () => {
cancelled = true;
};
}, [listOrgId]);
useEffect(() => {
if (!formId && eventId) setSelectedEventId(eventId);
}, [formId, eventId]);
useEffect(() => {
if (formId || !defaultFormName?.trim()) return;
setForm((prev) => (prev.name.trim() ? prev : { ...prev, name: defaultFormName.trim() }));
}, [formId, defaultFormName]);
useEffect(() => {
if (!isReg) return;
if (!listOrgId) {
setFixedLists([]);
return;
}
participantListsApi.list({ organization: listOrgId }).then((r) => {
if (r.data?.lists) setFixedLists(r.data.lists.filter((L) => L.type === "fixed"));
}).catch(() => setFixedLists([]));
}, [isReg, listOrgId]);
useEffect(() => {
if (!formId) return;
let cancelled = false;
(async () => {
try {
const data = await formsApi.get(formId);
if (cancelled) return;
const loadMode: FormMode = isReg ? "registration" : isOther ? "other" : "feedback";
const questions =
data.type === "registration"
? mergeRegFieldsFromApi(data.fields, qTypes)
: fromApiFields(data.fields, qTypes, loadMode);
const s = data.settings ?? {};
const tags = Array.isArray(s.onSubmitAddTags) ? s.onSubmitAddTags.join(", ") : "";
setForm({
name: data.name ?? "",
description: data.description ?? "",
questions: questions.length ? questions : (data.type === "registration" ? initialRegQuestions() : [newQ(qTypes)]),
showThankYou: s.showThankYou === false ? "No" : "Yes",
thankYouMsg: s.thankYouMessage?.trim() ? s.thankYouMessage : "Thank you for your feedback!",
collectEmail: s.collectEmail ? "Yes" : "No",
anonymous: s.allowAnonymous ? "Yes" : (isFeedbackOrOther ? "Yes" : "No"),
publish: s.isActive !== false ? "Published" : "Draft",
paymentEnabled: s.paymentEnabled ? "Yes" : "No",
amount: s.paymentAmount != null && s.paymentAmount !== "" ? String(s.paymentAmount) : "",
confirmationEmail: s.sendConfirmationEmail === false ? "No" : "Yes",
onSubmitTags: tags,
onSubmitListId: s.onSubmitParticipantListId ?? "",
});
setSelectedEventId(eventIdFromFormRef(data.event));
} catch {
if (!cancelled) setLoading(false);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [formId, qTypes.join(","), isFeedbackOrOther]);
const set = <K extends keyof FForm>(k: K, v: FForm[K]) => setForm(p => ({ ...p, [k]: v }));
const addQ = () => setForm(p => ({ ...p, questions: [...p.questions, newQ(qTypes)] }));
const removeQ = (id: string) => setForm(p => ({ ...p, questions: p.questions.filter(q => !q.fixed && q.id !== id) }));
const updateQuestion = (id: string, patch: Partial<Question>) =>
setForm(p => ({ ...p, questions: p.questions.map(q => (q.id === id ? { ...q, ...patch } : q)) }));
const commitQuestionLabel = useCallback(() => {
setForm((p) => ({ ...p, questions: assignUniqueFieldNames(p.questions) }));
}, []);
const reorderQuestions = useCallback((from: number, to: number) => {
setForm((p) => ({ ...p, questions: reorderList(p.questions, from, to) }));
}, []);
const reorderRegExtras = useCallback((from: number, to: number) => {
setForm((p) => {
const core = p.questions.filter((q) => q.fixed);
const extras = p.questions.filter((q) => !q.fixed);
return { ...p, questions: [...core, ...reorderList(extras, from, to)] };
});
}, []);
const validateSection = useCallback((f: FForm, n: number): boolean => {
if (n === 1) return f.name.trim().length > 0 && selectedEventId.trim().length > 0;
if (n === 2) {
if (isReg || isWebCheckIn) {
const coreOk = f.questions.filter((q) => q.fixed).every((q) => q.label.trim().length > 0);
const extras = f.questions.filter((q) => !q.fixed);
const extrasOk = extras.every((q) => {
if (!q.label.trim()) return false;
if (isSectionFieldType(q.type)) return true;
if (q.mapsTo === "custom" && !q.customMapsKey?.trim()) return false;
if (!fieldChoicesAreValid(q)) return false;
return true;
});
return coreOk && extrasOk;
}
const anyLabel = f.questions.some(q => q.label.trim().length > 0);
const choicesOk = f.questions.every(q => isSectionFieldType(q.type) || !q.label.trim() || fieldChoicesAreValid(q));
return anyLabel && choicesOk;
}
if (n === 3 && isReg) return true;
if (n === 4 && isReg) return true;
if (n === (isReg ? 5 : 3)) return true;
return true;
}, [isReg, isWebCheckIn, selectedEventId]);
const tryAdvance = useCallback((n: number) => {
const valid = validateSection(form, n);
setSectionErrors(prev => ({ ...prev, [n]: valid ? "" : "Please complete required fields before continuing." }));
if (valid) acc.advance(n);
}, [form, validateSection, acc]);
const buildPayload = useCallback(() => {
const apiType = isReg ? "registration" : isOther ? "other" : isWebCheckIn ? "web-check-in" : "feedback";
const saveMode: FormMode = apiType;
const uniqueQuestions = assignUniqueFieldNames(form.questions);
const fields = isReg
? uniqueQuestions.filter(q => q.label.trim()).map(q => toApiFieldReg(q))
: uniqueQuestions.filter(q => q.label.trim()).map(q => toApiField(q, saveMode));
const isActive = form.publish === "Published";
const tagArr = form.onSubmitTags.split(/[,;\n]+/).map(t => t.trim()).filter(Boolean);
return {
name: form.name.trim(),
description: form.description.trim(),
type: apiType,
fields,
settings: {
isActive,
thankYouMessage: form.thankYouMsg.trim(),
showThankYou: form.showThankYou === "Yes",
collectEmail: form.collectEmail === "Yes",
allowAnonymous: form.anonymous === "Yes",
paymentEnabled: form.paymentEnabled === "Yes",
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
sendConfirmationEmail: form.confirmationEmail === "Yes",
...(isReg
? {
onSubmitAddTags: tagArr,
onSubmitParticipantListId: form.onSubmitListId.trim() || null,
}
: {}),
},
};
}, [form, isReg, isOther, isWebCheckIn, isFeedbackOrOther]);
const handleSave = useCallback(async () => {
if (!form.name.trim() || !selectedEventId.trim()) {
if (!selectedEventId.trim()) setSectionErrors((prev) => ({ ...prev, 1: "Select an event for this form." }));
return;
}
setSubmitting(true);
setSectionErrors({});
try {
const payload = buildPayload();
if (formId) {
await formsApi.update(formId, { ...payload, event: selectedEventId });
} else {
await formsApi.create({ ...payload, event: selectedEventId });
}
onSuccess();
onClose();
} catch {
setSectionErrors(prev => ({ ...prev, 1: "Failed to save. Please try again." }));
} finally {
setSubmitting(false);
}
}, [form.name, formId, selectedEventId, buildPayload, onSuccess, onClose]);
const onSaveLast = useCallback(() => {
const lastN = isReg ? 5 : 3;
if (!validateSection(form, lastN)) {
setSectionErrors(prev => ({ ...prev, [lastN]: "Please complete required fields." }));
return;
}
setSectionErrors(prev => ({ ...prev, [lastN]: "" }));
handleSave();
}, [form, isReg, validateSection, handleSave]);
const breadcrumb = isFeedback ? "Forms & Workflow · Feedback" : isReg ? "Forms & Workflow · Registration" : isWebCheckIn ? "Forms & Workflow · Web check-in" : "Forms & Workflow · Other Forms";
const title = formId
? (isFeedback ? "Edit Feedback Form" : isReg ? "Edit Registration Form" : isWebCheckIn ? "Edit Web check-in Form" : "Edit Form")
: (isFeedback ? "Create Feedback Form" : isReg ? "Create Registration Form" : isWebCheckIn ? "Create Web check-in Form" : "Create Other Form");
const panelDescription = isFeedback
? "Design a feedback form to collect responses from your attendees."
: isReg
? "Build a registration form; each submission creates or updates a participant."
: isWebCheckIn
? "Create a web check-in form with the required personal details and document upload fields."
: "Build custom forms (other / general-purpose) for your event.";
if (loading) {
return (
<PanelShell onClose={onClose} title={title} breadcrumb={breadcrumb} description={panelDescription}
completedCount={0} totalSections={TOTAL} ctaLabel="Save" ctaDisabled>
<div style={{ padding: "20px", color: "var(--muted)" }}>Loading form…</div>
</PanelShell>
);
}
return (
<PanelShell
onClose={onClose}
title={title}
breadcrumb={breadcrumb}
description={panelDescription}
completedCount={acc.done.size}
totalSections={TOTAL}
ctaLabel="Save"
ctaDisabled={!form.name.trim() || !selectedEventId.trim() || submitting || (!formId && orgEvents.length === 0)}
onCtaClick={handleSave}
>
<AccordionSection
n={1}
title="Form Details"
subtitle="Name and describe your form"
open={acc.open === 1}
completed={acc.done.has(1)}
onToggle={() => acc.toggle(1)}
onSave={() => tryAdvance(1)}
nextLabel={isFeedbackOrOther ? "Questions" : "Fields"}
sectionError={sectionErrors[1]}
>
<Field>
<Label text="Form Name" required />
<input style={IS} placeholder={isFeedbackOrOther ? "e.g. Post-Event Feedback 2026" : "e.g. Conference Registration 2026"} value={form.name} onChange={e => set("name", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
<Field>
<Label text="Event" required />
<div style={{ position: "relative" }}>
<select
style={SS}
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">Select an event…</option>
{selectedEventId && !orgEvents.some((e) => e._id === selectedEventId) ? (
<option value={selectedEventId}>Current linked event</option>
) : null}
{orgEvents.map((e) => (
<option key={e._id} value={e._id}>
{e.name}
</option>
))}
</select>
</div>
{listOrgId && orgEvents.length === 0 ? (
<HelperText text="No events in this organization. Create an event under Portfolio (All Events) first." />
) : null}
</Field>
<Field>
<Label text="Description" />
<textarea style={{ ...IS, resize: "vertical", minHeight: "72px" }} placeholder="Briefly describe this form…" value={form.description} onChange={e => set("description", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
{isFeedbackOrOther && !isWebCheckIn && (
<Grid cols={2}>
<Field>
<Label text="Anonymous Responses" required />
<RadioGroup name="anon" value={form.anonymous} onChange={v => set("anonymous", v as FForm["anonymous"])} options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
<HelperText text="Anonymous forms don't record the respondent's identity." />
</Field>
<Field>
<Label text="Collect Email" />
<RadioGroup name="collectEmail" value={form.collectEmail} onChange={v => set("collectEmail", v as FForm["collectEmail"])} options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
</Field>
</Grid>
)}
</AccordionSection>
<AccordionSection
n={2}
title={isFeedbackOrOther ? "Questions" : "Form Fields"}
subtitle={isReg ? "Core fields (first name, last name, email) plus optional mapped fields" : isFeedbackOrOther ? "Add and order your feedback questions" : "Add and arrange fields"}
open={acc.open === 2}
completed={acc.done.has(2)}
onToggle={() => acc.toggle(2)}
onSave={() => tryAdvance(2)}
nextLabel={isReg ? "Participant actions" : "Settings"}
sectionError={sectionErrors[2]}
>
{isWebCheckIn ? (
<HelperText text="This form starts with First name, Last name, Email, and Upload document fields, and you can add more questions below." />
) : null}
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{isReg ? (
<>
{form.questions.filter((q) => q.fixed).map((q, i) => (
<RegCoreFieldRow key={q.id} q={q} idx={i} onPatch={(patch) => updateQuestion(q.id, patch)} onLabelCommit={commitQuestionLabel} />
))}
{form.questions.filter((q) => !q.fixed).map((q, i) => (
<RegExtraFieldRow key={q.id} q={q} types={qTypes} idx={i} dragIndex={i} onReorder={reorderRegExtras} onPatch={(patch) => updateQuestion(q.id, patch)} onRemove={() => removeQ(q.id)} onLabelCommit={commitQuestionLabel} />
))}
</>
) : (
form.questions.map((q, i) => (
<QuestionRow key={q.id} q={q} types={qTypes} idx={i} dragIndex={i} onReorder={reorderQuestions} onPatch={(patch) => updateQuestion(q.id, patch)} onRemove={() => removeQ(q.id)} canRemove={form.questions.length > 1} onLabelCommit={commitQuestionLabel} />
))
)}
</div>
{isReg ? (
<button type="button" onClick={addQ}
style={{ display: "flex", alignItems: "center", gap: "6px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px dashed var(--primary)", background: "transparent", color: "var(--primary)", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> Add field
</button>
) : (
<button type="button" onClick={addQ}
style={{ display: "flex", alignItems: "center", gap: "6px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px dashed var(--primary)", background: "transparent", color: "var(--primary)", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> Add {isFeedbackOrOther ? "Question" : "Field"}
</button>
)}
<HelperText text={isReg ? "Core fields stay at the top. Drag extra fields by the grip icon to reorder." : isWebCheckIn ? "The required core fields are locked in place; add more questions only if needed." : "Drag the grip icon to reorder questions."} />
</AccordionSection>
{isReg && (
<AccordionSection
n={3}
title="Participant actions"
subtitle="Tags and lists applied when someone submits"
open={acc.open === 3}
completed={acc.done.has(3)}
onToggle={() => acc.toggle(3)}
onSave={() => tryAdvance(3)}
nextLabel="Payment"
sectionError={sectionErrors[3]}
>
<Field>
<Label text="Add tags on submit" />
<input style={IS} placeholder="e.g. VIP, Registered — comma separated" value={form.onSubmitTags} onChange={e => set("onSubmitTags", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
<HelperText text="Merged onto the participant record (deduplicated)." />
</Field>
<Field>
<Label text="Add to fixed participant list" />
<div style={{ position: "relative" }}>
<select style={SS} value={form.onSubmitListId} onChange={e => set("onSubmitListId", e.target.value)} onFocus={focusIn} onBlur={focusOut}>
<option value="">— None —</option>
{fixedLists.map((L) => (
<option key={L._id} value={L._id}>{L.name}</option>
))}
</select>
</div>
<HelperText text="Only fixed (manual) lists in your organization are shown." />
</Field>
</AccordionSection>
)}
{isReg && (
<AccordionSection
n={4}
title="Payment"
subtitle="Configure payment settings for paid registrations"
open={acc.open === 4}
completed={acc.done.has(4)}
onToggle={() => acc.toggle(4)}
onSave={() => tryAdvance(4)}
nextLabel="Publish Settings"
sectionError={sectionErrors[4]}
>
<Grid cols={2}>
<Field>
<Label text="Enable Payment" required />
<RadioGroup name="regPayment" value={form.paymentEnabled} onChange={v => set("paymentEnabled", v as FForm["paymentEnabled"])} options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
</Field>
{form.paymentEnabled === "Yes" && (
<Field>
<Label text="Registration Fee (₹)" required />
<input type="number" style={IS} placeholder="0.00" value={form.amount} onChange={e => set("amount", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
)}
<Field>
<Label text="Confirmation Email" required />
<RadioGroup name="confEmail" value={form.confirmationEmail} onChange={v => set("confirmationEmail", v as FForm["confirmationEmail"])} options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
<HelperText text="Send a confirmation email after registration." />
</Field>
</Grid>
</AccordionSection>
)}
<AccordionSection
n={isReg ? 5 : 3}
title="Publish Settings"
subtitle="Set the form status and thank-you message"
open={acc.open === (isReg ? 5 : 3)}
completed={acc.done.has(isReg ? 5 : 3)}
onToggle={() => acc.toggle(isReg ? 5 : 3)}
onSave={onSaveLast}
nextLabel="Save"
sectionError={sectionErrors[isReg ? 5 : 3]}
>
<Grid cols={2}>
<Field>
<Label text="Status" required />
<div style={{ position: "relative" }}>
<select style={SS} value={form.publish} onChange={e => set("publish", e.target.value as FForm["publish"])} onFocus={focusIn} onBlur={focusOut}>
<option>Draft</option>
<option>Published</option>
</select>
</div>
</Field>
<Field span2>
<Label text="Thank You Message" />
<input style={IS} placeholder="Thank you for your response!" value={form.thankYouMsg} onChange={e => set("thankYouMsg", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
<HelperText text="Shown to respondents after they submit the form." />
</Field>
</Grid>
</AccordionSection>
</PanelShell>
);
}