diff --git a/app/app/comp.tsx b/app/app/comp.tsx deleted file mode 100644 index 648af1e..0000000 --- a/app/app/comp.tsx +++ /dev/null @@ -1,545 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { - StyleSheet, - View, - Text, - TextInput, - Pressable, - ScrollView, - Image, - KeyboardAvoidingView, - Platform, - ActivityIndicator, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { - portalVerify, - portalCreate, - adminStatus, - adminWipe, - adminSwitchTable, - adminDonorSearch, - AuthError, - type PortalTicket, - type AdminStatus, - type DonorSearchResult, -} from "../lib/api"; -import { useAuth } from "../lib/auth"; -import { useMenu } from "../lib/menu"; -import { theme } from "../lib/theme"; - -const TYPES = ["Guest", "Worker", "Performer", "Volunteer", "Speaker"]; -const TYPE_ICON: Record = { - Guest: "🎫", - Worker: "🛠️", - Performer: "🎭", - Volunteer: "🙌", - Speaker: "🎤", -}; - -type Section = "comp" | "donors" | "actions"; -const NAV: { key: Section; icon: string; label: string }[] = [ - { key: "comp", icon: "🎟️", label: "Comp\ntickets" }, - { key: "donors", icon: "🔎", label: "Donor\nlookup" }, - { key: "actions", icon: "⚠️", label: "Actions" }, -]; - -export default function AdminHub() { - const { operator } = useAuth(); - const { open: openMenu } = useMenu(); - const [password, setPassword] = useState(""); - const [unlocked, setUnlocked] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [section, setSection] = useState
("comp"); - - const relock = useCallback(() => { - setUnlocked(false); - setError("Password changed — unlock again."); - }, []); - - async function unlock() { - if (!password || busy) return; - setBusy(true); - setError(""); - try { - await portalVerify(password); - setUnlocked(true); - } catch (e: any) { - setError(e instanceof AuthError ? "Wrong password" : e?.message ?? "Failed"); - } finally { - setBusy(false); - } - } - - return ( - - - - - - Admin · crush33 - - - - {!unlocked ? ( - - - Admin-only area. Enter the shared portal password to unlock. - Portal password - - {!!error && {error}} - - {busy ? "Checking…" : "Unlock"} - - - - ) : ( - - - {NAV.map((n) => { - const active = section === n.key; - return ( - setSection(n.key)}> - {n.icon} - {n.label} - - ); - })} - - - - - {section === "comp" && } - {section === "donors" && } - {section === "actions" && } - - - - )} - - ); -} - -/* ---------------- Comp tickets ---------------- */ - -function CompSection({ password, operator, onRelock }: { password: string; operator: string | null; onRelock: () => void }) { - const [type, setType] = useState("Guest"); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [result, setResult] = useState(null); - - async function create() { - if (!name.trim() || !email.trim() || busy) return; - setBusy(true); - setError(""); - try { - const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator ?? "" }); - setResult(r); - setName(""); - setEmail(""); - } catch (e: any) { - if (e instanceof AuthError) onRelock(); - else setError(e?.message ?? "Failed to create ticket"); - } finally { - setBusy(false); - } - } - - return ( - - Comp tickets - Entry-only tickets for guests & staff. - - Ticket type - - {TYPES.map((t) => ( - setType(t)}> - {(TYPE_ICON[t] ?? "🎫") + " " + t} - - ))} - - - Full name - - Email - - - {!!error && {error}} - - {busy ? "Creating…" : `Create ${type} ticket`} - - - {result && ( - - - {result.code} - {result.type} · {result.name} - {result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"} - - )} - - ); -} - -/* ---------------- Donor lookup ---------------- */ - -function DonorSection({ password, onRelock }: { password: string; onRelock: () => void }) { - const [query, setQuery] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [results, setResults] = useState(null); - - async function run() { - const q = query.trim(); - if (q.length < 2 || busy) return; - setBusy(true); - setError(""); - try { - const r = await adminDonorSearch(password, q); - setResults(r.results); - } catch (e: any) { - if (e instanceof AuthError) onRelock(); - else setError(e?.message ?? "Search failed"); - } finally { - setBusy(false); - } - } - - return ( - - Donor lookup - 🔒 Admin only · private donor info. Search by name, email, phone, address, bear name… - - - - - {busy ? "…" : "Search"} - - - - {!!error && {error}} - {results !== null && !busy && results.length === 0 && No donors match “{query.trim()}”.} - - {results?.map((d, i) => ( - - - {d.name || d.email || "(unnamed)"} - {d.lifetime != null && {money(d.lifetime)}} - - {!!d.bearName && 🐻 {d.bearName}} - {!!d.email && ✉️ {d.email}} - {!!d.altEmail && ✉️ {d.altEmail} (alt)} - {!!d.phone && 📞 {d.phone}} - {!!d.address && 🏠 {d.address}} - - {d.source === "master" ? "directory" : "transactions"} - {d.tags.map((t) => ( - {t} - ))} - - - ))} - - ); -} - -/* ---------------- Actions (danger zone) ---------------- */ - -function ActionsSection({ password, onRelock }: { password: string; onRelock: () => void }) { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(false); - const [msg, setMsg] = useState(""); - const [confirm, setConfirm] = useState(null); - const [busy, setBusy] = useState(false); - const [newTickets, setNewTickets] = useState(""); - const [newAudit, setNewAudit] = useState(""); - - const refresh = useCallback(async () => { - setLoading(true); - try { - setStatus(await adminStatus(password)); - } catch (e: any) { - if (e instanceof AuthError) onRelock(); - } finally { - setLoading(false); - } - }, [password, onRelock]); - - // Load status the first time this section renders. - useEffect(() => { - refresh(); - }, [refresh]); - - async function doWipe() { - setBusy(true); - setMsg(""); - try { - const r = await adminWipe(password); - setMsg(`✓ Wiped ${r.ticketsDeleted} tickets and ${r.auditDeleted} audit rows.`); - setConfirm(null); - refresh(); - } catch (e: any) { - if (e instanceof AuthError) onRelock(); - else setMsg(e?.message ?? "Wipe failed"); - } finally { - setBusy(false); - } - } - - async function doSwitch() { - if (!newTickets.trim()) return; - setBusy(true); - setMsg(""); - try { - const r = await adminSwitchTable(password, newTickets.trim(), newAudit.trim() || undefined); - setMsg(`✓ Now using tickets table ${r.tickets.tableId}.`); - setConfirm(null); - setNewTickets(""); - setNewAudit(""); - refresh(); - } catch (e: any) { - if (e instanceof AuthError) onRelock(); - else setMsg(e?.message ?? "Switch failed"); - } finally { - setBusy(false); - } - } - - return ( - - Actions - Event-management tools. These change live data — read the warnings. - - {/* Current status */} - - - Active event table - - {loading ? "…" : "↻"} - - - {status ? ( - <> - tickets: {status.tickets.tableId} · {status.tickets.count} records - audit: {status.audit.tableId ?? "—"} · {status.audit.count} records - - ) : ( - {loading ? "loading…" : "—"} - )} - - - {!!msg && {msg}} - - {/* Wipe slate */} - - 🧹 Wipe the slate clean - - Permanently deletes every ticket and every check-in in the active event - table. Use this to reset before a run-through or a fresh event. - - • Does NOT affect donor data. - • Cannot be undone. - { setMsg(""); setConfirm("wipe"); }}> - Wipe slate… - - - - {/* Switch table */} - - 🔀 Switch event table - - Point the scanner at a different NocoDB table — e.g. to start a new event on - a fresh table while keeping the current one intact. - - • Create the new table first (duplicate the current one's structure in NocoDB — keep the Id column). - • The current event's data is NOT deleted, just no longer shown. - New tickets table ID - - New audit table ID (optional) - - { setMsg(""); setConfirm("switch"); }} disabled={!newTickets.trim()}> - Switch table… - - - - {confirm === "wipe" && ( - setConfirm(null)} - /> - )} - {confirm === "switch" && ( - setConfirm(null)} - /> - )} - - ); -} - -function ConfirmModal({ - title, - lines, - confirmLabel, - busy, - onConfirm, - onCancel, -}: { - title: string; - lines: string[]; - confirmLabel: string; - busy: boolean; - onConfirm: () => void; - onCancel: () => void; -}) { - return ( - - - ⚠️ - {title} - {lines.map((l, i) => ( - {l} - ))} - - {busy ? : {confirmLabel}} - - - Cancel - - - - ); -} - -function money(n: number): string { - return "$" + Math.round(n).toLocaleString(); -} - -const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: theme.bg }, - topbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 10 }, - brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, - hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" }, - - body: { flex: 1, flexDirection: "row" }, - sidebar: { width: 84, backgroundColor: theme.card, borderRightWidth: 1, borderRightColor: theme.cardBorder, paddingTop: 8 }, - navItem: { paddingVertical: 14, alignItems: "center", gap: 4, borderLeftWidth: 3, borderLeftColor: "transparent" }, - navItemOn: { backgroundColor: theme.bg, borderLeftColor: theme.primary }, - navIcon: { fontSize: 22 }, - navLabel: { color: theme.textDim, fontSize: 11, fontWeight: "700", textAlign: "center", lineHeight: 13 }, - navLabelOn: { color: theme.text }, - content: { flex: 1 }, - pad: { padding: 16, paddingBottom: 48 }, - - h1: { color: theme.text, fontSize: 22, fontWeight: "800", marginBottom: 2 }, - sub: { color: theme.textDim, fontSize: 13, lineHeight: 19, marginBottom: 8 }, - lead: { color: theme.textDim, fontSize: 15, lineHeight: 21, marginBottom: 8 }, - label: { color: theme.textDim, fontSize: 13, marginTop: 14, marginBottom: 6 }, - input: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, color: theme.text, fontSize: 16, marginBottom: 2 }, - error: { color: theme.dangerBright, marginTop: 12, fontSize: 14, fontWeight: "600" }, - msg: { color: theme.successBright, marginTop: 10, fontSize: 14, fontWeight: "700" }, - bold: { fontWeight: "800", color: theme.text }, - - btn: { backgroundColor: theme.successBright, borderRadius: 13, paddingVertical: 15, alignItems: "center", marginTop: 18 }, - btnOff: { opacity: 0.4 }, - btnText: { color: "#06210f", fontSize: 18, fontWeight: "800" }, - - types: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, - typePill: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 999, paddingHorizontal: 13, paddingVertical: 8 }, - typePillOn: { backgroundColor: theme.primary, borderColor: theme.primary }, - typePillText: { color: theme.textDim, fontSize: 13, fontWeight: "700" }, - typePillTextOn: { color: "#fff" }, - - result: { marginTop: 22, alignItems: "center", backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 16, padding: 20 }, - qr: { width: 200, height: 200, backgroundColor: "#fff", borderRadius: 10 }, - rcode: { color: theme.successBright, fontSize: 22, fontWeight: "800", letterSpacing: 2, marginTop: 12 }, - rwho: { color: theme.text, fontSize: 16, marginTop: 4 }, - rmail: { color: theme.textDim, fontSize: 13, marginTop: 8 }, - - searchRow: { flexDirection: "row", gap: 8, alignItems: "center", marginTop: 8 }, - searchBtn: { backgroundColor: theme.primary, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13 }, - searchBtnText: { color: "#fff", fontWeight: "800", fontSize: 15 }, - donorCard: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, padding: 14, marginTop: 12 }, - donorHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, - donorName: { color: theme.text, fontSize: 17, fontWeight: "800", flex: 1 }, - donorAmt: { color: theme.successBright, fontSize: 16, fontWeight: "800", marginLeft: 8 }, - donorLine: { color: theme.textDim, fontSize: 14, marginTop: 3 }, - donorTags: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 8, alignItems: "center" }, - donorSource: { color: theme.textDim, fontSize: 11, fontWeight: "700", textTransform: "uppercase", backgroundColor: theme.bg, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 6, paddingHorizontal: 6, paddingVertical: 2 }, - donorTag: { color: theme.text, fontSize: 12, backgroundColor: theme.primaryDark, borderRadius: 6, paddingHorizontal: 7, paddingVertical: 2 }, - - statusBox: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, padding: 14, marginTop: 4 }, - statusRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, - statusLabel: { color: theme.textDim, fontSize: 12, fontWeight: "700", textTransform: "uppercase", letterSpacing: 0.5 }, - refresh: { color: theme.text, fontSize: 20 }, - statusVal: { color: theme.text, fontSize: 14, marginTop: 6, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace" }, - - dangerCard: { backgroundColor: "#241717", borderWidth: 1, borderColor: theme.danger, borderRadius: 14, padding: 16, marginTop: 18 }, - dangerTitle: { color: "#ff9a9a", fontSize: 17, fontWeight: "800", marginBottom: 6 }, - dangerBody: { color: "#e9cfcf", fontSize: 14, lineHeight: 20 }, - dangerBullet: { color: "#d9b8b8", fontSize: 13, lineHeight: 19, marginTop: 4 }, - redBtn: { backgroundColor: theme.dangerBright, borderRadius: 12, paddingVertical: 14, alignItems: "center", marginTop: 16 }, - redBtnText: { color: "#fff", fontSize: 16, fontWeight: "800" }, - - modalScrim: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0,0,0,0.72)", alignItems: "center", justifyContent: "center", padding: 24 }, - modalCard: { backgroundColor: "#1a1010", borderWidth: 2, borderColor: theme.dangerBright, borderRadius: 18, padding: 22, width: "100%", maxWidth: 380 }, - modalWarn: { fontSize: 40, textAlign: "center" }, - modalTitle: { color: "#fff", fontSize: 20, fontWeight: "900", textAlign: "center", marginTop: 4, marginBottom: 12 }, - modalLine: { color: "#f0d9d9", fontSize: 14, lineHeight: 20 }, - cancelBtn: { paddingVertical: 14, alignItems: "center", marginTop: 6 }, - cancelText: { color: theme.textDim, fontSize: 16, fontWeight: "700" }, -}); diff --git a/app/components/SideMenu.tsx b/app/components/SideMenu.tsx index 890027b..a91600e 100644 --- a/app/components/SideMenu.tsx +++ b/app/components/SideMenu.tsx @@ -1,13 +1,16 @@ import { useEffect, useRef } from "react"; -import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions } from "react-native"; +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"; -const ITEMS: { label: string; icon: string; route: string; seg: string }[] = [ +// 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: "/comp", seg: "comp" }, + { label: "Admin (crush33)", icon: "🔐", route: "", seg: "__admin", external: CRUSH_URL }, { label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" }, ]; @@ -32,8 +35,12 @@ export default function SideMenu({ visible, onClose }: { visible: boolean; onClo ]).start(); }, [visible, panelW, tx, fade]); - const go = (item: { route: string; seg: string }) => { + 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); }; diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts index 8c55c0e..b6efcba 100644 --- a/backend/src/routes/portal.ts +++ b/backend/src/routes/portal.ts @@ -103,92 +103,345 @@ const PAGE = ` -Camp Scan — Comp Tickets +Camp Scan — Admin (crush33) -
-
- -

Comp Ticket Portal

-

Entry-only tickets for workers & guests

-
+
+ +

Admin · crush33

+

Admin-only area. Enter the shared portal password.

+ + +
+
- - +
+
+
🐻 Admin · crush33
+
Lock 🔒
+
+
+
+ + + +
+
+ +
+

Comp tickets

+

Entry-only tickets for guests & staff.

+ +
+ 🎫 Guest🛠️ Worker🎭 Performer🙌 Volunteer🎤 Speaker +
+ + + + + +
+
+ Ticket QR +
+
+
+
+
- - + +
+

Donor lookup

+

🔒 Admin only · private donor info. Search by name, email, phone, address, bear name…

+
+ + +
+
+
+
- - + +
+

Actions

+

Event-management tools. These change live data — read the warnings.

+
+
Active event table
+
loading…
+
+
- - +
+

🧹 Wipe the slate clean

+

Permanently deletes every ticket and every check-in in the active event table. Use before a run-through or a fresh event.

+
  • Does NOT affect donor data.
  • Cannot be undone.
+ +
- -
+
+

🔀 Switch event table

+

Point the scanner at a different NocoDB table — start a new event on a fresh table while keeping the current one intact.

+
  • Create the new table first (duplicate the current one's structure in NocoDB — keep the Id column).
  • The current event's data is NOT deleted, just no longer shown.
+ + + + + +
+
+
+
+
-
- Ticket QR -
-
-
- +
+
`;