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, Delta, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';5import { LineChart } from '@/components/charts/line-chart';6import { getAssetsBySlugs, getAssetSnapshots } from '@/lib/queries/assets';7import { getIndex, getIndexSeries } from '@/lib/queries/indices';8import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format';9import { catName } from '@/lib/taxonomy';10import type { SP } from '@/lib/search-params';11import { Thumb } from '@/components/market/bits';12import { CompareForm, ShareButton } from './compare-form';1314export const metadata: Metadata = { title: 'Compare', description: 'Compare up to four collectible assets or RareIndex indices side by side: indexed performance and key metrics.' };1516const SLOTS = ['a', 'b', 'c', 'd'] as const;17const SERIES = ['var(--ri-series-1, #2f5bd6)', 'var(--ri-series-2, #c2410c)', 'var(--ri-series-3, #0e9384)', 'var(--ri-series-4, #7c3aed)'];18const WINDOWS: Array<{ id: string; days: number; label: string }> = [19 { id: '3m', days: 92, label: '3M' },20 { id: '1y', days: 366, label: '1Y' },21 { id: '2y', days: 731, label: '2Y' },22 { id: '5y', days: 1827, label: '5Y' },23];2425type Column = { key: string; label: string; short: string; href: string; color: string; kind: 'asset' | 'index'; image?: string | null; rows: Array<[string, React.ReactNode]> };2627export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {28 const sp = await searchParams;29 const keys = SLOTS.map((k) => (Array.isArray(sp[k]) ? sp[k]![0] : sp[k])).filter((v): v is string => Boolean(v));30 const win = WINDOWS.find((w) => w.id === (Array.isArray(sp.w) ? sp.w[0] : sp.w)) ?? WINDOWS[2]!;31 const assetSlugs = keys.filter((k) => !k.toUpperCase().startsWith('RARE'));32 const tickers = keys.filter((k) => k.toUpperCase().startsWith('RARE'));33 const [assets, indicesRaw] = await Promise.all([getAssetsBySlugs(assetSlugs), Promise.all(tickers.map((t) => getIndex(t)))]);34 const indices = indicesRaw.filter((i): i is NonNullable<typeof i> => Boolean(i));35 // keep the URL order so colours follow the entity, never the rank36 const ordered = keys.map((k) => assets.find((a) => a.slug === k) ?? indices.find((i) => i.ticker.toUpperCase() === k.toUpperCase()) ?? null).filter((x): x is NonNullable<typeof x> => Boolean(x));3738 const series = await Promise.all(39 ordered.map(async (item, i) => {40 if ('slug' in item) {41 const pts = (await getAssetSnapshots(item.id, '', win.days)).filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! }));42 return { id: item.slug, label: item.title.length > 34 ? `${item.title.slice(0, 33)}…` : item.title, color: SERIES[i % 4], points: pts };43 }44 const pts = (await getIndexSeries(item.id, win.days)).map((p) => ({ x: p.date, y: p.value }));45 return { id: item.ticker, label: item.ticker, dashed: true, color: SERIES[i % 4], points: pts };46 }),47 );4849 const columns: Column[] = ordered.map((item, i) => {50 if ('slug' in item) {51 const a = item;52 return {53 key: a.slug,54 label: a.title,55 short: a.title.length > 28 ? `${a.title.slice(0, 27)}…` : a.title,56 href: `/asset/${a.slug}`,57 color: SERIES[i % 4]!,58 kind: 'asset',59 image: a.heroImageUrl,60 rows: [61 ['Category', catName(a.categorySlug)],62 ['RIV', a.rivUsd === null ? <span className="text-subtle">—</span> : <span className="font-semibold">{fmtMoney(a.rivUsd)}</span>],63 ['Confidence', a.rivSampleSize ? `${confidenceLabel(a.rivConfidence)} · n=${a.rivSampleSize}` : <span className="text-subtle">—</span>],64 ['1D', <Delta key="d1" value={a.change1d} />],65 ['7D', <Delta key="d7" value={a.change7d} />],66 ['30D', <Delta key="d30" value={a.change30d} />],67 ['1Y', <Delta key="d1y" value={a.change1y} />],68 ['Latest sale', a.latestSaleUsd === null ? <span className="text-subtle">—</span> : `${fmtMoney(a.latestSaleUsd)} · ${fmtRelative(a.latestSaleAt)}`],69 ['Sales 30D / all', `${fmtNum(a.sales30d)} / ${fmtNum(a.salesCount)}`],70 ['Active listings', fmtNum(a.activeListings)],71 ['Liquidity', a.liquidityScore == null ? <span className="text-subtle">—</span> : `${Math.round(a.liquidityScore)}/100`],72 ['Rarity', a.rarityScore == null ? <span className="text-subtle">—</span> : `${Math.round(a.rarityScore)}/100`],73 ['Trending', a.trendingScore == null ? <span className="text-subtle">—</span> : a.trendingScore.toFixed(1)],74 ],75 };76 }77 const ix = item;78 return {79 key: ix.ticker,80 label: ix.name,81 short: ix.ticker,82 href: `/rareindex/${ix.ticker}`,83 color: SERIES[i % 4]!,84 kind: 'index',85 rows: [86 ['Category', 'Index'],87 ['Value', ix.latest ? <span className="font-semibold">{ix.latest.value.toFixed(2)}</span> : <Badge>building</Badge>],88 ['Coverage', ix.latest?.coverage != null ? `${Math.round(ix.latest.coverage * 100)}%` : <span className="text-subtle">—</span>],89 ['1D', <Delta key="d1" value={ix.change1d} />],90 ['7D', <Delta key="d7" value={ix.change7d} />],91 ['30D', <Delta key="d30" value={ix.change30d} />],92 ['1Y', <Delta key="d1y" value={ix.change1y} />],93 ['Median sale', ix.latest?.medianSaleUsd != null ? fmtMoney(ix.latest.medianSaleUsd) : <span className="text-subtle">—</span>],94 ['Transactions', ix.latest ? fmtNum(ix.latest.transactions) : <span className="text-subtle">—</span>],95 ['Constituents', ix.latest ? fmtNum(ix.latest.constituentsCount) : <span className="text-subtle">—</span>],96 ['Liquidity', ix.latest?.liquidityScore != null ? `${Math.round(ix.latest.liquidityScore)}/100` : <span className="text-subtle">—</span>],97 ['Rarity', <span key="r" className="text-subtle">—</span>],98 ['Momentum', ix.latest?.momentum != null ? ix.latest.momentum.toFixed(2) : <span className="text-subtle">—</span>],99 ],100 };101 });102103 const qs = (w: string) => {104 const p = new URLSearchParams();105 keys.forEach((k, i) => p.set(SLOTS[i]!, k));106 p.set('w', w);107 return `/compare?${p.toString()}`;108 };109 const metricLabels = columns[0]?.rows.map((r) => r[0]) ?? [];110111 return (112 <div>113 <PageHeader title="Compare" description="Up to four assets or indices. Performance is rebased to 0% at the start of the window using each item's daily RIV or index value; metrics sit side by side with their evidence." compact actions={keys.length ? <ShareButton /> : null} />114 <CompareForm initial={keys} />115 {columns.length ? (116 <div className="mt-4 grid gap-4">117 <Card className="p-3 sm:p-4">118 <div className="flex flex-wrap items-center justify-between gap-2">119 <h2 className="text-sm font-semibold">Indexed performance</h2>120 <div className="inline-flex rounded-md border border-border bg-sunken p-0.5 text-[11px] font-medium" role="group" aria-label="Window">121 {WINDOWS.map((w) => (122 <Link key={w.id} href={qs(w.id)} scroll={false} className={cn('num h-7 rounded-[4px] px-2.5 leading-7', w.id === win.id ? 'bg-elevated text-fg shadow-card' : 'text-muted hover:text-fg')} aria-current={w.id === win.id ? 'true' : undefined}>123 {w.label}124 </Link>125 ))}126 </div>127 </div>128 <LineChart ariaLabel="Indexed performance comparison" height={300} indexed className="mt-2" series={series} emptyLabel="No daily history yet for the selected items in this window" />129 </Card>130131 {/* desktop: matrix */}132 <Card className="hidden overflow-hidden md:block">133 <CardHeader title="Key metrics" subtitle="Every estimate carries its evidence; “—” means data unavailable, never zero." />134 <Table>135 <thead>136 <tr>137 <th className={th}>Metric</th>138 {columns.map((c) => (139 <th key={c.key} className={cn(th, 'text-right')}>140 <span className="inline-flex items-center gap-1.5">141 <span className="inline-block h-2 w-2 rounded-full" style={{ background: c.color }} />142 <Link href={c.href} className="hover:underline">143 {c.short}144 </Link>145 </span>146 </th>147 ))}148 </tr>149 </thead>150 <tbody>151 {metricLabels.map((label, ri) => (152 <tr key={label} className="hover:bg-sunken">153 <td className={cn(td, 'text-muted')}>{label}</td>154 {columns.map((c) => (155 <td key={c.key} className={tdNum}>156 {c.rows[ri]?.[1] ?? <span className="text-subtle">—</span>}157 </td>158 ))}159 </tr>160 ))}161 </tbody>162 </Table>163 </Card>164165 {/* phone: one card per item, swipeable */}166 <div className="md:hidden">167 <h2 className="mb-2 text-sm font-semibold">Key metrics</h2>168 <div className="scrollbar-none -mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-1">169 {columns.map((c) => (170 <article key={c.key} className="card w-[86vw] max-w-sm shrink-0 snap-center overflow-hidden">171 <header className="flex items-center gap-2.5 border-b border-border px-3 py-2.5" style={{ boxShadow: `inset 3px 0 0 ${c.color}` }}>172 {c.image !== undefined ? <Thumb src={c.image} alt="" size={36} rounded="rounded-md" /> : null}173 <Link href={c.href} className="min-w-0 flex-1 truncate text-[13px] font-semibold text-fg">174 {c.label}175 </Link>176 </header>177 <dl className="divide-y divide-border text-[12px]">178 {c.rows.map(([k, v]) => (179 <div key={k} className="flex items-center justify-between gap-3 px-3 py-1.5">180 <dt className="text-muted">{k}</dt>181 <dd className="num text-right text-fg">{v}</dd>182 </div>183 ))}184 </dl>185 </article>186 ))}187 </div>188 <p className="mt-1 text-center text-[10px] text-subtle">Swipe to compare</p>189 </div>190 </div>191 ) : (192 <Card className="mt-4">193 <EmptyState title="Pick items to compare" description="Search an asset name or use index tickers such as RARE, RARE-TCG, RARE-WATCH. Up to four items; the URL is shareable." />194 </Card>195 )}196 </div>197 );198}199