Initial Camp Scan ticketing system

Backend (Fastify + TS): FluentForms webhook -> NocoDB row + QR + MailerSend
email; PIN auth; scan/lookup/redeem with per-code serialization; reusable QR
codes with count-based check-in; admin search.

App (Expo, one codebase): Android APK + iPhone PWA. Login, camera scanner
(native + web barcode-detector split), green/red overlay with sound + haptics,
admin lookup/redeem. Session token persisted per device.

Ops: multi-stage Dockerfile serving API + PWA same-origin, compose bound to
127.0.0.1; Forgejo Actions runner + tag-triggered signed APK build for Obtainium.
Docs in README.md and INSTALL.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 03:47:59 +00:00
commit 3397e3e3ec
60 changed files with 14703 additions and 0 deletions

48
app/app.json Normal file
View file

@ -0,0 +1,48 @@
{
"expo": {
"name": "Camp Scan",
"slug": "camptickets",
"version": "0.1.0",
"orientation": "portrait",
"scheme": "campscan",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"android": {
"package": "top.mowden.campscan",
"versionCode": 1,
"permissions": [
"android.permission.CAMERA",
"android.permission.VIBRATE"
]
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "top.mowden.campscan",
"infoPlist": {
"NSCameraUsageDescription": "Camp Scan uses the camera to scan ticket QR codes at the gate."
}
},
"web": {
"bundler": "metro",
"output": "single",
"favicon": "./assets/icon.png"
},
"plugins": [
"expo-router",
"expo-audio",
"expo-status-bar",
[
"expo-camera",
{
"cameraPermission": "Camp Scan uses the camera to scan ticket QR codes at the gate."
}
],
"expo-secure-store"
],
"extra": {
"router": {
"origin": false
}
}
}
}

49
app/app/_layout.tsx Normal file
View file

@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import { View, ActivityIndicator } from "react-native";
import { Stack, useRouter, useSegments } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import { getToken } from "../lib/api";
import { theme } from "../lib/theme";
export default function RootLayout() {
const [ready, setReady] = useState(false);
const [hasToken, setHasToken] = useState(false);
const router = useRouter();
const segments = useSegments();
useEffect(() => {
getToken().then((t) => {
setHasToken(!!t);
setReady(true);
});
}, []);
useEffect(() => {
if (!ready) return;
const onLogin = segments[0] === "login";
if (!hasToken && !onLogin) router.replace("/login");
if (hasToken && onLogin) router.replace("/");
}, [ready, hasToken, segments, router]);
if (!ready) {
return (
<View style={{ flex: 1, backgroundColor: theme.bg, alignItems: "center", justifyContent: "center" }}>
<ActivityIndicator color={theme.successBright} size="large" />
</View>
);
}
return (
<SafeAreaProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: theme.bg },
animation: "fade",
}}
/>
</SafeAreaProvider>
);
}

212
app/app/admin.tsx Normal file
View file

