-
{order.items.map((it) =>
// /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 ORDERS / {filteredOrders.length}
{order.items.map((it) =>
{item.product_key}