diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx index 358a7a4..cfd2a76 100644 --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -4,6 +4,7 @@ import { Stack, useRouter, useSegments } from "expo-router"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { StatusBar } from "expo-status-bar"; import { AuthProvider, useAuth } from "../lib/auth"; +import { MenuProvider } from "../lib/menu"; import { theme } from "../lib/theme"; export default function RootLayout() { @@ -11,7 +12,9 @@ export default function RootLayout() { - + + + ); diff --git a/app/app/admin.tsx b/app/app/admin.tsx index e978059..92b4052 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -4,6 +4,7 @@ import { router } from "expo-router"; import { SafeAreaView } from "react-native-safe-area-context"; import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api"; import { feedbackSuccess, feedbackError } from "../lib/feedback"; +import { useMenu } from "../lib/menu"; import { theme } from "../lib/theme"; function fmtTime(iso: string): string { @@ -43,6 +44,7 @@ function AuditList({ entries }: { entries: AuditEntry[] }) { } export default function AdminScreen() { + const { open: openMenu } = useMenu(); const [q, setQ] = useState(""); const [results, setResults] = useState([]); const [busy, setBusy] = useState(false); @@ -119,10 +121,8 @@ export default function AdminScreen() { return ( - router.replace("/")} hitSlop={10}> - - β€Ή Scanner - + + ☰ Admin lookup @@ -276,6 +276,7 @@ const styles = StyleSheet.create({ paddingVertical: 10, }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, + hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" }, link: { color: theme.textDim, fontSize: 16, fontWeight: "600" }, searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 }, input: { diff --git a/app/app/comp.tsx b/app/app/comp.tsx new file mode 100644 index 0000000..364aac0 --- /dev/null +++ b/app/app/comp.tsx @@ -0,0 +1,239 @@ +import { useState } from "react"; +import { + StyleSheet, + View, + Text, + TextInput, + Pressable, + ScrollView, + Image, + KeyboardAvoidingView, + Platform, +} from "react-native"; +import { router } from "expo-router"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { portalVerify, portalCreate, AuthError, type PortalTicket } from "../lib/api"; +import { useAuth } from "../lib/auth"; +import { useMenu } from "../lib/menu"; +import { theme } from "../lib/theme"; + +const TYPES = ["Guest", "Worker", "Performer", "Volunteer", "Speaker"]; +const TYPE_ICON: Record = { + Guest: "🎫", + Worker: "πŸ› οΈ", + Performer: "🎭", + Volunteer: "πŸ™Œ", + Speaker: "🎀", +}; + +export default function CompScreen() { + const { operator } = useAuth(); + const { open: openMenu } = useMenu(); + const [password, setPassword] = useState(""); + const [unlocked, setUnlocked] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const [type, setType] = useState("Guest"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [result, setResult] = useState(null); + + async function unlock() { + if (!password || busy) return; + setBusy(true); + setError(""); + try { + await portalVerify(password); + setUnlocked(true); + } catch (e: any) { + setError(e instanceof AuthError ? "Wrong password" : (e?.message ?? "Failed")); + } finally { + setBusy(false); + } + } + + async function create() { + if (!name.trim() || !email.trim() || busy) return; + setBusy(true); + setError(""); + try { + const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator }); + setResult(r); + setName(""); + setEmail(""); + } catch (e: any) { + if (e instanceof AuthError) { + setUnlocked(false); // password rotated β€” re-gate + setError("Password changed β€” unlock again."); + } else { + setError(e?.message ?? "Failed to create ticket"); + } + } finally { + setBusy(false); + } + } + + return ( + + + + ☰ + + Comp Tickets + + + + + + {!unlocked ? ( + + Entry-only tickets for workers & guests. Enter the shared portal password. + Portal password + + {!!error && {error}} + + {busy ? "Checking…" : "Unlock"} + + + ) : ( + + Ticket type + + {TYPES.map((t) => ( + setType(t)} + > + + {(TYPE_ICON[t] ?? "🎫") + " " + t} + + + ))} + + + Full name + + + Email + + + {!!error && {error}} + + {busy ? "Creating…" : `Create ${type} ticket`} + + + {result && ( + + + {result.code} + + {result.type} Β· {result.name} + + + {result.emailSent ? "βœ“ Emailed the ticket" : "Email not sent β€” screenshot this QR"} + + + )} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + topbar: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 10, + }, + brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, + hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" }, + link: { color: theme.textDim, fontSize: 16, fontWeight: "600", width: 72 }, + lead: { color: theme.textDim, fontSize: 15, lineHeight: 21, marginBottom: 8 }, + label: { color: theme.textDim, fontSize: 13, marginTop: 16, marginBottom: 6 }, + input: { + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 14, + color: theme.text, + fontSize: 16, + }, + error: { color: theme.dangerBright, marginTop: 12, fontSize: 14, fontWeight: "600" }, + btn: { + backgroundColor: theme.successBright, + borderRadius: 13, + paddingVertical: 15, + alignItems: "center", + marginTop: 20, + }, + btnOff: { opacity: 0.4 }, + btnText: { color: "#06210f", fontSize: 18, fontWeight: "800" }, + + types: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + typePill: { + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 999, + paddingHorizontal: 14, + paddingVertical: 9, + }, + typePillOn: { backgroundColor: theme.primary, borderColor: theme.primary }, + typePillText: { color: theme.textDim, fontSize: 14, fontWeight: "700" }, + typePillTextOn: { color: "#fff" }, + + result: { + marginTop: 22, + alignItems: "center", + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 16, + padding: 20, + }, + qr: { width: 220, height: 220, backgroundColor: "#fff", borderRadius: 10 }, + rcode: { color: theme.successBright, fontSize: 22, fontWeight: "800", letterSpacing: 2, marginTop: 12 }, + rwho: { color: theme.text, fontSize: 16, marginTop: 4 }, + rmail: { color: theme.textDim, fontSize: 13, marginTop: 8 }, +}); diff --git a/app/app/index.tsx b/app/app/index.tsx index ab552ff..c3bcdbc 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -6,6 +6,7 @@ import QRScanner from "../components/QRScanner"; import ResultOverlay from "../components/ResultOverlay"; import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api"; import { useAuth } from "../lib/auth"; +import { useMenu } from "../lib/menu"; import { feedbackSuccess, feedbackError } from "../lib/feedback"; import { theme } from "../lib/theme"; @@ -19,7 +20,8 @@ const MODES: { key: Mode; label: string; icon: string }[] = [ ]; export default function ScannerScreen() { - const { signOut, operator } = useAuth(); + const { operator } = useAuth(); + const { open: openMenu } = useMenu(); const [mode, setMode] = useState("tickets"); const [phase, setPhase] = useState("scanning"); const [ticket, setTicket] = useState(null); @@ -147,29 +149,19 @@ export default function ScannerScreen() { } }, [ticket, count, mode, resume, showError]); - const doLogout = useCallback(async () => { - await signOut(); - // The auth gate redirects to /login when signedIn flips to false. - }, [signOut]); - const isIce = mode === "ice"; const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : ""; return ( - + + ☰ + + 🐻 Camp Scan {!!operator && {operator}} - - router.push("/admin")} hitSlop={10}> - Admin - - - Sign out - - @@ -474,6 +466,8 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 10, }, + hamburger: { color: theme.text, fontSize: 26, fontWeight: "700", paddingRight: 4 }, + titleWrap: { flex: 1, marginLeft: 12 }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, operator: { color: theme.textDim, fontSize: 13, marginTop: 1 }, topActions: { flexDirection: "row", gap: 18, alignItems: "center" }, diff --git a/app/app/stats.tsx b/app/app/stats.tsx new file mode 100644 index 0000000..9c9428a --- /dev/null +++ b/app/app/stats.tsx @@ -0,0 +1,306 @@ +import { useCallback, useEffect, useState } from "react"; +import { StyleSheet, View, Text, Pressable, ScrollView, ActivityIndicator, RefreshControl } from "react-native"; +import { router } from "expo-router"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { getStats, type Stats } from "../lib/api"; +import { useMenu } from "../lib/menu"; +import { theme } from "../lib/theme"; + +const TYPE_ICON: Record = { + Regular: "🎟️", + Guest: "🎫", + Worker: "πŸ› οΈ", + Performer: "🎭", + Volunteer: "πŸ™Œ", + Speaker: "🎀", +}; +const MEDAL = ["πŸ₯‡", "πŸ₯ˆ", "πŸ₯‰"]; + +function Bar({ pct, color }: { pct: number; color?: string }) { + return ( + + + + ); +} + +function Tile({ value, label, accent }: { value: string | number; label: string; accent?: boolean }) { + return ( + + {value} + {label} + + ); +} + +export default function StatsScreen() { + const { open: openMenu } = useMenu(); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(""); + + const load = useCallback(async (force = false) => { + setError(""); + try { + setStats(await getStats(force)); + } catch (e: any) { + if (e?.name === "AuthError") return router.replace("/login"); + setError(e?.message ?? "Failed to load report"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const onRefresh = () => { + setRefreshing(true); + load(true); + }; + + const peakHour = stats?.checkinsByHour.length + ? stats.checkinsByHour.reduce((a, b) => (b.count > a.count ? b : a)) + : null; + const maxHour = stats ? Math.max(1, ...stats.checkinsByHour.map((h) => h.count)) : 1; + + return ( + + + + ☰ + + Event Report + + ↻ + + + + {loading ? ( + + ) : error ? ( + {error} + ) : stats ? ( + } + > + {/* Hero: check-in progress */} + + {stats.tickets.pct}% + checked in + + + {stats.tickets.redeemed} of {stats.tickets.total} tickets Β· {stats.tickets.remaining} to go + + + + {/* Core tiles */} + + + + + + + + {/* Ice */} + + 🧊 Ice + + + {stats.ice.redeemed} of {stats.ice.total} bags handed out Β· {stats.ice.remaining} left Β· {stats.ice.ticketsSold} ice tickets sold + + + + {/* Ticket types */} + + Ticket types + {stats.types.map((t) => ( + + + {(TYPE_ICON[t.type] ?? "🎫") + " " + t.type} + + + + + + {t.redeemed}/{t.total} + Β· {t.count}Γ— + + + ))} + + + {/* People breakdown */} + + Who's coming + + + + + + + + + {/* Extras + donors */} + + Add-ons & donors + + πŸš— {stats.extras.carParking} parking + 🚐 {stats.extras.rvParking} RV + 🏍️ {stats.extras.utv} UTV + 🐻 {stats.donors.members} members + ⭐ {stats.donors.orders} donor orders + 🎟️ {stats.donors.vouchers} vouchers + + + + {/* Operator leaderboard */} + {stats.operators.length > 0 && ( + + Gate crew leaderboard + {stats.operators.slice(0, 8).map((o, i) => ( + + {MEDAL[i] ?? `${i + 1}.`} + + {o.name} + + + {o.checkins} check-ins{o.ice ? ` Β· ${o.ice} ice` : ""} + {o.undos ? ` Β· ${o.undos} undo` : ""} + + + ))} + + )} + + {/* Comp tickets issued */} + {stats.comps.total > 0 && ( + + 🎟️ Comp tickets issued ({stats.comps.total}) + {stats.comps.byCreator.map((c) => ( + + + {c.name} + + {c.count} issued + + ))} + + )} + + {/* Check-in timeline */} + {stats.checkinsByHour.length > 0 && ( + + Check-ins by hour + + {stats.checkinsByHour.map((h) => ( + + {h.count} + + {h.hour.slice(11)}h + + ))} + + {peakHour && ( + Busiest hour: {peakHour.count} checked in around {peakHour.hour.slice(11)}:00 + )} + + )} + + Updated {new Date(stats.generatedAt).toLocaleTimeString()} Β· pull to refresh + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + topbar: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 10, + }, + brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, + hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" }, + link: { color: theme.textDim, fontSize: 16, fontWeight: "700" }, + error: { color: theme.dangerBright, textAlign: "center", marginTop: 40, fontSize: 15 }, + + hero: { + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 18, + padding: 22, + alignItems: "center", + }, + heroPct: { color: theme.successBright, fontSize: 64, fontWeight: "900", lineHeight: 66 }, + heroSub: { color: theme.textDim, fontSize: 15, marginBottom: 14 }, + heroCounts: { color: theme.text, fontSize: 15, marginTop: 10, textAlign: "center" }, + + barTrack: { width: "100%", height: 12, borderRadius: 6, backgroundColor: theme.cardBorder, overflow: "hidden" }, + barFill: { height: "100%", borderRadius: 6 }, + + tileRow: { flexDirection: "row", flexWrap: "wrap", gap: 10, marginTop: 12 }, + tile: { + flexGrow: 1, + flexBasis: "22%", + minWidth: 74, + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 12, + paddingVertical: 12, + alignItems: "center", + }, + tileValue: { color: theme.text, fontSize: 24, fontWeight: "800" }, + tileLabel: { color: theme.textDim, fontSize: 11, marginTop: 2, textAlign: "center" }, + + card: { + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 16, + padding: 16, + marginTop: 14, + }, + cardTitle: { color: theme.text, fontSize: 16, fontWeight: "800", marginBottom: 10 }, + cardSub: { color: theme.textDim, fontSize: 13, marginTop: 8, lineHeight: 18 }, + + typeRow: { flexDirection: "row", alignItems: "center", gap: 10, marginVertical: 5 }, + typeName: { color: theme.text, fontSize: 14, fontWeight: "600", width: 120 }, + typeBarWrap: { flex: 1 }, + typeCount: { color: theme.text, fontSize: 13, fontWeight: "700", minWidth: 66, textAlign: "right" }, + typeOrders: { color: theme.textDim, fontWeight: "400" }, + + chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + chip: { + color: theme.text, + backgroundColor: theme.cardBorder, + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 7, + fontSize: 13, + fontWeight: "600", + overflow: "hidden", + }, + + opRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 }, + opRank: { fontSize: 16, width: 28, textAlign: "center", color: theme.textDim, fontWeight: "800" }, + opName: { color: theme.text, fontSize: 15, fontWeight: "600", flex: 1 }, + opStat: { color: theme.textDim, fontSize: 13 }, + + spark: { flexDirection: "row", alignItems: "flex-end", justifyContent: "space-between", gap: 4, height: 118, marginTop: 4 }, + sparkCol: { flex: 1, alignItems: "center", justifyContent: "flex-end" }, + sparkVal: { color: theme.textDim, fontSize: 10, marginBottom: 3 }, + sparkBar: { width: "70%", minWidth: 8, backgroundColor: theme.successBright, borderRadius: 3 }, + sparkLabel: { color: theme.textDim, fontSize: 9, marginTop: 3 }, + + stamp: { color: theme.textDim, fontSize: 12, textAlign: "center", marginTop: 20 }, +}); diff --git a/app/components/SideMenu.tsx b/app/components/SideMenu.tsx new file mode 100644 index 0000000..522b2c3 --- /dev/null +++ b/app/components/SideMenu.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef } from "react"; +import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions } from "react-native"; +import { router, useSegments } from "expo-router"; +import { useAuth } from "../lib/auth"; +import { theme } from "../lib/theme"; + +const ITEMS: { label: string; icon: string; route: string; seg: string }[] = [ + { label: "Scanner", icon: "πŸ“·", route: "/", seg: "" }, + { label: "Event report", icon: "πŸ“Š", route: "/stats", seg: "stats" }, + { label: "Comp tickets", icon: "🎟️", route: "/comp", seg: "comp" }, + { label: "Admin lookup", icon: "πŸ”Ž", route: "/admin", seg: "admin" }, +]; + +export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) { + const { operator, signOut } = useAuth(); + const segments = useSegments(); + const current = segments[0] ?? ""; + const { width } = useWindowDimensions(); + const panelW = Math.min(320, width * 0.84); + const tx = useRef(new Animated.Value(-panelW)).current; + const fade = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.parallel([ + Animated.timing(tx, { + toValue: visible ? 0 : -panelW, + duration: 220, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(fade, { toValue: visible ? 1 : 0, duration: 220, useNativeDriver: true }), + ]).start(); + }, [visible, panelW, tx, fade]); + + const go = (item: { route: string; seg: string }) => { + onClose(); + if (item.seg !== current) router.replace(item.route as any); + }; + + return ( + + + + + + + 🐻 Camp Scan + {!!operator && {operator}} + + + {ITEMS.map((it) => { + const active = it.seg === current; + return ( + go(it)}> + {it.icon} + {it.label} + + ); + })} + + + { + onClose(); + signOut(); + }} + > + πŸšͺ + Sign out + + + + ); +} + +const styles = StyleSheet.create({ + scrim: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0,0,0,0.55)" }, + panel: { + position: "absolute", + top: 0, + bottom: 0, + left: 0, + backgroundColor: theme.card, + borderRightWidth: 1, + borderRightColor: theme.cardBorder, + paddingTop: 54, + paddingHorizontal: 14, + paddingBottom: 28, + }, + header: { paddingHorizontal: 8, paddingBottom: 14, borderBottomWidth: 1, borderBottomColor: theme.cardBorder }, + logo: { color: theme.text, fontSize: 20, fontWeight: "800" }, + operator: { color: theme.textDim, fontSize: 14, marginTop: 3 }, + items: { marginTop: 14, gap: 4 }, + item: { flexDirection: "row", alignItems: "center", gap: 14, paddingVertical: 14, paddingHorizontal: 12, borderRadius: 12 }, + itemActive: { backgroundColor: theme.primary }, + itemIcon: { fontSize: 20, width: 26, textAlign: "center" }, + itemText: { color: theme.text, fontSize: 17, fontWeight: "600" }, + itemTextActive: { color: "#fff", fontWeight: "800" }, + spacer: { flex: 1 }, + signout: { + flexDirection: "row", + alignItems: "center", + gap: 14, + paddingVertical: 14, + paddingHorizontal: 12, + borderRadius: 12, + borderTopWidth: 1, + borderTopColor: theme.cardBorder, + }, + signoutText: { color: theme.dangerBright, fontSize: 17, fontWeight: "700" }, +}); diff --git a/app/lib/api.ts b/app/lib/api.ts index 2123da8..ccc02e8 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -22,6 +22,7 @@ export interface TicketView { name: string; email: string; ticketType: string; + createdBy: string; total: number; redeemed: number; remaining: number; @@ -194,6 +195,63 @@ export interface AuditEntry { action: "check-in" | "undo" | "ice" | "ice-undo"; } +export interface Stats { + orders: number; + tickets: { total: number; redeemed: number; remaining: number; pct: number }; + people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number }; + ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number }; + types: { type: string; count: number; total: number; redeemed: number }[]; + donors: { orders: number; members: number; vouchers: number }; + extras: { carParking: number; rvParking: number; utv: number }; + comps: { total: number; byCreator: { name: string; count: number }[] }; + operators: { name: string; checkins: number; ice: number; undos: number }[]; + checkinsByHour: { hour: string; count: number }[]; + generatedAt: string; +} + +export function getStats(force = false): Promise { + return authed(`/api/stats${force ? "?force=1" : ""}`); +} + +// Comp-ticket portal (password-gated; separate from the staff PIN). +export async function portalVerify(password: string): Promise<{ ok: boolean; types: string[] }> { + const res = await fetch(`${API_BASE}/api/portal/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + if (res.status === 401) throw new AuthError("Wrong password"); + if (!res.ok) throw new ApiError(`Verify failed (${res.status})`); + return res.json(); +} + +export interface PortalTicket { + ok: boolean; + code: string; + type: string; + name: string; + emailSent: boolean; + qr: string; // data URL +} + +export async function portalCreate(input: { + password: string; + name: string; + email: string; + type: string; + createdBy?: string; +}): Promise { + const res = await fetch(`${API_BASE}/api/portal/create-ticket`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (res.status === 401) throw new AuthError("Wrong password"); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(body?.detail ?? body?.error ?? `Create failed (${res.status})`); + return body; +} + export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{ enabled: boolean; entries: AuditEntry[]; diff --git a/app/lib/menu.tsx b/app/lib/menu.tsx new file mode 100644 index 0000000..356c944 --- /dev/null +++ b/app/lib/menu.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext, useState, type ReactNode } from "react"; +import SideMenu from "../components/SideMenu"; + +interface MenuState { + open: () => void; + close: () => void; +} + +const Ctx = createContext({ open: () => {}, close: () => {} }); + +export function MenuProvider({ children }: { children: ReactNode }) { + const [visible, setVisible] = useState(false); + return ( + setVisible(true), close: () => setVisible(false) }}> + {children} + setVisible(false)} /> + + ); +} + +export const useMenu = () => useContext(Ctx); diff --git a/backend/src/fields.ts b/backend/src/fields.ts index c6b4cc8..50c569d 100644 --- a/backend/src/fields.ts +++ b/backend/src/fields.ts @@ -25,6 +25,7 @@ export const COL = { iceAccess: "Ice Access", paymentMethod: "Payment Method", ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps + createdBy: "Created By", // gate-staff name who issued a comp ticket (portal) // Columns this system manages: code: "Ticket Code", @@ -96,6 +97,7 @@ export interface TicketView { name: string; email: string; ticketType: string; // "" for regular; Guest/Worker/... for special tickets + createdBy: string; // who issued a comp ticket total: number; redeemed: number; remaining: number; @@ -124,6 +126,7 @@ export function toView(rec: NocoRecord): TicketView { name: String(rec[COL.name] ?? ""), email: String(rec[COL.email] ?? ""), ticketType: String(rec[COL.ticketType] ?? ""), + createdBy: String(rec[COL.createdBy] ?? ""), total, redeemed, remaining: Math.max(0, total - redeemed), diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts index 9a1f077..8c55c0e 100644 --- a/backend/src/routes/portal.ts +++ b/backend/src/routes/portal.ts @@ -22,6 +22,21 @@ export async function portalRoutes(app: FastifyInstance): Promise { reply.type("text/html").send(PAGE); }); + // Password check only (for the in-app portal to gate its form). + app.post( + "/api/portal/verify", + { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const cfg = app.ctx.config; + if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" }); + const b = (req.body ?? {}) as { password?: string }; + if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) { + return reply.code(401).send({ error: "bad_password" }); + } + return { ok: true, types: TYPES }; + }, + ); + app.post( "/api/portal/create-ticket", { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, @@ -29,13 +44,21 @@ export async function portalRoutes(app: FastifyInstance): Promise { const cfg = app.ctx.config; if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" }); - const b = (req.body ?? {}) as { password?: string; name?: string; email?: string; type?: string }; + const b = (req.body ?? {}) as { + password?: string; + name?: string; + email?: string; + type?: string; + createdBy?: string; + }; if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) { return reply.code(401).send({ error: "bad_password" }); } const name = String(b.name ?? "").trim(); const email = String(b.email ?? "").trim(); const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest"; + // Who issued it β€” from the in-app portal (signed-in gate staff) or header. + const createdBy = String(b.createdBy ?? req.headers["x-operator"] ?? "").slice(0, 80).trim(); if (!name || !email) { return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" }); } @@ -47,6 +70,7 @@ export async function portalRoutes(app: FastifyInstance): Promise { adultNames: [name], email, ticketType: type, + createdBy, counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 }, submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`, }); diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index eaa2a67..eb03880 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { normalizeCode, looksLikeCode } from "../services/code.js"; import { lookupByCode, redeem, search, createTicket } from "../ticketService.js"; import { renderQrPng } from "../services/qrcode.js"; +import { computeStats } from "../services/stats.js"; import { COL } from "../fields.js"; async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise { @@ -42,6 +43,12 @@ export async function ticketRoutes(app: FastifyInstance): Promise { }, ); + // Aggregate event report (check-in progress, ice, types, extras, operators). + app.get("/api/stats", { preHandler: requireStaff }, async (req) => { + const force = String((req.query as any)?.force ?? "") === "1"; + return computeStats(app.ctx, force); + }); + // Recent check-in audit log (all, or filtered to one code via ?code=). app.get("/api/audit", { preHandler: requireStaff }, async (req) => { const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined; diff --git a/backend/src/services/audit.ts b/backend/src/services/audit.ts index 05e3b50..2782958 100644 --- a/backend/src/services/audit.ts +++ b/backend/src/services/audit.ts @@ -79,6 +79,43 @@ export class AuditLogger { } } + private mapRow(r: any): AuditRow { + return { + id: r.Id, + code: r[AUDIT_COL.code] ?? "", + people: Number(r[AUDIT_COL.people]) || 0, + name: r[AUDIT_COL.name] ?? "", + operator: r[AUDIT_COL.operator] ?? "", + remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0, + at: r[AUDIT_COL.at] ?? r.CreatedAt ?? "", + action: (r[AUDIT_COL.action] ?? "check-in") as AuditEntry["action"], + }; + } + + /** Every audit row, paginated (for reporting/aggregation). */ + async all(): Promise { + if (!this.tableId) return []; + const out: AuditRow[] = []; + const pageSize = 1000; + let offset = 0; + for (;;) { + const url = new URL(this.url); + url.searchParams.set("limit", String(pageSize)); + url.searchParams.set("offset", String(offset)); + const res = await fetch(url.toString(), { + headers: { "xc-token": this.token, "Content-Type": "application/json" }, + }); + if (!res.ok) break; + const body: any = await res.json().catch(() => ({})); + const list = body?.list ?? []; + out.push(...list.map((r: any) => this.mapRow(r))); + if (!list.length || body?.pageInfo?.isLastPage || list.length < pageSize) break; + offset += pageSize; + if (offset > 200000) break; + } + return out; + } + /** Recent entries, newest first, optionally filtered to one code. */ async recent(opts: { code?: string; limit?: number } = {}): Promise { if (!this.tableId) return []; diff --git a/backend/src/services/nocodb.ts b/backend/src/services/nocodb.ts index 707a70a..a9f8587 100644 --- a/backend/src/services/nocodb.ts +++ b/backend/src/services/nocodb.ts @@ -102,6 +102,26 @@ export class NocoDBClient { return (Array.isArray(body) ? body[0] : body) as NocoRecord; } + /** Fetch every record in the table, paginating. */ + async all(): Promise { + const out: NocoRecord[] = []; + const pageSize = 1000; + let offset = 0; + for (;;) { + const url = new URL(this.recordsUrl); + url.searchParams.set("limit", String(pageSize)); + url.searchParams.set("offset", String(offset)); + const body = await this.request(url.toString()); + const list = (body?.list ?? []) as NocoRecord[]; + out.push(...list); + const info = body?.pageInfo; + if (!list.length || info?.isLastPage || list.length < pageSize) break; + offset += pageSize; + if (offset > 200000) break; // safety + } + return out; + } + /** Cheap connectivity probe for healthchecks. */ async ping(): Promise { const url = new URL(this.recordsUrl); diff --git a/backend/src/services/stats.ts b/backend/src/services/stats.ts new file mode 100644 index 0000000..3e9801f --- /dev/null +++ b/backend/src/services/stats.ts @@ -0,0 +1,131 @@ +import type { AppContext } from "../context.js"; +import { COL, toView, toNumber, type NocoRecord } from "../fields.js"; + +export interface Stats { + orders: number; + tickets: { total: number; redeemed: number; remaining: number; pct: number }; + people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number }; + ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number }; + types: { type: string; count: number; total: number; redeemed: number }[]; + donors: { orders: number; members: number; vouchers: number }; + extras: { carParking: number; rvParking: number; utv: number }; + comps: { total: number; byCreator: { name: string; count: number }[] }; + operators: { name: string; checkins: number; ice: number; undos: number }[]; + checkinsByHour: { hour: string; count: number }[]; + generatedAt: string; +} + +let cache: { at: number; data: Stats } | null = null; +const TTL_MS = 20_000; + +export async function computeStats(ctx: AppContext, force = false): Promise { + const now = Date.now(); + if (!force && cache && now - cache.at < TTL_MS) return cache.data; + + const records = await ctx.nocodb.all(); + const bagsPerTicket = ctx.config.ICE_BAGS_PER_TICKET || 3; + + let total = 0, + redeemed = 0, + iceTotal = 0, + iceRedeemed = 0; + let adults = 0, + youth = 0, + kids12 = 0, + kids9 = 0, + kids4 = 0; + let carParking = 0, + rvParking = 0, + utv = 0, + donorOrders = 0, + members = 0, + vouchers = 0; + const typeMap = new Map(); + const compByCreator = new Map(); + let compTotal = 0; + + for (const r of records as NocoRecord[]) { + const v = toView(r); + if (v.ticketType) { + compTotal += 1; + const who = v.createdBy || "(unknown)"; + compByCreator.set(who, (compByCreator.get(who) ?? 0) + 1); + } + total += v.total; + redeemed += v.redeemed; + iceTotal += v.ice.total; + iceRedeemed += v.ice.redeemed; + adults += toNumber(r[COL.adults]); + youth += toNumber(r[COL.youth]); + kids12 += toNumber(r[COL.kids12]); + kids9 += toNumber(r[COL.kids9]); + kids4 += toNumber(r[COL.kids4]); + + const t = v.ticketType || "Regular"; + const e = typeMap.get(t) ?? { count: 0, total: 0, redeemed: 0 }; + e.count += 1; + e.total += v.total; + e.redeemed += v.redeemed; + typeMap.set(t, e); + + if (v.extras.carParking) carParking += 1; + if (v.extras.rvParking) rvParking += 1; + if (v.extras.utv) utv += 1; + if (v.extras.isDonor) donorOrders += 1; + if (v.extras.donorTier === "member") members += 1; + vouchers += v.extras.vouchers; + } + + // Operator activity + check-in timeline from the audit log. + const audit = await ctx.audit.all().catch(() => []); + const opMap = new Map(); + const hourMap = new Map(); + for (const a of audit) { + if (a.operator) { + const o = opMap.get(a.operator) ?? { checkins: 0, ice: 0, undos: 0 }; + if (a.action === "check-in") o.checkins += a.people; + else if (a.action === "undo") o.undos += -a.people; + else if (a.action === "ice") o.ice += a.people; + opMap.set(a.operator, o); + } + if (a.action === "check-in" && a.people > 0 && a.at) { + const hour = String(a.at).slice(0, 13); // YYYY-MM-DDTHH + hourMap.set(hour, (hourMap.get(hour) ?? 0) + a.people); + } + } + + const data: Stats = { + orders: records.length, + tickets: { total, redeemed, remaining: Math.max(0, total - redeemed), pct: total ? Math.round((redeemed / total) * 100) : 0 }, + people: { adults, youth, kids12, kids9, kids4Free: kids4 }, + ice: { + total: iceTotal, + redeemed: iceRedeemed, + remaining: Math.max(0, iceTotal - iceRedeemed), + pct: iceTotal ? Math.round((iceRedeemed / iceTotal) * 100) : 0, + ticketsSold: Math.round(iceTotal / bagsPerTicket), + }, + types: [...typeMap.entries()] + .map(([type, e]) => ({ type, ...e })) + .sort((a, b) => b.total - a.total), + donors: { orders: donorOrders, members, vouchers }, + extras: { carParking, rvParking, utv }, + comps: { + total: compTotal, + byCreator: [...compByCreator.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count), + }, + operators: [...opMap.entries()] + .map(([name, o]) => ({ name, ...o })) + .sort((a, b) => b.checkins - a.checkins), + checkinsByHour: [...hourMap.entries()] + .sort((a, b) => (a[0] < b[0] ? -1 : 1)) + .slice(-12) + .map(([hour, count]) => ({ hour, count })), + generatedAt: new Date().toISOString(), + }; + + cache = { at: now, data }; + return data; +} diff --git a/backend/src/ticketService.ts b/backend/src/ticketService.ts index c894f76..040ae07 100644 --- a/backend/src/ticketService.ts +++ b/backend/src/ticketService.ts @@ -131,6 +131,7 @@ export interface WebhookInput { adultNames?: string[]; email: string; ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps + createdBy?: string; // gate-staff name who issued a comp address?: string; isDonor?: boolean; donorTier?: string; @@ -180,6 +181,7 @@ export async function createTicket( }; if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n"); if (input.ticketType) fields[COL.ticketType] = input.ticketType; + if (input.createdBy) fields[COL.createdBy] = input.createdBy; if (input.address !== undefined) fields[COL.address] = input.address; if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor; if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;