import { useMemo, useState } from 'react'; import { api } from '../lib/api'; import type { StoreItem, StorefrontDefinition } from '../lib/types'; import { profileImageUrl } from '../lib/types'; import { useApi } from '../lib/useApi'; import { PageHeader } from '../components/PageHeader'; import { Empty } from '../components/Empty'; import { Modal } from '../components/Modal'; import { useToast } from '../components/Toast'; import { Confirm } from '../components/Confirm'; import { currencyName, num, relativeTime } from '../lib/format'; import { Plus, RefreshCw, Search, Trash } from '../components/Icons'; // Storefronts the SPA can target when /admin/v1/storefronts hasn't // responded yet. Mirrors StoreService.GetStorefrontDefinitions on the // server side — replaced by the live response as soon as it arrives. const FALLBACK_STOREFRONTS: StorefrontDefinition[] = [ { key: 'main', storefrontType: null, displayName: 'Main watch catalog', scope: 'watch' }, { key: 'watch', storefrontType: 3, displayName: 'Watch gift-drop shelf', scope: 'watch' }, { key: 'all', storefrontType: null, displayName: 'All storefront shelves', scope: 'shared' }, { key: 'rro', storefrontType: null, displayName: 'All RRO and Rec Center shelves', scope: 'shared' }, { key: 'giftdrop:1', storefrontType: 1, displayName: 'Laser Tag', scope: 'room' }, { key: 'giftdrop:2', storefrontType: 2, displayName: 'Rec Center', scope: 'room' }, { key: 'giftdrop:100', storefrontType: 100, displayName: 'Quest - Lost Skulls', scope: 'room' }, { key: 'giftdrop:101', storefrontType: 101, displayName: 'Quest - Dracula', scope: 'room' }, { key: 'giftdrop:102', storefrontType: 102, displayName: 'Quest - Golden Trophy', scope: 'room' }, { key: 'giftdrop:103', storefrontType: 103, displayName: 'Quest - Crimson Cauldron', scope: 'room' }, { key: 'giftdrop:200', storefrontType: 200, displayName: 'Rec Royale', scope: 'room' }, { key: 'giftdrop:300', storefrontType: 300, displayName: 'Cafe', scope: 'room' }, { key: 'giftdrop:400', storefrontType: 400, displayName: 'Paintball', scope: 'room' }, { key: 'giftdrop:401', storefrontType: 401, displayName: 'Paintball - River', scope: 'room' }, { key: 'giftdrop:402', storefrontType: 402, displayName: 'Paintball - Homestead', scope: 'room' }, { key: 'giftdrop:403', storefrontType: 403, displayName: 'Paintball - Quarry', scope: 'room' }, { key: 'giftdrop:404', storefrontType: 404, displayName: 'Paintball - Clear Cut', scope: 'room' }, { key: 'giftdrop:405', storefrontType: 405, displayName: 'Paintball - Spillway', scope: 'room' }, { key: 'giftdrop:406', storefrontType: 406, displayName: 'Paintball - Sunset Drive-In', scope: 'room' }, { key: 'giftdrop:500', storefrontType: 500, displayName: 'Bowling', scope: 'room' }, { key: 'giftdrop:600', storefrontType: 600, displayName: 'Stunt Runner', scope: 'room' }, { key: 'giftdrop:700', storefrontType: 700, displayName: 'Dorm Mirror', scope: 'room' }, { key: 'season:1', storefrontType: null, displayName: 'Season 1', scope: 'season' }, ]; const CATEGORIES = ['head', 'torso', 'legs', 'feet', 'accessory', 'hair', 'face', 'consumable', 'roomtemplate']; // Sort options for the catalog list. `dir` keeps the comparator direction // out of the sort dropdown labels so "Newest first" reads naturally. type SortKey = 'name-asc' | 'name-desc' | 'price-asc' | 'price-desc' | 'updated-desc' | 'created-desc'; const SORT_OPTIONS: { key: SortKey; label: string }[] = [ { key: 'name-asc', label: 'Name (A → Z)' }, { key: 'name-desc', label: 'Name (Z → A)' }, { key: 'price-asc', label: 'Price (low → high)' }, { key: 'price-desc', label: 'Price (high → low)' }, { key: 'updated-desc', label: 'Recently updated' }, { key: 'created-desc', label: 'Recently created' }, ]; type ViewMode = 'cards' | 'table'; export function Store() { const [storefront, setStorefront] = useState(''); const [category, setCategory] = useState(''); const [search, setSearch] = useState(''); const [sort, setSort] = useState('updated-desc'); const [view, setView] = useState('cards'); const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive' | 'limited' | 'expired'>('all'); const [selected, setSelected] = useState>(new Set()); const [editing, setEditing] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); const [pendingBulk, setPendingBulk] = useState(null); const [bulkBusy, setBulkBusy] = useState(false); const toast = useToast(); // Server-side pagination is overkill for this dataset (low hundreds // of rows typically); pull a generous slice and filter / sort in the // browser so the storefront/category dropdowns don't trigger network // round-trips on every change. const qs = new URLSearchParams(); qs.set('take', '500'); const { data, loading, error, refresh } = useApi(`/storeitems?${qs}`); const { data: storefrontData } = useApi('/storefronts'); const storefronts = storefrontData ?? FALLBACK_STOREFRONTS; // Apply every filter (search / storefront / category / activeFilter) // then sort. Done in one memo so a single state change re-renders // once instead of cascading through multiple useMemos. const items = useMemo(() => { if (!data) return [] as StoreItem[]; const now = Date.now(); const q = search.trim().toLowerCase(); const filtered = data.filter(it => { if (storefront && it.storefront !== storefront) return false; if (category && it.category !== category) return false; if (q && !it.slug.toLowerCase().includes(q) && !it.displayName.toLowerCase().includes(q)) return false; if (activeFilter === 'active' && !it.isActive) return false; if (activeFilter === 'inactive' && it.isActive) return false; if (activeFilter === 'limited' && !it.isLimitedTime) return false; if (activeFilter === 'expired') { if (!it.availableUntil) return false; if (Date.parse(it.availableUntil) > now) return false; } return true; }); const cmp = (a: StoreItem, b: StoreItem) => { switch (sort) { case 'name-asc': return a.displayName.localeCompare(b.displayName); case 'name-desc': return b.displayName.localeCompare(a.displayName); case 'price-asc': return a.price - b.price; case 'price-desc': return b.price - a.price; case 'updated-desc': return Date.parse(b.updatedAt) - Date.parse(a.updatedAt); case 'created-desc': return Date.parse(b.createdAt) - Date.parse(a.createdAt); } }; return [...filtered].sort(cmp); }, [data, storefront, category, search, sort, activeFilter]); // Bulk-selection helpers — keep the Set immutable so React picks up // the change without us tracking version numbers. const toggleSelected = (id: number) => setSelected(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const allVisibleSelected = items.length > 0 && items.every(it => selected.has(it.id)); const selectAllVisible = () => setSelected(prev => { if (allVisibleSelected) { const next = new Set(prev); for (const it of items) next.delete(it.id); return next; } const next = new Set(prev); for (const it of items) next.add(it.id); return next; }); const clearSelection = () => setSelected(new Set()); const del = async () => { if (!pendingDelete) return; try { await api(`/storeitems/${pendingDelete.id}`, { method: 'DELETE' }); toast.push('Item deleted', 'success'); setPendingDelete(null); refresh(); } catch (e) { toast.push((e as Error).message, 'error'); } }; // Bulk action — sequential rather than Promise.all so the server // doesn't get hit with 50 simultaneous writes (and so a partial // failure tells us how far we got). Selected ids that aren't in the // current `data` array (e.g. removed while the user was selecting) // are skipped without error. const runBulk = async () => { if (!pendingBulk || !data) return; setBulkBusy(true); let ok = 0; let failed = 0; try { const targets = data.filter(it => selected.has(it.id)); for (const it of targets) { try { if (pendingBulk.action === 'delete') { await api(`/storeitems/${it.id}`, { method: 'DELETE' }); } else { await api(`/storeitems/${it.id}`, { method: 'PUT', body: { IsActive: pendingBulk.action === 'activate' }, }); } ok++; } catch { failed++; } } const verb = pendingBulk.action === 'delete' ? 'deleted' : pendingBulk.action === 'activate' ? 'activated' : 'deactivated'; toast.push(`${verb} ${ok}/${ok + failed}`, failed > 0 ? 'error' : 'success'); clearSelection(); setPendingBulk(null); refresh(); } finally { setBulkBusy(false); } }; return (
} /> {/* Filter / sort bar — kept on a single card so the controls share visual weight and the count + view-toggle anchor the right edge. */}
setSearch(e.target.value)} placeholder="Search slug or display name…" className="input pl-8 w-full" />
{/* Status chips — single-select. Stays on its own row so labels wrap predictably on narrow screens. */}
{(['all', 'active', 'inactive', 'limited', 'expired'] as const).map(f => ( ))}
{data ? `${items.length} of ${data.length} items` : ''}
{/* Bulk action bar — only shows when there's a selection. Floats above the grid as a sticky-feeling banner. */} {selected.size > 0 && (
{selected.size} selected
)} {error &&
{error}
} {!loading && items.length === 0 && ( )} {items.length > 0 && view === 'cards' && (
{items.map(it => ( toggleSelected(it.id)} onEdit={() => setEditing(it)} onDelete={() => setPendingDelete(it)} /> ))}
)} {items.length > 0 && view === 'table' && (
{items.map(it => ( ))}
Item Storefront Category Price State
toggleSelected(it.id)} className="size-4 accent-brand-500" />
{it.displayName}
{it.slug}
{it.storefront} {it.category} {num(it.price)} {currencyName(it.currencyType)}
)} {editing && ( setEditing(null)} onSaved={() => { setEditing(null); refresh(); }} /> )} setPendingDelete(null)} title="Delete store item" body={<>Hard-delete {pendingDelete?.displayName}? Prefer toggling IsActive=false for live items.} confirmLabel="Delete" destructive onConfirm={del} /> pendingBulk && !bulkBusy && setPendingBulk(null)} title={ pendingBulk?.action === 'delete' ? `Delete ${pendingBulk.count} items` : pendingBulk?.action === 'activate' ? `Activate ${pendingBulk?.count} items` : `Deactivate ${pendingBulk?.count} items` } body={pendingBulk?.action === 'delete' ? <>Hard-delete {pendingBulk.count} selected items? This cannot be undone. : <>Toggle {pendingBulk?.count} items to {pendingBulk?.action === 'activate' ? 'active' : 'inactive'}?} confirmLabel={bulkBusy ? 'Working…' : pendingBulk?.action === 'delete' ? 'Delete all' : 'Apply'} destructive={pendingBulk?.action === 'delete'} onConfirm={runBulk} />
); } // ── Cards / table cells ────────────────────────────────────────────── function StoreCard({ item, selected, onToggleSelected, onEdit, onDelete, }: { item: StoreItem; selected: boolean; onToggleSelected: () => void; onEdit: () => void; onDelete: () => void; }) { return (
{/* Selection checkbox — sits over the thumbnail. Only fully opaque when the item is selected OR the user is hovering the card. */} {/* Active/limited/expired badges — top-right of the thumbnail. */}
{item.displayName}
{item.slug}
{num(item.price)} {currencyName(item.currencyType)}
{item.storefront} · {item.category}
); } function ItemBadges({ item }: { item: StoreItem }) { const expired = item.availableUntil && Date.parse(item.availableUntil) < Date.now(); return (
{item.isActive ? Active : Inactive} {item.isLimitedTime && Limited} {expired && Expired}
); } // ── Thumbnail ──────────────────────────────────────────────────────── // Pulls the image from img.* with the same signed URL the watch uses // (sig=p1, optional ?width hint). Falls back to a placeholder tile // showing the filename when the image is missing or 404s — that's // surprisingly common with imported rooms whose CDN bytes never made // the trip. function Thumb({ imageName, size, fill }: { imageName: string | null; size: number; fill?: boolean }) { const [errored, setErrored] = useState(false); const url = imageName && !errored ? profileImageUrl(imageName, Math.max(size, 128)) : null; if (!url) { return (
{imageName || 'no image'}
); } return ( setErrored(true)} alt={imageName ?? ''} loading="lazy" className={`${fill ? 'w-full h-full object-cover' : ''} bg-ink-950`} style={fill ? undefined : { width: size, height: size }} /> ); } // ── Edit / create modal ────────────────────────────────────────────── function StoreItemForm({ item, storefronts, onClose, onSaved, }: { item: StoreItem | null; storefronts: StorefrontDefinition[]; onClose: () => void; onSaved: () => void; }) { const isNew = item === null; const [form, setForm] = useState({ slug: item?.slug ?? '', displayName: item?.displayName ?? '', description: item?.description ?? '', storefront: item?.storefront ?? 'main', category: item?.category ?? 'accessory', imageName: item?.imageName ?? '', currencyType: item?.currencyType ?? 2, price: item?.price ?? 100, isActive: item?.isActive ?? true, isLimitedTime: item?.isLimitedTime ?? false, availableUntil: item?.availableUntil ?? '', }); const [busy, setBusy] = useState(false); const toast = useToast(); const save = async () => { setBusy(true); try { const body = { Slug: form.slug.trim(), DisplayName: form.displayName.trim(), Description: form.description.trim(), Storefront: form.storefront, Category: form.category, ImageName: form.imageName.trim(), CurrencyType: form.currencyType, Price: form.price, IsActive: form.isActive, IsLimitedTime: form.isLimitedTime, AvailableUntil: form.availableUntil || null, }; if (isNew) { await api('/storeitems', { method: 'POST', body }); } else { await api(`/storeitems/${item!.id}`, { method: 'PUT', body }); } toast.push(isNew ? 'Item created' : 'Item updated', 'success'); onSaved(); } catch (e) { toast.push((e as Error).message, 'error'); } finally { setBusy(false); } }; return ( } >
{/* Live preview anchored next to the form — pulls the image using the same code path the catalog grid uses, so any "no image" or 404 the admin sees in the cards is reproduced here while editing. */}
Preview
{form.imageName || 'no imageName set'}
setForm({ ...form, slug: e.target.value })} disabled={!isNew} className="input font-mono text-xs" /> setForm({ ...form, displayName: e.target.value })} className="input" />