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:
parent
b36d63a6a4
commit
d293e53ee6
15 changed files with 587 additions and 125 deletions
|
|
@ -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" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue