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%
6.7 KB · 128 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader } from '@/components/ui/page-header';4import { Card, CardHeader, EmptyState, Stat } from '@/components/ui/primitives';5import { Segmented } from '@/components/ui/tabs';6import { LineChart } from '@/components/charts/line-chart';7import { HistoryTab } from '@/components/asset/asset-tabs';8import { getAssetBySlug, getAssetSalePoints, getAssetVariants, rankedAssets } from '@/lib/queries/assets';9import { searchAssets } from '@rareindex/search';10import { fmtMoney, fmtNum, confidenceLabel } from '@/lib/format';11import { catName, gradeLabel } from '@/lib/taxonomy';12import { sp1, spEnum, type SP } from '@/lib/search-params';13import { AssetList } from '@/components/market/asset-list';1415export const metadata: Metadata = { title: 'Price history', description: 'Search any tracked collectible and view its full price history: sales, RareIndex Valuation, lowest ask and volume.' };1617const PERIODS = ['1y', '2y', '5y', 'all'] as const;1819/** ISO day string N days ago ('' for no cutoff); kept outside components so render stays pure. */20function windowCutoffIso(days: number | null): string {21  if (!days) return '';22  return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);23}2425export default async function PriceHistoryPage({ searchParams }: { searchParams: Promise<SP> }) {26  const sp = await searchParams;27  const slug = sp1(sp, 'asset') ?? null;28  const q = sp1(sp, 'q') ?? '';29  const period = spEnum(sp, 'p', PERIODS, '2y');30  const vId = sp1(sp, 'v') ?? null;31  const asset = slug ? await getAssetBySlug(slug) : null;32  return (33    <div>34      <PageHeader kicker="Series" title="Price history" description="Historical series for any asset: verified sales as points, daily RIV, lowest ask and volume. Never interpolated; gaps are gaps." compact />35      <form action="/price-history" className="mb-4 flex max-w-xl gap-2">36        <input name="q" defaultValue={q} placeholder="Find an asset… e.g. Charizard 1st edition PSA 10" className="h-10 flex-1 rounded-md border border-border bg-elevated px-3 text-[14px] text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" aria-label="Find an asset" />37        <button type="submit" className="h-10 rounded-md bg-accent px-4 text-sm font-medium text-accent-fg">38          Find39        </button>40      </form>41      {asset ? (42        <Selected asset={asset} period={period} vId={vId} />43      ) : q ? (44        <Results q={q} />45      ) : (46        <Popular />47      )}48    </div>49  );50}5152async function Selected({ asset, period, vId }: { asset: NonNullable<Awaited<ReturnType<typeof getAssetBySlug>>>; period: (typeof PERIODS)[number]; vId: string | null }) {53  const variants = await getAssetVariants(asset.id);54  const variant = vId ? variants.find((v) => v.id === vId) ?? null : null;55  const points = await getAssetSalePoints(asset.id, 5000);56  const days = period === '1y' ? 365 : period === '2y' ? 730 : period === '5y' ? 1826 : null;57  const cutoff = windowCutoffIso(days);58  const scatter = points.filter((p) => (!variant || p.variantId === variant.id) && p.date >= cutoff);59  const href = (p: string, v: string | null) => `/price-history?asset=${asset.slug}&p=${p}${v ? `&v=${v}` : ''}`;60  return (61    <div className="grid gap-4">62      <Card className="p-4">63        <div className="flex flex-wrap items-start justify-between gap-3">64          <div>65            <Link href={`/asset/${asset.slug}`} className="text-base font-semibold text-fg hover:underline">66              {asset.title}67            </Link>68            <p className="text-xs text-muted">69              {catName(asset.categorySlug)}70              {variant ? ` · ${variant.label}` : ' · all variants'}71            </p>72          </div>73          <div className="flex flex-wrap gap-2">74            <Segmented active={period} options={PERIODS.map((p) => ({ id: p, label: p.toUpperCase(), href: href(p, variant?.id ?? null) }))} />75            {variants.length ? <Segmented active={variant?.id ?? 'all'} options={[{ id: 'all', label: 'All', href: href(period, null) }, ...variants.slice(0, 6).map((v) => ({ id: v.id, label: v.label, href: href(period, v.id) }))]} /> : null}76          </div>77        </div>78        <dl className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">79          <Stat label="RIV" value={asset.rivUsd === null ? '—' : fmtMoney(asset.rivUsd)} sub={`${confidenceLabel(asset.rivConfidence)} · n=${asset.rivSampleSize}`} />80          <Stat label="Sales in window" value={fmtNum(scatter.length)} />81          <Stat label="Window high" value={scatter.length ? fmtMoney(Math.max(...scatter.map((p) => p.usd))) : '—'} />82          <Stat label="Window low" value={scatter.length ? fmtMoney(Math.min(...scatter.map((p) => p.usd))) : '—'} />83        </dl>84        <LineChart ariaLabel="Sales scatter" height={320} currency className="mt-3" reference={asset.rivUsd !== null ? { value: asset.rivUsd, label: 'RIV' } : null} series={[{ id: 'sales', label: 'Verified sales', kind: 'dots', points: scatter.map((p) => ({ x: p.date, y: p.usd, meta: `${gradeLabel(p.grader, p.grade) ?? 'raw'} · ${p.sourceName}` })) }]} emptyLabel="No verified sales in this window" />85      </Card>86      <HistoryTab asset={asset} variant={variant} />87    </div>88  );89}9091async function Results({ q }: { q: string }) {92  const res = await searchAssets(q, { limit: 20 });93  return (94    <Card className="overflow-hidden">95      <CardHeader title={`Matches for “${q}”`} subtitle="Pick an asset to open its history" />96      {res.hits.length ? (97        <ul className="divide-y divide-border text-[13px]">98          {res.hits.map((h) => (99            <li key={h.id}>100              <Link href={`/price-history?asset=${h.slug}`} className="flex items-center justify-between gap-3 px-4 py-2 hover:bg-sunken">101                <span className="min-w-0">102                  <span className="block truncate font-medium text-fg">{h.title}</span>103                  <span className="text-[11px] text-muted">104                    {catName(h.categorySlug)} · {h.salesCount} sales105                  </span>106                </span>107                <span className="num text-muted">{h.rivUsd === null ? '—' : fmtMoney(h.rivUsd)}</span>108              </Link>109            </li>110          ))}111        </ul>112      ) : (113        <EmptyState title="No matches" />114      )}115    </Card>116  );117}118119async function Popular() {120  const items = await rankedAssets('volume', { limit: 20 });121  return (122    <Card className="overflow-hidden">123      <CardHeader title="Most traded · 30 days" subtitle="Start with the assets that have the richest history" />124      <AssetList items={items} columns={['riv', 'sales30d', 'change30d', 'liquidity']} emptyTitle="No traded assets yet" />125    </Card>126  );127}128