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>
This commit is contained in:
2026-08-06 15:00:10 +02:00
co-authored by Claude Sonnet 5
parent d74fec3a5c
commit 8e6cb542d9
119 changed files with 5116 additions and 4316 deletions
+70 -36
View File
@@ -6,7 +6,9 @@ import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { QrImage } from "@/components/shared/QrImage";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
// Helper formatters
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
@@ -672,20 +674,23 @@ export default function UserDashboardPage() {
return (
<div className="max-w-6xl mx-auto w-full px-4 py-6 sm:px-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<h1 className="text-2xl font-semibold">Welcome{user ? `, ${user.name}` : ""}</h1>
<div className="max-w-6xl mx-auto w-full">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Welcome{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/reset-password")}
>Reset password</button>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/payments")}
>Payment history</button>
<button
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/donate")}
>Make donation</button>
</div>
@@ -695,16 +700,21 @@ export default function UserDashboardPage() {
{loading && <p className="text-gray-500 text-sm mb-3">Loading</p>}
<section className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold">My Registrations</h2>
<label className="text-sm flex items-center gap-2">
<section className="mb-6 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<ClipboardList className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">My Registrations</h2>
</div>
<label className="text-sm flex items-center gap-2 text-gray-600">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} />
<span>Show past events</span>
</label>
</div>
<p className="text-sm text-gray-600 mb-3 italic">
Click on registration to edit or make payment
<p className="text-sm text-gray-500 mb-3">
Click on a registration to edit or make a payment
</p>
{registrations.length === 0 ? (
<p className="text-gray-600 text-sm">No registrations yet.</p>
@@ -722,16 +732,20 @@ export default function UserDashboardPage() {
return (
<li
key={r.id}
className={"border rounded p-3 cursor-pointer " + (isCancelled ? "opacity-60 bg-gray-50" : "hover:bg-gray-50")}
className={"border rounded-xl p-3 cursor-pointer flex items-start gap-3 " + (isCancelled ? "opacity-60 bg-gray-50" : "hover:bg-gray-50 hover:border-brand-200")}
onClick={() => setActiveRegId(r.id)}
>
<div className="flex items-start justify-between gap-3">
<div>
<ApiImage src={r.event?.picture} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0 hidden sm:block" />
<div className="flex items-start justify-between gap-3 flex-1 min-w-0">
<div className="min-w-0">
<div className="font-medium flex items-center gap-2">
<span>{r.event?.title || r.eventId}</span>
<span className="truncate">{r.event?.title || r.eventId}</span>
<EventStatusBadge event={r.event} />
</div>
<div className="text-xs text-gray-600">
{r.event?.startDate && (
<div className="text-xs text-gray-500 mt-0.5">{formatDate(new Date(r.event.startDate))}</div>
)}
<div className="text-xs text-gray-600 mt-1">
Status: <RegistrationStatusBadge status={r.status} />
</div>
{(() => {
@@ -788,6 +802,7 @@ export default function UserDashboardPage() {
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 self-center hidden sm:block" />
</li>
);
})}
@@ -795,16 +810,24 @@ export default function UserDashboardPage() {
)}
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold mb-2">Upcoming Events</h2>
<div className="grid lg:grid-cols-2 gap-6">
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<Calendar className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
</div>
{upcomingEvents.length === 0 ? (
<p className="text-gray-600 text-sm">No upcoming events.</p>
) : (
<ul className="grid md:grid-cols-2 gap-3">
<ul className="space-y-3">
{upcomingEvents.map(ev => (
<li key={ev.id} className="border rounded p-4">
<li key={ev.id} className="border rounded-xl p-3 flex gap-3">
<ApiImage src={ev.picture} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0 hidden sm:block" />
<div className="min-w-0 flex-1">
<div className="font-medium flex items-center gap-2">
<span>{ev.title}</span>
<span className="truncate">{ev.title}</span>
<EventStatusBadge event={ev} />
</div>
<div className="text-sm text-gray-600">{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
@@ -817,7 +840,7 @@ export default function UserDashboardPage() {
}}
>Print all tickets</button>
<button
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => {
const ids = tickets.filter(t => t.eventId === ev.id).map(t => t.id);
emailTickets(ids);
@@ -833,15 +856,21 @@ export default function UserDashboardPage() {
>WhatsApp tickets</button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<section>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
<h2 className="text-xl font-semibold">My Tickets</h2>
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-3">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">My Tickets</h2>
</div>
{tickets.length > 0 && (
<div className="flex flex-wrap gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => selectAll(true)}>Select all</button>
@@ -852,7 +881,7 @@ export default function UserDashboardPage() {
onClick={() => printTickets(tickets.filter(t => selectedIds.includes(t.id)))}
>Print selected</button>
<button
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
disabled={selectedIds.length === 0}
onClick={() => emailTickets(selectedIds)}
>Email selected</button>
@@ -869,7 +898,7 @@ export default function UserDashboardPage() {
{tickets.filter(t => showPast || !isEventOver(t.event)).length === 0 ? (
<p className="text-gray-600 text-sm">No tickets yet.</p>
) : (
<ul className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
<ul className="grid sm:grid-cols-2 gap-3">
{tickets
.filter((t: any) => showPast || !isEventOver(t.event))
.map((t: any) => {
@@ -878,11 +907,15 @@ export default function UserDashboardPage() {
const variantName = t.registrationOption?.variant?.name;
const type = variantName ? `${optionName}${variantName}` : optionName;
return (
<li key={t.id} className="border rounded-xl p-3 space-y-2 bg-white shadow-sm">
<li key={t.id} className="border rounded-xl p-3 space-y-2 bg-gray-50">
<div className="flex items-center gap-2">
<ApiImage src={t.event?.picture} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" />
<span className="font-medium text-sm truncate flex-1">{t.event?.title || t.eventId}</span>
</div>
<div className="flex items-start justify-between">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={selected[t.id]} onChange={e => toggleSelect(t.id, e.target.checked)} />
<span className="font-medium">{t.event?.title || t.eventId}</span>
<span className="text-xs text-gray-500">Select</span>
</label>
<EventStatusBadge event={t.event} />
{eventDate && <span className="text-xs text-gray-500">{formatDate(eventDate)}</span>}
@@ -894,7 +927,7 @@ export default function UserDashboardPage() {
<div className="flex justify-center"><QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" /></div>
<div className="text-center text-xs text-gray-500">#{t.id.slice(0, 8)}</div>
<div className="flex gap-2 pt-1 flex-wrap">
<button onClick={() => emailTickets([t.id])} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Email</button>
<button onClick={() => emailTickets([t.id])} className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1">Email</button>
{user?.phoneNumber && (
<button onClick={() => whatsappTickets([t.id])} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1">WhatsApp</button>
)}
@@ -906,6 +939,7 @@ export default function UserDashboardPage() {
</ul>
)}
</section>
</div>
{activeReg && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
@@ -932,7 +966,7 @@ export default function UserDashboardPage() {
<div className="font-medium mb-1 flex items-center justify-between">
<span>Registration items</span>
{!editMode && canModifyActive && (
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">Edit</button>
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50">Edit</button>
)}
</div>
{!editMode ? (
@@ -1067,7 +1101,7 @@ export default function UserDashboardPage() {
<div className="flex flex-wrap gap-2 pt-2">
{showFormsButton && (
<button
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
>Attendee forms</button>
)}
@@ -1083,7 +1117,7 @@ export default function UserDashboardPage() {
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
printTickets(list);
}}>Print all tickets</button>
<button className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
<button className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
{user?.phoneNumber && (
<button className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" onClick={() => whatsappRegistration(activeReg.id)}>WhatsApp tickets</button>
)}
@@ -1130,7 +1164,7 @@ export default function UserDashboardPage() {
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full p-5" onClick={e => e.stopPropagation()}>
{dialog.loading ? (
<div className="flex flex-col items-center">
<div className="w-10 h-10 mb-3 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
<div className="w-10 h-10 mb-3 border-4 border-brand-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
<div className="text-base font-medium">{dialog.loadingTitle || "Sending tickets…"}</div>
<p className="text-sm text-gray-600 mt-1">{dialog.loadingSubtitle || "Please wait while we send your tickets."}</p>
</div>
@@ -1140,7 +1174,7 @@ export default function UserDashboardPage() {
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
<div className="flex justify-end">
<button
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => setDialog({ open: false, message: "", loading: false })}
>OK</button>
</div>