@ -0,0 +1,212 @@
import { useCallback, useState } from "react";
import { StyleSheet, View, Text, Pressable, TextInput, ScrollView, ActivityIndicator } from "react-native";
import { router } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
import { searchTickets, redeem, type TicketView } from "../lib/api";
import { feedbackSuccess, feedbackError } from "../lib/feedback";
import { theme } from "../lib/theme";
export default function AdminScreen() {
const [q, setQ] = useState("");
const [results, setResults] = useState<TicketView[]>([]);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const [searched, setSearched] = useState(false);
const doSearch = useCallback(async () => {
if (!q.trim()) return;
setBusy(true);
setNote("");
try {
const { results } = await searchTickets(q.trim());
setResults(results);
setSearched(true);
} catch (e: any) {
if (e?.name === "AuthError") return router.replace("/login");
setNote(e?.message ?? "Search failed");
} finally {
setBusy(false);
}
}, [q]);
const adjust = useCallback(async (t: TicketView, delta: number) => {
setNote("");
try {
const res = await redeem(t.code, delta);
if (!res.ok) {
feedbackError();
const msgs: Record<string, string> = {
insufficient: `Only ${res.ticket?.remaining ?? 0} remaining.`,
exhausted: "Already fully redeemed.",
not_found: "Ticket not found.",
db_error: `Database error: ${res.detail ?? ""}`,
};
setNote(msgs[res.reason] ?? "Update failed");
if (res.ticket) updateRow(res.ticket);
return;
}
feedbackSuccess();
updateRow(res.ticket);
setNote(`${delta > 0 ? "Checked in" : "Restored"} ${Math.abs(delta)} for ${res.ticket.name}. Database updated.`);
} catch (e: any) {
if (e?.name === "AuthError") return router.replace("/login");
feedbackError();
setNote(e?.message ?? "Update failed");
}
function updateRow(updated: TicketView) {
setResults((rows) => rows.map((r) => (r.code === updated.code ? updated : r)));
}
}, []);
return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
<Pressable onPress={() => router.replace("/")} hitSlop={10}>
<Text style={styles.link}> Scanner</Text>
</Pressable>
<Text style={styles.brand}>Admin lookup</Text>
<View style={{ width: 60 }} />
</View>
<View style={styles.searchRow}>
<TextInput
style={styles.input}
placeholder="Name, email, or ticket code"
placeholderTextColor={theme.textDim}
value={q}
onChangeText={setQ}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
onSubmitEditing={doSearch}
/>
<Pressable style={styles.searchBtn} onPress={doSearch}>
<Text style={styles.searchBtnText}>Search</Text>
</Pressable>
</View>
{!!note && <Text style={styles.note}>{note}</Text>}
{busy ? (
<ActivityIndicator color={theme.successBright} style={{ marginTop: 30 }} />
) : (
<ScrollView style={styles.list} contentContainerStyle={{ paddingBottom: 40 }}>
{searched && results.length === 0 && <Text style={styles.empty}>No matching tickets.</Text>}
{results.map((t) => (
<TicketCard key={t.code} ticket={t} onAdjust={adjust} />
))}
</ScrollView>
)}
</SafeAreaView>
);
}
function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: TicketView, d: number) => void }) {
const tags: string[] = [];
if (ticket.extras.carParking) tags.push("🚗 Car");
if (ticket.extras.rvParking) tags.push("🚐 RV");
if (ticket.extras.iceAccess) tags.push("🧊 Ice");
if (ticket.extras.isDonor) tags.push("⭐ Donor");
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} free`);
return (
<View style={styles.card}>
<View style={styles.cardHead}>
<Text style={styles.cardName}>{ticket.name}</Text>
<Text style={styles.cardCode}>{ticket.code}</Text>
</View>
{!!ticket.email && <Text style={styles.cardEmail}>{ticket.email}</Text>}
<Text style={styles.cardCounts}>
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{ticket.remaining}</Text> remaining ·{" "}
{ticket.redeemed}/{ticket.total} redeemed
</Text>
{tags.length > 0 && (
<View style={styles.tags}>
{tags.map((t) => (
<Text key={t} style={styles.tag}>
{t}
</Text>
))}
</View>
)}
<View style={styles.actions}>
<Pressable
style={[styles.actBtn, styles.actUndo]}
onPress={() => onAdjust(ticket, -1)}
disabled={ticket.redeemed <= 0}
>
<Text style={styles.actText}> Undo 1</Text>
</Pressable>
{[1, 2, 5].map((n) => (
<Pressable
key={n}
style={[styles.actBtn, styles.actRedeem, ticket.remaining < n && styles.actDisabled]}
onPress={() => onAdjust(ticket, n)}
disabled={ticket.remaining < n}
>
<Text style={styles.actText}>+ Check in {n}</Text>
</Pressable>
))}
</View>
</View>
);
}
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" },
link: { color: theme.textDim, fontSize: 16, fontWeight: "600", width: 60 },
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
input: {
flex: 1,
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
color: theme.text,
fontSize: 16,
},
searchBtn: { backgroundColor: theme.primary, borderRadius: 12, paddingHorizontal: 18, justifyContent: "center" },
searchBtnText: { color: "#fff", fontSize: 16, fontWeight: "700" },
note: { color: theme.text, backgroundColor: theme.card, marginHorizontal: 16, marginTop: 12, padding: 12, borderRadius: 10, fontSize: 14 },
list: { flex: 1, marginTop: 12, paddingHorizontal: 16 },
empty: { color: theme.textDim, textAlign: "center", marginTop: 30, fontSize: 16 },
card: {
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 14,
padding: 16,
marginBottom: 14,
},
cardHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 6 },
cardName: { color: theme.text, fontSize: 20, fontWeight: "700" },
cardCode: { color: theme.textDim, fontSize: 14, letterSpacing: 1 },
cardEmail: { color: theme.textDim, fontSize: 14, marginTop: 2 },
cardCounts: { color: theme.text, fontSize: 16, marginTop: 10 },
tags: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 10 },
tag: {
color: theme.text,
backgroundColor: theme.cardBorder,
paddingHorizontal: 9,
paddingVertical: 4,
borderRadius: 999,
fontSize: 12,
overflow: "hidden",
},
actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 14 },
actBtn: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10 },
actRedeem: { backgroundColor: theme.primary },
actUndo: { backgroundColor: theme.warn },
actDisabled: { opacity: 0.35 },
actText: { color: "#fff", fontSize: 14, fontWeight: "700" },
});

332
app/app/index.tsx Normal file
View file

@ -0,0 +1,332 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { StyleSheet, View, Text, Pressable, ScrollView } from "react-native";
import { router } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
import QRScanner from "../components/QRScanner";
import ResultOverlay, { OverlayStatus } from "../components/ResultOverlay";
import { lookup, redeem, logout, type TicketView } from "../lib/api";
import { feedbackSuccess, feedbackError } from "../lib/feedback";
import { theme } from "../lib/theme";
type Phase = "scanning" | "busy" | "confirm" | "success" | "error";
export default function ScannerScreen() {
const [phase, setPhase] = useState<Phase>("scanning");
const [ticket, setTicket] = useState<TicketView | null>(null);
const [count, setCount] = useState(1);
const [message, setMessage] = useState("");
const [checkedIn, setCheckedIn] = useState(0);
const resumeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const resume = useCallback(() => {
if (resumeTimer.current) clearTimeout(resumeTimer.current);
setTicket(null);
setMessage("");
setCheckedIn(0);
setCount(1);
setPhase("scanning");
}, []);
useEffect(() => () => {
if (resumeTimer.current) clearTimeout(resumeTimer.current);
}, []);
const showError = useCallback((msg: string) => {
feedbackError();
setMessage(msg);
setPhase("error");
}, []);
const handleScan = useCallback(
async (raw: string) => {
setPhase("busy");
try {
const res = await lookup(raw);
if (!res.ok) {
showError(`Database error: ${res.detail}`);
return;
}
if (!res.found) {
showError(`Not a valid ticket:\n${raw.slice(0, 40)}`);
return;
}
setTicket(res.ticket);
setCount(Math.min(1, res.ticket.remaining));
setPhase("confirm");
} catch (e: any) {
if (e?.name === "AuthError") {
router.replace("/login");
return;
}
showError(e?.message ?? "Lookup failed");
}
},
[showError],
);
const handleCheckIn = useCallback(async () => {
if (!ticket || count < 1) return;
setPhase("busy");
try {
const res = await redeem(ticket.code, count);
if (!res.ok) {
const reasons: Record<string, string> = {
exhausted: "All tickets on this code are already redeemed.",
insufficient: `Only ${res.ticket?.remaining ?? 0} left on this ticket.`,
not_found: "Ticket not found.",
db_error: `Database error: ${res.detail ?? ""}`,
};
showError(reasons[res.reason] ?? "Check-in failed");
if (res.ticket) setTicket(res.ticket);
return;
}
feedbackSuccess();
setTicket(res.ticket);
setCheckedIn(res.checkedIn);
setPhase("success");
resumeTimer.current = setTimeout(resume, 4000);
} catch (e: any) {
if (e?.name === "AuthError") {
router.replace("/login");
return;
}
showError(e?.message ?? "Check-in failed");
}
}, [ticket, count, resume, showError]);
const doLogout = useCallback(async () => {
await logout();
router.replace("/login");
}, []);
return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
<Text style={styles.brand}>🐻 Camp Scan</Text>
<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 style={styles.scannerArea}>
<QRScanner onScan={handleScan} active={phase === "scanning"} />
{phase === "scanning" && (
<View pointerEvents="none" style={styles.reticle}>
<View style={styles.reticleBox} />
<Text style={styles.hint}>Point the camera at a ticket QR code</Text>
</View>
)}
{phase === "confirm" && ticket && (
<ResultOverlay status="neutral" onDismiss={undefined}>
<ConfirmCard
ticket={ticket}
count={count}
setCount={setCount}
onCheckIn={handleCheckIn}
onCancel={resume}
/>
</ResultOverlay>
)}
{phase === "success" && ticket && (
<ResultOverlay status="success" onDismiss={resume}>
<Text style={styles.bigIcon}></Text>
<Text style={styles.bigTitle}>Checked in {checkedIn}</Text>
<Text style={styles.name}>{ticket.name}</Text>
<Text style={styles.counts}>
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
</Text>
<ExtrasRow ticket={ticket} />
<Text style={styles.dbConfirm}>Database updated</Text>
<Text style={styles.tapHint}>Tap to scan the next ticket</Text>
</ResultOverlay>
)}
{phase === "error" && (
<ResultOverlay status="error" onDismiss={resume}>
<Text style={styles.bigIcon}></Text>
<Text style={styles.bigTitle}>Problem</Text>
<Text style={styles.errorMsg}>{message}</Text>
{ticket && (
<Text style={styles.counts}>
{ticket.name} · {ticket.remaining} remaining
</Text>
)}
<Text style={styles.tapHint}>Tap to try again</Text>
</ResultOverlay>
)}
{phase === "busy" && (
<View style={styles.busy}>
<Text style={styles.busyText}>Working</Text>
</View>
)}
</View>
</SafeAreaView>
);
}
function ExtrasRow({ ticket }: { ticket: TicketView }) {
const tags: string[] = [];
if (ticket.extras.carParking) tags.push("🚗 Car parking");
if (ticket.extras.rvParking) tags.push("🚐 RV parking");
if (ticket.extras.iceAccess) tags.push("🧊 Ice access");
if (ticket.extras.isDonor) tags.push("⭐ Donor");
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`);
if (!tags.length) return null;
return (
<View style={styles.tags}>
{tags.map((t) => (
<Text key={t} style={styles.tag}>
{t}
</Text>
))}
</View>
);
}
function ConfirmCard({
ticket,
count,
setCount,
onCheckIn,
onCancel,
}: {
ticket: TicketView;
count: number;
setCount: (n: number) => void;
onCheckIn: () => void;
onCancel: () => void;
}) {
const exhausted = ticket.remaining <= 0;
return (
<ScrollView style={styles.card} contentContainerStyle={styles.cardContent}>
<Text style={styles.cardName}>{ticket.name}</Text>
<Text style={styles.cardCode}>{ticket.code}</Text>
<Text style={styles.cardCounts}>
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{ticket.remaining}</Text> of{" "}
{ticket.total} remaining
</Text>
<Text style={styles.cardSub}>{ticket.redeemed} already redeemed</Text>
<ExtrasRow ticket={ticket} />
{exhausted ? (
<Text style={styles.exhausted}>All tickets on this code are already redeemed.</Text>
) : (
<>
<Text style={styles.stepperLabel}>How many are entering now?</Text>
<View style={styles.stepper}>
<StepBtn label="" onPress={() => setCount(Math.max(1, count - 1))} disabled={count <= 1} />
<Text style={styles.stepValue}>{count}</Text>
<StepBtn
label="+"
onPress={() => setCount(Math.min(ticket.remaining, count + 1))}
disabled={count >= ticket.remaining}
/>
</View>
<Pressable style={styles.checkinBtn} onPress={onCheckIn}>
<Text style={styles.checkinText}>Check in {count}</Text>
</Pressable>
</>
)}
<Pressable style={styles.cancelBtn} onPress={onCancel}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
</ScrollView>
);
}
function StepBtn({ label, onPress, disabled }: { label: string; onPress: () => void; disabled?: boolean }) {
return (
<Pressable style={[styles.stepBtn, disabled && styles.stepBtnDisabled]} onPress={onPress} disabled={disabled}>
<Text style={styles.stepBtnText}>{label}</Text>
</Pressable>
);
}
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" },
topActions: { flexDirection: "row", gap: 18 },
link: { color: theme.textDim, fontSize: 15, fontWeight: "600" },
scannerArea: { flex: 1, position: "relative", overflow: "hidden" },
reticle: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center" },
reticleBox: {
width: 240,
height: 240,
borderWidth: 3,
borderColor: "rgba(255,255,255,0.85)",
borderRadius: 24,
},
hint: { color: "#fff", marginTop: 20, fontSize: 15, textShadowColor: "#000", textShadowRadius: 4 },
busy: {
position: "absolute", top: 0, left: 0, right: 0, bottom: 0,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.4)",
},
busyText: { color: "#fff", fontSize: 18, fontWeight: "600" },
bigIcon: { color: "#fff", fontSize: 96, fontWeight: "900", lineHeight: 104 },
bigTitle: { color: "#fff", fontSize: 34, fontWeight: "800", marginTop: 4 },
name: { color: "#fff", fontSize: 24, fontWeight: "700", marginTop: 12, textAlign: "center" },
counts: { color: "rgba(255,255,255,0.95)", fontSize: 18, marginTop: 8, textAlign: "center" },
dbConfirm: { color: "#fff", fontSize: 15, marginTop: 16, fontWeight: "600" },
errorMsg: { color: "#fff", fontSize: 18, marginTop: 12, textAlign: "center", lineHeight: 24 },
tapHint: { color: "rgba(255,255,255,0.75)", fontSize: 14, marginTop: 24 },
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
tag: {
color: "#fff",
backgroundColor: "rgba(255,255,255,0.18)",
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 999,
fontSize: 13,
overflow: "hidden",
},
card: { maxHeight: "100%", width: "100%" },
cardContent: { alignItems: "center", paddingVertical: 8 },
cardName: { color: theme.text, fontSize: 26, fontWeight: "800", textAlign: "center" },
cardCode: { color: theme.textDim, fontSize: 15, marginTop: 4, letterSpacing: 1 },
cardCounts: { color: theme.text, fontSize: 22, marginTop: 16 },
cardSub: { color: theme.textDim, fontSize: 14, marginTop: 4 },
exhausted: { color: theme.dangerBright, fontSize: 17, marginTop: 20, textAlign: "center", fontWeight: "600" },
stepperLabel: { color: theme.text, fontSize: 16, marginTop: 22 },
stepper: { flexDirection: "row", alignItems: "center", gap: 24, marginTop: 12 },
stepBtn: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: theme.primary,
alignItems: "center",
justifyContent: "center",
},
stepBtnDisabled: { backgroundColor: theme.cardBorder },
stepBtnText: { color: "#fff", fontSize: 32, fontWeight: "800", lineHeight: 36 },
stepValue: { color: theme.text, fontSize: 44, fontWeight: "800", minWidth: 64, textAlign: "center" },
checkinBtn: {
backgroundColor: theme.successBright,
paddingHorizontal: 40,
paddingVertical: 16,
borderRadius: 14,
marginTop: 24,
},
checkinText: { color: "#06210f", fontSize: 22, fontWeight: "800" },
cancelBtn: { marginTop: 16, padding: 10 },
cancelText: { color: theme.textDim, fontSize: 16 },
});

