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
+35 -23
View File
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { HandHeart } from "lucide-react";
export default function DonatePage() {
const { token } = useAuth();
@@ -57,32 +58,43 @@ export default function DonatePage() {
return (
<div className="max-w-xl mx-auto w-full p-6">
<h1 className="text-2xl font-semibold mb-4">Make a donation</h1>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-rose-50 flex items-center justify-center shrink-0">
<HandHeart className="w-5 h-5 text-rose-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">Make a donation</h1>
<p className="text-sm text-gray-500">Support an event or the ministry directly.</p>
</div>
</div>
<label className="block text-sm font-medium mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 mb-3" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
</select>
<div className="border rounded-xl p-5 bg-white shadow-sm">
{error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>}
<label className="block text-sm font-medium mb-1">Amount (R)</label>
<input
type="number"
min="15"
step="1"
placeholder="Enter amount (min R15)"
value={amount}
onChange={(e)=>setAmount(e.target.value)}
className="w-full border rounded px-3 py-2 mb-1"
/>
<p className="text-xs text-gray-600 mb-3">Minimum donation is R15.</p>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select className="w-full border rounded-lg px-3 py-2 mb-4 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
</select>
<button
disabled={loading}
onClick={submit}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
>{loading?"Starting checkout...":"Donate"}</button>
<label className="block text-sm font-medium text-gray-700 mb-1">Amount (R)</label>
<input
type="number"
min="15"
step="1"
placeholder="Enter amount (min R15)"
value={amount}
onChange={(e)=>setAmount(e.target.value)}
className="w-full border rounded-lg px-3 py-2 mb-1 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
<p className="text-xs text-gray-500 mb-4">Minimum donation is R15.</p>
<button
disabled={loading}
onClick={submit}
className="w-full bg-brand-600 hover:bg-brand-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-60 font-medium transition-colors"
>{loading?"Starting checkout...":"Donate"}</button>
</div>
</div>
);
}
+10 -4
View File
@@ -4,6 +4,7 @@ import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
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 };
@@ -130,9 +131,14 @@ function FormsContent() {
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={() => router.push('/dashboard/user')}>Back</button>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<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" onClick={() => router.push('/dashboard/user')}>Back</button>
</div>
{loading && <div className="text-sm text-gray-600 mb-2">Loading</div>}
@@ -214,7 +220,7 @@ function FormsContent() {
))}
</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-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>
+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>
@@ -6,6 +6,7 @@ import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { formatDateTime } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { Receipt } from "lucide-react";
interface PaymentItem {
id: string;
@@ -74,10 +75,15 @@ export default function UserPaymentsPage() {
return (
<div className="max-w-4xl mx-auto w-full px-4 py-6 sm:px-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">My Payments</h1>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<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">
<Receipt className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">My Payments</h1>
</div>
<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-lg 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")}
>Back to dashboard</button>
</div>
@@ -183,7 +189,7 @@ export default function UserPaymentsPage() {
) : (
<button
key={p}
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
className={`px-2 py-1 rounded ${page === p ? "bg-brand-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
disabled={fetching}
onClick={() => goToPage(p as number)}
>{p}</button>
+228 -199
View File
@@ -6,6 +6,15 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
import { User, Lock, ShieldAlert, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset"; device: string | null; createdAt: string };
const ACTIVITY_META: Record<ActivityEvent["type"], { label: (device: string | null) => string; icon: typeof LogIn }> = {
login: { label: d => `Logged in${d ? ` from ${d}` : ""}`, icon: LogIn },
password_changed: { label: () => "Password changed", icon: KeyRound },
password_reset: { label: () => "Password reset via email link", icon: RotateCcw },
};
export default function UserProfilePage() {
const { user, token, logout, updateToken } = useAuth();
@@ -91,6 +100,7 @@ export default function UserProfilePage() {
setNewPassword("");
setConfirmPassword("");
setPwMsg({ type: "ok", text: "Password changed." });
loadActivity();
} catch (e: any) {
setPwMsg({ type: "err", text: e?.message || "Failed to change password." });
} finally {
@@ -123,6 +133,25 @@ export default function UserProfilePage() {
}
};
// ── Account activity ──────────────────────────────────────────────────────
const [activity, setActivity] = useState<ActivityEvent[]>([]);
const [loadingActivity, setLoadingActivity] = useState(false);
const loadActivity = async () => {
if (!token) return;
setLoadingActivity(true);
try {
const res = await apiFetch<ActivityEvent[]>("/api/users/activity", { authToken: token });
setActivity(Array.isArray(res) ? res : []);
} catch (e) {
// Non-critical — just leave the list empty rather than showing an error banner.
} finally {
setLoadingActivity(false);
}
};
useEffect(() => { loadActivity(); }, [token]);
// ── Account closure ───────────────────────────────────────────────────────
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
const [deleteData, setDeleteData] = useState(false);
@@ -153,214 +182,214 @@ export default function UserProfilePage() {
// ── Shared helpers ────────────────────────────────────────────────────────
const Alert = ({ msg }: { msg: { type: "ok" | "err"; text: string } }) => (
<div className={`mt-3 p-3 rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
<div className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
{msg.text}
</div>
);
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const SectionHeader = ({ icon: Icon, title, tone = "brand" }: { icon: typeof User; title: string; tone?: "brand" | "amber" | "red" }) => {
const toneCls = tone === "amber" ? "bg-amber-50 text-amber-600" : tone === "red" ? "bg-red-50 text-red-600" : "bg-brand-50 text-brand-600";
return (
<div className="flex items-center gap-2 mb-4">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${toneCls}`}>
<Icon className="w-5 h-5" />
</div>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
</div>
);
};
return (
<div className="max-w-2xl mx-auto w-full p-6 space-y-8">
<h1 className="text-2xl font-semibold">Profile &amp; Security</h1>
<div className="max-w-4xl mx-auto w-full">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Profile &amp; Security</h1>
<p className="text-sm text-gray-500 mt-1">Manage your personal information and keep your account secure.</p>
</div>
{/* ── Profile info ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-4">Personal information</h2>
<form onSubmit={saveProfile} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input
type="email"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
<input
type="tel"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={phone}
onChange={e => setPhone(e.target.value)}
placeholder="e.g. 082 123 4567"
/>
</div>
<div className="grid lg:grid-cols-2 gap-6 items-start">
<div className="space-y-6">
{/* ── Profile info ─────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={User} title="Personal information" />
<form onSubmit={saveProfile} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input type="email" className={inputCls} value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
<input type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
</div>
{hasValidPhone && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
<select
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={notifPref}
onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}
>
<option value="email">Email only</option>
<option value="whatsapp">WhatsApp only</option>
<option value="both">Email &amp; WhatsApp</option>
</select>
<p className="mt-1 text-xs text-gray-500">Security alerts (password reset, login notifications) are always sent via email regardless of this setting.</p>
</div>
)}
{hasValidPhone && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
<select className={inputCls} value={notifPref} onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}>
<option value="email">Email only</option>
<option value="whatsapp">WhatsApp only</option>
<option value="both">Email &amp; WhatsApp</option>
</select>
<p className="mt-1 text-xs text-gray-500">Security alerts (password reset, login notifications) are always sent via email regardless of this setting.</p>
</div>
)}
<button
type="submit"
disabled={savingProfile}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingProfile ? "Saving…" : "Save changes"}
</button>
{profileMsg && <Alert msg={profileMsg} />}
</form>
</section>
{/* ── Password ─────────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-4">Change password</h2>
<form onSubmit={changePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={savingPw}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingPw ? "Changing…" : "Change password"}
</button>
{pwMsg && <Alert msg={pwMsg} />}
</form>
</section>
{/* ── Security ─────────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-1">Security</h2>
<p className="text-sm text-gray-500 mb-4">
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
You will remain logged in on this device.
</p>
<button
onClick={revokeSessions}
disabled={revoking}
className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{revoking ? "Signing out…" : "Sign out all other devices"}
</button>
{revokeMsg && <Alert msg={revokeMsg} />}
</section>
{/* ── Danger zone ──────────────────────────────────────────────────── */}
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold text-red-700 mb-1">Close account</h2>
<p className="text-sm text-gray-500 mb-4">
Closing your account will deactivate it immediately. You can also request that your personal
information (name, email, phone number) be permanently deleted. Tickets and payment records
will remain for accounting purposes but will show as &quot;Deleted User&quot;.
</p>
{closeStep === "idle" && (
<button
onClick={() => setCloseStep("confirm")}
className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50"
>
Close my account
</button>
)}
{closeStep === "confirm" && (
<form onSubmit={submitAccountClosure} className="space-y-4">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<strong>This action cannot be undone.</strong> Please read carefully before continuing.
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={deleteData}
onChange={e => setDeleteData(e.target.checked)}
/>
<span className="text-sm text-gray-700">
<span className="font-medium">Also delete my personal data</span> your name, email address,
and phone number will be permanently removed and cannot be recovered.
</span>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm with your password
</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
value={closePassword}
onChange={e => setClosePassword(e.target.value)}
required
placeholder="Enter your password to confirm"
autoComplete="current-password"
/>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => { setCloseStep("idle"); setClosePassword(""); setDeleteData(false); setCloseMsg(null); }}
className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200"
>
Cancel
<button type="submit" disabled={savingProfile} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{savingProfile ? "Saving…" : "Save changes"}
</button>
<button
type="submit"
disabled={closing || !closePassword}
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
>
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
{profileMsg && <Alert msg={profileMsg} />}
</form>
</section>
{/* ── Account activity ─────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Clock} title="Account activity" />
{loadingActivity && activity.length === 0 ? (
<p className="text-sm text-gray-400">Loading</p>
) : activity.length === 0 ? (
<p className="text-sm text-gray-500">No recent activity recorded yet.</p>
) : (
<ul className="space-y-3">
{activity.map(ev => {
const meta = ACTIVITY_META[ev.type] || ACTIVITY_META.login;
const Icon = meta.icon;
return (
<li key={ev.id} className="flex items-start gap-3">
<div className="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center shrink-0 mt-0.5">
<Icon className="w-3.5 h-3.5 text-gray-500" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm text-gray-800">{meta.label(ev.device)}</div>
<div className="text-xs text-gray-400">{new Date(ev.createdAt).toLocaleString()}</div>
</div>
</li>
);
})}
</ul>
)}
</section>
</div>
<div className="space-y-6">
{/* ── Password ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Lock} title="Change password" />
<form onSubmit={changePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input type="password" className={inputCls} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required autoComplete="current-password" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input type="password" className={inputCls} value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} autoComplete="new-password" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
<input type="password" className={inputCls} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required autoComplete="new-password" />
</div>
<button type="submit" disabled={savingPw} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{savingPw ? "Changing…" : "Change password"}
</button>
</div>
{closeMsg && <Alert msg={closeMsg} />}
</form>
)}
</section>
{pwMsg && <Alert msg={pwMsg} />}
</form>
</section>
{/* ── Security ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={ShieldAlert} title="Security" tone="amber" />
<p className="text-sm text-gray-500 mb-4">
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
You will remain logged in on this device.
</p>
<button
onClick={revokeSessions}
disabled={revoking}
className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{revoking ? "Signing out…" : "Sign out all other devices"}
</button>
{revokeMsg && <Alert msg={revokeMsg} />}
</section>
{/* ── Danger zone ──────────────────────────────────────────────── */}
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Trash2} title="Close account" tone="red" />
<p className="text-sm text-gray-500 mb-4">
Closing your account will deactivate it immediately. You can also request that your personal
information (name, email, phone number) be permanently deleted. Tickets and payment records
will remain for accounting purposes but will show as &quot;Deleted User&quot;.
</p>
{closeStep === "idle" && (
<button
onClick={() => setCloseStep("confirm")}
className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50"
>
Close my account
</button>
)}
{closeStep === "confirm" && (
<form onSubmit={submitAccountClosure} className="space-y-4">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<strong>This action cannot be undone.</strong> Please read carefully before continuing.
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={deleteData}
onChange={e => setDeleteData(e.target.checked)}
/>
<span className="text-sm text-gray-700">
<span className="font-medium">Also delete my personal data</span> your name, email address,
and phone number will be permanently removed and cannot be recovered.
</span>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm with your password
</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
value={closePassword}
onChange={e => setClosePassword(e.target.value)}
required
placeholder="Enter your password to confirm"
autoComplete="current-password"
/>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => { setCloseStep("idle"); setClosePassword(""); setDeleteData(false); setCloseMsg(null); }}
className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200"
>
Cancel
</button>
<button
type="submit"
disabled={closing || !closePassword}
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
>
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
</button>
</div>
{closeMsg && <Alert msg={closeMsg} />}
</form>
)}
</section>
</div>
</div>
</div>
);
}
}
@@ -4,6 +4,7 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
import { KeyRound } from "lucide-react";
export default function ResetPasswordPage() {
const { token } = useAuth();
@@ -43,10 +44,15 @@ export default function ResetPasswordPage() {
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">Reset password</h1>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<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">
<KeyRound className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Reset password</h1>
</div>
<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-lg 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")}
>Back to dashboard</button>
</div>
@@ -96,7 +102,7 @@ export default function ResetPasswordPage() {
<button
type="submit"
disabled={!canSubmit || loading}
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-60"
className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60"
>{loading ? "Saving…" : "Update password"}</button>
<button
type="button"