diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx index 1bb83b9..b2a2f99 100644 --- a/frontend/src/app/(auth)/forgot-password/page.tsx +++ b/frontend/src/app/(auth)/forgot-password/page.tsx @@ -17,7 +17,7 @@ export default function ForgotPasswordPage() { try { setLoading(true); setStatus(null); - const res: any = await apiFetch("/api/users/forgot", { method: "POST", body: { email } }); + const res = await apiFetch<{ message?: string }>("/api/users/forgot", { method: "POST", body: { email } }); setStatus(res?.message || "If that email exists, a password reset link has been sent."); } catch (err) { try { diff --git a/frontend/src/app/activate-account/page.tsx b/frontend/src/app/activate-account/page.tsx index 237a91f..e6a1014 100644 --- a/frontend/src/app/activate-account/page.tsx +++ b/frontend/src/app/activate-account/page.tsx @@ -25,7 +25,7 @@ function ActivateAccountContent() { try { setLoading(true); setStatus(null); - const data = await apiFetch("/api/users/activate", { method: "POST", body: { token, password } }); + const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password } }); // Auto-login: save the session token (under the key AuthContext reads) and load the // dashboard fresh so the new session is picked up. if (data?.token) saveToken(data.token); diff --git a/frontend/src/app/dashboard/admin/cashup/page.tsx b/frontend/src/app/dashboard/admin/cashup/page.tsx index 21387e1..036f769 100644 --- a/frontend/src/app/dashboard/admin/cashup/page.tsx +++ b/frontend/src/app/dashboard/admin/cashup/page.tsx @@ -7,12 +7,13 @@ import { apiFetch } from "@/lib/api"; import { useDismissingState } from "@/hooks/useDismissingState"; import { Wallet } from "lucide-react"; import { errorMessage } from "@/lib/utils"; +import type { Event } from "@/types"; export default function CashupLandingPage() { const { token } = useAuth(); const router = useRouter(); - const [events, setEvents] = useState([]); + const [events, setEvents] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useDismissingState(null); const [search, setSearch] = useState(""); @@ -26,7 +27,7 @@ export default function CashupLandingPage() { setLoading(true); setError(null); try { - const evs = await apiFetch("/api/events/all?includePast=true&includeInactive=true", { authToken: token }); + const evs = await apiFetch("/api/events/all?includePast=true&includeInactive=true", { authToken: token }); setEvents(Array.isArray(evs) ? evs : []); } catch (e) { setError(errorMessage(e) || "Failed to load events"); @@ -34,7 +35,7 @@ export default function CashupLandingPage() { setLoading(false); } })(); - }, [token]); + }, [token, setError]); const filtered = useMemo(() => { const now = new Date(); diff --git a/frontend/src/app/dashboard/admin/page.tsx b/frontend/src/app/dashboard/admin/page.tsx index cfd04bb..bdf5092 100644 --- a/frontend/src/app/dashboard/admin/page.tsx +++ b/frontend/src/app/dashboard/admin/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; @@ -14,6 +14,7 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard"; import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile"; import { AreaTrendChart } from "@/components/charts/AreaTrendChart"; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"; +import type { DashboardStats, PaymentStats } from "@/types"; const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`; const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`; @@ -67,19 +68,19 @@ export default function AdminDashboardPage() { // /api/stats/admin covers today/week/month payment totals (unchanged from before); // /api/stats/overview is the month-over-month KPI/trend/top-events endpoint. Both are // computed server-side — no full payments/events list ever ships to the client. - const [paymentStats, setPaymentStats] = useStableState(null); + const [paymentStats, setPaymentStats] = useStableState(null); const [overview, setOverview] = useStableState(null); const [loadingStats, setLoadingStats] = useState(false); const hasLoadedOnce = useRef(false); const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today"); - const loadStats = async () => { + const loadStats = useCallback(async () => { if (!token) return; const isFirstLoad = !hasLoadedOnce.current; try { if (isFirstLoad) setLoadingStats(true); const [dash, overviewData] = await Promise.all([ - apiFetch("/api/stats/admin", { authToken: token }), + apiFetch("/api/stats/admin", { authToken: token }), apiFetch("/api/stats/overview", { authToken: token }), ]); setPaymentStats(dash.paymentStats); @@ -90,12 +91,12 @@ export default function AdminDashboardPage() { hasLoadedOnce.current = true; if (isFirstLoad) setLoadingStats(false); } - }; + }, [token, setPaymentStats, setOverview]); useEffect(() => { if (!token) return; loadStats(); - }, [token]); + }, [token, loadStats]); // Poll every 15s while the tab is visible; pause in the background and refetch // immediately on return instead of leaving stale numbers up. @@ -105,7 +106,7 @@ export default function AdminDashboardPage() { }, 15000, !!token); const paymentsTabValue = paymentStats - ? paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth + ? (paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth) ?? 0 : 0; return ( diff --git a/frontend/src/app/dashboard/admin/settings/page.tsx b/frontend/src/app/dashboard/admin/settings/page.tsx index b6879ba..b738983 100644 --- a/frontend/src/app/dashboard/admin/settings/page.tsx +++ b/frontend/src/app/dashboard/admin/settings/page.tsx @@ -179,9 +179,9 @@ function SiteSettingsPageInner() { setConsentRetentionDays(s.consent_medical_retention_days || "30"); setNotifyOnImpersonation(s.notify_user_on_impersonation === "true"); }) - .catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" })) + .catch((e: unknown) => setResult({ ok: false, message: errorMessage(e) || "Failed to load settings" })) .finally(() => setLoadingInitial(false)); - }, [token]); + }, [token, setResult]); // Clear result when switching tabs const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); }; @@ -921,7 +921,7 @@ function WhatsAppTab({ active }: { active: boolean }) { setBusy(action); setActionMsg(null); try { - const res = await apiFetch(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body }); + const res = await apiFetch<{ message?: string }>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body }); setActionMsg({ type: "ok", text: res?.message || `${action} successful.` }); await fetchStatus(); await fetchConfig(); @@ -978,7 +978,7 @@ function WhatsAppTab({ active }: { active: boolean }) { setSavingInstance(true); setActionMsg(null); try { - const res = await apiFetch("/api/whatsapp/create-instance", { method: "POST", authToken: token! }); + const res = await apiFetch<{ message?: string }>("/api/whatsapp/create-instance", { method: "POST", authToken: token! }); setActionMsg({ type: "ok", text: res?.message || "Instance created." }); await fetchConfig(); } catch (e) { diff --git a/frontend/src/app/dashboard/staff/page.tsx b/frontend/src/app/dashboard/staff/page.tsx index 4e7ecd9..e4fb8c8 100644 --- a/frontend/src/app/dashboard/staff/page.tsx +++ b/frontend/src/app/dashboard/staff/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; @@ -9,6 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling"; import { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react"; import { StatCard, StatCardRow } from "@/components/shared/StatCard"; import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile"; +import type { DashboardStats, RecentScan, ScanStats } from "@/types"; const QUICK_ACTIONS = [ { href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode }, @@ -35,17 +36,17 @@ export default function StaffDashboardPage() { // (/api/stats/staff) instead of two separate calls. useStableState skips re-renders when // a poll returns identical data, and hasLoadedOnce below means "Refreshing…" only shows // on the very first load — together these stop the stats panel from flickering. - const [stats, setStats] = useStableState(null); - const [recentScans, setRecentScans] = useStableState([]); + const [stats, setStats] = useStableState(null); + const [recentScans, setRecentScans] = useStableState([]); const [loadingStats, setLoadingStats] = useState(false); const hasLoadedOnce = useRef(false); - const loadStats = async () => { + const loadStats = useCallback(async () => { if (!token) return; const isFirstLoad = !hasLoadedOnce.current; try { if (isFirstLoad) setLoadingStats(true); - const data = await apiFetch("/api/stats/staff", { authToken: token }); + const data = await apiFetch("/api/stats/staff", { authToken: token }); setStats(data.scanStats); setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []); } catch { @@ -54,12 +55,12 @@ export default function StaffDashboardPage() { hasLoadedOnce.current = true; if (isFirstLoad) setLoadingStats(false); } - }; + }, [token, setStats, setRecentScans]); useEffect(() => { if (!token) return; loadStats(); - }, [token]); + }, [token, loadStats]); // Poll every 10s while the tab is visible; pause in the background and refetch // immediately on return instead of leaving stale numbers up. @@ -104,7 +105,7 @@ export default function StaffDashboardPage() { {loadingStats && Refreshing…}
    - {recentScans.map((u: any) => ( + {recentScans.map((u) => (
  • {u.ticket?.event?.title || u.ticket?.eventId || 'Event'}
    @@ -122,9 +123,9 @@ export default function StaffDashboardPage() {

    Today by staff

    - {stats?.byStaff?.length > 0 ? ( + {stats && stats.byStaff?.length > 0 ? (
      - {stats.byStaff.map((s: any) => ( + {stats.byStaff.map((s) => (
    • {s.name || 'Staff'} {s.count} diff --git a/frontend/src/app/dashboard/supervisor/offline-scans/page.tsx b/frontend/src/app/dashboard/supervisor/offline-scans/page.tsx index 5c4e48c..17d4abc 100644 --- a/frontend/src/app/dashboard/supervisor/offline-scans/page.tsx +++ b/frontend/src/app/dashboard/supervisor/offline-scans/page.tsx @@ -8,6 +8,7 @@ import { formatDate } from "@/lib/date"; import { useDismissingState } from "@/hooks/useDismissingState"; import { WifiOff, Unlock, CheckCircle2 } from "lucide-react"; import { errorMessage } from "@/lib/utils"; +import type { Event } from "@/types"; type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null }; type Report = { @@ -29,7 +30,7 @@ export default function OfflineScansPage() { if (!user) router.replace("/login"); }, [user, loading, router]); - const [events, setEvents] = useState([]); + const [events, setEvents] = useState([]); const [eventId, setEventId] = useState(""); const [locks, setLocks] = useState([]); const [reports, setReports] = useState([]); @@ -39,7 +40,7 @@ export default function OfflineScansPage() { useEffect(() => { if (!token || !canView) return; - apiFetch("/api/events/all?includePast=true", { authToken: token }) + apiFetch("/api/events/all?includePast=true", { authToken: token }) .then(evs => { const recent = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 14 * 864e5); setEvents(recent); diff --git a/frontend/src/app/dashboard/supervisor/page.tsx b/frontend/src/app/dashboard/supervisor/page.tsx index 33edca4..ea66b07 100644 --- a/frontend/src/app/dashboard/supervisor/page.tsx +++ b/frontend/src/app/dashboard/supervisor/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; @@ -14,6 +14,7 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard"; import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile"; import { AreaTrendChart } from "@/components/charts/AreaTrendChart"; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"; +import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/types"; const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`; const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`; @@ -66,20 +67,20 @@ export default function SupervisorDashboardPage() { // /api/stats/supervisor covers scan activity + today's payments (unchanged from before); // /api/stats/overview is the month-over-month KPI/trend/top-events endpoint, shared // with the admin dashboard since supervisors already have full revenue/donation visibility. - const [scanStats, setScanStats] = useStableState(null); - const [paymentStats, setPaymentStats] = useStableState(null); - const [recentScans, setRecentScans] = useStableState([]); + const [scanStats, setScanStats] = useStableState(null); + const [paymentStats, setPaymentStats] = useStableState(null); + const [recentScans, setRecentScans] = useStableState([]); const [overview, setOverview] = useStableState(null); const [loadingStats, setLoadingStats] = useState(false); const hasLoadedOnce = useRef(false); - const loadStats = async () => { + const loadStats = useCallback(async () => { if (!token) return; const isFirstLoad = !hasLoadedOnce.current; try { if (isFirstLoad) setLoadingStats(true); const [data, overviewData] = await Promise.all([ - apiFetch("/api/stats/supervisor", { authToken: token }), + apiFetch("/api/stats/supervisor", { authToken: token }), apiFetch("/api/stats/overview", { authToken: token }), ]); setScanStats(data.scanStats); @@ -92,12 +93,12 @@ export default function SupervisorDashboardPage() { hasLoadedOnce.current = true; if (isFirstLoad) setLoadingStats(false); } - }; + }, [token, setScanStats, setPaymentStats, setRecentScans, setOverview]); useEffect(() => { if (!token) return; loadStats(); - }, [token]); + }, [token, loadStats]); // Poll every 15s while the tab is visible; pause in the background and refetch // immediately on return instead of leaving stale numbers up. @@ -184,7 +185,7 @@ export default function SupervisorDashboardPage() { {loadingStats && Refreshing…}
      - {recentScans.map((u: any) => ( + {recentScans.map((u) => (
    • {u.ticket?.event?.title || u.ticket?.eventId || 'Event'}
      @@ -236,11 +237,11 @@ export default function SupervisorDashboardPage() {
      No payment data yet.
      )} - {scanStats?.byStaff?.length > 0 && ( + {scanStats && scanStats.byStaff?.length > 0 && (
      Today by staff
        - {scanStats.byStaff.map((s: any) => ( + {scanStats.byStaff.map((s) => (
      • {s.name || 'Staff'} {s.count} diff --git a/frontend/src/app/dashboard/supervisor/waitlist/page.tsx b/frontend/src/app/dashboard/supervisor/waitlist/page.tsx index 7f64071..8bfce8c 100644 --- a/frontend/src/app/dashboard/supervisor/waitlist/page.tsx +++ b/frontend/src/app/dashboard/supervisor/waitlist/page.tsx @@ -9,6 +9,7 @@ import { useDismissingState } from "@/hooks/useDismissingState"; import { ListChecks, ArrowUp, ArrowDown } from "lucide-react"; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"; import { errorMessage } from "@/lib/utils"; +import type { Event } from "@/types"; type Entry = { id: string; @@ -44,7 +45,7 @@ export default function WaitlistPage() { if (!user) router.replace("/login"); }, [user, loading, router]); - const [events, setEvents] = useState([]); + const [events, setEvents] = useState([]); const [eventId, setEventId] = useState(search.get("eventId") || ""); const [entries, setEntries] = useState([]); const [busy, setBusy] = useState(false); @@ -53,7 +54,7 @@ export default function WaitlistPage() { useEffect(() => { if (!token || !canView) return; - apiFetch("/api/events/all", { authToken: token }) + apiFetch("/api/events/all", { authToken: token }) .then(evs => { const withList = (evs || []).filter(e => e.waitlistEnabled); setEvents(withList); @@ -76,7 +77,7 @@ export default function WaitlistPage() { }, [token, eventId, setError]); useEffect(() => { load(); }, [load]); - const act = async (path: string, body?: any) => { + const act = async (path: string, body?: { direction: "up" | "down" }) => { setBusy(true); try { const res = await apiFetch<{ message?: string }>(path, { method: "POST", authToken: token!, body }); diff --git a/frontend/src/app/dashboard/user/donate/page.tsx b/frontend/src/app/dashboard/user/donate/page.tsx index 8fb6167..3ee06c8 100644 --- a/frontend/src/app/dashboard/user/donate/page.tsx +++ b/frontend/src/app/dashboard/user/donate/page.tsx @@ -5,10 +5,11 @@ import { apiFetch } from "@/lib/api"; import { useDismissingState } from "@/hooks/useDismissingState"; import { HandHeart } from "lucide-react"; import { errorMessage } from "@/lib/utils"; +import type { Event } from "@/types"; export default function DonatePage() { const { token } = useAuth(); - const [events, setEvents] = useState([]); + const [events, setEvents] = useState([]); const [eventId, setEventId] = useState(""); const [amount, setAmount] = useState(""); const [loading, setLoading] = useState(false); @@ -18,14 +19,14 @@ export default function DonatePage() { useEffect(() => { (async () => { try { - const evs = await apiFetch("/api/events"); + const evs = await apiFetch("/api/events"); setEvents(evs); if (evs.length > 0) setEventId(evs[0].id); } catch (e) { setError(errorMessage(e) || "Failed to load events"); } })(); - }, []); + }, [setError]); const submit = async () => { if (!token) { setError("Please login"); return; } diff --git a/frontend/src/app/dashboard/user/payments/page.tsx b/frontend/src/app/dashboard/user/payments/page.tsx index 1b1c123..0acd7cb 100644 --- a/frontend/src/app/dashboard/user/payments/page.tsx +++ b/frontend/src/app/dashboard/user/payments/page.tsx @@ -8,6 +8,7 @@ import { formatDateTime } from "@/lib/date"; import { formatPaymentMethod } from "@/lib/paymentMethod"; import { Receipt } from "lucide-react"; import { errorMessage } from "@/lib/utils"; +import type { Paginated } from "@/types"; interface PaymentItem { id: string; @@ -50,7 +51,7 @@ export default function UserPaymentsPage() { setInfo(null); setSendingReceipt(true); try { - const res: any = await apiFetch(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, { + const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, { method: "POST", body: { channel }, authToken: token, @@ -83,7 +84,7 @@ export default function UserPaymentsPage() { setError(null); setFetching(true); try { - const res = await apiFetch(buildQuery(p), { authToken: token }); + const res = await apiFetch>(buildQuery(p), { authToken: token }); setPayments(Array.isArray(res?.data) ? res.data : []); setTotal(res?.total ?? 0); setTotalPages(res?.pages ?? 1); @@ -93,9 +94,10 @@ export default function UserPaymentsPage() { } finally { setFetching(false); } - }, [token, buildQuery]); + }, [token, buildQuery, setError]); - useEffect(() => { if (token) loadPayments(1); }, [token, startDate, endDate, method, kind]); + // loadPayments changes whenever a filter does (via buildQuery), so this refetches page 1 on filter changes. + useEffect(() => { if (token) loadPayments(1); }, [token, loadPayments]); const goToPage = (p: number) => loadPayments(p); @@ -151,7 +153,7 @@ export default function UserPaymentsPage() {
      - setKind(e.target.value as "" | "payment" | "refund")}> diff --git a/frontend/src/app/events/[id]/ClientActions.tsx b/frontend/src/app/events/[id]/ClientActions.tsx index 31a1649..89649b3 100644 --- a/frontend/src/app/events/[id]/ClientActions.tsx +++ b/frontend/src/app/events/[id]/ClientActions.tsx @@ -4,7 +4,9 @@ import { Share2, QrCode, CalendarPlus } from "lucide-react"; import QRCode from "qrcode"; import { API_BASE } from "@/lib/api"; -export default function ClientActions({ event }: { event: any }) { +type Props = { event: { id: string; title: string; description?: string | null } }; + +export default function ClientActions({ event }: Props) { return (
        - {opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => { + {opt.variants!.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map((v) => { const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price; const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0; const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0); diff --git a/frontend/src/app/register/[eventId]/RegisterForm.tsx b/frontend/src/app/register/[eventId]/RegisterForm.tsx index da18d6b..eddf378 100644 --- a/frontend/src/app/register/[eventId]/RegisterForm.tsx +++ b/frontend/src/app/register/[eventId]/RegisterForm.tsx @@ -19,7 +19,7 @@ function lowStockThreshold(stockLimit: number): number { return Math.round(stockLimit * pct); } -type EarlyBirdTier = { id: string; deadline: string; price: number; stockLimit?: number; order?: number }; +type EarlyBirdTier = { id: string; deadline: string; price: number; stockLimit?: number; availableCount?: number; order?: number }; type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number; order?: number }; type EventOption = { id: string; @@ -32,7 +32,7 @@ type EventOption = { variants?: OptionVariant[]; }; -type Event = { +export type RegisterEvent = { id: string; title: string; description?: string; @@ -42,7 +42,8 @@ type Event = { price: number; picture?: string; eventOptions?: EventOption[]; - form?: { isRequired: boolean; fields?: any[] } | null; + form?: { isRequired: boolean; fields?: unknown[] } | null; + requiresRegistration?: boolean; requiresAuth?: boolean; allowNamedAttendees?: boolean; requiresParentalConsent?: boolean; @@ -62,7 +63,7 @@ function effectiveUnitForOption(opt: EventOption): number { const now = new Date(); const applicable = tiers .map((t) => ({ ...t, _d: new Date(t.deadline) })) - .filter((t) => now < t._d && (t.stockLimit === undefined || t.stockLimit === 0 || (t as any).availableCount === undefined || (t as any).availableCount > 0)) + .filter((t) => now < t._d && (t.stockLimit === undefined || t.stockLimit === 0 || t.availableCount === undefined || t.availableCount > 0)) .sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime()); return applicable.length > 0 ? applicable[0].price : base; } @@ -253,7 +254,7 @@ function OptionCard({ ); } -export default function RegisterForm({ event: baseEvent }: { event: Event }) { +export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) { const { token, user } = useAuth(); const eventId = baseEvent.id; @@ -272,9 +273,9 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) { setOffer(res); setQuantities(q => ({ ...q, [res.variantId ? `${res.eventOptionId}:${res.variantId}` : res.eventOptionId]: res.quantity })); }) - .catch((e: any) => setOfferError(e?.message || "This waitlist link is not valid.")); + .catch((e: unknown) => setOfferError(errorMessage(e) || "This waitlist link is not valid.")); }, []); - const event = useMemo(() => { + const event = useMemo(() => { if (!offer) return baseEvent; return { ...baseEvent, @@ -560,11 +561,11 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) { const now = new Date(); const upcomingTiers = (event.eventOptions || []).flatMap(opt => (opt.earlyBirdTiers || []) - .map((t: any) => ({ ...t, deadline: new Date(t.deadline) })) - .filter((t: any) => t.deadline > now) - ).sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime()); + .map((t) => ({ ...t, deadline: new Date(t.deadline) })) + .filter((t) => t.deadline > now) + ).sort((a, b) => a.deadline.getTime() - b.deadline.getTime()); if (upcomingTiers.length === 0) return null; - const soonest = upcomingTiers[0].deadline as Date; + const soonest = upcomingTiers[0].deadline; const dateStr = soonest.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); return (
        diff --git a/frontend/src/app/register/[eventId]/page.tsx b/frontend/src/app/register/[eventId]/page.tsx index a333924..7457e48 100644 --- a/frontend/src/app/register/[eventId]/page.tsx +++ b/frontend/src/app/register/[eventId]/page.tsx @@ -2,18 +2,18 @@ import { notFound, redirect } from "next/navigation"; import { Navbar } from "@/components/layout/Navbar"; import { Footer } from "@/components/layout/Footer"; import { apiFetch, ApiError } from "@/lib/api"; -import RegisterForm from "./RegisterForm"; +import RegisterForm, { type RegisterEvent } from "./RegisterForm"; import { FunnelTracker } from "@/components/events/FunnelTracker"; export const revalidate = 60; export default async function RegisterPage({ params }: { params: Promise<{ eventId: string }> }) { const { eventId } = await params; - let event: any; + let event: RegisterEvent; try { // Fetched server-side (same pattern as /events/[id]) so the form's content is part of // the initial HTML instead of showing a loading skeleton after a client-side fetch. - event = await apiFetch(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } }); + event = await apiFetch(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } }); } catch (e) { if (e instanceof ApiError && e.status === 404) notFound(); throw e; diff --git a/frontend/src/app/registration/success/page.tsx b/frontend/src/app/registration/success/page.tsx index cbcc450..aa33e55 100644 --- a/frontend/src/app/registration/success/page.tsx +++ b/frontend/src/app/registration/success/page.tsx @@ -6,6 +6,7 @@ import { useSearchParams, useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; import { CheckCircle2 } from "lucide-react"; import { errorMessage } from "@/lib/utils"; +import type { Registration } from "@/types"; type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown }; @@ -20,7 +21,7 @@ function RegistrationSuccessContent() { return isNaN(n) ? 0 : n; }, [fallbackTotalParam]); - const [reg, setReg] = React.useState(null); + const [reg, setReg] = React.useState(null); const [form, setForm] = React.useState<{ isRequired: boolean; fields: FormField[] } | null>(null); const [error, setError] = React.useState(null); const [payLoading, setPayLoading] = React.useState(false); @@ -31,10 +32,10 @@ function RegistrationSuccessContent() { if (!registrationId) return; try { // Try to fetch registration and event form - const r = token ? await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null; + const r = token ? await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null; if (r) { setReg(r); - const ev = await (await import('@/lib/api')).apiFetch(`/api/events/${encodeURIComponent(r.eventId)}`); + const ev = await (await import('@/lib/api')).apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(r.eventId)}`); if (ev?.form) setForm(ev.form); } } catch (e) { @@ -49,7 +50,7 @@ function RegistrationSuccessContent() { // multiple early-bird prices) — fall back to the old client-side estimate otherwise. if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed; try { - return (reg.registrationOptions || []).reduce((s: number, ro: any) => { + return (reg.registrationOptions || []).reduce((s, ro) => { const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) ? Number(ro.priceSnapshot) : (ro.variant?.price ?? ro.eventOption?.price ?? 0); @@ -86,7 +87,7 @@ function RegistrationSuccessContent() { // reload the registration so the displayed totalDue reflects it, instead of redirecting. setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`); try { - const r = await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }); + const r = await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }); if (r) setReg(r); } catch {} return; diff --git a/frontend/src/app/self-service/page.tsx b/frontend/src/app/self-service/page.tsx index 2ceba5e..1672bf5 100644 --- a/frontend/src/app/self-service/page.tsx +++ b/frontend/src/app/self-service/page.tsx @@ -3,10 +3,11 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { apiFetch } from "@/lib/api"; -import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth"; +import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth"; import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField"; import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor"; import { errorMessage } from "@/lib/utils"; +import type { Registration } from "@/types"; // ─── Types ──────────────────────────────────────────────────────────────────── type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null }; @@ -26,7 +27,7 @@ type FormField = { isRequired: boolean; order: number; helpText?: string | null; - options?: any; + options?: unknown; }; type EventForm = { id: string; @@ -294,11 +295,11 @@ export default function SelfServicePage() { // Second pass for accounts with two-factor on: exchange the code that was // sent after the password step. The kiosk never asks to be remembered. const data = setupChallenge - ? await apiFetch("/api/users/login/2fa", { + ? await apiFetch("/api/users/login/2fa", { method: "POST", body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false }, }) - : await apiFetch("/api/users/login", { + : await apiFetch("/api/users/login", { method: "POST", body: { email: setupEmail, password: setupPassword }, }); @@ -405,7 +406,15 @@ export default function SelfServicePage() { async function handleRegister() { setFormLoading(true); try { - const payload: any = { + const user: { name: string; email?: string; phoneNumber?: string; password?: string } = { + name: visitorName.trim(), + ...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}), + ...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}), + }; + if (createAccount && visitorPassword) { + user.password = visitorPassword; + } + const payload: Record = { eventId: selectedEventId, options: Object.entries(quantities) .filter(([, qty]) => qty > 0) @@ -413,11 +422,7 @@ export default function SelfServicePage() { const [eventOptionId, variantId] = key.split("::"); return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) }; }), - user: { - name: visitorName.trim(), - ...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}), - ...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}), - }, + user, // If the visitor declined to create an account, don't push an unsolicited // activation email/WhatsApp at them afterward. skipActivationNotice: !createAccount, @@ -431,11 +436,7 @@ export default function SelfServicePage() { payload.attendees = kioskNames; payload.ticketDelivery = delivery; } - if (createAccount && visitorPassword) { - payload.user.password = visitorPassword; - } - - const result: any = await apiFetch("/api/registrations/manual", { + const result = await apiFetch("/api/registrations/manual", { method: "POST", body: payload, authToken: supervisorToken, diff --git a/frontend/src/app/set-banner/page.tsx b/frontend/src/app/set-banner/page.tsx index fdede96..a959867 100644 --- a/frontend/src/app/set-banner/page.tsx +++ b/frontend/src/app/set-banner/page.tsx @@ -38,7 +38,7 @@ export default function SetBannerPage() { // Load current banner useEffect(() => { - apiFetch("/api/banner") + apiFetch<{ message?: string; type?: BannerType; liveFrom?: string | null; liveTill?: string | null } | null>("/api/banner") .then(b => { if (!b) return; setMessage(b.message || ""); diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index 174132e..0db3bff 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth"; import { AuthUser, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; +import { errorMessage } from "@/lib/utils"; // Matches the backend's minimum gap between code sends. const RESEND_COOLDOWN_SECONDS = 60; @@ -42,24 +43,16 @@ export function LoginForm() { window.location.href = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`; }; - const getErrorMessage = (err: any) => { - if (!err) return "Login failed"; - - // If it's already a normal Error - if (err.message && typeof err.message === "string") { - // Handle case where message is accidentally JSON string - try { - const parsed = JSON.parse(err.message); - return parsed.message || err.message; - } catch { - return err.message; - } + const getErrorMessage = (err: unknown) => { + const msg = errorMessage(err); + if (!msg) return "Login failed"; + // Handle case where message is accidentally a JSON string + try { + const parsed = JSON.parse(msg); + return parsed?.message || msg; + } catch { + return msg; } - - // If API response object was thrown directly - if (err.message) return err.message; - - return "Login failed"; }; const onSubmit = async (e: React.FormEvent) => { diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx index 573648e..c00b3f1 100644 --- a/frontend/src/components/auth/RegisterForm.tsx +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; import { isValidZAPhone } from "@/lib/phone"; +import { errorMessage } from "@/lib/utils"; export function RegisterForm() { const { register } = useAuth(); @@ -23,24 +24,16 @@ export function RegisterForm() { const hasValidPhone = isValidZAPhone(phoneNumber); - const getErrorMessage = (err: any) => { - if (!err) return "Login failed"; - - // If it's already a normal Error - if (err.message && typeof err.message === "string") { - // Handle case where message is accidentally JSON string - try { - const parsed = JSON.parse(err.message); - return parsed.message || err.message; - } catch { - return err.message; - } + const getErrorMessage = (err: unknown) => { + const msg = errorMessage(err); + if (!msg) return "Registration failed. Please try again."; + // Handle case where message is accidentally a JSON string + try { + const parsed = JSON.parse(msg); + return parsed?.message || msg; + } catch { + return msg; } - - // If API response object was thrown directly - if (err.message) return err.message; - - return "Registration failed. Please try again."; }; const onSubmit = async (e: React.FormEvent) => { diff --git a/frontend/src/components/qr/QRScanner.tsx b/frontend/src/components/qr/QRScanner.tsx index da86e9e..c7fd2db 100644 --- a/frontend/src/components/qr/QRScanner.tsx +++ b/frontend/src/components/qr/QRScanner.tsx @@ -28,11 +28,10 @@ export const QRScanner = React.forwardRef( // Track the underlying HTMLVideoElement from react-webcam reliably const [videoEl, setVideoEl] = useState(null); useEffect(() => { - let id: any; + let id: ReturnType | undefined; if (showCam) { id = setInterval(() => { - const anyCam = webcamRef.current as any; - const v: HTMLVideoElement | null = anyCam?.video ?? null; + const v = webcamRef.current?.video ?? null; if (v) { setVideoEl(v); clearInterval(id); } }, 100); } else { @@ -43,13 +42,14 @@ export const QRScanner = React.forwardRef( // Beep + cooldown state const lastScanRef = useRef(0); - const audioCtxRef = useRef(null); + const audioCtxRef = useRef(null); const playBeep = () => { try { - const AC = (window as any).AudioContext || (window as any).webkitAudioContext; + // Older Safari only has the prefixed constructor. + const AC = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AC) return; if (!audioCtxRef.current) audioCtxRef.current = new AC(); - const ctx = audioCtxRef.current as AudioContext; + const ctx = audioCtxRef.current; const oscillator = ctx.createOscillator(); const gainNode = ctx.createGain(); oscillator.type = "sine"; @@ -117,13 +117,14 @@ export const QRScanner = React.forwardRef( async function startScan() { const host = typeof window !== "undefined" ? window.location.hostname : ""; const isLocal = host === "localhost" || host === "127.0.0.1"; - if (!(navigator as any)?.mediaDevices?.getUserMedia) { + // mediaDevices is missing on insecure origins and some older browsers. + if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) { setLocalError("Camera API is not available in this browser."); return; } setLocalError(null); try { - const warmup = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false } as any); + const warmup = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false }); try { warmup.getTracks().forEach(t => t.stop()); } catch {} setShowCam(true); if (!active) toggle(); @@ -173,7 +174,7 @@ export const QRScanner = React.forwardRef( className="absolute inset-0 w-full h-full object-cover" onUserMedia={() => setLocalError(null)} onUserMediaError={(e) => { - const name = (e as any)?.name || (e as any)?.message; + const name = typeof e === "string" ? e : e.name || e.message; const protocol = typeof window !== "undefined" ? window.location.protocol : ""; const host = typeof window !== "undefined" ? window.location.hostname : ""; const isLocal = host === "localhost" || host === "127.0.0.1"; diff --git a/frontend/src/components/scanner/useOfflineScanning.ts b/frontend/src/components/scanner/useOfflineScanning.ts index 46818cb..e857070 100644 --- a/frontend/src/components/scanner/useOfflineScanning.ts +++ b/frontend/src/components/scanner/useOfflineScanning.ts @@ -57,7 +57,7 @@ export function useOfflineScanning(token: string | null) { queue.forEach(q => byLock.set(q.lockId, [...(byLock.get(q.lockId) || []), q])); let accepted = 0, conflicts = 0; for (const [lockId, scans] of byLock) { - const res = await apiFetch<{ accepted: number; duplicates: number; conflicts: any[] }>(`/api/scan-locks/${lockId}/sync`, { + const res = await apiFetch<{ accepted: number; duplicates: number; conflicts: unknown[] }>(`/api/scan-locks/${lockId}/sync`, { method: "POST", authToken: token, body: { deviceId: deviceId(), scans }, }); accepted += res.accepted; diff --git a/frontend/src/components/shared/AttendeesPanel.tsx b/frontend/src/components/shared/AttendeesPanel.tsx index 66b310a..0ac3568 100644 --- a/frontend/src/components/shared/AttendeesPanel.tsx +++ b/frontend/src/components/shared/AttendeesPanel.tsx @@ -12,7 +12,7 @@ const CONSENT_LABEL: Record = { declined: { text: "consent declined", cls: "text-red-700" }, }; -type Attendee = { +export type Attendee = { id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null; dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null; ticket?: { id: string; isUsed: boolean } | null; @@ -23,20 +23,41 @@ type Row = { dateOfBirth: string; gender: string; parentName: string; parentEmail: string; parentPhone: string; }; +// The parts of a registration (with its attendees, options and event) this panel reads. +export type AttendeesRegistration = { + id: string; + ticketDelivery?: "payer" | "each"; + attendees?: Attendee[]; + event?: { + requiresParentalConsent?: boolean; + collectGender?: boolean; + allowNamedAttendees?: boolean; + startDate?: string; + } | null; + registrationOptions?: { + eventOptionId: string; + variantId?: string | null; + quantity: number; + eventOption?: { name?: string; isMainTicket?: boolean } | null; + variant?: { name?: string } | null; + }[]; + formResponses?: { attendeeId?: string | null }[]; +}; + /** * Named attendees on a registration (group tickets): shows who each main-ticket place * is for, and lets the payer (or staff) name places, change details, choose where the * tickets go, and send an attendee the link to their own form. */ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, onMessage }: { - registration: any; + registration: AttendeesRegistration; token: string; canEdit: boolean; hasForm: boolean; onSaved: () => void; onMessage: (msg: string) => void; }) { - const attendees: Attendee[] = registration.attendees || []; + const attendees = useMemo(() => registration.attendees || [], [registration.attendees]); const youth = !!registration.event?.requiresParentalConsent; const collectGender = !!registration.event?.collectGender; const eventStart: string | undefined = registration.event?.startDate; @@ -117,7 +138,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, } }; - const answered = new Set((registration.formResponses || []).map((r: any) => r.attendeeId).filter(Boolean)); + const answered = new Set((registration.formResponses || []).map((r) => r.attendeeId).filter(Boolean)); const resendConsent = async (requestId: string) => { try { diff --git a/frontend/src/components/shared/TransferTicketModal.tsx b/frontend/src/components/shared/TransferTicketModal.tsx index b17080e..3e21f34 100644 --- a/frontend/src/components/shared/TransferTicketModal.tsx +++ b/frontend/src/components/shared/TransferTicketModal.tsx @@ -6,8 +6,16 @@ import { apiFetch } from "@/lib/api"; import { isMinorOn } from "@/components/shared/AttendeeNamesEditor"; import { errorMessage } from "@/lib/utils"; +type TransferTicket = { id: string; isUsed?: boolean; quantity?: number; usages?: unknown[] }; +type TransferEvent = { + startDate?: string; + allowTicketTransfer?: boolean; + requiresParentalConsent?: boolean; + collectGender?: boolean; +}; + /** Whether to offer "Transfer" on a ticket (the server checks the rules again). */ -export function canOfferTransfer(ticket: any, event: any, isSupervisor = false): boolean { +export function canOfferTransfer(ticket: TransferTicket | null | undefined, event: TransferEvent | null | undefined, isSupervisor = false): boolean { if (!ticket || !event) return false; if ((ticket.usages || []).length > 0 || ticket.isUsed) return false; if (event.startDate && new Date(event.startDate) <= new Date()) return false; @@ -20,8 +28,8 @@ export function canOfferTransfer(ticket: any, event: any, isSupervisor = false): * to the details typed, and the ticket only moves (with a new QR code) once they accept. */ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: { - ticket: any; - event: any; + ticket: TransferTicket; + event: TransferEvent | null | undefined; token: string; onClose: () => void; onDone: (message: string) => void; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index f66b028..0a3b415 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -8,7 +8,7 @@ export type AuthContextValue = { user: AuthUser | null; token: string | null; loading: boolean; - login: (email: string, password: string) => Promise; + login: (email: string, password: string) => Promise; verifyTwoFactor: (challengeId: string, code: string, rememberDevice: boolean) => Promise; resendTwoFactor: (challengeId: string) => Promise; register: (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => Promise; diff --git a/frontend/src/hooks/use-toast.ts b/frontend/src/hooks/use-toast.ts index f771b2a..40848d4 100644 --- a/frontend/src/hooks/use-toast.ts +++ b/frontend/src/hooks/use-toast.ts @@ -42,7 +42,7 @@ function addToast(toast: ToastInput) { const newToast: Toast = { ...toast, id, - open: true as any, + open: true, } // Ensure limit @@ -62,11 +62,11 @@ function updateToast(id: string, update: Partial) { function dismissToast(id?: string) { if (id) { queueRemoval(id) - updateToast(id, { open: false } as any) + updateToast(id, { open: false }) } else { state.toasts.forEach((t) => { queueRemoval(t.id) - updateToast(t.id, { open: false } as any) + updateToast(t.id, { open: false }) }) } } diff --git a/frontend/src/hooks/useQRScanner.ts b/frontend/src/hooks/useQRScanner.ts index 00161c2..a7ff4d8 100644 --- a/frontend/src/hooks/useQRScanner.ts +++ b/frontend/src/hooks/useQRScanner.ts @@ -44,7 +44,7 @@ export function useQRScanner(videoEl: HTMLVideoElement | null, opts: UseQRScanne try { if (!videoEl) return; const result = await reader.decodeOnceFromVideoElement(videoEl); - const text = (result as any)?.getText ? (result as any).getText() : (result as any)?.text ?? String(result ?? ""); + const text = result?.getText() ?? ""; if (text) onResult?.(text); } catch { // Ignore transient decode errors and retry diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0f0cd42..7b5314a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,7 +1,9 @@ +import type { Paginated, Payment, User } from "@/types"; + export type ApiOptions = { method?: string; headers?: Record; - body?: any; + body?: unknown; authToken?: string | null; nextOptions?: RequestInit; }; @@ -83,7 +85,7 @@ export class ApiError extends Error { } } -export async function apiFetch(path: string, options: ApiOptions = {}): Promise { +export async function apiFetch(path: string, options: ApiOptions = {}): Promise { const { method = "GET", headers = {}, body, authToken, nextOptions } = options; const url = path.startsWith("http") ? path : `${API_BASE}${path}`; @@ -163,10 +165,10 @@ export async function apiFetchListForPrerender(path: string, options: ApiOpti // Several list endpoints (/api/users, /api/payments) are paginated and cap // `limit` at 200 server-side, so a single request can silently miss records // beyond the first page. This walks every page and returns the full list. -async function fetchAllPages(path: string, token: string, params: Record = {}): Promise { +async function fetchAllPages(path: string, token: string, params: Record = {}): Promise { const query = new URLSearchParams({ ...params, limit: "200", page: "1" }); - const first = await apiFetch(`${path}?${query.toString()}`, { authToken: token }); - const items: any[] = Array.isArray(first?.data) ? first.data : []; + const first = await apiFetch>(`${path}?${query.toString()}`, { authToken: token }); + const items: T[] = Array.isArray(first?.data) ? first.data : []; const pages = first?.pages || 1; if (pages > 1) { @@ -177,7 +179,7 @@ async function fetchAllPages(path: string, token: string, params: Record { const page = i + 2; const pageQuery = new URLSearchParams({ ...params, limit: "200", page: String(page) }); - return apiFetch(`${path}?${pageQuery.toString()}`, { authToken: token }); + return apiFetch>(`${path}?${pageQuery.toString()}`, { authToken: token }); }) ); for (const res of remaining) { @@ -188,19 +190,21 @@ async function fetchAllPages(path: string, token: string, params: Record = {}): Promise { - return fetchAllPages("/api/users", token, params); +export function fetchAllUsers(token: string, params: Record = {}): Promise { + return fetchAllPages("/api/users", token, params); } -export function fetchAllPayments(token: string, params: Record = {}): Promise { - return fetchAllPages("/api/payments", token, params); +export function fetchAllPayments(token: string, params: Record = {}): Promise { + return fetchAllPages("/api/payments", token, params); } // Creates a Yoco checkout for a registration's full remaining outstanding balance. // Omitting `amount` makes the backend charge the full remainder rather than a partial amount — // partial-amount checkouts are only ever created from the supervisor payments dashboard. -export function createFullPaymentCheckout(token: string, registrationId: string): Promise<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }> { - return apiFetch("/api/payments/yoco-checkout", { +export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }; + +export function createFullPaymentCheckout(token: string, registrationId: string): Promise { + return apiFetch("/api/payments/yoco-checkout", { method: "POST", authToken: token, body: { diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 41837db..4a9959b 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -30,7 +30,7 @@ export type TwoFactorChallenge = { enrolling?: boolean; }; -export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge & { twoFactorRequired: true } { +export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge { return !!res && typeof res === "object" && (res as TwoFactorChallenge).twoFactorRequired === true; } diff --git a/frontend/src/lib/export.ts b/frontend/src/lib/export.ts index 4797658..8405216 100644 --- a/frontend/src/lib/export.ts +++ b/frontend/src/lib/export.ts @@ -1,4 +1,4 @@ -export type TableRow = Record; +export type TableRow = Record; export function downloadCsv(filename: string, rows: TableRow[], columns?: { key: string; label?: string }[]) { const cols = columns && columns.length > 0 ? columns : Object.keys(rows[0] || {}).map(k => ({ key: k, label: k })); @@ -24,7 +24,7 @@ function escapeCsv(v: string) { return v; } -function valueToString(v: any): string { +function valueToString(v: unknown): string { if (v == null) return ''; if (v instanceof Date) return v.toISOString(); if (typeof v === 'object') return JSON.stringify(v); diff --git a/frontend/src/lib/fuzzyMatch.ts b/frontend/src/lib/fuzzyMatch.ts index 4dcb211..2696f5f 100644 --- a/frontend/src/lib/fuzzyMatch.ts +++ b/frontend/src/lib/fuzzyMatch.ts @@ -15,7 +15,9 @@ export function editDistance(a: string, b: string): number { } /** Return a score 0–1 for how well a user matches a search query */ -export function scoreUser(u: any, raw: string): number { +export type MatchableUser = { name?: string | null; email?: string | null; phoneNumber?: string | null }; + +export function scoreUser(u: MatchableUser, raw: string): number { const q = raw.toLowerCase().trim(); if (!q || q.length < 2) return 0; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d0252f9..6b2f822 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,4 +1,9 @@ +// Shapes of the records the backend API returns. These mirror the Prisma models +// (backend/prisma/schema.prisma) with dates as ISO strings. Relations are optional +// because each endpoint includes a different subset of them. + export type Role = "user" | "staff" | "supervisor" | "admin"; +export type NotificationPreference = "email" | "whatsapp" | "both"; export type AuthUser = { id: string; @@ -16,27 +21,117 @@ export type AuthResponse = { user: AuthUser; }; +// A user record as admin/supervisor endpoints return it (never includes the password). +export type User = { + id: string; + name: string; + email: string; + phoneNumber?: string | null; + role?: Role; + isActive?: boolean; + notificationPreference?: NotificationPreference; + twoFactorEnabled?: boolean; + createdAt?: string; + updatedAt?: string; +}; + +// The few user fields most endpoints embed in related records. +export type UserSummary = Pick & Partial>; + +export type OptionVariant = { + id: string; + eventOptionId?: string; + name: string; + price?: number | null; + staffPrice?: number | null; + stockLimit?: number; + order?: number; +}; + +export type EarlyBirdTier = { + id: string; + eventOptionId?: string; + variantId?: string | null; + deadline: string; + price: number; + order?: number; + stockLimit?: number; +}; + export type EventOption = { id: string; + eventId?: string; name: string; price: number; isMainTicket?: boolean; + stockLimit?: number; + staffPrice?: number | null; + variants?: OptionVariant[]; + earlyBirdTiers?: EarlyBirdTier[]; createdAt?: string; updatedAt?: string; }; +export type FormFieldType = + | "yes_no" | "text" | "date" | "numeric" | "statement" | "paragraph" | "rating" | "multiple_choice"; + +export type EventFormField = { + id: string; + formId?: string; + type: FormFieldType; + label: string; + isRequired?: boolean; + order?: number; + helpText?: string | null; + options?: unknown; +}; + +export type EventForm = { + id?: string; + eventId?: string; + isRequired: boolean; + fields: EventFormField[]; +}; + export type Event = { id: string; title: string; - description?: string; + description?: string | null; startDate: string; endDate: string; registrationDeadline?: string | null; goLiveAt?: string; price: number; - picture?: string; + picture?: string | null; + location?: string | null; isActive?: boolean; + isHidden?: boolean; + isLive?: boolean; + requiresAuth?: boolean; + requiresRegistration?: boolean; + allowNamedAttendees?: boolean; + requiresParentalConsent?: boolean; + consentText?: string | null; + consentTextVersion?: number; + consentCollectMedical?: boolean; + consentCollectEmergency?: boolean; + consentCollectPhoto?: boolean; + waitlistEnabled?: boolean; + waitlistOfferHours?: number; + waitlistPartialOffers?: boolean; + allowTicketTransfer?: boolean; + collectGender?: boolean; + allowSponsorships?: boolean; + sponsorshipGoalSpots?: number | null; + showSponsorshipProgress?: boolean; + contactName?: string | null; + contactPhone?: string | null; + contactEmail?: string | null; + createdById?: string | null; + redirectUrl?: string | null; options?: EventOption[]; + eventOptions?: EventOption[]; + form?: EventForm | null; createdAt?: string; updatedAt?: string; cashupStatus?: "open" | "closed"; @@ -143,22 +238,34 @@ export type EventFinancials = { salesByOption: SalesByOption[]; history: EventCashup[]; }; +export type RegistrationStatus = "pending" | "confirmed" | "partial_paid" | "paid" | "cancelled"; -export type RegistrationItem = { - id?: string; +export type RegistrationOption = { + id: string; + registrationId?: string; eventOptionId: string; quantity: number; + variantId?: string | null; + priceSnapshot?: number | null; + eventOption?: EventOption; + variant?: OptionVariant | null; }; export type Registration = { id: string; userId: string; eventId: string; - status: "pending" | "paid" | string; + status: RegistrationStatus; + discountTotal?: number; + ticketDelivery?: "payer" | "each"; createdAt?: string; updatedAt?: string; - event?: Pick; - options?: RegistrationItem[]; + user?: UserSummary; + event?: Event; + registrationOptions?: RegistrationOption[]; + payments?: Payment[]; + // Tranche-aware total the backend attaches to registration responses. + totalDueComputed?: number | null; }; export type Ticket = { @@ -167,11 +274,16 @@ export type Ticket = { registrationOptionId?: string; userId: string; eventId: string; + quantity?: number; isUsed: boolean; - emailSent: boolean; - createdAt: string; - updatedAt: string; - event?: Pick; + emailSent?: boolean; + attendeeId?: string | null; + holderUserId?: string | null; + createdAt?: string; + updatedAt?: string; + user?: UserSummary; + event?: Event; + registrationOption?: RegistrationOption; }; export type Payment = { @@ -179,9 +291,61 @@ export type Payment = { amount: number; method: string; userId: string; - registrationId?: string; - eventId?: string; + recordedById?: string | null; + registrationId?: string | null; + eventId?: string | null; isDonation?: boolean; + isSponsorship?: boolean; + status?: string | null; + externalId?: string | null; + originalPaymentId?: string | null; createdAt?: string; - updatedAt?: string; + user?: UserSummary; + recordedBy?: UserSummary | null; + event?: Pick | null; + registration?: Registration | null; +}; + +// A page of results from a paginated list endpoint (/api/users, /api/payments, ...). +export type Paginated = { + data: T[]; + total: number; + page: number; + pages: number; +}; + +// /api/stats/{staff,supervisor,admin} — dashboard summaries. +export type ScanStats = { + totalToday: number; + myToday: number; + lastHour: number; + byStaff: { scannedById: string; name: string; count: number }[]; +}; + +export type PaymentStats = { + totalToday: number; + donationsToday?: number; + totalWeek?: number; + totalMonth?: number; +}; + +export type RecentScan = { + id: string; + scannedAt: string; + quantityRedeemed: number; + scannedById?: string; + scannedBy?: { id: string; name: string } | null; + ticket?: { + id: string; + eventId?: string; + event?: { title: string } | null; + registrationOption?: { eventOption?: { name: string } | null } | null; + } | null; +}; + +export type DashboardStats = { + scanStats: ScanStats; + recentScans: RecentScan[]; + paymentStats: PaymentStats; + activeEventsCount: number; };