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,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]);