Add Check-in / Ice / Banquet scan modes

- Ice mode: prepaid ice bags (Ice Total / Ice Redeemed columns) redeemed
  independently of ticket check-ins; grab all bags at once or some now.
- Banquet mode: donor total (online + offline) looked up by the ticket's
  email via the Donors Master List, with a manual email override. New
  DonorService + POST /api/banquet.
- Redeem generalized over a resource (tickets|ice); audit records ice actions.
- App gains a mode selector; webhook maps ice_bags (defaults to
  ICE_BAGS_DEFAULT when only a boolean ice option is present).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 04:07:51 +00:00
parent b36d63a6a4
commit d293e53ee6
15 changed files with 587 additions and 125 deletions

View file

@ -1,21 +1,32 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { StyleSheet, View, Text, Pressable, ScrollView } from "react-native";
import { StyleSheet, View, Text, Pressable, ScrollView, TextInput } 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 ResultOverlay from "../components/ResultOverlay";
import { lookup, redeem, banquet, logout, type TicketView, type DonorLookup } from "../lib/api";
import { feedbackSuccess, feedbackError } from "../lib/feedback";
import { theme } from "../lib/theme";
type Phase = "scanning" | "busy" | "confirm" | "success" | "error";
type Mode = "tickets" | "ice" | "banquet";
type Phase = "scanning" | "busy" | "confirm" | "success" | "error" | "banquet";
const MODES: { key: Mode; label: string; icon: string }[] = [
{ key: "tickets", label: "Check-in", icon: "🎟️" },
{ key: "ice", label: "Ice", icon: "🧊" },
{ key: "banquet", label: "Banquet", icon: "🍽️" },
];
export default function ScannerScreen() {
const [mode, setMode] = useState<Mode>("tickets");
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 [donor, setDonor] = useState<DonorLookup | null>(null);
const [donorTicketName, setDonorTicketName] = useState("");
const [manualEmail, setManualEmail] = useState("");
const resumeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const resume = useCallback(() => {
@ -24,12 +35,25 @@ export default function ScannerScreen() {
setMessage("");
setCheckedIn(0);
setCount(1);
setDonor(null);
setDonorTicketName("");
setPhase("scanning");
}, []);
useEffect(() => () => {
if (resumeTimer.current) clearTimeout(resumeTimer.current);
}, []);
const switchMode = useCallback(
(m: Mode) => {
setMode(m);
resume();
},
[resume],
);
useEffect(
() => () => {
if (resumeTimer.current) clearTimeout(resumeTimer.current);
},
[],
);
const showError = useCallback((msg: string) => {
feedbackError();
@ -37,46 +61,73 @@ export default function ScannerScreen() {
setPhase("error");
}, []);
const handleScan = useCallback(
async (raw: string) => {
const runBanquet = useCallback(
async (input: { code?: string; email?: string }) => {
setPhase("busy");
try {
const res = await lookup(raw);
const res = await banquet(input);
if (!res.ok) {
showError(`Database error: ${res.detail}`);
const msgs: Record<string, string> = {
not_found: "That ticket wasn't found.",
no_email: "No email to look up.",
banquet_disabled: "Banquet lookup isn't configured.",
db_error: `Database error: ${res.detail ?? ""}`,
};
showError(msgs[res.reason] ?? "Lookup failed");
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");
setDonor(res.donor);
setDonorTicketName(res.ticketName);
if (res.donor.found) feedbackSuccess();
else feedbackError();
setPhase("banquet");
} catch (e: any) {
if (e?.name === "AuthError") {
router.replace("/login");
return;
}
if (e?.name === "AuthError") return router.replace("/login");
showError(e?.message ?? "Lookup failed");
}
},
[showError],
);
const handleCheckIn = useCallback(async () => {
const handleScan = useCallback(
async (raw: string) => {
if (mode === "banquet") {
runBanquet({ code: raw });
return;
}
setPhase("busy");
try {
const res = await lookup(raw);
if (!res.ok) return showError(`Database error: ${res.detail}`);
if (!res.found) return showError(`Not a valid ticket:\n${raw.slice(0, 40)}`);
const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining;
setTicket(res.ticket);
// Ice: default to grabbing all remaining bags at once. Tickets: default 1.
setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining));
setPhase("confirm");
} catch (e: any) {
if (e?.name === "AuthError") return router.replace("/login");
showError(e?.message ?? "Lookup failed");
}
},
[mode, runBanquet, showError],
);
const handleRedeem = useCallback(async () => {
if (!ticket || count < 1) return;
setPhase("busy");
try {
const res = await redeem(ticket.code, count);
const res = await redeem(ticket.code, count, mode === "ice" ? "ice" : "tickets");
if (!res.ok) {
const remaining = mode === "ice" ? res.ticket?.ice.remaining ?? 0 : res.ticket?.remaining ?? 0;
const noun = mode === "ice" ? "ice bags" : "tickets";
const reasons: Record<string, string> = {
exhausted: "All tickets on this code are already redeemed.",
insufficient: `Only ${res.ticket?.remaining ?? 0} left on this ticket.`,
exhausted: `All ${noun} on this code are already redeemed.`,
insufficient: `Only ${remaining} ${noun} left on this code.`,
not_found: "Ticket not found.",
db_error: `Database error: ${res.detail ?? ""}`,
};
showError(reasons[res.reason] ?? "Check-in failed");
showError(reasons[res.reason] ?? "Redemption failed");
if (res.ticket) setTicket(res.ticket);
return;
}
@ -86,19 +137,19 @@ export default function ScannerScreen() {
setPhase("success");
resumeTimer.current = setTimeout(resume, 4000);
} catch (e: any) {
if (e?.name === "AuthError") {
router.replace("/login");
return;
}
showError(e?.message ?? "Check-in failed");
if (e?.name === "AuthError") return router.replace("/login");
showError(e?.message ?? "Redemption failed");
}
}, [ticket, count, resume, showError]);
}, [ticket, count, mode, resume, showError]);
const doLogout = useCallback(async () => {
await logout();
router.replace("/login");
}, []);
const isIce = mode === "ice";
const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : "";
return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
@ -113,22 +164,43 @@ export default function ScannerScreen() {
</View>
</View>
<View style={styles.modeBar}>
{MODES.map((m) => (
<Pressable
key={m.key}
style={[styles.modeBtn, mode === m.key && styles.modeBtnActive]}
onPress={() => switchMode(m.key)}
>
<Text style={[styles.modeText, mode === m.key && styles.modeTextActive]}>
{m.icon} {m.label}
</Text>
</Pressable>
))}
</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>
<Text style={styles.hint}>
{mode === "banquet"
? "Scan a ticket to see donation total"
: isIce
? "Scan a ticket to hand out ice"
: "Point the camera at a ticket QR code"}
</Text>
</View>
)}
{phase === "confirm" && ticket && (
<ResultOverlay status="neutral" onDismiss={undefined}>
<ResultOverlay status="neutral">
<ConfirmCard
ticket={ticket}
isIce={isIce}
count={count}
setCount={setCount}
onCheckIn={handleCheckIn}
onConfirm={handleRedeem}
onCancel={resume}
/>
</ResultOverlay>
@ -137,27 +209,43 @@ export default function ScannerScreen() {
{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} />
{isIce ? (
<>
<Text style={styles.bigTitle}>
{checkedIn} {successNoun}
</Text>
<Text style={styles.name}>{ticket.name}</Text>
<Text style={styles.counts}>
{ticket.ice.redeemed} of {ticket.ice.total} bags taken · {ticket.ice.remaining} left
</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 === "banquet" && (
<ResultOverlay status={donor?.found ? "success" : "error"} onDismiss={resume}>
<BanquetResult donor={donor} ticketName={donorTicketName} />
<Text style={styles.tapHint}>Tap to scan again</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>
)}
@ -168,15 +256,68 @@ export default function ScannerScreen() {
</View>
)}
</View>
{mode === "banquet" && (phase === "scanning" || phase === "banquet") && (
<View style={styles.emailBar}>
<TextInput
style={styles.emailInput}
placeholder="Or look up an email manually"
placeholderTextColor={theme.textDim}
value={manualEmail}
onChangeText={setManualEmail}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
returnKeyType="search"
onSubmitEditing={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
/>
<Pressable
style={styles.emailBtn}
onPress={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
>
<Text style={styles.emailBtnText}>Look up</Text>
</Pressable>
</View>
)}
</SafeAreaView>
);
}
function money(n: number): string {
return "$" + (Math.round(n * 100) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticketName: string }) {
if (!donor) return null;
if (!donor.found) {
return (
<>
<Text style={styles.bigIcon}></Text>
<Text style={styles.bigTitle}>No donations found</Text>
<Text style={styles.name}>{donor.email}</Text>
{!!ticketName && <Text style={styles.counts}>Ticket: {ticketName}</Text>}
</>
);
}
return (
<>
<Text style={styles.bigTitle}>{donor.name || ticketName || donor.email}</Text>
<Text style={styles.donorTotal}>{money(donor.total)}</Text>
<Text style={styles.counts}>total donated</Text>
<View style={styles.donorBreak}>
<Text style={styles.donorBreakItem}>Online {money(donor.online)}</Text>
<Text style={styles.donorBreakItem}>Offline {money(donor.offline)}</Text>
</View>
<Text style={styles.donorEmail}>{donor.email}</Text>
</>
);
}
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.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
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;
@ -193,45 +334,50 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
function ConfirmCard({
ticket,
isIce,
count,
setCount,
onCheckIn,
onConfirm,
onCancel,
}: {
ticket: TicketView;
isIce: boolean;
count: number;
setCount: (n: number) => void;
onCheckIn: () => void;
onConfirm: () => void;
onCancel: () => void;
}) {
const exhausted = ticket.remaining <= 0;
const remaining = isIce ? ticket.ice.remaining : ticket.remaining;
const total = isIce ? ticket.ice.total : ticket.total;
const redeemed = isIce ? ticket.ice.redeemed : ticket.redeemed;
const exhausted = remaining <= 0;
const unit = isIce ? "bags of ice" : "tickets";
const question = isIce ? "How many ice bags?" : "How many are entering now?";
const cta = isIce ? `Give ${count}` : `Check in ${count}`;
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 style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
</Text>
<Text style={styles.cardSub}>{ticket.redeemed} already redeemed</Text>
<ExtrasRow ticket={ticket} />
<Text style={styles.cardSub}>{redeemed} already redeemed</Text>
{!isIce && <ExtrasRow ticket={ticket} />}
{isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>}
{exhausted ? (
<Text style={styles.exhausted}>All tickets on this code are already redeemed.</Text>
total > 0 && <Text style={styles.exhausted}>All {unit} on this code are already redeemed.</Text>
) : (
<>
<Text style={styles.stepperLabel}>How many are entering now?</Text>
<Text style={styles.stepperLabel}>{question}</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}
/>
<StepBtn label="+" onPress={() => setCount(Math.min(remaining, count + 1))} disabled={count >= remaining} />
</View>
<Pressable style={styles.checkinBtn} onPress={onCheckIn}>
<Text style={styles.checkinText}>Check in {count}</Text>
<Pressable style={styles.checkinBtn} onPress={onConfirm}>
<Text style={styles.checkinText}>{cta}</Text>
</Pressable>
</>
)}
@ -262,71 +408,74 @@ const styles = StyleSheet.create({
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
topActions: { flexDirection: "row", gap: 18 },
link: { color: theme.textDim, fontSize: 15, fontWeight: "600" },
modeBar: { flexDirection: "row", gap: 8, paddingHorizontal: 12, paddingBottom: 8 },
modeBtn: {
flex: 1,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
alignItems: "center",
},
modeBtnActive: { backgroundColor: theme.primary, borderColor: theme.primary },
modeText: { color: theme.textDim, fontSize: 15, fontWeight: "700" },
modeTextActive: { color: "#fff" },
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)",
},
reticleBox: { width: 240, height: 240, borderWidth: 3, borderColor: "rgba(255,255,255,0.85)", borderRadius: 24 },
hint: { color: "#fff", marginTop: 20, fontSize: 15, textAlign: "center", paddingHorizontal: 20, 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 },
bigTitle: { color: "#fff", fontSize: 32, fontWeight: "800", marginTop: 4, textAlign: "center" },
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 },
donorTotal: { color: "#fff", fontSize: 64, fontWeight: "900", marginTop: 10 },
donorBreak: { flexDirection: "row", gap: 18, marginTop: 14 },
donorBreakItem: { color: "rgba(255,255,255,0.95)", fontSize: 16, fontWeight: "600" },
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 14 },
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",
},
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 },
cardCounts: { color: theme.text, fontSize: 22, marginTop: 16, textAlign: "center" },
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",
},
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,
},
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 },
emailBar: { flexDirection: "row", gap: 10, paddingHorizontal: 12, paddingVertical: 10, backgroundColor: theme.bg },
emailInput: {
flex: 1,
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
color: theme.text,
fontSize: 16,
},
emailBtn: { backgroundColor: theme.primary, borderRadius: 12, paddingHorizontal: 18, justifyContent: "center" },
emailBtnText: { color: "#fff", fontSize: 16, fontWeight: "700" },
});

