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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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's what'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>
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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 "Deleted User".
|
||||
</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 "Deleted User".
|
||||
</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"
|
||||
|
||||
Reference in New Issue
Block a user