// /admin.html — admin orders panel. Customer-grouped by default with search + // sort, flat list for per-order sorting. Shares page chrome with account.jsx // (loaded headlessly before this file) via window.PageChrome/HeroStrip/etc. const { useState, useEffect, useMemo, useCallback } = React; const CUSTOMER_SORTS = [ { id: 'last_order', label: 'LAST ORDER (NEWEST)' }, { id: 'first_order', label: 'LAST ORDER (OLDEST)' }, { id: 'spent_desc', label: 'SPENT ↓' }, { id: 'spent_asc', label: 'SPENT ↑' }, { id: 'orders_desc', label: 'ORDER COUNT ↓' }, { id: 'name_asc', label: 'NAME A→Z' }, ]; const FLAT_SORTS = [ { id: 'newest', label: 'NEWEST FIRST' }, { id: 'oldest', label: 'OLDEST FIRST' }, { id: 'price_desc', label: 'PRICE ↓' }, { id: 'price_asc', label: 'PRICE ↑' }, ]; const STATUS_FILTERS = [ { id: 'all', label: 'ALL' }, { id: 'pending', label: 'PENDING' }, { id: 'paid', label: 'PAID' }, ]; function AdminApp() { const { user, isAdmin, isMod } = window.useAuthState(); const ready = window.ExecAuth && window.ExecAuth.isReady(); // ORDERS | CATALOG — picked up from ?tab= so a refresh lands you where // you were. The legacy WEBSITE tab was merged into CATALOG (status now // lives on each product row + editor), so ?tab=website redirects here. // Mods land on CATALOG by default (no ORDERS visible). Admins default // to ORDERS. const initialTab = (() => { try { const t = new URL(location.href).searchParams.get('tab'); if (t === 'catalog') return 'catalog'; if (t === 'status' || t === 'website') return 'status'; if (t === 'orders' && isAdmin) return 'orders'; } catch {} return isAdmin ? 'orders' : 'catalog'; })(); const [tab, setTabState] = useState(initialTab); const setTab = (next) => { if (next === 'orders' && !isAdmin) return; // mod safety net setTabState(next); try { const url = new URL(location.href); if (next === 'orders') url.searchParams.delete('tab'); else url.searchParams.set('tab', next); history.replaceState({}, '', url.toString()); } catch {} }; const [orders, setOrders] = useState(null); const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [grouped, setGrouped] = useState(true); const [customerSort, setCustomerSort] = useState('last_order'); const [flatSort, setFlatSort] = useState('newest'); const refresh = useCallback(async () => { try { const res = await fetch('/api/admin/orders', { credentials: 'same-origin' }); if (!res.ok) throw new Error(res.status); const data = await res.json(); setOrders(data.orders || []); } catch { setOrders([]); } }, []); useEffect(() => { if (isAdmin && tab === 'orders') refresh(); }, [isAdmin, refresh, tab]); const setKey = async (itemId, key, orderStatus) => { if (orderStatus !== 'paid') { if (!window.confirm( 'This order has not been paid yet.\n\n' + 'Are you sure you want to deliver a key now?\n\n' + 'The customer will see "REVEAL KEY" even though their payment is incomplete.' )) return false; } try { const res = await fetch(`/api/admin/orders/${itemId}/key`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), }); if (!res.ok) { alert('Save failed: ' + res.status); return false; } await refresh(); return true; } catch { alert('Save failed: network error'); return false; } }; // ---------- Derived data ---------- // IMPORTANT: every hook call MUST appear before the early-return gating // below. Calling useMemo (or any hook) only after a conditional return // makes the hook count differ between renders → "Rendered more hooks // than during the previous render" crash. Filters here are safe with // null/empty orders, so we compute unconditionally. const filteredOrders = useMemo(() => { let arr = orders || []; if (statusFilter !== 'all') arr = arr.filter((o) => o.status === statusFilter); if (search.trim()) { const q = search.trim().toLowerCase(); arr = arr.filter((o) => { const fields = [o.username, o.global_name, o.discord_id].filter(Boolean).join(' ').toLowerCase(); return fields.includes(q); }); } return arr; }, [orders, statusFilter, search]); const customers = useMemo(() => groupByCustomer(filteredOrders, customerSort), [filteredOrders, customerSort]); const flatSorted = useMemo(() => sortFlat(filteredOrders, flatSort), [filteredOrders, flatSort]); const totalRevenue = (orders || []) .filter((o) => o.status === 'paid') .reduce((s, o) => s + (o.amount_cents || 0), 0); const pendingKeys = (orders || []).reduce((n, o) => { if (o.status !== 'paid') return n; return n + o.items.filter((it) => !it.product_key).length; }, 0); const uniqueCustomers = new Set((orders || []).map((o) => o.discord_id)).size; // ---------- Gating (all hooks above this line) ---------- if (!ready) return ; if (!user) return ; if (!isAdmin && !isMod) { return (
ACCESS DENIED
Your account is not authorized for the admin panel.
↶ MY ACCOUNT
); } return (
{isAdmin && ( )} {!isAdmin && isMod && ( MODERATOR )}
{tab === 'catalog' && } {tab === 'status' && } {tab === 'orders' && <> 0}/>
// ADMIN

ORDERS / {filteredOrders.length}

STATUS
SORT
{orders === null && } {orders !== null && filteredOrders.length === 0 && ( )} {orders !== null && filteredOrders.length > 0 && grouped && (
{customers.map((c) => )}
)} {orders !== null && filteredOrders.length > 0 && !grouped && (
{flatSorted.map((o) => )}
)} }
); } // ---------- Customer card ---------- function CustomerCard({ customer, onSetKey }) { const [open, setOpen] = useState(false); return (
{open && (
{customer.orders.map((o) => )}
)}
); } function Pair({ label, value }) { return ( {label} {value} ); } // ---------- Flat order row (used in customer body AND flat view) ---------- function FlatOrder({ order, onSetKey, hideCustomer }) { const dotClass = order.status === 'paid' ? 'pulse' : ''; return (
{order.status.toUpperCase()} #{String(order.id).padStart(4, '0')} {!hideCustomer && ( {order.global_name || order.username || order.discord_id} )} {window.fmtDate(order.created_at)} {window.fmt((order.amount_cents || 0) / 100)}
); } // ---------- Key editor (set, edit, with confirms) ---------- function KeyEditor({ item, orderStatus, onSetKey }) { const [editing, setEditing] = useState(false); const [val, setVal] = useState(''); const delivered = !!item.product_key; const begin = () => { setVal(item.product_key || ''); setEditing(true); }; const cancel = () => { setEditing(false); setVal(''); }; const save = async () => { const key = val.trim(); if (!key) return; if (delivered && key === item.product_key) { setEditing(false); return; } if (delivered) { if (!window.confirm( 'OVERWRITE an already-delivered key?\n\n' + `Current: ${item.product_key}\n` + `New: ${key}\n\n` + 'The customer will see only the new key.' )) return; } const ok = await onSetKey(item.id, key, orderStatus); if (ok) { setEditing(false); setVal(''); } }; return (
  • {item.product_category} {item.product_name} · {String(item.tier).toUpperCase()}
    {!editing && delivered && (
    {item.product_key}
    )} {!editing && !delivered && ( )} {editing && (
    setVal(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cancel(); }} />
    )}
  • ); } // ---------- helpers ---------- function groupByCustomer(orders, sortId) { const map = new Map(); for (const o of orders) { const id = o.discord_id; let c = map.get(id); if (!c) { c = { discord_id: id, username: o.username, display_name: o.global_name || o.username || id, avatar_url: o.avatar_url, orders: [], total_spent: 0, last_order_at: 0, first_order_at: Infinity, pending_key_count: 0, }; map.set(id, c); } c.orders.push(o); if (o.status === 'paid') c.total_spent += (o.amount_cents || 0); c.last_order_at = Math.max(c.last_order_at, o.created_at || 0); c.first_order_at = Math.min(c.first_order_at, o.created_at || Infinity); if (o.status === 'paid') for (const it of o.items) if (!it.product_key) c.pending_key_count++; } const arr = Array.from(map.values()); for (const c of arr) c.orders.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); const cmp = { last_order: (a, b) => b.last_order_at - a.last_order_at, first_order: (a, b) => a.last_order_at - b.last_order_at, spent_desc: (a, b) => b.total_spent - a.total_spent, spent_asc: (a, b) => a.total_spent - b.total_spent, orders_desc: (a, b) => b.orders.length - a.orders.length, name_asc: (a, b) => (a.display_name || '').localeCompare(b.display_name || ''), }[sortId] || ((a, b) => b.last_order_at - a.last_order_at); arr.sort(cmp); return arr; } function sortFlat(orders, sortId) { const arr = orders.slice(); const cmp = { newest: (a, b) => (b.created_at || 0) - (a.created_at || 0), oldest: (a, b) => (a.created_at || 0) - (b.created_at || 0), price_desc: (a, b) => (b.amount_cents || 0) - (a.amount_cents || 0), price_asc: (a, b) => (a.amount_cents || 0) - (b.amount_cents || 0), }[sortId] || ((a, b) => (b.created_at || 0) - (a.created_at || 0)); arr.sort(cmp); return arr; } const __root = document.getElementById('admin-root'); if (__root) ReactDOM.createRoot(__root).render();