TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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