SPB Git

spb/rareindex

Public
8.3 KB · 138 lines tsx
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
Files · d93392862678cbf30e175697fcb812bc4ff0b5a7
Menu

Theme