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 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 10:17:53 +02:00
co-authored by Claude Sonnet 5
parent 193739c042
commit f3e6525467
26 changed files with 119 additions and 79 deletions
+2
View File
@@ -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
@@ -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<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
@@ -29,7 +30,7 @@ export default function EventCashupPage() {
const [data, setData] = useState<EventFinancials | null>(null);
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [busy, setBusy] = useState(false);
const isClosed = data?.event?.cashupStatus === "closed";
@@ -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<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [search, setSearch] = useState("");
const [showPast, setShowPast] = useState(true);
const [showInactive, setShowInactive] = useState(false);
@@ -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<any[]>([]);
const [events, setEvents] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
// Filters
const [query, setQuery] = useState("");
@@ -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 (
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
{result ? (
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
{result.ok ? "✓" : "✗"} {result.message}
<button type="button" onClick={onDismiss} className="ml-1 text-gray-400 hover:text-gray-600 text-xs">×</button>
</span>
) : (
<span />
@@ -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() {
<input className={inputCls} placeholder="https://events.yourchurch.org"
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
</Field>
<SaveBar saving={saving} onSave={saveOrganisation} result={result} onDismiss={() => setResult(null)} />
<SaveBar saving={saving} onSave={saveOrganisation} result={result} />
</div>
)}
@@ -371,7 +370,7 @@ export default function SiteSettingsPage() {
}} className="text-sm" />
</Field>
<SaveBar saving={saving} onSave={saveBranding} result={result} onDismiss={() => setResult(null)} />
<SaveBar saving={saving} onSave={saveBranding} result={result} />
</div>
)}
@@ -385,7 +384,7 @@ export default function SiteSettingsPage() {
<input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org"
value={notifEmails} onChange={e => setNotifEmails(e.target.value)} />
</Field>
<SaveBar saving={saving} onSave={saveNotifications} result={result} onDismiss={() => setResult(null)} />
<SaveBar saving={saving} onSave={saveNotifications} result={result} />
</div>
)}
@@ -464,7 +463,7 @@ export default function SiteSettingsPage() {
)}
</div>
<SaveBar saving={saving} onSave={saveSmtp} result={result} onDismiss={() => setResult(null)} />
<SaveBar saving={saving} onSave={saveSmtp} result={result} />
</div>
)}
@@ -503,7 +502,7 @@ export default function SiteSettingsPage() {
</div>
</div>
<SaveBar saving={saving} onSave={saveLegal} result={result} onDismiss={() => setResult(null)} />
<SaveBar saving={saving} onSave={saveLegal} result={result} />
</div>
)}
</div>
@@ -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<UserItem[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
@@ -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<string | null>(null);
// ── Load config ──────────────────────────────────────────────────────────────
@@ -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<any[]>([]);
const [eventId, setEventId] = useState<string>(paramEventId);
const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId);
@@ -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<QRScannerHandle>(null);
const [lastResult, setLastResult] = useState<string | null>(null);
const [scanInfo, setScanInfo] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
const [errorModal, setErrorModal] = useState<string | null>(null);
const { token, user } = useAuth();
@@ -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<Mode>("registration");
const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<string | null>(null);
const [error, setError] = useDismissingState<string | null>(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(""); }
@@ -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<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
// Tabs: attendees (current), automations (coming soon), broadcasts
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
@@ -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 <input type="datetime-local">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
@@ -112,8 +113,8 @@ function EventOptionsContent() {
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]);
const [loadingEv, setLoadingEv] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => {
if (!token) return;
@@ -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<NotifyUser[]>(initialRecipients || []);
const [loading, setLoading] = useState(!hasInitial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(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<any[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const loadEvents = async () => {
if (!token) return;
@@ -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<any[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => {
if (!token) return;
@@ -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<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [createdReg, setCreatedReg] = useState<any | null>(null);
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
@@ -110,8 +111,8 @@ export default function ManualRegistrationPage() {
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
const { token } = useAuth();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(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);
@@ -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<HTMLDivElement>(null);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
// Load all users for client-side fuzzy matching
useEffect(() => {
@@ -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<any[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [registrations, setRegistrations] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({});
@@ -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<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
const [loadingEvents, setLoadingEvents] = useState(false);
const [allEvents, setAllEvents] = useState<any[]>([]);
@@ -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<string>("");
const [amount, setAmount] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
useEffect(() => {
(async () => {
@@ -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<any | null>(null);
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
// Local entry state for new responses
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
+19 -4
View File
@@ -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 <span className={`text-[10px] px-1.5 py-0.5 rounded ${className}`}>{label}</span>;
}
// 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<string, { label: string; className: string }> = {
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 <span className={`text-[10px] px-1.5 py-0.5 rounded ${s.className}`}>{s.label}</span>;
}
// 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<any[]>([]);
const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
// Filters
@@ -682,7 +697,7 @@ export default function UserDashboardPage() {
<EventStatusBadge event={r.event} />
</div>
<div className="text-xs text-gray-600">
Status: {r.status}{isCancelled && <span className="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-700">cancelled</span>}
Status: <RegistrationStatusBadge status={r.status} />
</div>
{(() => {
const fs = formStatuses[r.id];
@@ -869,7 +884,7 @@ export default function UserDashboardPage() {
</div>
<div className="space-y-3">
<div>
<div className="text-sm text-gray-600">Status: {activeReg.status}</div>
<div className="text-sm text-gray-600">Status: <RegistrationStatusBadge status={activeReg.status} /></div>
{activeBill && (
<div className="text-sm">
<div>Total: {formatRand(activeBill.totalDue)}</div>
+3 -2
View File
@@ -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<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [registration, setRegistration] = useState<any | null>(null);
const [payments, setPayments] = useState<any[]>([]);
const [amount, setAmount] = useState<string>("");
@@ -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<PaymentItem[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
@@ -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) => {
@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState(false);
const canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]);
+24
View File
@@ -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<T>(initial: T, ms = 7000) {
const [value, setValue] = useState<T>(initial);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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;
}