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;