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>
This commit is contained in:
Hank 2026-07-23 01:49:15 +00:00
parent 3a3119e324
commit 1c8cb47209
3 changed files with 328 additions and 613 deletions

View file

@ -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<string, string> = {
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<Section>("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 (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
<Pressable onPress={openMenu} hitSlop={12}>
<Text style={styles.hamburger}></Text>
</Pressable>
<Text style={styles.brand}>Admin · crush33</Text>
<View style={{ width: 60 }} />
</View>
{!unlocked ? (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={{ padding: 20 }}>
<Text style={styles.lead}>Admin-only area. Enter the shared portal password to unlock.</Text>
<Text style={styles.label}>Portal password</Text>
<TextInput
style={styles.input}
secureTextEntry
value={password}
onChangeText={setPassword}
placeholder="Shared admin password"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="go"
onSubmitEditing={unlock}
/>
{!!error && <Text style={styles.error}>{error}</Text>}
<Pressable style={[styles.btn, (busy || !password) && styles.btnOff]} onPress={unlock} disabled={busy || !password}>
<Text style={styles.btnText}>{busy ? "Checking…" : "Unlock"}</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
) : (
<View style={styles.body}>
<View style={styles.sidebar}>
{NAV.map((n) => {
const active = section === n.key;
return (
<Pressable key={n.key} style={[styles.navItem, active && styles.navItemOn]} onPress={() => setSection(n.key)}>
<Text style={styles.navIcon}>{n.icon}</Text>
<Text style={[styles.navLabel, active && styles.navLabelOn]}>{n.label}</Text>
</Pressable>
);
})}
</View>
<View style={styles.content}>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
{section === "comp" && <CompSection password={password} operator={operator} onRelock={relock} />}
{section === "donors" && <DonorSection password={password} onRelock={relock} />}
{section === "actions" && <ActionsSection password={password} onRelock={relock} />}
</KeyboardAvoidingView>
</View>
</View>
)}
</SafeAreaView>
);
}
/* ---------------- 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<PortalTicket | null>(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 (
<ScrollView contentContainerStyle={styles.pad}>
<Text style={styles.h1}>Comp tickets</Text>
<Text style={styles.sub}>Entry-only tickets for guests &amp; staff.</Text>
<Text style={styles.label}>Ticket type</Text>
<View style={styles.types}>
{TYPES.map((t) => (
<Pressable key={t} style={[styles.typePill, type === t && styles.typePillOn]} onPress={() => setType(t)}>
<Text style={[styles.typePillText, type === t && styles.typePillTextOn]}>{(TYPE_ICON[t] ?? "🎫") + " " + t}</Text>
</Pressable>
))}
</View>
<Text style={styles.label}>Full name</Text>
<TextInput style={styles.input} value={name} onChangeText={setName} placeholder="Attendee name" placeholderTextColor={theme.textDim} autoCapitalize="words" />
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Where to send the ticket"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
{!!error && <Text style={styles.error}>{error}</Text>}
<Pressable style={[styles.btn, (busy || !name.trim() || !email.trim()) && styles.btnOff]} onPress={create} disabled={busy || !name.trim() || !email.trim()}>
<Text style={styles.btnText}>{busy ? "Creating…" : `Create ${type} ticket`}</Text>
</Pressable>
{result && (
<View style={styles.result}>
<Image source={{ uri: result.qr }} style={styles.qr} />
<Text style={styles.rcode}>{result.code}</Text>
<Text style={styles.rwho}>{result.type} · {result.name}</Text>
<Text style={styles.rmail}>{result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"}</Text>
</View>
)}
</ScrollView>
);
}
/* ---------------- 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<DonorSearchResult[] | null>(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 (
<ScrollView contentContainerStyle={styles.pad} keyboardShouldPersistTaps="handled">
<Text style={styles.h1}>Donor lookup</Text>
<Text style={styles.sub}>🔒 Admin only · private donor info. Search by name, email, phone, address, bear name</Text>
<View style={styles.searchRow}>
<TextInput
style={[styles.input, { flex: 1, marginBottom: 0 }]}
value={query}
onChangeText={setQuery}
placeholder="Search donors…"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
onSubmitEditing={run}
/>
<Pressable style={[styles.searchBtn, (busy || query.trim().length < 2) && styles.btnOff]} onPress={run} disabled={busy || query.trim().length < 2}>
<Text style={styles.searchBtnText}>{busy ? "…" : "Search"}</Text>
</Pressable>
</View>
{!!error && <Text style={styles.error}>{error}</Text>}
{results !== null && !busy && results.length === 0 && <Text style={styles.sub}>No donors match {query.trim()}.</Text>}
{results?.map((d, i) => (
<View key={i} style={styles.donorCard}>
<View style={styles.donorHead}>
<Text style={styles.donorName}>{d.name || d.email || "(unnamed)"}</Text>
{d.lifetime != null && <Text style={styles.donorAmt}>{money(d.lifetime)}</Text>}
</View>
{!!d.bearName && <Text style={styles.donorLine}>🐻 {d.bearName}</Text>}
{!!d.email && <Text style={styles.donorLine}> {d.email}</Text>}
{!!d.altEmail && <Text style={styles.donorLine}> {d.altEmail} (alt)</Text>}
{!!d.phone && <Text style={styles.donorLine}>📞 {d.phone}</Text>}
{!!d.address && <Text style={styles.donorLine}>🏠 {d.address}</Text>}
<View style={styles.donorTags}>
<Text style={styles.donorSource}>{d.source === "master" ? "directory" : "transactions"}</Text>
{d.tags.map((t) => (
<Text key={t} style={styles.donorTag}>{t}</Text>
))}
</View>
</View>
))}
</ScrollView>
);
}
/* ---------------- Actions (danger zone) ---------------- */
function ActionsSection({ password, onRelock }: { password: string; onRelock: () => void }) {
const [status, setStatus] = useState<AdminStatus | null>(null);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState("");
const [confirm, setConfirm] = useState<null | "wipe" | "switch">(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 (
<ScrollView contentContainerStyle={styles.pad} keyboardShouldPersistTaps="handled">
<Text style={styles.h1}>Actions</Text>
<Text style={styles.sub}>Event-management tools. These change live data read the warnings.</Text>
{/* Current status */}
<View style={styles.statusBox}>
<View style={styles.statusRow}>
<Text style={styles.statusLabel}>Active event table</Text>
<Pressable onPress={refresh} hitSlop={8}>
<Text style={styles.refresh}>{loading ? "…" : "↻"}</Text>
</Pressable>
</View>
{status ? (
<>
<Text style={styles.statusVal}>tickets: {status.tickets.tableId} · {status.tickets.count} records</Text>
<Text style={styles.statusVal}>audit: {status.audit.tableId ?? "—"} · {status.audit.count} records</Text>
</>
) : (
<Text style={styles.statusVal}>{loading ? "loading…" : "—"}</Text>
)}
</View>
{!!msg && <Text style={styles.msg}>{msg}</Text>}
{/* Wipe slate */}
<View style={styles.dangerCard}>
<Text style={styles.dangerTitle}>🧹 Wipe the slate clean</Text>
<Text style={styles.dangerBody}>
Permanently deletes <Text style={styles.bold}>every ticket and every check-in</Text> in the active event
table. Use this to reset before a run-through or a fresh event.
</Text>
<Text style={styles.dangerBullet}> Does NOT affect donor data.</Text>
<Text style={styles.dangerBullet}> Cannot be undone.</Text>
<Pressable style={styles.redBtn} onPress={() => { setMsg(""); setConfirm("wipe"); }}>
<Text style={styles.redBtnText}>Wipe slate</Text>
</Pressable>
</View>
{/* Switch table */}
<View style={styles.dangerCard}>
<Text style={styles.dangerTitle}>🔀 Switch event table</Text>
<Text style={styles.dangerBody}>
Point the scanner at a <Text style={styles.bold}>different NocoDB table</Text> e.g. to start a new event on
a fresh table while keeping the current one intact.
</Text>
<Text style={styles.dangerBullet}> Create the new table first (duplicate the current one's structure in NocoDB keep the Id column).</Text>
<Text style={styles.dangerBullet}> The current event's data is NOT deleted, just no longer shown.</Text>
<Text style={styles.label}>New tickets table ID</Text>
<TextInput style={styles.input} value={newTickets} onChangeText={setNewTickets} placeholder="e.g. mv1a2b3c…" placeholderTextColor={theme.textDim} autoCapitalize="none" autoCorrect={false} />
<Text style={styles.label}>New audit table ID (optional)</Text>
<TextInput style={styles.input} value={newAudit} onChangeText={setNewAudit} placeholder="leave blank to keep current" placeholderTextColor={theme.textDim} autoCapitalize="none" autoCorrect={false} />
<Pressable style={[styles.redBtn, !newTickets.trim() && styles.btnOff]} onPress={() => { setMsg(""); setConfirm("switch"); }} disabled={!newTickets.trim()}>
<Text style={styles.redBtnText}>Switch table</Text>
</Pressable>
</View>
{confirm === "wipe" && (
<ConfirmModal
title="Wipe the slate clean?"
lines={[
`This will PERMANENTLY DELETE all data in the active event table:`,
`${status?.tickets.count ?? "?"} ticket records (${status?.tickets.tableId ?? "?"})`,
`${status?.audit.count ?? "?"} check-in / audit records`,
``,
`Donor data is not touched. This CANNOT be undone.`,
]}
confirmLabel={busy ? "Wiping…" : "Yes, delete everything"}
busy={busy}
onConfirm={doWipe}
onCancel={() => setConfirm(null)}
/>
)}
{confirm === "switch" && (
<ConfirmModal
title="Switch the active event table?"
lines={[
`The scanner will start using:`,
`• tickets → ${newTickets.trim()}`,
newAudit.trim() ? `• audit → ${newAudit.trim()}` : `• audit → unchanged`,
``,
`The current event (${status?.tickets.tableId ?? "?"}, ${status?.tickets.count ?? "?"} records) stays intact but will no longer be shown until you switch back. Purchases and scans will go to the new table.`,
]}
confirmLabel={busy ? "Switching…" : "Yes, switch table"}
busy={busy}
onConfirm={doSwitch}
onCancel={() => setConfirm(null)}
/>
)}
</ScrollView>
);
}
function ConfirmModal({
title,
lines,
confirmLabel,
busy,
onConfirm,
onCancel,
}: {
title: string;
lines: string[];
confirmLabel: string;
busy: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<View style={styles.modalScrim}>
<View style={styles.modalCard}>
<Text style={styles.modalWarn}></Text>
<Text style={styles.modalTitle}>{title}</Text>
{lines.map((l, i) => (
<Text key={i} style={styles.modalLine}>{l}</Text>
))}
<Pressable style={[styles.redBtn, busy && styles.btnOff]} onPress={onConfirm} disabled={busy}>
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.redBtnText}>{confirmLabel}</Text>}
</Pressable>
<Pressable style={styles.cancelBtn} onPress={onCancel} disabled={busy}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
</View>
</View>
);
}
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" },
});