119
app/app/login.tsx Normal file
View file

@ -0,0 +1,119 @@
import { useState } from "react";
import { StyleSheet, View, Text, Pressable } from "react-native";
import { router } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
import { login, AuthError } from "../lib/api";
import { primeFeedback } from "../lib/feedback";
import { theme } from "../lib/theme";
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
export default function LoginScreen() {
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function press(k: string) {
// First tap unlocks web audio playback.
primeFeedback();
setError("");
if (k === "clear") return setPin("");
if (k === "back") return setPin((p) => p.slice(0, -1));
const next = (pin + k).slice(0, 12);
setPin(next);
}
async function submit() {
if (!pin) return;
setBusy(true);
setError("");
try {
await login(pin);
router.replace("/");
} catch (e: any) {
setError(e instanceof AuthError ? "Incorrect PIN" : (e?.message ?? "Login failed"));
setPin("");
} finally {
setBusy(false);
}
}
return (
<SafeAreaView style={styles.root}>
<View style={styles.header}>
<Text style={styles.logo}>🐻</Text>
<Text style={styles.title}>Camp Scan</Text>
<Text style={styles.subtitle}>Enter the gate PIN</Text>
</View>
<View style={styles.dots}>
{Array.from({ length: Math.max(4, pin.length) }).map((_, i) => (
<View key={i} style={[styles.dot, i < pin.length && styles.dotFilled]} />
))}
</View>
{!!error && <Text style={styles.error}>{error}</Text>}
<View style={styles.pad}>
{KEYS.map((k) => (
<Pressable
key={k}
style={[styles.key, (k === "clear" || k === "back") && styles.keyAlt]}
onPress={() => press(k)}
>
<Text style={styles.keyText}>{k === "back" ? "⌫" : k === "clear" ? "C" : k}</Text>
</Pressable>
))}
</View>
<Pressable
style={[styles.submit, (busy || !pin) && styles.submitDisabled]}
onPress={submit}
disabled={busy || !pin}
>
<Text style={styles.submitText}>{busy ? "Signing in…" : "Sign in"}</Text>
</Pressable>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg, alignItems: "center", paddingHorizontal: 24 },
header: { alignItems: "center", marginTop: 48 },
logo: { fontSize: 56 },
title: { color: theme.text, fontSize: 30, fontWeight: "800", marginTop: 8 },
subtitle: { color: theme.textDim, fontSize: 16, marginTop: 6 },
dots: { flexDirection: "row", gap: 14, marginTop: 32, minHeight: 18 },
dot: { width: 14, height: 14, borderRadius: 7, backgroundColor: theme.cardBorder },
dotFilled: { backgroundColor: theme.successBright },
error: { color: theme.dangerBright, marginTop: 16, fontSize: 15, fontWeight: "600" },
pad: {
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: 16,
marginTop: 28,
maxWidth: 300,
},
key: {
width: 84,
height: 84,
borderRadius: 42,
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
alignItems: "center",
justifyContent: "center",
},
keyAlt: { backgroundColor: "transparent" },
keyText: { color: theme.text, fontSize: 30, fontWeight: "600" },
submit: {
marginTop: 28,
backgroundColor: theme.successBright,
paddingHorizontal: 60,
paddingVertical: 16,
borderRadius: 14,
},
submitDisabled: { opacity: 0.4 },
submitText: { color: "#06210f", fontSize: 20, fontWeight: "800" },
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
app/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

BIN
app/assets/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 B

BIN
app/assets/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
app/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
app/assets/sounds/error.wav Normal file

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,56 @@
import { useRef } from "react";
import { StyleSheet, View, Text, Pressable } from "react-native";
import { CameraView, useCameraPermissions } from "expo-camera";
import { theme } from "../lib/theme";
export interface QRScannerProps {
onScan: (code: string) => void;
active: boolean;
}
/** Native (Android/iOS) scanner using expo-camera. */
export default function QRScanner({ onScan, active }: QRScannerProps) {
const [permission, requestPermission] = useCameraPermissions();
const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 });
if (!permission) {
return <View style={styles.fill} />;
}
if (!permission.granted) {
return (
<View style={[styles.fill, styles.center]}>
<Text style={styles.msg}>Camera access is needed to scan tickets.</Text>
<Pressable style={styles.btn} onPress={requestPermission}>
<Text style={styles.btnText}>Grant camera permission</Text>
</Pressable>
</View>
);
}
return (
<CameraView
style={styles.fill}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
onBarcodeScanned={
active
? ({ data }) => {
const now = Date.now();
// Debounce repeated frames of the same code.
if (data === lastScan.current.code && now - lastScan.current.at < 3000) return;
lastScan.current = { code: data, at: now };
onScan(data);
}
: undefined
}
/>
);
}
const styles = StyleSheet.create({
fill: { flex: 1, width: "100%", height: "100%" },
center: { alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: theme.bg },
msg: { color: theme.text, fontSize: 16, textAlign: "center", marginBottom: 20 },
btn: { backgroundColor: theme.primary, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 },
btnText: { color: "#fff", fontSize: 16, fontWeight: "600" },
});

