A Feedback step in the event wizard builds a survey that is sent at a chosen time to everyone who checked in (or everyone registered), with personal links and one reminder after 3 days. Answers can be anonymous. Adds rating (1-5 stars) and multiple-choice field types, also usable on attendee forms, and a Feedback summary report with response rate, average ratings, choice breakdowns, comments and event comparison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
14 KiB
TypeScript
301 lines
14 KiB
TypeScript
"use client";
|
||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||
import { useSearchParams, useRouter } from "next/navigation";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import { apiFetch } from "@/lib/api";
|
||
import { Navbar } from "@/components/layout/Navbar";
|
||
import { Footer } from "@/components/layout/Footer";
|
||
import { FileText } from "lucide-react";
|
||
|
||
// Types for form fields
|
||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||
|
||
function FormsContent() {
|
||
const search = useSearchParams();
|
||
const router = useRouter();
|
||
const { token } = useAuth();
|
||
const registrationId = search.get("registrationId");
|
||
// Set when a named attendee opens the link sent to them — show only their form.
|
||
const onlyAttendeeId = search.get("attendeeId");
|
||
|
||
const [registration, setRegistration] = useState<any | null>(null);
|
||
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [info, setInfo] = useState<string | null>(null);
|
||
|
||
// Local entry state for new responses, keyed by form block (see `blocks` below)
|
||
const [formsData, setFormsData] = useState<Record<string, Record<string, string>>>({});
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
if (!registrationId) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
setInfo(null);
|
||
try {
|
||
// authToken optional — guests access via registrationId alone
|
||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
|
||
setRegistration(reg);
|
||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||
if (ev?.form) setEventForm(ev.form);
|
||
// Load any saved draft to prefill fields
|
||
try {
|
||
const draft = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
|
||
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
|
||
} catch {}
|
||
} catch (e: any) {
|
||
setError(e?.message || 'Failed to load registration');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
}, [token, registrationId]);
|
||
|
||
const submittedCount = useMemo(() => {
|
||
return (registration?.formResponses || []).length;
|
||
}, [registration]);
|
||
|
||
const requiredCount = useMemo(() => {
|
||
if (!registration) return 0;
|
||
try {
|
||
return (registration.registrationOptions || [])
|
||
.filter((ro: any) => ro.eventOption?.isMainTicket)
|
||
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}, [registration]);
|
||
|
||
// One form block per place still to fill: each named attendee (group tickets) by name,
|
||
// then numbered blocks for unnamed places. Unnamed blocks keep their old numeric keys,
|
||
// so drafts saved before attendees existed still prefill.
|
||
type FormBlock = { key: string; label: string; attendeeId?: string };
|
||
const blocks = useMemo((): FormBlock[] => {
|
||
if (!registration) return [];
|
||
const responses = registration.formResponses || [];
|
||
const answered = new Set(responses.map((r: any) => r.attendeeId).filter(Boolean));
|
||
const attendees = (registration.attendees || []) as any[];
|
||
const named = attendees
|
||
.filter(a => !answered.has(a.id))
|
||
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id as string }));
|
||
const unnamedPlaces = Math.max(0, requiredCount - attendees.length);
|
||
const unnamedAnswered = responses.filter((r: any) => !r.attendeeId).length;
|
||
const unnamed = Array.from({ length: Math.max(0, unnamedPlaces - unnamedAnswered) }, (_, i) => ({
|
||
key: String(i), label: `Attendee ${unnamedAnswered + i + 1}`,
|
||
}));
|
||
const all: FormBlock[] = [...named, ...unnamed];
|
||
return onlyAttendeeId ? all.filter(b => b.attendeeId === onlyAttendeeId) : all;
|
||
}, [registration, requiredCount, onlyAttendeeId]);
|
||
const remaining = blocks.length;
|
||
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a: any) => a.id === id)?.name;
|
||
|
||
// Determine if all required fields are filled for each attendee form to be submitted
|
||
const canSubmit = useMemo(() => {
|
||
if (!eventForm || remaining <= 0) return false;
|
||
const reqFields = (eventForm.fields || [])
|
||
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
|
||
.map(f => f.id);
|
||
for (const b of blocks) {
|
||
const data = formsData[b.key] || {};
|
||
for (const fid of reqFields) {
|
||
const v = data[fid];
|
||
if (v === undefined || v === null || String(v).trim() === '') {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
}, [eventForm, formsData, remaining, blocks]);
|
||
|
||
const submit = async () => {
|
||
if (!registrationId) return;
|
||
try {
|
||
setError(null); setInfo(null);
|
||
const payload = blocks.map(b => ({ ...(b.attendeeId ? { attendeeId: b.attendeeId } : {}), answers: formsData[b.key] || {} }));
|
||
if (payload.length === 0) {
|
||
// Nothing left to submit – still go to success page to continue the flow
|
||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||
return;
|
||
}
|
||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
|
||
method: 'POST',
|
||
authToken: token || undefined,
|
||
body: { responses: payload }
|
||
});
|
||
// On success, redirect to the success page
|
||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||
} catch (e: any) {
|
||
setError(e?.message || 'Failed to submit forms');
|
||
}
|
||
};
|
||
|
||
const saveDraft = async () => {
|
||
if (!registrationId) return;
|
||
try {
|
||
setError(null); setInfo(null);
|
||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
|
||
method: 'PUT',
|
||
authToken: token || undefined,
|
||
body: { data: formsData }
|
||
});
|
||
setInfo('Draft saved. You can come back and finish later.');
|
||
if (!token || !registrationId) return;
|
||
try {
|
||
// Redirect to the success page
|
||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||
} catch (e: any) {
|
||
setError(e?.message || 'Failded to go to next step');
|
||
}
|
||
} catch (e: any) {
|
||
setError(e?.message || 'Failed to save draft');
|
||
}
|
||
};
|
||
|
||
const skip = async () => {
|
||
if (!registrationId) return;
|
||
try {
|
||
// Redirect to the success page
|
||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||
} catch (e: any) {
|
||
setError(e?.message || 'Failded to skip');
|
||
}
|
||
};
|
||
|
||
if (!registrationId) {
|
||
return (
|
||
<div className="min-h-screen flex flex-col">
|
||
<Navbar />
|
||
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
|
||
<div className="border rounded-xl p-6 bg-white shadow-sm text-gray-700">Missing registrationId.</div>
|
||
</main>
|
||
<Footer />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen flex flex-col">
|
||
<Navbar />
|
||
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||
<FileText className="w-5 h-5 text-brand-600" />
|
||
</div>
|
||
<h1 className="text-2xl font-semibold text-gray-900">Attendee forms</h1>
|
||
</div>
|
||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors" onClick={skip}>Skip for now</button>
|
||
</div>
|
||
|
||
{loading && <div className="text-sm text-gray-600 mb-2">Loading…</div>}
|
||
{error && <div className="text-sm text-red-600 mb-2">{error}</div>}
|
||
{info && <div className="text-sm text-emerald-700 mb-2">{info}</div>}
|
||
|
||
{registration && (
|
||
<div className="border rounded p-3 mb-4 bg-white">
|
||
<div className="font-medium">{registration.event?.title || registration.eventId}</div>
|
||
<div className="text-xs text-gray-600">Registration #{registration.id.slice(0,8)}</div>
|
||
<div className="text-sm mt-1">Forms submitted: {submittedCount} / {requiredCount}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Existing responses (read-only list) */}
|
||
{registration && (registration.formResponses || []).length > 0 && (
|
||
<div className="border rounded p-3 mb-4 bg-gray-50">
|
||
<div className="text-sm font-medium mb-2">Submitted responses</div>
|
||
<ul className="space-y-2">
|
||
{(registration.formResponses || []).map((resp: any, idx: number) => (
|
||
<li key={resp.id} className="bg-white border rounded p-2">
|
||
<div className="font-medium text-sm mb-1">{attendeeName(resp.attendeeId) || `Attendee ${idx + 1}`}</div>
|
||
{(resp.answers || []).length === 0 ? (
|
||
<div className="text-xs text-gray-600">No answers recorded.</div>
|
||
) : (
|
||
<ul className="text-xs list-disc pl-5 space-y-0.5">
|
||
{resp.answers.map((a: any) => (
|
||
<li key={a.id}>
|
||
<span className="text-gray-600">{eventForm?.fields.find(f => f.id === a.fieldId)?.label || (`Field ${a.fieldId}`)}</span>: {a.value}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* Remaining forms to fill */}
|
||
{eventForm && remaining > 0 && (
|
||
<div className="border rounded p-3 bg-gray-50">
|
||
<div className="text-sm font-medium mb-2">Fill remaining attendee details ({remaining})</div>
|
||
<div className="space-y-4">
|
||
{blocks.map(({ key: idx, label }) => (
|
||
<div key={idx} className="bg-white border rounded p-3">
|
||
<div className="font-medium mb-2">{label}</div>
|
||
{eventForm.fields.map((f) => (
|
||
<div key={f.id} className="mb-2">
|
||
{f.type === 'statement' ? (
|
||
<div className="text-sm text-gray-700 whitespace-pre-line">{f.label}</div>
|
||
) : f.type === 'paragraph' ? (
|
||
<div className="text-sm text-gray-700">
|
||
{f.label && <div className="font-medium mb-1 whitespace-pre-line">{f.label}</div>}
|
||
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
|
||
{isExtraFieldType(f.type) ? (
|
||
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: v } }))} />
|
||
) : f.type === 'yes_no' ? (
|
||
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
|
||
<option value="">Select</option>
|
||
<option value="yes">Yes</option>
|
||
<option value="no">No</option>
|
||
</select>
|
||
) : f.type === 'date' ? (
|
||
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||
) : f.type === 'numeric' ? (
|
||
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||
) : (
|
||
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||
)}
|
||
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-3 flex gap-2">
|
||
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
|
||
{!onlyAttendeeId && (
|
||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{eventForm && remaining === 0 && (
|
||
<div className="text-sm text-emerald-700">All required attendee forms are completed for this registration.</div>
|
||
)}
|
||
</main>
|
||
<Footer />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function FormsPage() {
|
||
return (
|
||
<Suspense fallback={<div className="p-6">Loading…</div>}>
|
||
<FormsContent />
|
||
</Suspense>
|
||
);
|
||
}
|