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>
48
app/app.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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" },
|
||||
});
|
||||
BIN
app/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 195 B |
BIN
app/assets/icon-192.png
Normal file
|
After Width: | Height: | Size: 773 B |
BIN
app/assets/icon-512.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/assets/icon.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/assets/sounds/error.wav
Normal file
BIN
app/assets/sounds/success.wav
Normal file
56
app/components/QRScanner.tsx
Normal 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" },
|
||||
});
|
||||
121
app/components/QRScanner.web.tsx
Normal 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" },
|
||||
});
|
||||
32
app/components/ResultOverlay.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
33
app/package.json
Normal 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
|
After Width: | Height: | Size: 195 B |
BIN
app/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 773 B |
BIN
app/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
15
app/public/manifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
31
app/scripts/inject-pwa.mjs
Normal 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
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||