From 1ca2c38fbf49209709f19cba70b4e5dabb707101 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 16:01:41 +0000 Subject: [PATCH] Fix login hang after PIN + enrich Banquet mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/app/_layout.tsx | 48 +++++----- app/app/admin.tsx | 8 +- app/app/index.tsx | 67 ++++++++++--- app/app/login.tsx | 9 +- app/lib/api.ts | 15 ++- app/lib/auth.tsx | 43 +++++++++ backend/src/services/donors.ts | 168 ++++++++++++++++++++++++--------- 7 files changed, 268 insertions(+), 90 deletions(-) create mode 100644 app/lib/auth.tsx diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx index ceb4c3e..c7c7dca 100644 --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -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 ( + + + + + + + ); +} + +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 ( - - - - + ); } diff --git a/app/app/admin.tsx b/app/app/admin.tsx index e769981..8b5633e 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -119,10 +119,12 @@ export default function AdminScreen() { router.replace("/")} hitSlop={10}> - ‹ Scanner + + ‹ Scanner + Admin lookup - + @@ -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, diff --git a/app/app/index.tsx b/app/app/index.tsx index ff13689..cc043f6 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -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("tickets"); const [phase, setPhase] = useState("scanning"); const [ticket, setTicket] = useState(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 ( <> - No donations found + No donor record {donor.email} {!!ticketName && Ticket: {ticketName}} @@ -301,13 +303,33 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke } return ( <> - {donor.name || ticketName || donor.email} - {money(donor.total)} - total donated - - Online {money(donor.online)} - Offline {money(donor.offline)} + {donor.name || donor.bearName || ticketName || donor.email} + {!!donor.bearName && donor.bearName !== donor.name && ( + {donor.bearName} + )} + + + {donor.isMember ? "⭐ Member" : donor.status || "Donor"} + + {donor.tags.map((t) => ( + + {t} + + ))} + + + + {money(donor.lifetime)} + lifetime + + + + {money(donor.lastYear)} + last 12 months + + + {donor.email} ); @@ -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" }, diff --git a/app/app/login.tsx b/app/app/login.tsx index c3e9f4b..b03a8d6 100644 --- a/app/app/login.tsx +++ b/app/app/login.tsx @@ -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(""); diff --git a/app/lib/api.ts b/app/lib/api.ts index c678207..e6c7adb 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -59,9 +59,17 @@ export async function login(pin: string): Promise { 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 { cachedToken = null; await clearToken(); + onCleared?.(); } async function authed(path: string, init: RequestInit = {}): Promise { @@ -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"; } diff --git a/app/lib/auth.tsx b/app/lib/auth.tsx new file mode 100644 index 0000000..4d903d5 --- /dev/null +++ b/app/lib/auth.tsx @@ -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; + signOut: () => Promise; +} + +const Ctx = createContext(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 {children}; +} + +export function useAuth(): AuthState { + const c = useContext(Ctx); + if (!c) throw new Error("useAuth must be used within AuthProvider"); + return c; +} diff --git a/backend/src/services/donors.ts b/backend/src/services/donors.ts index 580ad7b..bd13de8 100644 --- a/backend/src/services/donors.ts +++ b/backend/src/services/donors.ts @@ -4,17 +4,22 @@ export interface DonorLookup { found: boolean; email: string; name: string; - online: number; - offline: number; - total: number; + bearName: string; + lifetime: number; // total all-time giving + lastYear: number; // giving in the last 12 months + online: number; // lifetime online + offline: number; // lifetime offline + isMember: boolean; // appears to be a current member + tags: string[]; + status: string; // short human status line source: "master" | "transactions" | "none"; } /** - * Looks up a donor's total giving for Banquet mode. Primary source is the - * "Donors Master List" (which carries pre-rolled Total Donations / Total - * Online / Total Offline). Falls back to summing the online + offline - * transaction tables by email when the donor isn't in the master list. + * Looks up a donor for Banquet mode. Lifetime totals come from the "Donors + * Master List" (authoritative pre-rolled totals); the last-12-months figure is + * computed from the online + offline transaction tables (the only source with + * dates). Tickets are irrelevant here — banquet only cares about giving. */ export class DonorService { private readonly base: string; @@ -59,44 +64,97 @@ export class DonorService { async lookup(rawEmail: string): Promise { const email = rawEmail.trim(); const esc = email.replace(/[(),]/g, " "); - const empty: DonorLookup = { found: false, email, name: "", online: 0, offline: 0, total: 0, source: "none" }; + const empty: DonorLookup = { + found: false, + email, + name: "", + bearName: "", + lifetime: 0, + lastYear: 0, + online: 0, + offline: 0, + isMember: false, + tags: [], + status: "", + source: "none", + }; if (!email) return empty; - // 1) Master list (authoritative rolled-up totals), matching either email. - if (this.masterId) { - const rows = await this.list( - this.masterId, - `(Email,eq,${esc})~or(Alternate Email,eq,${esc})`, - 1, - ); - if (rows.length) { - const r = rows[0]; - const online = num(r["Total Online Donations"]); - const offline = num(r["Total Offline Donations"]); - const total = r["Total Donations"] !== undefined ? num(r["Total Donations"]) : online + offline; - const name = - r["Display Name"] || - [r["First Name"], r["Last Name"]].filter(Boolean).join(" ") || - r["Bear Name"] || - ""; - return { found: true, email, name, online, offline, total, source: "master" }; - } + // Always pull transactions (needed for the last-12-months figure and as a + // lifetime fallback). Runs in parallel with the master-list lookup. + const txnP: Promise<{ online: any[]; offline: any[] }> = + this.onlineId && this.offlineId + ? Promise.all([ + this.list(this.onlineId, `(Email,eq,${esc})`, 500), + this.list(this.offlineId, `(Email,eq,${esc})`, 500), + ]).then(([online, offline]) => ({ online, offline })) + : Promise.resolve({ online: [], offline: [] }); + + const masterP: Promise = this.masterId + ? this.list(this.masterId, `(Email,eq,${esc})~or(Alternate Email,eq,${esc})`, 1).then((r) => r[0] ?? null) + : Promise.resolve(null); + + const [{ online: onlineRows, offline: offlineRows }, master] = await Promise.all([txnP, masterP]); + + // Last 12 months, summed from dated transactions (Paid or unspecified). + const cutoff = new Date(); + cutoff.setFullYear(cutoff.getFullYear() - 1); + const lastYear = + sumSince(onlineRows, "Donation Amount", "Donation Date", cutoff) + + sumSince(offlineRows, "Donation Amount", "Donation Date", cutoff); + + const txnOnline = sumPaid(onlineRows, "Donation Amount"); + const txnOffline = sumPaid(offlineRows, "Donation Amount"); + + if (master) { + const online = master["Total Online Donations"] !== undefined ? num(master["Total Online Donations"]) : txnOnline; + const offline = + master["Total Offline Donations"] !== undefined ? num(master["Total Offline Donations"]) : txnOffline; + const lifetime = + master["Total Donations"] !== undefined ? num(master["Total Donations"]) : online + offline; + const name = + master["Display Name"] || + [master["First Name"], master["Last Name"]].filter(Boolean).join(" ") || + master["Bear Name"] || + ""; + const tags = splitTags(master["Tags"]); + const upcoming = String(master["Upcoming Rewards"] ?? ""); + const isMember = /member/i.test(upcoming) || tags.some((t) => /member/i.test(t)); + return { + found: true, + email, + name, + bearName: String(master["Bear Name"] ?? ""), + lifetime, + lastYear, + online, + offline, + isMember, + tags, + status: isMember ? "Member" : "Donor", + source: "master", + }; } - // 2) Fallback: sum transaction tables by email. - if (this.onlineId && this.offlineId) { - const [onlineRows, offlineRows] = await Promise.all([ - this.list(this.onlineId, `(Email,eq,${esc})`, 200), - this.list(this.offlineId, `(Email,eq,${esc})`, 200), - ]); - const online = sum(onlineRows, "Donation Amount"); - const offline = sum(offlineRows, "Donation Amount"); - const name = onlineRows[0]?.["Bear Name"] || onlineRows[0]?.["Name"] || offlineRows[0]?.["Name"] || ""; - const found = online + offline > 0 || onlineRows.length + offlineRows.length > 0; - return { found, email, name, online, offline, total: online + offline, source: found ? "transactions" : "none" }; - } - - return empty; + // Not in the master list: build from transactions alone. + const found = txnOnline + txnOffline > 0 || onlineRows.length + offlineRows.length > 0; + if (!found) return empty; + const name = onlineRows[0]?.["Name"] || offlineRows[0]?.["Name"] || ""; + const bearName = onlineRows[0]?.["Bear Name"] || offlineRows[0]?.["Bear Name"] || ""; + return { + found: true, + email, + name, + bearName, + lifetime: txnOnline + txnOffline, + lastYear, + online: txnOnline, + offline: txnOffline, + isMember: false, + tags: [], + status: "Donor", + source: "transactions", + }; } } @@ -105,6 +163,30 @@ function num(v: unknown): number { return Number.isFinite(n) ? n : 0; } -function sum(rows: any[], field: string): number { - return rows.reduce((acc, r) => acc + num(r[field]), 0); +// Count a transaction unless it's explicitly not paid (refunded/failed/pending). +function isPaid(row: any): boolean { + const s = String(row["Payment Status"] ?? "").trim(); + if (!s) return true; + return /paid|complete|success/i.test(s); +} + +function sumPaid(rows: any[], amountField: string): number { + return rows.reduce((acc, r) => (isPaid(r) ? acc + num(r[amountField]) : acc), 0); +} + +function sumSince(rows: any[], amountField: string, dateField: string, cutoff: Date): number { + return rows.reduce((acc, r) => { + if (!isPaid(r)) return acc; + const raw = r[dateField]; + if (!raw) return acc; + const d = new Date(raw); + if (isNaN(d.getTime()) || d < cutoff) return acc; + return acc + num(r[amountField]); + }, 0); +} + +function splitTags(v: unknown): string[] { + if (Array.isArray(v)) return v.map((x) => String(x)).filter(Boolean); + if (typeof v === "string") return v.split(",").map((s) => s.trim()).filter(Boolean); + return []; }