Add event report dashboard, slide-out drawer, in-app comp portal + creator tracking
All checks were successful
Build Android APK / build-apk (push) Successful in 56m36s

- Reporting: GET /api/stats aggregates check-in progress, ice, ticket types,
  people breakdown, add-ons/donors, gate-crew leaderboard (from audit),
  comp tickets by creator, and a by-hour check-in timeline. New /stats screen.
- Slide-out drawer (custom RN Animated, no new native deps) replaces per-screen
  header links; available on every main screen via a hamburger.
- In-app comp portal (/comp), password-gated like /crush33, reusing the portal
  endpoints; records the issuing gate-staff name (Created By column) and reports
  comps per creator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-13 17:30:18 +00:00
parent 251edfce42
commit 43fddec286
15 changed files with 979 additions and 21 deletions

View file

@ -4,6 +4,7 @@ import { Stack, useRouter, useSegments } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { AuthProvider, useAuth } from "../lib/auth"; import { AuthProvider, useAuth } from "../lib/auth";
import { MenuProvider } from "../lib/menu";
import { theme } from "../lib/theme"; import { theme } from "../lib/theme";
export default function RootLayout() { export default function RootLayout() {
@ -11,7 +12,9 @@ export default function RootLayout() {
<SafeAreaProvider> <SafeAreaProvider>
<StatusBar style="light" /> <StatusBar style="light" />
<AuthProvider> <AuthProvider>
<MenuProvider>
<AuthGate /> <AuthGate />
</MenuProvider>
</AuthProvider> </AuthProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );

View file

@ -4,6 +4,7 @@ import { router } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api"; import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api";
import { feedbackSuccess, feedbackError } from "../lib/feedback"; import { feedbackSuccess, feedbackError } from "../lib/feedback";
import { useMenu } from "../lib/menu";
import { theme } from "../lib/theme"; import { theme } from "../lib/theme";
function fmtTime(iso: string): string { function fmtTime(iso: string): string {
@ -43,6 +44,7 @@ function AuditList({ entries }: { entries: AuditEntry[] }) {
} }
export default function AdminScreen() { export default function AdminScreen() {
const { open: openMenu } = useMenu();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [results, setResults] = useState<TicketView[]>([]); const [results, setResults] = useState<TicketView[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -119,10 +121,8 @@ export default function AdminScreen() {
return ( return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}> <SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}> <View style={styles.topbar}>
<Pressable onPress={() => router.replace("/")} hitSlop={10}> <Pressable onPress={openMenu} hitSlop={12}>
<Text style={styles.link} numberOfLines={1}> <Text style={styles.hamburger}></Text>
Scanner
</Text>
</Pressable> </Pressable>
<Text style={styles.brand}>Admin lookup</Text> <Text style={styles.brand}>Admin lookup</Text>
<View style={{ width: 72 }} /> <View style={{ width: 72 }} />
@ -276,6 +276,7 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
}, },
brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" },
link: { color: theme.textDim, fontSize: 16, fontWeight: "600" }, link: { color: theme.textDim, fontSize: 16, fontWeight: "600" },
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 }, searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
input: { input: {

239
app/app/comp.tsx Normal file
View file

@ -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<string, string> = {
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<PortalTicket | null>(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 (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
<Pressable onPress={openMenu} hitSlop={12}>
<Text style={styles.hamburger}></Text>
</Pressable>
<Text style={styles.brand}>Comp Tickets</Text>
<View style={{ width: 60 }} />
</View>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 48 }}>
{!unlocked ? (
<View>
<Text style={styles.lead}>Entry-only tickets for workers &amp; guests. Enter the shared portal password.</Text>
<Text style={styles.label}>Portal password</Text>
<TextInput
style={styles.input}
secureTextEntry
value={password}
onChangeText={setPassword}
placeholder="Shared admin password"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="go"
onSubmitEditing={unlock}
/>
{!!error && <Text style={styles.error}>{error}</Text>}
<Pressable style={[styles.btn, (busy || !password) && styles.btnOff]} onPress={unlock} disabled={busy || !password}>
<Text style={styles.btnText}>{busy ? "Checking…" : "Unlock"}</Text>
</Pressable>
</View>
) : (
<View>
<Text style={styles.label}>Ticket type</Text>
<View style={styles.types}>
{TYPES.map((t) => (
<Pressable
key={t}
style={[styles.typePill, type === t && styles.typePillOn]}
onPress={() => setType(t)}
>
<Text style={[styles.typePillText, type === t && styles.typePillTextOn]}>
{(TYPE_ICON[t] ?? "🎫") + " " + t}
</Text>
</Pressable>
))}
</View>
<Text style={styles.label}>Full name</Text>
<TextInput
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Attendee name"
placeholderTextColor={theme.textDim}
autoCapitalize="words"
/>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Where to send the ticket"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
{!!error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.btn, (busy || !name.trim() || !email.trim()) && styles.btnOff]}
onPress={create}
disabled={busy || !name.trim() || !email.trim()}
>
<Text style={styles.btnText}>{busy ? "Creating…" : `Create ${type} ticket`}</Text>
</Pressable>
{result && (
<View style={styles.result}>
<Image source={{ uri: result.qr }} style={styles.qr} />
<Text style={styles.rcode}>{result.code}</Text>
<Text style={styles.rwho}>
{result.type} · {result.name}
</Text>
<Text style={styles.rmail}>
{result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"}
</Text>
</View>
)}
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
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 },
});

View file

@ -6,6 +6,7 @@ import QRScanner from "../components/QRScanner";
import ResultOverlay from "../components/ResultOverlay"; import ResultOverlay from "../components/ResultOverlay";
import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api"; import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api";
import { useAuth } from "../lib/auth"; import { useAuth } from "../lib/auth";
import { useMenu } from "../lib/menu";
import { feedbackSuccess, feedbackError } from "../lib/feedback"; import { feedbackSuccess, feedbackError } from "../lib/feedback";
import { theme } from "../lib/theme"; import { theme } from "../lib/theme";
@ -19,7 +20,8 @@ const MODES: { key: Mode; label: string; icon: string }[] = [
]; ];
export default function ScannerScreen() { export default function ScannerScreen() {
const { signOut, operator } = useAuth(); const { operator } = useAuth();
const { open: openMenu } = useMenu();
const [mode, setMode] = useState<Mode>("tickets"); const [mode, setMode] = useState<Mode>("tickets");
const [phase, setPhase] = useState<Phase>("scanning"); const [phase, setPhase] = useState<Phase>("scanning");
const [ticket, setTicket] = useState<TicketView | null>(null); const [ticket, setTicket] = useState<TicketView | null>(null);
@ -147,29 +149,19 @@ export default function ScannerScreen() {
} }
}, [ticket, count, mode, resume, showError]); }, [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 isIce = mode === "ice";
const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : ""; const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : "";
return ( return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}> <SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}> <View style={styles.topbar}>
<View> <Pressable onPress={openMenu} hitSlop={12}>
<Text style={styles.hamburger}></Text>
</Pressable>
<View style={styles.titleWrap}>
<Text style={styles.brand}>🐻 Camp Scan</Text> <Text style={styles.brand}>🐻 Camp Scan</Text>
{!!operator && <Text style={styles.operator}>{operator}</Text>} {!!operator && <Text style={styles.operator}>{operator}</Text>}
</View> </View>
<View style={styles.topActions}>
<Pressable onPress={() => router.push("/admin")} hitSlop={10}>
<Text style={styles.link}>Admin</Text>
</Pressable>
<Pressable onPress={doLogout} hitSlop={10}>
<Text style={styles.link}>Sign out</Text>
</Pressable>
</View>
</View> </View>
<View style={styles.modeBar}> <View style={styles.modeBar}>
@ -474,6 +466,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 10, 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" }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
operator: { color: theme.textDim, fontSize: 13, marginTop: 1 }, operator: { color: theme.textDim, fontSize: 13, marginTop: 1 },
topActions: { flexDirection: "row", gap: 18, alignItems: "center" }, topActions: { flexDirection: "row", gap: 18, alignItems: "center" },

306
app/app/stats.tsx Normal file
View file

@ -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<string, string> = {
Regular: "🎟️",
Guest: "🎫",
Worker: "🛠️",
Performer: "🎭",
Volunteer: "🙌",
Speaker: "🎤",
};
const MEDAL = ["🥇", "🥈", "🥉"];
function Bar({ pct, color }: { pct: number; color?: string }) {
return (
<View style={styles.barTrack}>
<View style={[styles.barFill, { width: `${Math.min(100, Math.max(0, pct))}%`, backgroundColor: color ?? theme.successBright }]} />
</View>
);
}
function Tile({ value, label, accent }: { value: string | number; label: string; accent?: boolean }) {
return (
<View style={styles.tile}>
<Text style={[styles.tileValue, accent && { color: theme.successBright }]}>{value}</Text>
<Text style={styles.tileLabel}>{label}</Text>
</View>
);
}
export default function StatsScreen() {
const { open: openMenu } = useMenu();
const [stats, setStats] = useState<Stats | null>(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 (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
<Pressable onPress={openMenu} hitSlop={12}>
<Text style={styles.hamburger}></Text>
</Pressable>
<Text style={styles.brand}>Event Report</Text>
<Pressable onPress={onRefresh} hitSlop={10}>
<Text style={styles.link}></Text>
</Pressable>
</View>
{loading ? (
<ActivityIndicator color={theme.successBright} size="large" style={{ marginTop: 40 }} />
) : error ? (
<Text style={styles.error}>{error}</Text>
) : stats ? (
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 48 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.successBright} />}
>
{/* Hero: check-in progress */}
<View style={styles.hero}>
<Text style={styles.heroPct}>{stats.tickets.pct}%</Text>
<Text style={styles.heroSub}>checked in</Text>
<Bar pct={stats.tickets.pct} />
<Text style={styles.heroCounts}>
{stats.tickets.redeemed} of {stats.tickets.total} tickets · {stats.tickets.remaining} to go
</Text>
</View>
{/* Core tiles */}
<View style={styles.tileRow}>
<Tile value={stats.orders} label="orders" />
<Tile value={stats.tickets.total} label="tickets sold" />
<Tile value={stats.tickets.redeemed} label="checked in" accent />
<Tile value={stats.tickets.remaining} label="remaining" />
</View>
{/* Ice */}
<View style={styles.card}>
<Text style={styles.cardTitle}>🧊 Ice</Text>
<Bar pct={stats.ice.pct} color="#4fc3f7" />
<Text style={styles.cardSub}>
{stats.ice.redeemed} of {stats.ice.total} bags handed out · {stats.ice.remaining} left · {stats.ice.ticketsSold} ice tickets sold
</Text>
</View>
{/* Ticket types */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Ticket types</Text>
{stats.types.map((t) => (
<View key={t.type} style={styles.typeRow}>
<Text style={styles.typeName}>
{(TYPE_ICON[t.type] ?? "🎫") + " " + t.type}
</Text>
<View style={styles.typeBarWrap}>
<Bar pct={t.total ? (t.redeemed / t.total) * 100 : 0} />
</View>
<Text style={styles.typeCount}>
{t.redeemed}/{t.total}
<Text style={styles.typeOrders}> · {t.count}×</Text>
</Text>
</View>
))}
</View>
{/* People breakdown */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Who's coming</Text>
<View style={styles.tileRow}>
<Tile value={stats.people.adults} label="adults" />
<Tile value={stats.people.youth} label="youth 13-16" />
<Tile value={stats.people.kids12 + stats.people.kids9} label="kids 5-12" />
<Tile value={stats.people.kids4Free} label="under 5 (free)" />
</View>
</View>
{/* Extras + donors */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Add-ons & donors</Text>
<View style={styles.chips}>
<Text style={styles.chip}>🚗 {stats.extras.carParking} parking</Text>
<Text style={styles.chip}>🚐 {stats.extras.rvParking} RV</Text>
<Text style={styles.chip}>🏍 {stats.extras.utv} UTV</Text>
<Text style={styles.chip}>🐻 {stats.donors.members} members</Text>
<Text style={styles.chip}> {stats.donors.orders} donor orders</Text>
<Text style={styles.chip}>🎟 {stats.donors.vouchers} vouchers</Text>
</View>
</View>
{/* Operator leaderboard */}
{stats.operators.length > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Gate crew leaderboard</Text>
{stats.operators.slice(0, 8).map((o, i) => (
<View key={o.name} style={styles.opRow}>
<Text style={styles.opRank}>{MEDAL[i] ?? `${i + 1}.`}</Text>
<Text style={styles.opName} numberOfLines={1}>
{o.name}
</Text>
<Text style={styles.opStat}>
{o.checkins} check-ins{o.ice ? ` · ${o.ice} ice` : ""}
{o.undos ? ` · ${o.undos} undo` : ""}
</Text>
</View>
))}
</View>
)}
{/* Comp tickets issued */}
{stats.comps.total > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>🎟 Comp tickets issued ({stats.comps.total})</Text>
{stats.comps.byCreator.map((c) => (
<View key={c.name} style={styles.opRow}>
<Text style={styles.opName} numberOfLines={1}>
{c.name}
</Text>
<Text style={styles.opStat}>{c.count} issued</Text>
</View>
))}
</View>
)}
{/* Check-in timeline */}
{stats.checkinsByHour.length > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Check-ins by hour</Text>
<View style={styles.spark}>
{stats.checkinsByHour.map((h) => (
<View key={h.hour} style={styles.sparkCol}>
<Text style={styles.sparkVal}>{h.count}</Text>
<View style={[styles.sparkBar, { height: 6 + (h.count / maxHour) * 80 }]} />
<Text style={styles.sparkLabel}>{h.hour.slice(11)}h</Text>
</View>
))}
</View>
{peakHour && (
<Text style={styles.cardSub}>Busiest hour: {peakHour.count} checked in around {peakHour.hour.slice(11)}:00</Text>
)}
</View>
)}
<Text style={styles.stamp}>Updated {new Date(stats.generatedAt).toLocaleTimeString()} · pull to refresh</Text>
</ScrollView>
) : null}
</SafeAreaView>
);
}
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 },
});

