SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
8.6 KB · 143 lines tsx
Raw Blame History
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