View file

@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from "react";
import { StyleSheet, View, Text, Pressable } from "react-native";
import { BarcodeDetector } from "barcode-detector/ponyfill";
import { theme } from "../lib/theme";
import type { QRScannerProps } from "./QRScanner";
/** Web/PWA scanner using getUserMedia + the BarcodeDetector ponyfill (zxing-wasm). */
export default function QRScanner({ onScan, active }: QRScannerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const rafRef = useRef<number | null>(null);
const activeRef = useRef(active);
const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 });
const [error, setError] = useState<string | null>(null);
const [starting, setStarting] = useState(true);
activeRef.current = active;
async function start() {
setError(null);
setStarting(true);
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" } },
audio: false,
});
streamRef.current = stream;
const video = videoRef.current;
if (video) {
video.srcObject = stream;
video.setAttribute("playsinline", "true");
await video.play().catch(() => {});
}
const detector = new BarcodeDetector({ formats: ["qr_code"] });
let busy = false;
const tick = async () => {
rafRef.current = requestAnimationFrame(tick);
const v = videoRef.current;
if (!v || v.readyState < 2 || busy || !activeRef.current) return;
busy = true;
try {
const codes = await detector.detect(v);
if (codes && codes.length) {
const data = codes[0].rawValue;
const now = Date.now();
if (!(data === lastScan.current.code && now - lastScan.current.at < 3000)) {
lastScan.current = { code: data, at: now };
onScan(data);
}
}
} catch {
/* transient decode error; keep scanning */
} finally {
busy = false;
}
};
rafRef.current = requestAnimationFrame(tick);
setStarting(false);
} catch (e: any) {
setStarting(false);
setError(
e?.name === "NotAllowedError"
? "Camera permission was denied. Allow camera access and reload."
: "Could not open the camera. Make sure you're on HTTPS and no other app is using it.",
);
}
}
useEffect(() => {
start();
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
streamRef.current?.getTracks().forEach((t) => t.stop());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<View style={styles.fill}>
{/* Raw DOM video element; react-dom renders it inside the RN-Web div tree. */}
<video
ref={videoRef as any}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
muted
autoPlay
playsInline
/>
{starting && !error && (
<View style={styles.overlayMsg}>
<Text style={styles.msg}>Starting camera</Text>
</View>
)}
{error && (
<View style={styles.overlayMsg}>
<Text style={styles.msg}>{error}</Text>
<Pressable style={styles.btn} onPress={start}>
<Text style={styles.btnText}>Retry</Text>
</Pressable>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
fill: { flex: 1, width: "100%", height: "100%", backgroundColor: "#000" },
overlayMsg: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
padding: 24,
backgroundColor: theme.bg,
},
msg: { color: theme.text, fontSize: 16, textAlign: "center", marginBottom: 16 },
btn: { backgroundColor: theme.primary, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 },
btnText: { color: "#fff", fontSize: 16, fontWeight: "600" },
});

View file

@ -0,0 +1,32 @@
import { ReactNode } from "react";
import { StyleSheet, View, Pressable } from "react-native";
import { theme } from "../lib/theme";
export type OverlayStatus = "success" | "error" | "neutral";
const BG: Record<OverlayStatus, string> = {
success: theme.success,
error: theme.danger,
neutral: theme.card,
};
export default function ResultOverlay({
status,
onDismiss,
children,
}: {
status: OverlayStatus;
onDismiss?: () => void;
children: ReactNode;
}) {
return (
<Pressable style={[styles.fill, { backgroundColor: BG[status] }]} onPress={onDismiss}>
<View style={styles.inner}>{children}</View>
</Pressable>
);
}
const styles = StyleSheet.create({
fill: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 10 },
inner: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
});