View file

@ -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);
};

View file

@ -103,92 +103,345 @@ const PAGE = `<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f1a12" />
<title>Camp Scan Comp Tickets</title>
<title>Camp Scan Admin (crush33)</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
.wrap { max-width: 460px; margin: 0 auto; padding: 28px 20px 64px; }
header { text-align: center; margin-bottom: 22px; }
.logo { font-size: 52px; }
h1 { font-size: 22px; margin: 8px 0 2px; }
.sub { color: #9db3a4; font-size: 14px; margin: 0; }
a { color: #58d68d; }
label { display: block; font-size: 13px; color: #9db3a4; margin: 14px 0 5px; }
input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px; color: #eaf2ec; font-size: 16px; }
button { width: 100%; background: #25c05a; color: #06210f; font-weight: 800; font-size: 18px; border: none; padding: 15px; border-radius: 13px; margin-top: 18px; }
button:disabled { opacity: 0.5; }
.msg { margin-top: 14px; font-size: 15px; font-weight: 600; text-align: center; min-height: 20px; }
.err { color: #e04343; }
.ok { color: #58d68d; }
.result { display: none; text-align: center; margin-top: 18px; background: #16241a; border: 1px solid #24382a; border-radius: 14px; padding: 18px; }
.result img { width: 220px; height: 220px; background: #fff; border-radius: 10px; padding: 8px; }
.result .code { font-family: ui-monospace, Menlo, monospace; font-size: 20px; letter-spacing: 2px; margin: 12px 0 4px; color: #58d68d; }
.result .who { font-size: 16px; color: #c4d6c9; }
.hint { color: #6c8f74; font-size: 12px; text-align: center; margin-top: 10px; }
input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 12px 14px; color: #eaf2ec; font-size: 15px; }
.btn { background: #25c05a; color: #06210f; font-weight: 800; font-size: 16px; border: none; padding: 13px 18px; border-radius: 12px; cursor: pointer; }
.btn:disabled { opacity: 0.5; cursor: default; }
.btn-red { background: #e04343; color: #fff; }
.btn-ghost { background: transparent; color: #eaf2ec; border: 1px solid #2e7d32; }
.msg { margin-top: 12px; font-size: 14px; font-weight: 600; min-height: 18px; }
.err { color: #e04343; } .ok { color: #58d68d; }
.mono { font-family: ui-monospace, Menlo, monospace; }
/* Unlock */
#unlock { max-width: 420px; margin: 0 auto; padding: 48px 20px; text-align: center; }
#unlock .logo { font-size: 52px; }
#unlock h1 { font-size: 22px; margin: 8px 0 4px; }
#unlock .sub { color: #9db3a4; font-size: 14px; }
#unlock input { text-align: center; margin-top: 18px; }
#unlock .btn { width: 100%; margin-top: 16px; }
/* Hub */
#hub { display: none; min-height: 100vh; }
.top { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; border-bottom: 1px solid #24382a; }
.top .brand { font-weight: 800; font-size: 17px; }
.top .lock { color: #9db3a4; font-size: 13px; cursor: pointer; }
.layout { display: flex; align-items: flex-start; }
.side { width: 190px; flex: none; border-right: 1px solid #24382a; padding: 12px 0; min-height: calc(100vh - 50px); }
.nav { display: flex; align-items: center; gap: 10px; padding: 13px 18px; color: #9db3a4; cursor: pointer; border-left: 3px solid transparent; font-weight: 700; font-size: 15px; }
.nav .i { font-size: 18px; }
.nav.on { color: #eaf2ec; background: #16241a; border-left-color: #2e7d32; }
.main { flex: 1; padding: 22px 26px 64px; max-width: 760px; }
.sec { display: none; }
.sec.on { display: block; }
h2 { font-size: 22px; margin: 0 0 4px; }
.lead { color: #9db3a4; font-size: 14px; margin: 0 0 8px; line-height: 1.5; }
.card { background: #16241a; border: 1px solid #24382a; border-radius: 14px; padding: 16px; margin-top: 14px; }
.pills { display: flex; flex-wrap: wrap; gap: 8px; }
.pill { border: 1px solid #24382a; background: #16241a; border-radius: 999px; padding: 8px 14px; cursor: pointer; font-weight: 700; font-size: 14px; color: #9db3a4; }
.pill.on { background: #2e7d32; border-color: #2e7d32; color: #fff; }
.row { display: flex; gap: 8px; align-items: center; }
.result { display: none; text-align: center; margin-top: 16px; }
.result img { width: 200px; height: 200px; background: #fff; border-radius: 10px; padding: 8px; }
.result .code { font-size: 20px; letter-spacing: 2px; margin: 10px 0 2px; color: #58d68d; }
/* Donor cards */
.donor { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 13px 15px; margin-top: 11px; }
.donor .h { display: flex; justify-content: space-between; align-items: center; }
.donor .nm { font-weight: 800; font-size: 16px; }
.donor .amt { color: #58d68d; font-weight: 800; }
.donor .ln { color: #9db3a4; font-size: 14px; margin-top: 3px; }
.tags { margin-top: 8px; }
.tag { display: inline-block; background: #1b5e20; color: #fff; border-radius: 6px; padding: 2px 7px; font-size: 12px; margin-right: 5px; }
.src { display: inline-block; border: 1px solid #24382a; border-radius: 6px; padding: 2px 6px; font-size: 11px; color: #9db3a4; text-transform: uppercase; margin-right: 5px; }
/* Danger */
.danger { background: #241717; border: 1px solid #8f1d1d; border-radius: 14px; padding: 16px; margin-top: 18px; }
.danger h3 { color: #ff9a9a; margin: 0 0 6px; font-size: 17px; }
.danger p, .danger li { color: #e9cfcf; font-size: 14px; line-height: 1.5; }
.danger ul { margin: 6px 0 0; padding-left: 20px; }
.status { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px; }
.status .k { color: #9db3a4; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
.status .v { font-size: 14px; margin-top: 5px; }
/* Modal */
.scrim { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.72); align-items: center; justify-content: center; padding: 20px; z-index: 10; }
.scrim.on { display: flex; }
.modal { background: #1a1010; border: 2px solid #e04343; border-radius: 18px; padding: 22px; max-width: 420px; width: 100%; }
.modal .warn { font-size: 40px; text-align: center; }
.modal h3 { text-align: center; margin: 4px 0 12px; font-size: 20px; }
.modal pre { white-space: pre-wrap; color: #f0d9d9; font-size: 14px; line-height: 1.55; font-family: inherit; margin: 0; }
.modal .btn { width: 100%; margin-top: 16px; }
.modal .cancel { width: 100%; margin-top: 8px; background: transparent; border: none; color: #9db3a4; font-weight: 700; font-size: 15px; padding: 12px; cursor: pointer; }
@media (max-width: 640px) {
.side { width: 74px; }
.nav { flex-direction: column; gap: 3px; padding: 12px 4px; font-size: 11px; text-align: center; }
.main { padding: 18px 14px 48px; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">🐻</div>
<h1>Comp Ticket Portal</h1>
<p class="sub">Entry-only tickets for workers &amp; guests</p>
</header>
<div id="unlock">
<div class="logo">🐻</div>
<h1>Admin · crush33</h1>
<p class="sub">Admin-only area. Enter the shared portal password.</p>
<input id="pw" type="password" autocomplete="current-password" placeholder="Portal password" />
<button class="btn" id="unlockBtn">Unlock</button>
<div id="unlockMsg" class="msg" style="text-align:center"></div>
</div>
<label>Portal password</label>
<input id="pw" type="password" autocomplete="current-password" placeholder="Shared admin password" />
<div id="hub">
<div class="top">
<div class="brand">🐻 Admin · crush33</div>
<div class="lock" id="relock">Lock 🔒</div>
</div>
<div class="layout">
<div class="side">
<div class="nav on" data-sec="comp"><span class="i">🎟</span> Comp tickets</div>
<div class="nav" data-sec="donors"><span class="i">🔎</span> Donor lookup</div>
<div class="nav" data-sec="actions"><span class="i"></span> Actions</div>
</div>
<div class="main">
<!-- Comp -->
<div class="sec on" id="sec-comp">
<h2>Comp tickets</h2>
<p class="lead">Entry-only tickets for guests &amp; staff.</p>
<label>Ticket type</label>
<div class="pills" id="typePills">
<span class="pill on">🎫 Guest</span><span class="pill">🛠 Worker</span><span class="pill">🎭 Performer</span><span class="pill">🙌 Volunteer</span><span class="pill">🎤 Speaker</span>
</div>
<label>Full name</label>
<input id="cName" type="text" autocomplete="off" placeholder="Attendee name" />
<label>Email</label>
<input id="cEmail" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" />
<button class="btn" id="cGo" style="width:100%;margin-top:18px">Create ticket</button>
<div id="cMsg" class="msg"></div>
<div id="cResult" class="result">
<img id="cQr" alt="Ticket QR" />
<div class="code mono" id="cCode"></div>
<div class="lead" id="cWho"></div>
<div class="lead" id="cMail"></div>
</div>
</div>
<label>Ticket type</label>
<select id="type">
<option>Guest</option><option>Worker</option><option>Performer</option>
<option>Volunteer</option><option>Speaker</option>
</select>
<!-- Donors -->
<div class="sec" id="sec-donors">
<h2>Donor lookup</h2>
<p class="lead">🔒 Admin only · private donor info. Search by name, email, phone, address, bear name</p>
<div class="row">
<input id="dQ" type="text" autocomplete="off" placeholder="Search donors…" style="flex:1" />
<button class="btn" id="dGo">Search</button>
</div>
<div id="dMsg" class="msg"></div>
<div id="dResults"></div>
</div>
<label>Full name</label>
<input id="name" type="text" autocomplete="off" placeholder="Attendee name" />
<!-- Actions -->
<div class="sec" id="sec-actions">
<h2>Actions</h2>
<p class="lead">Event-management tools. These change live data read the warnings.</p>
<div class="status">
<div class="k">Active event table <span id="aRefresh" style="float:right;cursor:pointer"></span></div>
<div class="v mono" id="aStatus">loading</div>
</div>
<div id="aMsg" class="msg"></div>
<label>Email</label>
<input id="email" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" />
<div class="danger">
<h3>🧹 Wipe the slate clean</h3>
<p>Permanently deletes <b>every ticket and every check-in</b> in the active event table. Use before a run-through or a fresh event.</p>
<ul><li>Does NOT affect donor data.</li><li>Cannot be undone.</li></ul>
<button class="btn btn-red" id="wipeBtn" style="width:100%">Wipe slate</button>
</div>
<button id="go">Create ticket</button>
<div id="msg" class="msg"></div>
<div class="danger">
<h3>🔀 Switch event table</h3>
<p>Point the scanner at a <b>different NocoDB table</b> start a new event on a fresh table while keeping the current one intact.</p>
<ul><li>Create the new table first (duplicate the current one's structure in NocoDB — keep the Id column).</li><li>The current event's data is NOT deleted, just no longer shown.</li></ul>
<label>New tickets table ID</label>
<input id="swTickets" type="text" autocomplete="off" placeholder="e.g. mv1a2b3c…" />
<label>New audit table ID (optional)</label>
<input id="swAudit" type="text" autocomplete="off" placeholder="leave blank to keep current" />
<button class="btn btn-red" id="switchBtn" style="width:100%;margin-top:14px">Switch table</button>
</div>
</div>
</div>
</div>
</div>
<div id="result" class="result">
<img id="qr" alt="Ticket QR" />
<div class="code" id="rcode"></div>
<div class="who" id="rwho"></div>
<div class="hint" id="rmail"></div>
<button id="another" style="background:transparent;color:#eaf2ec;border:1px solid #2e7d32;font-size:15px;">Create another</button>
<div class="scrim" id="scrim">
<div class="modal">
<div class="warn"></div>
<h3 id="mTitle"></h3>
<pre id="mBody"></pre>
<button class="btn btn-red" id="mConfirm"></button>
<button class="cancel" id="mCancel">Cancel</button>
</div>
</div>
<script>
var $ = function (id) { return document.getElementById(id); };
function setMsg(t, ok) { var m = $("msg"); m.textContent = t; m.className = "msg " + (ok ? "ok" : "err"); }
var PW = "";
var counts = { tickets: "?", audit: "?", table: "?" };
var pendingAction = null;
$("go").addEventListener("click", function () {
var pw = $("pw").value, name = $("name").value.trim(), email = $("email").value.trim(), type = $("type").value;
if (!pw) return setMsg("Enter the portal password.");
if (!name || !email) return setMsg("Name and email are required.");
$("go").disabled = true; setMsg("Creating…", true);
fetch("/api/portal/create-ticket", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw, name: name, email: email, type: type })
}).then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); })
.then(function (x) {
$("go").disabled = false;
if (x.s === 401) return setMsg("Wrong password.");
if (x.s !== 200 || !x.d.ok) return setMsg(x.d.detail || x.d.error || "Failed to create ticket.");
setMsg("");
$("qr").src = x.d.qr; $("rcode").textContent = x.d.code;
$("rwho").textContent = x.d.type + " · " + x.d.name;
$("rmail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — show/screenshot this QR.";
$("result").style.display = "block";
$("name").value = ""; $("email").value = "";
function api(path, body) {
return fetch(path, { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ password: PW }, body || {})) })
.then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); });
}
function relock(m) { PW = ""; $("hub").style.display = "none"; $("unlock").style.display = "block";
$("unlockMsg").textContent = m || ""; $("unlockMsg").className = "msg err"; }
// ---- Unlock ----
function unlock() {
var pw = $("pw").value;
if (!pw) { $("unlockMsg").textContent = "Enter the password."; $("unlockMsg").className = "msg err"; return; }
$("unlockBtn").disabled = true; $("unlockMsg").textContent = "Checking…"; $("unlockMsg").className = "msg ok";
fetch("/api/portal/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pw }) })
.then(function (r) { return r.status; })
.then(function (s) {
$("unlockBtn").disabled = false;
if (s !== 200) { $("unlockMsg").textContent = "Wrong password."; $("unlockMsg").className = "msg err"; return; }
PW = pw; $("unlockMsg").textContent = ""; $("unlock").style.display = "none"; $("hub").style.display = "block";
loadStatus();
})
.catch(function () { $("go").disabled = false; setMsg("Network error."); });
.catch(function () { $("unlockBtn").disabled = false; $("unlockMsg").textContent = "Network error."; });
}
$("unlockBtn").addEventListener("click", unlock);
$("pw").addEventListener("keydown", function (e) { if (e.key === "Enter") unlock(); });
$("relock").addEventListener("click", function () { relock(""); $("unlockMsg").textContent = ""; });
// ---- Nav ----
var navs = document.querySelectorAll(".nav");
for (var i = 0; i < navs.length; i++) navs[i].addEventListener("click", function () {
var sec = this.getAttribute("data-sec");
for (var j = 0; j < navs.length; j++) navs[j].classList.toggle("on", navs[j] === this);
var secs = document.querySelectorAll(".sec");
for (var k = 0; k < secs.length; k++) secs[k].classList.toggle("on", secs[k].id === "sec-" + sec);
}.bind(navs[i]));
// ---- Comp ----
var compType = "Guest";
var pills = document.querySelectorAll("#typePills .pill");
for (var p = 0; p < pills.length; p++) pills[p].addEventListener("click", function () {
for (var q = 0; q < pills.length; q++) pills[q].classList.toggle("on", pills[q] === this);
compType = this.textContent.replace(/^[^A-Za-z]+/, "").trim();
}.bind(pills[p]));
function setC(t, ok) { $("cMsg").textContent = t; $("cMsg").className = "msg " + (ok ? "ok" : "err"); }
$("cGo").addEventListener("click", function () {
var name = $("cName").value.trim(), email = $("cEmail").value.trim();
if (!name || !email) return setC("Name and email are required.");
$("cGo").disabled = true; setC("Creating…", true);
api("/api/portal/create-ticket", { name: name, email: email, type: compType }).then(function (x) {
$("cGo").disabled = false;
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200 || !x.d.ok) return setC(x.d.detail || x.d.error || "Failed.");
setC("");
$("cQr").src = x.d.qr; $("cCode").textContent = x.d.code;
$("cWho").textContent = x.d.type + " · " + x.d.name;
$("cMail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — screenshot this QR.";
$("cResult").style.display = "block"; $("cName").value = ""; $("cEmail").value = "";
}).catch(function () { $("cGo").disabled = false; setC("Network error."); });
});
$("another").addEventListener("click", function () { $("result").style.display = "none"; $("name").focus(); });
// ---- Donors ----
function esc(s) { return String(s == null ? "" : s).replace(/[&<>]/g, function (c) { return c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"; }); }
function money(n) { return "$" + Math.round(n).toLocaleString(); }
function searchDonors() {
var q = $("dQ").value.trim();
if (q.length < 2) { $("dMsg").textContent = "Type at least 2 characters."; $("dMsg").className = "msg err"; return; }
$("dGo").disabled = true; $("dMsg").textContent = "Searching…"; $("dMsg").className = "msg ok"; $("dResults").innerHTML = "";
api("/api/admin/donor-search", { query: q }).then(function (x) {
$("dGo").disabled = false;
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200) { $("dMsg").textContent = (x.d && (x.d.detail || x.d.error)) || "Search failed."; $("dMsg").className = "msg err"; return; }
var r = x.d.results || [];
$("dMsg").textContent = r.length ? r.length + " result" + (r.length === 1 ? "" : "s") : "No donors match “" + q + "”.";
$("dMsg").className = "msg";
var html = "";
for (var i = 0; i < r.length; i++) {
var d = r[i];
html += '<div class="donor"><div class="h"><span class="nm">' + esc(d.name || d.email || "(unnamed)") + '</span>';
if (d.lifetime != null) html += '<span class="amt">' + money(d.lifetime) + '</span>';
html += '</div>';
if (d.bearName) html += '<div class="ln">🐻 ' + esc(d.bearName) + '</div>';
if (d.email) html += '<div class="ln">✉️ ' + esc(d.email) + '</div>';
if (d.altEmail) html += '<div class="ln">✉️ ' + esc(d.altEmail) + ' (alt)</div>';
if (d.phone) html += '<div class="ln">📞 ' + esc(d.phone) + '</div>';
if (d.address) html += '<div class="ln">🏠 ' + esc(d.address) + '</div>';
html += '<div class="tags"><span class="src">' + (d.source === "master" ? "directory" : "transactions") + '</span>';
for (var t = 0; t < (d.tags || []).length; t++) html += '<span class="tag">' + esc(d.tags[t]) + '</span>';
html += '</div></div>';
}
$("dResults").innerHTML = html;
}).catch(function () { $("dGo").disabled = false; $("dMsg").textContent = "Network error."; $("dMsg").className = "msg err"; });
}
$("dGo").addEventListener("click", searchDonors);
$("dQ").addEventListener("keydown", function (e) { if (e.key === "Enter") searchDonors(); });
// ---- Actions ----
function loadStatus() {
$("aStatus").textContent = "loading…";
api("/api/admin/status", {}).then(function (x) {
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200) { $("aStatus").textContent = "error"; return; }
var t = x.d.tickets, a = x.d.audit;
counts = { tickets: t.count, audit: a.count, table: t.tableId };
$("aStatus").textContent = "tickets: " + t.tableId + " · " + t.count + " records\\naudit: " + (a.tableId || "—") + " · " + a.count + " records";
}).catch(function () { $("aStatus").textContent = "network error"; });
}
$("aRefresh").addEventListener("click", loadStatus);
function aMsg(t, ok) { $("aMsg").textContent = t; $("aMsg").className = "msg " + (ok ? "ok" : "err"); }
function openModal(title, body, confirmLabel, action) {
$("mTitle").textContent = title; $("mBody").textContent = body;
$("mConfirm").textContent = confirmLabel; pendingAction = action; $("scrim").classList.add("on");
}
function closeModal() { $("scrim").classList.remove("on"); pendingAction = null; $("mConfirm").disabled = false; }
$("mCancel").addEventListener("click", closeModal);
$("mConfirm").addEventListener("click", function () { if (pendingAction) { $("mConfirm").disabled = true; pendingAction(); } });
$("wipeBtn").addEventListener("click", function () {
openModal("Wipe the slate clean?",
"This will PERMANENTLY DELETE all data in the active event table:\\n" +
"• " + counts.tickets + " ticket records (" + counts.table + ")\\n" +
"• " + counts.audit + " check-in / audit records\\n\\n" +
"Donor data is not touched. This CANNOT be undone.",
"Yes, delete everything", doWipe);
});
function doWipe() {
api("/api/admin/wipe", {}).then(function (x) {
closeModal();
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200 || !x.d.ok) return aMsg((x.d && (x.d.detail || x.d.error)) || "Wipe failed.");
aMsg("✓ Wiped " + x.d.ticketsDeleted + " tickets and " + x.d.auditDeleted + " audit rows.", true);
loadStatus();
}).catch(function () { closeModal(); aMsg("Network error."); });
}
$("switchBtn").addEventListener("click", function () {
var t = $("swTickets").value.trim(), a = $("swAudit").value.trim();
if (!t) return aMsg("Enter the new tickets table ID.");
openModal("Switch the active event table?",
"The scanner will start using:\\n• tickets → " + t + "\\n• audit → " + (a || "unchanged") + "\\n\\n" +
"The current event (" + counts.table + ", " + counts.tickets + " records) stays intact but will no longer be shown until you switch back. New purchases and scans go to the new table.",
"Yes, switch table", function () { doSwitch(t, a); });
});
function doSwitch(t, a) {
api("/api/admin/switch-table", { ticketsTableId: t, auditTableId: a || undefined }).then(function (x) {
closeModal();
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200 || !x.d.ok) return aMsg((x.d && (x.d.detail || x.d.error)) || "Switch failed.");
aMsg("✓ Now using tickets table " + x.d.tickets.tableId + ".", true);
$("swTickets").value = ""; $("swAudit").value = ""; loadStatus();
}).catch(function () { closeModal(); aMsg("Network error."); });
}
</script>
</body>
</html>`;