CampgroundTickets/app/components/SideMenu.tsx
Hank 1c8cb47209 Move admin hub into the /crush33 page; drop the /comp app route
The admin area belongs at /crush33 (the standalone, portal-password
page — no app login), not an in-app /comp route I'd added unasked.

- Rebuilt the /crush33 page into the full hub: password unlock →
  sidebar (Comp tickets · Donor lookup · Actions). Vanilla JS calling
  the same /api/portal + /api/admin endpoints. Actions has the danger
  cards + an "are you sure" modal spelling out exactly what happens.
- Deleted app/app/comp.tsx (removes the /comp route).
- Drawer "Admin (crush33)" now opens the /crush33 web page (Linking)
  instead of routing to /comp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:49:15 +00:00

119 lines
4.6 KiB
TypeScript

import { useEffect, useRef } from "react";
import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions, Linking, Platform } from "react-native";
import { router, useSegments } from "expo-router";
import { useAuth } from "../lib/auth";
import { theme } from "../lib/theme";
// The admin hub is the standalone /crush33 web page (not an app route).
const CRUSH_URL = Platform.OS === "web" ? "/crush33" : "https://scan.beartariacampgrounds.com/crush33";
const ITEMS: { label: string; icon: string; route: string; seg: string; external?: string }[] = [
{ label: "Scanner", icon: "📷", route: "/", seg: "" },
{ label: "Event report", icon: "📊", route: "/stats", seg: "stats" },
{ label: "Admin (crush33)", icon: "🔐", route: "", seg: "__admin", external: CRUSH_URL },
{ label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" },
];
export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) {
const { operator, signOut } = useAuth();
const segments = useSegments();
const current = segments[0] ?? "";
const { width } = useWindowDimensions();
const panelW = Math.min(320, width * 0.84);
const tx = useRef(new Animated.Value(-panelW)).current;
const fade = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(tx, {
toValue: visible ? 0 : -panelW,
duration: 220,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(fade, { toValue: visible ? 1 : 0, duration: 220, useNativeDriver: true }),
]).start();
}, [visible, panelW, tx, fade]);
const go = (item: { route: string; seg: string; external?: string }) => {
onClose();
if (item.external) {
Linking.openURL(item.external).catch(() => {});
return;
}
if (item.seg !== current) router.replace(item.route as any);
};
return (
<View pointerEvents={visible ? "auto" : "none"} style={StyleSheet.absoluteFill}>
<Animated.View style={[styles.scrim, { opacity: fade }]}>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.panel, { width: panelW, transform: [{ translateX: tx }] }]}>
<View style={styles.header}>
<Text style={styles.logo}>🐻 Camp Scan</Text>
{!!operator && <Text style={styles.operator}>{operator}</Text>}
</View>
<View style={styles.items}>
{ITEMS.map((it) => {
const active = it.seg === current;
return (
<Pressable key={it.route} style={[styles.item, active && styles.itemActive]} onPress={() => go(it)}>
<Text style={styles.itemIcon}>{it.icon}</Text>
<Text style={[styles.itemText, active && styles.itemTextActive]}>{it.label}</Text>
</Pressable>
);
})}
</View>
<View style={styles.spacer} />
<Pressable
style={styles.signout}
onPress={() => {
onClose();
signOut();
}}
>
<Text style={styles.itemIcon}>🚪</Text>
<Text style={styles.signoutText}>Sign out</Text>
</Pressable>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
scrim: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0,0,0,0.55)" },
panel: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
backgroundColor: theme.card,
borderRightWidth: 1,
borderRightColor: theme.cardBorder,
paddingTop: 54,
paddingHorizontal: 14,
paddingBottom: 28,
},
header: { paddingHorizontal: 8, paddingBottom: 14, borderBottomWidth: 1, borderBottomColor: theme.cardBorder },
logo: { color: theme.text, fontSize: 20, fontWeight: "800" },
operator: { color: theme.textDim, fontSize: 14, marginTop: 3 },
items: { marginTop: 14, gap: 4 },
item: { flexDirection: "row", alignItems: "center", gap: 14, paddingVertical: 14, paddingHorizontal: 12, borderRadius: 12 },
itemActive: { backgroundColor: theme.primary },
itemIcon: { fontSize: 20, width: 26, textAlign: "center" },
itemText: { color: theme.text, fontSize: 17, fontWeight: "600" },
itemTextActive: { color: "#fff", fontWeight: "800" },
spacer: { flex: 1 },
signout: {
flexDirection: "row",
alignItems: "center",
gap: 14,
paddingVertical: 14,
paddingHorizontal: 12,
borderRadius: 12,
borderTopWidth: 1,
borderTopColor: theme.cardBorder,
},
signoutText: { color: theme.dangerBright, fontSize: 17, fontWeight: "700" },
});