Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
"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>
);
}