TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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 kicker="Browse" 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-3 flex flex-col gap-2">52 <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial">53 {HAS_OPTIONS.map((h) => (54 <a key={h.id || 'all'} href={withParams('/explore', { ...pick(sp, KEYS), has: h.id || null, page: null })} className={`chip ${has === h.id ? 'chip-active' : ''}`}>55 {h.label}56 </a>57 ))}58 </div>59 <div className="flex flex-wrap items-end justify-between gap-2">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 <SortMenu current={sort} params={params} />78 <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 }) }]} />79 </div>80 </div>81 </div>82 <Suspense fallback={<Skeleton className="h-96" />}>83 <Results filters={filters} view={view} params={params} />84 </Suspense>85 <div className="mt-3 flex flex-wrap gap-1.5 text-[11px] text-muted">86 <span>Families:</span>87 {FAMILIES.filter((f) => f.phase === 1).map((f) => (88 <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">89 {f.name}90 </a>91 ))}92 </div>93 </div>94 );95}9697function SortMenu({ current, params }: { current: ExploreSort; params: Record<string, string | undefined> }) {98 return (99 <details className="relative">100 <summary className="plain btn btn-sm h-8 cursor-pointer gap-1.5 text-[12px]">101 <span className="text-subtle">Sort</span>102 <span className="font-medium text-fg">{SORT_LABEL[current]}</span>103 <span aria-hidden className="text-subtle">▾</span>104 </summary>105 <div className="absolute right-0 top-full z-30 mt-1 w-56 overflow-hidden rounded-md border border-border bg-elevated py-1 shadow-pop">106 {SORTS.map((s) => (107 <a key={s} href={withParams('/explore', { ...params, sort: s, page: null })} className={`flex min-h-[40px] items-center px-3 text-[13px] hover:bg-sunken md:min-h-[32px] ${s === current ? 'font-medium text-fg' : 'text-muted'}`} aria-current={s === current ? 'true' : undefined}>108 {SORT_LABEL[s]}109 </a>110 ))}111 </div>112 </details>113 );114}115116async function Results({ filters, view, params }: { filters: Parameters<typeof exploreAssets>[0]; view: 'table' | 'grid'; params: Record<string, string | undefined> }) {117 const [res, counts] = await Promise.all([exploreAssets(filters), getScopeCounts({ scope: filters.category ? categoryScope(filters.category) : null, set: filters.set ?? null, brand: filters.brand ?? null })]);118 await attachGuidePrices(res.items);119 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);120 return (121 <>122 <p className="mb-2 num text-[11px] text-subtle">123 {res.total.toLocaleString('en-US')} assets match · {fmtNum(counts.priced)} of {fmtNum(counts.assets)} in scope have a valuation124 {counts.withObservations ? ` · ${fmtNum(counts.withObservations)} carry guide prices` : ''}125 {counts.assets > counts.priced ? ' · valuations publish as verified sales accrue' : ''}126 </p>127 {view === 'grid' ? (128 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">129 {res.items.map((a) => (130 <AssetCard key={a.id} a={a} />131 ))}132 {!res.items.length ? <div className="col-span-full"><AssetList items={[]} /></div> : null}133 </div>134 ) : (135 <div className="card overflow-hidden">136 <AssetList items={res.items} columns={[...columns]} rank startRank={(res.page - 1) * res.pageSize + 1} />137 </div>138 )}139 <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath="/explore" params={params} />140 </>141 );142}143