Fix login hang after PIN + enrich Banquet mode
Some checks failed
Build Android APK / build-apk (push) Failing after 57m31s

- Auth: introduce a shared AuthProvider/useAuth so entering the PIN updates
  reactive state and the layout's gate navigates immediately (previously it
  cached the token check at startup, so login appeared to hang until a manual
  refresh). login/scanner now use signIn/signOut.
- Banquet: donor lookup now returns lifetime giving, last-12-months giving
  (computed from dated transactions), member/donor status, bear name, and tags.
  The banquet result screen shows status badge + lifetime and last-year figures.
  Tickets are irrelevant in banquet mode.
- Admin: fix "‹ Scanner" back link wrapping (remove fixed width).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 16:01:41 +00:00
parent dc39c41428
commit 1ca2c38fbf
7 changed files with 268 additions and 90 deletions

View file

@ -1,30 +1,33 @@
import { useEffect, useState } from "react";
import { useEffect } 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 { AuthProvider, useAuth } from "../lib/auth";
import { theme } from "../lib/theme";
export default function RootLayout() {
const [ready, setReady] = useState(false);
const [hasToken, setHasToken] = useState(false);
return (
<SafeAreaProvider>
<StatusBar style="light" />
<AuthProvider>
<AuthGate />
</AuthProvider>
</SafeAreaProvider>
);
}
function AuthGate() {
const { ready, signedIn } = useAuth();
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 (!signedIn && !onLogin) router.replace("/login");
if (signedIn && onLogin) router.replace("/");
}, [ready, signedIn, segments, router]);
if (!ready) {
return (
@ -35,15 +38,12 @@ export default function RootLayout() {
}
return (
<SafeAreaProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: theme.bg },
animation: "fade",
}}
/>
</SafeAreaProvider>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: theme.bg },
animation: "fade",
}}
/>
);
}

View file

@ -119,10 +119,12 @@ export default function AdminScreen() {
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
<Pressable onPress={() => router.replace("/")} hitSlop={10}>
<Text style={styles.link}> Scanner</Text>
<Text style={styles.link} numberOfLines={1}>
Scanner
</Text>
</Pressable>
<Text style={styles.brand}>Admin lookup</Text>
<View style={{ width: 60 }} />
<View style={{ width: 72 }} />
</View>
<View style={styles.searchRow}>
@ -269,7 +271,7 @@ const styles = StyleSheet.create({
paddingVertical: 10,
},
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
link: { color: theme.textDim, fontSize: 16, fontWeight: "600", width: 60 },
link: { color: theme.textDim, fontSize: 16, fontWeight: "600" },
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
input: {
flex: 1,

View file

@ -4,7 +4,8 @@ import { router } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
import QRScanner from "../components/QRScanner";
import ResultOverlay from "../components/ResultOverlay";
import { lookup, redeem, banquet, logout, type TicketView, type DonorLookup } from "../lib/api";
import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api";
import { useAuth } from "../lib/auth";
import { feedbackSuccess, feedbackError } from "../lib/feedback";
import { theme } from "../lib/theme";
@ -18,6 +19,7 @@ const MODES: { key: Mode; label: string; icon: string }[] = [
];
export default function ScannerScreen() {
const { signOut } = useAuth();
const [mode, setMode] = useState<Mode>("tickets");
const [phase, setPhase] = useState<Phase>("scanning");
const [ticket, setTicket] = useState<TicketView | null>(null);
@ -143,9 +145,9 @@ export default function ScannerScreen() {
}, [ticket, count, mode, resume, showError]);
const doLogout = useCallback(async () => {
await logout();
router.replace("/login");
}, []);
await signOut();
// The auth gate redirects to /login when signedIn flips to false.
}, [signOut]);
const isIce = mode === "ice";
const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : "";
@ -293,7 +295,7 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke
return (
<>
<Text style={styles.bigIcon}></Text>
<Text style={styles.bigTitle}>No donations found</Text>
<Text style={styles.bigTitle}>No donor record</Text>
<Text style={styles.name}>{donor.email}</Text>
{!!ticketName && <Text style={styles.counts}>Ticket: {ticketName}</Text>}
</>
@ -301,13 +303,33 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke
}
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>
<Text style={styles.bigTitle}>{donor.name || donor.bearName || ticketName || donor.email}</Text>
{!!donor.bearName && donor.bearName !== donor.name && (
<Text style={styles.donorBear}>{donor.bearName}</Text>
)}
<View style={styles.statusRow}>
<Text style={[styles.statusBadge, donor.isMember && styles.statusMember]}>
{donor.isMember ? "⭐ Member" : donor.status || "Donor"}
</Text>
{donor.tags.map((t) => (
<Text key={t} style={styles.statusBadge}>
{t}
</Text>
))}
</View>
<View style={styles.donorFigures}>
<View style={styles.donorFigure}>
<Text style={styles.donorFigureAmt}>{money(donor.lifetime)}</Text>
<Text style={styles.donorFigureLbl}>lifetime</Text>
</View>
<View style={styles.donorFigureDivider} />
<View style={styles.donorFigure}>
<Text style={styles.donorFigureAmt}>{money(donor.lastYear)}</Text>
<Text style={styles.donorFigureLbl}>last 12 months</Text>
</View>
</View>
<Text style={styles.donorEmail}>{donor.email}</Text>
</>
);
@ -438,10 +460,25 @@ const styles = StyleSheet.create({
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 },
donorBear: { color: "rgba(255,255,255,0.9)", fontSize: 17, marginTop: 4, fontStyle: "italic" },
statusRow: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
statusBadge: {
color: "#fff",
backgroundColor: "rgba(255,255,255,0.18)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
fontSize: 14,
fontWeight: "700",
overflow: "hidden",
},
statusMember: { backgroundColor: "rgba(255,215,0,0.28)" },
donorFigures: { flexDirection: "row", alignItems: "center", marginTop: 22 },
donorFigure: { alignItems: "center", paddingHorizontal: 18 },
donorFigureAmt: { color: "#fff", fontSize: 40, fontWeight: "900" },
donorFigureLbl: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 4 },
donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 },
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 },
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" },