120
app/lib/api.ts Normal file
View file

@ -0,0 +1,120 @@
import { Platform } from "react-native";
import { loadToken, saveToken, clearToken } from "./storage";
/**
* API base URL. On web the app is served from the same origin as the API, so we
* use a relative path. On native (the Android APK) it must point at the public
* HTTPS host, baked in at build time via EXPO_PUBLIC_API_URL.
*/
export const API_BASE =
Platform.OS === "web"
? ""
: (process.env.EXPO_PUBLIC_API_URL ?? "https://scan.beartariacampgrounds.com").replace(/\/+$/, "");
export interface TicketView {
code: string;
name: string;
email: string;
total: number;
redeemed: number;
remaining: number;
extras: {
carParking: boolean;
rvParking: boolean;
iceAccess: boolean;
isDonor: boolean;
freeUnder4: number;
};
ages: { bracket: string; count: number; free: boolean }[];
}
export class AuthError extends Error {}
export class ApiError extends Error {}
let cachedToken: string | null = null;
export async function getToken(): Promise<string | null> {
if (cachedToken) return cachedToken;
cachedToken = await loadToken();
return cachedToken;
}
export async function login(pin: string): Promise<void> {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pin }),
});
if (res.status === 401) throw new AuthError("Incorrect PIN");
if (!res.ok) throw new ApiError(`Login failed (${res.status})`);
const { token } = (await res.json()) as { token: string };
cachedToken = token;
await saveToken(token);
}
export async function logout(): Promise<void> {
cachedToken = null;
await clearToken();
}
async function authed<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = await getToken();
if (!token) throw new AuthError("Not logged in");
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(init.headers || {}),
},
});
if (res.status === 401) {
await logout();
throw new AuthError("Session expired");
}
const text = await res.text();
let body: any = undefined;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!res.ok) {
throw new ApiError(body?.error ?? body?.detail ?? `Request failed (${res.status})`);
}
return body as T;
}
export type LookupResult =
| { ok: true; found: true; ticket: TicketView }
| { ok: true; found: false }
| { ok: false; reason: "db_error"; detail: string };
export function lookup(code: string): Promise<LookupResult> {
return authed<LookupResult>("/api/lookup", {
method: "POST",
body: JSON.stringify({ code }),
});
}
export type RedeemResult =
| { ok: true; ticket: TicketView; checkedIn: number }
| {
ok: false;
reason: "not_found" | "exhausted" | "insufficient" | "db_error";
ticket?: TicketView;
detail?: string;
};
export function redeem(code: string, count: number): Promise<RedeemResult> {
return authed<RedeemResult>("/api/redeem", {
method: "POST",
body: JSON.stringify({ code, count }),
});
}
export function searchTickets(q: string): Promise<{ results: TicketView[] }> {
return authed<{ results: TicketView[] }>(`/api/tickets?q=${encodeURIComponent(q)}`);
}

64
app/lib/feedback.ts Normal file
View file

@ -0,0 +1,64 @@
import { Platform } from "react-native";
import { createAudioPlayer, setAudioModeAsync, type AudioPlayer } from "expo-audio";
import * as Haptics from "expo-haptics";
// Preloaded one-shot players. Created on first prime() call, which must happen
// in response to a user gesture (the PIN login tap) so web autoplay policies
// allow later programmatic playback.
let successPlayer: AudioPlayer | null = null;
let errorPlayer: AudioPlayer | null = null;
let primed = false;
export async function primeFeedback(): Promise<void> {
if (primed) return;
primed = true;
try {
await setAudioModeAsync({ playsInSilentMode: true });
} catch {
/* not fatal */
}
try {
successPlayer = createAudioPlayer(require("../assets/sounds/success.wav"));
errorPlayer = createAudioPlayer(require("../assets/sounds/error.wav"));
// Nudge the web audio context alive with a muted play/pause.
if (Platform.OS === "web") {
for (const p of [successPlayer, errorPlayer]) {
try {
p.volume = 0;
p.play();
p.pause();
p.seekTo(0);
p.volume = 1;
} catch {
/* ignore */
}
}
}
} catch {
/* audio unavailable; feedback falls back to haptics/visual only */
}
}
function replay(player: AudioPlayer | null): void {
if (!player) return;
try {
player.seekTo(0);
player.play();
} catch {
/* ignore */
}
}
export function feedbackSuccess(): void {
replay(successPlayer);
if (Platform.OS !== "web") {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
}
}
export function feedbackError(): void {
replay(errorPlayer);
if (Platform.OS !== "web") {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
}
}

