import { useCallback, useEffect, useState } from 'react' import { Link } from 'react-router-dom' import PublicLayout from '../../components/PublicLayout.jsx' import PageHeader from '../../components/PageHeader.jsx' import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx' import { useAsync } from '../../lib/useAsync.js' import { api } from '../../api/client.js' // ── The player-vendor marketplace ─────────────────────────────────────────── // // What every player vendor on the shard is selling, for how much, and where it // is standing — the same index the in-game Vendor Search gump reads, honouring // the same per-vendor opt-out, reachable without logging in to the game. // // Three things this page must be honest about, all of them consequences of how // the data is gathered (docs/link/v3.md §8): // // • **The prices are not live.** The shard sweeps vendors round-robin, so a // shop can be a full cycle behind. The banner says how far, from `staleAt`. // A page that implied live prices would send people across the world to a // vendor whose item sold twenty minutes ago. // • **A shop can be truncated.** A commodity reseller with thousands of stacks // publishes only the first N, and saying so beats presenting a partial shop // as complete. // • **An item may have no name.** On a shard whose operator has not converted // a cliloc table, `displayName` is null and the honest render is the item id // — not an invented name. // // There is deliberately no live feed here. The market feature's SSE stream ships // disabled: a firehose of whole vendor inventories would be the site's single // biggest bandwidth consumer, and nothing on this page needs it. const PAGE = 50 const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—') const SORTS = [ { key: 'price_asc', label: 'Cheapest' }, { key: 'price_desc', label: 'Priciest' }, { key: 'recent', label: 'Recently seen' }, ] // How old the index may be, in words. `staleAt` is the OLDEST vendor row, so // this is a worst case rather than an average — which is the number worth // showing, because the one stale shop is the one that wastes a trip. function staleness(staleAt) { if (!staleAt) return null const ms = Date.now() - new Date(staleAt).getTime() if (!Number.isFinite(ms) || ms < 0) return null const mins = Math.round(ms / 60000) if (mins < 1) return 'just now' if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago` const hours = Math.round(mins / 60) if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago` return `${Math.round(hours / 24)} days ago` } // The item's name, or an honest statement that we do not have one. Never a // fabricated label — "Item 3922" would be indistinguishable from a real name. const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}` function Chip({ active, onClick, children }) { return ( ) } function ListingRow({ listing }) { const v = listing.vendor || {} // `location` is one field the admin can gate away wholesale, so everything // that reads from it has to tolerate its absence rather than assuming a map. const loc = v.location || null const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null return (
Prices last refreshed {age} {meta.data?.vendors ? ` · ${num(meta.data.vendors)} shops` : ''} {meta.data?.items ? ` · ${num(meta.data.items)} listings` : ''}
)} setInput(e.target.value)} placeholder="Search listings…" style={{ width: '100%', marginBottom: 10 }} />Showing {num(state.listings.length)} of {num(state.total)}