import type { Metadata } from 'next'; import Link from 'next/link'; import { PageHeader } from '@/components/ui/page-header'; import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives'; import { LineChart } from '@/components/charts/line-chart'; import { getAssetsBySlugs, getAssetSnapshots } from '@/lib/queries/assets'; import { getIndex, getIndexSeries } from '@/lib/queries/indices'; import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format'; import { catName } from '@/lib/taxonomy'; import type { SP } from '@/lib/search-params'; import { Thumb } from '@/components/market/bits'; import { CompareForm, ShareButton } from './compare-form'; export const metadata: Metadata = { title: 'Compare', description: 'Compare up to four collectible assets or RareIndex indices side by side: indexed performance and key metrics.' }; const SLOTS = ['a', 'b', 'c', 'd'] as const; const SERIES = ['var(--ri-series-1, #2f5bd6)', 'var(--ri-series-2, #c2410c)', 'var(--ri-series-3, #0e9384)', 'var(--ri-series-4, #7c3aed)']; const WINDOWS: Array<{ id: string; days: number; label: string }> = [ { id: '3m', days: 92, label: '3M' }, { id: '1y', days: 366, label: '1Y' }, { id: '2y', days: 731, label: '2Y' }, { id: '5y', days: 1827, label: '5Y' }, ]; type Column = { key: string; label: string; short: string; href: string; color: string; kind: 'asset' | 'index'; image?: string | null; rows: Array<[string, React.ReactNode]> }; export default async function ComparePage({ searchParams }: { searchParams: Promise }) { const sp = await searchParams; const keys = SLOTS.map((k) => (Array.isArray(sp[k]) ? sp[k]![0] : sp[k])).filter((v): v is string => Boolean(v)); const win = WINDOWS.find((w) => w.id === (Array.isArray(sp.w) ? sp.w[0] : sp.w)) ?? WINDOWS[2]!; const assetSlugs = keys.filter((k) => !k.toUpperCase().startsWith('RARE')); const tickers = keys.filter((k) => k.toUpperCase().startsWith('RARE')); const [assets, indicesRaw] = await Promise.all([getAssetsBySlugs(assetSlugs), Promise.all(tickers.map((t) => getIndex(t)))]); const indices = indicesRaw.filter((i): i is NonNullable => Boolean(i)); // keep the URL order so colours follow the entity, never the rank 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 => Boolean(x)); const series = await Promise.all( ordered.map(async (item, i) => { if ('slug' in item) { const pts = (await getAssetSnapshots(item.id, '', win.days)).filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! })); return { id: item.slug, label: item.title.length > 34 ? `${item.title.slice(0, 33)}…` : item.title, color: SERIES[i % 4], points: pts }; } const pts = (await getIndexSeries(item.id, win.days)).map((p) => ({ x: p.date, y: p.value })); return { id: item.ticker, label: item.ticker, dashed: true, color: SERIES[i % 4], points: pts }; }), ); const columns: Column[] = ordered.map((item, i) => { if ('slug' in item) { const a = item; return { key: a.slug, label: a.title, short: a.title.length > 28 ? `${a.title.slice(0, 27)}…` : a.title, href: `/asset/${a.slug}`, color: SERIES[i % 4]!, kind: 'asset', image: a.heroImageUrl, rows: [ ['Category', catName(a.categorySlug)], ['RIV', a.rivUsd === null ? — : {fmtMoney(a.rivUsd)}], ['Confidence', a.rivSampleSize ? `${confidenceLabel(a.rivConfidence)} · n=${a.rivSampleSize}` : —], ['1D', ], ['7D', ], ['30D', ], ['1Y', ], ['Latest sale', a.latestSaleUsd === null ? — : `${fmtMoney(a.latestSaleUsd)} · ${fmtRelative(a.latestSaleAt)}`], ['Sales 30D / all', `${fmtNum(a.sales30d)} / ${fmtNum(a.salesCount)}`], ['Active listings', fmtNum(a.activeListings)], ['Liquidity', a.liquidityScore == null ? — : `${Math.round(a.liquidityScore)}/100`], ['Rarity', a.rarityScore == null ? — : `${Math.round(a.rarityScore)}/100`], ['Trending', a.trendingScore == null ? — : a.trendingScore.toFixed(1)], ], }; } const ix = item; return { key: ix.ticker, label: ix.name, short: ix.ticker, href: `/rareindex/${ix.ticker}`, color: SERIES[i % 4]!, kind: 'index', rows: [ ['Category', 'Index'], ['Value', ix.latest ? {ix.latest.value.toFixed(2)} : building], ['Coverage', ix.latest?.coverage != null ? `${Math.round(ix.latest.coverage * 100)}%` : —], ['1D', ], ['7D', ], ['30D', ], ['1Y', ], ['Median sale', ix.latest?.medianSaleUsd != null ? fmtMoney(ix.latest.medianSaleUsd) : —], ['Transactions', ix.latest ? fmtNum(ix.latest.transactions) : —], ['Constituents', ix.latest ? fmtNum(ix.latest.constituentsCount) : —], ['Liquidity', ix.latest?.liquidityScore != null ? `${Math.round(ix.latest.liquidityScore)}/100` : —], ['Rarity', —], ['Momentum', ix.latest?.momentum != null ? ix.latest.momentum.toFixed(2) : —], ], }; }); const qs = (w: string) => { const p = new URLSearchParams(); keys.forEach((k, i) => p.set(SLOTS[i]!, k)); p.set('w', w); return `/compare?${p.toString()}`; }; const metricLabels = columns[0]?.rows.map((r) => r[0]) ?? []; return (
: null} /> {columns.length ? (

Indexed performance

{WINDOWS.map((w) => ( {w.label} ))}
{/* desktop: matrix */} {columns.map((c) => ( ))} {metricLabels.map((label, ri) => ( {columns.map((c) => ( ))} ))}
Metric {c.short}
{label} {c.rows[ri]?.[1] ?? —}
{/* phone: one card per item, swipeable */}

Key metrics

{columns.map((c) => (
{c.image !== undefined ? : null} {c.label}
{c.rows.map(([k, v]) => (
{k}
{v}
))}
))}

Swipe to compare

) : ( )}
); }