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 ### 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: 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 ### Fixed
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types"; import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" }; 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 [data, setData] = useState<EventFinancials | null>(null);
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]); const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false); 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 [busy, setBusy] = useState(false);
const isClosed = data?.event?.cashupStatus === "closed"; const isClosed = data?.event?.cashupStatus === "closed";
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function CashupLandingPage() { export default function CashupLandingPage() {
const { token } = useAuth(); const { token } = useAuth();
@@ -11,7 +12,7 @@ export default function CashupLandingPage() {
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [showPast, setShowPast] = useState(true); const [showPast, setShowPast] = useState(true);
const [showInactive, setShowInactive] = useState(false); const [showInactive, setShowInactive] = useState(false);
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const; 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 [registrations, setRegistrations] = useState<any[]>([]);
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false); const [loadingRegs, setLoadingRegs] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
// Filters // Filters
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api"; import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal"; type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
@@ -36,19 +37,17 @@ function Field({
} }
function SaveBar({ function SaveBar({
saving, onSave, result, onDismiss, saving, onSave, result,
}: { }: {
saving: boolean; saving: boolean;
onSave: () => void; onSave: () => void;
result: { ok: boolean; message: string } | null; result: { ok: boolean; message: string } | null;
onDismiss: () => void;
}) { }) {
return ( return (
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3"> <div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
{result ? ( {result ? (
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}> <span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
{result.ok ? "✓" : "✗"} {result.message} {result.ok ? "✓" : "✗"} {result.message}
<button type="button" onClick={onDismiss} className="ml-1 text-gray-400 hover:text-gray-600 text-xs">×</button>
</span> </span>
) : ( ) : (
<span /> <span />
@@ -75,7 +74,7 @@ export default function SiteSettingsPage() {
// Per-tab save state // Per-tab save state
const [saving, setSaving] = useState(false); 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 ── // ── Organisation ──
const [orgName, setOrgName] = useState(""); const [orgName, setOrgName] = useState("");
@@ -104,7 +103,7 @@ export default function SiteSettingsPage() {
const [smtpPass, setSmtpPass] = useState(""); const [smtpPass, setSmtpPass] = useState("");
const [smtpPassSet, setSmtpPassSet] = useState(false); const [smtpPassSet, setSmtpPassSet] = useState(false);
const [smtpTesting, setSmtpTesting] = 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 ── // ── Legal ──
const [legalOperatorName, setLegalOperatorName] = useState(""); const [legalOperatorName, setLegalOperatorName] = useState("");
@@ -332,7 +331,7 @@ export default function SiteSettingsPage() {
<input className={inputCls} placeholder="https://events.yourchurch.org" <input className={inputCls} placeholder="https://events.yourchurch.org"
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} /> value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
</Field> </Field>
<SaveBar saving={saving} onSave={saveOrganisation} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveOrganisation} result={result} />
</div> </div>
)} )}
@@ -371,7 +370,7 @@ export default function SiteSettingsPage() {
}} className="text-sm" /> }} className="text-sm" />
</Field> </Field>
<SaveBar saving={saving} onSave={saveBranding} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveBranding} result={result} />
</div> </div>
)} )}
@@ -385,7 +384,7 @@ export default function SiteSettingsPage() {
<input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org" <input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org"
value={notifEmails} onChange={e => setNotifEmails(e.target.value)} /> value={notifEmails} onChange={e => setNotifEmails(e.target.value)} />
</Field> </Field>
<SaveBar saving={saving} onSave={saveNotifications} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveNotifications} result={result} />
</div> </div>
)} )}
@@ -464,7 +463,7 @@ export default function SiteSettingsPage() {
)} )}
</div> </div>
<SaveBar saving={saving} onSave={saveSmtp} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveSmtp} result={result} />
</div> </div>
)} )}
@@ -503,7 +502,7 @@ export default function SiteSettingsPage() {
</div> </div>
</div> </div>
<SaveBar saving={saving} onSave={saveLegal} result={result} onDismiss={() => setResult(null)} /> <SaveBar saving={saving} onSave={saveLegal} result={result} />
</div> </div>
)} )}
</div> </div>
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
interface UserItem { interface UserItem {
id: string; id: string;
@@ -43,7 +44,7 @@ export default function AdminUsersPage() {
// Data state // Data state
const [users, setUsers] = useState<UserItem[]>([]); const [users, setUsers] = useState<UserItem[]>([]);
const [fetching, setFetching] = useState(false); 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 [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -123,7 +124,7 @@ export default function WhatsAppAdminPage() {
const [pairingPhone, setPairingPhone] = useState(""); const [pairingPhone, setPairingPhone] = useState("");
// ── Shared action feedback ─────────────────────────────────────────────────── // ── 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); const [busy, setBusy] = useState<string | null>(null);
// ── Load config ────────────────────────────────────────────────────────────── // ── Load config ──────────────────────────────────────────────────────────────
@@ -3,6 +3,7 @@
import React, { Suspense, useEffect, useMemo, useState } from "react"; import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date"; import { formatDate } from "@/lib/date";
import { QrImage } from "@/components/shared/QrImage"; import { QrImage } from "@/components/shared/QrImage";
@@ -18,7 +19,7 @@ function EventTicketsContent() {
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [eventId, setEventId] = useState<string>(paramEventId); const [eventId, setEventId] = useState<string>(paramEventId);
const [tickets, setTickets] = useState<any[]>([]); 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 [loading, setLoading] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId); 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 { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function TicketScanningPage() { export default function TicketScanningPage() {
const router = useRouter(); const router = useRouter();
const scannerRef = useRef<QRScannerHandle>(null); const scannerRef = useRef<QRScannerHandle>(null);
const [lastResult, setLastResult] = useState<string | null>(null); const [lastResult, setLastResult] = useState<string | null>(null);
const [scanInfo, setScanInfo] = useState<string | null>(null); const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null); const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
const [errorModal, setErrorModal] = useState<string | null>(null); const [errorModal, setErrorModal] = useState<string | null>(null);
const { token, user } = useAuth(); const { token, user } = useAuth();
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch"; import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
type Mode = "registration" | "payment" | "tickets" | "refund"; type Mode = "registration" | "payment" | "tickets" | "refund";
@@ -154,30 +155,8 @@ export default function AtTheDoorPage() {
const [mode, setMode] = useState<Mode>("registration"); const [mode, setMode] = useState<Mode>("registration");
const [activeRegistration, setActiveRegistration] = useState<any | null>(null); const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<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 handleRegistrationCreated = (registration: any) => { const handleRegistrationCreated = (registration: any) => {
setActiveRegistration(registration); setActiveRegistration(registration);
@@ -1332,7 +1311,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [localError, setLocalError] = useState(""); const [localError, setLocalError] = useDismissingState("");
const [localInfo, setLocalInfo] = useState(""); const [localInfo, setLocalInfo] = useState("");
useEffect(() => { useEffect(() => {
@@ -1369,7 +1348,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
}, },
}); });
setLocalInfo("Tickets sent successfully."); setLocalInfo("Tickets sent successfully.");
setTimeout(() => { setLocalInfo(""); onClose(); }, 2000); setTimeout(() => { setLocalInfo(""); onClose(); }, 7000);
} catch (e: any) { } catch (e: any) {
setLocalError(e?.message || "Failed to send tickets"); setLocalError(e?.message || "Failed to send tickets");
} finally { } finally {
@@ -1461,7 +1440,7 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
const [method, setMethod] = useState("cash"); const [method, setMethod] = useState("cash");
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [localError, setLocalError] = useState(""); const [localError, setLocalError] = useDismissingState("");
useEffect(() => { useEffect(() => {
if (open) { setAmount(""); setReason(""); setLocalError(""); } if (open) { setAmount(""); setReason(""); setLocalError(""); }
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
type Attendee = { id: string; name: string; email: string; pref: string }; type Attendee = { id: string; name: string; email: string; pref: string };
@@ -171,8 +172,8 @@ function EmailAttendeesPageInner() {
// Load events for selection // Load events for selection
const [loadingEvents, setLoadingEvents] = useState(false); const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
// Tabs: attendees (current), automations (coming soon), broadcasts // Tabs: attendees (current), automations (coming soon), broadcasts
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees'); 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 { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api"; 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"> // 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 // This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
@@ -112,8 +113,8 @@ function EventOptionsContent() {
const [selectedEventId, setSelectedEventId] = useState<string>(""); const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]); const [options, setOptions] = useState<any[]>([]);
const [loadingEv, setLoadingEv] = useState(false); const [loadingEv, setLoadingEv] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => { const loadEvents = async () => {
if (!token) return; if (!token) return;
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, resolveToApiOrigin } from "@/lib/api"; import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── helpers ──────────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────────
@@ -690,7 +691,7 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
const [selected, setSelected] = useState<NotifyUser[]>(initialRecipients || []); const [selected, setSelected] = useState<NotifyUser[]>(initialRecipients || []);
const [loading, setLoading] = useState(!hasInitial); const [loading, setLoading] = useState(!hasInitial);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
useEffect(() => { useEffect(() => {
if (hasInitial) return; // already have the data — skip the network round trip entirely 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 [events, setEvents] = useState<any[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false); const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const loadEvents = async () => { const loadEvents = async () => {
if (!token) return; if (!token) return;
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph'; type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
@@ -144,8 +145,8 @@ export default function FormsBrowserPage() {
const [items, setItems] = useState<any[]>([]); const [items, setItems] = useState<any[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null); const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingList, setLoadingList] = useState(false); const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const loadEvents = async () => { const loadEvents = async () => {
if (!token) return; if (!token) return;
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; 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 }; 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 [phoneNumber, setPhoneNumber] = useState("");
const [registerAsGuest, setRegisterAsGuest] = useState(false); const [registerAsGuest, setRegisterAsGuest] = useState(false);
const [busy, setBusy] = 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 [createdReg, setCreatedReg] = useState<any | null>(null);
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null); const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({}); 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; }) { 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 { token } = useAuth();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<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 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); const count = Math.max(0, mainTickets);
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch"; import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── Pricing helpers ───────────────────────────────────────────────────────── // ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -72,8 +73,8 @@ export default function ManualRegistrationPage() {
const searchRef = useRef<HTMLDivElement>(null); const searchRef = useRef<HTMLDivElement>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useDismissingState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
// Load all users for client-side fuzzy matching // Load all users for client-side fuzzy matching
useEffect(() => { useEffect(() => {
@@ -4,6 +4,7 @@ import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api"; import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch"; import { scoreUser } from "@/lib/fuzzyMatch";
function RegistrationOptions({ regs, regOutstanding }: { function RegistrationOptions({ regs, regOutstanding }: {
@@ -115,8 +116,8 @@ function PaymentsContent() {
const [payments, setPayments] = useState<any[]>([]); const [payments, setPayments] = useState<any[]>([]);
const [loadingList, setLoadingList] = useState(false); const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [registrations, setRegistrations] = useState<any[]>([]); const [registrations, setRegistrations] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false); const [loadingRegs, setLoadingRegs] = useState(false);
const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({}); 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 { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api"; import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// Attendee with preference info // Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string }; type Attendee = { id: string; name: string; phone: string; pref: string };
@@ -358,8 +359,8 @@ function WhatsAppAttendeesPageInner() {
if (!user) router.replace("/login"); if (!user) router.replace("/login");
}, [user, loading, router]); }, [user, loading, router]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees"); const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
const [loadingEvents, setLoadingEvents] = useState(false); const [loadingEvents, setLoadingEvents] = useState(false);
const [allEvents, setAllEvents] = useState<any[]>([]); const [allEvents, setAllEvents] = useState<any[]>([]);
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function DonatePage() { export default function DonatePage() {
const { token } = useAuth(); const { token } = useAuth();
@@ -9,8 +10,8 @@ export default function DonatePage() {
const [eventId, setEventId] = useState<string>(""); const [eventId, setEventId] = useState<string>("");
const [amount, setAmount] = useState<string>(""); const [amount, setAmount] = useState<string>("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -3,6 +3,7 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// Types for form fields // Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null }; 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 [registration, setRegistration] = useState<any | null>(null);
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null); const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
// Local entry state for new responses // Local entry state for new responses
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({}); 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 { formatDate } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod"; import { formatPaymentMethod } from "@/lib/paymentMethod";
import { QrImage } from "@/components/shared/QrImage"; import { QrImage } from "@/components/shared/QrImage";
import { useDismissingState } from "@/hooks/useDismissingState";
// Helper formatters // Helper formatters
const formatRand = (n: number) => `R ${n.toFixed(2)}`; 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>; 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. // 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 // 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. // 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 { token, user } = useAuth();
const [registrations, setRegistrations] = useState<any[]>([]); const [registrations, setRegistrations] = useState<any[]>([]);
const [tickets, setTickets] = useState<any[]>([]); const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
// Filters // Filters
@@ -682,7 +697,7 @@ export default function UserDashboardPage() {
<EventStatusBadge event={r.event} /> <EventStatusBadge event={r.event} />
</div> </div>
<div className="text-xs text-gray-600"> <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> </div>
{(() => { {(() => {
const fs = formStatuses[r.id]; const fs = formStatuses[r.id];
@@ -869,7 +884,7 @@ export default function UserDashboardPage() {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div> <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 && ( {activeBill && (
<div className="text-sm"> <div className="text-sm">
<div>Total: {formatRand(activeBill.totalDue)}</div> <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 { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
function MakePaymentContent() { function MakePaymentContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const registrationId = searchParams.get("registrationId"); const registrationId = searchParams.get("registrationId");
const { token } = useAuth(); const { token } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useDismissingState<string | null>(null);
const [registration, setRegistration] = useState<any | null>(null); const [registration, setRegistration] = useState<any | null>(null);
const [payments, setPayments] = useState<any[]>([]); const [payments, setPayments] = useState<any[]>([]);
const [amount, setAmount] = useState<string>(""); const [amount, setAmount] = useState<string>("");
@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { formatDateTime } from "@/lib/date"; import { formatDateTime } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod"; import { formatPaymentMethod } from "@/lib/paymentMethod";
@@ -30,7 +31,7 @@ export default function UserPaymentsPage() {
const [payments, setPayments] = useState<PaymentItem[]>([]); const [payments, setPayments] = useState<PaymentItem[]>([]);
const [fetching, setFetching] = useState(false); 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 [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone"; import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
export default function UserProfilePage() { export default function UserProfilePage() {
const { user, token, logout, updateToken } = useAuth(); const { user, token, logout, updateToken } = useAuth();
@@ -19,7 +20,7 @@ export default function UserProfilePage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email"); 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 [savingProfile, setSavingProfile] = useState(false);
const hasValidPhone = isValidZAPhone(phone); const hasValidPhone = isValidZAPhone(phone);
@@ -63,7 +64,7 @@ export default function UserProfilePage() {
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = 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 [savingPw, setSavingPw] = useState(false);
const changePassword = async (e: React.FormEvent) => { const changePassword = async (e: React.FormEvent) => {
@@ -98,7 +99,7 @@ export default function UserProfilePage() {
}; };
// ── Revoke sessions ─────────────────────────────────────────────────────── // ── 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 [revoking, setRevoking] = useState(false);
const revokeSessions = async () => { const revokeSessions = async () => {
@@ -126,7 +127,7 @@ export default function UserProfilePage() {
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle"); const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
const [deleteData, setDeleteData] = useState(false); const [deleteData, setDeleteData] = useState(false);
const [closePassword, setClosePassword] = useState(""); 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 [closing, setClosing] = useState(false);
const submitAccountClosure = async (e: React.FormEvent) => { const submitAccountClosure = async (e: React.FormEvent) => {
@@ -2,6 +2,7 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
@@ -11,7 +12,7 @@ export default function ResetPasswordPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState(""); const [confirm, setConfirm] = useState("");
const [status, setStatus] = useState<string | null>(null); 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 [loading, setLoading] = useState(false);
const canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]); 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;
}