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 [];
}