View file

@ -1,14 +1,15 @@
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 { AuthError } from "../lib/api";
import { useAuth } from "../lib/auth";
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 { signIn } = useAuth();
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@ -28,8 +29,8 @@ export default function LoginScreen() {
setBusy(true);
setError("");
try {
await login(pin);
router.replace("/");
await signIn(pin);
// The auth gate in _layout navigates once signedIn flips to true.
} catch (e: any) {
setError(e instanceof AuthError ? "Incorrect PIN" : (e?.message ?? "Login failed"));
setPin("");

View file

@ -59,9 +59,17 @@ export async function login(pin: string): Promise<void> {
await saveToken(token);
}
// Lets the auth provider react when the token is cleared (e.g. on a 401), so
// UI state stays in sync with storage.
let onCleared: (() => void) | null = null;
export function onAuthCleared(cb: (() => void) | null): void {
onCleared = cb;
}
export async function logout(): Promise<void> {
cachedToken = null;
await clearToken();
onCleared?.();
}
async function authed<T>(path: string, init: RequestInit = {}): Promise<T> {
@ -128,9 +136,14 @@ export interface DonorLookup {
found: boolean;
email: string;
name: string;
bearName: string;
lifetime: number;
lastYear: number;
online: number;
offline: number;
total: number;
isMember: boolean;
tags: string[];
status: string;
source: "master" | "transactions" | "none";
}

43
app/lib/auth.tsx Normal file
View file

@ -0,0 +1,43 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { login as apiLogin, logout as apiLogout, getToken, onAuthCleared } from "./api";
interface AuthState {
ready: boolean; // finished the initial token load
signedIn: boolean;
signIn: (pin: string) => Promise<void>;
signOut: () => Promise<void>;
}
const Ctx = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [signedIn, setSignedIn] = useState(false);
useEffect(() => {
getToken().then((t) => {
setSignedIn(!!t);
setReady(true);
});
// Keep state in sync when the token is cleared elsewhere (401 handling).
onAuthCleared(() => setSignedIn(false));
return () => onAuthCleared(null);
}, []);
const signIn = async (pin: string) => {
await apiLogin(pin);
setSignedIn(true);
};
const signOut = async () => {
await apiLogout();
setSignedIn(false);
};
return <Ctx.Provider value={{ ready, signedIn, signIn, signOut }}>{children}</Ctx.Provider>;
}
export function useAuth(): AuthState {
const c = useContext(Ctx);
if (!c) throw new Error("useAuth must be used within AuthProvider");
return c;
}