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 {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus(null);
|
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.");
|
setStatus(res?.message || "If that email exists, a password reset link has been sent.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function ActivateAccountContent() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus(null);
|
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
|
// Auto-login: save the session token (under the key AuthContext reads) and load the
|
||||||
// dashboard fresh so the new session is picked up.
|
// dashboard fresh so the new session is picked up.
|
||||||
if (data?.token) saveToken(data.token);
|
if (data?.token) saveToken(data.token);
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { apiFetch } from "@/lib/api";
|
|||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { Wallet } from "lucide-react";
|
import { Wallet } from "lucide-react";
|
||||||
import { errorMessage } from "@/lib/utils";
|
import { errorMessage } from "@/lib/utils";
|
||||||
|
import type { Event } from "@/types";
|
||||||
|
|
||||||
export default function CashupLandingPage() {
|
export default function CashupLandingPage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [events, setEvents] = useState<any[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useDismissingState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -26,7 +27,7 @@ export default function CashupLandingPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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 : []);
|
setEvents(Array.isArray(evs) ? evs : []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(errorMessage(e) || "Failed to load events");
|
setError(errorMessage(e) || "Failed to load events");
|
||||||
@@ -34,7 +35,7 @@ export default function CashupLandingPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [token]);
|
}, [token, setError]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
@@ -14,6 +14,7 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
|||||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
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 formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
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/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
|
// /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.
|
// 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 [overview, setOverview] = useStableState<Overview | null>(null);
|
||||||
const [loadingStats, setLoadingStats] = useState(false);
|
const [loadingStats, setLoadingStats] = useState(false);
|
||||||
const hasLoadedOnce = useRef(false);
|
const hasLoadedOnce = useRef(false);
|
||||||
const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today");
|
const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today");
|
||||||
|
|
||||||
const loadStats = async () => {
|
const loadStats = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const isFirstLoad = !hasLoadedOnce.current;
|
const isFirstLoad = !hasLoadedOnce.current;
|
||||||
try {
|
try {
|
||||||
if (isFirstLoad) setLoadingStats(true);
|
if (isFirstLoad) setLoadingStats(true);
|
||||||
const [dash, overviewData] = await Promise.all([
|
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 }),
|
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
||||||
]);
|
]);
|
||||||
setPaymentStats(dash.paymentStats);
|
setPaymentStats(dash.paymentStats);
|
||||||
@@ -90,12 +91,12 @@ export default function AdminDashboardPage() {
|
|||||||
hasLoadedOnce.current = true;
|
hasLoadedOnce.current = true;
|
||||||
if (isFirstLoad) setLoadingStats(false);
|
if (isFirstLoad) setLoadingStats(false);
|
||||||
}
|
}
|
||||||
};
|
}, [token, setPaymentStats, setOverview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
loadStats();
|
loadStats();
|
||||||
}, [token]);
|
}, [token, loadStats]);
|
||||||
|
|
||||||
// Poll every 15s while the tab is visible; pause in the background and refetch
|
// Poll every 15s while the tab is visible; pause in the background and refetch
|
||||||
// immediately on return instead of leaving stale numbers up.
|
// immediately on return instead of leaving stale numbers up.
|
||||||
@@ -105,7 +106,7 @@ export default function AdminDashboardPage() {
|
|||||||
}, 15000, !!token);
|
}, 15000, !!token);
|
||||||
|
|
||||||
const paymentsTabValue = paymentStats
|
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;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -179,9 +179,9 @@ function SiteSettingsPageInner() {
|
|||||||
setConsentRetentionDays(s.consent_medical_retention_days || "30");
|
setConsentRetentionDays(s.consent_medical_retention_days || "30");
|
||||||
setNotifyOnImpersonation(s.notify_user_on_impersonation === "true");
|
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));
|
.finally(() => setLoadingInitial(false));
|
||||||
}, [token]);
|
}, [token, setResult]);
|
||||||
|
|
||||||
// Clear result when switching tabs
|
// Clear result when switching tabs
|
||||||
const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); };
|
const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); };
|
||||||
@@ -921,7 +921,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
|||||||
setBusy(action);
|
setBusy(action);
|
||||||
setActionMsg(null);
|
setActionMsg(null);
|
||||||
try {
|
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.` });
|
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
|
||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
await fetchConfig();
|
await fetchConfig();
|
||||||
@@ -978,7 +978,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
|||||||
setSavingInstance(true);
|
setSavingInstance(true);
|
||||||
setActionMsg(null);
|
setActionMsg(null);
|
||||||
try {
|
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." });
|
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
|
||||||
await fetchConfig();
|
await fetchConfig();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch } from "@/lib/api";
|
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 { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react";
|
||||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||||
|
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
|
||||||
|
|
||||||
const QUICK_ACTIONS = [
|
const QUICK_ACTIONS = [
|
||||||
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
|
{ 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
|
// (/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
|
// 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.
|
// on the very first load — together these stop the stats panel from flickering.
|
||||||
const [stats, setStats] = useStableState<any | null>(null);
|
const [stats, setStats] = useStableState<ScanStats | null>(null);
|
||||||
const [recentScans, setRecentScans] = useStableState<any[]>([]);
|
const [recentScans, setRecentScans] = useStableState<RecentScan[]>([]);
|
||||||
const [loadingStats, setLoadingStats] = useState(false);
|
const [loadingStats, setLoadingStats] = useState(false);
|
||||||
const hasLoadedOnce = useRef(false);
|
const hasLoadedOnce = useRef(false);
|
||||||
|
|
||||||
const loadStats = async () => {
|
const loadStats = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const isFirstLoad = !hasLoadedOnce.current;
|
const isFirstLoad = !hasLoadedOnce.current;
|
||||||
try {
|
try {
|
||||||
if (isFirstLoad) setLoadingStats(true);
|
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);
|
setStats(data.scanStats);
|
||||||
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
|
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -54,12 +55,12 @@ export default function StaffDashboardPage() {
|
|||||||
hasLoadedOnce.current = true;
|
hasLoadedOnce.current = true;
|
||||||
if (isFirstLoad) setLoadingStats(false);
|
if (isFirstLoad) setLoadingStats(false);
|
||||||
}
|
}
|
||||||
};
|
}, [token, setStats, setRecentScans]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
loadStats();
|
loadStats();
|
||||||
}, [token]);
|
}, [token, loadStats]);
|
||||||
|
|
||||||
// Poll every 10s while the tab is visible; pause in the background and refetch
|
// Poll every 10s while the tab is visible; pause in the background and refetch
|
||||||
// immediately on return instead of leaving stale numbers up.
|
// 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>}
|
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||||
</div>
|
</div>
|
||||||
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
|
<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">
|
<li key={u.id} className="border rounded p-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
||||||
@@ -122,9 +123,9 @@ export default function StaffDashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||||
<h2 className="text-lg font-semibold mb-3">Today by staff</h2>
|
<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">
|
<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">
|
<li key={s.scannedById} className="flex justify-between">
|
||||||
<span>{s.name || 'Staff'}</span>
|
<span>{s.name || 'Staff'}</span>
|
||||||
<span className="font-medium">{s.count}</span>
|
<span className="font-medium">{s.count}</span>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { formatDate } from "@/lib/date";
|
|||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
|
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
|
||||||
import { errorMessage } from "@/lib/utils";
|
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 Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
|
||||||
type Report = {
|
type Report = {
|
||||||
@@ -29,7 +30,7 @@ export default function OfflineScansPage() {
|
|||||||
if (!user) router.replace("/login");
|
if (!user) router.replace("/login");
|
||||||
}, [user, loading, router]);
|
}, [user, loading, router]);
|
||||||
|
|
||||||
const [events, setEvents] = useState<any[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [eventId, setEventId] = useState("");
|
const [eventId, setEventId] = useState("");
|
||||||
const [locks, setLocks] = useState<Lock[]>([]);
|
const [locks, setLocks] = useState<Lock[]>([]);
|
||||||
const [reports, setReports] = useState<Report[]>([]);
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
@@ -39,7 +40,7 @@ export default function OfflineScansPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !canView) return;
|
if (!token || !canView) return;
|
||||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
|
||||||
.then(evs => {
|
.then(evs => {
|
||||||
const recent = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 14 * 864e5);
|
const recent = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 14 * 864e5);
|
||||||
setEvents(recent);
|
setEvents(recent);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useAuth } from "@/hooks/useAuth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
@@ -14,6 +14,7 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
|||||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
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 formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
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/supervisor covers scan activity + today's payments (unchanged from before);
|
||||||
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint, shared
|
// /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.
|
// with the admin dashboard since supervisors already have full revenue/donation visibility.
|
||||||
const [scanStats, setScanStats] = useStableState<any | null>(null);
|
const [scanStats, setScanStats] = useStableState<ScanStats | null>(null);
|
||||||
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
|
const [paymentStats, setPaymentStats] = useStableState<PaymentStats | null>(null);
|
||||||
const [recentScans, setRecentScans] = useStableState<any[]>([]);
|
const [recentScans, setRecentScans] = useStableState<RecentScan[]>([]);
|
||||||
const [overview, setOverview] = useStableState<Overview | null>(null);
|
const [overview, setOverview] = useStableState<Overview | null>(null);
|
||||||
const [loadingStats, setLoadingStats] = useState(false);
|
const [loadingStats, setLoadingStats] = useState(false);
|
||||||
const hasLoadedOnce = useRef(false);
|
const hasLoadedOnce = useRef(false);
|
||||||
|
|
||||||
const loadStats = async () => {
|
const loadStats = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const isFirstLoad = !hasLoadedOnce.current;
|
const isFirstLoad = !hasLoadedOnce.current;
|
||||||
try {
|
try {
|
||||||
if (isFirstLoad) setLoadingStats(true);
|
if (isFirstLoad) setLoadingStats(true);
|
||||||
const [data, overviewData] = await Promise.all([
|
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 }),
|
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
||||||
]);
|
]);
|
||||||
setScanStats(data.scanStats);
|
setScanStats(data.scanStats);
|
||||||
@@ -92,12 +93,12 @@ export default function SupervisorDashboardPage() {
|
|||||||
hasLoadedOnce.current = true;
|
hasLoadedOnce.current = true;
|
||||||
if (isFirstLoad) setLoadingStats(false);
|
if (isFirstLoad) setLoadingStats(false);
|
||||||
}
|
}
|
||||||
};
|
}, [token, setScanStats, setPaymentStats, setRecentScans, setOverview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
loadStats();
|
loadStats();
|
||||||
}, [token]);
|
}, [token, loadStats]);
|
||||||
|
|
||||||
// Poll every 15s while the tab is visible; pause in the background and refetch
|
// Poll every 15s while the tab is visible; pause in the background and refetch
|
||||||
// immediately on return instead of leaving stale numbers up.
|
// 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>}
|
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||||
</div>
|
</div>
|
||||||
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
|
<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">
|
<li key={u.id} className="border rounded p-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
<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>
|
<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="mt-4">
|
||||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||||
<ul className="text-sm text-gray-700 space-y-1">
|
<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">
|
<li key={s.scannedById} className="flex justify-between">
|
||||||
<span>{s.name || 'Staff'}</span>
|
<span>{s.name || 'Staff'}</span>
|
||||||
<span className="font-medium">{s.count}</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 { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||||
import { errorMessage } from "@/lib/utils";
|
import { errorMessage } from "@/lib/utils";
|
||||||
|
import type { Event } from "@/types";
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,7 +45,7 @@ export default function WaitlistPage() {
|
|||||||
if (!user) router.replace("/login");
|
if (!user) router.replace("/login");
|
||||||
}, [user, loading, router]);
|
}, [user, loading, router]);
|
||||||
|
|
||||||
const [events, setEvents] = useState<any[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [eventId, setEventId] = useState(search.get("eventId") || "");
|
const [eventId, setEventId] = useState(search.get("eventId") || "");
|
||||||
const [entries, setEntries] = useState<Entry[]>([]);
|
const [entries, setEntries] = useState<Entry[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -53,7 +54,7 @@ export default function WaitlistPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !canView) return;
|
if (!token || !canView) return;
|
||||||
apiFetch<any[]>("/api/events/all", { authToken: token })
|
apiFetch<Event[]>("/api/events/all", { authToken: token })
|
||||||
.then(evs => {
|
.then(evs => {
|
||||||
const withList = (evs || []).filter(e => e.waitlistEnabled);
|
const withList = (evs || []).filter(e => e.waitlistEnabled);
|
||||||
setEvents(withList);
|
setEvents(withList);
|
||||||
@@ -76,7 +77,7 @@ export default function WaitlistPage() {
|
|||||||
}, [token, eventId, setError]);
|
}, [token, eventId, setError]);
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const act = async (path: string, body?: any) => {
|
const act = async (path: string, body?: { direction: "up" | "down" }) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ message?: string }>(path, { method: "POST", authToken: token!, body });
|
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 { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { HandHeart } from "lucide-react";
|
import { HandHeart } from "lucide-react";
|
||||||
import { errorMessage } from "@/lib/utils";
|
import { errorMessage } from "@/lib/utils";
|
||||||
|
import type { Event } from "@/types";
|
||||||
|
|
||||||
export default function DonatePage() {
|
export default function DonatePage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const [events, setEvents] = useState<any[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [eventId, setEventId] = useState<string>("");
|
const [eventId, setEventId] = useState<string>("");
|
||||||
const [amount, setAmount] = useState<string>("");
|
const [amount, setAmount] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -18,14 +19,14 @@ export default function DonatePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const evs = await apiFetch<any[]>("/api/events");
|
const evs = await apiFetch<Event[]>("/api/events");
|
||||||
setEvents(evs);
|
setEvents(evs);
|
||||||
if (evs.length > 0) setEventId(evs[0].id);
|
if (evs.length > 0) setEventId(evs[0].id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(errorMessage(e) || "Failed to load events");
|
setError(errorMessage(e) || "Failed to load events");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, [setError]);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!token) { setError("Please login"); return; }
|
if (!token) { setError("Please login"); return; }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { formatDateTime } from "@/lib/date";
|
|||||||
import { formatPaymentMethod } from "@/lib/paymentMethod";
|
import { formatPaymentMethod } from "@/lib/paymentMethod";
|
||||||
import { Receipt } from "lucide-react";
|
import { Receipt } from "lucide-react";
|
||||||
import { errorMessage } from "@/lib/utils";
|
import { errorMessage } from "@/lib/utils";
|
||||||
|
import type { Paginated } from "@/types";
|
||||||
|
|
||||||
interface PaymentItem {
|
interface PaymentItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,7 +51,7 @@ export default function UserPaymentsPage() {
|
|||||||
setInfo(null);
|
setInfo(null);
|
||||||
setSendingReceipt(true);
|
setSendingReceipt(true);
|
||||||
try {
|
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",
|
method: "POST",
|
||||||
body: { channel },
|
body: { channel },
|
||||||
authToken: token,
|
authToken: token,
|
||||||
@@ -83,7 +84,7 @@ export default function UserPaymentsPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setFetching(true);
|
setFetching(true);
|
||||||
try {
|
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 : []);
|
setPayments(Array.isArray(res?.data) ? res.data : []);
|
||||||
setTotal(res?.total ?? 0);
|
setTotal(res?.total ?? 0);
|
||||||
setTotalPages(res?.pages ?? 1);
|
setTotalPages(res?.pages ?? 1);
|
||||||
@@ -93,9 +94,10 @@ export default function UserPaymentsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setFetching(false);
|
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);
|
const goToPage = (p: number) => loadPayments(p);
|
||||||
|
|
||||||
@@ -151,7 +153,7 @@ export default function UserPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-600 mb-1">Type</label>
|
<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="">Payments & refunds</option>
|
||||||
<option value="payment">Payments only</option>
|
<option value="payment">Payments only</option>
|
||||||
<option value="refund">Refunds only</option>
|
<option value="refund">Refunds only</option>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { Share2, QrCode, CalendarPlus } from "lucide-react";
|
|||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
import { API_BASE } from "@/lib/api";
|
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 (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -12,7 +14,7 @@ export default function ClientActions({ event }: { event: any }) {
|
|||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: event.title,
|
title: event.title,
|
||||||
text: event.description,
|
text: event.description ?? undefined,
|
||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
|||||||
|
|
||||||
export const revalidate = 60;
|
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 = {
|
type EventOption = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -283,7 +283,7 @@ function TicketList({ event }: { event: Event }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y">
|
<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 vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||||
const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0;
|
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);
|
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);
|
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 OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number; order?: number };
|
||||||
type EventOption = {
|
type EventOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,7 +32,7 @@ type EventOption = {
|
|||||||
variants?: OptionVariant[];
|
variants?: OptionVariant[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type Event = {
|
export type RegisterEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -42,7 +42,8 @@ type Event = {
|
|||||||
price: number;
|
price: number;
|
||||||
picture?: string;
|
picture?: string;
|
||||||
eventOptions?: EventOption[];
|
eventOptions?: EventOption[];
|
||||||
form?: { isRequired: boolean; fields?: any[] } | null;
|
form?: { isRequired: boolean; fields?: unknown[] } | null;
|
||||||
|
requiresRegistration?: boolean;
|
||||||
requiresAuth?: boolean;
|
requiresAuth?: boolean;
|
||||||
allowNamedAttendees?: boolean;
|
allowNamedAttendees?: boolean;
|
||||||
requiresParentalConsent?: boolean;
|
requiresParentalConsent?: boolean;
|
||||||
@@ -62,7 +63,7 @@ function effectiveUnitForOption(opt: EventOption): number {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const applicable = tiers
|
const applicable = tiers
|
||||||
.map((t) => ({ ...t, _d: new Date(t.deadline) }))
|
.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());
|
.sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime());
|
||||||
return applicable.length > 0 ? applicable[0].price : base;
|
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 { token, user } = useAuth();
|
||||||
const eventId = baseEvent.id;
|
const eventId = baseEvent.id;
|
||||||
|
|
||||||
@@ -272,9 +273,9 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
|||||||
setOffer(res);
|
setOffer(res);
|
||||||
setQuantities(q => ({ ...q, [res.variantId ? `${res.eventOptionId}:${res.variantId}` : res.eventOptionId]: res.quantity }));
|
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;
|
if (!offer) return baseEvent;
|
||||||
return {
|
return {
|
||||||
...baseEvent,
|
...baseEvent,
|
||||||
@@ -560,11 +561,11 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const upcomingTiers = (event.eventOptions || []).flatMap(opt =>
|
const upcomingTiers = (event.eventOptions || []).flatMap(opt =>
|
||||||
(opt.earlyBirdTiers || [])
|
(opt.earlyBirdTiers || [])
|
||||||
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
|
.map((t) => ({ ...t, deadline: new Date(t.deadline) }))
|
||||||
.filter((t: any) => t.deadline > now)
|
.filter((t) => t.deadline > now)
|
||||||
).sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime());
|
).sort((a, b) => a.deadline.getTime() - b.deadline.getTime());
|
||||||
if (upcomingTiers.length === 0) return null;
|
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" });
|
const dateStr = soonest.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
|
<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 { Navbar } from "@/components/layout/Navbar";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
import RegisterForm from "./RegisterForm";
|
import RegisterForm, { type RegisterEvent } from "./RegisterForm";
|
||||||
import { FunnelTracker } from "@/components/events/FunnelTracker";
|
import { FunnelTracker } from "@/components/events/FunnelTracker";
|
||||||
|
|
||||||
export const revalidate = 60;
|
export const revalidate = 60;
|
||||||
|
|
||||||
export default async function RegisterPage({ params }: { params: Promise<{ eventId: string }> }) {
|
export default async function RegisterPage({ params }: { params: Promise<{ eventId: string }> }) {
|
||||||
const { eventId } = await params;
|
const { eventId } = await params;
|
||||||
let event: any;
|
let event: RegisterEvent;
|
||||||
try {
|
try {
|
||||||
// Fetched server-side (same pattern as /events/[id]) so the form's content is part of
|
// 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.
|
// 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) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 404) notFound();
|
if (e instanceof ApiError && e.status === 404) notFound();
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSearchParams, useRouter } from "next/navigation";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
import { errorMessage } from "@/lib/utils";
|
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 };
|
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;
|
return isNaN(n) ? 0 : n;
|
||||||
}, [fallbackTotalParam]);
|
}, [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 [form, setForm] = React.useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [payLoading, setPayLoading] = React.useState(false);
|
const [payLoading, setPayLoading] = React.useState(false);
|
||||||
@@ -31,10 +32,10 @@ function RegistrationSuccessContent() {
|
|||||||
if (!registrationId) return;
|
if (!registrationId) return;
|
||||||
try {
|
try {
|
||||||
// Try to fetch registration and event form
|
// 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) {
|
if (r) {
|
||||||
setReg(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);
|
if (ev?.form) setForm(ev.form);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -49,7 +50,7 @@ function RegistrationSuccessContent() {
|
|||||||
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
||||||
if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed;
|
if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed;
|
||||||
try {
|
try {
|
||||||
return (reg.registrationOptions || []).reduce((s: number, ro: any) => {
|
return (reg.registrationOptions || []).reduce((s, ro) => {
|
||||||
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
|
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
|
||||||
? Number(ro.priceSnapshot)
|
? Number(ro.priceSnapshot)
|
||||||
: (ro.variant?.price ?? ro.eventOption?.price ?? 0);
|
: (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.
|
// 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.`);
|
setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`);
|
||||||
try {
|
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);
|
if (r) setReg(r);
|
||||||
} catch {}
|
} catch {}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
|
|||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { apiFetch } from "@/lib/api";
|
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 { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||||
import { errorMessage } from "@/lib/utils";
|
import { errorMessage } from "@/lib/utils";
|
||||||
|
import type { Registration } from "@/types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||||
@@ -26,7 +27,7 @@ type FormField = {
|
|||||||
isRequired: boolean;
|
isRequired: boolean;
|
||||||
order: number;
|
order: number;
|
||||||
helpText?: string | null;
|
helpText?: string | null;
|
||||||
options?: any;
|
options?: unknown;
|
||||||
};
|
};
|
||||||
type EventForm = {
|
type EventForm = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -294,11 +295,11 @@ export default function SelfServicePage() {
|
|||||||
// Second pass for accounts with two-factor on: exchange the code that was
|
// 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.
|
// sent after the password step. The kiosk never asks to be remembered.
|
||||||
const data = setupChallenge
|
const data = setupChallenge
|
||||||
? await apiFetch("/api/users/login/2fa", {
|
? await apiFetch<AuthResponse>("/api/users/login/2fa", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
|
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
|
||||||
})
|
})
|
||||||
: await apiFetch("/api/users/login", {
|
: await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { email: setupEmail, password: setupPassword },
|
body: { email: setupEmail, password: setupPassword },
|
||||||
});
|
});
|
||||||
@@ -405,7 +406,15 @@ export default function SelfServicePage() {
|
|||||||
async function handleRegister() {
|
async function handleRegister() {
|
||||||
setFormLoading(true);
|
setFormLoading(true);
|
||||||
try {
|
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,
|
eventId: selectedEventId,
|
||||||
options: Object.entries(quantities)
|
options: Object.entries(quantities)
|
||||||
.filter(([, qty]) => qty > 0)
|
.filter(([, qty]) => qty > 0)
|
||||||
@@ -413,11 +422,7 @@ export default function SelfServicePage() {
|
|||||||
const [eventOptionId, variantId] = key.split("::");
|
const [eventOptionId, variantId] = key.split("::");
|
||||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
|
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
|
||||||
}),
|
}),
|
||||||
user: {
|
user,
|
||||||
name: visitorName.trim(),
|
|
||||||
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
|
|
||||||
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
|
|
||||||
},
|
|
||||||
// If the visitor declined to create an account, don't push an unsolicited
|
// If the visitor declined to create an account, don't push an unsolicited
|
||||||
// activation email/WhatsApp at them afterward.
|
// activation email/WhatsApp at them afterward.
|
||||||
skipActivationNotice: !createAccount,
|
skipActivationNotice: !createAccount,
|
||||||
@@ -431,11 +436,7 @@ export default function SelfServicePage() {
|
|||||||
payload.attendees = kioskNames;
|
payload.attendees = kioskNames;
|
||||||
payload.ticketDelivery = delivery;
|
payload.ticketDelivery = delivery;
|
||||||
}
|
}
|
||||||
if (createAccount && visitorPassword) {
|
const result = await apiFetch<Registration>("/api/registrations/manual", {
|
||||||
payload.user.password = visitorPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: any = await apiFetch("/api/registrations/manual", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: payload,
|
body: payload,
|
||||||
authToken: supervisorToken,
|
authToken: supervisorToken,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function SetBannerPage() {
|
|||||||
|
|
||||||
// Load current banner
|
// Load current banner
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<any>("/api/banner")
|
apiFetch<{ message?: string; type?: BannerType; liveFrom?: string | null; liveTill?: string | null } | null>("/api/banner")
|
||||||
.then(b => {
|
.then(b => {
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
setMessage(b.message || "");
|
setMessage(b.message || "");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { AuthUser, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
import { AuthUser, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { errorMessage } from "@/lib/utils";
|
||||||
|
|
||||||
// Matches the backend's minimum gap between code sends.
|
// Matches the backend's minimum gap between code sends.
|
||||||
const RESEND_COOLDOWN_SECONDS = 60;
|
const RESEND_COOLDOWN_SECONDS = 60;
|
||||||
@@ -42,24 +43,16 @@ export function LoginForm() {
|
|||||||
window.location.href = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
|
window.location.href = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getErrorMessage = (err: any) => {
|
const getErrorMessage = (err: unknown) => {
|
||||||
if (!err) return "Login failed";
|
const msg = errorMessage(err);
|
||||||
|
if (!msg) return "Login failed";
|
||||||
// If it's already a normal Error
|
// Handle case where message is accidentally a JSON string
|
||||||
if (err.message && typeof err.message === "string") {
|
try {
|
||||||
// Handle case where message is accidentally JSON string
|
const parsed = JSON.parse(msg);
|
||||||
try {
|
return parsed?.message || msg;
|
||||||
const parsed = JSON.parse(err.message);
|
} catch {
|
||||||
return parsed.message || err.message;
|
return msg;
|
||||||
} catch {
|
|
||||||
return err.message;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If API response object was thrown directly
|
|
||||||
if (err.message) return err.message;
|
|
||||||
|
|
||||||
return "Login failed";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { isValidZAPhone } from "@/lib/phone";
|
import { isValidZAPhone } from "@/lib/phone";
|
||||||
|
import { errorMessage } from "@/lib/utils";
|
||||||
|
|
||||||
export function RegisterForm() {
|
export function RegisterForm() {
|
||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
@@ -23,24 +24,16 @@ export function RegisterForm() {
|
|||||||
|
|
||||||
const hasValidPhone = isValidZAPhone(phoneNumber);
|
const hasValidPhone = isValidZAPhone(phoneNumber);
|
||||||
|
|
||||||
const getErrorMessage = (err: any) => {
|
const getErrorMessage = (err: unknown) => {
|
||||||
if (!err) return "Login failed";
|
const msg = errorMessage(err);
|
||||||
|
if (!msg) return "Registration failed. Please try again.";
|
||||||
// If it's already a normal Error
|
// Handle case where message is accidentally a JSON string
|
||||||
if (err.message && typeof err.message === "string") {
|
try {
|
||||||
// Handle case where message is accidentally JSON string
|
const parsed = JSON.parse(msg);
|
||||||
try {
|
return parsed?.message || msg;
|
||||||
const parsed = JSON.parse(err.message);
|
} catch {
|
||||||
return parsed.message || err.message;
|
return msg;
|
||||||
} catch {
|
|
||||||
return err.message;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) => {
|
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
|
// Track the underlying HTMLVideoElement from react-webcam reliably
|
||||||
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let id: any;
|
let id: ReturnType<typeof setInterval> | undefined;
|
||||||
if (showCam) {
|
if (showCam) {
|
||||||
id = setInterval(() => {
|
id = setInterval(() => {
|
||||||
const anyCam = webcamRef.current as any;
|
const v = webcamRef.current?.video ?? null;
|
||||||
const v: HTMLVideoElement | null = anyCam?.video ?? null;
|
|
||||||
if (v) { setVideoEl(v); clearInterval(id); }
|
if (v) { setVideoEl(v); clearInterval(id); }
|
||||||
}, 100);
|
}, 100);
|
||||||
} else {
|
} else {
|
||||||
@@ -43,13 +42,14 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
|||||||
|
|
||||||
// Beep + cooldown state
|
// Beep + cooldown state
|
||||||
const lastScanRef = useRef<number>(0);
|
const lastScanRef = useRef<number>(0);
|
||||||
const audioCtxRef = useRef<any>(null);
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
const playBeep = () => {
|
const playBeep = () => {
|
||||||
try {
|
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 (!AC) return;
|
||||||
if (!audioCtxRef.current) audioCtxRef.current = new AC();
|
if (!audioCtxRef.current) audioCtxRef.current = new AC();
|
||||||
const ctx = audioCtxRef.current as AudioContext;
|
const ctx = audioCtxRef.current;
|
||||||
const oscillator = ctx.createOscillator();
|
const oscillator = ctx.createOscillator();
|
||||||
const gainNode = ctx.createGain();
|
const gainNode = ctx.createGain();
|
||||||
oscillator.type = "sine";
|
oscillator.type = "sine";
|
||||||
@@ -117,13 +117,14 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
|||||||
async function startScan() {
|
async function startScan() {
|
||||||
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
||||||
const isLocal = host === "localhost" || host === "127.0.0.1";
|
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.");
|
setLocalError("Camera API is not available in this browser.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
try {
|
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 {}
|
try { warmup.getTracks().forEach(t => t.stop()); } catch {}
|
||||||
setShowCam(true);
|
setShowCam(true);
|
||||||
if (!active) toggle();
|
if (!active) toggle();
|
||||||
@@ -173,7 +174,7 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
|||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
onUserMedia={() => setLocalError(null)}
|
onUserMedia={() => setLocalError(null)}
|
||||||
onUserMediaError={(e) => {
|
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 protocol = typeof window !== "undefined" ? window.location.protocol : "";
|
||||||
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
||||||
const isLocal = host === "localhost" || host === "127.0.0.1";
|
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]));
|
queue.forEach(q => byLock.set(q.lockId, [...(byLock.get(q.lockId) || []), q]));
|
||||||
let accepted = 0, conflicts = 0;
|
let accepted = 0, conflicts = 0;
|
||||||
for (const [lockId, scans] of byLock) {
|
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 },
|
method: "POST", authToken: token, body: { deviceId: deviceId(), scans },
|
||||||
});
|
});
|
||||||
accepted += res.accepted;
|
accepted += res.accepted;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
|||||||
declined: { text: "consent declined", cls: "text-red-700" },
|
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;
|
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;
|
dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||||
ticket?: { id: string; isUsed: boolean } | null;
|
ticket?: { id: string; isUsed: boolean } | null;
|
||||||
@@ -23,20 +23,41 @@ type Row = {
|
|||||||
dateOfBirth: string; gender: string; parentName: string; parentEmail: string; parentPhone: string;
|
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
|
* 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
|
* 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.
|
* tickets go, and send an attendee the link to their own form.
|
||||||
*/
|
*/
|
||||||
export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, onMessage }: {
|
export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, onMessage }: {
|
||||||
registration: any;
|
registration: AttendeesRegistration;
|
||||||
token: string;
|
token: string;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
hasForm: boolean;
|
hasForm: boolean;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onMessage: (msg: string) => void;
|
onMessage: (msg: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const attendees: Attendee[] = registration.attendees || [];
|
const attendees = useMemo<Attendee[]>(() => registration.attendees || [], [registration.attendees]);
|
||||||
const youth = !!registration.event?.requiresParentalConsent;
|
const youth = !!registration.event?.requiresParentalConsent;
|
||||||
const collectGender = !!registration.event?.collectGender;
|
const collectGender = !!registration.event?.collectGender;
|
||||||
const eventStart: string | undefined = registration.event?.startDate;
|
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) => {
|
const resendConsent = async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,8 +6,16 @@ import { apiFetch } from "@/lib/api";
|
|||||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||||
import { errorMessage } from "@/lib/utils";
|
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). */
|
/** 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 || !event) return false;
|
||||||
if ((ticket.usages || []).length > 0 || ticket.isUsed) return false;
|
if ((ticket.usages || []).length > 0 || ticket.isUsed) return false;
|
||||||
if (event.startDate && new Date(event.startDate) <= new Date()) 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.
|
* 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 }: {
|
export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||||
ticket: any;
|
ticket: TransferTicket;
|
||||||
event: any;
|
event: TransferEvent | null | undefined;
|
||||||
token: string;
|
token: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: (message: string) => void;
|
onDone: (message: string) => void;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type AuthContextValue = {
|
|||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
loading: boolean;
|
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>;
|
verifyTwoFactor: (challengeId: string, code: string, rememberDevice: boolean) => Promise<AuthUser>;
|
||||||
resendTwoFactor: (challengeId: string) => Promise<TwoFactorChallenge>;
|
resendTwoFactor: (challengeId: string) => Promise<TwoFactorChallenge>;
|
||||||
register: (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => Promise<void>;
|
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 = {
|
const newToast: Toast = {
|
||||||
...toast,
|
...toast,
|
||||||
id,
|
id,
|
||||||
open: true as any,
|
open: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure limit
|
// Ensure limit
|
||||||
@@ -62,11 +62,11 @@ function updateToast(id: string, update: Partial<Toast>) {
|
|||||||
function dismissToast(id?: string) {
|
function dismissToast(id?: string) {
|
||||||
if (id) {
|
if (id) {
|
||||||
queueRemoval(id)
|
queueRemoval(id)
|
||||||
updateToast(id, { open: false } as any)
|
updateToast(id, { open: false })
|
||||||
} else {
|
} else {
|
||||||
state.toasts.forEach((t) => {
|
state.toasts.forEach((t) => {
|
||||||
queueRemoval(t.id)
|
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 {
|
try {
|
||||||
if (!videoEl) return;
|
if (!videoEl) return;
|
||||||
const result = await reader.decodeOnceFromVideoElement(videoEl);
|
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);
|
if (text) onResult?.(text);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore transient decode errors and retry
|
// Ignore transient decode errors and retry
|
||||||
|
|||||||
+16
-12
@@ -1,7 +1,9 @@
|
|||||||
|
import type { Paginated, Payment, User } from "@/types";
|
||||||
|
|
||||||
export type ApiOptions = {
|
export type ApiOptions = {
|
||||||
method?: string;
|
method?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: any;
|
body?: unknown;
|
||||||
authToken?: string | null;
|
authToken?: string | null;
|
||||||
nextOptions?: RequestInit;
|
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 { method = "GET", headers = {}, body, authToken, nextOptions } = options;
|
||||||
|
|
||||||
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
|
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
|
// Several list endpoints (/api/users, /api/payments) are paginated and cap
|
||||||
// `limit` at 200 server-side, so a single request can silently miss records
|
// `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.
|
// 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 query = new URLSearchParams({ ...params, limit: "200", page: "1" });
|
||||||
const first = await apiFetch<any>(`${path}?${query.toString()}`, { authToken: token });
|
const first = await apiFetch<Paginated<T>>(`${path}?${query.toString()}`, { authToken: token });
|
||||||
const items: any[] = Array.isArray(first?.data) ? first.data : [];
|
const items: T[] = Array.isArray(first?.data) ? first.data : [];
|
||||||
const pages = first?.pages || 1;
|
const pages = first?.pages || 1;
|
||||||
|
|
||||||
if (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) => {
|
Array.from({ length: pages - 1 }, (_, i) => {
|
||||||
const page = i + 2;
|
const page = i + 2;
|
||||||
const pageQuery = new URLSearchParams({ ...params, limit: "200", page: String(page) });
|
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) {
|
for (const res of remaining) {
|
||||||
@@ -188,19 +190,21 @@ async function fetchAllPages(path: string, token: string, params: Record<string,
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<any[]> {
|
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<User[]> {
|
||||||
return fetchAllPages("/api/users", token, params);
|
return fetchAllPages<User>("/api/users", token, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> {
|
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<Payment[]> {
|
||||||
return fetchAllPages("/api/payments", token, params);
|
return fetchAllPages<Payment>("/api/payments", token, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a Yoco checkout for a registration's full remaining outstanding balance.
|
// 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 —
|
// 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.
|
// 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 }> {
|
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
|
||||||
return apiFetch("/api/payments/yoco-checkout", {
|
|
||||||
|
export function createFullPaymentCheckout(token: string, registrationId: string): Promise<CheckoutResponse> {
|
||||||
|
return apiFetch<CheckoutResponse>("/api/payments/yoco-checkout", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
authToken: token,
|
authToken: token,
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export type TwoFactorChallenge = {
|
|||||||
enrolling?: boolean;
|
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;
|
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 }[]) {
|
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 }));
|
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;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
function valueToString(v: any): string {
|
function valueToString(v: unknown): string {
|
||||||
if (v == null) return '';
|
if (v == null) return '';
|
||||||
if (v instanceof Date) return v.toISOString();
|
if (v instanceof Date) return v.toISOString();
|
||||||
if (typeof v === 'object') return JSON.stringify(v);
|
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 */
|
/** 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();
|
const q = raw.toLowerCase().trim();
|
||||||
if (!q || q.length < 2) return 0;
|
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 Role = "user" | "staff" | "supervisor" | "admin";
|
||||||
|
export type NotificationPreference = "email" | "whatsapp" | "both";
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,27 +21,117 @@ export type AuthResponse = {
|
|||||||
user: AuthUser;
|
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 = {
|
export type EventOption = {
|
||||||
id: string;
|
id: string;
|
||||||
|
eventId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
price: number;
|
price: number;
|
||||||
isMainTicket?: boolean;
|
isMainTicket?: boolean;
|
||||||
|
stockLimit?: number;
|
||||||
|
staffPrice?: number | null;
|
||||||
|
variants?: OptionVariant[];
|
||||||
|
earlyBirdTiers?: EarlyBirdTier[];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: 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 = {
|
export type Event = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string | null;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
registrationDeadline?: string | null;
|
registrationDeadline?: string | null;
|
||||||
goLiveAt?: string;
|
goLiveAt?: string;
|
||||||
price: number;
|
price: number;
|
||||||
picture?: string;
|
picture?: string | null;
|
||||||
|
location?: string | null;
|
||||||
isActive?: boolean;
|
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[];
|
options?: EventOption[];
|
||||||
|
eventOptions?: EventOption[];
|
||||||
|
form?: EventForm | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
cashupStatus?: "open" | "closed";
|
cashupStatus?: "open" | "closed";
|
||||||
@@ -143,22 +238,34 @@ export type EventFinancials = {
|
|||||||
salesByOption: SalesByOption[];
|
salesByOption: SalesByOption[];
|
||||||
history: EventCashup[];
|
history: EventCashup[];
|
||||||
};
|
};
|
||||||
|
export type RegistrationStatus = "pending" | "confirmed" | "partial_paid" | "paid" | "cancelled";
|
||||||
|
|
||||||
export type RegistrationItem = {
|
export type RegistrationOption = {
|
||||||
id?: string;
|
id: string;
|
||||||
|
registrationId?: string;
|
||||||
eventOptionId: string;
|
eventOptionId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
variantId?: string | null;
|
||||||
|
priceSnapshot?: number | null;
|
||||||
|
eventOption?: EventOption;
|
||||||
|
variant?: OptionVariant | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Registration = {
|
export type Registration = {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
status: "pending" | "paid" | string;
|
status: RegistrationStatus;
|
||||||
|
discountTotal?: number;
|
||||||
|
ticketDelivery?: "payer" | "each";
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
event?: Pick<Event, "id" | "title" | "price">;
|
user?: UserSummary;
|
||||||
options?: RegistrationItem[];
|
event?: Event;
|
||||||
|
registrationOptions?: RegistrationOption[];
|
||||||
|
payments?: Payment[];
|
||||||
|
// Tranche-aware total the backend attaches to registration responses.
|
||||||
|
totalDueComputed?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Ticket = {
|
export type Ticket = {
|
||||||
@@ -167,11 +274,16 @@ export type Ticket = {
|
|||||||
registrationOptionId?: string;
|
registrationOptionId?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
quantity?: number;
|
||||||
isUsed: boolean;
|
isUsed: boolean;
|
||||||
emailSent: boolean;
|
emailSent?: boolean;
|
||||||
createdAt: string;
|
attendeeId?: string | null;
|
||||||
updatedAt: string;
|
holderUserId?: string | null;
|
||||||
event?: Pick<Event, "id" | "title">;
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
user?: UserSummary;
|
||||||
|
event?: Event;
|
||||||
|
registrationOption?: RegistrationOption;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Payment = {
|
export type Payment = {
|
||||||
@@ -179,9 +291,61 @@ export type Payment = {
|
|||||||
amount: number;
|
amount: number;
|
||||||
method: string;
|
method: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
registrationId?: string;
|
recordedById?: string | null;
|
||||||
eventId?: string;
|
registrationId?: string | null;
|
||||||
|
eventId?: string | null;
|
||||||
isDonation?: boolean;
|
isDonation?: boolean;
|
||||||
|
isSponsorship?: boolean;
|
||||||
|
status?: string | null;
|
||||||
|
externalId?: string | null;
|
||||||
|
originalPaymentId?: string | null;
|
||||||
createdAt?: string;
|
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