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 { 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 { router, useSegments } from "expo-router";
|
||||||
import { useAuth } from "../lib/auth";
|
import { useAuth } from "../lib/auth";
|
||||||
import { theme } from "../lib/theme";
|
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: "Scanner", icon: "📷", route: "/", seg: "" },
|
||||||
{ label: "Event report", icon: "📊", route: "/stats", seg: "stats" },
|
{ 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" },
|
{ label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -32,8 +35,12 @@ export default function SideMenu({ visible, onClose }: { visible: boolean; onClo
|
||||||
]).start();
|
]).start();
|
||||||
}, [visible, panelW, tx, fade]);
|
}, [visible, panelW, tx, fade]);
|
||||||
|
|
||||||
const go = (item: { route: string; seg: string }) => {
|
const go = (item: { route: string; seg: string; external?: string }) => {
|
||||||
onClose();
|
onClose();
|
||||||
|
if (item.external) {
|
||||||
|
Linking.openURL(item.external).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (item.seg !== current) router.replace(item.route as any);
|
if (item.seg !== current) router.replace(item.route as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,92 +103,345 @@ const PAGE = `<!doctype html>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#0f1a12" />
|
<meta name="theme-color" content="#0f1a12" />
|
||||||
<title>Camp Scan — Comp Tickets</title>
|
<title>Camp Scan — Admin (crush33)</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; }
|
:root { color-scheme: dark; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
|
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; }
|
a { color: #58d68d; }
|
||||||
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; }
|
|
||||||
label { display: block; font-size: 13px; color: #9db3a4; margin: 14px 0 5px; }
|
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; }
|
input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 12px 14px; color: #eaf2ec; font-size: 15px; }
|
||||||
button { width: 100%; background: #25c05a; color: #06210f; font-weight: 800; font-size: 18px; border: none; padding: 15px; border-radius: 13px; margin-top: 18px; }
|
.btn { background: #25c05a; color: #06210f; font-weight: 800; font-size: 16px; border: none; padding: 13px 18px; border-radius: 12px; cursor: pointer; }
|
||||||
button:disabled { opacity: 0.5; }
|
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
.msg { margin-top: 14px; font-size: 15px; font-weight: 600; text-align: center; min-height: 20px; }
|
.btn-red { background: #e04343; color: #fff; }
|
||||||
.err { color: #e04343; }
|
.btn-ghost { background: transparent; color: #eaf2ec; border: 1px solid #2e7d32; }
|
||||||
.ok { color: #58d68d; }
|
.msg { margin-top: 12px; font-size: 14px; font-weight: 600; min-height: 18px; }
|
||||||
.result { display: none; text-align: center; margin-top: 18px; background: #16241a; border: 1px solid #24382a; border-radius: 14px; padding: 18px; }
|
.err { color: #e04343; } .ok { color: #58d68d; }
|
||||||
.result img { width: 220px; height: 220px; background: #fff; border-radius: 10px; padding: 8px; }
|
.mono { font-family: ui-monospace, Menlo, monospace; }
|
||||||
.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; }
|
/* Unlock */
|
||||||
.hint { color: #6c8f74; font-size: 12px; text-align: center; margin-top: 10px; }
|
#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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div id="unlock">
|
||||||
<header>
|
<div class="logo">🐻</div>
|
||||||
<div class="logo">🐻</div>
|
<h1>Admin · crush33</h1>
|
||||||
<h1>Comp Ticket Portal</h1>
|
<p class="sub">Admin-only area. Enter the shared portal password.</p>
|
||||||
<p class="sub">Entry-only tickets for workers & guests</p>
|
<input id="pw" type="password" autocomplete="current-password" placeholder="Portal password" />
|
||||||
</header>
|
<button class="btn" id="unlockBtn">Unlock</button>
|
||||||
|
<div id="unlockMsg" class="msg" style="text-align:center"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>Portal password</label>
|
<div id="hub">
|
||||||
<input id="pw" type="password" autocomplete="current-password" placeholder="Shared admin password" />
|
<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>
|
<!-- Donors -->
|
||||||
<select id="type">
|
<div class="sec" id="sec-donors">
|
||||||
<option>Guest</option><option>Worker</option><option>Performer</option>
|
<h2>Donor lookup</h2>
|
||||||
<option>Volunteer</option><option>Speaker</option>
|
<p class="lead">🔒 Admin only · private donor info. Search by name, email, phone, address, bear name…</p>
|
||||||
</select>
|
<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>
|
<!-- Actions -->
|
||||||
<input id="name" type="text" autocomplete="off" placeholder="Attendee name" />
|
<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>
|
<div class="danger">
|
||||||
<input id="email" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" />
|
<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 class="danger">
|
||||||
<div id="msg" class="msg"></div>
|
<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">
|
<div class="scrim" id="scrim">
|
||||||
<img id="qr" alt="Ticket QR" />
|
<div class="modal">
|
||||||
<div class="code" id="rcode"></div>
|
<div class="warn">⚠️</div>
|
||||||
<div class="who" id="rwho"></div>
|
<h3 id="mTitle"></h3>
|
||||||
<div class="hint" id="rmail"></div>
|
<pre id="mBody"></pre>
|
||||||
<button id="another" style="background:transparent;color:#eaf2ec;border:1px solid #2e7d32;font-size:15px;">Create another</button>
|
<button class="btn btn-red" id="mConfirm"></button>
|
||||||
|
<button class="cancel" id="mCancel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var $ = function (id) { return document.getElementById(id); };
|
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 () {
|
function api(path, body) {
|
||||||
var pw = $("pw").value, name = $("name").value.trim(), email = $("email").value.trim(), type = $("type").value;
|
return fetch(path, { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
if (!pw) return setMsg("Enter the portal password.");
|
body: JSON.stringify(Object.assign({ password: PW }, body || {})) })
|
||||||
if (!name || !email) return setMsg("Name and email are required.");
|
.then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); });
|
||||||
$("go").disabled = true; setMsg("Creating…", true);
|
}
|
||||||
fetch("/api/portal/create-ticket", {
|
function relock(m) { PW = ""; $("hub").style.display = "none"; $("unlock").style.display = "block";
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
$("unlockMsg").textContent = m || ""; $("unlockMsg").className = "msg err"; }
|
||||||
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 }; }); })
|
// ---- Unlock ----
|
||||||
.then(function (x) {
|
function unlock() {
|
||||||
$("go").disabled = false;
|
var pw = $("pw").value;
|
||||||
if (x.s === 401) return setMsg("Wrong password.");
|
if (!pw) { $("unlockMsg").textContent = "Enter the password."; $("unlockMsg").className = "msg err"; return; }
|
||||||
if (x.s !== 200 || !x.d.ok) return setMsg(x.d.detail || x.d.error || "Failed to create ticket.");
|
$("unlockBtn").disabled = true; $("unlockMsg").textContent = "Checking…"; $("unlockMsg").className = "msg ok";
|
||||||
setMsg("");
|
fetch("/api/portal/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pw }) })
|
||||||
$("qr").src = x.d.qr; $("rcode").textContent = x.d.code;
|
.then(function (r) { return r.status; })
|
||||||
$("rwho").textContent = x.d.type + " · " + x.d.name;
|
.then(function (s) {
|
||||||
$("rmail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — show/screenshot this QR.";
|
$("unlockBtn").disabled = false;
|
||||||
$("result").style.display = "block";
|
if (s !== 200) { $("unlockMsg").textContent = "Wrong password."; $("unlockMsg").className = "msg err"; return; }
|
||||||
$("name").value = ""; $("email").value = "";
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue