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 (
{listing.amount > 1 ? `${num(listing.amount)} × ` : ''} {itemLabel(listing)}
{v.serial ? ( {v.shopName || 'an unnamed shop'} ) : ( v.shopName || 'an unnamed shop' )} {v.ownerName ? ` · ${v.ownerName}` : ''} {where ? ` · ${where}` : ''} {/* Priced by the container it sits in, exactly as the in-game search reports it — the price buys the whole container, not this item. */} {listing.child ? ' · sold with its container' : ''}
{num(listing.price)}
gold
) } export default function Market() { const [input, setInput] = useState('') const [q, setQ] = useState('') const [map, setMap] = useState('') const [region, setRegion] = useState('') const [sort, setSort] = useState('price_asc') const [minPrice, setMinPrice] = useState('') const [maxPrice, setMaxPrice] = useState('') // Applied prices are separate from the typed ones so the search fires when the // user is done, not on every digit of "250000". const [prices, setPrices] = useState({ min: '', max: '' }) const [state, setState] = useState({ loading: true, error: null, listings: [], total: 0, staleAt: null }) const [more, setMore] = useState(false) const meta = useAsync(() => api.shard.marketMeta()) // Debounced: typing "vanquishing" should be one request, not eleven — and the // endpoint is rate-limited, so an undebounced box would 429 a fast typist. useEffect(() => { const timer = setTimeout(() => setQ(input.trim()), 300) return () => clearTimeout(timer) }, [input]) useEffect(() => { const timer = setTimeout(() => setPrices({ min: minPrice, max: maxPrice }), 500) return () => clearTimeout(timer) }, [minPrice, maxPrice]) const load = useCallback( (offset) => api.shard.market({ q, map, region, sort, minPrice: prices.min, maxPrice: prices.max, limit: PAGE, offset, }), [q, map, region, sort, prices], ) useEffect(() => { let alive = true setState({ loading: true, error: null, listings: [], total: 0, staleAt: null }) load(0) .then((page) => { if (!alive) return setState({ loading: false, error: null, listings: page.listings || [], total: page.total || 0, staleAt: page.staleAt || null, }) }) .catch((error) => alive && setState({ loading: false, error, listings: [], total: 0, staleAt: null })) return () => { alive = false } }, [load]) const loadMore = async () => { setMore(true) try { const page = await load(state.listings.length) setState((s) => ({ ...s, listings: [...s.listings, ...(page.listings || [])], total: page.total ?? s.total, staleAt: page.staleAt ?? s.staleAt, })) } catch { // A failed "load more" leaves what is on screen alone; the button stays // available to retry. } finally { setMore(false) } } const maps = meta.data?.maps || [] const regions = meta.data?.regions || [] const age = staleness(state.staleAt) return (
{/* Not decoration. The sweep is round-robin, so the index is inherently up to one full cycle old and the page has to say so. */} {age && (

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 }} />
setMinPrice(e.target.value)} placeholder="Min price" style={{ maxWidth: 140 }} /> setMaxPrice(e.target.value)} placeholder="Max price" style={{ maxWidth: 140 }} />
{SORTS.map((s) => ( setSort(s.key)}> {s.label} ))}
{/* Facet and region names come from the shard's own data, never a list in this file — a shard running custom maps gets its own names here with no code change (docs/link/v3.md §6.1 R2). */} {maps.length > 0 && (
setMap('')}>All facets {maps.map((m) => ( setMap(m)}>{m} ))}
)} {regions.length > 0 && ( )} {state.loading && } {state.error && } {!state.loading && !state.error && state.listings.length === 0 && ( {meta.data?.vendors ? 'Nothing on the shard matches that.' : 'No player vendors have been indexed yet.'} )} {!state.loading && !state.error && state.listings.length > 0 && ( <>

Showing {num(state.listings.length)} of {num(state.total)}

{state.listings.map((l) => ( ))}
{state.listings.length < state.total && (
)} )}
) }