42
app/lib/storage.ts Normal file
View file

@ -0,0 +1,42 @@
import { Platform } from "react-native";
// Token persistence: localStorage on web, SecureStore on native.
const KEY = "campscan.token";
export async function saveToken(token: string): Promise<void> {
if (Platform.OS === "web") {
try {
window.localStorage.setItem(KEY, token);
} catch {
/* ignore */
}
return;
}
const SecureStore = await import("expo-secure-store");
await SecureStore.setItemAsync(KEY, token);
}
export async function loadToken(): Promise<string | null> {
if (Platform.OS === "web") {
try {
return window.localStorage.getItem(KEY);
} catch {
return null;
}
}
const SecureStore = await import("expo-secure-store");
return SecureStore.getItemAsync(KEY);
}
export async function clearToken(): Promise<void> {
if (Platform.OS === "web") {
try {
window.localStorage.removeItem(KEY);
} catch {
/* ignore */
}
return;
}
const SecureStore = await import("expo-secure-store");
await SecureStore.deleteItemAsync(KEY);
}

14
app/lib/theme.ts Normal file
View file

@ -0,0 +1,14 @@
export const theme = {
bg: "#0f1a12",
card: "#16241a",
cardBorder: "#24382a",
text: "#eaf2ec",
textDim: "#9db3a4",
primary: "#2e7d32",
primaryDark: "#1b5e20",
success: "#1b7f3b",
successBright: "#25c05a",
danger: "#8f1d1d",
dangerBright: "#e04343",
warn: "#b8860b",
};

