Files
hope-events/frontend/src/app/forms/page.tsx
T
joshuaandClaude Sonnet 5 8e6cb542d9 Full site redesign, help system, and dashboard stats fixes
Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:00:10 +02:00

276 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";
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'; 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="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">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-brand-600 text-white hover:bg-brand-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>
)}
</main>
<Footer />
</div>
);
}
export default function FormsPage() {
return (
<Suspense fallback={<div className="p-6">Loading</div>}>
<FormsContent />
</Suspense>
);
}