1158 lines
48 KiB
TypeScript
1158 lines
48 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|