Files
hope-events/frontend/src/app/forms/page.tsx
T
joshua 3d381944d2 Initial commit
Next.js + Express event management app for Hope Family Church.
2026-07-23 15:26:47 +02:00

254 lines
12 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.
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
// Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
function FormsContent() {
const search = useSearchParams();
const router = useRouter();
const { token } = useAuth();
const registrationId = search.get("registrationId");
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
const [formsData, setFormsData] = useState<Record<number, 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]);
const remaining = Math.max(0, requiredCount - submittedCount);
// 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 (let i = 0; i < remaining; i++) {
const data = formsData[i] || {};
for (const fid of reqFields) {
const v = data[fid];
if (v === undefined || v === null || String(v).trim() === '') {
return false;
}
}
}
return true;
}, [eventForm, formsData, remaining]);
const submit = async () => {
if (!registrationId) return;
try {
setError(null); setInfo(null);
const payload: any[] = [];
for (let i = 0; i < remaining; i++) {
payload.push({ answers: formsData[i] || {} });
}
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="p-6">Missing registrationId.</div>;
return (
<div className="max-w-2xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Attendee forms</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" 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">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">
{Array.from({ length: remaining }, (_, idx) => (
<div key={idx} className="bg-white border rounded p-3">
<div className="font-medium mb-2">Attendee {submittedCount + idx + 1}</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>
{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-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
<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>
)}
</div>
);
}
export default function FormsPage() {
return (
<Suspense fallback={<div className="p-6">Loading</div>}>
<FormsContent />
</Suspense>
);
}