7902
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

33
app/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "camptickets-app",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"web": "expo start --web",
"android": "expo start --android",
"export:web": "expo export --platform web && node scripts/inject-pwa.mjs dist",
"typecheck": "tsc --noEmit",
"prebuild": "expo prebuild"
},
"dependencies": {
"@expo/metro-runtime": "~57.0.3",
"@types/react": "~19.2.4",
"barcode-detector": "^3.2.0",
"expo": "~57.0.4",
"expo-audio": "~57.0.0",
"expo-camera": "~57.0.1",
"expo-constants": "~57.0.3",
"expo-haptics": "~57.0.0",
"expo-linking": "~57.0.2",
"expo-router": "~57.0.4",
"expo-secure-store": "~57.0.0",
"expo-status-bar": "~57.0.0",
"react-dom": "19.2.3",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-web": "^0.21.2",
"typescript": "~6.0.3"
}
}

BIN
app/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

BIN
app/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 B

BIN
app/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

15
app/public/manifest.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "Camp Scan — Beartaria Campgrounds",
"short_name": "Camp Scan",
"description": "Scan and redeem 2026 Beartaria Campgrounds tickets at the gate.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0f1a12",
"theme_color": "#0f1a12",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}

View file

@ -0,0 +1,31 @@
// Post-export step: inject PWA manifest link, theme color, and apple-touch meta
// into the SPA index.html. Expo's `single` web output does not use +html.tsx,
// so we patch the generated file directly. Idempotent.
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const dist = process.argv[2] || "dist";
const file = join(dist, "index.html");
const HEAD = `
<meta name="theme-color" content="#0f1a12" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Camp Scan" />`;
let html = readFileSync(file, "utf8");
if (!html.includes('rel="manifest"')) {
html = html.replace("</head>", `${HEAD}\n </head>`);
}
// Allow full-screen camera: disable user zoom, cover the notch.
html = html.replace(
/<meta name="viewport"[^>]*\/>/,
'<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />',
);
writeFileSync(file, html);
console.log("inject-pwa: patched", file);

10
app/tsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}