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 } 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 { CompareForm } from './compare-form';1213export const metadata: Metadata = { title: 'Compare', description: 'Compare up to four collectible assets or RareIndex indices side by side: indexed performance and key metrics.' };1415const SLOTS = ['a', 'b', 'c', 'd'] as const;1617export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {18 const sp = await searchParams;19 const keys = SLOTS.map((k) => (Array.isArray(sp[k]) ? sp[k]![0] : sp[k])).filter((v): v is string => Boolean(v));20 const assetSlugs = keys.filter((k) => !k.toUpperCase().startsWith('RARE'));21 const tickers = keys.filter((k) => k.toUpperCase().startsWith('RARE'));22 const [assets, indices] = await Promise.all([getAssetsBySlugs(assetSlugs), Promise.all(tickers.map((t) => getIndex(t)))]);23 const series = await Promise.all([24 ...assets.map(async (a) => ({ id: a.slug, label: a.title.length > 40 ? `${a.title.slice(0, 39)}…` : a.title, points: (await getAssetSnapshots(a.id, '', 730)).filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! })) })),25 ...indices.filter((i): i is NonNullable<typeof i> => Boolean(i)).map(async (i) => ({ id: i.ticker, label: i.ticker, dashed: true, points: (await getIndexSeries(i.id, 730)).map((p) => ({ x: p.date, y: p.value })) })),26 ]);27 const found = assets.length + indices.filter(Boolean).length;28 return (29 <div>30 <PageHeader title="Compare" description="Up to four assets or indices. Performance is rebased to 0% at the start of the common window using each item's daily RIV or index value; metrics are shown side by side with their evidence." compact />31 <CompareForm initial={keys} />32 {found ? (33 <div className="mt-4 grid gap-4">34 <Card className="p-4">35 <h2 className="text-sm font-semibold">Indexed performance · 2 years</h2>36 <LineChart ariaLabel="Indexed performance comparison" height={320} indexed className="mt-3" series={series} emptyLabel="No daily history yet for the selected items" />37 </Card>38 <Card className="overflow-hidden">39 <CardHeader title="Key metrics" />40 <Table>41 <thead>42 <tr>43 <th className={th}>Metric</th>44 {assets.map((a) => (45 <th key={a.id} className={cn(th, 'text-right')}>46 <Link href={`/asset/${a.slug}`} className="hover:underline">47 {a.title.length > 32 ? `${a.title.slice(0, 31)}…` : a.title}48 </Link>49 </th>50 ))}51 {indices.filter(Boolean).map((i) => (52 <th key={i!.ticker} className={cn(th, 'text-right')}>53 <Link href={`/rareindex/${i!.ticker}`} className="hover:underline">54 {i!.ticker}55 </Link>56 </th>57 ))}58 </tr>59 </thead>60 <tbody>61 {[62 ['Category', assets.map((a) => catName(a.categorySlug)), indices.map(() => 'Index')],63 ['RIV / value', assets.map((a) => (a.rivUsd === null ? '—' : fmtMoney(a.rivUsd))), indices.map((i) => (i?.latest ? i.latest.value.toFixed(2) : 'building'))],64 ['Confidence', assets.map((a) => `${confidenceLabel(a.rivConfidence)} (n=${a.rivSampleSize})`), indices.map((i) => (i?.latest?.coverage != null ? `coverage ${Math.round(i.latest.coverage * 100)}%` : '—'))],65 ['1D', assets.map((a) => <Delta key={a.id} value={a.change1d} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change1d} />)],66 ['7D', assets.map((a) => <Delta key={a.id} value={a.change7d} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change7d} />)],67 ['30D', assets.map((a) => <Delta key={a.id} value={a.change30d} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change30d} />)],68 ['1Y', assets.map((a) => <Delta key={a.id} value={a.change1y} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change1y} />)],69 ['Latest sale', assets.map((a) => (a.latestSaleUsd === null ? '—' : `${fmtMoney(a.latestSaleUsd)} · ${fmtRelative(a.latestSaleAt)}`)), indices.map((i) => (i?.latest?.medianSaleUsd != null ? `median ${fmtMoney(i.latest.medianSaleUsd)}` : '—'))],70 ['Sales (30D / all)', assets.map((a) => `${fmtNum(a.sales30d)} / ${fmtNum(a.salesCount)}`), indices.map((i) => (i?.latest ? fmtNum(i.latest.transactions) : '—'))],71 ['Active listings', assets.map((a) => fmtNum(a.activeListings)), indices.map(() => '—')],72 ['Liquidity', assets.map((a) => (a.liquidityScore == null ? '—' : Math.round(a.liquidityScore))), indices.map((i) => (i?.latest?.liquidityScore != null ? Math.round(i.latest.liquidityScore) : '—'))],73 ['Rarity', assets.map((a) => (a.rarityScore == null ? '—' : Math.round(a.rarityScore))), indices.map(() => '—')],74 ['Trending', assets.map((a) => (a.trendingScore == null ? '—' : a.trendingScore.toFixed(1))), indices.map((i) => (i?.latest?.momentum != null ? i.latest.momentum.toFixed(2) : '—'))],75 ].map(([label, av, iv]) => (76 <tr key={label as string}>77 <td className={cn(td, 'text-muted')}>{label as string}</td>78 {(av as React.ReactNode[]).map((v, i) => (79 <td key={`a${i}`} className={tdNum}>80 {v}81 </td>82 ))}83 {(iv as React.ReactNode[]).map((v, i) => (84 <td key={`i${i}`} className={tdNum}>85 {v}86 </td>87 ))}88 </tr>89 ))}90 </tbody>91 </Table>92 </Card>93 </div>94 ) : (95 <Card className="mt-4">96 <EmptyState title="Pick items to compare" description="Add asset slugs (from any asset URL) or index tickers such as RARE, RARE-TCG, RARE-WATCH." />97 </Card>98 )}99 </div>100 );101}102