112
app/components/SideMenu.tsx Normal file
View file

@ -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 (
<View pointerEvents={visible ? "auto" : "none"} style={StyleSheet.absoluteFill}>
<Animated.View style={[styles.scrim, { opacity: fade }]}>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.panel, { width: panelW, transform: [{ translateX: tx }] }]}>
<View style={styles.header}>
<Text style={styles.logo}>🐻 Camp Scan</Text>
{!!operator && <Text style={styles.operator}>{operator}</Text>}
</View>
<View style={styles.items}>
{ITEMS.map((it) => {
const active = it.seg === current;
return (
<Pressable key={it.route} style={[styles.item, active && styles.itemActive]} onPress={() => go(it)}>
<Text style={styles.itemIcon}>{it.icon}</Text>
<Text style={[styles.itemText, active && styles.itemTextActive]}>{it.label}</Text>
</Pressable>
);
})}
</View>
<View style={styles.spacer} />
<Pressable
style={styles.signout}
onPress={() => {
onClose();
signOut();
}}
>
<Text style={styles.itemIcon}>🚪</Text>
<Text style={styles.signoutText}>Sign out</Text>
</Pressable>
</Animated.View>
</View>
);
}
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" },
});

View file

@ -22,6 +22,7 @@ export interface TicketView {
name: string; name: string;
email: string; email: string;
ticketType: string; ticketType: string;
createdBy: string;
total: number; total: number;
redeemed: number; redeemed: number;
remaining: number; remaining: number;
@ -194,6 +195,63 @@ export interface AuditEntry {
action: "check-in" | "undo" | "ice" | "ice-undo"; 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<Stats> {
return authed<Stats>(`/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<PortalTicket> {
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<{ export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{
enabled: boolean; enabled: boolean;
entries: AuditEntry[]; entries: AuditEntry[];

21
app/lib/menu.tsx Normal file
View file

@ -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<MenuState>({ open: () => {}, close: () => {} });
export function MenuProvider({ children }: { children: ReactNode }) {
const [visible, setVisible] = useState(false);
return (
<Ctx.Provider value={{ open: () => setVisible(true), close: () => setVisible(false) }}>
{children}
<SideMenu visible={visible} onClose={() => setVisible(false)} />
</Ctx.Provider>
);
}
export const useMenu = () => useContext(Ctx);

View file

@ -25,6 +25,7 @@ export const COL = {
iceAccess: "Ice Access", iceAccess: "Ice Access",
paymentMethod: "Payment Method", paymentMethod: "Payment Method",
ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps 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: // Columns this system manages:
code: "Ticket Code", code: "Ticket Code",
@ -96,6 +97,7 @@ export interface TicketView {
name: string; name: string;
email: string; email: string;
ticketType: string; // "" for regular; Guest/Worker/... for special tickets ticketType: string; // "" for regular; Guest/Worker/... for special tickets
createdBy: string; // who issued a comp ticket
total: number; total: number;
redeemed: number; redeemed: number;
remaining: number; remaining: number;
@ -124,6 +126,7 @@ export function toView(rec: NocoRecord): TicketView {
name: String(rec[COL.name] ?? ""), name: String(rec[COL.name] ?? ""),
email: String(rec[COL.email] ?? ""), email: String(rec[COL.email] ?? ""),
ticketType: String(rec[COL.ticketType] ?? ""), ticketType: String(rec[COL.ticketType] ?? ""),
createdBy: String(rec[COL.createdBy] ?? ""),
total, total,
redeemed, redeemed,
remaining: Math.max(0, total - redeemed), remaining: Math.max(0, total - redeemed),

View file

@ -22,6 +22,21 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
reply.type("text/html").send(PAGE); 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( app.post(
"/api/portal/create-ticket", "/api/portal/create-ticket",
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
@ -29,13 +44,21 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
const cfg = app.ctx.config; const cfg = app.ctx.config;
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" }); 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)) { if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
return reply.code(401).send({ error: "bad_password" }); return reply.code(401).send({ error: "bad_password" });
} }
const name = String(b.name ?? "").trim(); const name = String(b.name ?? "").trim();
const email = String(b.email ?? "").trim(); const email = String(b.email ?? "").trim();
const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest"; 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) { if (!name || !email) {
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" }); 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<void> {
adultNames: [name], adultNames: [name],
email, email,
ticketType: type, ticketType: type,
createdBy,
counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 }, counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`, submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`,
}); });

View file

@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { normalizeCode, looksLikeCode } from "../services/code.js"; import { normalizeCode, looksLikeCode } from "../services/code.js";
import { lookupByCode, redeem, search, createTicket } from "../ticketService.js"; import { lookupByCode, redeem, search, createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js"; import { renderQrPng } from "../services/qrcode.js";
import { computeStats } from "../services/stats.js";
import { COL } from "../fields.js"; import { COL } from "../fields.js";
async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> { async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> {
@ -42,6 +43,12 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
}, },
); );
// 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=). // Recent check-in audit log (all, or filtered to one code via ?code=).
app.get("/api/audit", { preHandler: requireStaff }, async (req) => { app.get("/api/audit", { preHandler: requireStaff }, async (req) => {
const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined; const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined;

View file

@ -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<AuditRow[]> {
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. */ /** Recent entries, newest first, optionally filtered to one code. */
async recent(opts: { code?: string; limit?: number } = {}): Promise<AuditRow[]> { async recent(opts: { code?: string; limit?: number } = {}): Promise<AuditRow[]> {
if (!this.tableId) return []; if (!this.tableId) return [];

View file

@ -102,6 +102,26 @@ export class NocoDBClient {
return (Array.isArray(body) ? body[0] : body) as NocoRecord; return (Array.isArray(body) ? body[0] : body) as NocoRecord;
} }
/** Fetch every record in the table, paginating. */
async all(): Promise<NocoRecord[]> {
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. */ /** Cheap connectivity probe for healthchecks. */
async ping(): Promise<boolean> { async ping(): Promise<boolean> {
const url = new URL(this.recordsUrl); const url = new URL(this.recordsUrl);

View file

@ -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<Stats> {
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<string, { count: number; total: number; redeemed: number }>();
const compByCreator = new Map<string, number>();
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<string, { checkins: number; ice: number; undos: number }>();
const hourMap = new Map<string, number>();
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;
}

View file

@ -131,6 +131,7 @@ export interface WebhookInput {
adultNames?: string[]; adultNames?: string[];
email: string; email: string;
ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps
createdBy?: string; // gate-staff name who issued a comp
address?: string; address?: string;
isDonor?: boolean; isDonor?: boolean;
donorTier?: string; 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.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
if (input.ticketType) fields[COL.ticketType] = input.ticketType; 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.address !== undefined) fields[COL.address] = input.address;
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor; if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier; if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;