View file

@ -11,6 +11,12 @@ export const API_BASE =
? ""
: (process.env.EXPO_PUBLIC_API_URL ?? "https://scan.beartariacampgrounds.com").replace(/\/+$/, "");
export interface ResourceCount {
total: number;
redeemed: number;
remaining: number;
}
export interface TicketView {
code: string;
name: string;
@ -18,6 +24,7 @@ export interface TicketView {
total: number;
redeemed: number;
remaining: number;
ice: ResourceCount;
extras: {
carParking: boolean;
rvParking: boolean;
@ -108,10 +115,33 @@ export type RedeemResult =
detail?: string;
};
export function redeem(code: string, count: number): Promise<RedeemResult> {
export type Resource = "tickets" | "ice";
export function redeem(code: string, count: number, resource: Resource = "tickets"): Promise<RedeemResult> {
return authed<RedeemResult>("/api/redeem", {
method: "POST",
body: JSON.stringify({ code, count }),
body: JSON.stringify({ code, count, resource }),
});
}
export interface DonorLookup {
found: boolean;
email: string;
name: string;
online: number;
offline: number;
total: number;
source: "master" | "transactions" | "none";
}
export type BanquetResult =
| { ok: true; ticketName: string; donor: DonorLookup }
| { ok: false; reason: "not_found" | "no_email" | "db_error" | "banquet_disabled"; detail?: string };
export function banquet(input: { code?: string; email?: string }): Promise<BanquetResult> {
return authed<BanquetResult>("/api/banquet", {
method: "POST",
body: JSON.stringify(input),
});
}