1import type { Metadata } from 'next';2import { Suspense } from 'react';3import { PageHeader } from '@/components/ui/page-header';4import { FilterBar } from '@/components/ui/filter-bar';5import { Pagination } from '@/components/ui/pagination';6import { Segmented } from '@/components/ui/tabs';7import { AssetCard, AssetList } from '@/components/market/asset-list';8import { Skeleton } from '@/components/ui/primitives';9import { attachGuidePrices, exploreAssets, getScopeCounts, categoryScope, type ExploreSort, type HasFilter } from '@/lib/queries/assets';10import { fmtNum } from '@/lib/format';11import { CATEGORIES, FAMILIES, GRADERS } from '@rareindex/taxonomy';12import { pick, sp1, spEnum, spInt, spNum, withParams, type SP } from '@/lib/search-params';1314export const metadata: Metadata = { title: 'Explore collectibles', description: 'Browse tracked collectible assets across every category, filtered by grade, price, liquidity, rarity and momentum.' };1516const SORTS: ExploreSort[] = ['relevance', 'riv', 'change7d', 'change30d', 'sales', 'liquidity', 'rarity', 'trending', 'newest', 'latest_sale', 'opportunity', 'name'];17const HAS_OPTIONS: Array<{ id: HasFilter | ''; label: string }> = [{ id: '', label: 'All' }, { id: 'sales', label: 'With sales' }, { id: 'valuation', label: 'With valuation' }, { id: 'listings', label: 'With listings' }, { id: 'observations', label: 'With guide price' }, { id: 'images', label: 'With image' }];18const SORT_LABEL: Record<ExploreSort, string> = { relevance: 'Most data', name: 'Name', number: 'Set · number', riv: 'Valuation', change7d: '7D change', change30d: '30D change', sales: 'Sales', liquidity: 'Liquidity', rarity: 'Rarity', trending: 'Trending', newest: 'Newest', latest_sale: 'Last sale', opportunity: 'Value opportunity' };19const KEYS = ['category', 'grader', 'grade', 'min', 'max', 'liq', 'rar', 'mom', 'from', 'to', 'brand', 'set', 'q', 'sort', 'view', 'has'];2021export default async function ExplorePage({ searchParams }: { searchParams: Promise<SP> }) {22 const sp = await searchParams;23 const sort = spEnum(sp, 'sort', SORTS, 'relevance');24 const has = spEnum<HasFilter | ''>(sp, 'has', ['', 'sales', 'valuation', 'listings', 'observations', 'images'] as const, '');25 const view = spEnum(sp, 'view', ['table', 'grid'] as const, 'table');26 const page = spInt(sp, 'page');27 const filters = {28 category: sp1(sp, 'category') ?? null,29 grader: sp1(sp, 'grader') ?? null,30 grade: sp1(sp, 'grade') ?? null,31 priceMin: spNum(sp, 'min'),32 priceMax: spNum(sp, 'max'),33 liquidityMin: spNum(sp, 'liq'),34 rarityMin: spNum(sp, 'rar'),35 momentumMin: spNum(sp, 'mom'),36 yearFrom: spNum(sp, 'from'),37 yearTo: spNum(sp, 'to'),38 brand: sp1(sp, 'brand') ?? null,39 set: sp1(sp, 'set') ?? null,40 q: sp1(sp, 'q') ?? null,41 has: has || null,42 sort,43 page,44 pageSize: 48,45 };46 const categoryOptions = CATEGORIES.map((c) => ({ value: c.slug, label: `${' '.repeat(c.level)}${c.level ? '↳ ' : ''}${c.name}` }));47 const params = pick(sp, KEYS);48 return (49 <div>50 <PageHeader title="Explore" description="Every tracked collectible asset — priced or not — sortable by data richness, RareIndex Valuation, momentum, liquidity or rarity. Filters are reflected in the URL." compact />51 <div className="mb-2 flex flex-wrap items-center gap-1 text-[11px]">52 <span className="mr-1 text-subtle">Show</span>53 {HAS_OPTIONS.map((h) => (54 <a key={h.id || 'all'} href={withParams('/explore', { ...pick(sp, KEYS), has: h.id || null, page: null })} className={`rounded-full border px-2 py-0.5 ${has === h.id ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:border-border-strong hover:text-fg'}`}>55 {h.label}56 </a>57 ))}58 </div>59 <div className="mb-3 flex flex-wrap items-end justify-between gap-3">60 <Suspense>61 <FilterBar62 fields={[63 { name: 'q', label: 'Search', type: 'text', placeholder: 'title contains…', width: 'w-44' },64 { name: 'category', label: 'Category', type: 'select', options: categoryOptions, width: 'w-52' },65 { name: 'grader', label: 'Grader', type: 'select', options: GRADERS.filter((g) => g.slug !== 'raw').map((g) => ({ value: g.slug, label: g.name })), width: 'w-32' },66 { name: 'grade', label: 'Grade', type: 'text', placeholder: 'e.g. 10', width: 'w-20' },67 { name: 'min', label: 'Min RIV $', type: 'number', placeholder: '0', width: 'w-24' },68 { name: 'max', label: 'Max RIV $', type: 'number', placeholder: '∞', width: 'w-24' },69 { name: 'liq', label: 'Liquidity ≥', type: 'number', placeholder: '0–100', width: 'w-20' },70 { name: 'rar', label: 'Rarity ≥', type: 'number', placeholder: '0–100', width: 'w-20' },71 { name: 'from', label: 'Year from', type: 'number', placeholder: '1900', width: 'w-20' },72 { name: 'to', label: 'Year to', type: 'number', placeholder: '2026', width: 'w-20' },73 ]}74 />75 </Suspense>76 <div className="flex items-center gap-2">77 <Segmented active={view} options={[{ id: 'table', label: 'Table', href: withParams('/explore', { ...params, view: 'table', page: null }) }, { id: 'grid', label: 'Cards', href: withParams('/explore', { ...params, view: 'grid', page: null }) }]} />78 <label className="flex items-center gap-1 text-[11px] text-subtle">79 Sort80 <SortSelect current={sort} params={params} />81 </label>82 </div>83 </div>84 <Suspense fallback={<Skeleton className="h-96" />}>85 <Results filters={filters} view={view} params={params} />86 </Suspense>87 <div className="mt-3 flex flex-wrap gap-1.5 text-[11px] text-muted">88 <span>Families:</span>89 {FAMILIES.filter((f) => f.phase === 1).map((f) => (90 <a key={f.slug} href={withParams('/explore', { ...params, category: f.slug, page: null })} className="rounded-full border border-border px-2 py-0.5 hover:border-border-strong hover:text-fg">91 {f.name}92 </a>93 ))}94 </div>95 </div>96 );97}9899function SortSelect({ current, params }: { current: ExploreSort; params: Record<string, string | undefined> }) {100 return (101 <span className="inline-flex flex-wrap gap-1">102 {SORTS.map((s) => (103 <a key={s} href={withParams('/explore', { ...params, sort: s, page: null })} className={`rounded-sm px-1.5 py-0.5 ${s === current ? 'bg-inset font-medium text-fg' : 'hover:text-fg'}`}>104 {SORT_LABEL[s]}105 </a>106 ))}107 </span>108 );109}110111async function Results({ filters, view, params }: { filters: Parameters<typeof exploreAssets>[0]; view: 'table' | 'grid'; params: Record<string, string | undefined> }) {112 const [res, counts] = await Promise.all([exploreAssets(filters), getScopeCounts({ scope: filters.category ? categoryScope(filters.category) : null, set: filters.set ?? null, brand: filters.brand ?? null })]);113 await attachGuidePrices(res.items);114 const columns = filters.sort === 'rarity' || filters.sort === 'trending' || filters.sort === 'opportunity' ? (['riv', 'change30d', 'sales', 'liquidity', 'rarity', 'trending', 'opportunity'] as const) : (['riv', 'change1d', 'change7d', 'change30d', 'latestSale', 'sales', 'listings', 'liquidity'] as const);115 return (116 <>117 <p className="mb-2 num text-[11px] text-subtle">118 {res.total.toLocaleString('en-US')} assets match · {fmtNum(counts.priced)} of {fmtNum(counts.assets)} in scope have a valuation119 {counts.withObservations ? ` · ${fmtNum(counts.withObservations)} carry guide prices` : ''}120 {counts.assets > counts.priced ? ' · valuations publish as verified sales accrue' : ''}121 </p>122 {view === 'grid' ? (123 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">124 {res.items.map((a) => (125 <AssetCard key={a.id} a={a} />126 ))}127 {!res.items.length ? <div className="col-span-full"><AssetList items={[]} /></div> : null}128 </div>129 ) : (130 <div className="card overflow-hidden">131 <AssetList items={res.items} columns={[...columns]} rank startRank={(res.page - 1) * res.pageSize + 1} />132 </div>133 )}134 <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath="/explore" params={params} />135 </>136 );137}138