From f3e65254672bb45de18b42842d9db6d5e7c8b170 Mon Sep 17 00:00:00 2001 From: joshua Date: Fri, 24 Jul 2026 10:17:53 +0200 Subject: [PATCH] Add registration status badges and auto-dismissing dashboard messages Two consistency fixes requested after the payment-method work: 1. Registration status (pending/confirmed/partial_paid/paid/cancelled) was printed as a raw string on the user dashboard. Added RegistrationStatusBadge mirroring the existing EventStatusBadge pattern, using the same status colors already established on dashboard/admin/registrations. 2. Inline success/error banners across dashboard pages persisted indefinitely. Added a shared useDismissingState hook (drop-in useState replacement that auto-clears a truthy value after 7s, resetting the timer on each update) and swapped it in across ~24 dashboard files. Excluded: message-only modal dialogs (ticket- scanning's success/error confirmations) and two states that mix live form-validation feedback with async results inside actively- open forms (the registration-edit modal's editError, the event create/edit modal's error) - those keep persisting until the user acts, since auto-hiding a "fix this field" message mid-edit would be a regression. Also fixed at-the-door's existing bespoke auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same consistent 7s, and removed admin/settings' manual x dismiss button in favor of the same auto-only behavior. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 ++ .../app/dashboard/admin/cashup/[id]/page.tsx | 3 +- .../src/app/dashboard/admin/cashup/page.tsx | 3 +- .../dashboard/admin/registrations/page.tsx | 5 +-- .../src/app/dashboard/admin/settings/page.tsx | 19 +++++------ .../src/app/dashboard/admin/users/page.tsx | 3 +- .../src/app/dashboard/admin/whatsapp/page.tsx | 3 +- .../dashboard/staff/event-tickets/page.tsx | 3 +- .../dashboard/staff/ticket-scanning/page.tsx | 5 +-- .../dashboard/supervisor/at-the-door/page.tsx | 33 ++++--------------- .../supervisor/email-attendees/page.tsx | 5 +-- .../supervisor/event-options/page.tsx | 5 +-- .../app/dashboard/supervisor/events/page.tsx | 5 +-- .../app/dashboard/supervisor/forms/page.tsx | 5 +-- .../supervisor/manual-registration/page.tsx | 7 ++-- .../app/dashboard/supervisor/manual/page.tsx | 5 +-- .../dashboard/supervisor/payments/page.tsx | 5 +-- .../supervisor/whatsapp-attendees/page.tsx | 5 +-- .../src/app/dashboard/user/donate/page.tsx | 5 +-- .../src/app/dashboard/user/forms/page.tsx | 5 +-- frontend/src/app/dashboard/user/page.tsx | 23 ++++++++++--- frontend/src/app/dashboard/user/pay/page.tsx | 5 +-- .../src/app/dashboard/user/payments/page.tsx | 3 +- .../src/app/dashboard/user/profile/page.tsx | 9 ++--- .../dashboard/user/reset-password/page.tsx | 3 +- frontend/src/hooks/useDismissingState.ts | 24 ++++++++++++++ 26 files changed, 119 insertions(+), 79 deletions(-) create mode 100644 frontend/src/hooks/useDismissingState.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b17a58..71f134f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project follows [Semantic Versioning](https://semver.org/). ### Added - User dashboard: new "Payment history" page listing the user's own payments (donations excluded), with server-side pagination (25 per page), date range, method, and payment/refund filters. +- User dashboard: registration status (Pending/Confirmed/Partially Paid/Paid/Cancelled) is now shown as a colored badge, matching the existing event Closed/Past/Inactive badge convention, instead of a raw status string. +- Dashboard-wide: inline success/error/confirmation messages (e.g. after creating a manual registration on `/dashboard/supervisor/manual`) now auto-dismiss after 7 seconds instead of persisting indefinitely, via a new shared `useDismissingState` hook. Applied consistently across all dashboard pages with this pattern; excluded are message-only modal dialogs (e.g. ticket-scanning's success/error confirmations, which still require a manual OK) and a couple of mixed validation/async error states shown inside actively-open forms (the registration-edit modal and the event create/edit modal), which continue to persist until the user acts. ### Fixed diff --git a/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx index 17d4409..e0a465a 100644 --- a/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx +++ b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types"; const METHOD_LABELS: Record = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" }; @@ -29,7 +30,7 @@ export default function EventCashupPage() { const [data, setData] = useState(null); const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useDismissingState(null); const [busy, setBusy] = useState(false); const isClosed = data?.event?.cashupStatus === "closed"; diff --git a/frontend/src/app/dashboard/admin/cashup/page.tsx b/frontend/src/app/dashboard/admin/cashup/page.tsx index 48c6cf8..f74ae14 100644 --- a/frontend/src/app/dashboard/admin/cashup/page.tsx +++ b/frontend/src/app/dashboard/admin/cashup/page.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; export default function CashupLandingPage() { const { token } = useAuth(); @@ -11,7 +12,7 @@ export default function CashupLandingPage() { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useDismissingState(null); const [search, setSearch] = useState(""); const [showPast, setShowPast] = useState(true); const [showInactive, setShowInactive] = useState(false); diff --git a/frontend/src/app/dashboard/admin/registrations/page.tsx b/frontend/src/app/dashboard/admin/registrations/page.tsx index 7ec0d88..f9abc0c 100644 --- a/frontend/src/app/dashboard/admin/registrations/page.tsx +++ b/frontend/src/app/dashboard/admin/registrations/page.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const; @@ -29,8 +30,8 @@ export default function AdminRegistrationsPage() { const [registrations, setRegistrations] = useState([]); const [events, setEvents] = useState([]); const [loadingRegs, setLoadingRegs] = useState(false); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); // Filters const [query, setQuery] = useState(""); diff --git a/frontend/src/app/dashboard/admin/settings/page.tsx b/frontend/src/app/dashboard/admin/settings/page.tsx index b2aa25e..88d1070 100644 --- a/frontend/src/app/dashboard/admin/settings/page.tsx +++ b/frontend/src/app/dashboard/admin/settings/page.tsx @@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api"; import { useSiteSettings } from "@/contexts/SiteSettingsContext"; +import { useDismissingState } from "@/hooks/useDismissingState"; type TabId = "organisation" | "branding" | "notifications" | "email" | "legal"; @@ -36,19 +37,17 @@ function Field({ } function SaveBar({ - saving, onSave, result, onDismiss, + saving, onSave, result, }: { saving: boolean; onSave: () => void; result: { ok: boolean; message: string } | null; - onDismiss: () => void; }) { return (
{result ? ( {result.ok ? "✓" : "✗"} {result.message} - ) : ( @@ -75,7 +74,7 @@ export default function SiteSettingsPage() { // Per-tab save state const [saving, setSaving] = useState(false); - const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); + const [result, setResult] = useDismissingState<{ ok: boolean; message: string } | null>(null); // ── Organisation ── const [orgName, setOrgName] = useState(""); @@ -104,7 +103,7 @@ export default function SiteSettingsPage() { const [smtpPass, setSmtpPass] = useState(""); const [smtpPassSet, setSmtpPassSet] = useState(false); const [smtpTesting, setSmtpTesting] = useState(false); - const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; message: string; raw?: string } | null>(null); + const [smtpTestResult, setSmtpTestResult] = useDismissingState<{ ok: boolean; message: string; raw?: string } | null>(null); // ── Legal ── const [legalOperatorName, setLegalOperatorName] = useState(""); @@ -332,7 +331,7 @@ export default function SiteSettingsPage() { setAppBaseUrl(e.target.value)} /> - setResult(null)} /> +
)} @@ -371,7 +370,7 @@ export default function SiteSettingsPage() { }} className="text-sm" /> - setResult(null)} /> + )} @@ -385,7 +384,7 @@ export default function SiteSettingsPage() { setNotifEmails(e.target.value)} /> - setResult(null)} /> + )} @@ -464,7 +463,7 @@ export default function SiteSettingsPage() { )} - setResult(null)} /> + )} @@ -503,7 +502,7 @@ export default function SiteSettingsPage() { - setResult(null)} /> + )} diff --git a/frontend/src/app/dashboard/admin/users/page.tsx b/frontend/src/app/dashboard/admin/users/page.tsx index 80380e6..2744a0c 100644 --- a/frontend/src/app/dashboard/admin/users/page.tsx +++ b/frontend/src/app/dashboard/admin/users/page.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; interface UserItem { id: string; @@ -43,7 +44,7 @@ export default function AdminUsersPage() { // Data state const [users, setUsers] = useState([]); const [fetching, setFetching] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useDismissingState(null); const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [total, setTotal] = useState(0); diff --git a/frontend/src/app/dashboard/admin/whatsapp/page.tsx b/frontend/src/app/dashboard/admin/whatsapp/page.tsx index 0f00b17..5ae404d 100644 --- a/frontend/src/app/dashboard/admin/whatsapp/page.tsx +++ b/frontend/src/app/dashboard/admin/whatsapp/page.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -123,7 +124,7 @@ export default function WhatsAppAdminPage() { const [pairingPhone, setPairingPhone] = useState(""); // ── Shared action feedback ─────────────────────────────────────────────────── - const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); + const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null); const [busy, setBusy] = useState(null); // ── Load config ────────────────────────────────────────────────────────────── diff --git a/frontend/src/app/dashboard/staff/event-tickets/page.tsx b/frontend/src/app/dashboard/staff/event-tickets/page.tsx index 7d7fd85..4132fcb 100644 --- a/frontend/src/app/dashboard/staff/event-tickets/page.tsx +++ b/frontend/src/app/dashboard/staff/event-tickets/page.tsx @@ -3,6 +3,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; import { useRouter, useSearchParams } from "next/navigation"; import { formatDate } from "@/lib/date"; import { QrImage } from "@/components/shared/QrImage"; @@ -18,7 +19,7 @@ function EventTicketsContent() { const [events, setEvents] = useState([]); const [eventId, setEventId] = useState(paramEventId); const [tickets, setTickets] = useState([]); - const [error, setError] = useState(null); + const [error, setError] = useDismissingState(null); const [loading, setLoading] = useState(false); const [selectedUserId, setSelectedUserId] = useState(paramUserId); diff --git a/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx index 98364ad..6ba6019 100644 --- a/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx +++ b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx @@ -4,14 +4,15 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; import { useRouter } from "next/navigation"; export default function TicketScanningPage() { const router = useRouter(); const scannerRef = useRef(null); const [lastResult, setLastResult] = useState(null); - const [scanInfo, setScanInfo] = useState(null); - const [error, setError] = useState(null); + const [scanInfo, setScanInfo] = useDismissingState(null); + const [error, setError] = useDismissingState(null); const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null); const [errorModal, setErrorModal] = useState(null); const { token, user } = useAuth(); diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx index d0aa52d..981f3df 100644 --- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; import { scoreUser } from "@/lib/fuzzyMatch"; +import { useDismissingState } from "@/hooks/useDismissingState"; type Mode = "registration" | "payment" | "tickets" | "refund"; @@ -154,30 +155,8 @@ export default function AtTheDoorPage() { const [mode, setMode] = useState("registration"); const [activeRegistration, setActiveRegistration] = useState(null); - const [info, setInfo] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - if (!info) return; - - const id = setTimeout(() => { - setInfo(null); - }, 10000); // ⏱ disappears after 5s - - return () => clearTimeout(id); - - }, [info]); - - useEffect(() => { - if (!error) return; - - const id = setTimeout(() => { - setError(null); - }, 15000); // errors linger slightly longer - - return () => clearTimeout(id); - - }, [error]); + const [info, setInfo] = useDismissingState(null); + const [error, setError] = useDismissingState(null); const handleRegistrationCreated = (registration: any) => { setActiveRegistration(registration); @@ -1332,7 +1311,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf const [phone, setPhone] = useState(""); const [email, setEmail] = useState(""); const [sending, setSending] = useState(false); - const [localError, setLocalError] = useState(""); + const [localError, setLocalError] = useDismissingState(""); const [localInfo, setLocalInfo] = useState(""); useEffect(() => { @@ -1369,7 +1348,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf }, }); setLocalInfo("Tickets sent successfully."); - setTimeout(() => { setLocalInfo(""); onClose(); }, 2000); + setTimeout(() => { setLocalInfo(""); onClose(); }, 7000); } catch (e: any) { setLocalError(e?.message || "Failed to send tickets"); } finally { @@ -1461,7 +1440,7 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded const [method, setMethod] = useState("cash"); const [reason, setReason] = useState(""); const [saving, setSaving] = useState(false); - const [localError, setLocalError] = useState(""); + const [localError, setLocalError] = useDismissingState(""); useEffect(() => { if (open) { setAmount(""); setReason(""); setLocalError(""); } diff --git a/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx b/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx index 53c2195..defc9e7 100644 --- a/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx +++ b/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx @@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter, useSearchParams } from "next/navigation"; import { apiFetch, fetchAllUsers } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; type Attendee = { id: string; name: string; email: string; pref: string }; @@ -171,8 +172,8 @@ function EmailAttendeesPageInner() { // Load events for selection const [loadingEvents, setLoadingEvents] = useState(false); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); // Tabs: attendees (current), automations (coming soon), broadcasts const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees'); diff --git a/frontend/src/app/dashboard/supervisor/event-options/page.tsx b/frontend/src/app/dashboard/supervisor/event-options/page.tsx index 98ed6d9..8cd545c 100644 --- a/frontend/src/app/dashboard/supervisor/event-options/page.tsx +++ b/frontend/src/app/dashboard/supervisor/event-options/page.tsx @@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter, useSearchParams } from "next/navigation"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; // Format a Date (or date-like input) to the value expected by // This returns local time (browser timezone) as YYYY-MM-DDTHH:mm @@ -112,8 +113,8 @@ function EventOptionsContent() { const [selectedEventId, setSelectedEventId] = useState(""); const [options, setOptions] = useState([]); const [loadingEv, setLoadingEv] = useState(false); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); const loadEvents = async () => { if (!token) return; diff --git a/frontend/src/app/dashboard/supervisor/events/page.tsx b/frontend/src/app/dashboard/supervisor/events/page.tsx index 4e1a42a..ec406d8 100644 --- a/frontend/src/app/dashboard/supervisor/events/page.tsx +++ b/frontend/src/app/dashboard/supervisor/events/page.tsx @@ -5,6 +5,7 @@ import { createPortal } from "react-dom"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch, resolveToApiOrigin } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -690,7 +691,7 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even const [selected, setSelected] = useState(initialRecipients || []); const [loading, setLoading] = useState(!hasInitial); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useDismissingState(null); useEffect(() => { if (hasInitial) return; // already have the data — skip the network round trip entirely @@ -1415,7 +1416,7 @@ export default function ManageEventsPage() { const [events, setEvents] = useState([]); const [loadingEvents, setLoadingEvents] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useDismissingState(null); const loadEvents = async () => { if (!token) return; diff --git a/frontend/src/app/dashboard/supervisor/forms/page.tsx b/frontend/src/app/dashboard/supervisor/forms/page.tsx index 1999dac..853e4ff 100644 --- a/frontend/src/app/dashboard/supervisor/forms/page.tsx +++ b/frontend/src/app/dashboard/supervisor/forms/page.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch, fetchAllUsers } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph'; @@ -144,8 +145,8 @@ export default function FormsBrowserPage() { const [items, setItems] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [loadingList, setLoadingList] = useState(false); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); const loadEvents = async () => { if (!token) return; diff --git a/frontend/src/app/dashboard/supervisor/manual-registration/page.tsx b/frontend/src/app/dashboard/supervisor/manual-registration/page.tsx index 49db1b6..d620ac4 100644 --- a/frontend/src/app/dashboard/supervisor/manual-registration/page.tsx +++ b/frontend/src/app/dashboard/supervisor/manual-registration/page.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null }; @@ -18,7 +19,7 @@ export default function ManualRegistrationPage() { const [phoneNumber, setPhoneNumber] = useState(""); const [registerAsGuest, setRegisterAsGuest] = useState(false); const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useDismissingState(null); const [createdReg, setCreatedReg] = useState(null); const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null); const [formsData, setFormsData] = useState>>({}); @@ -110,8 +111,8 @@ export default function ManualRegistrationPage() { function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record>; setFormsData: any; }) { const { token } = useAuth(); const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0); const count = Math.max(0, mainTickets); diff --git a/frontend/src/app/dashboard/supervisor/manual/page.tsx b/frontend/src/app/dashboard/supervisor/manual/page.tsx index 69118be..dcac4a0 100644 --- a/frontend/src/app/dashboard/supervisor/manual/page.tsx +++ b/frontend/src/app/dashboard/supervisor/manual/page.tsx @@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch, fetchAllUsers } from "@/lib/api"; import { scoreUser } from "@/lib/fuzzyMatch"; +import { useDismissingState } from "@/hooks/useDismissingState"; // ─── Pricing helpers ───────────────────────────────────────────────────────── @@ -72,8 +73,8 @@ export default function ManualRegistrationPage() { const searchRef = useRef(null); const [submitting, setSubmitting] = useState(false); - const [message, setMessage] = useState(null); - const [error, setError] = useState(null); + const [message, setMessage] = useDismissingState(null); + const [error, setError] = useDismissingState(null); // Load all users for client-side fuzzy matching useEffect(() => { diff --git a/frontend/src/app/dashboard/supervisor/payments/page.tsx b/frontend/src/app/dashboard/supervisor/payments/page.tsx index 03a07a0..32963ee 100644 --- a/frontend/src/app/dashboard/supervisor/payments/page.tsx +++ b/frontend/src/app/dashboard/supervisor/payments/page.tsx @@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter, useSearchParams } from "next/navigation"; import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; import { scoreUser } from "@/lib/fuzzyMatch"; function RegistrationOptions({ regs, regOutstanding }: { @@ -115,8 +116,8 @@ function PaymentsContent() { const [payments, setPayments] = useState([]); const [loadingList, setLoadingList] = useState(false); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); const [registrations, setRegistrations] = useState([]); const [loadingRegs, setLoadingRegs] = useState(false); const [regOutstanding, setRegOutstanding] = useState>({}); diff --git a/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx b/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx index e80e153..ef02d1d 100644 --- a/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx +++ b/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx @@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter, useSearchParams } from "next/navigation"; import { apiFetch, fetchAllUsers } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; // Attendee with preference info type Attendee = { id: string; name: string; phone: string; pref: string }; @@ -358,8 +359,8 @@ function WhatsAppAttendeesPageInner() { if (!user) router.replace("/login"); }, [user, loading, router]); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees"); const [loadingEvents, setLoadingEvents] = useState(false); const [allEvents, setAllEvents] = useState([]); diff --git a/frontend/src/app/dashboard/user/donate/page.tsx b/frontend/src/app/dashboard/user/donate/page.tsx index ae34a9d..e17ba79 100644 --- a/frontend/src/app/dashboard/user/donate/page.tsx +++ b/frontend/src/app/dashboard/user/donate/page.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; export default function DonatePage() { const { token } = useAuth(); @@ -9,8 +10,8 @@ export default function DonatePage() { const [eventId, setEventId] = useState(""); const [amount, setAmount] = useState(""); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); useEffect(() => { (async () => { diff --git a/frontend/src/app/dashboard/user/forms/page.tsx b/frontend/src/app/dashboard/user/forms/page.tsx index 2aa1faa..222d298 100644 --- a/frontend/src/app/dashboard/user/forms/page.tsx +++ b/frontend/src/app/dashboard/user/forms/page.tsx @@ -3,6 +3,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; // Types for form fields type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null }; @@ -16,8 +17,8 @@ function FormsContent() { const [registration, setRegistration] = useState(null); const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); // Local entry state for new responses const [formsData, setFormsData] = useState>>({}); diff --git a/frontend/src/app/dashboard/user/page.tsx b/frontend/src/app/dashboard/user/page.tsx index 8180b5f..9c7868a 100644 --- a/frontend/src/app/dashboard/user/page.tsx +++ b/frontend/src/app/dashboard/user/page.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { formatDate } from "@/lib/date"; import { formatPaymentMethod } from "@/lib/paymentMethod"; import { QrImage } from "@/components/shared/QrImage"; +import { useDismissingState } from "@/hooks/useDismissingState"; // Helper formatters const formatRand = (n: number) => `R ${n.toFixed(2)}`; @@ -30,6 +31,20 @@ function EventStatusBadge({ event }: { event: any }) { return {label}; } +// Status badge for a registration, shown wherever registration.status is displayed on this +// page. Colors match the status coloring already used on dashboard/admin/registrations. +const REGISTRATION_STATUS_STYLES: Record = { + pending: { label: 'Pending', className: 'bg-gray-100 text-gray-600' }, + confirmed: { label: 'Confirmed', className: 'bg-blue-50 text-blue-700' }, + partial_paid: { label: 'Partially Paid', className: 'bg-amber-50 text-amber-700' }, + paid: { label: 'Paid', className: 'bg-green-50 text-green-700' }, + cancelled: { label: 'Cancelled', className: 'bg-red-50 text-red-700' }, +}; +function RegistrationStatusBadge({ status }: { status: string }) { + const s = REGISTRATION_STATUS_STYLES[status] || { label: status, className: 'bg-gray-100 text-gray-500' }; + return {s.label}; +} + // Effective unit price for an event option (or one of its variants), early-bird aware. // Used by the registration editor, which works off raw /api/events/:id data rather than // a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx. @@ -53,8 +68,8 @@ export default function UserDashboardPage() { const { token, user } = useAuth(); const [registrations, setRegistrations] = useState([]); const [tickets, setTickets] = useState([]); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); const [loading, setLoading] = useState(false); // Filters @@ -682,7 +697,7 @@ export default function UserDashboardPage() {
- Status: {r.status}{isCancelled && cancelled} + Status:
{(() => { const fs = formStatuses[r.id]; @@ -869,7 +884,7 @@ export default function UserDashboardPage() {
-
Status: {activeReg.status}
+
Status:
{activeBill && (
Total: {formatRand(activeBill.totalDue)}
diff --git a/frontend/src/app/dashboard/user/pay/page.tsx b/frontend/src/app/dashboard/user/pay/page.tsx index f438a54..d69327e 100644 --- a/frontend/src/app/dashboard/user/pay/page.tsx +++ b/frontend/src/app/dashboard/user/pay/page.tsx @@ -3,14 +3,15 @@ import React, { Suspense, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; function MakePaymentContent() { const searchParams = useSearchParams(); const registrationId = searchParams.get("registrationId"); const { token } = useAuth(); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [info, setInfo] = useState(null); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); const [registration, setRegistration] = useState(null); const [payments, setPayments] = useState([]); const [amount, setAmount] = useState(""); diff --git a/frontend/src/app/dashboard/user/payments/page.tsx b/frontend/src/app/dashboard/user/payments/page.tsx index 26af19e..3eaf317 100644 --- a/frontend/src/app/dashboard/user/payments/page.tsx +++ b/frontend/src/app/dashboard/user/payments/page.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; import { formatDateTime } from "@/lib/date"; import { formatPaymentMethod } from "@/lib/paymentMethod"; @@ -30,7 +31,7 @@ export default function UserPaymentsPage() { const [payments, setPayments] = useState([]); const [fetching, setFetching] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useDismissingState(null); const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [total, setTotal] = useState(0); diff --git a/frontend/src/app/dashboard/user/profile/page.tsx b/frontend/src/app/dashboard/user/profile/page.tsx index 35009c3..3ddc5ca 100644 --- a/frontend/src/app/dashboard/user/profile/page.tsx +++ b/frontend/src/app/dashboard/user/profile/page.tsx @@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; import { isValidZAPhone } from "@/lib/phone"; +import { useDismissingState } from "@/hooks/useDismissingState"; export default function UserProfilePage() { const { user, token, logout, updateToken } = useAuth(); @@ -19,7 +20,7 @@ export default function UserProfilePage() { const [email, setEmail] = useState(""); const [phone, setPhone] = useState(""); const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email"); - const [profileMsg, setProfileMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); + const [profileMsg, setProfileMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null); const [savingProfile, setSavingProfile] = useState(false); const hasValidPhone = isValidZAPhone(phone); @@ -63,7 +64,7 @@ export default function UserProfilePage() { const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [pwMsg, setPwMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); + const [pwMsg, setPwMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null); const [savingPw, setSavingPw] = useState(false); const changePassword = async (e: React.FormEvent) => { @@ -98,7 +99,7 @@ export default function UserProfilePage() { }; // ── Revoke sessions ─────────────────────────────────────────────────────── - const [revokeMsg, setRevokeMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); + const [revokeMsg, setRevokeMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null); const [revoking, setRevoking] = useState(false); const revokeSessions = async () => { @@ -126,7 +127,7 @@ export default function UserProfilePage() { const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle"); const [deleteData, setDeleteData] = useState(false); const [closePassword, setClosePassword] = useState(""); - const [closeMsg, setCloseMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); + const [closeMsg, setCloseMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null); const [closing, setClosing] = useState(false); const submitAccountClosure = async (e: React.FormEvent) => { diff --git a/frontend/src/app/dashboard/user/reset-password/page.tsx b/frontend/src/app/dashboard/user/reset-password/page.tsx index 606fba5..59bc143 100644 --- a/frontend/src/app/dashboard/user/reset-password/page.tsx +++ b/frontend/src/app/dashboard/user/reset-password/page.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; import { useRouter } from "next/navigation"; export default function ResetPasswordPage() { @@ -11,7 +12,7 @@ export default function ResetPasswordPage() { const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [status, setStatus] = useState(null); - const [error, setError] = useState(null); + const [error, setError] = useDismissingState(null); const [loading, setLoading] = useState(false); const canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]); diff --git a/frontend/src/hooks/useDismissingState.ts b/frontend/src/hooks/useDismissingState.ts new file mode 100644 index 0000000..7fdf683 --- /dev/null +++ b/frontend/src/hooks/useDismissingState.ts @@ -0,0 +1,24 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * Like useState, but a truthy value auto-clears back to the initial falsy value after + * `ms` milliseconds. Every call to the setter cancels any pending timer and (if the new + * value is truthy) arms a fresh one, so rapid-fire updates reset the countdown instead of + * cutting it short. Meant for post-action confirmation/error banners that shouldn't linger. + */ +export function useDismissingState(initial: T, ms = 7000) { + const [value, setValue] = useState(initial); + const timeoutRef = useRef | null>(null); + + const set = useCallback((next: T) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setValue(next); + if (next) { + timeoutRef.current = setTimeout(() => setValue(initial), ms); + } + }, [ms, initial]); + + useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []); + + return [value, set] as const; +}