Fix login hang after PIN + enrich Banquet mode
Some checks failed
Build Android APK / build-apk (push) Failing after 57m31s
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:
parent
dc39c41428
commit
1ca2c38fbf
7 changed files with 268 additions and 90 deletions
|
|
@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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
43
app/lib/auth.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue