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:
parent
3a3119e324
commit
1c8cb47209
3 changed files with 328 additions and 613 deletions
545
app/app/comp.tsx
545
app/app/comp.tsx
|
|
@ -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 & 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" },
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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 === "&" ? "&" : c === "<" ? "<" : ">"; }); }
|
||||
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>`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue