Add shared API types and type the smaller frontend pages
Expand src/types with User, Event, EventOption, Registration, Payment, Ticket, paginated results and dashboard stats, modelled on the Prisma schema. apiFetch now defaults to unknown instead of any. Dashboard loaders are wrapped in useCallback so their effects can list them as dependencies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -25,7 +25,7 @@ function ActivateAccountContent() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const data = await apiFetch<any>("/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);
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -26,7 +27,7 @@ export default function CashupLandingPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/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();
|
||||
|
||||
@@ -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<any | null>(null);
|
||||
const [paymentStats, setPaymentStats] = useStableState<PaymentStats | null>(null);
|
||||
const [overview, setOverview] = useStableState<Overview | null>(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<any>("/api/stats/admin", { authToken: token }),
|
||||
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
|
||||
apiFetch<Overview>("/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 (
|
||||
|
||||
@@ -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<any>(`/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<any>("/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) {
|
||||
|
||||
@@ -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<any | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<any[]>([]);
|
||||
const [stats, setStats] = useStableState<ScanStats | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<RecentScan[]>([]);
|
||||
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<any>("/api/stats/staff", { authToken: token });
|
||||
const data = await apiFetch<DashboardStats>("/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 && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
{recentScans.map((u) => (
|
||||
<li key={u.id} className="border rounded p-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
||||
@@ -122,9 +123,9 @@ export default function StaffDashboardPage() {
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Today by staff</h2>
|
||||
{stats?.byStaff?.length > 0 ? (
|
||||
{stats && stats.byStaff?.length > 0 ? (
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{stats.byStaff.map((s: any) => (
|
||||
{stats.byStaff.map((s) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [locks, setLocks] = useState<Lock[]>([]);
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
@@ -39,7 +40,7 @@ export default function OfflineScansPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
apiFetch<Event[]>("/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);
|
||||
|
||||
@@ -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<any | null>(null);
|
||||
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<any[]>([]);
|
||||
const [scanStats, setScanStats] = useStableState<ScanStats | null>(null);
|
||||
const [paymentStats, setPaymentStats] = useStableState<PaymentStats | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<RecentScan[]>([]);
|
||||
const [overview, setOverview] = useStableState<Overview | null>(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<any>("/api/stats/supervisor", { authToken: token }),
|
||||
apiFetch<DashboardStats>("/api/stats/supervisor", { authToken: token }),
|
||||
apiFetch<Overview>("/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 && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
{recentScans.map((u) => (
|
||||
<li key={u.id} className="border rounded p-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
||||
@@ -236,11 +237,11 @@ export default function SupervisorDashboardPage() {
|
||||
<div className="text-sm text-gray-500">No payment data yet.</div>
|
||||
)}
|
||||
|
||||
{scanStats?.byStaff?.length > 0 && (
|
||||
{scanStats && scanStats.byStaff?.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{scanStats.byStaff.map((s: any) => (
|
||||
{scanStats.byStaff.map((s) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState(search.get("eventId") || "");
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -53,7 +54,7 @@ export default function WaitlistPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all", { authToken: token })
|
||||
apiFetch<Event[]>("/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 });
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>("");
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -18,14 +19,14 @@ export default function DonatePage() {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events");
|
||||
const evs = await apiFetch<Event[]>("/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; }
|
||||
|
||||
@@ -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<any>(`/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<any>(buildQuery(p), { authToken: token });
|
||||
const res = await apiFetch<Paginated<PaymentItem>>(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() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Type</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as any)}>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as "" | "payment" | "refund")}>
|
||||
<option value="">Payments & refunds</option>
|
||||
<option value="payment">Payments only</option>
|
||||
<option value="refund">Refunds only</option>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -12,7 +14,7 @@ export default function ClientActions({ event }: { event: any }) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: event.title,
|
||||
text: event.description,
|
||||
text: event.description ?? undefined,
|
||||
url: window.location.href,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number };
|
||||
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number; order?: number };
|
||||
type EventOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -283,7 +283,7 @@ function TicketList({ event }: { event: Event }) {
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{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);
|
||||
|
||||
@@ -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<Event>(() => {
|
||||
const event = useMemo<RegisterEvent>(() => {
|
||||
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 (
|
||||
<div className="mt-4 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
|
||||
|
||||
@@ -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<any>(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } });
|
||||
event = await apiFetch<RegisterEvent>(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) notFound();
|
||||
throw e;
|
||||
|
||||
@@ -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<any | null>(null);
|
||||
const [reg, setReg] = React.useState<Registration | null>(null);
|
||||
const [form, setForm] = React.useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(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<Registration>(`/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<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
if (r) setReg(r);
|
||||
} catch {}
|
||||
return;
|
||||
|
||||
@@ -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<AuthResponse>("/api/users/login/2fa", {
|
||||
method: "POST",
|
||||
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
|
||||
})
|
||||
: await apiFetch("/api/users/login", {
|
||||
: await apiFetch<AuthResponse | TwoFactorChallenge>("/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<string, unknown> = {
|
||||
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<Registration>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
authToken: supervisorToken,
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function SetBannerPage() {
|
||||
|
||||
// Load current banner
|
||||
useEffect(() => {
|
||||
apiFetch<any>("/api/banner")
|
||||
apiFetch<{ message?: string; type?: BannerType; liveFrom?: string | null; liveTill?: string | null } | null>("/api/banner")
|
||||
.then(b => {
|
||||
if (!b) return;
|
||||
setMessage(b.message || "");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -28,11 +28,10 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
// Track the underlying HTMLVideoElement from react-webcam reliably
|
||||
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
||||
useEffect(() => {
|
||||
let id: any;
|
||||
let id: ReturnType<typeof setInterval> | 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<QRScannerHandle, QRScannerProps>(
|
||||
|
||||
// Beep + cooldown state
|
||||
const lastScanRef = useRef<number>(0);
|
||||
const audioCtxRef = useRef<any>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(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<QRScannerHandle, QRScannerProps>(
|
||||
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<QRScannerHandle, QRScannerProps>(
|
||||
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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,7 +12,7 @@ const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
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<Attendee[]>(() => 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,7 +8,7 @@ export type AuthContextValue = {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<AuthUser | (TwoFactorChallenge & { twoFactorRequired: true })>;
|
||||
login: (email: string, password: string) => Promise<AuthUser | TwoFactorChallenge>;
|
||||
verifyTwoFactor: (challengeId: string, code: string, rememberDevice: boolean) => Promise<AuthUser>;
|
||||
resendTwoFactor: (challengeId: string) => Promise<TwoFactorChallenge>;
|
||||
register: (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => Promise<void>;
|
||||
|
||||
@@ -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<Toast>) {
|
||||
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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-12
@@ -1,7 +1,9 @@
|
||||
import type { Paginated, Payment, User } from "@/types";
|
||||
|
||||
export type ApiOptions = {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
body?: unknown;
|
||||
authToken?: string | null;
|
||||
nextOptions?: RequestInit;
|
||||
};
|
||||
@@ -83,7 +85,7 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = any>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||
export async function apiFetch<T = unknown>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||
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<T>(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<string, string> = {}): Promise<any[]> {
|
||||
async function fetchAllPages<T>(path: string, token: string, params: Record<string, string> = {}): Promise<T[]> {
|
||||
const query = new URLSearchParams({ ...params, limit: "200", page: "1" });
|
||||
const first = await apiFetch<any>(`${path}?${query.toString()}`, { authToken: token });
|
||||
const items: any[] = Array.isArray(first?.data) ? first.data : [];
|
||||
const first = await apiFetch<Paginated<T>>(`${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<string,
|
||||
Array.from({ length: pages - 1 }, (_, i) => {
|
||||
const page = i + 2;
|
||||
const pageQuery = new URLSearchParams({ ...params, limit: "200", page: String(page) });
|
||||
return apiFetch<any>(`${path}?${pageQuery.toString()}`, { authToken: token });
|
||||
return apiFetch<Paginated<T>>(`${path}?${pageQuery.toString()}`, { authToken: token });
|
||||
})
|
||||
);
|
||||
for (const res of remaining) {
|
||||
@@ -188,19 +190,21 @@ async function fetchAllPages(path: string, token: string, params: Record<string,
|
||||
return items;
|
||||
}
|
||||
|
||||
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<any[]> {
|
||||
return fetchAllPages("/api/users", token, params);
|
||||
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<User[]> {
|
||||
return fetchAllPages<User>("/api/users", token, params);
|
||||
}
|
||||
|
||||
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> {
|
||||
return fetchAllPages("/api/payments", token, params);
|
||||
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<Payment[]> {
|
||||
return fetchAllPages<Payment>("/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<CheckoutResponse> {
|
||||
return apiFetch<CheckoutResponse>("/api/payments/yoco-checkout", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type TableRow = Record<string, any>;
|
||||
export type TableRow = Record<string, unknown>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+178
-14
@@ -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<User, "id" | "name" | "email"> & Partial<Pick<User, "phoneNumber" | "role">>;
|
||||
|
||||
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<Event, "id" | "title" | "price">;
|
||||
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<Event, "id" | "title">;
|
||||
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<Event, "id" | "title"> | null;
|
||||
registration?: Registration | null;
|
||||
};
|
||||
|
||||
// A page of results from a paginated list endpoint (/api/users, /api/payments, ...).
|
||||
export type Paginated<T> = {
|
||||
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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user