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%

Public market web: home, markets, indices, explore, asset pages, search, SEO (agent E)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 17 days ago (Sep 7, 2026) parent ea481cc

76 changed files +7,445 −25

added apps/web/dev.sh +7 −0
@@ -0,0 +1,7 @@
1 +#!/bin/sh
2 +# Local dev helper: loads ../../.env and starts the Next dev server (default port 3000).
3 +cd "$(dirname "$0")" || exit 1
4 +set -a
5 +[ -f ../../.env ] && . ../../.env
6 +set +a
7 +exec pnpm exec next dev --webpack -p "${PORT:-3000}"
modified apps/web/next.config.ts +7 −0
@@ -1,8 +1,10 @@
1 1 import type { NextConfig } from 'next';
2 +import path from 'node:path';
2 3
3 4 const nextConfig: NextConfig = {
4 5 reactStrictMode: true,
5 6 poweredByHeader: false,
7 + agentRules: false,
6 8 // Workspace packages are consumed as TypeScript sources.
7 9 transpilePackages: ['@rareindex/shared', '@rareindex/database', '@rareindex/taxonomy', '@rareindex/connectors'],
8 10 serverExternalPackages: ['postgres', 'pino', 'cheerio'],
@@ -12,7 +14,12 @@ const nextConfig: NextConfig = {
12 14 },
13 15 experimental: {
14 16 optimizePackageImports: ['lucide-react'],
17 + // Workspace packages use NodeNext-style `./file.js` imports that resolve to .ts sources.
18 + // Turbopack does not rewrite these outside the app root, so the app is built with webpack
19 + // (`next dev/build --webpack`) where extensionAlias handles the mapping.
20 + extensionAlias: { '.js': ['.ts', '.tsx', '.js'], '.mjs': ['.mts', '.mjs'] },
15 21 },
22 + outputFileTracingRoot: path.resolve(__dirname, '../..'),
16 23 async headers() {
17 24 return [
18 25 {
modified apps/web/package.json +5 −3
@@ -3,8 +3,8 @@
3 3 "version": "0.1.0",
4 4 "private": true,
5 5 "scripts": {
6 − "dev": "next dev -p 3000",
7 − "build": "next build",
6 + "dev": "next dev --webpack -p 3000",
7 + "build": "next build --webpack",
8 8 "start": "next start",
9 9 "lint": "eslint",
10 10 "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -21,7 +21,9 @@
21 21 "react": "19.2.8",
22 22 "react-dom": "19.2.8",
23 23 "server-only": "^0.0.1",
24 − "zod": "^4.0.0"
24 + "zod": "^4.0.0",
25 + "@rareindex/search": "workspace:*",
26 + "drizzle-orm": "^0.45.0"
25 27 },
26 28 "devDependencies": {
27 29 "@tailwindcss/postcss": "^4",
added apps/web/scripts/screenshot.mjs +32 −0
@@ -0,0 +1,32 @@
1 +// Visual check helper: node scripts/screenshot.mjs <baseUrl> <outDir> [paths...]
2 +// Uses a locally installed Playwright (resolved from PLAYWRIGHT_MODULE or the default require path).
3 +import { createRequire } from 'node:module';
4 +import { mkdirSync } from 'node:fs';
5 +import path from 'node:path';
6 +
7 +const require = createRequire(import.meta.url);
8 +const pwPath = process.env.PLAYWRIGHT_MODULE ?? 'playwright';
9 +const { chromium } = require(pwPath);
10 +
11 +const [base = 'http://localhost:3000', out = '/tmp/ri-shots', ...paths] = process.argv.slice(2);
12 +const routes = paths.length ? paths : ['/', '/markets', '/markets/pokemon', '/rareindex', '/explore', '/sales', '/data'];
13 +mkdirSync(out, { recursive: true });
14 +
15 +const browser = await chromium.launch();
16 +for (const [label, viewport, theme] of [
17 + ['desktop-light', { width: 1440, height: 900 }, 'light'],
18 + ['desktop-dark', { width: 1440, height: 900 }, 'dark'],
19 + ['mobile-light', { width: 390, height: 844 }, 'light'],
20 +]) {
21 + const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1, colorScheme: theme });
22 + await ctx.addInitScript((t) => localStorage.setItem('ri-theme', t), theme);
23 + const page = await ctx.newPage();
24 + for (const r of routes) {
25 + await page.goto(base + r, { waitUntil: 'networkidle', timeout: 120_000 });
26 + const file = path.join(out, `${label}${r === '/' ? '-home' : r.replace(/[^a-z0-9]+/gi, '-')}.png`);
27 + await page.screenshot({ path: file, fullPage: true });
28 + console.log('saved', file);
29 + }
30 + await ctx.close();
31 +}
32 +await browser.close();
added apps/web/src/app/about/page.tsx +52 −0
@@ -0,0 +1,52 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Card } from '@/components/ui/primitives';
5 +
6 +export const metadata: Metadata = { title: 'About', description: 'RareIndex is the global data layer for collectible assets: mission, how it works, disclaimers, source attribution and contact.' };
7 +
8 +export default function AboutPage() {
9 + return (
10 + <div className="mx-auto max-w-3xl">
11 + <PageHeader title="About RareIndex" description="RareIndex structures the collectible economy: one canonical asset graph with every important variation, every observable listing, every verified historical sale and every meaningful market signal that can legally and reliably be obtained." />
12 + <Card className="p-6" as="article">
13 + <h2 className="text-base font-semibold">Mission</h2>
14 + <p className="mt-2 text-[14px] leading-relaxed text-muted">Whenever someone asks “What is this collectible worth?” or “What is happening in the collectibles market?”, RareIndex should be the answer — a research-grade market data terminal for cards, watches, sneakers, LEGO, comics, video games, coins, art and every other collectible category, rather than another marketplace aggregator.</p>
15 +
16 + <h2 className="mt-6 text-base font-semibold">How it works</h2>
17 + <ol className="mt-2 list-decimal space-y-1.5 pl-5 text-[14px] leading-relaxed text-muted">
18 + <li>Connectors collect public market data from marketplaces, auction houses, dealers, price guides, grading companies and manufacturer catalogues, with provenance for every record.</li>
19 + <li>Records are normalised, deduplicated and resolved to canonical assets and variants (grade/condition).</li>
20 + <li>The price engine computes the RareIndex Valuation (RIV) with a range and a confidence, flags outliers and keeps an audit trail.</li>
21 + <li>Rarity, liquidity, momentum and trending scores, category indices and the RARE global index are derived from those observations.</li>
22 + </ol>
23 + <p className="mt-2 text-[14px] text-muted">
24 + The full methodology is published on the{' '}
25 + <Link href="/data#methodology" className="underline">
26 + Data page
27 + </Link>
28 + .
29 + </p>
30 +
31 + <h2 id="disclaimers" className="mt-6 text-base font-semibold">
32 + Disclaimers
33 + </h2>
34 + <ul className="mt-2 list-disc space-y-1.5 pl-5 text-[14px] leading-relaxed text-muted">
35 + <li>Valuations are estimates derived from observed public data and carry a stated confidence. They are not appraisals, offers or investment advice.</li>
36 + <li>Listing prices are asking prices, not confirmed transactions.</li>
37 + <li>Past performance does not guarantee future results. Collectibles are illiquid, carry high transaction costs and their prices can move sharply.</li>
38 + <li>RareIndex does not authenticate, grade or inspect items. Grades and certification numbers are reported as published by their source; verify them with the grading company.</li>
39 + <li>Analytical flags such as “value opportunity”, “price discrepancy” or “possible misidentification” are signals, not conclusions.</li>
40 + </ul>
41 +
42 + <h2 className="mt-6 text-base font-semibold">Source attribution and compliance</h2>
43 + <p className="mt-2 text-[14px] leading-relaxed text-muted">Third-party data is attributed to its source and linked where possible; RareIndex does not represent third-party content as its own. Product names, set names, brand names and marks belong to their respective owners. RareIndex collects publicly accessible information only, respects applicable laws, source terms, robots policies, rate limits and authentication boundaries, and never bypasses logins, paywalls or CAPTCHAs. Private collections and watchlists are private by default and are never sold.</p>
44 +
45 + <h2 className="mt-6 text-base font-semibold">Contact</h2>
46 + <p className="mt-2 text-[14px] leading-relaxed text-muted">
47 + Data corrections and source requests: <a href="mailto:data@rareindex.io" className="underline">data@rareindex.io</a>. API and partnerships: <a href="mailto:hello@rareindex.io" className="underline">hello@rareindex.io</a>. Rights holders who wish a source to be excluded can write to <a href="mailto:legal@rareindex.io" className="underline">legal@rareindex.io</a>.
48 + </p>
49 + </Card>
50 + </div>
51 + );
52 +}
added apps/web/src/app/api-docs/page.tsx +123 −0
@@ -0,0 +1,123 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Card, CardHeader, Table, th, td, Badge } from '@/components/ui/primitives';
5 +
6 +export const metadata: Metadata = { title: 'API', description: 'RareIndex public API: assets, sales, listings, price history, categories, indices, markets and trending — with tiers and rate limits.' };
7 +
8 +const ENDPOINTS: Array<{ method: string; path: string; desc: string; params?: string; tier: string }> = [
9 + { method: 'GET', path: '/v1/assets/search', desc: 'Hybrid search over canonical assets with natural-language filters.', params: 'q, category, grader, grade, min, max, limit, offset', tier: 'free' },
10 + { method: 'GET', path: '/v1/assets/:id', desc: 'Canonical asset with variants, RIV, scores and evidence (sample size, confidence, updated_at).', tier: 'free' },
11 + { method: 'GET', path: '/v1/assets/:id/sales', desc: 'Verified sales with source attribution, native currency and USD at historical FX.', params: 'variant, from, to, limit, offset, format=json|csv', tier: 'hobby' },
12 + { method: 'GET', path: '/v1/assets/:id/listings', desc: 'Active listings with discount to RIV.', params: 'variant, availability, limit', tier: 'hobby' },
13 + { method: 'GET', path: '/v1/assets/:id/history', desc: 'Daily price snapshots (RIV, median, lowest ask, volume, listings).', params: 'variant, from, to, format', tier: 'hobby' },
14 + { method: 'GET', path: '/v1/categories', desc: 'The taxonomy tree with live coverage counts.', tier: 'free' },
15 + { method: 'GET', path: '/v1/indices', desc: 'All indices with latest value, period returns and build status.', tier: 'free' },
16 + { method: 'GET', path: '/v1/indices/:ticker/history', desc: 'Daily index values with coverage, transactions and liquidity.', params: 'from, to, format', tier: 'professional' },
17 + { method: 'GET', path: '/v1/markets', desc: 'Category market rows: index, changes, sales, volume, listings, market cap estimate + confidence.', params: 'parent', tier: 'free' },
18 + { method: 'GET', path: '/v1/trending', desc: 'Trending assets and categories with momentum breakdown.', params: 'category, limit', tier: 'free' },
19 +];
20 +
21 +const TIERS = [
22 + ['free', '60 req/min · 1,000 req/day', 'Search, asset overview, categories, indices, markets, trending'],
23 + ['hobby', '120 req/min · 10,000 req/day', '+ sales, listings, history; CSV exports'],
24 + ['professional', '600 req/min · 100,000 req/day', '+ index history, bulk comps, webhooks (alerts)'],
25 + ['research', 'custom', 'Historical backfills, raw provenance, academic use'],
26 + ['enterprise', 'custom', 'SLA, dedicated support, custom connectors'],
27 +];
28 +
29 +export default function ApiDocsPage() {
30 + return (
31 + <div>
32 + <PageHeader title="API" description="Programmatic access to the same data that powers the site. Every response includes evidence fields (sample size, confidence, updated_at) and source attribution — never a bare number." />
33 + <Card className="p-5">
34 + <h2 className="text-sm font-semibold">Base URL & authentication</h2>
35 + <pre className="mt-2 overflow-x-auto rounded-md bg-sunken p-3 text-[12px] leading-relaxed"><code>{`https://www.rareindex.io/api/v1 # same-origin path
36 +https://api.rareindex.io/v1 # dedicated host (when enabled)
37 +
38 +Authorization: Bearer ri_live_xxxxxxxxxxxxxxxxxxxx
39 +
40 +curl -s "https://www.rareindex.io/api/v1/assets/search?q=1999+charizard+psa+10&limit=5" \\
41 + -H "Authorization: Bearer $RAREINDEX_API_KEY"`}</code></pre>
42 + <p className="mt-2 text-[12px] text-muted">
43 + Keys are created from your account (
44 + <Link href="/account" className="underline">
45 + Account → API keys
46 + </Link>
47 + ). Rate limits are enforced per key and returned in <code className="rounded-sm bg-inset px-1">X-RateLimit-*</code> headers. All timestamps are ISO-8601 UTC; monetary fields carry <code className="rounded-sm bg-inset px-1">currency</code> and <code className="rounded-sm bg-inset px-1">price_usd</code>.
48 + </p>
49 + </Card>
50 + <Card className="mt-4 overflow-hidden">
51 + <CardHeader title="Endpoints" subtitle="v1 — stable contract; fields are only ever added" />
52 + <Table>
53 + <thead>
54 + <tr>
55 + <th className={th}>Method</th>
56 + <th className={th}>Path</th>
57 + <th className={th}>Description</th>
58 + <th className={th}>Query params</th>
59 + <th className={th}>Tier</th>
60 + </tr>
61 + </thead>
62 + <tbody>
63 + {ENDPOINTS.map((e) => (
64 + <tr key={e.path}>
65 + <td className={td}>
66 + <Badge tone="index">{e.method}</Badge>
67 + </td>
68 + <td className={td}>
69 + <code className="text-[12px]">{e.path}</code>
70 + </td>
71 + <td className={`${td} whitespace-normal text-muted`}>{e.desc}</td>
72 + <td className={`${td} whitespace-normal text-[11px] text-subtle`}>{e.params ?? '—'}</td>
73 + <td className={td}>
74 + <Badge>{e.tier}+</Badge>
75 + </td>
76 + </tr>
77 + ))}
78 + </tbody>
79 + </Table>
80 + </Card>
81 + <div className="mt-4 grid gap-4 lg:grid-cols-2">
82 + <Card className="overflow-hidden">
83 + <CardHeader title="Tiers" subtitle="Limits are configurable per key" />
84 + <Table>
85 + <thead>
86 + <tr>
87 + <th className={th}>Tier</th>
88 + <th className={th}>Limits</th>
89 + <th className={th}>Access</th>
90 + </tr>
91 + </thead>
92 + <tbody>
93 + {TIERS.map(([t, l, a]) => (
94 + <tr key={t}>
95 + <td className={`${td} font-medium`}>{t}</td>
96 + <td className={`${td} text-muted`}>{l}</td>
97 + <td className={`${td} whitespace-normal text-muted`}>{a}</td>
98 + </tr>
99 + ))}
100 + </tbody>
101 + </Table>
102 + </Card>
103 + <Card className="p-5">
104 + <h2 className="text-sm font-semibold">Response shape</h2>
105 + <pre className="mt-2 overflow-x-auto rounded-md bg-sunken p-3 text-[11px] leading-relaxed"><code>{`{
106 + "data": {
107 + "id": "rare_…", "slug": "…", "title": "…",
108 + "category": "pokemon",
109 + "valuation": {
110 + "riv_usd": 8450, "low_usd": 7600, "high_usd": 9300,
111 + "confidence": 0.82, "confidence_label": "high",
112 + "sample_size": 37, "updated_at": "2026-09-07T10:12:00Z"
113 + },
114 + "scores": { "liquidity": 71, "rarity": 64, "momentum_30d": 0.04 }
115 + },
116 + "meta": { "source_attribution": [...], "generated_at": "…" }
117 +}`}</code></pre>
118 + <p className="mt-2 text-[12px] leading-relaxed text-muted">Missing evidence is returned as <code className="rounded-sm bg-inset px-1">null</code> with a reason, never as a fabricated value. Valuations are estimates; listing prices are not confirmed transactions.</p>
119 + </Card>
120 + </div>
121 + </div>
122 + );
123 +}
added apps/web/src/app/api/search/suggest/route.ts +16 −0
@@ -0,0 +1,16 @@
1 +import { NextResponse } from 'next/server';
2 +import { suggest } from '@rareindex/search';
3 +
4 +export const dynamic = 'force-dynamic';
5 +
6 +export async function GET(req: Request) {
7 + const { searchParams } = new URL(req.url);
8 + const q = (searchParams.get('q') ?? '').slice(0, 80);
9 + if (q.trim().length < 2) return NextResponse.json({ items: [] }, { headers: { 'cache-control': 'public, max-age=30' } });
10 + try {
11 + const items = await suggest(q, 8);
12 + return NextResponse.json({ items }, { headers: { 'cache-control': 'public, max-age=60, stale-while-revalidate=300' } });
13 + } catch (err) {
14 + return NextResponse.json({ items: [], error: err instanceof Error ? err.message : 'error' }, { status: 200 });
15 + }
16 +}
added apps/web/src/app/asset/[slug]/opengraph-image.tsx +43 −0
@@ -0,0 +1,43 @@
1 +import { ImageResponse } from 'next/og';
2 +import { getAssetBySlug } from '@/lib/queries/assets';
3 +import { catName } from '@/lib/taxonomy';
4 +
5 +export const runtime = 'nodejs';
6 +export const alt = 'RareIndex asset card';
7 +export const size = { width: 1200, height: 630 };
8 +export const contentType = 'image/png';
9 +
10 +function money(v: number | null): string {
11 + if (v === null) return 'Data unavailable';
12 + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: v >= 1000 ? 0 : 2 }).format(v);
13 +}
14 +
15 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
16 + const { slug } = await params;
17 + const a = await getAssetBySlug(slug);
18 + const title = a?.title ?? 'RareIndex';
19 + const cat = a ? catName(a.categorySlug) : '';
20 + const riv = a ? money(a.rivUsd) : '';
21 + const chg = a?.change30d ?? null;
22 + return new ImageResponse(
23 + (
24 + <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: 64, background: '#0b0c0e', color: '#f2f2f0', fontFamily: 'sans-serif' }}>
25 + <div style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: 28 }}>
26 + <div style={{ width: 40, height: 40, background: '#f2f2f0', color: '#0b0c0e', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, borderRadius: 6 }}>R</div>
27 + <span style={{ fontWeight: 600 }}>RareIndex</span>
28 + <span style={{ color: '#8b8b93' }}>· {cat}</span>
29 + </div>
30 + <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
31 + <div style={{ fontSize: 56, fontWeight: 600, lineHeight: 1.1, letterSpacing: -1 }}>{title.length > 80 ? `${title.slice(0, 79)}…` : title}</div>
32 + <div style={{ display: 'flex', alignItems: 'baseline', gap: 24 }}>
33 + <span style={{ fontSize: 24, color: '#8b8b93', textTransform: 'uppercase', letterSpacing: 2 }}>RareIndex Valuation</span>
34 + <span style={{ fontSize: 64, fontWeight: 700 }}>{riv}</span>
35 + {chg !== null ? <span style={{ fontSize: 32, color: chg >= 0 ? '#34c47c' : '#ef5a5f' }}>{`${chg >= 0 ? '+' : ''}${(chg * 100).toFixed(1)}% 30D`}</span> : null}
36 + </div>
37 + </div>
38 + <div style={{ fontSize: 22, color: '#6f6f76' }}>{a ? `${a.salesCount.toLocaleString('en-US')} verified sales · ${a.activeListings.toLocaleString('en-US')} active listings · www.rareindex.io` : 'www.rareindex.io'}</div>
39 + </div>
40 + ),
41 + { ...size },
42 + );
43 +}
added apps/web/src/app/asset/[slug]/page.tsx +124 −0
@@ -0,0 +1,124 @@
1 +import type { Metadata } from 'next';
2 +import { notFound } from 'next/navigation';
3 +import { Suspense } from 'react';
4 +import { Tabs } from '@/components/ui/tabs';
5 +import { Skeleton } from '@/components/ui/primitives';
6 +import { AssetHeader } from '@/components/asset/asset-header';
7 +import { ASSET_TABS, AnalysisTab, ComparablesTab, GradesTab, HistoryTab, ImagesTab, ListingsTab, OverviewTab, PopulationTab, SalesTab, SourcesTab, type AssetTab } from '@/components/asset/asset-tabs';
8 +import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts } from '@/lib/queries/assets';
9 +import { catName, categoryPath } from '@/lib/taxonomy';
10 +import { sp1, spEnum, spInt, type SP } from '@/lib/search-params';
11 +import { fmtMoney } from '@/lib/format';
12 +
13 +export const revalidate = 120;
14 +
15 +const SITE = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io';
16 +
17 +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
18 + const { slug } = await params;
19 + const a = await getAssetBySlug(slug);
20 + if (!a) return { title: 'Asset not found' };
21 + const price = a.rivUsd !== null ? ` · RIV ${fmtMoney(a.rivUsd)}` : a.latestSaleUsd !== null ? ` · last sale ${fmtMoney(a.latestSaleUsd)}` : '';
22 + const description = `${a.title}${price}. ${catName(a.categorySlug)} price history, verified sales, live listings, rarity and liquidity on RareIndex.`;
23 + return {
24 + title: a.title,
25 + description,
26 + alternates: { canonical: `${SITE}/asset/${a.slug}` },
27 + openGraph: { title: a.title, description, url: `${SITE}/asset/${a.slug}`, images: [{ url: `/asset/${a.slug}/opengraph-image`, width: 1200, height: 630 }] },
28 + twitter: { card: 'summary_large_image', title: a.title, description },
29 + };
30 +}
31 +
32 +export default async function AssetPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {
33 + const { slug } = await params;
34 + const sp = await searchParams;
35 + const found = await getAssetBySlug(slug);
36 + if (!found) notFound();
37 + const tab = spEnum<AssetTab>(sp, 'tab', ASSET_TABS, 'overview');
38 + const [variants, live] = await Promise.all([getAssetVariants(found.id), getAssetLiveCounts(found.id)]);
39 + // asset_stats is rebuilt by the valuation worker; until then fall back to live counts from canonical tables.
40 + const asset = {
41 + ...found,
42 + salesCount: Math.max(found.salesCount, live.sales),
43 + sales30d: Math.max(found.sales30d, live.sales30d),
44 + activeListings: Math.max(found.activeListings, live.listings),
45 + sourcesCount: Math.max(found.sourcesCount, live.sources),
46 + latestSaleUsd: found.latestSaleUsd ?? live.latestSaleUsd,
47 + latestSaleAt: found.latestSaleAt ?? live.latestSaleAt,
48 + minAskUsd: found.minAskUsd ?? live.minAskUsd,
49 + };
50 + const vParam = sp1(sp, 'v');
51 + const variant = vParam ? variants.find((v) => v.id === vParam) ?? null : null;
52 + const page = spInt(sp, 'page');
53 + const href = (t: AssetTab, v: string | null = variant?.id ?? null) => `/asset/${asset.slug}?tab=${t}${v ? `&v=${v}` : ''}`;
54 + const listingsForLd = await getAssetListings(asset.id, { limit: 20 });
55 + const crumbs = categoryPath(asset.categorySlug);
56 +
57 + const jsonLd = [
58 + {
59 + '@context': 'https://schema.org',
60 + '@type': 'Product',
61 + name: asset.title,
62 + image: asset.heroImageUrl ?? undefined,
63 + description: asset.description ?? `${asset.title} — ${catName(asset.categorySlug)} collectible tracked by RareIndex.`,
64 + brand: asset.brand ? { '@type': 'Brand', name: asset.brand } : undefined,
65 + category: crumbs.map((c) => c.name).join(' > '),
66 + productID: asset.id,
67 + sku: asset.identifiers.sku ?? asset.identifiers.upc ?? undefined,
68 + url: `${SITE}/asset/${asset.slug}`,
69 + ...(listingsForLd.length && listingsForLd.some((l) => l.priceUsd !== null)
70 + ? {
71 + offers: {
72 + '@type': 'AggregateOffer',
73 + priceCurrency: 'USD',
74 + lowPrice: Math.min(...listingsForLd.filter((l) => l.priceUsd !== null).map((l) => l.priceUsd!)),
75 + highPrice: Math.max(...listingsForLd.filter((l) => l.priceUsd !== null).map((l) => l.priceUsd!)),
76 + offerCount: listingsForLd.length,
77 + offers: listingsForLd.slice(0, 5).map((l) => ({ '@type': 'Offer', price: l.priceUsd, priceCurrency: 'USD', url: l.sourceUrl, availability: 'https://schema.org/InStock', seller: l.seller ? { '@type': 'Organization', name: l.seller } : undefined })),
78 + },
79 + }
80 + : {}),
81 + },
82 + {
83 + '@context': 'https://schema.org',
84 + '@type': 'BreadcrumbList',
85 + itemListElement: [{ '@type': 'ListItem', position: 1, name: 'Markets', item: `${SITE}/markets` }, ...crumbs.map((c, i) => ({ '@type': 'ListItem', position: i + 2, name: c.name, item: `${SITE}/markets/${c.slug}` })), { '@type': 'ListItem', position: crumbs.length + 2, name: asset.title, item: `${SITE}/asset/${asset.slug}` }],
86 + },
87 + ];
88 +
89 + return (
90 + <div>
91 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
92 + <AssetHeader asset={asset} variants={variants} activeVariant={variant} tabHref={(v) => href(tab, v)} />
93 + <Tabs
94 + className="mt-6 mb-4"
95 + active={tab}
96 + tabs={[
97 + { id: 'overview', label: 'Overview', href: href('overview') },
98 + { id: 'sales', label: 'Sales', count: variant ? variant.salesCount : asset.salesCount, href: href('sales') },
99 + { id: 'listings', label: 'Listings', count: variant ? variant.activeListings : asset.activeListings, href: href('listings') },
100 + { id: 'grades', label: 'Grades', count: variants.filter((v) => v.grader && v.grader !== 'raw').length || null, href: href('grades') },
101 + { id: 'population', label: 'Population', href: href('population') },
102 + { id: 'images', label: 'Images', href: href('images') },
103 + { id: 'history', label: 'History', href: href('history') },
104 + { id: 'comparables', label: 'Comparables', href: href('comparables') },
105 + { id: 'analysis', label: 'AI Analysis', href: href('analysis') },
106 + { id: 'sources', label: 'Sources', count: asset.sourcesCount || null, href: href('sources') },
107 + ]}
108 + />
109 + <Suspense key={`${tab}-${variant?.id ?? ''}-${page}`} fallback={<Skeleton className="h-96" />}>
110 + {tab === 'overview' ? <OverviewTab asset={asset} variant={variant} /> : null}
111 + {tab === 'sales' ? <SalesTab asset={asset} variant={variant} page={page} /> : null}
112 + {tab === 'listings' ? <ListingsTab asset={asset} variant={variant} /> : null}
113 + {tab === 'grades' ? <GradesTab asset={asset} /> : null}
114 + {tab === 'population' ? <PopulationTab asset={asset} /> : null}
115 + {tab === 'images' ? <ImagesTab asset={asset} /> : null}
116 + {tab === 'history' ? <HistoryTab asset={asset} variant={variant} /> : null}
117 + {tab === 'comparables' ? <ComparablesTab asset={asset} /> : null}
118 + {tab === 'analysis' ? <AnalysisTab asset={asset} /> : null}
119 + {tab === 'sources' ? <SourcesTab asset={asset} /> : null}
120 + </Suspense>
121 + <p className="mt-6 text-[11px] leading-relaxed text-subtle">Valuations are estimates with a stated confidence; listing prices are not confirmed transactions; past performance does not guarantee future results. RareIndex does not authenticate items. Data attributed to its source; marks belong to their owners.</p>
122 + </div>
123 + );
124 +}
added apps/web/src/app/auctions/calendar/page.tsx +74 −0
@@ -0,0 +1,74 @@
1 +import type { Metadata } from 'next';
2 +import { Suspense } from 'react';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Segmented } from '@/components/ui/tabs';
5 +import { FilterBar } from '@/components/ui/filter-bar';
6 +import { Card, EmptyState, Badge } from '@/components/ui/primitives';
7 +import { listAuctionHouses, listAuctions } from '@/lib/queries/market-lists';
8 +import { fmtDate, fmtRelative } from '@/lib/format';
9 +import { catName } from '@/lib/taxonomy';
10 +import { FAMILIES } from '@rareindex/taxonomy';
11 +import { sp1, spEnum, withParams, type SP } from '@/lib/search-params';
12 +
13 +export const metadata: Metadata = { title: 'Auction calendar', description: 'Upcoming collectible auctions today, this week and this month, filterable by category, house and region.' };
14 +
15 +export default async function CalendarPage({ searchParams }: { searchParams: Promise<SP> }) {
16 + const sp = await searchParams;
17 + const range = spEnum(sp, 'range', ['today', 'week', 'month'] as const, 'week');
18 + const category = sp1(sp, 'category') ?? null;
19 + const house = sp1(sp, 'house') ?? null;
20 + const now = new Date();
21 + const to = new Date(now.getTime() + (range === 'today' ? 1 : range === 'week' ? 7 : 31) * 86_400_000);
22 + const [auctions, houses] = await Promise.all([listAuctions({ from: now, to, category, house, limit: 200 }), listAuctionHouses()]);
23 + const byDay = new Map<string, typeof auctions>();
24 + for (const a of auctions) {
25 + const d = (a.endsAt ?? a.startsAt ?? now).toISOString().slice(0, 10);
26 + (byDay.get(d) ?? byDay.set(d, []).get(d)!).push(a);
27 + }
28 + const params = { category: category ?? undefined, house: house ?? undefined };
29 + return (
30 + <div>
31 + <PageHeader crumbs={[{ label: 'Auctions', href: '/auctions' }, { label: 'Calendar' }]} title="Auction calendar" description="Auctions closing in the selected window, grouped by day (UTC)." compact />
32 + <div className="mb-4 flex flex-wrap items-end justify-between gap-3">
33 + <Segmented active={range} options={[{ id: 'today', label: 'Today', href: withParams('/auctions/calendar', { ...params, range: 'today' }) }, { id: 'week', label: 'This week', href: withParams('/auctions/calendar', { ...params, range: 'week' }) }, { id: 'month', label: 'This month', href: withParams('/auctions/calendar', { ...params, range: 'month' }) }]} />
34 + <Suspense>
35 + <FilterBar fields={[{ name: 'category', label: 'Category', type: 'select', options: FAMILIES.map((f) => ({ value: f.slug, label: f.name })), width: 'w-44' }, { name: 'house', label: 'House', type: 'select', options: houses.map((h) => ({ value: h.house, label: h.house })), width: 'w-44' }]} />
36 + </Suspense>
37 + </div>
38 + {byDay.size ? (
39 + <div className="space-y-4">
40 + {[...byDay.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([day, items]) => (
41 + <Card key={day}>
42 + <div className="border-b border-border px-4 py-2 text-xs font-semibold">{fmtDate(day, { month: 'long' })}</div>
43 + <ul className="divide-y divide-border text-[12px]">
44 + {items.map((a) => (
45 + <li key={a.id} className="flex flex-wrap items-center justify-between gap-2 px-4 py-2">
46 + <div className="min-w-0">
47 + <a href={a.url} target="_blank" rel="noopener nofollow" className="font-medium text-fg hover:underline">
48 + {a.name}
49 + </a>
50 + <div className="text-[11px] text-muted">
51 + {a.auctionHouse}
52 + {a.location ? ` · ${a.location}` : ''}
53 + {a.categorySlugs.length ? ` · ${a.categorySlugs.slice(0, 3).map(catName).join(', ')}` : ''}
54 + </div>
55 + </div>
56 + <div className="flex items-center gap-2 text-[11px] text-muted">
57 + <Badge tone={a.status === 'live' ? 'gain' : 'index'}>{a.status}</Badge>
58 + <span>{a.endsAt ? `closes ${fmtDate(a.endsAt, { time: true })} (${fmtRelative(a.endsAt)})` : a.startsAt ? `starts ${fmtDate(a.startsAt, { time: true })}` : ''}</span>
59 + <span>· {a.lotsTracked} lots</span>
60 + </div>
61 + </li>
62 + ))}
63 + </ul>
64 + </Card>
65 + ))}
66 + </div>
67 + ) : (
68 + <Card>
69 + <EmptyState title="No auctions in this window" description="Auctions appear once auction-house connectors publish catalogues. Try a wider range." />
70 + </Card>
71 + )}
72 + </div>
73 + );
74 +}
added apps/web/src/app/auctions/page.tsx +109 −0
@@ -0,0 +1,109 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
5 +import { Tabs } from '@/components/ui/tabs';
6 +import { LotsTable } from '@/components/market/sales-table';
7 +import { listAuctionHouses, listAuctions, listLots } from '@/lib/queries/market-lists';
8 +import { fmtDate, fmtNum, fmtRelative, cn } from '@/lib/format';
9 +import { catName } from '@/lib/taxonomy';
10 +import { spEnum, type SP } from '@/lib/search-params';
11 +
12 +export const metadata: Metadata = { title: 'Auctions', description: 'Auction houses, live and upcoming lots, ending-soon lots and results across the collectibles market.' };
13 +
14 +export default async function AuctionsPage({ searchParams }: { searchParams: Promise<SP> }) {
15 + const sp = await searchParams;
16 + const tab = spEnum(sp, 'tab', ['ending', 'live', 'upcoming', 'results', 'houses'] as const, 'ending');
17 + const [houses, ending, lots, auctions] = await Promise.all([
18 + listAuctionHouses(),
19 + listLots({ endingWithinHours: 72, limit: 60 }),
20 + tab === 'results' ? listLots({ status: 'ended', limit: 60, sort: 'value' }) : tab === 'live' ? listLots({ status: 'live', limit: 60 }) : tab === 'upcoming' ? listLots({ status: 'upcoming', limit: 60 }) : Promise.resolve([]),
21 + listAuctions({ limit: 100 }),
22 + ]);
23 + return (
24 + <div>
25 + <PageHeader
26 + title="Auctions"
27 + description="Lots aggregated from auction-house connectors. Estimates and bids are the house’s own figures; hammer prices flow into Sales once an auction ends and the lot is matched to a canonical asset."
28 + compact
29 + actions={
30 + <Link href="/auctions/calendar" className="rounded-md border border-border px-2.5 py-1.5 text-xs font-medium hover:bg-inset">
31 + Auction calendar →
32 + </Link>
33 + }
34 + />
35 + <Tabs
36 + active={tab}
37 + tabs={[
38 + { id: 'ending', label: 'Ending soon', count: ending.length, href: '/auctions?tab=ending' },
39 + { id: 'live', label: 'Live', href: '/auctions?tab=live' },
40 + { id: 'upcoming', label: 'Upcoming', href: '/auctions?tab=upcoming' },
41 + { id: 'results', label: 'Results', href: '/auctions?tab=results' },
42 + { id: 'houses', label: 'Auction houses', count: houses.length, href: '/auctions?tab=houses' },
43 + ]}
44 + className="mb-4"
45 + />
46 + {tab === 'houses' ? (
47 + <Card className="overflow-hidden">
48 + <CardHeader title="Auction houses" subtitle="Sources with auction connectors" />
49 + {houses.length ? (
50 + <Table>
51 + <thead>
52 + <tr>
53 + <th className={th}>House</th>
54 + <th className={cn(th, 'text-right')}>Auctions</th>
55 + <th className={cn(th, 'text-right')}>Lots tracked</th>
56 + <th className={cn(th, 'text-right')}>Open</th>
57 + </tr>
58 + </thead>
59 + <tbody>
60 + {houses.map((h) => (
61 + <tr key={h.house}>
62 + <td className={cn(td, 'font-medium')}>{h.house}</td>
63 + <td className={tdNum}>{fmtNum(h.auctions)}</td>
64 + <td className={tdNum}>{fmtNum(h.lots)}</td>
65 + <td className={tdNum}>{fmtNum(h.upcoming)}</td>
66 + </tr>
67 + ))}
68 + </tbody>
69 + </Table>
70 + ) : (
71 + <EmptyState title="No auction houses connected yet" description="Auction-house connectors (results and catalogues) are part of the Phase 1 connector roadmap." />
72 + )}
73 + </Card>
74 + ) : (
75 + <div className="grid gap-4 xl:grid-cols-[2fr_1fr]">
76 + <Card className="overflow-hidden">
77 + <CardHeader title={tab === 'ending' ? 'Lots ending in the next 72 hours' : tab === 'results' ? 'Recent results by value' : `${tab[0]!.toUpperCase()}${tab.slice(1)} lots`} />
78 + <LotsTable items={tab === 'ending' ? ending : lots} />
79 + </Card>
80 + <Card className="overflow-hidden">
81 + <CardHeader title="Auctions" subtitle="Catalogues tracked" />
82 + {auctions.length ? (
83 + <ul className="divide-y divide-border text-[12px]">
84 + {auctions.slice(0, 40).map((a) => (
85 + <li key={a.id} className="px-4 py-2">
86 + <a href={a.url} target="_blank" rel="noopener nofollow" className="font-medium text-fg hover:underline">
87 + {a.name}
88 + </a>
89 + <div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-[11px] text-muted">
90 + <Badge tone={a.status === 'live' ? 'gain' : a.status === 'upcoming' ? 'index' : 'neutral'}>{a.status}</Badge>
91 + <span>{a.auctionHouse}</span>
92 + <span>· {a.endsAt ? `ends ${fmtDate(a.endsAt)} (${fmtRelative(a.endsAt)})` : a.startsAt ? `starts ${fmtDate(a.startsAt)}` : 'dates n/a'}</span>
93 + <span>· {a.lotsTracked} lots{a.lotCount ? ` / ${a.lotCount}` : ''}</span>
94 + {a.categorySlugs.slice(0, 2).map((c) => (
95 + <span key={c}>· {catName(c)}</span>
96 + ))}
97 + </div>
98 + </li>
99 + ))}
100 + </ul>
101 + ) : (
102 + <EmptyState title="No auctions tracked" className="py-8" />
103 + )}
104 + </Card>
105 + </div>
106 + )}
107 + </div>
108 + );
109 +}
added apps/web/src/app/brand/[brand]/page.tsx +63 −0
@@ -0,0 +1,63 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { PageHeader } from '@/components/ui/page-header';
5 +import { Card, Stat } from '@/components/ui/primitives';
6 +import { AssetList } from '@/components/market/asset-list';
7 +import { Pagination } from '@/components/ui/pagination';
8 +import { getBrandStats } from '@/lib/queries/markets';
9 +import { exploreAssets, type ExploreSort } from '@/lib/queries/assets';
10 +import { catName } from '@/lib/taxonomy';
11 +import { fmtMoney, fmtNum } from '@/lib/format';
12 +import { spEnum, spInt, type SP } from '@/lib/search-params';
13 +
14 +export const revalidate = 300;
15 +
16 +export async function generateMetadata({ params }: { params: Promise<{ brand: string }> }): Promise<Metadata> {
17 + const { brand } = await params;
18 + const name = decodeURIComponent(brand);
19 + return { title: `${name} collectibles`, description: `${name}: tracked collectible assets, valuations, sales and listings on RareIndex.` };
20 +}
21 +
22 +export default async function BrandPage({ params, searchParams }: { params: Promise<{ brand: string }>; searchParams: Promise<SP> }) {
23 + const { brand } = await params;
24 + const sp = await searchParams;
25 + const name = decodeURIComponent(brand);
26 + const stats = await getBrandStats(name);
27 + if (!stats) notFound();
28 + const sort = spEnum<ExploreSort>(sp, 'sort', ['riv', 'change30d', 'sales', 'newest', 'latest_sale'], 'riv');
29 + const page = spInt(sp, 'page');
30 + const res = await exploreAssets({ brand: name, sort, page, pageSize: 60 });
31 + return (
32 + <div>
33 + <PageHeader
34 + crumbs={[{ label: 'Markets', href: '/markets' }, { label: stats.brand }]}
35 + title={stats.brand}
36 + description="Brand view across every category where this brand appears."
37 + meta={stats.categories.map((c) => (
38 + <Link key={c} href={`/markets/${c}`} className="rounded-sm bg-inset px-1.5 py-0.5 hover:text-fg">
39 + {catName(c)}
40 + </Link>
41 + ))}
42 + />
43 + <section className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-3 lg:grid-cols-6">
44 + {[
45 + ['Tracked assets', fmtNum(stats.assets)],
46 + ['With valuation', fmtNum(stats.priced)],
47 + ['Sales', fmtNum(stats.sales)],
48 + ['Active listings', fmtNum(stats.listings)],
49 + ['Median RIV', stats.medianRivUsd != null ? fmtMoney(stats.medianRivUsd) : '—'],
50 + ['Top RIV', stats.maxRivUsd != null ? fmtMoney(stats.maxRivUsd) : '—'],
51 + ].map(([k, v]) => (
52 + <div key={k} className="bg-elevated px-3 py-2.5">
53 + <Stat label={k} value={v} />
54 + </div>
55 + ))}
56 + </section>
57 + <Card className="mt-4 overflow-hidden">
58 + <AssetList items={res.items} rank startRank={(page - 1) * 60 + 1} />
59 + </Card>
60 + <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath={`/brand/${brand}`} params={{ sort }} />
61 + </div>
62 + );
63 +}
added apps/web/src/app/categories/page.tsx +72 −0
@@ -0,0 +1,72 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Badge } from '@/components/ui/primitives';
5 +import { getCategoryCounts } from '@/lib/queries/markets';
6 +import { FAMILIES, getCategory } from '@rareindex/taxonomy';
7 +import { fmtNum } from '@/lib/format';
8 +import { INDICES } from '@rareindex/taxonomy';
9 +
10 +export const metadata: Metadata = { title: 'Categories', description: 'The RareIndex collectible taxonomy: every family and subcategory tracked, with launch phase and live coverage.' };
11 +export const revalidate = 300;
12 +
13 +export default async function CategoriesPage() {
14 + const counts = await getCategoryCounts();
15 + const total = (slugs: string[]) => slugs.reduce((a, s) => a + (counts.get(s)?.assets ?? 0), 0);
16 + const groups = [1, 2, 3].map((phase) => ({ phase, families: FAMILIES.filter((f) => f.phase === phase) }));
17 + return (
18 + <div>
19 + <PageHeader title="Categories" description={`An extensible taxonomy of ${FAMILIES.length} collectible families. Phase 1 is the launch wedge; every family is architected for data from day one. New categories are proposed automatically from market data and approved by an editor.`} />
20 + {groups.map((g) => (
21 + <section key={g.phase} className="mb-8">
22 + <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
23 + Phase {g.phase}
24 + <span className="text-xs font-normal text-muted">{g.phase === 1 ? 'Launch wedge' : g.phase === 2 ? 'Next expansion' : 'Full universe'}</span>
25 + <span className="num text-xs font-normal text-subtle">{g.families.length} families</span>
26 + </h2>
27 + <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
28 + {g.families.map((f) => {
29 + const assets = total([f.slug, ...f.children.flatMap((c) => [c, ...(getCategory(c)?.children ?? [])])]);
30 + const idx = INDICES.find((i) => i.ticker === f.index);
31 + return (
32 + <div key={f.slug} className="card flex flex-col p-3">
33 + <div className="flex items-start justify-between gap-2">
34 + <Link href={`/markets/${f.slug}`} className="text-[13px] font-semibold text-fg hover:underline">
35 + {f.name}
36 + </Link>
37 + {idx ? (
38 + <Link href={`/rareindex/${idx.ticker}`} className="shrink-0">
39 + <Badge tone="index">{idx.ticker}</Badge>
40 + </Link>
41 + ) : null}
42 + </div>
43 + {f.description ? <p className="mt-1 line-clamp-2 text-[11px] text-muted">{f.description}</p> : null}
44 + {f.children.length ? (
45 + <ul className="mt-2 flex flex-wrap gap-1">
46 + {f.children.map((c) => {
47 + const n = getCategory(c)!;
48 + const cnt = counts.get(c)?.assets ?? 0;
49 + return (
50 + <li key={c}>
51 + <Link href={`/markets/${c}`} className="rounded-sm bg-inset px-1.5 py-0.5 text-[11px] text-muted hover:text-fg">
52 + {n.name}
53 + {cnt ? <span className="num ml-1 text-subtle">{fmtNum(cnt, { compact: true })}</span> : null}
54 + </Link>
55 + </li>
56 + );
57 + })}
58 + </ul>
59 + ) : null}
60 + <div className="mt-auto flex items-center justify-between pt-2 text-[11px] text-subtle">
61 + <span className="num">{assets ? `${fmtNum(assets)} assets` : 'no data yet'}</span>
62 + <span>{f.conditionScale ? `scale: ${f.conditionScale}` : ''}</span>
63 + </div>
64 + </div>
65 + );
66 + })}
67 + </div>
68 + </section>
69 + ))}
70 + </div>
71 + );
72 +}
added apps/web/src/app/compare/compare-form.tsx +109 −0
@@ -0,0 +1,109 @@
1 +'use client';
2 +
3 +import { useRouter } from 'next/navigation';
4 +import { useEffect, useState } from 'react';
5 +
6 +interface Suggestion {
7 + type: string;
8 + label: string;
9 + sublabel: string | null;
10 + href: string;
11 +}
12 +
13 +const SLOTS = ['a', 'b', 'c', 'd'] as const;
14 +
15 +/** Four slot pickers with suggestions; values are asset slugs or index tickers, stored in the URL. */
16 +export function CompareForm({ initial }: { initial: string[] }) {
17 + const router = useRouter();
18 + const [values, setValues] = useState<string[]>([...initial, '', '', '', ''].slice(0, 4));
19 + const [typing, setTyping] = useState<{ i: number; q: string } | null>(null);
20 + const [items, setItems] = useState<Suggestion[]>([]);
21 +
22 + useEffect(() => {
23 + if (!typing || typing.q.trim().length < 2) return;
24 + const ctrl = new AbortController();
25 + const t = setTimeout(() => {
26 + fetch(`/api/search/suggest?q=${encodeURIComponent(typing.q)}`, { signal: ctrl.signal })
27 + .then((r) => r.json())
28 + .then((d: { items: Suggestion[] }) => setItems((d.items ?? []).filter((x) => x.type === 'asset' || x.type === 'index')))
29 + .catch(() => {});
30 + }, 150);
31 + return () => {
32 + clearTimeout(t);
33 + ctrl.abort();
34 + };
35 + }, [typing]);
36 +
37 + const apply = (vals: string[]) => {
38 + const sp = new URLSearchParams();
39 + vals.forEach((v, i) => {
40 + if (v) sp.set(SLOTS[i]!, v);
41 + });
42 + router.push(`/compare?${sp.toString()}`);
43 + };
44 +
45 + return (
46 + <form
47 + className="card grid gap-2 p-3 sm:grid-cols-4"
48 + onSubmit={(e) => {
49 + e.preventDefault();
50 + apply(values);
51 + }}
52 + >
53 + {SLOTS.map((s, i) => (
54 + <label key={s} className="relative flex flex-col gap-0.5 text-[10px] font-medium uppercase tracking-wider text-subtle">
55 + Slot {s.toUpperCase()}
56 + <input
57 + value={values[i] ?? ''}
58 + onChange={(e) => {
59 + const v = [...values];
60 + v[i] = e.target.value;
61 + setValues(v);
62 + setTyping({ i, q: e.target.value });
63 + if (e.target.value.trim().length < 2) setItems([]);
64 + }}
65 + onBlur={() =>
66 + setTimeout(() => {
67 + setTyping(null);
68 + setItems([]);
69 + }, 150)
70 + }
71 + placeholder="asset slug or RARE-TCG"
72 + className="h-9 rounded-md border border-border bg-elevated px-2 text-[13px] normal-case tracking-normal text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none"
73 + />
74 + {typing?.i === i && items.length ? (
75 + <ul className="absolute left-0 top-full z-20 mt-1 w-full overflow-hidden rounded-md border border-border bg-elevated text-[12px] normal-case tracking-normal shadow-pop">
76 + {items.map((it) => {
77 + const val = it.href.startsWith('/asset/') ? it.href.slice(7) : it.href.startsWith('/rareindex/') ? it.href.slice(11) : it.label;
78 + return (
79 + <li key={it.href}>
80 + <button
81 + type="button"
82 + onMouseDown={(e) => e.preventDefault()}
83 + onClick={() => {
84 + const v = [...values];
85 + v[i] = val;
86 + setValues(v);
87 + setTyping(null);
88 + apply(v);
89 + }}
90 + className="block w-full truncate px-2 py-1.5 text-left text-fg hover:bg-sunken"
91 + >
92 + {it.label} <span className="text-subtle">· {it.sublabel}</span>
93 + </button>
94 + </li>
95 + );
96 + })}
97 + </ul>
98 + ) : null}
99 + </label>
100 + ))}
101 + <div className="sm:col-span-4 flex items-center justify-between text-[11px] text-subtle">
102 + <span>Tip: paste slugs from asset URLs; tickers: RARE, RARE-TCG, RARE-SPORT, RARE-WATCH, RARE-SNEAKER, RARE-LEGO, RARE-COMIC, RARE-GAME…</span>
103 + <button type="submit" className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg">
104 + Compare
105 + </button>
106 + </div>
107 + </form>
108 + );
109 +}
added apps/web/src/app/compare/page.tsx +101 −0
@@ -0,0 +1,101 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum } from '@/components/ui/primitives';
5 +import { LineChart } from '@/components/charts/line-chart';
6 +import { getAssetsBySlugs, getAssetSnapshots } from '@/lib/queries/assets';
7 +import { getIndex, getIndexSeries } from '@/lib/queries/indices';
8 +import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format';
9 +import { catName } from '@/lib/taxonomy';
10 +import type { SP } from '@/lib/search-params';
11 +import { CompareForm } from './compare-form';
12 +
13 +export const metadata: Metadata = { title: 'Compare', description: 'Compare up to four collectible assets or RareIndex indices side by side: indexed performance and key metrics.' };
14 +
15 +const SLOTS = ['a', 'b', 'c', 'd'] as const;
16 +
17 +export 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 +}
added apps/web/src/app/data/page.tsx +190 −0
@@ -0,0 +1,190 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { readFile } from 'node:fs/promises';
4 +import path from 'node:path';
5 +import { PageHeader } from '@/components/ui/page-header';
6 +import { Card, CardHeader, Table, th, td, tdNum, Badge, EmptyState, Stat } from '@/components/ui/primitives';
7 +import { getSiteStats } from '@/lib/queries/site';
8 +import { getConnectorCoverage, getFxCoverage } from '@/lib/queries/market-lists';
9 +import { getMarketRows } from '@/lib/queries/markets';
10 +import { fmtNum, fmtRelative, cn } from '@/lib/format';
11 +import { catName, humanize } from '@/lib/taxonomy';
12 +
13 +export const metadata: Metadata = { title: 'Data & methodology', description: 'Coverage by category, source and connector; RareIndex methodology; exports and API access.' };
14 +export const revalidate = 300;
15 +
16 +async function loadMethodology(): Promise<string> {
17 + const candidates = [path.resolve(process.cwd(), '../../docs/METHODOLOGY.md'), path.resolve(process.cwd(), 'docs/METHODOLOGY.md'), path.resolve(process.cwd(), '../docs/METHODOLOGY.md')];
18 + for (const p of candidates) {
19 + try {
20 + return await readFile(p, 'utf8');
21 + } catch {
22 + /* try next */
23 + }
24 + }
25 + return '';
26 +}
27 +
28 +/** Minimal markdown → JSX for headings, paragraphs and bullet lists (methodology doc is controlled content). */
29 +function Markdown({ text }: { text: string }) {
30 + const blocks = text.split(/\n{2,}/);
31 + return (
32 + <div className="prose-ri space-y-3 text-[13px] leading-relaxed text-muted">
33 + {blocks.map((b, i) => {
34 + const t = b.trim();
35 + if (!t) return null;
36 + if (t.startsWith('# ')) return <h2 key={i} className="text-lg font-semibold text-fg">{t.slice(2)}</h2>;
37 + if (t.startsWith('## ')) return <h3 key={i} id={t.slice(3).toLowerCase().replace(/[^a-z0-9]+/g, '-')} className="pt-2 text-sm font-semibold text-fg">{t.slice(3)}</h3>;
38 + if (t.startsWith('- ')) return <ul key={i} className="list-disc space-y-1 pl-5">{t.split('\n').map((l, j) => <li key={j} dangerouslySetInnerHTML={{ __html: inline(l.replace(/^- /, '')) }} />)}</ul>;
39 + return <p key={i} dangerouslySetInnerHTML={{ __html: inline(t.replace(/\n/g, ' ')) }} />;
40 + })}
41 + </div>
42 + );
43 +}
44 +function inline(s: string): string {
45 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\*\*(.+?)\*\*/g, '<strong class="text-fg">$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/`(.+?)`/g, '<code class="rounded-sm bg-inset px-1">$1</code>');
46 +}
47 +
48 +export default async function DataPage() {
49 + const [stats, connectors, fx, rows, md] = await Promise.all([getSiteStats(), getConnectorCoverage(), getFxCoverage(), getMarketRows(), loadMethodology()]);
50 + const active = rows.filter((r) => r.counts.assets > 0);
51 + return (
52 + <div>
53 + <PageHeader title="Data" description="What RareIndex covers today, where it comes from, and how the numbers are computed. Coverage figures are live counts." />
54 + <section className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-4 lg:grid-cols-8">
55 + {[
56 + ['Assets', fmtNum(stats.assets)],
57 + ['Sales', fmtNum(stats.sales)],
58 + ['Observations', fmtNum(stats.observations)],
59 + ['Active listings', fmtNum(stats.activeListings)],
60 + ['Sources', fmtNum(stats.sources)],
61 + ['Connectors', fmtNum(stats.connectors)],
62 + ['Categories with data', `${active.length}/${rows.length}`],
63 + ['FX rates', fx.rows ? `${fmtNum(fx.rows)} · ${fx.first}→${fx.last}` : '—'],
64 + ].map(([k, v]) => (
65 + <div key={k} className="bg-elevated px-3 py-2.5">
66 + <Stat label={k} value={v} />
67 + </div>
68 + ))}
69 + </section>
70 +
71 + <Card className="mt-4 overflow-hidden">
72 + <CardHeader title="Connectors & sources" subtitle="Every source, what it contributes and its health. Trust scores weight sources in valuations." />
73 + {connectors.length ? (
74 + <Table>
75 + <thead>
76 + <tr>
77 + <th className={th}>Connector</th>
78 + <th className={th}>Source</th>
79 + <th className={th}>Type</th>
80 + <th className={th}>Contributes</th>
81 + <th className={th}>Categories</th>
82 + <th className={cn(th, 'text-right')}>Trust</th>
83 + <th className={cn(th, 'text-right')}>Raw</th>
84 + <th className={cn(th, 'text-right')}>Sales</th>
85 + <th className={cn(th, 'text-right')}>Listings</th>
86 + <th className={cn(th, 'text-right')}>Observations</th>
87 + <th className={th}>Health</th>
88 + <th className={th}>Last success</th>
89 + </tr>
90 + </thead>
91 + <tbody>
92 + {connectors.map((c) => (
93 + <tr key={c.id} className="hover:bg-sunken">
94 + <td className={cn(td, 'font-medium')}>{c.displayName}</td>
95 + <td className={td}>
96 + {c.sourceUrl ? (
97 + <a href={c.sourceUrl} target="_blank" rel="noopener nofollow" className="text-muted hover:text-fg">
98 + {c.sourceName} ↗
99 + </a>
100 + ) : (
101 + c.sourceName
102 + )}
103 + </td>
104 + <td className={cn(td, 'text-muted')}>{humanize(c.sourceType)}</td>
105 + <td className={cn(td, 'text-[11px] text-muted')}>{[c.supportsSold && 'sales', c.supportsListings && 'listings', c.supportsCatalog && 'catalog', c.supportsAuctions && 'auctions', c.supportsPopulation && 'population'].filter(Boolean).join(' · ') || '—'}</td>
106 + <td className={cn(td, 'max-w-[220px] truncate text-[11px] text-muted')} title={c.categories.map(catName).join(', ')}>
107 + {c.categories.map(catName).join(', ')}
108 + </td>
109 + <td className={tdNum}>{Math.round(c.trustScore * 100)}%</td>
110 + <td className={tdNum}>{fmtNum(c.rawRecords)}</td>
111 + <td className={tdNum}>{fmtNum(c.sales)}</td>
112 + <td className={tdNum}>{fmtNum(c.listings)}</td>
113 + <td className={tdNum}>{fmtNum(c.observations)}</td>
114 + <td className={td}>
115 + <Badge tone={c.healthStatus === 'healthy' ? 'gain' : c.healthStatus === 'degraded' ? 'alert' : c.healthStatus === 'failing' ? 'loss' : 'neutral'}>{c.healthStatus ?? c.status}</Badge>
116 + </td>
117 + <td className={cn(td, 'text-muted')}>{c.lastSuccessAt ? fmtRelative(c.lastSuccessAt) : 'never'}</td>
118 + </tr>
119 + ))}
120 + </tbody>
121 + </Table>
122 + ) : (
123 + <EmptyState title="No connectors registered yet" description="Connectors are declared in the registry and mirrored here after seeding." />
124 + )}
125 + </Card>
126 +
127 + <Card className="mt-4 overflow-hidden">
128 + <CardHeader title="Coverage by category" subtitle="Live counts from canonical tables" />
129 + <Table>
130 + <thead>
131 + <tr>
132 + <th className={th}>Category</th>
133 + <th className={cn(th, 'text-right')}>Assets</th>
134 + <th className={cn(th, 'text-right')}>Priced</th>
135 + <th className={cn(th, 'text-right')}>Sales</th>
136 + <th className={cn(th, 'text-right')}>Listings</th>
137 + <th className={cn(th, 'text-right')}>Observations</th>
138 + <th className={th}>Last sale</th>
139 + </tr>
140 + </thead>
141 + <tbody>
142 + {[...rows].sort((a, b) => b.counts.assets - a.counts.assets).map((r) => (
143 + <tr key={r.node.slug} className={cn('hover:bg-sunken', r.counts.assets === 0 && 'text-subtle')}>
144 + <td className={td}>
145 + <Link href={`/markets/${r.node.slug}`} className="hover:underline">
146 + {r.node.name}
147 + </Link>
148 + <Badge className="ml-2">P{r.node.phase}</Badge>
149 + </td>
150 + <td className={tdNum}>{fmtNum(r.counts.assets)}</td>
151 + <td className={tdNum}>{fmtNum(r.counts.priced)}</td>
152 + <td className={tdNum}>{fmtNum(r.counts.sales)}</td>
153 + <td className={tdNum}>{fmtNum(r.counts.listings)}</td>
154 + <td className={tdNum}>{fmtNum(r.counts.observations)}</td>
155 + <td className={cn(td, 'text-muted')}>{r.counts.lastSaleAt ? fmtRelative(r.counts.lastSaleAt) : '—'}</td>
156 + </tr>
157 + ))}
158 + </tbody>
159 + </Table>
160 + </Card>
161 +
162 + <div className="mt-4 grid gap-4 lg:grid-cols-[2fr_1fr]">
163 + <Card className="p-5" as="article">
164 + <div id="methodology" />
165 + {md ? <Markdown text={md} /> : <EmptyState title="Methodology document not found" />}
166 + </Card>
167 + <Card className="p-5 self-start">
168 + <h2 className="text-sm font-semibold">Exports & API</h2>
169 + <p className="mt-1 text-[12px] leading-relaxed text-muted">Professional and research users can export comparable sales, price histories and index series as CSV or JSON through the public API. Each export carries source attribution and the timestamp of the underlying observations.</p>
170 + <ul className="mt-3 space-y-1 text-[12px]">
171 + <li>
172 + <code className="rounded-sm bg-inset px-1">GET /v1/assets/:id/sales?format=csv</code>
173 + </li>
174 + <li>
175 + <code className="rounded-sm bg-inset px-1">GET /v1/assets/:id/history?format=csv</code>
176 + </li>
177 + <li>
178 + <code className="rounded-sm bg-inset px-1">GET /v1/indices/:ticker/history?format=json</code>
179 + </li>
180 + </ul>
181 + <Link href="/api-docs" className="mt-3 inline-block text-xs font-medium text-fg underline">
182 + API documentation →
183 + </Link>
184 + <h3 className="mt-5 text-sm font-semibold">Corrections</h3>
185 + <p className="mt-1 text-[12px] leading-relaxed text-muted">Spotted a misidentified item, a duplicate or a wrong price? Every record links to its source; write to data@rareindex.io with the asset URL and we will review it against the audit log.</p>
186 + </Card>
187 + </div>
188 + </div>
189 + );
190 +}
added apps/web/src/app/explore/page.tsx +120 −0
@@ -0,0 +1,120 @@
1 +import type { Metadata } from 'next';
2 +import { Suspense } from 'react';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { FilterBar } from '@/components/ui/filter-bar';
5 +import { Pagination } from '@/components/ui/pagination';
6 +import { Segmented } from '@/components/ui/tabs';
7 +import { AssetCard, AssetList } from '@/components/market/asset-list';
8 +import { Skeleton } from '@/components/ui/primitives';
9 +import { exploreAssets, type ExploreSort } from '@/lib/queries/assets';
10 +import { CATEGORIES, FAMILIES, GRADERS } from '@rareindex/taxonomy';
11 +import { pick, sp1, spEnum, spInt, spNum, withParams, type SP } from '@/lib/search-params';
12 +
13 +export const metadata: Metadata = { title: 'Explore collectibles', description: 'Browse tracked collectible assets across every category, filtered by grade, price, liquidity, rarity and momentum.' };
14 +
15 +const SORTS: ExploreSort[] = ['riv', 'change7d', 'change30d', 'sales', 'liquidity', 'rarity', 'trending', 'newest', 'latest_sale', 'opportunity'];
16 +const SORT_LABEL: Record<ExploreSort, string> = { riv: 'Valuation', change7d: '7D change', change30d: '30D change', sales: 'Sales', liquidity: 'Liquidity', rarity: 'Rarity', trending: 'Trending', newest: 'Newest', latest_sale: 'Last sale', opportunity: 'Value opportunity' };
17 +const KEYS = ['category', 'grader', 'grade', 'min', 'max', 'liq', 'rar', 'mom', 'from', 'to', 'brand', 'set', 'q', 'sort', 'view'];
18 +
19 +export default async function ExplorePage({ searchParams }: { searchParams: Promise<SP> }) {
20 + const sp = await searchParams;
21 + const sort = spEnum(sp, 'sort', SORTS, 'riv');
22 + const view = spEnum(sp, 'view', ['table', 'grid'] as const, 'table');
23 + const page = spInt(sp, 'page');
24 + const filters = {
25 + category: sp1(sp, 'category') ?? null,
26 + grader: sp1(sp, 'grader') ?? null,
27 + grade: sp1(sp, 'grade') ?? null,
28 + priceMin: spNum(sp, 'min'),
29 + priceMax: spNum(sp, 'max'),
30 + liquidityMin: spNum(sp, 'liq'),
31 + rarityMin: spNum(sp, 'rar'),
32 + momentumMin: spNum(sp, 'mom'),
33 + yearFrom: spNum(sp, 'from'),
34 + yearTo: spNum(sp, 'to'),
35 + brand: sp1(sp, 'brand') ?? null,
36 + set: sp1(sp, 'set') ?? null,
37 + q: sp1(sp, 'q') ?? null,
38 + sort,
39 + page,
40 + pageSize: 48,
41 + };
42 + const categoryOptions = CATEGORIES.map((c) => ({ value: c.slug, label: `${' '.repeat(c.level)}${c.level ? '↳ ' : ''}${c.name}` }));
43 + const params = pick(sp, KEYS);
44 + return (
45 + <div>
46 + <PageHeader title="Explore" description="Every tracked collectible asset, ranked by RareIndex Valuation, momentum, liquidity or rarity. Filters are reflected in the URL." compact />
47 + <div className="mb-3 flex flex-wrap items-end justify-between gap-3">
48 + <Suspense>
49 + <FilterBar
50 + fields={[
51 + { name: 'q', label: 'Search', type: 'text', placeholder: 'title contains…', width: 'w-44' },
52 + { name: 'category', label: 'Category', type: 'select', options: categoryOptions, width: 'w-52' },
53 + { name: 'grader', label: 'Grader', type: 'select', options: GRADERS.filter((g) => g.slug !== 'raw').map((g) => ({ value: g.slug, label: g.name })), width: 'w-32' },
54 + { name: 'grade', label: 'Grade', type: 'text', placeholder: 'e.g. 10', width: 'w-20' },
55 + { name: 'min', label: 'Min RIV $', type: 'number', placeholder: '0', width: 'w-24' },
56 + { name: 'max', label: 'Max RIV $', type: 'number', placeholder: '∞', width: 'w-24' },
57 + { name: 'liq', label: 'Liquidity ≥', type: 'number', placeholder: '0–100', width: 'w-20' },
58 + { name: 'rar', label: 'Rarity ≥', type: 'number', placeholder: '0–100', width: 'w-20' },
59 + { name: 'from', label: 'Year from', type: 'number', placeholder: '1900', width: 'w-20' },
60 + { name: 'to', label: 'Year to', type: 'number', placeholder: '2026', width: 'w-20' },
61 + ]}
62 + />
63 + </Suspense>
64 + <div className="flex items-center gap-2">
65 + <Segmented active={view} options={[{ id: 'table', label: 'Table', href: withParams('/explore', { ...params, view: 'table', page: null }) }, { id: 'grid', label: 'Cards', href: withParams('/explore', { ...params, view: 'grid', page: null }) }]} />
66 + <label className="flex items-center gap-1 text-[11px] text-subtle">
67 + Sort
68 + <SortSelect current={sort} params={params} />
69 + </label>
70 + </div>
71 + </div>
72 + <Suspense fallback={<Skeleton className="h-96" />}>
73 + <Results filters={filters} view={view} params={params} />
74 + </Suspense>
75 + <div className="mt-3 flex flex-wrap gap-1.5 text-[11px] text-muted">
76 + <span>Families:</span>
77 + {FAMILIES.filter((f) => f.phase === 1).map((f) => (
78 + <a key={f.slug} href={withParams('/explore', { ...params, category: f.slug, page: null })} className="rounded-full border border-border px-2 py-0.5 hover:border-border-strong hover:text-fg">
79 + {f.name}
80 + </a>
81 + ))}
82 + </div>
83 + </div>
84 + );
85 +}
86 +
87 +function SortSelect({ current, params }: { current: ExploreSort; params: Record<string, string | undefined> }) {
88 + return (
89 + <span className="inline-flex flex-wrap gap-1">
90 + {SORTS.map((s) => (
91 + <a key={s} href={withParams('/explore', { ...params, sort: s, page: null })} className={`rounded-sm px-1.5 py-0.5 ${s === current ? 'bg-inset font-medium text-fg' : 'hover:text-fg'}`}>
92 + {SORT_LABEL[s]}
93 + </a>
94 + ))}
95 + </span>
96 + );
97 +}
98 +
99 +async function Results({ filters, view, params }: { filters: Parameters<typeof exploreAssets>[0]; view: 'table' | 'grid'; params: Record<string, string | undefined> }) {
100 + const res = await exploreAssets(filters);
101 + const columns = filters.sort === 'rarity' || filters.sort === 'trending' || filters.sort === 'opportunity' ? (['riv', 'change30d', 'sales', 'liquidity', 'rarity', 'trending', 'opportunity'] as const) : (['riv', 'change1d', 'change7d', 'change30d', 'latestSale', 'sales', 'listings', 'liquidity'] as const);
102 + return (
103 + <>
104 + <p className="mb-2 num text-[11px] text-subtle">{res.total.toLocaleString('en-US')} assets</p>
105 + {view === 'grid' ? (
106 + <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
107 + {res.items.map((a) => (
108 + <AssetCard key={a.id} a={a} />
109 + ))}
110 + {!res.items.length ? <div className="col-span-full"><AssetList items={[]} /></div> : null}
111 + </div>
112 + ) : (
113 + <div className="card overflow-hidden">
114 + <AssetList items={res.items} columns={[...columns]} rank startRank={(res.page - 1) * res.pageSize + 1} />
115 + </div>
116 + )}
117 + <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath="/explore" params={params} />
118 + </>
119 + );
120 +}
modified apps/web/src/app/globals.css +19 −0
@@ -30,6 +30,11 @@
30 30 --ri-alert: #b45309;
31 31 --ri-alert-bg: #fdf1e3;
32 32 --ri-gold: #a16207;
33 + --ri-series-1: #2f5bd6;
34 + --ri-series-2: #c2410c;
35 + --ri-series-3: #0e9384;
36 + --ri-series-4: #7c3aed;
37 + --ri-series-muted: #b8b8bd;
33 38 --ri-ring: rgba(11, 11, 12, 0.18);
34 39 --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 1px rgba(0, 0, 0, 0.03);
35 40 --ri-shadow-lg: 0 10px 30px -12px rgba(0, 0, 0, 0.18);
@@ -63,6 +68,11 @@ html[data-theme="dark"] {
63 68 --ri-alert: #f0a34c;
64 69 --ri-alert-bg: rgba(240, 163, 76, 0.12);
65 70 --ri-gold: #d4a53a;
71 + --ri-series-1: #5b7fe6;
72 + --ri-series-2: #cf7a1f;
73 + --ri-series-3: #199c8a;
74 + --ri-series-4: #8f6fe8;
75 + --ri-series-muted: #4a4b52;
66 76 --ri-ring: rgba(242, 242, 240, 0.22);
67 77 --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
68 78 --ri-shadow-lg: 0 14px 40px -14px rgba(0, 0, 0, 0.7);
@@ -94,6 +104,11 @@ html[data-theme="dark"] {
94 104 --ri-alert: #f0a34c;
95 105 --ri-alert-bg: rgba(240, 163, 76, 0.12);
96 106 --ri-gold: #d4a53a;
107 + --ri-series-1: #5b7fe6;
108 + --ri-series-2: #cf7a1f;
109 + --ri-series-3: #199c8a;
110 + --ri-series-4: #8f6fe8;
111 + --ri-series-muted: #4a4b52;
97 112 --ri-ring: rgba(242, 242, 240, 0.22);
98 113 --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
99 114 --ri-shadow-lg: 0 14px 40px -14px rgba(0, 0, 0, 0.7);
@@ -125,6 +140,10 @@ html[data-theme="dark"] {
125 140 --color-alert: var(--ri-alert);
126 141 --color-alert-bg: var(--ri-alert-bg);
127 142 --color-gold: var(--ri-gold);
143 + --color-series-1: var(--ri-series-1);
144 + --color-series-2: var(--ri-series-2);
145 + --color-series-3: var(--ri-series-3);
146 + --color-series-4: var(--ri-series-4);
128 147 --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
129 148 --font-mono: var(--font-geist-mono), ui-monospace, "SF Mono", Menlo, monospace;
130 149 --radius-sm: var(--ri-radius-sm);
added apps/web/src/app/grading/page.tsx +94 −0
@@ -0,0 +1,94 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Card, CardHeader, EmptyState, Table, th, td, tdNum } from '@/components/ui/primitives';
5 +import { BarChart } from '@/components/charts/bar-chart';
6 +import { getDbGraders, getGradePremiums, getGraderActivity } from '@/lib/queries/market-lists';
7 +import { fmtMoney, fmtNum, fmtRelative, cn } from '@/lib/format';
8 +import { catName } from '@/lib/taxonomy';
9 +
10 +export const metadata: Metadata = { title: 'Grading', description: 'Grading companies, scales, empirical grade premiums and population data. PSA 10, BGS 10 and CGC 10 are never assumed equivalent.' };
11 +export const revalidate = 600;
12 +
13 +export default async function GradingPage() {
14 + const [graders, premiums, activity] = await Promise.all([getDbGraders(), getGradePremiums(), getGraderActivity()]);
15 + const act = new Map(activity.map((a) => [a.grader, a]));
16 + const byCat = new Map<string, typeof premiums>();
17 + for (const p of premiums) (byCat.get(p.categorySlug) ?? byCat.set(p.categorySlug, []).get(p.categorySlug)!).push(p);
18 + return (
19 + <div>
20 + <PageHeader title="Grading" description="Grades are tracked per grading company. A market multiplier is measured empirically from sales of the same asset in different grades (§118): PSA 10 ≠ BGS 10 ≠ CGC 10 until the data says otherwise." />
21 + <Card className="overflow-hidden">
22 + <CardHeader title="Grading companies" subtitle="Scale, categories and observed activity in RareIndex" />
23 + <Table>
24 + <thead>
25 + <tr>
26 + <th className={th}>Grader</th>
27 + <th className={th}>Scale</th>
28 + <th className={th}>Categories</th>
29 + <th className={cn(th, 'text-right')}>Graded sales</th>
30 + <th className={cn(th, 'text-right')}>Assets</th>
31 + <th className={cn(th, 'text-right')}>Median sale</th>
32 + <th className={cn(th, 'text-right')}>Pop reports</th>
33 + <th className={th}>Links</th>
34 + </tr>
35 + </thead>
36 + <tbody>
37 + {graders.map((g) => {
38 + const a = act.get(g.slug);
39 + return (
40 + <tr key={g.slug} className="hover:bg-sunken">
41 + <td className={cn(td, 'font-medium')}>{g.name}</td>
42 + <td className={cn(td, 'text-muted')}>
43 + {g.scale.type}
44 + {g.scale.top ? ` · top ${g.scale.top}` : ''}
45 + {g.scale.values.length ? ` · ${g.scale.values.length} steps` : ''}
46 + </td>
47 + <td className={cn(td, 'max-w-[260px] truncate text-muted')}>{g.categorySlugs.map(catName).join(', ') || '—'}</td>
48 + <td className={tdNum}>{fmtNum(a?.sales ?? 0)}</td>
49 + <td className={tdNum}>{fmtNum(a?.assets ?? 0)}</td>
50 + <td className={tdNum}>{a?.medianUsd != null ? fmtMoney(a.medianUsd) : '—'}</td>
51 + <td className={tdNum}>{fmtNum(a?.populationReports ?? 0)}</td>
52 + <td className={cn(td, 'text-[11px]')}>
53 + {g.populationUrl ? (
54 + <a href={g.populationUrl} target="_blank" rel="noopener nofollow" className="mr-2 text-muted hover:text-fg">
55 + Population ↗
56 + </a>
57 + ) : null}
58 + {g.verifyUrl ? (
59 + <a href={g.verifyUrl.replace('{cert}', '')} target="_blank" rel="noopener nofollow" className="text-muted hover:text-fg">
60 + Verify ↗
61 + </a>
62 + ) : null}
63 + </td>
64 + </tr>
65 + );
66 + })}
67 + </tbody>
68 + </Table>
69 + </Card>
70 +
71 + <Card className="mt-4">
72 + <CardHeader title="Empirical grade premiums" subtitle="Market multiplier vs the category's raw/base price, with sample size; recomputed by the valuation worker" />
73 + {byCat.size ? (
74 + <div className="grid gap-6 p-4 lg:grid-cols-2">
75 + {[...byCat.entries()].map(([cat, rows]) => (
76 + <div key={cat}>
77 + <h3 className="mb-2 text-xs font-semibold">
78 + <Link href={`/markets/${cat}`} className="hover:underline">
79 + {catName(cat)}
80 + </Link>
81 + <span className="ml-2 text-[11px] font-normal text-subtle">updated {fmtRelative(rows[0]!.computedAt)}</span>
82 + </h3>
83 + <BarChart ariaLabel={`Grade premiums for ${catName(cat)}`} data={rows.slice(0, 14).map((r) => ({ label: `${r.grader.toUpperCase()} ${r.grade}`, value: r.marketMultiplier, sublabel: `n=${r.sampleSize}` }))} maxBars={14} />
84 + </div>
85 + ))}
86 + </div>
87 + ) : (
88 + <EmptyState title="No premiums computed yet" description="Premiums require the same asset to have sold in several grades; they appear as sales accumulate." />
89 + )}
90 + </Card>
91 + <p className="mt-3 text-[11px] leading-relaxed text-subtle">RareIndex does not grade or authenticate items. Certification numbers shown on sales and listings are as published by the source; verify them with the grading company.</p>
92 + </div>
93 + );
94 +}
added apps/web/src/app/listings/page.tsx +57 −0
@@ -0,0 +1,57 @@
1 +import type { Metadata } from 'next';
2 +import { Suspense } from 'react';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { FilterBar } from '@/components/ui/filter-bar';
5 +import { Pagination } from '@/components/ui/pagination';
6 +import { Skeleton } from '@/components/ui/primitives';
7 +import { ListingsTable } from '@/components/market/sales-table';
8 +import { listListings, listListingSources } from '@/lib/queries/market-lists';
9 +import { CATEGORIES, GRADERS } from '@rareindex/taxonomy';
10 +import { pick, sp1, spEnum, spInt, spNum, type SP } from '@/lib/search-params';
11 +
12 +export const metadata: Metadata = { title: 'Listings', description: 'Live asking prices across marketplaces, compared with the RareIndex Valuation to surface value opportunities.' };
13 +
14 +const KEYS = ['category', 'source', 'grader', 'min', 'max', 'disc', 'type', 'q', 'sort'];
15 +
16 +export default async function ListingsPage({ searchParams }: { searchParams: Promise<SP> }) {
17 + const sp = await searchParams;
18 + const sources = await listListingSources();
19 + const filters = { category: sp1(sp, 'category') ?? null, source: sp1(sp, 'source') ?? null, grader: sp1(sp, 'grader') ?? null, minUsd: spNum(sp, 'min'), maxUsd: spNum(sp, 'max'), minDiscount: spNum(sp, 'disc') === null ? null : spNum(sp, 'disc')! / 100, listingType: sp1(sp, 'type') ?? null, q: sp1(sp, 'q') ?? null, sort: spEnum(sp, 'sort', ['newest', 'price_asc', 'price_desc', 'discount', 'ending'] as const, 'newest'), page: spInt(sp, 'page'), pageSize: 50 };
20 + return (
21 + <div>
22 + <PageHeader title="Listings" description="Current asks observed on public marketplaces. A listing price is not a market value; the “vs RIV” column shows the discount or premium to the RareIndex Valuation with its confidence — analytical data, not investment advice (§123)." compact />
23 + <Suspense>
24 + <FilterBar
25 + className="mb-3"
26 + fields={[
27 + { name: 'q', label: 'Search', type: 'text', placeholder: 'asset or title…', width: 'w-44' },
28 + { name: 'category', label: 'Category', type: 'select', options: CATEGORIES.map((c) => ({ value: c.slug, label: `${c.level ? '↳ ' : ''}${c.name}` })), width: 'w-48' },
29 + { name: 'source', label: 'Marketplace', type: 'select', options: sources.map((s) => ({ value: s.id, label: `${s.name} (${s.listings.toLocaleString('en-US')})` })), width: 'w-44' },
30 + { name: 'grader', label: 'Grader', type: 'select', options: GRADERS.map((g) => ({ value: g.slug, label: g.name })), width: 'w-28' },
31 + { name: 'type', label: 'Type', type: 'select', options: ['fixed_price', 'auction', 'best_offer', 'ask'].map((t) => ({ value: t, label: t.replace('_', ' ') })), width: 'w-28' },
32 + { name: 'min', label: 'Min $', type: 'number', width: 'w-24' },
33 + { name: 'max', label: 'Max $', type: 'number', width: 'w-24' },
34 + { name: 'disc', label: 'Discount ≥ %', type: 'number', placeholder: 'e.g. 15', width: 'w-24' },
35 + { name: 'sort', label: 'Sort', type: 'select', options: [{ value: 'newest', label: 'Newest' }, { value: 'discount', label: 'Biggest discount' }, { value: 'price_asc', label: 'Price ↑' }, { value: 'price_desc', label: 'Price ↓' }, { value: 'ending', label: 'Ending soon' }], placeholder: 'Newest', width: 'w-36' },
36 + ]}
37 + />
38 + </Suspense>
39 + <Suspense fallback={<Skeleton className="h-96" />}>
40 + <Results filters={filters} params={pick(sp, KEYS)} />
41 + </Suspense>
42 + </div>
43 + );
44 +}
45 +
46 +async function Results({ filters, params }: { filters: Parameters<typeof listListings>[0]; params: Record<string, string | undefined> }) {
47 + const res = await listListings(filters);
48 + return (
49 + <>
50 + <p className="mb-2 num text-[11px] text-subtle">{res.total.toLocaleString('en-US')} active listings</p>
51 + <div className="card overflow-hidden">
52 + <ListingsTable items={res.items} />
53 + </div>
54 + <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath="/listings" params={params} />
55 + </>
56 + );
57 +}
added apps/web/src/app/market-map/page.tsx +61 −0
@@ -0,0 +1,61 @@
1 +import type { Metadata } from 'next';
2 +import { PageHeader } from '@/components/ui/page-header';
3 +import { Segmented } from '@/components/ui/tabs';
4 +import { Card, EmptyState } from '@/components/ui/primitives';
5 +import { Treemap } from '@/components/charts/treemap';
6 +import { getMarketRows } from '@/lib/queries/markets';
7 +import { spEnum, sp1, type SP } from '@/lib/search-params';
8 +import { getCategory } from '@rareindex/taxonomy';
9 +
10 +export const metadata: Metadata = { title: 'Market map', description: 'Heatmap of collectible category performance: area by 30-day volume or tracked assets, colour by period change.' };
11 +export const revalidate = 300;
12 +
13 +const PERIODS = ['1d', '7d', '30d', '1y'] as const;
14 +const SIZES = ['volume', 'assets', 'sales'] as const;
15 +
16 +export default async function MarketMapPage({ searchParams }: { searchParams: Promise<SP> }) {
17 + const sp = await searchParams;
18 + const period = spEnum(sp, 'p', PERIODS, '30d');
19 + const size = spEnum(sp, 'size', SIZES, 'volume');
20 + const parent = sp1(sp, 'family') ?? null;
21 + const rows = await getMarketRows(parent);
22 + const key = period === '1d' ? 'change1d' : period === '7d' ? 'change7d' : period === '1y' ? 'change1y' : 'change30d';
23 + const nodes = rows
24 + .filter((r) => r.counts.assets > 0)
25 + .map((r) => {
26 + const vol = r.counts.volume30dUsd ?? 0;
27 + const s = size === 'volume' ? vol : size === 'sales' ? r.counts.sales : r.counts.assets;
28 + return { id: r.node.slug, label: r.node.name, size: s, change: r.snapshot?.[key] ?? null, href: r.node.children.length && !parent ? `/market-map?p=${period}&size=${size}&family=${r.node.slug}` : `/markets/${r.node.slug}`, sublabel: `${r.counts.assets.toLocaleString('en-US')} assets · ${r.counts.sales.toLocaleString('en-US')} sales`, sizeLabel: size === 'volume' ? '30D volume ($)' : size === 'sales' ? 'sales' : 'tracked assets' };
29 + })
30 + .filter((n) => n.size > 0);
31 + const withChange = nodes.filter((n) => n.change !== null).length;
32 + const q = (p: string, s: string) => `/market-map?p=${p}&size=${s}${parent ? `&family=${parent}` : ''}`;
33 + return (
34 + <div>
35 + <PageHeader
36 + crumbs={parent ? [{ label: 'Market map', href: '/market-map' }, { label: getCategory(parent)?.name ?? parent }] : undefined}
37 + title={parent ? `${getCategory(parent)?.name ?? parent} map` : 'Market map'}
38 + description="Area is a real activity measure (volume, sales or tracked assets); colour is the period change of the category index — grey means no change is computable yet. Click a family to drill into its subcategories."
39 + compact
40 + actions={
41 + <>
42 + <Segmented active={period} options={PERIODS.map((p) => ({ id: p, label: p.toUpperCase(), href: q(p, size) }))} />
43 + <Segmented active={size} options={SIZES.map((s) => ({ id: s, label: s === 'volume' ? 'Volume' : s === 'sales' ? 'Sales' : 'Assets', href: q(period, s) }))} />
44 + </>
45 + }
46 + />
47 + {nodes.length ? (
48 + <>
49 + <Treemap ariaLabel="Market heatmap" nodes={nodes} height={520} limit={period === '1d' ? 0.05 : period === '7d' ? 0.1 : period === '30d' ? 0.15 : 0.4} />
50 + <p className="mt-2 text-[11px] text-subtle">
51 + {nodes.length} categories with data · {withChange} with a computed {period.toUpperCase()} change{size === 'volume' && nodes.every((n) => n.size === 0) ? ' · no volume yet, showing assets' : ''}.
52 + </p>
53 + </>
54 + ) : (
55 + <Card>
56 + <EmptyState title="No market data yet" description={size === 'volume' ? 'No 30-day volume recorded yet — try sizing by tracked assets.' : 'The map fills as assets, sales and category snapshots are ingested.'} />
57 + </Card>
58 + )}
59 + </div>
60 + );
61 +}
added apps/web/src/app/markets/[slug]/page.tsx +285 −0
@@ -0,0 +1,285 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { Suspense } from 'react';
5 +import { PageHeader } from '@/components/ui/page-header';
6 +import { Card, CardHeader, Delta, EmptyState, Skeleton, Stat, Badge, Unavailable } from '@/components/ui/primitives';
7 +import { MarketTable } from '@/components/market/market-table';
8 +import { AssetTable } from '@/components/market/asset-list';
9 +import { SalesTable, ListingsTable, LotsTable } from '@/components/market/sales-table';
10 +import { LineChart } from '@/components/charts/line-chart';
11 +import { BarChart } from '@/components/charts/bar-chart';
12 +import { getBrandsInCategory, getCategoryRow, getCategorySeries, getMarketRows, getPopulationTrend, getSetsInCategory } from '@/lib/queries/markets';
13 +import { rankedAssets, categoryScope } from '@/lib/queries/assets';
14 +import { getRecentSalesInScope, getTopSales, listListings, listLots, listNews, listRadar } from '@/lib/queries/market-lists';
15 +import { getIndex } from '@/lib/queries/indices';
16 +import { fmtMoney, fmtNum, fmtRelative, fmtDate } from '@/lib/format';
17 +import { categoryCrumbs, getCategory, humanize } from '@/lib/taxonomy';
18 +import { CATEGORIES } from '@rareindex/taxonomy';
19 +
20 +export const revalidate = 300;
21 +
22 +export async function generateStaticParams() {
23 + return CATEGORIES.filter((c) => c.phase === 1).map((c) => ({ slug: c.slug }));
24 +}
25 +
26 +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
27 + const { slug } = await params;
28 + const c = getCategory(slug);
29 + if (!c) return { title: 'Market not found' };
30 + return { title: `${c.name} market`, description: `${c.name} collectibles market on RareIndex: index, sales, volume, top movers, most valuable assets, listings and news.` };
31 +}
32 +
33 +export default async function MarketPage({ params }: { params: Promise<{ slug: string }> }) {
34 + const { slug } = await params;
35 + const row = await getCategoryRow(slug);
36 + if (!row) notFound();
37 + const { node, snapshot, counts } = row;
38 + const index = node.index ? await getIndex(node.index) : null;
39 + return (
40 + <div>
41 + <PageHeader
42 + crumbs={[{ label: 'Markets', href: '/markets' }, ...categoryCrumbs(slug)]}
43 + title={node.name}
44 + description={node.description ?? `${node.name} market data: verified sales, live listings, valuations and category index.`}
45 + meta={
46 + <>
47 + <Badge tone={node.phase === 1 ? 'index' : 'neutral'}>Phase {node.phase}</Badge>
48 + {index ? (
49 + <Link href={`/rareindex/${index.ticker}`} className="hover:text-fg">
50 + Subindex {index.ticker}
51 + </Link>
52 + ) : null}
53 + {node.compliance?.length ? <Badge tone="alert">{node.compliance.map(humanize).join(' · ')}</Badge> : null}
54 + <Link href={`/explore?category=${slug}`} className="hover:text-fg">
55 + Explore assets →
56 + </Link>
57 + </>
58 + }
59 + />
60 +
61 + <section className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-4 lg:grid-cols-8">
62 + {[
63 + ['Tracked assets', fmtNum(counts.assets)],
64 + ['With valuation', fmtNum(counts.priced)],
65 + ['Sales (all time)', fmtNum(counts.sales)],
66 + ['Sales 30D', fmtNum(counts.sales30d)],
67 + ['Volume 30D', counts.volume30dUsd != null ? fmtMoney(counts.volume30dUsd, 'USD', { compact: true }) : '—'],
68 + ['Active listings', fmtNum(counts.listings)],
69 + ['Median RIV', counts.medianRivUsd != null ? fmtMoney(counts.medianRivUsd) : '—'],
70 + ['Market cap est.', snapshot?.marketCapEstUsd != null ? fmtMoney(snapshot.marketCapEstUsd, 'USD', { compact: true }) : '—'],
71 + ].map(([k, v]) => (
72 + <div key={k} className="bg-elevated px-3 py-2.5">
73 + <Stat label={k} value={v} />
74 + </div>
75 + ))}
76 + </section>
77 + {snapshot?.marketCapEstUsd != null ? <p className="mt-1 text-[11px] text-subtle">Market cap estimate = estimated population × representative RIV per asset, summed; indicative only (§124).</p> : null}
78 +
79 + <Suspense fallback={<Skeleton className="mt-4 h-72" />}>
80 + <IndexPanel slug={slug} />
81 + </Suspense>
82 +
83 + <Suspense fallback={<Skeleton className="mt-4 h-72" />}>
84 + <Movers slug={slug} />
85 + </Suspense>
86 +
87 + <Suspense fallback={<Skeleton className="mt-4 h-72" />}>
88 + <Activity slug={slug} />
89 + </Suspense>
90 +
91 + <Suspense fallback={<Skeleton className="mt-4 h-40" />}>
92 + <Structure slug={slug} />
93 + </Suspense>
94 + </div>
95 + );
96 +}
97 +
98 +async function IndexPanel({ slug }: { slug: string }) {
99 + const series = await getCategorySeries(slug, 730);
100 + const points = series.filter((p) => p.indexValue != null).map((p) => ({ x: p.date, y: p.indexValue! }));
101 + const salesSeries = series.map((p) => ({ x: p.date, y: p.sales }));
102 + return (
103 + <section className="mt-4 grid gap-4 lg:grid-cols-[2fr_1fr]">
104 + <Card className="p-4">
105 + <h2 className="text-sm font-semibold">Category index</h2>
106 + <p className="text-xs text-muted">Daily category index from valuations of priced constituents; publishes once enough assets are priced.</p>
107 + <div className="mt-2">
108 + <LineChart ariaLabel={`${slug} category index`} height={220} series={[{ id: 'idx', label: 'Category index', kind: 'area', points }]} emptyLabel="Index building — not enough priced constituents yet" />
109 + </div>
110 + </Card>
111 + <Card className="p-4">
112 + <h2 className="text-sm font-semibold">Daily sales</h2>
113 + <p className="text-xs text-muted">Verified transactions per day (count)</p>
114 + <div className="mt-2">
115 + <LineChart ariaLabel={`${slug} daily sales`} height={220} series={[{ id: 'sales', label: 'Sales', kind: 'step', points: salesSeries }]} emptyLabel="No snapshot history yet" />
116 + </div>
117 + </Card>
118 + </section>
119 + );
120 +}
121 +
122 +async function Movers({ slug }: { slug: string }) {
123 + const scope = categoryScope(slug);
124 + const [gainers, losers, volume, expensive, trending] = await Promise.all([
125 + rankedAssets('gainers', { scope, limit: 8, window: '7d' }),
126 + rankedAssets('losers', { scope, limit: 8, window: '7d' }),
127 + rankedAssets('volume', { scope, limit: 8 }),
128 + rankedAssets('expensive', { scope, limit: 8 }),
129 + rankedAssets('trending', { scope, limit: 8 }),
130 + ]);
131 + const block = (title: string, sub: string, items: typeof gainers, columns: Parameters<typeof AssetTable>[0]['columns']) => (
132 + <Card>
133 + <CardHeader title={title} subtitle={sub} />
134 + <AssetTable items={items} columns={columns} emptyTitle="No data yet" emptyDescription="Needs priced assets with sales history in this category." />
135 + </Card>
136 + );
137 + return (
138 + <section className="mt-4 grid gap-4 xl:grid-cols-2">
139 + {block('Top gainers · 7D', 'Assets with ≥3 sales used in valuation', gainers, ['riv', 'change7d', 'change30d', 'sales'])}
140 + {block('Top losers · 7D', 'Assets with ≥3 sales used in valuation', losers, ['riv', 'change7d', 'change30d', 'sales'])}
141 + {block('Highest volume · 30D', 'Sum of verified sale prices, USD', volume, ['riv', 'sales30d', 'change30d', 'liquidity'])}
142 + {block('Most valuable', 'Ranked by RareIndex Valuation', expensive, ['riv', 'latestSale', 'change1y', 'rarity'])}
143 + {block('Trending', 'Price × volume × listing momentum', trending, ['riv', 'trending', 'change7d', 'sales30d'])}
144 + <Card>
145 + <CardHeader title="Value opportunities" subtitle="Assets whose lowest ask sits materially below RIV — analytical data, not advice" />
146 + <AssetTable items={await rankedAssets('opportunity', { scope, limit: 8 })} columns={['riv', 'minAsk', 'opportunity', 'listings']} emptyTitle="No opportunities detected" emptyDescription="Requires both a valuation and active listings." />
147 + </Card>
148 + </section>
149 + );
150 +}
151 +
152 +async function Activity({ slug }: { slug: string }) {
153 + const scope = categoryScope(slug);
154 + const [records, recent, listings, lots, news, radar, pop] = await Promise.all([
155 + getTopSales(6, scope),
156 + getRecentSalesInScope(scope, 10),
157 + listListings({ category: slug, pageSize: 10, sort: 'newest' }),
158 + listLots({ category: slug, limit: 6, sort: 'value' }),
159 + listNews({ category: slug, limit: 6 }),
160 + listRadar({ scope, limit: 6 }),
161 + getPopulationTrend(slug),
162 + ]);
163 + const popByGrader = new Map<string, Array<{ x: string; y: number }>>();
164 + for (const p of pop) (popByGrader.get(p.grader) ?? popByGrader.set(p.grader, []).get(p.grader)!).push({ x: p.reportDate, y: p.total });
165 + return (
166 + <section className="mt-4 grid gap-4 xl:grid-cols-2">
167 + <Card>
168 + <CardHeader title="Newest records" subtitle="Highest verified sales in this market" action={<Link href={`/records`} className="text-muted hover:text-fg">All records →</Link>} />
169 + <SalesTable items={records} emptyDescription="No verified sales in this category yet." />
170 + </Card>
171 + <Card>
172 + <CardHeader title="Latest sales" action={<Link href={`/sales?category=${slug}`} className="text-muted hover:text-fg">All sales →</Link>} />
173 + <SalesTable items={recent} emptyDescription="No sales in this category yet." />
174 + </Card>
175 + <Card className="xl:col-span-2">
176 + <CardHeader title="Live listings" subtitle="Asking prices are not market values" action={<Link href={`/listings?category=${slug}`} className="text-muted hover:text-fg">All listings →</Link>} />
177 + <ListingsTable items={listings.items} />
178 + </Card>
179 + <Card>
180 + <CardHeader title="Biggest auctions" subtitle="Lots by hammer, bid or high estimate" action={<Link href="/auctions" className="text-muted hover:text-fg">Auctions →</Link>} />
181 + <LotsTable items={lots} />
182 + </Card>
183 + <Card>
184 + <CardHeader title="Population trends" subtitle="Total graded population reported per grader" />
185 + {popByGrader.size ? (
186 + <div className="p-4">
187 + <LineChart ariaLabel="Population trend" height={200} series={[...popByGrader.entries()].map(([g, pts]) => ({ id: g, label: g.toUpperCase(), points: pts }))} />
188 + </div>
189 + ) : (
190 + <EmptyState title="No population reports" description="Population data appears when grading-company connectors publish reports for assets in this category." className="py-8" />
191 + )}
192 + </Card>
193 + <Card>
194 + <CardHeader title="Rare Radar" action={<Link href="/radar" className="text-muted hover:text-fg">Open radar →</Link>} />
195 + {radar.length ? (
196 + <ul className="divide-y divide-border text-[12px]">
197 + {radar.map((r) => (
198 + <li key={r.id} className="flex items-center justify-between gap-3 px-4 py-2">
199 + <Link href={`/asset/${r.assetSlug}`} className="truncate font-medium text-fg hover:underline">
200 + {r.assetTitle}
201 + </Link>
202 + <span className="shrink-0 text-muted">
203 + {humanize(r.kind)} · {fmtRelative(r.detectedAt)}
204 + </span>
205 + </li>
206 + ))}
207 + </ul>
208 + ) : (
209 + <EmptyState title="Radar is quiet" className="py-8" />
210 + )}
211 + </Card>
212 + <Card>
213 + <CardHeader title="News" action={<Link href={`/news?category=${slug}`} className="text-muted hover:text-fg">All news →</Link>} />
214 + {news.items.length ? (
215 + <ul className="divide-y divide-border text-[12px]">
216 + {news.items.map((n) => (
217 + <li key={n.id} className="px-4 py-2">
218 + <a href={n.url} target="_blank" rel="noopener nofollow" className="font-medium text-fg hover:underline">
219 + {n.title}
220 + </a>
221 + <span className="block text-[11px] text-muted">
222 + {n.sourceName} · {n.publishedAt ? fmtDate(n.publishedAt) : 'undated'}
223 + </span>
224 + </li>
225 + ))}
226 + </ul>
227 + ) : (
228 + <EmptyState title="No news indexed" description="News connectors are not yet publishing for this market." className="py-8" />
229 + )}
230 + </Card>
231 + </section>
232 + );
233 +}
234 +
235 +async function Structure({ slug }: { slug: string }) {
236 + const [children, sets, brands] = await Promise.all([getMarketRows(slug), getSetsInCategory(slug, 40), getBrandsInCategory(slug, 30)]);
237 + return (
238 + <section className="mt-4 grid gap-4">
239 + {children.length ? (
240 + <Card>
241 + <CardHeader title="Subcategories" />
242 + <MarketTable rows={children} showPhase />
243 + </Card>
244 + ) : null}
245 + <div className="grid gap-4 lg:grid-cols-2">
246 + <Card>
247 + <CardHeader title="Sets & releases" subtitle="Ranked by sales" />
248 + {sets.length ? (
249 + <ul className="grid grid-cols-1 divide-y divide-border text-[12px] sm:grid-cols-2 sm:divide-y-0">
250 + {sets.map((s) => (
251 + <li key={s.slug} className="flex items-center justify-between gap-2 border-b border-border px-4 py-1.5">
252 + <Link href={`/set/${s.slug}`} className="truncate font-medium text-fg hover:underline">
253 + {s.name}
254 + {s.releaseYear ? <span className="ml-1 text-subtle">{s.releaseYear}</span> : null}
255 + </Link>
256 + <span className="num shrink-0 text-muted">
257 + {fmtNum(s.assets)} assets · {fmtNum(s.sales)} sales
258 + </span>
259 + </li>
260 + ))}
261 + </ul>
262 + ) : (
263 + <EmptyState title="No sets catalogued" className="py-8" />
264 + )}
265 + </Card>
266 + <Card>
267 + <CardHeader title="Brands" subtitle="Median RIV of priced assets" />
268 + {brands.length ? (
269 + <div className="p-4">
270 + <BarChart ariaLabel="Assets per brand" data={brands.slice(0, 12).map((b) => ({ label: b.brand, value: b.assets, sublabel: `${b.priced} priced · median RIV ${b.medianRivUsd != null ? fmtMoney(b.medianRivUsd) : '—'}`, href: `/brand/${encodeURIComponent(b.brand)}` }))} />
271 + </div>
272 + ) : (
273 + <EmptyState title="No brand data" className="py-8" />
274 + )}
275 + </Card>
276 + </div>
277 + <p className="text-[11px] text-subtle">
278 + Unavailable metrics render as <Unavailable /> rather than estimates (§192).
279 + </p>
280 + <span className="hidden">
281 + <Delta value={null} />
282 + </span>
283 + </section>
284 + );
285 +}
added apps/web/src/app/markets/page.tsx +42 −0
@@ -0,0 +1,42 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { MarketTable } from '@/components/market/market-table';
5 +import { getMarketRows } from '@/lib/queries/markets';
6 +import { listIndices } from '@/lib/queries/indices';
7 +import { fmtNum } from '@/lib/format';
8 +
9 +export const metadata: Metadata = { title: 'Markets', description: 'Collectible category markets: index performance, sales, volume, listings, liquidity and market capitalization estimates.' };
10 +export const revalidate = 300;
11 +
12 +export default async function MarketsPage() {
13 + const [rows, indices] = await Promise.all([getMarketRows(), listIndices()]);
14 + const active = rows.filter((r) => r.counts.assets > 0);
15 + const totals = active.reduce((a, r) => ({ assets: a.assets + r.counts.assets, sales: a.sales + r.counts.sales, listings: a.listings + r.counts.listings }), { assets: 0, sales: 0, listings: 0 });
16 + const published = indices.filter((i) => i.latest).length;
17 + return (
18 + <div>
19 + <PageHeader
20 + title="Markets"
21 + description="One row per collectible family. Index and period changes come from category snapshots; counts come live from the canonical tables. Families without data are listed so coverage is transparent."
22 + meta={
23 + <>
24 + <span className="num">{fmtNum(active.length)} families with data · {fmtNum(totals.assets)} assets · {fmtNum(totals.sales)} sales · {fmtNum(totals.listings)} active listings</span>
25 + <span>·</span>
26 + <Link href="/rareindex" className="hover:text-fg">
27 + {published}/{indices.length} indices published
28 + </Link>
29 + <span>·</span>
30 + <Link href="/market-map" className="hover:text-fg">
31 + Heatmap view →
32 + </Link>
33 + </>
34 + }
35 + />
36 + <div className="card overflow-hidden">
37 + <MarketTable rows={rows} />
38 + </div>
39 + <p className="mt-3 text-[11px] leading-relaxed text-subtle">Market capitalization estimates multiply an estimated population by a representative value and are shown only with their confidence label; they are indicative, never precise (§124). Listing prices are not confirmed transactions.</p>
40 + </div>
41 + );
42 +}
added apps/web/src/app/news/page.tsx +52 −0
@@ -0,0 +1,52 @@
1 +import type { Metadata } from 'next';
2 +import { PageHeader } from '@/components/ui/page-header';
3 +import { Tabs } from '@/components/ui/tabs';
4 +import { Card, EmptyState, Badge } from '@/components/ui/primitives';
5 +import { Pagination } from '@/components/ui/pagination';
6 +import { listNews } from '@/lib/queries/market-lists';
7 +import { fmtDate } from '@/lib/format';
8 +import { catName, humanize } from '@/lib/taxonomy';
9 +import { sp1, spInt, type SP } from '@/lib/search-params';
10 +
11 +export const metadata: Metadata = { title: 'Market news', description: 'Collectibles market news: auction results, record sales, grading changes, releases, trends, discoveries and events, summarised with links to the source.' };
12 +
13 +const TYPES = ['auction_results', 'record_sales', 'grading', 'releases', 'trends', 'discoveries', 'events'];
14 +
15 +export default async function NewsPage({ searchParams }: { searchParams: Promise<SP> }) {
16 + const sp = await searchParams;
17 + const type = sp1(sp, 'type') ?? null;
18 + const category = sp1(sp, 'category') ?? null;
19 + const page = spInt(sp, 'page');
20 + const res = await listNews({ type, category, page, limit: 40 });
21 + return (
22 + <div>
23 + <PageHeader title="News" description="Aggregated from public sources with attribution. AI summaries, when present, always link to the original article and never replace it (§156)." compact />
24 + <Tabs active={type ?? 'all'} tabs={[{ id: 'all', label: 'All', href: '/news' }, ...TYPES.map((t) => ({ id: t, label: humanize(t), href: `/news?type=${t}` }))]} className="mb-4" />
25 + {res.items.length ? (
26 + <>
27 + <ul className="grid grid-cols-1 gap-2 md:grid-cols-2">
28 + {res.items.map((n) => (
29 + <li key={n.id} className="card p-3">
30 + <div className="flex items-center gap-2 text-[11px] text-muted">
31 + {n.newsType ? <Badge>{humanize(n.newsType)}</Badge> : null}
32 + <span>{n.sourceName}</span>
33 + <span>· {n.publishedAt ? fmtDate(n.publishedAt) : 'undated'}</span>
34 + </div>
35 + <a href={n.url} target="_blank" rel="noopener nofollow" className="mt-1 block text-[14px] font-medium text-fg hover:underline">
36 + {n.title}
37 + </a>
38 + {n.aiSummary || n.summary ? <p className="mt-1 line-clamp-3 text-[12px] leading-relaxed text-muted">{n.aiSummary ?? n.summary}</p> : null}
39 + {n.categorySlugs.length ? <div className="mt-1.5 text-[11px] text-subtle">{n.categorySlugs.map(catName).join(' · ')}</div> : null}
40 + </li>
41 + ))}
42 + </ul>
43 + <Pagination page={page} pageSize={40} total={res.total} basePath="/news" params={{ type: type ?? undefined, category: category ?? undefined }} />
44 + </>
45 + ) : (
46 + <Card>
47 + <EmptyState title="No news indexed yet" description="News connectors are not yet publishing. Nothing is shown until real articles are ingested." />
48 + </Card>
49 + )}
50 + </div>
51 + );
52 +}
modified apps/web/src/app/page.tsx +129 −4
@@ -1,6 +1,131 @@
1 −import { EmptyState } from '@/components/ui/primitives';
1 +import { Suspense } from 'react';
2 +import Link from 'next/link';
3 +import { Hero } from '@/components/home/hero';
4 +import { IndexTape } from '@/components/market/index-strip';
5 +import { AssetRail, LatestTransactions, LotsRail, MarketInsights, RadarRail, SalesRail } from '@/components/home/sections';
6 +import { Skeleton } from '@/components/ui/primitives';
7 +import { getLatestSales, getSiteStats } from '@/lib/queries/site';
8 +import { listIndices } from '@/lib/queries/indices';
9 +import { rankedAssets } from '@/lib/queries/assets';
10 +import { getMarketRows } from '@/lib/queries/markets';
11 +import { getTopSales, listLots, listRadar } from '@/lib/queries/market-lists';
12 +import { LineChart } from '@/components/charts/line-chart';
13 +import { getIndexSeries } from '@/lib/queries/indices';
2 14
3 −// Placeholder — replaced by the homepage module (hero, live tape, discovery grid).
4 −export default function HomePage() {
5 − return <EmptyState title="RareIndex is initialising" description="The global market for collectibles. Data pipelines are being connected." />;
15 +export const revalidate = 120;
16 +
17 +export default async function HomePage() {
18 + const [stats, indices] = await Promise.all([getSiteStats(), listIndices()]);
19 + return (
20 + <div className="-mx-4 sm:-mx-6">
21 + <div className="px-4 sm:px-6">
22 + <Hero stats={stats} />
23 + </div>
24 + <div className="mt-8">
25 + <IndexTape indices={indices} />
26 + </div>
27 + <div className="px-4 pt-6 sm:px-6">
28 + <Suspense fallback={<Skeleton className="h-64 w-full" />}>
29 + <FlagshipPanel />
30 + </Suspense>
31 + <Suspense fallback={<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-72" />)}</div>}>
32 + <Discovery />
33 + </Suspense>
34 + <Suspense fallback={<Skeleton className="mt-4 h-48" />}>
35 + <Latest />
36 + </Suspense>
37 + </div>
38 + </div>
39 + );
40 +}
41 +
42 +async function FlagshipPanel() {
43 + const indices = await listIndices();
44 + const rare = indices.find((i) => i.isFlagship);
45 + const subs = indices.filter((i) => !i.isFlagship && i.latest);
46 + const series = rare ? await getIndexSeries(rare.id, 365) : [];
47 + return (
48 + <section className="grid gap-4 lg:grid-cols-[2fr_1fr]">
49 + <div className="card p-4">
50 + <div className="flex items-baseline justify-between gap-3">
51 + <div>
52 + <h2 className="text-sm font-semibold text-fg">RARE · RareIndex Global Collectibles Index</h2>
53 + <p className="text-xs text-muted">Chain-linked, equal-weighted across subindices · base 1,000 on 2024-01-01</p>
54 + </div>
55 + <Link href="/rareindex/RARE" className="text-xs font-medium text-muted hover:text-fg">
56 + Index detail →
57 + </Link>
58 + </div>
59 + <div className="mt-3">
60 + <LineChart ariaLabel="RARE index over the last year" height={220} series={[{ id: 'rare', label: 'RARE', kind: 'area', points: series.map((p) => ({ x: p.date, y: p.value })) }]} emptyLabel={rare ? `Index building — publishes once ${rare.minConstituents} priced constituents exist (${rare.pricedAssets} today)` : 'Index not configured'} />
61 + </div>
62 + </div>
63 + <div className="card p-4">
64 + <h2 className="text-sm font-semibold text-fg">Subindices</h2>
65 + <p className="text-xs text-muted">Daily change · latest published value</p>
66 + {subs.length ? (
67 + <ul className="mt-2 divide-y divide-border">
68 + {subs.slice(0, 10).map((i) => (
69 + <li key={i.ticker}>
70 + <Link href={`/rareindex/${i.ticker}`} className="flex items-center justify-between py-1.5 text-[12px] hover:bg-sunken">
71 + <span className="flex items-center gap-2">
72 + <span className="inline-block h-2 w-2 rounded-full" style={{ background: i.color ?? 'var(--ri-index)' }} />
73 + <span className="font-medium text-fg">{i.ticker}</span>
74 + </span>
75 + <span className="num flex items-center gap-3">
76 + <span className="text-muted">{i.latest!.value.toFixed(2)}</span>
77 + <span className={i.change1d === null ? 'text-subtle' : i.change1d >= 0 ? 'text-gain' : 'text-loss'}>{i.change1d === null ? '—' : `${i.change1d >= 0 ? '+' : ''}${(i.change1d * 100).toFixed(2)}%`}</span>
78 + </span>
79 + </Link>
80 + </li>
81 + ))}
82 + </ul>
83 + ) : (
84 + <p className="mt-6 text-center text-xs text-subtle">
85 + No subindex has reached its minimum constituent count yet.
86 + <br />
87 + <Link href="/rareindex" className="underline hover:text-fg">
88 + See build progress
89 + </Link>
90 + </p>
91 + )}
92 + </div>
93 + </section>
94 + );
95 +}
96 +
97 +async function Discovery() {
98 + const [trending, movers, losers, records, radar, lots, watched, newest, markets] = await Promise.all([
99 + rankedAssets('trending', { limit: 8 }),
100 + rankedAssets('gainers', { limit: 8, window: '7d' }),
101 + rankedAssets('losers', { limit: 8, window: '7d' }),
102 + getTopSales(8),
103 + listRadar({ limit: 8 }),
104 + listLots({ endingWithinHours: 72, status: null, limit: 8 }),
105 + rankedAssets('watched', { limit: 8 }),
106 + rankedAssets('newest', { limit: 8 }),
107 + getMarketRows(),
108 + ]);
109 + return (
110 + <section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
111 + <AssetRail title="Trending Now" subtitle="Price × volume × listing momentum" href="/trending" items={trending} metric="trending" />
112 + <AssetRail title="Biggest Movers" subtitle="7-day change in RareIndex Valuation" href="/explore?sort=change7d" items={movers.length ? movers : losers} metric="change7d" empty="Movers appear when assets have ≥3 sales and a 7-day history." />
113 + <SalesRail title="Record Sales" subtitle="Highest verified transactions" href="/records" items={records} />
114 + <RadarRail items={radar} />
115 + <LotsRail items={lots} />
116 + <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" empty="Watch an asset from its page to see it here." />
117 + <AssetRail title="Newly Added" subtitle="Latest canonical assets" href="/explore?sort=newest" items={newest} metric="new" />
118 + <MarketInsights rows={markets} />
119 + <AssetRail title="Value Opportunities" subtitle="Listings materially below RIV (analytical, not advice)" href="/listings?sort=discount" items={await rankedAssets('opportunity', { limit: 8 })} metric="opportunity" empty="Requires valuations and active listings." />
120 + </section>
121 + );
122 +}
123 +
124 +async function Latest() {
125 + const items = await getLatestSales(12);
126 + return (
127 + <section className="mt-6">
128 + <LatestTransactions items={items} />
129 + </section>
130 + );
6 131 }
added apps/web/src/app/price-history/page.tsx +127 −0
@@ -0,0 +1,127 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Card, CardHeader, EmptyState, Stat } from '@/components/ui/primitives';
5 +import { Segmented } from '@/components/ui/tabs';
6 +import { LineChart } from '@/components/charts/line-chart';
7 +import { HistoryTab } from '@/components/asset/asset-tabs';
8 +import { getAssetBySlug, getAssetSalePoints, getAssetVariants, rankedAssets } from '@/lib/queries/assets';
9 +import { searchAssets } from '@rareindex/search';
10 +import { fmtMoney, fmtNum, confidenceLabel } from '@/lib/format';
11 +import { catName, gradeLabel } from '@/lib/taxonomy';
12 +import { sp1, spEnum, type SP } from '@/lib/search-params';
13 +import { AssetList } from '@/components/market/asset-list';
14 +
15 +export const metadata: Metadata = { title: 'Price history', description: 'Search any tracked collectible and view its full price history: sales, RareIndex Valuation, lowest ask and volume.' };
16 +
17 +const PERIODS = ['1y', '2y', '5y', 'all'] as const;
18 +
19 +/** ISO day string N days ago ('' for no cutoff); kept outside components so render stays pure. */
20 +function windowCutoffIso(days: number | null): string {
21 + if (!days) return '';
22 + return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
23 +}
24 +
25 +export 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 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 + Find
39 + </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 +}
51 +
52 +async 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 +}
90 +
91 +async 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} sales
105 + </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 +}
118 +
119 +async 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 +}
added apps/web/src/app/radar/page.tsx +63 −0
@@ -0,0 +1,63 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Tabs } from '@/components/ui/tabs';
5 +import { Card, EmptyState, Badge } from '@/components/ui/primitives';
6 +import { Thumb } from '@/components/market/bits';
7 +import { listRadar } from '@/lib/queries/market-lists';
8 +import { fmtMoney, fmtRelative } from '@/lib/format';
9 +import { catName, humanize } from '@/lib/taxonomy';
10 +import { sp1, type SP } from '@/lib/search-params';
11 +
12 +export const metadata: Metadata = { title: 'Rare Radar', description: 'Newly detected rare items: first listings in years, ultra-low populations, rare auction appearances, price discrepancies and undocumented variations.' };
13 +
14 +const KINDS = ['first_listing_in_years', 'ultra_low_population', 'rare_auction_appearance', 'price_discrepancy', 'undocumented_variation', 'record_sale'];
15 +
16 +export default async function RadarPage({ searchParams }: { searchParams: Promise<SP> }) {
17 + const sp = await searchParams;
18 + const kind = sp1(sp, 'kind') ?? null;
19 + const items = await listRadar({ kind, limit: 100 });
20 + return (
21 + <div>
22 + <PageHeader title="Rare Radar" description="Signals detected by the pipeline (§155): each finding cites its evidence (listing, sale, population report). Findings expire; nothing here is a recommendation." compact />
23 + <Tabs active={kind ?? 'all'} tabs={[{ id: 'all', label: 'All', href: '/radar' }, ...KINDS.map((k) => ({ id: k, label: humanize(k), href: `/radar?kind=${k}` }))]} className="mb-4" />
24 + {items.length ? (
25 + <ul className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
26 + {items.map((r) => (
27 + <li key={r.id} className="card flex gap-3 p-3">
28 + <Thumb src={r.heroImageUrl} alt="" size={56} rounded="rounded-md" />
29 + <div className="min-w-0 flex-1">
30 + <div className="flex items-center justify-between gap-2">
31 + <Badge tone={r.kind === 'record_sale' ? 'gold' : r.kind === 'price_discrepancy' ? 'alert' : 'rarity'}>{humanize(r.kind)}</Badge>
32 + <span className="num text-[11px] text-subtle">signal {r.score.toFixed(0)} · {fmtRelative(r.detectedAt)}</span>
33 + </div>
34 + <Link href={`/asset/${r.assetSlug}`} className="mt-1 block truncate text-[13px] font-medium text-fg hover:underline">
35 + {r.assetTitle}
36 + </Link>
37 + <div className="text-[11px] text-muted">
38 + {catName(r.categorySlug)} · RIV {r.rivUsd === null ? '—' : fmtMoney(r.rivUsd)}
39 + </div>
40 + {Object.keys(r.evidence).length ? (
41 + <dl className="mt-1.5 grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
42 + {Object.entries(r.evidence)
43 + .slice(0, 6)
44 + .map(([k, v]) => (
45 + <div key={k} className="contents">
46 + <dt className="truncate text-subtle">{humanize(k)}</dt>
47 + <dd className="num truncate text-muted">{typeof v === 'number' ? v.toLocaleString('en-US', { maximumFractionDigits: 2 }) : String(v)}</dd>
48 + </div>
49 + ))}
50 + </dl>
51 + ) : null}
52 + </div>
53 + </li>
54 + ))}
55 + </ul>
56 + ) : (
57 + <Card>
58 + <EmptyState title="Radar is quiet" description="Findings appear when the pipeline has enough history to detect unusual events." />
59 + </Card>
60 + )}
61 + </div>
62 + );
63 +}
added apps/web/src/app/rareindex/[ticker]/page.tsx +214 −0
@@ -0,0 +1,214 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { PageHeader } from '@/components/ui/page-header';
5 +import { Card, CardHeader, Delta, Stat, Table, th, td, tdNum, Badge, EmptyState } from '@/components/ui/primitives';
6 +import { Segmented } from '@/components/ui/tabs';
7 +import { LineChart } from '@/components/charts/line-chart';
8 +import { analyzeSeries, getBenchmarkSeries, getCorrelations, getIndex, getIndexConstituents, getIndexSeries, listBenchmarkTickers, listIndices } from '@/lib/queries/indices';
9 +import { fmtMoney, fmtNum, fmtPct, cn } from '@/lib/format';
10 +import { catName } from '@/lib/taxonomy';
11 +import { spEnum, type SP } from '@/lib/search-params';
12 +import { INDICES } from '@rareindex/taxonomy';
13 +
14 +export const revalidate = 300;
15 +
16 +export async function generateStaticParams() {
17 + return INDICES.map((i) => ({ ticker: i.ticker }));
18 +}
19 +
20 +export async function generateMetadata({ params }: { params: Promise<{ ticker: string }> }): Promise<Metadata> {
21 + const { ticker } = await params;
22 + const i = await getIndex(ticker);
23 + if (!i) return { title: 'Index not found' };
24 + return { title: `${i.ticker} · ${i.name}`, description: i.description ?? `${i.name}: value, returns, volatility, drawdown, liquidity, transactions and constituents.` };
25 +}
26 +
27 +const PERIODS = ['1m', '3m', '1y', '3y', 'all'] as const;
28 +const DAYS: Record<(typeof PERIODS)[number], number | null> = { '1m': 31, '3m': 92, '1y': 365, '3y': 1096, all: null };
29 +const BENCH = ['SPX', 'GOLD', 'BTC'] as const;
30 +
31 +export default async function IndexPage({ params, searchParams }: { params: Promise<{ ticker: string }>; searchParams: Promise<SP> }) {
32 + const { ticker } = await params;
33 + const sp = await searchParams;
34 + const period = spEnum(sp, 'p', PERIODS, '1y');
35 + const bench = spEnum(sp, 'vs', ['none', ...BENCH] as const, 'none');
36 + const index = await getIndex(ticker);
37 + if (!index) notFound();
38 + const [all, full, window, constituents, corr, benchTickers] = await Promise.all([getIndexSeries(index.id), getIndexSeries(index.id, DAYS[period]), listIndices(), getIndexConstituents(index.id, 100), getCorrelations(index.ticker), listBenchmarkTickers()]);
39 + const a = analyzeSeries(all);
40 + const latest = index.latest;
41 + const benchSeries = bench !== 'none' ? await getBenchmarkSeries(bench, DAYS[period]) : [];
42 + const siblings = window.filter((i) => !i.isFlagship && i.ticker !== index.ticker);
43 + const href = (p: string, vs: string) => `/rareindex/${index.ticker}?p=${p}&vs=${vs}`;
44 +
45 + return (
46 + <div>
47 + <PageHeader
48 + crumbs={[{ label: 'RareIndex', href: '/rareindex' }, { label: index.ticker }]}
49 + title={
50 + <span className="flex items-center gap-3">
51 + <span className="inline-block h-4 w-4 rounded-sm" style={{ background: index.color ?? 'var(--ri-index)' }} />
52 + {index.ticker}
53 + {index.isFlagship ? <Badge tone="index">Flagship</Badge> : null}
54 + </span>
55 + }
56 + description={`${index.name}. ${index.description ?? ''} Methodology: ${index.methodology} · weighting: ${index.weighting} · base ${index.baseValue.toLocaleString('en-US')} on ${index.baseDate}.`}
57 + meta={
58 + <>
59 + {index.familySlugs.slice(0, 8).map((f) => (
60 + <Link key={f} href={`/markets/${f}`} className="rounded-sm bg-inset px-1.5 py-0.5 hover:text-fg">
61 + {catName(f)}
62 + </Link>
63 + ))}
64 + {index.familySlugs.length > 8 ? <span>+{index.familySlugs.length - 8} more</span> : null}
65 + </>
66 + }
67 + />
68 +
69 + {latest ? (
70 + <section className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-4 lg:grid-cols-8">
71 + {[
72 + ['Value', latest.value.toFixed(2)],
73 + ['1D', <Delta key="d" value={a.returns['1d']} />],
74 + ['7D', <Delta key="w" value={a.returns['7d']} />],
75 + ['30D', <Delta key="m" value={a.returns['30d']} />],
76 + ['YTD', <Delta key="y" value={a.returns.ytd} />],
77 + ['1Y', <Delta key="1y" value={a.returns['1y']} />],
78 + ['3Y', <Delta key="3y" value={a.returns['3y']} />],
79 + ['All time', <Delta key="all" value={a.returns.all} />],
80 + ['Volatility (ann.)', a.volatility === null ? '—' : fmtPct(a.volatility, 1, false)],
81 + ['Max drawdown', a.maxDrawdown === null ? '—' : fmtPct(-a.maxDrawdown, 1)],
82 + ['Constituents', fmtNum(latest.constituentsCount)],
83 + ['Transactions', fmtNum(latest.transactions)],
84 + ['Median sale', latest.medianSaleUsd != null ? fmtMoney(latest.medianSaleUsd) : '—'],
85 + ['Avg sale', latest.avgSaleUsd != null ? fmtMoney(latest.avgSaleUsd) : '—'],
86 + ['Liquidity', latest.liquidityScore != null ? `${Math.round(latest.liquidityScore)}/100` : '—'],
87 + ['Momentum', latest.momentum != null ? latest.momentum.toFixed(2) : '—'],
88 + ].map(([k, v], i) => (
89 + <div key={i} className="bg-elevated px-3 py-2.5">
90 + <Stat label={k as string} value={v} />
91 + </div>
92 + ))}
93 + </section>
94 + ) : (
95 + <EmptyState title={`${index.ticker} is still building`} description={`This index publishes once ${index.minConstituents} constituents carry a valuation. Today: ${index.pricedAssets} priced assets across its families. Values are never back-filled with estimates.`} className="card" />
96 + )}
97 +
98 + {latest ? (
99 + <div className="mt-1 grid gap-1 text-[11px] text-subtle sm:grid-cols-3">
100 + <span>Market cap est.: {latest.marketCapEstUsd != null ? `${fmtMoney(latest.marketCapEstUsd, 'USD', { compact: true })} (${latest.marketCapConfidence ?? 'confidence n/a'})` : 'unavailable'}</span>
101 + <span>Tracked assets: {latest.trackedAssets != null ? fmtNum(latest.trackedAssets) : '—'} · breadth: {latest.breadth ?? '—'}</span>
102 + <span>Coverage on last date: {latest.coverage != null ? `${Math.round(latest.coverage * 100)}%` : '—'} · high {a.high?.value.toFixed(1)} ({a.high?.date}) · low {a.low?.value.toFixed(1)} ({a.low?.date})</span>
103 + </div>
104 + ) : null}
105 +
106 + <Card className="mt-4 p-4">
107 + <div className="flex flex-wrap items-center justify-between gap-2">
108 + <h2 className="text-sm font-semibold">Performance</h2>
109 + <div className="flex flex-wrap items-center gap-2">
110 + <Segmented active={period} options={PERIODS.map((p) => ({ id: p, label: p.toUpperCase(), href: href(p, bench) }))} />
111 + <Segmented active={bench} options={[{ id: 'none', label: 'Index only', href: href(period, 'none') }, ...BENCH.map((b) => ({ id: b, label: `vs ${b}`, href: href(period, b) }))]} />
112 + </div>
113 + </div>
114 + <LineChart
115 + ariaLabel={`${index.ticker} performance`}
116 + height={300}
117 + className="mt-3"
118 + indexed={bench !== 'none'}
119 + series={[
120 + { id: index.ticker, label: index.ticker, kind: bench === 'none' ? 'area' : 'line', color: index.color ?? undefined, points: full.map((p) => ({ x: p.date, y: p.value })) },
121 + ...(bench !== 'none' ? [{ id: bench, label: bench, dashed: true, points: benchSeries.map((p) => ({ x: p.date, y: p.value })) }] : []),
122 + ]}
123 + emptyLabel={bench !== 'none' && !benchSeries.length ? `Benchmark ${bench} not loaded yet (available: ${benchTickers.map((b) => b.ticker).join(', ') || 'none'})` : 'No published values in this window'}
124 + />
125 + {bench !== 'none' ? <p className="mt-2 text-[11px] leading-relaxed text-subtle">Benchmark comparison (§187): collectibles trade infrequently, carry high transaction costs and are valued from observed sales rather than continuous quotes; an index of valuations is smoother and lags liquid markets. Compare direction and magnitude, not tradability. Benchmark source: {benchSeries[0]?.source ?? 'n/a'}.</p> : null}
126 + </Card>
127 +
128 + <section className="mt-4 grid gap-4 lg:grid-cols-[3fr_2fr]">
129 + <Card className="overflow-hidden">
130 + <CardHeader title="Constituents" subtitle={`${constituents.length} active constituents (top 100 by weight)`} />
131 + {constituents.length ? (
132 + <Table>
133 + <thead>
134 + <tr>
135 + <th className={th}>Asset</th>
136 + <th className={th}>Variant</th>
137 + <th className={cn(th, 'text-right')}>Weight</th>
138 + <th className={cn(th, 'text-right')}>RIV</th>
139 + <th className={cn(th, 'text-right')}>30D</th>
140 + <th className={cn(th, 'text-right')}>Liquidity</th>
141 + <th className={cn(th, 'text-right')}>Sales</th>
142 + <th className={th}>Added</th>
143 + </tr>
144 + </thead>
145 + <tbody>
146 + {constituents.map((c) => (
147 + <tr key={`${c.assetId}-${c.variantLabel}`} className="hover:bg-sunken">
148 + <td className={cn(td, 'max-w-[320px]')}>
149 + <Link href={`/asset/${c.slug}`} className="block truncate font-medium text-fg hover:underline">
150 + {c.title}
151 + </Link>
152 + <span className="text-[11px] text-muted">{catName(c.categorySlug)}</span>
153 + </td>
154 + <td className={cn(td, 'text-muted')}>{c.variantLabel ?? 'asset'}</td>
155 + <td className={tdNum}>{(c.weight * 100).toFixed(2)}%</td>
156 + <td className={tdNum}>{fmtMoney(c.rivUsd)}</td>
157 + <td className={tdNum}>
158 + <Delta value={c.change30d} />
159 + </td>
160 + <td className={tdNum}>{c.liquidityScore != null ? Math.round(c.liquidityScore) : '—'}</td>
161 + <td className={tdNum}>{fmtNum(c.salesCount)}</td>
162 + <td className={cn(td, 'text-muted')}>{c.addedAt}</td>
163 + </tr>
164 + ))}
165 + </tbody>
166 + </Table>
167 + ) : (
168 + <EmptyState title="No constituents yet" description="Constituents are selected from priced assets with sufficient sales history; membership is recorded with dates." />
169 + )}
170 + </Card>
171 + <div className="grid gap-4">
172 + <Card>
173 + <CardHeader title="Correlation with other indices" subtitle="Daily returns, overlapping history only" />
174 + {corr.length ? (
175 + <ul className="divide-y divide-border text-[12px]">
176 + {corr.map((c) => (
177 + <li key={`${c.other}-${c.windowDays}`} className="flex items-center justify-between px-4 py-1.5">
178 + <Link href={`/rareindex/${c.other}`} className="font-medium text-fg hover:underline">
179 + {c.other}
180 + </Link>
181 + <span className="num text-muted">
182 + {c.coefficient.toFixed(2)} · {c.windowDays}d · n={c.observations}
183 + </span>
184 + </li>
185 + ))}
186 + </ul>
187 + ) : (
188 + <p className="px-4 py-6 text-center text-xs text-subtle">Not enough overlapping history.</p>
189 + )}
190 + </Card>
191 + <Card>
192 + <CardHeader title="Other subindices" />
193 + <ul className="divide-y divide-border text-[12px]">
194 + {siblings.map((s) => (
195 + <li key={s.ticker}>
196 + <Link href={`/rareindex/${s.ticker}`} className="flex items-center justify-between px-4 py-1.5 hover:bg-sunken">
197 + <span className="flex items-center gap-2 font-medium text-fg">
198 + <span className="inline-block h-2 w-2 rounded-sm" style={{ background: s.color ?? 'var(--ri-index)' }} />
199 + {s.ticker}
200 + </span>
201 + <span className="num flex items-center gap-3 text-muted">
202 + {s.latest ? s.latest.value.toFixed(1) : <span className="text-subtle">building</span>}
203 + <Delta value={s.change30d} />
204 + </span>
205 + </Link>
206 + </li>
207 + ))}
208 + </ul>
209 + </Card>
210 + </div>
211 + </section>
212 + </div>
213 + );
214 +}
added apps/web/src/app/rareindex/page.tsx +144 −0
@@ -0,0 +1,144 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Card, CardHeader, Delta, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
5 +import { LineChart } from '@/components/charts/line-chart';
6 +import { getCorrelationMatrix, getIndexSeries, listIndices } from '@/lib/queries/indices';
7 +import { fmtNum, fmtMoney, cn } from '@/lib/format';
8 +import { heatStyle } from '@/components/charts/heat';
9 +
10 +export const metadata: Metadata = { title: 'RareIndex indices', description: 'RARE, the RareIndex Global Collectibles Index, and its subindices: values, returns, liquidity, transactions and constituents.' };
11 +export const revalidate = 300;
12 +
13 +export default async function RareIndexPage() {
14 + const indices = await listIndices();
15 + const flagship = indices.find((i) => i.isFlagship);
16 + const subs = indices.filter((i) => !i.isFlagship);
17 + const published = subs.filter((s) => s.latest);
18 + const series = flagship ? await getIndexSeries(flagship.id) : [];
19 + const subSeries = await Promise.all(published.slice(0, 4).map(async (s) => ({ s, pts: await getIndexSeries(s.id, 365) })));
20 + const corr = await getCorrelationMatrix(365);
21 + return (
22 + <div>
23 + <PageHeader
24 + title="RareIndex"
25 + description="RARE aggregates the subindices; each subindex is a chain-linked index of priced constituents in its families (equal-weighted by default, base 1,000 on 2024-01-01). An index publishes only once it has enough priced constituents, and every daily value stores its coverage."
26 + meta={
27 + <>
28 + <span className="num">
29 + {published.length}/{subs.length} subindices published
30 + </span>
31 + <Link href="/data#methodology" className="hover:text-fg">
32 + Methodology →
33 + </Link>
34 + </>
35 + }
36 + />
37 + <section className="grid gap-4 lg:grid-cols-[3fr_2fr]">
38 + <Card className="p-4">
39 + <div className="flex items-baseline justify-between">
40 + <h2 className="text-sm font-semibold">RARE — Global Collectibles Index</h2>
41 + {flagship?.latest ? (
42 + <span className="num flex items-center gap-2 text-sm">
43 + <span className="font-semibold">{flagship.latest.value.toFixed(2)}</span>
44 + <Delta value={flagship.change1d} />
45 + </span>
46 + ) : null}
47 + </div>
48 + <LineChart ariaLabel="RARE index, all history" height={260} className="mt-2" series={[{ id: 'rare', label: 'RARE', kind: 'area', points: series.map((p) => ({ x: p.date, y: p.value })) }]} emptyLabel={flagship ? `Index building — needs ${flagship.minConstituents} priced constituents (${flagship.pricedAssets} today)` : 'Not configured'} />
49 + </Card>
50 + <Card className="p-4">
51 + <h2 className="text-sm font-semibold">Subindices · 1 year, indexed</h2>
52 + <p className="text-xs text-muted">Up to four published subindices, rebased to 0% at the start of the window</p>
53 + <LineChart ariaLabel="Subindices indexed performance" height={260} className="mt-2" indexed series={subSeries.map(({ s, pts }) => ({ id: s.ticker, label: s.ticker, points: pts.map((p) => ({ x: p.date, y: p.value })) }))} emptyLabel="No subindex published yet" />
54 + </Card>
55 + </section>
56 +
57 + <Card className="mt-4 overflow-hidden">
58 + <CardHeader title="All indices" subtitle="Values and period returns from index_values; build progress for indices not yet published" />
59 + <Table>
60 + <thead>
61 + <tr>
62 + <th className={th}>Index</th>
63 + <th className={cn(th, 'text-right')}>Value</th>
64 + <th className={cn(th, 'text-right')}>1D</th>
65 + <th className={cn(th, 'text-right')}>7D</th>
66 + <th className={cn(th, 'text-right')}>30D</th>
67 + <th className={cn(th, 'text-right')}>YTD</th>
68 + <th className={cn(th, 'text-right')}>1Y</th>
69 + <th className={cn(th, 'text-right')}>Constituents</th>
70 + <th className={cn(th, 'text-right')}>Transactions</th>
71 + <th className={cn(th, 'text-right')}>Median sale</th>
72 + <th className={cn(th, 'text-right')}>Liquidity</th>
73 + <th className={cn(th, 'text-right')}>Coverage</th>
74 + <th className={th}>Since</th>
75 + </tr>
76 + </thead>
77 + <tbody>
78 + {indices.map((i) => (
79 + <tr key={i.ticker} className="hover:bg-sunken">
80 + <td className={td}>
81 + <Link href={`/rareindex/${i.ticker}`} className="flex items-center gap-2 font-medium text-fg hover:underline">
82 + <span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: i.color ?? 'var(--ri-index)' }} />
83 + {i.ticker}
84 + {i.isFlagship ? <Badge tone="index">Flagship</Badge> : null}
85 + </Link>
86 + <span className="block pl-[18px] text-[11px] text-muted">{i.name}</span>
87 + </td>
88 + <td className={tdNum}>{i.latest ? i.latest.value.toFixed(2) : <span className="text-subtle" title="Not enough priced constituents">building {i.pricedAssets}/{i.minConstituents}</span>}</td>
89 + {([i.change1d, i.change7d, i.change30d, i.changeYtd, i.change1y] as const).map((v, k) => (
90 + <td key={k} className={tdNum} style={heatStyle(v)}>
91 + <Delta value={v} />
92 + </td>
93 + ))}
94 + <td className={tdNum}>{i.latest ? fmtNum(i.latest.constituentsCount) : fmtNum(i.constituents)}</td>
95 + <td className={tdNum}>{i.latest ? fmtNum(i.latest.transactions) : '—'}</td>
96 + <td className={tdNum}>{i.latest?.medianSaleUsd != null ? fmtMoney(i.latest.medianSaleUsd) : '—'}</td>
97 + <td className={tdNum}>{i.latest?.liquidityScore != null ? Math.round(i.latest.liquidityScore) : '—'}</td>
98 + <td className={tdNum}>{i.latest?.coverage != null ? `${Math.round(i.latest.coverage * 100)}%` : '—'}</td>
99 + <td className={cn(td, 'text-muted')}>{i.firstDate ?? '—'}</td>
100 + </tr>
101 + ))}
102 + </tbody>
103 + </Table>
104 + </Card>
105 +
106 + <Card className="mt-4">
107 + <CardHeader title="Correlation matrix · 1Y" subtitle="Pearson correlation of daily index returns (§186). Computed only where overlapping history exists." />
108 + {corr.tickers.length ? (
109 + <div className="overflow-x-auto p-4">
110 + <table className="num text-[11px]">
111 + <thead>
112 + <tr>
113 + <th />
114 + {corr.tickers.map((t) => (
115 + <th key={t} className="px-2 py-1 text-left font-medium text-subtle">
116 + {t.replace('RARE-', '')}
117 + </th>
118 + ))}
119 + </tr>
120 + </thead>
121 + <tbody>
122 + {corr.tickers.map((a) => (
123 + <tr key={a}>
124 + <th className="pr-2 text-left font-medium text-muted">{a.replace('RARE-', '')}</th>
125 + {corr.tickers.map((b) => {
126 + const v = a === b ? 1 : corr.matrix[a]?.[b];
127 + return (
128 + <td key={b} className="px-2 py-1 text-center" style={v === undefined ? {} : { background: `color-mix(in oklab, var(--ri-index) ${Math.round(Math.abs(v) * 70)}%, var(--ri-bg-elevated))` }}>
129 + {v === undefined ? '·' : v.toFixed(2)}
130 + </td>
131 + );
132 + })}
133 + </tr>
134 + ))}
135 + </tbody>
136 + </table>
137 + </div>
138 + ) : (
139 + <p className="px-4 py-8 text-center text-xs text-subtle">Correlations are computed once at least two indices have overlapping daily history.</p>
140 + )}
141 + </Card>
142 + </div>
143 + );
144 +}
added apps/web/src/app/records/page.tsx +47 −0
@@ -0,0 +1,47 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Card, CardHeader, EmptyState } from '@/components/ui/primitives';
5 +import { SalesTable } from '@/components/market/sales-table';
6 +import { Thumb, GradeBadge, SourceLink } from '@/components/market/bits';
7 +import { getRecordSales, getTopSales } from '@/lib/queries/market-lists';
8 +import { fmtDate, fmtMoney } from '@/lib/format';
9 +import { catName } from '@/lib/taxonomy';
10 +
11 +export const metadata: Metadata = { title: 'Record sales', description: 'The highest verified collectible sales tracked by RareIndex, overall and per category.' };
12 +export const revalidate = 300;
13 +
14 +export default async function RecordsPage() {
15 + const [records, top] = await Promise.all([getRecordSales(), getTopSales(100)]);
16 + return (
17 + <div>
18 + <PageHeader title="Record sales" description="Highest verified transactions per collectible family (§154). Only sales with confidence ≥ 80% and a source link qualify; private treaty sales reported without documentation are excluded." />
19 + {records.length ? (
20 + <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
21 + {records.map((r) => (
22 + <Link key={r.id} href={r.assetSlug ? `/asset/${r.assetSlug}` : '/sales'} className="card flex gap-3 p-3 hover:border-border-strong">
23 + <Thumb src={r.heroImageUrl ?? r.imageUrls[0]} alt="" size={64} rounded="rounded-md" />
24 + <span className="min-w-0 flex-1">
25 + <span className="block text-[10px] font-semibold uppercase tracking-wider text-subtle">{catName(r.familySlug)}</span>
26 + <span className="block truncate text-[13px] font-medium text-fg">{r.assetTitle ?? r.rawTitle}</span>
27 + <span className="num block text-lg font-semibold text-fg">{fmtMoney(r.priceUsd)}</span>
28 + <span className="flex flex-wrap items-center gap-1.5 text-[11px] text-muted">
29 + <GradeBadge grader={r.grader} grade={r.grade} />
30 + {fmtDate(r.saleDate)} · <SourceLink name={r.sourceName} url={r.sourceUrl} />
31 + </span>
32 + </span>
33 + </Link>
34 + ))}
35 + </div>
36 + ) : (
37 + <Card>
38 + <EmptyState title="No verified record sales yet" description="Records populate as auction results and marketplace sales are ingested and matched." />
39 + </Card>
40 + )}
41 + <Card className="mt-6 overflow-hidden">
42 + <CardHeader title="Top 100 verified sales" subtitle="All categories, by USD at the historical exchange rate" />
43 + <SalesTable items={top} />
44 + </Card>
45 + </div>
46 + );
47 +}
added apps/web/src/app/robots.ts +11 −0
@@ -0,0 +1,11 @@
1 +import type { MetadataRoute } from 'next';
2 +
3 +const SITE = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io';
4 +
5 +export default function robots(): MetadataRoute.Robots {
6 + return {
7 + rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/account', '/api/', '/collections', '/watchlist', '/alerts', '/search?'] }],
8 + sitemap: [`${SITE}/sitemap-index.xml`, `${SITE}/sitemap/0.xml`],
9 + host: SITE,
10 + };
11 +}
added apps/web/src/app/sales/page.tsx +57 −0
@@ -0,0 +1,57 @@
1 +import type { Metadata } from 'next';
2 +import { Suspense } from 'react';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { FilterBar } from '@/components/ui/filter-bar';
5 +import { Pagination } from '@/components/ui/pagination';
6 +import { Skeleton } from '@/components/ui/primitives';
7 +import { SalesTable } from '@/components/market/sales-table';
8 +import { listSales, listSaleSources } from '@/lib/queries/market-lists';
9 +import { CATEGORIES, GRADERS } from '@rareindex/taxonomy';
10 +import { pick, sp1, spEnum, spInt, spNum, type SP } from '@/lib/search-params';
11 +
12 +export const metadata: Metadata = { title: 'Sales', description: 'Latest verified collectible transactions across marketplaces and auction houses, with grade, source attribution and native currency.' };
13 +
14 +const KEYS = ['category', 'source', 'grader', 'min', 'max', 'days', 'type', 'q', 'sort'];
15 +
16 +export default async function SalesPage({ searchParams }: { searchParams: Promise<SP> }) {
17 + const sp = await searchParams;
18 + const sources = await listSaleSources();
19 + const filters = { category: sp1(sp, 'category') ?? null, source: sp1(sp, 'source') ?? null, grader: sp1(sp, 'grader') ?? null, minUsd: spNum(sp, 'min'), maxUsd: spNum(sp, 'max'), days: spNum(sp, 'days'), saleType: sp1(sp, 'type') ?? null, q: sp1(sp, 'q') ?? null, sort: spEnum(sp, 'sort', ['date', 'price'] as const, 'date'), page: spInt(sp, 'page'), pageSize: 50 };
20 + return (
21 + <div>
22 + <PageHeader title="Sales" description="Observed transactions only — never asking prices. Each row links to its source; prices show USD at the historical exchange rate of the sale date, with the native amount beneath." compact />
23 + <Suspense>
24 + <FilterBar
25 + className="mb-3"
26 + fields={[
27 + { name: 'q', label: 'Search', type: 'text', placeholder: 'asset or title…', width: 'w-44' },
28 + { name: 'category', label: 'Category', type: 'select', options: CATEGORIES.map((c) => ({ value: c.slug, label: `${c.level ? '↳ ' : ''}${c.name}` })), width: 'w-48' },
29 + { name: 'source', label: 'Source', type: 'select', options: sources.map((s) => ({ value: s.id, label: `${s.name} (${s.sales.toLocaleString('en-US')})` })), width: 'w-44' },
30 + { name: 'grader', label: 'Grader', type: 'select', options: GRADERS.map((g) => ({ value: g.slug, label: g.name })), width: 'w-28' },
31 + { name: 'type', label: 'Type', type: 'select', options: ['auction', 'fixed_price', 'best_offer', 'private', 'dealer'].map((t) => ({ value: t, label: t.replace('_', ' ') })), width: 'w-28' },
32 + { name: 'min', label: 'Min $', type: 'number', width: 'w-24' },
33 + { name: 'max', label: 'Max $', type: 'number', width: 'w-24' },
34 + { name: 'days', label: 'Last N days', type: 'number', placeholder: 'all', width: 'w-24' },
35 + { name: 'sort', label: 'Sort', type: 'select', options: [{ value: 'date', label: 'Newest' }, { value: 'price', label: 'Highest price' }], placeholder: 'Newest', width: 'w-32' },
36 + ]}
37 + />
38 + </Suspense>
39 + <Suspense fallback={<Skeleton className="h-96" />}>
40 + <Results filters={filters} params={pick(sp, KEYS)} />
41 + </Suspense>
42 + </div>
43 + );
44 +}
45 +
46 +async function Results({ filters, params }: { filters: Parameters<typeof listSales>[0]; params: Record<string, string | undefined> }) {
47 + const res = await listSales(filters);
48 + return (
49 + <>
50 + <p className="mb-2 num text-[11px] text-subtle">{res.total.toLocaleString('en-US')} sales</p>
51 + <div className="card overflow-hidden">
52 + <SalesTable items={res.items} />
53 + </div>
54 + <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath="/sales" params={params} />
55 + </>
56 + );
57 +}
added apps/web/src/app/search/page.tsx +174 −0
@@ -0,0 +1,174 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { Suspense } from 'react';
4 +import { searchAssets } from '@rareindex/search';
5 +import { PageHeader } from '@/components/ui/page-header';
6 +import { Card, CardHeader, EmptyState, Badge, Skeleton } from '@/components/ui/primitives';
7 +import { Pagination } from '@/components/ui/pagination';
8 +import { AssetList } from '@/components/market/asset-list';
9 +import { getAssetsBySlugs } from '@/lib/queries/assets';
10 +import { rows, sql } from '@/lib/queries/_util';
11 +import { CATEGORIES, INDICES, getGrader } from '@rareindex/taxonomy';
12 +import { catName } from '@/lib/taxonomy';
13 +import { sp1, spInt, type SP } from '@/lib/search-params';
14 +
15 +export const metadata: Metadata = { title: 'Search', description: 'Search collectible assets, sets, categories, brands and sources with natural-language filters.' };
16 +
17 +export default async function SearchPage({ searchParams }: { searchParams: Promise<SP> }) {
18 + const sp = await searchParams;
19 + const q = sp1(sp, 'q') ?? '';
20 + const category = sp1(sp, 'category') ?? null;
21 + const page = spInt(sp, 'page');
22 + return (
23 + <div>
24 + <PageHeader title={q ? <span>Results for “{q}”</span> : 'Search'} description="Hybrid search: lexical + trigram + popularity, with structured filters parsed from your words (grader, grade, year, price bounds, condition, edition)." compact />
25 + <form action="/search" role="search" className="mb-4 flex max-w-2xl gap-2">
26 + <input name="q" defaultValue={q} type="search" placeholder="1999 Charizard PSA 10, Rolex Daytona under $20,000, sealed Mario 64…" 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="Search" />
27 + {category ? <input type="hidden" name="category" value={category} /> : null}
28 + <button type="submit" className="h-10 rounded-md bg-accent px-4 text-sm font-medium text-accent-fg">
29 + Search
30 + </button>
31 + </form>
32 + {q ? (
33 + <Suspense key={`${q}-${category}-${page}`} fallback={<Skeleton className="h-96" />}>
34 + <Results q={q} category={category} page={page} />
35 + </Suspense>
36 + ) : (
37 + <Card>
38 + <EmptyState title="Type anything collectible" description="Card, watch, game, sneaker, LEGO set, comic, coin… Filters like “PSA 10”, “under $5,000”, “sealed” or a year are understood." />
39 + </Card>
40 + )}
41 + </div>
42 + );
43 +}
44 +
45 +async function Results({ q, category, page }: { q: string; category: string | null; page: number }) {
46 + const pageSize = 24;
47 + const res = await searchAssets(q, { limit: pageSize, offset: (page - 1) * pageSize, categorySlug: category });
48 + const items = await getAssetsBySlugs(res.hits.map((h) => h.slug));
49 + const p = res.parsed;
50 + const lower = q.toLowerCase();
51 + const matchedCats = CATEGORIES.filter((c) => c.name.toLowerCase().includes(lower) || p.categorySlugs.includes(c.slug)).slice(0, 6);
52 + const matchedIdx = INDICES.filter((i) => i.ticker.toLowerCase().includes(lower) || i.name.toLowerCase().includes(lower)).slice(0, 3);
53 + const sets = (await rows<{ slug: string; name: string; category_slug: string }>(sql`SELECT slug, name, category_slug FROM sets WHERE name ILIKE ${'%' + q + '%'} ORDER BY name LIMIT 6`)).map((s) => ({ ...s }));
54 + const brands = (await rows<{ brand: string; n: number }>(sql`SELECT brand, count(*) AS n FROM assets WHERE brand ILIKE ${'%' + q + '%'} GROUP BY brand ORDER BY n DESC LIMIT 6`)).map((b) => ({ ...b }));
55 + const sources = (await rows<{ id: string; name: string }>(sql`SELECT id, name FROM sources WHERE name ILIKE ${'%' + q + '%'} LIMIT 4`)).map((s) => ({ ...s }));
56 + const chips = [
57 + p.grader ? `${getGrader(p.grader)?.name ?? p.grader.toUpperCase()}${p.grade ? ` ${p.grade}` : ''}` : null,
58 + p.year ? `year ${p.year}` : null,
59 + p.priceMin != null ? `≥ $${p.priceMin.toLocaleString('en-US')}` : null,
60 + p.priceMax != null ? `≤ $${p.priceMax.toLocaleString('en-US')}` : null,
61 + ...p.conditions,
62 + ...p.editions,
63 + ...p.categorySlugs.map((c) => catName(c)),
64 + ].filter(Boolean) as string[];
65 + return (
66 + <div className="grid gap-4 lg:grid-cols-[1fr_280px]">
67 + <div>
68 + <div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px] text-muted">
69 + <span className="num">
70 + {res.total.toLocaleString('en-US')} assets · {res.tookMs} ms
71 + </span>
72 + {chips.length ? <span>· understood:</span> : null}
73 + {chips.map((c) => (
74 + <Badge key={c} tone="index">
75 + {c}
76 + </Badge>
77 + ))}
78 + {p.text && p.text !== q ? <span>· text “{p.text}”</span> : null}
79 + </div>
80 + <Card className="overflow-hidden">
81 + <AssetList items={items} rank startRank={(page - 1) * pageSize + 1} emptyTitle="No matching assets" emptyDescription="Try fewer words, or browse the category pages. New assets are added continuously by the connectors." />
82 + </Card>
83 + <Pagination page={page} pageSize={pageSize} total={res.total} basePath="/search" params={{ q, category: category ?? undefined }} />
84 + </div>
85 + <aside className="grid gap-3 self-start">
86 + {res.categories.length ? (
87 + <Card>
88 + <CardHeader title="Narrow by category" />
89 + <ul className="divide-y divide-border text-[12px]">
90 + {res.categories.map((c) => (
91 + <li key={c.slug}>
92 + <Link href={`/search?q=${encodeURIComponent(q)}&category=${c.slug}`} className={`flex items-center justify-between px-4 py-1.5 hover:bg-sunken ${category === c.slug ? 'font-medium text-fg' : 'text-muted'}`}>
93 + {c.name}
94 + <span className="num">{c.count}</span>
95 + </Link>
96 + </li>
97 + ))}
98 + {category ? (
99 + <li>
100 + <Link href={`/search?q=${encodeURIComponent(q)}`} className="block px-4 py-1.5 text-muted hover:text-fg">
101 + Clear category
102 + </Link>
103 + </li>
104 + ) : null}
105 + </ul>
106 + </Card>
107 + ) : null}
108 + {matchedCats.length || matchedIdx.length ? (
109 + <Card>
110 + <CardHeader title="Markets & indices" />
111 + <ul className="divide-y divide-border text-[12px]">
112 + {matchedCats.map((c) => (
113 + <li key={c.slug}>
114 + <Link href={`/markets/${c.slug}`} className="block px-4 py-1.5 text-fg hover:bg-sunken">
115 + {c.name} <span className="text-subtle">market</span>
116 + </Link>
117 + </li>
118 + ))}
119 + {matchedIdx.map((i) => (
120 + <li key={i.ticker}>
121 + <Link href={`/rareindex/${i.ticker}`} className="block px-4 py-1.5 text-fg hover:bg-sunken">
122 + {i.ticker} <span className="text-subtle">index</span>
123 + </Link>
124 + </li>
125 + ))}
126 + </ul>
127 + </Card>
128 + ) : null}
129 + {sets.length ? (
130 + <Card>
131 + <CardHeader title="Sets" />
132 + <ul className="divide-y divide-border text-[12px]">
133 + {sets.map((s) => (
134 + <li key={s.slug}>
135 + <Link href={`/set/${s.slug}`} className="block px-4 py-1.5 text-fg hover:bg-sunken">
136 + {s.name} <span className="text-subtle">{catName(s.category_slug)}</span>
137 + </Link>
138 + </li>
139 + ))}
140 + </ul>
141 + </Card>
142 + ) : null}
143 + {brands.length ? (
144 + <Card>
145 + <CardHeader title="Brands" />
146 + <ul className="divide-y divide-border text-[12px]">
147 + {brands.map((b) => (
148 + <li key={b.brand}>
149 + <Link href={`/brand/${encodeURIComponent(b.brand)}`} className="flex justify-between px-4 py-1.5 text-fg hover:bg-sunken">
150 + {b.brand} <span className="num text-subtle">{Number(b.n)}</span>
151 + </Link>
152 + </li>
153 + ))}
154 + </ul>
155 + </Card>
156 + ) : null}
157 + {sources.length ? (
158 + <Card>
159 + <CardHeader title="Sources" />
160 + <ul className="divide-y divide-border text-[12px]">
161 + {sources.map((s) => (
162 + <li key={s.id}>
163 + <Link href={`/sales?source=${s.id}`} className="block px-4 py-1.5 text-fg hover:bg-sunken">
164 + {s.name} <span className="text-subtle">sales</span>
165 + </Link>
166 + </li>
167 + ))}
168 + </ul>
169 + </Card>
170 + ) : null}
171 + </aside>
172 + </div>
173 + );
174 +}
added apps/web/src/app/set/[slug]/page.tsx +59 −0
@@ -0,0 +1,59 @@
1 +import type { Metadata } from 'next';
2 +import { notFound } from 'next/navigation';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Card, Stat } from '@/components/ui/primitives';
5 +import { AssetList } from '@/components/market/asset-list';
6 +import { Pagination } from '@/components/ui/pagination';
7 +import { getSet, getSetStats } from '@/lib/queries/markets';
8 +import { exploreAssets, type ExploreSort } from '@/lib/queries/assets';
9 +import { catName, categoryCrumbs } from '@/lib/taxonomy';
10 +import { fmtMoney, fmtNum, fmtRelative } from '@/lib/format';
11 +import { spEnum, spInt, type SP } from '@/lib/search-params';
12 +
13 +export const revalidate = 300;
14 +
15 +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
16 + const { slug } = await params;
17 + const s = await getSet(slug);
18 + if (!s) return { title: 'Set not found' };
19 + return { title: `${s.name} · ${catName(s.categorySlug)} set`, description: `${s.name}${s.releaseYear ? ` (${s.releaseYear})` : ''}: every tracked asset with valuation, sales and listings.` };
20 +}
21 +
22 +export default async function SetPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {
23 + const { slug } = await params;
24 + const sp = await searchParams;
25 + const set = await getSet(slug);
26 + if (!set) notFound();
27 + const sort = spEnum<ExploreSort>(sp, 'sort', ['riv', 'change30d', 'sales', 'newest', 'latest_sale'], 'riv');
28 + const page = spInt(sp, 'page');
29 + const [stats, res] = await Promise.all([getSetStats(slug), exploreAssets({ set: slug, sort, page, pageSize: 60 })]);
30 + return (
31 + <div>
32 + <PageHeader
33 + crumbs={[{ label: 'Markets', href: '/markets' }, ...categoryCrumbs(set.categorySlug), { label: set.name }]}
34 + title={set.name}
35 + description={`${catName(set.categorySlug)} set${set.code ? ` · code ${set.code}` : ''}${set.releaseDate ? ` · released ${set.releaseDate}` : set.releaseYear ? ` · ${set.releaseYear}` : ''}${set.totalItems ? ` · ${set.totalItems} items in the official set` : ''}${set.language ? ` · ${set.language.toUpperCase()}` : ''}.`}
36 + />
37 + <section className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-4 lg:grid-cols-7">
38 + {[
39 + ['Tracked assets', fmtNum(stats.assets)],
40 + ['With valuation', fmtNum(stats.priced)],
41 + ['Sales', fmtNum(stats.sales)],
42 + ['Active listings', fmtNum(stats.listings)],
43 + ['Median RIV', stats.medianRivUsd != null ? fmtMoney(stats.medianRivUsd) : '—'],
44 + ['Top RIV', stats.maxRivUsd != null ? fmtMoney(stats.maxRivUsd) : '—'],
45 + ['Last sale', stats.lastSaleAt ? fmtRelative(stats.lastSaleAt) : '—'],
46 + ].map(([k, v]) => (
47 + <div key={k} className="bg-elevated px-3 py-2.5">
48 + <Stat label={k} value={v} />
49 + </div>
50 + ))}
51 + </section>
52 + {stats.sumRivUsd != null && stats.priced === stats.assets && stats.assets > 0 ? <p className="mt-1 text-[11px] text-subtle">Sum of RIV across all {stats.assets} priced assets: {fmtMoney(stats.sumRivUsd)} (a complete-set value indication, not a market cap).</p> : null}
53 + <Card className="mt-4 overflow-hidden">
54 + <AssetList items={res.items} rank startRank={(page - 1) * 60 + 1} columns={['riv', 'change30d', 'change1y', 'latestSale', 'sales', 'listings', 'rarity']} emptyTitle="No assets catalogued in this set yet" />
55 + </Card>
56 + <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath={`/set/${slug}`} params={{ sort }} />
57 + </div>
58 + );
59 +}
added apps/web/src/app/sitemap-index.xml/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +import { NextResponse } from 'next/server';
2 +import { countAssets } from '@/lib/queries/assets';
3 +
4 +export const dynamic = 'force-dynamic';
5 +
6 +const SITE = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io';
7 +const PAGE = 20_000;
8 +
9 +/** Sitemap index: /sitemap/0.xml (static + categories + indices + sets) then paginated asset sitemaps. */
10 +export async function GET() {
11 + let n = 0;
12 + try {
13 + n = await countAssets();
14 + } catch {
15 + n = 0;
16 + }
17 + const pages = 1 + Math.max(1, Math.ceil(n / PAGE));
18 + const now = new Date().toISOString();
19 + const body = `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${Array.from({ length: pages }, (_, i) => ` <sitemap><loc>${SITE}/sitemap/${i}.xml</loc><lastmod>${now}</lastmod></sitemap>`).join('\n')}\n</sitemapindex>\n`;
20 + return new NextResponse(body, { headers: { 'content-type': 'application/xml; charset=utf-8', 'cache-control': 'public, max-age=3600' } });
21 +}
added apps/web/src/app/sitemap.ts +45 −0
@@ -0,0 +1,45 @@
1 +import type { MetadataRoute } from 'next';
2 +import { CATEGORIES, INDICES } from '@rareindex/taxonomy';
3 +import { NAV } from '@/config/nav';
4 +import { countAssets, getAssetSlugsForSitemap } from '@/lib/queries/assets';
5 +import { getSetSlugsForSitemap } from '@/lib/queries/markets';
6 +
7 +const SITE = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io';
8 +const PAGE = 20_000;
9 +
10 +export async function generateSitemaps() {
11 + let n = 0;
12 + try {
13 + n = await countAssets();
14 + } catch {
15 + n = 0;
16 + }
17 + const assetPages = Math.max(1, Math.ceil(n / PAGE));
18 + return [{ id: 0 }, ...Array.from({ length: assetPages }, (_, i) => ({ id: i + 1 }))];
19 +}
20 +
21 +export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
22 + const now = new Date();
23 + if (id === 0) {
24 + let sets: string[] = [];
25 + try {
26 + sets = await getSetSlugsForSitemap();
27 + } catch {
28 + sets = [];
29 + }
30 + return [
31 + ...NAV.map((n) => ({ url: `${SITE}${n.href}`, lastModified: now, changeFrequency: 'hourly' as const, priority: n.href === '/' ? 1 : 0.7 })),
32 + ...CATEGORIES.map((c) => ({ url: `${SITE}/markets/${c.slug}`, lastModified: now, changeFrequency: 'hourly' as const, priority: c.phase === 1 ? 0.9 : 0.6 })),
33 + ...INDICES.map((i) => ({ url: `${SITE}/rareindex/${i.ticker}`, lastModified: now, changeFrequency: 'daily' as const, priority: 0.8 })),
34 + ...sets.map((s) => ({ url: `${SITE}/set/${s}`, lastModified: now, changeFrequency: 'daily' as const, priority: 0.6 })),
35 + { url: `${SITE}/records`, lastModified: now, changeFrequency: 'daily' as const, priority: 0.6 },
36 + { url: `${SITE}/radar`, lastModified: now, changeFrequency: 'hourly' as const, priority: 0.5 },
37 + ];
38 + }
39 + try {
40 + const assets = await getAssetSlugsForSitemap((id - 1) * PAGE, PAGE);
41 + return assets.map((a) => ({ url: `${SITE}/asset/${a.slug}`, lastModified: a.updatedAt, changeFrequency: 'daily' as const, priority: 0.7 }));
42 + } catch {
43 + return [];
44 + }
45 +}
added apps/web/src/app/trending/page.tsx +76 −0
@@ -0,0 +1,76 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader } from '@/components/ui/page-header';
4 +import { Card, CardHeader, Delta, EmptyState } from '@/components/ui/primitives';
5 +import { AssetList } from '@/components/market/asset-list';
6 +import { rankedAssets, categoryScope } from '@/lib/queries/assets';
7 +import { getTrendingCategories } from '@/lib/queries/market-lists';
8 +import { catName } from '@/lib/taxonomy';
9 +import { sp1, type SP } from '@/lib/search-params';
10 +import { FAMILIES } from '@rareindex/taxonomy';
11 +import { fmtNum } from '@/lib/format';
12 +
13 +export const metadata: Metadata = { title: 'Trending collectibles', description: 'What collectors are moving into: trending score from price, volume, listing, search and news momentum.' };
14 +export const revalidate = 120;
15 +
16 +export default async function TrendingPage({ searchParams }: { searchParams: Promise<SP> }) {
17 + const sp = await searchParams;
18 + const category = sp1(sp, 'category') ?? null;
19 + const scope = category ? categoryScope(category) : undefined;
20 + const [trending, cats, gainers7, gainers30] = await Promise.all([rankedAssets('trending', { scope, limit: 60 }), getTrendingCategories(12), rankedAssets('gainers', { scope, limit: 12, window: '7d' }), rankedAssets('gainers', { scope, limit: 12, window: '30d' })]);
21 + return (
22 + <div>
23 + <PageHeader
24 + title="Trending"
25 + description="Trending = price momentum × volume momentum × search momentum × listing momentum × news momentum, each normalised (§152). Scores are computed by the pipeline from observed data only."
26 + meta={
27 + <>
28 + <Link href="/trending" className={!category ? 'font-medium text-fg' : 'hover:text-fg'}>
29 + All
30 + </Link>
31 + {FAMILIES.filter((f) => f.phase === 1).map((f) => (
32 + <Link key={f.slug} href={`/trending?category=${f.slug}`} className={category === f.slug ? 'font-medium text-fg' : 'hover:text-fg'}>
33 + {f.name}
34 + </Link>
35 + ))}
36 + </>
37 + }
38 + />
39 + <section className="grid gap-4 lg:grid-cols-[3fr_1fr]">
40 + <Card className="overflow-hidden">
41 + <CardHeader title="Trending assets" subtitle="Ranked by trending score; momentum breakdown per horizon" />
42 + <AssetList items={trending} rank columns={['riv', 'trending', 'change1d', 'change7d', 'change30d', 'sales30d', 'listings', 'liquidity']} metric="trending" emptyTitle="No trending scores yet" emptyDescription="Trending needs several days of valuations, sales and listing history." />
43 + </Card>
44 + <Card>
45 + <CardHeader title="Trending categories" subtitle="Average trending score" />
46 + {cats.length ? (
47 + <ul className="divide-y divide-border text-[12px]">
48 + {cats.map((c) => (
49 + <li key={c.categorySlug}>
50 + <Link href={`/markets/${c.categorySlug}`} className="flex items-center justify-between px-4 py-2 hover:bg-sunken">
51 + <span className="font-medium text-fg">{catName(c.categorySlug)}</span>
52 + <span className="num flex items-center gap-2 text-muted">
53 + {c.trending.toFixed(1)} · {fmtNum(c.assets)} <Delta value={c.momentum} />
54 + </span>
55 + </Link>
56 + </li>
57 + ))}
58 + </ul>
59 + ) : (
60 + <EmptyState title="No data yet" className="py-8" />
61 + )}
62 + </Card>
63 + </section>
64 + <section className="mt-4 grid gap-4 lg:grid-cols-2">
65 + <Card className="overflow-hidden">
66 + <CardHeader title="Top gainers · 7D" />
67 + <AssetList items={gainers7} columns={['riv', 'change7d', 'sales30d']} emptyTitle="No 7-day movers" emptyDescription="Requires ≥3 sales per asset and a week of history." />
68 + </Card>
69 + <Card className="overflow-hidden">
70 + <CardHeader title="Top gainers · 30D" />
71 + <AssetList items={gainers30} columns={['riv', 'change30d', 'sales30d']} emptyTitle="No 30-day movers" emptyDescription="Requires ≥3 sales per asset and a month of history." />
72 + </Card>
73 + </section>
74 + </div>
75 + );
76 +}
added apps/web/src/components/asset/asset-header.tsx +117 −0
@@ -0,0 +1,117 @@
1 +import Image from 'next/image';
2 +import Link from 'next/link';
3 +import type { AssetDetail, VariantRow } from '@/lib/queries/assets';
4 +import { fmtMoney, fmtNum, fmtRelative, fmtDate, cn, confidenceLabel } from '@/lib/format';
5 +import { catName, categoryCrumbs } from '@/lib/taxonomy';
6 +import { Breadcrumbs } from '@/components/ui/page-header';
7 +import { Badge, Delta, Stat } from '@/components/ui/primitives';
8 +import { Evidence, ScoreMeter } from '@/components/ui/evidence';
9 +
10 +export function AssetHeader({ asset, variants, activeVariant, tabHref }: { asset: AssetDetail; variants: VariantRow[]; activeVariant: VariantRow | null; tabHref: (v: string | null) => string }) {
11 + const v = activeVariant;
12 + const riv = v ? v.rivUsd : asset.rivUsd;
13 + const low = v ? v.rivLowUsd : asset.rivLowUsd;
14 + const high = v ? v.rivHighUsd : asset.rivHighUsd;
15 + const conf = v ? v.rivConfidence : asset.rivConfidence;
16 + const n = v ? v.rivSampleSize : asset.rivSampleSize;
17 + const latest = v ? v.latestSaleUsd : asset.latestSaleUsd;
18 + const latestAt = v ? v.latestSaleAt : asset.latestSaleAt;
19 + return (
20 + <header>
21 + <Breadcrumbs items={[{ label: 'Markets', href: '/markets' }, ...categoryCrumbs(asset.categorySlug), ...(asset.setSlug && asset.setName ? [{ label: asset.setName, href: `/set/${asset.setSlug}` }] : []), { label: asset.name }]} />
22 + <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
23 + <div className="flex gap-4">
24 + <div className="relative h-28 w-28 shrink-0 overflow-hidden rounded-md border border-border bg-inset sm:h-40 sm:w-40">
25 + {asset.heroImageUrl ? <Image src={asset.heroImageUrl} alt={asset.title} fill sizes="160px" className="object-contain" priority /> : <span className="absolute inset-0 flex items-center justify-center text-[10px] uppercase tracking-wider text-subtle">No image</span>}
26 + </div>
27 + <div className="min-w-0">
28 + <h1 className="text-xl font-semibold tracking-tight text-fg sm:text-2xl">{asset.title}</h1>
29 + <div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px]">
30 + <Link href={`/markets/${asset.categorySlug}`}>
31 + <Badge tone="index">{catName(asset.categorySlug)}</Badge>
32 + </Link>
33 + {asset.year ? <Badge>{asset.year}</Badge> : null}
34 + {asset.edition ? <Badge>{asset.edition}</Badge> : null}
35 + {asset.variant ? <Badge>{asset.variant}</Badge> : null}
36 + {asset.language && asset.language !== 'en' ? <Badge>{asset.language.toUpperCase()}</Badge> : null}
37 + {asset.rarity ? <Badge tone="rarity">{asset.rarity}</Badge> : null}
38 + {asset.verified ? <Badge tone="gain">Verified record</Badge> : null}
39 + </div>
40 + <dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-[12px] sm:grid-cols-3">
41 + {[
42 + ['Set', asset.setName ? <Link key="s" href={asset.setSlug ? `/set/${asset.setSlug}` : '#'} className="hover:underline">{asset.setName}{asset.setCode ? ` (${asset.setCode})` : ''}</Link> : null],
43 + ['Number', asset.number],
44 + ['Brand', asset.brand ? <Link key="b" href={`/brand/${encodeURIComponent(asset.brand)}`} className="hover:underline">{asset.brand}</Link> : null],
45 + ['Franchise', asset.franchise],
46 + ['Reference', asset.reference],
47 + ['Model', asset.model],
48 + ['Release', asset.releaseDate],
49 + ['Production', asset.productionQuantity != null ? fmtNum(asset.productionQuantity) : null],
50 + ['Original MSRP', asset.originalMsrp != null ? fmtMoney(asset.originalMsrp, asset.originalMsrpCurrency ?? 'USD') : null],
51 + ]
52 + .filter(([, val]) => val)
53 + .map(([k, val]) => (
54 + <div key={k as string} className="min-w-0">
55 + <dt className="text-[10px] uppercase tracking-wider text-subtle">{k}</dt>
56 + <dd className="truncate text-fg">{val}</dd>
57 + </div>
58 + ))}
59 + </dl>
60 + {variants.length ? (
61 + <div className="mt-3">
62 + <p className="mb-1 text-[10px] font-medium uppercase tracking-wider text-subtle">Variant / grade</p>
63 + <div className="flex flex-wrap gap-1">
64 + <Link href={tabHref(null)} scroll={false} className={cn('rounded-md border px-2 py-1 text-[11px]', !v ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:text-fg')}>
65 + All variants
66 + </Link>
67 + {variants.slice(0, 12).map((x) => (
68 + <Link key={x.id} href={tabHref(x.id)} scroll={false} className={cn('rounded-md border px-2 py-1 text-[11px]', v?.id === x.id ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:text-fg')} title={`${x.salesCount} sales`}>
69 + {x.label}
70 + {x.salesCount ? <span className="num ml-1 opacity-70">{x.salesCount}</span> : null}
71 + </Link>
72 + ))}
73 + </div>
74 + </div>
75 + ) : null}
76 + </div>
77 + </div>
78 + <aside className="card p-4">
79 + <div className="flex items-baseline justify-between">
80 + <span className="text-[10px] font-semibold uppercase tracking-wider text-subtle">RareIndex Valuation{v ? ` · ${v.label}` : ''}</span>
81 + <span className="text-[10px] text-subtle">RIV</span>
82 + </div>
83 + {riv !== null ? (
84 + <>
85 + <div className="num mt-1 text-3xl font-semibold tracking-tight text-fg">{fmtMoney(riv)}</div>
86 + <div className="num text-[12px] text-muted">
87 + Range {fmtMoney(low)} – {fmtMoney(high)}
88 + </div>
89 + </>
90 + ) : (
91 + <div className="mt-1">
92 + <div className="text-2xl font-semibold text-subtle">Data unavailable</div>
93 + <p className="text-[11px] text-muted">Insufficient verified evidence for a valuation. Guide prices and listings, if any, are shown below without being presented as market value.</p>
94 + </div>
95 + )}
96 + <Evidence className="mt-2" confidence={conf} sampleSize={n} updatedAt={asset.updatedAt} />
97 + <dl className="mt-3 grid grid-cols-2 gap-3 border-t border-border pt-3">
98 + <Stat label="Latest sale" value={latest !== null ? fmtMoney(latest) : '—'} sub={latestAt ? fmtRelative(latestAt) : undefined} />
99 + <Stat label="30D change" value={<Delta value={v ? v.change30d : asset.change30d} />} />
100 + <Stat label="1Y change" value={<Delta value={v ? v.change1y : asset.change1y} />} />
101 + <Stat label="1D change" value={<Delta value={asset.change1d} />} />
102 + <Stat label="All-time high" value={asset.athUsd !== null ? fmtMoney(asset.athUsd) : '—'} sub={asset.athAt ? fmtDate(asset.athAt) : undefined} />
103 + <Stat label="All-time low" value={asset.atlUsd !== null ? fmtMoney(asset.atlUsd) : '—'} sub={asset.atlAt ? fmtDate(asset.atlAt) : undefined} />
104 + <Stat label="Active listings" value={fmtNum(v ? v.activeListings : asset.activeListings)} sub={(v ? v.minAskUsd : asset.minAskUsd) !== null ? `low ask ${fmtMoney(v ? v.minAskUsd : asset.minAskUsd)}` : undefined} />
105 + <Stat label="Sales 30D" value={fmtNum(v ? v.sales30d : asset.sales30d)} sub={`${fmtNum(v ? v.salesCount : asset.salesCount)} all time`} />
106 + </dl>
107 + <div className="mt-3 grid grid-cols-1 gap-2 border-t border-border pt-3">
108 + <ScoreMeter label="Liquidity" value={v ? v.liquidityScore : asset.liquidityScore} />
109 + <ScoreMeter label="Rarity" value={asset.rarityScore} tone="rarity" />
110 + <ScoreMeter label="Data quality" value={asset.dataQuality} tone="gain" />
111 + </div>
112 + <p className="mt-2 text-[10px] leading-relaxed text-subtle">Valuation is an estimate ({confidenceLabel(conf)} confidence) from observed sales, not an offer or appraisal. RareIndex does not authenticate items.</p>
113 + </aside>
114 + </div>
115 + </header>
116 + );
117 +}
added apps/web/src/components/asset/asset-tabs.tsx +479 −0
@@ -0,0 +1,479 @@
1 +import Image from 'next/image';
2 +import Link from 'next/link';
3 +import type { AssetDetail, VariantRow } from '@/lib/queries/assets';
4 +import { getAssetImages, getAssetListings, getAssetObservations, getAssetSalePoints, getAssetSales, getAssetSnapshots, getAssetSources, getComparables, getGradeDistribution, getLatestValuation, getMarketplaceDistribution, getPopulation, getPriceDistribution } from '@/lib/queries/assets';
5 +import { fmtDate, fmtMoney, fmtNum, fmtRelative, cn } from '@/lib/format';
6 +import { gradeLabel, humanize, catName } from '@/lib/taxonomy';
7 +import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge, Delta } from '@/components/ui/primitives';
8 +import { LineChart } from '@/components/charts/line-chart';
9 +import { BarChart } from '@/components/charts/bar-chart';
10 +import { SalesTable, ListingsTable } from '@/components/market/sales-table';
11 +import { SourceLink, Thumb } from '@/components/market/bits';
12 +import { Evidence } from '@/components/ui/evidence';
13 +
14 +export const ASSET_TABS = ['overview', 'sales', 'listings', 'grades', 'population', 'images', 'history', 'comparables', 'analysis', 'sources'] as const;
15 +export type AssetTab = (typeof ASSET_TABS)[number];
16 +
17 +export async function OverviewTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {
18 + const vid = variant?.id ?? '';
19 + const [snaps, points, dist, sales, listings, obs, valuation] = await Promise.all([
20 + getAssetSnapshots(asset.id, vid, 730),
21 + getAssetSalePoints(asset.id),
22 + getPriceDistribution(asset.id, variant?.id ?? null),
23 + getAssetSales(asset.id, { variantId: variant?.id ?? null, limit: 8 }),
24 + getAssetListings(asset.id, { variantId: variant?.id ?? null, limit: 6 }),
25 + getAssetObservations(asset.id, 12),
26 + getLatestValuation(asset.id, variant?.id ?? null),
27 + ]);
28 + const scatter = variant ? points.filter((p) => p.variantId === variant.id) : points;
29 + const rivSeries = snaps.filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! }));
30 + const askSeries = snaps.filter((p) => p.minAskUsd != null).map((p) => ({ x: p.date, y: p.minAskUsd! }));
31 + const obsSeries = snaps.filter((p) => p.observationUsd != null).map((p) => ({ x: p.date, y: p.observationUsd! }));
32 + const riv = variant ? variant.rivUsd : asset.rivUsd;
33 + return (
34 + <div className="grid gap-4">
35 + <Card className="p-4">
36 + <div className="flex flex-wrap items-baseline justify-between gap-2">
37 + <div>
38 + <h2 className="text-sm font-semibold">Price history</h2>
39 + <p className="text-xs text-muted">Verified sales (dots) with the daily RareIndex Valuation, lowest ask and guide observations when available. USD at historical FX.</p>
40 + </div>
41 + <Link href={`/price-history?asset=${asset.slug}`} className="text-xs text-muted hover:text-fg">
42 + Full history →
43 + </Link>
44 + </div>
45 + <LineChart
46 + ariaLabel={`Price history for ${asset.title}`}
47 + height={300}
48 + currency
49 + className="mt-3"
50 + reference={riv !== null ? { value: riv, label: `RIV ${fmtMoney(riv)}` } : null}
51 + series={[
52 + { id: 'sales', label: 'Sales', kind: 'dots', points: scatter.map((p) => ({ x: p.date, y: p.usd, meta: `${gradeLabel(p.grader, p.grade) ?? 'raw'} · ${p.sourceName}` })) },
53 + ...(rivSeries.length ? [{ id: 'riv', label: 'RIV', points: rivSeries }] : []),
54 + ...(askSeries.length ? [{ id: 'ask', label: 'Lowest ask', dashed: true, points: askSeries }] : []),
55 + ...(obsSeries.length ? [{ id: 'obs', label: 'Guide price', dashed: true, points: obsSeries }] : []),
56 + ]}
57 + emptyLabel="No priced observations yet for this asset"
58 + />
59 + </Card>
60 + <div className="grid gap-4 lg:grid-cols-3">
61 + <Card>
62 + <CardHeader title="Valuation breakdown" subtitle={valuation ? `${valuation.method} · window ${valuation.windowDays}d` : 'No valuation computed'} />
63 + {valuation ? (
64 + <div className="p-4">
65 + <Evidence confidence={valuation.confidence} sampleSize={valuation.sampleSize} updatedAt={valuation.computedAt} />
66 + <Table className="mt-3">
67 + <tbody>
68 + {Object.entries(valuation.methods).map(([k, v]) => (
69 + <tr key={k}>
70 + <td className={cn(td, 'text-muted')}>{humanize(k)}</td>
71 + <td className={tdNum}>{v === null ? <span className="text-subtle">—</span> : fmtMoney(v)}</td>
72 + </tr>
73 + ))}
74 + <tr>
75 + <td className={cn(td, 'font-medium')}>RIV</td>
76 + <td className={cn(tdNum, 'font-semibold')}>{fmtMoney(valuation.rivUsd)}</td>
77 + </tr>
78 + <tr>
79 + <td className={cn(td, 'text-muted')}>Low – high</td>
80 + <td className={tdNum}>
81 + {fmtMoney(valuation.lowUsd)} – {fmtMoney(valuation.highUsd)}
82 + </td>
83 + </tr>
84 + </tbody>
85 + </Table>
86 + {valuation.notes.length ? <ul className="mt-2 list-disc pl-4 text-[11px] text-muted">{valuation.notes.map((n, i) => <li key={i}>{n}</li>)}</ul> : null}
87 + {valuation.observationsUsed ? <p className="mt-2 text-[11px] text-subtle">{valuation.observationsUsed} guide observations were used as a weak prior (weighted below transactions).</p> : null}
88 + </div>
89 + ) : (
90 + <EmptyState title="Data unavailable" description="RIV is published only once enough verified sales exist (§115, §192)." className="py-8" />
91 + )}
92 + </Card>
93 + <Card>
94 + <CardHeader title="Price distribution · 1Y" subtitle="Dispersion of verified sale prices, USD (§135)" />
95 + {dist ? (
96 + <dl className="grid grid-cols-3 gap-3 p-4 text-[12px]">
97 + {[
98 + ['Sales', fmtNum(dist.n)],
99 + ['Min', fmtMoney(dist.min)],
100 + ['P25', fmtMoney(dist.p25)],
101 + ['Median', fmtMoney(dist.median)],
102 + ['P75', fmtMoney(dist.p75)],
103 + ['Max', fmtMoney(dist.max)],
104 + ['Trimmed mean', fmtMoney(dist.trimmedMean)],
105 + ].map(([k, v]) => (
106 + <div key={k}>
107 + <dt className="text-[10px] uppercase tracking-wider text-subtle">{k}</dt>
108 + <dd className="num font-medium text-fg">{v}</dd>
109 + </div>
110 + ))}
111 + </dl>
112 + ) : (
113 + <EmptyState title="No sales in the last year" className="py-8" />
114 + )}
115 + </Card>
116 + <Card>
117 + <CardHeader title="Guide prices" subtitle="Price-guide observations — informative, not transactions" />
118 + {obs.length ? (
119 + <ul className="divide-y divide-border text-[12px]">
120 + {obs.slice(0, 8).map((o) => (
121 + <li key={o.id} className="flex items-center justify-between gap-2 px-4 py-1.5">
122 + <span className="text-muted">
123 + <SourceLink name={o.sourceName} url={o.sourceUrl} /> · {humanize(o.priceKind)}
124 + </span>
125 + <span className="num">
126 + <span className="font-medium">{fmtMoney(o.priceUsd)}</span>
127 + <span className="ml-1 text-[10px] text-subtle">{o.observationDate}</span>
128 + </span>
129 + </li>
130 + ))}
131 + </ul>
132 + ) : (
133 + <EmptyState title="No guide observations" className="py-8" />
134 + )}
135 + </Card>
136 + </div>
137 + <div className="grid gap-4 lg:grid-cols-2">
138 + <Card className="overflow-hidden">
139 + <CardHeader title="Recent sales" action={<Link href={`?tab=sales${variant ? `&v=${variant.id}` : ''}`} className="text-muted hover:text-fg">All {sales.total} →</Link>} />
140 + <SalesTable items={sales.items} showAsset={false} />
141 + </Card>
142 + <Card className="overflow-hidden">
143 + <CardHeader title="Live listings" subtitle="Asking prices, not market value" action={<Link href={`?tab=listings${variant ? `&v=${variant.id}` : ''}`} className="text-muted hover:text-fg">All →</Link>} />
144 + <ListingsTable items={listings} showAsset={false} />
145 + </Card>
146 + </div>
147 + {asset.description ? (
148 + <Card className="p-4">
149 + <h2 className="text-sm font-semibold">About</h2>
150 + <p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed text-muted">{asset.description}</p>
151 + </Card>
152 + ) : null}
153 + </div>
154 + );
155 +}
156 +
157 +export async function SalesTab({ asset, variant, page }: { asset: AssetDetail; variant: VariantRow | null; page: number }) {
158 + const pageSize = 50;
159 + const res = await getAssetSales(asset.id, { variantId: variant?.id ?? null, limit: pageSize, offset: (page - 1) * pageSize, includeFlagged: true });
160 + return (
161 + <Card className="overflow-hidden">
162 + <CardHeader title={`Sales${variant ? ` · ${variant.label}` : ''}`} subtitle={`${res.total.toLocaleString('en-US')} observed transactions · flagged rows are kept for audit (§116) and excluded from valuation`} />
163 + <SalesTable items={res.items} showAsset={false} />
164 + {res.total > pageSize ? (
165 + <div className="flex items-center justify-between px-4 py-2 text-xs text-muted">
166 + <span className="num">
167 + page {page} / {Math.ceil(res.total / pageSize)}
168 + </span>
169 + <span className="flex gap-2">
170 + {page > 1 ? (
171 + <Link href={`?tab=sales&page=${page - 1}${variant ? `&v=${variant.id}` : ''}`} className="hover:text-fg">
172 + ← Newer
173 + </Link>
174 + ) : null}
175 + {page * pageSize < res.total ? (
176 + <Link href={`?tab=sales&page=${page + 1}${variant ? `&v=${variant.id}` : ''}`} className="hover:text-fg">
177 + Older →
178 + </Link>
179 + ) : null}
180 + </span>
181 + </div>
182 + ) : null}
183 + </Card>
184 + );
185 +}
186 +
187 +export async function ListingsTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {
188 + const [live, all] = await Promise.all([getAssetListings(asset.id, { variantId: variant?.id ?? null, limit: 100 }), getAssetListings(asset.id, { variantId: variant?.id ?? null, availability: 'all', limit: 100 })]);
189 + const ended = all.filter((l) => l.availability !== 'available');
190 + return (
191 + <div className="grid gap-4">
192 + <Card className="overflow-hidden">
193 + <CardHeader title="Active listings" subtitle="Sorted by asking price (USD). Cross-listings of the same physical item are grouped when detected." />
194 + <ListingsTable items={live} showAsset={false} />
195 + </Card>
196 + {ended.length ? (
197 + <Card className="overflow-hidden">
198 + <CardHeader title="Ended / removed listings" subtitle="Listing lifecycle is retained (new → price changed → sold/removed)" />
199 + <ListingsTable items={ended} showAsset={false} />
200 + </Card>
201 + ) : null}
202 + </div>
203 + );
204 +}
205 +
206 +export async function GradesTab({ asset }: { asset: AssetDetail }) {
207 + const rows = await getGradeDistribution(asset.id);
208 + const graded = rows.filter((r) => r.grader !== 'raw');
209 + return (
210 + <div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
211 + <Card className="overflow-hidden">
212 + <CardHeader title="Sales by grade" subtitle="Median and last sale per grader/grade — premiums are observed, never assumed across graders (§118)" />
213 + {rows.length ? (
214 + <Table>
215 + <thead>
216 + <tr>
217 + <th className={th}>Grade</th>
218 + <th className={cn(th, 'text-right')}>Sales</th>
219 + <th className={cn(th, 'text-right')}>Median</th>
220 + <th className={cn(th, 'text-right')}>Min</th>
221 + <th className={cn(th, 'text-right')}>Max</th>
222 + <th className={cn(th, 'text-right')}>Last sale</th>
223 + </tr>
224 + </thead>
225 + <tbody>
226 + {rows.map((r) => (
227 + <tr key={`${r.grader}-${r.grade}`} className="hover:bg-sunken">
228 + <td className={td}>
229 + <Badge tone={r.grader === 'raw' ? 'neutral' : 'index'}>{gradeLabel(r.grader, r.grade) ?? 'Raw'}</Badge>
230 + </td>
231 + <td className={tdNum}>{fmtNum(r.sales)}</td>
232 + <td className={cn(tdNum, 'font-medium')}>{fmtMoney(r.medianUsd)}</td>
233 + <td className={tdNum}>{fmtMoney(r.minUsd)}</td>
234 + <td className={tdNum}>{fmtMoney(r.maxUsd)}</td>
235 + <td className={tdNum}>
236 + {fmtMoney(r.lastSaleUsd)}
237 + <span className="block text-[10px] text-subtle">{r.lastSaleAt ? fmtRelative(r.lastSaleAt) : ''}</span>
238 + </td>
239 + </tr>
240 + ))}
241 + </tbody>
242 + </Table>
243 + ) : (
244 + <EmptyState title="No graded sales" description="Grade distribution appears once sales with grader and grade are ingested." />
245 + )}
246 + </Card>
247 + <Card>
248 + <CardHeader title="Median price by grade" />
249 + <div className="p-4">
250 + <BarChart ariaLabel="Median price by grade" currency data={graded.filter((g) => g.medianUsd != null).map((g) => ({ label: gradeLabel(g.grader, g.grade) ?? g.grade, value: g.medianUsd!, sublabel: `n=${g.sales}` }))} />
251 + </div>
252 + </Card>
253 + </div>
254 + );
255 +}
256 +
257 +export async function PopulationTab({ asset }: { asset: AssetDetail }) {
258 + const pops = await getPopulation(asset.id);
259 + const byGrader = new Map<string, typeof pops>();
260 + for (const p of pops) (byGrader.get(p.grader) ?? byGrader.set(p.grader, []).get(p.grader)!).push(p);
261 + if (!pops.length) return <Card><EmptyState title="Population data unavailable" description="Population reports appear when grading-company connectors publish a census for this asset. Counts are never estimated (§119, §192)." /></Card>;
262 + return (
263 + <div className="grid gap-4 lg:grid-cols-2">
264 + {[...byGrader.entries()].map(([grader, reports]) => {
265 + const latest = reports[reports.length - 1]!;
266 + const first = reports[0]!;
267 + const grades = Object.entries(latest.byGrade).sort((a, b) => Number(b[0]) - Number(a[0]));
268 + const top = grades[0];
269 + const gemRate = top && latest.total ? (top[1] / latest.total) : null;
270 + const growth = reports.length > 1 && first.total ? latest.total / first.total - 1 : null;
271 + return (
272 + <Card key={grader}>
273 + <CardHeader title={`${grader.toUpperCase()} population`} subtitle={`Report ${latest.reportDate}${latest.sourceUrl ? ' · ' : ''}`} action={latest.sourceUrl ? <SourceLink name="source" url={latest.sourceUrl} /> : null} />
274 + <dl className="grid grid-cols-4 gap-3 border-b border-border px-4 py-3 text-[12px]">
275 + <div><dt className="text-[10px] uppercase tracking-wider text-subtle">Total graded</dt><dd className="num font-semibold">{fmtNum(latest.total)}</dd></div>
276 + <div><dt className="text-[10px] uppercase tracking-wider text-subtle">Highest grade</dt><dd className="num font-semibold">{top ? `${top[0]} (${fmtNum(top[1])})` : '—'}</dd></div>
277 + <div><dt className="text-[10px] uppercase tracking-wider text-subtle">Top-grade rate</dt><dd className="num font-semibold">{gemRate === null ? '—' : `${(gemRate * 100).toFixed(1)}%`}</dd></div>
278 + <div><dt className="text-[10px] uppercase tracking-wider text-subtle">Population momentum</dt><dd className="num font-semibold"><Delta value={growth} /></dd></div>
279 + </dl>
280 + <div className="p-4">
281 + <BarChart ariaLabel={`${grader} population by grade`} data={grades.map(([g, n]) => ({ label: `Grade ${g}`, value: n }))} maxBars={20} />
282 + {reports.length > 1 ? <LineChart ariaLabel="Population over time" height={160} className="mt-4" series={[{ id: 'pop', label: 'Total population', points: reports.map((r) => ({ x: r.reportDate, y: r.total })) }]} /> : null}
283 + </div>
284 + </Card>
285 + );
286 + })}
287 + </div>
288 + );
289 +}
290 +
291 +export async function ImagesTab({ asset }: { asset: AssetDetail }) {
292 + const imgs = await getAssetImages(asset.id, 48);
293 + if (!imgs.length) return <Card><EmptyState title="No images stored" description="Images are referenced from their source with attribution; none have been captured for this asset yet." /></Card>;
294 + return (
295 + <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
296 + {imgs.map((im) => (
297 + <figure key={im.id} className="card overflow-hidden">
298 + <div className="relative aspect-square bg-inset">
299 + <Image src={im.url} alt={asset.title} fill sizes="(max-width: 640px) 50vw, 200px" className="object-contain" />
300 + </div>
301 + <figcaption className="truncate px-2 py-1 text-[10px] text-subtle">
302 + {im.role}
303 + {im.attribution ? ` · ${im.attribution}` : im.sourceId ? ` · ${im.sourceId}` : ''}
304 + </figcaption>
305 + </figure>
306 + ))}
307 + </div>
308 + );
309 +}
310 +
311 +export async function HistoryTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {
312 + const snaps = await getAssetSnapshots(asset.id, variant?.id ?? '');
313 + return (
314 + <div className="grid gap-4">
315 + <Card className="p-4">
316 + <h2 className="text-sm font-semibold">Daily series</h2>
317 + <p className="text-xs text-muted">RIV, median sale, lowest ask and volume from daily snapshots{variant ? ` · ${variant.label}` : ''}</p>
318 + <LineChart ariaLabel="RIV and median history" height={260} currency className="mt-3" series={[{ id: 'riv', label: 'RIV', points: snaps.filter((s) => s.rivUsd != null).map((s) => ({ x: s.date, y: s.rivUsd! })) }, { id: 'median', label: 'Median sale', dashed: true, points: snaps.filter((s) => s.medianUsd != null).map((s) => ({ x: s.date, y: s.medianUsd! })) }, { id: 'ask', label: 'Lowest ask', dashed: true, points: snaps.filter((s) => s.minAskUsd != null).map((s) => ({ x: s.date, y: s.minAskUsd! })) }]} emptyLabel="No snapshot history yet" />
319 + <div className="mt-4 grid gap-4 md:grid-cols-2">
320 + <LineChart ariaLabel="Transaction volume" height={160} currency series={[{ id: 'vol', label: 'Volume (USD)', kind: 'step', points: snaps.map((s) => ({ x: s.date, y: s.volumeUsd ?? 0 })) }]} emptyLabel="No volume history" />
321 + <LineChart ariaLabel="Active listings" height={160} series={[{ id: 'lst', label: 'Active listings', kind: 'step', points: snaps.map((s) => ({ x: s.date, y: s.listingsCount })) }]} emptyLabel="No listings history" />
322 + </div>
323 + </Card>
324 + <Card className="overflow-hidden">
325 + <CardHeader title="Snapshot table" subtitle={`${snaps.length} days`} />
326 + {snaps.length ? (
327 + <Table>
328 + <thead>
329 + <tr>
330 + <th className={th}>Date</th>
331 + <th className={cn(th, 'text-right')}>RIV</th>
332 + <th className={cn(th, 'text-right')}>Median</th>
333 + <th className={cn(th, 'text-right')}>Last sale</th>
334 + <th className={cn(th, 'text-right')}>Sales</th>
335 + <th className={cn(th, 'text-right')}>Volume</th>
336 + <th className={cn(th, 'text-right')}>Listings</th>
337 + <th className={cn(th, 'text-right')}>Low ask</th>
338 + </tr>
339 + </thead>
340 + <tbody>
341 + {[...snaps].reverse().slice(0, 120).map((s) => (
342 + <tr key={s.date}>
343 + <td className={cn(td, 'text-muted')}>{s.date}</td>
344 + <td className={tdNum}>{fmtMoney(s.rivUsd)}</td>
345 + <td className={tdNum}>{fmtMoney(s.medianUsd)}</td>
346 + <td className={tdNum}>{fmtMoney(s.latestSaleUsd)}</td>
347 + <td className={tdNum}>{s.salesCount}</td>
348 + <td className={tdNum}>{fmtMoney(s.volumeUsd)}</td>
349 + <td className={tdNum}>{s.listingsCount}</td>
350 + <td className={tdNum}>{fmtMoney(s.minAskUsd)}</td>
351 + </tr>
352 + ))}
353 + </tbody>
354 + </Table>
355 + ) : (
356 + <EmptyState title="No snapshots yet" description="Daily snapshots begin once the valuation worker has run for this asset." />
357 + )}
358 + </Card>
359 + </div>
360 + );
361 +}
362 +
363 +export async function ComparablesTab({ asset }: { asset: AssetDetail }) {
364 + const comps = await getComparables(asset, 12);
365 + return (
366 + <Card className="overflow-hidden">
367 + <CardHeader title="Comparable assets" subtitle="Same set/category, close valuation; similarity is a transparent attribute score (§134)" />
368 + {comps.length ? (
369 + <Table>
370 + <thead>
371 + <tr>
372 + <th className={th}>Asset</th>
373 + <th className={cn(th, 'text-right')}>Similarity</th>
374 + <th className={cn(th, 'text-right')}>RIV</th>
375 + <th className={cn(th, 'text-right')}>Last sale</th>
376 + <th className={cn(th, 'text-right')}>30D</th>
377 + <th className={cn(th, 'text-right')}>Sales</th>
378 + <th className={cn(th, 'text-right')}>Liquidity</th>
379 + </tr>
380 + </thead>
381 + <tbody>
382 + {comps.map((c) => (
383 + <tr key={c.id} className="hover:bg-sunken">
384 + <td className={cn(td, 'max-w-[360px]')}>
385 + <span className="flex items-center gap-2">
386 + <Thumb src={c.heroImageUrl} alt="" size={32} />
387 + <span className="min-w-0">
388 + <Link href={`/asset/${c.slug}`} className="block truncate font-medium text-fg hover:underline">
389 + {c.title}
390 + </Link>
391 + <span className="text-[11px] text-muted">{catName(c.categorySlug)}</span>
392 + </span>
393 + </span>
394 + </td>
395 + <td className={tdNum}>{Math.round(c.similarity * 100)}%</td>
396 + <td className={tdNum}>{fmtMoney(c.rivUsd)}</td>
397 + <td className={tdNum}>{fmtMoney(c.latestSaleUsd)}</td>
398 + <td className={tdNum}><Delta value={c.change30d} /></td>
399 + <td className={tdNum}>{fmtNum(c.salesCount)}</td>
400 + <td className={tdNum}>{c.liquidityScore != null ? Math.round(c.liquidityScore) : '—'}</td>
401 + </tr>
402 + ))}
403 + </tbody>
404 + </Table>
405 + ) : (
406 + <EmptyState title="No comparables yet" description="Comparables need other catalogued assets in the same set or category." />
407 + )}
408 + </Card>
409 + );
410 +}
411 +
412 +export function AnalysisTab({ asset }: { asset: AssetDetail }) {
413 + return (
414 + <Card>
415 + <EmptyState
416 + title="AI analysis"
417 + description="Ask the RareIndex research assistant about this asset. Answers are generated from RareIndex's structured data (sales, listings, valuations, indices) with citations — never from model memory."
418 + action={
419 + <Link href={`/research?asset=${asset.slug}`} className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg hover:opacity-90">
420 + Open in AI Research →
421 + </Link>
422 + }
423 + />
424 + </Card>
425 + );
426 +}
427 +
428 +export async function SourcesTab({ asset }: { asset: AssetDetail }) {
429 + const [sources, mkt] = await Promise.all([getAssetSources(asset.id), getMarketplaceDistribution(asset.id)]);
430 + return (
431 + <div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
432 + <Card className="overflow-hidden">
433 + <CardHeader title="Data provenance" subtitle="Every datapoint on this page traces back to one of these sources (§142, §178)" />
434 + {sources.length ? (
435 + <Table>
436 + <thead>
437 + <tr>
438 + <th className={th}>Source</th>
439 + <th className={th}>Type</th>
440 + <th className={cn(th, 'text-right')}>Trust</th>
441 + <th className={cn(th, 'text-right')}>Sales</th>
442 + <th className={cn(th, 'text-right')}>Listings</th>
443 + <th className={cn(th, 'text-right')}>Observations</th>
444 + <th className={th}>Last seen</th>
445 + </tr>
446 + </thead>
447 + <tbody>
448 + {sources.map((s) => (
449 + <tr key={s.sourceId} className="hover:bg-sunken">
450 + <td className={cn(td, 'font-medium')}>
451 + <SourceLink name={s.name} url={s.url} />
452 + </td>
453 + <td className={cn(td, 'text-muted')}>{humanize(s.sourceType)}</td>
454 + <td className={tdNum}>{Math.round(s.trustScore * 100)}%</td>
455 + <td className={tdNum}>{fmtNum(s.sales)}</td>
456 + <td className={tdNum}>{fmtNum(s.listings)}</td>
457 + <td className={tdNum}>{fmtNum(s.observations)}</td>
458 + <td className={cn(td, 'text-muted')}>{s.lastSeen ? fmtRelative(s.lastSeen) : '—'}</td>
459 + </tr>
460 + ))}
461 + </tbody>
462 + </Table>
463 + ) : (
464 + <EmptyState title="No source records yet" />
465 + )}
466 + <div className="border-t border-border px-4 py-2 text-[11px] text-subtle">
467 + Canonical key <code className="rounded-sm bg-inset px-1">{asset.canonicalKey}</code> · identifiers:{' '}
468 + {Object.keys(asset.identifiers).length ? Object.entries(asset.identifiers).map(([k, v]) => <code key={k} className="mr-1 rounded-sm bg-inset px-1">{k}={v}</code>) : 'none'} · created {fmtDate(asset.createdAt)}
469 + </div>
470 + </Card>
471 + <Card>
472 + <CardHeader title="Marketplace distribution" subtitle="Share of verified sales by source" />
473 + <div className="p-4">
474 + <BarChart ariaLabel="Sales by marketplace" data={mkt.map((m) => ({ label: m.sourceName, value: m.sales, sublabel: `volume ${fmtMoney(m.volumeUsd)}` }))} />
475 + </div>
476 + </Card>
477 + </div>
478 + );
479 +}
added apps/web/src/components/charts/bar-chart.tsx +54 −0
@@ -0,0 +1,54 @@
1 +'use client';
2 +
3 +import { useState } from 'react';
4 +import { compactNumber, fullNumber } from './scale';
5 +
6 +export interface BarDatum {
7 + label: string;
8 + value: number;
9 + sublabel?: string;
10 + color?: string;
11 + href?: string;
12 +}
13 +
14 +/**
15 + * Horizontal bar chart (magnitude by category). Bars ≤ 24px thick, 4px rounded data-end, single
16 + * hue by default, per-mark hover tooltip, direct value label at the tip.
17 + */
18 +export function BarChart({ data, currency = false, maxBars = 12, ariaLabel, className, percent = false }: { data: BarDatum[]; currency?: boolean; maxBars?: number; ariaLabel: string; className?: string; percent?: boolean }) {
19 + const [hover, setHover] = useState<number | null>(null);
20 + const rows = data.slice(0, maxBars);
21 + if (!rows.length) return <div className={`text-xs text-subtle ${className ?? ''}`}>No data</div>;
22 + const max = Math.max(...rows.map((r) => Math.abs(r.value)), 1e-9);
23 + const fmt = (v: number) => (percent ? `${(v * 100).toFixed(1)}%` : compactNumber(v, { currency }));
24 + const fmtFull = (v: number) => (percent ? `${(v * 100).toFixed(2)}%` : fullNumber(v, { currency }));
25 + return (
26 + <div className={`relative ${className ?? ''}`} role="img" aria-label={ariaLabel}>
27 + <ul className="space-y-1.5">
28 + {rows.map((r, i) => {
29 + const w = Math.max(2, (Math.abs(r.value) / max) * 100);
30 + return (
31 + <li key={`${r.label}-${i}`} className="grid grid-cols-[minmax(0,9rem)_1fr_auto] items-center gap-2 text-[12px]" onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
32 + <span className="truncate text-muted" title={r.label}>
33 + {r.href ? (
34 + <a href={r.href} className="hover:text-fg">
35 + {r.label}
36 + </a>
37 + ) : (
38 + r.label
39 + )}
40 + </span>
41 + <span className="relative h-4">
42 + <span className="absolute inset-y-0 left-0 rounded-r-[4px]" style={{ width: `${w}%`, background: r.color ?? 'var(--ri-index)', opacity: hover === null || hover === i ? 1 : 0.55 }} />
43 + </span>
44 + <span className="num w-16 text-right font-medium text-fg" title={fmtFull(r.value)}>
45 + {fmt(r.value)}
46 + </span>
47 + </li>
48 + );
49 + })}
50 + </ul>
51 + {hover !== null && rows[hover]?.sublabel ? <div className="mt-1 text-[11px] text-subtle">{rows[hover]!.sublabel}</div> : null}
52 + </div>
53 + );
54 +}
added apps/web/src/components/charts/heat.ts +7 −0
@@ -0,0 +1,7 @@
1 +import { divergingColor } from './scale';
2 +
3 +/** Inline style for heat-table cells (diverging change). */
4 +export function heatStyle(change: number | null | undefined, limit = 0.1): React.CSSProperties {
5 + if (change === null || change === undefined) return {};
6 + return { background: divergingColor(change, limit) };
7 +}
added apps/web/src/components/charts/line-chart.tsx +263 −0
@@ -0,0 +1,263 @@
1 +'use client';
2 +
3 +import { useEffect, useId, useMemo, useRef, useState } from 'react';
4 +import { SERIES_COLORS, compactNumber, fmtDay, fmtDayFull, fullNumber, niceTicks, parseDay } from './scale';
5 +
6 +export interface SeriesPoint {
7 + x: string; // ISO date (YYYY-MM-DD) or ISO datetime
8 + y: number;
9 + meta?: string;
10 +}
11 +
12 +export interface Series {
13 + id: string;
14 + label: string;
15 + points: SeriesPoint[];
16 + color?: string;
17 + kind?: 'line' | 'area' | 'dots' | 'step';
18 + /** dashed style for benchmarks/references */
19 + dashed?: boolean;
20 +}
21 +
22 +export interface LineChartProps {
23 + series: Series[];
24 + height?: number;
25 + currency?: boolean;
26 + /** show values as % change from first point of each series (indexed comparison) */
27 + indexed?: boolean;
28 + yLabel?: string;
29 + className?: string;
30 + ariaLabel: string;
31 + /** optional horizontal reference line (e.g. RIV) */
32 + reference?: { value: number; label: string } | null;
33 + emptyLabel?: string;
34 +}
35 +
36 +/**
37 + * Responsive SVG line/area/dot chart following the dataviz spec: 2px lines, hairline solid grid,
38 + * ≥8px end markers with a surface ring, crosshair + tooltip on hover, legend for ≥2 series,
39 + * selective end labels. Fixed categorical colour order; text uses text tokens only.
40 + */
41 +export function LineChart({ series, height = 260, currency = false, indexed = false, yLabel, className, ariaLabel, reference = null, emptyLabel = 'Not enough data to chart' }: LineChartProps) {
42 + const ref = useRef<HTMLDivElement>(null);
43 + const [width, setWidth] = useState(640);
44 + const [hover, setHover] = useState<number | null>(null);
45 + const uid = useId();
46 +
47 + useEffect(() => {
48 + const el = ref.current;
49 + if (!el) return;
50 + const ro = new ResizeObserver((entries) => {
51 + for (const e of entries) setWidth(Math.max(240, Math.floor(e.contentRect.width)));
52 + });
53 + ro.observe(el);
54 + return () => ro.disconnect();
55 + }, []);
56 +
57 + const prepared = useMemo(() => {
58 + const s = series
59 + .map((ser, i) => {
60 + const pts = ser.points
61 + .map((p) => ({ x: parseDay(p.x), y: p.y, meta: p.meta }))
62 + .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))
63 + .sort((a, b) => a.x - b.x);
64 + let out = pts;
65 + if (indexed && pts.length && pts[0]!.y !== 0) {
66 + const base = pts[0]!.y;
67 + out = pts.map((p) => ({ ...p, y: (p.y / base - 1) * 100 }));
68 + }
69 + return { ...ser, color: ser.color ?? SERIES_COLORS[i % SERIES_COLORS.length]!, pts: out };
70 + })
71 + .filter((s) => s.pts.length > 0);
72 + return s;
73 + }, [series, indexed]);
74 +
75 + const allPts = prepared.flatMap((s) => s.pts);
76 + const hasLine = prepared.some((s) => (s.kind ?? 'line') !== 'dots' && s.pts.length >= 2);
77 + if (allPts.length === 0 || (!hasLine && allPts.length < 1)) {
78 + return (
79 + <div ref={ref} className={className} style={{ height }}>
80 + <div className="flex h-full items-center justify-center text-xs text-subtle">{emptyLabel}</div>
81 + </div>
82 + );
83 + }
84 +
85 + const pad = { top: 12, right: 56, bottom: 26, left: 8 };
86 + const W = width;
87 + const H = height;
88 + const iw = W - pad.left - pad.right;
89 + const ih = H - pad.top - pad.bottom;
90 + const xs = allPts.map((p) => p.x);
91 + const ys = allPts.map((p) => p.y).concat(reference ? [indexed ? 0 : reference.value] : []);
92 + let xMin = Math.min(...xs);
93 + let xMax = Math.max(...xs);
94 + if (xMax - xMin < 7 * 86_400_000) {
95 + // pad a degenerate/short time range so single points and short runs stay readable
96 + xMin -= 15 * 86_400_000;
97 + xMax += 15 * 86_400_000;
98 + }
99 + let yMin = Math.min(...ys);
100 + let yMax = Math.max(...ys);
101 + if (yMin === yMax) {
102 + yMin -= Math.abs(yMin) * 0.05 || 1;
103 + yMax += Math.abs(yMax) * 0.05 || 1;
104 + }
105 + const yPad = (yMax - yMin) * 0.08;
106 + yMin -= yPad;
107 + yMax += yPad;
108 + const ticks = niceTicks(yMin, yMax, 4);
109 + const sx = (x: number) => pad.left + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw);
110 + const sy = (y: number) => pad.top + ih - ((y - yMin) / (yMax - yMin)) * ih;
111 + const span = xMax - xMin;
112 + const xTicks = niceTimeTicks(xMin, xMax, Math.max(2, Math.floor(iw / 110)));
113 + const fmtY = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(Math.abs(v) < 10 ? 1 : 0)}%` : compactNumber(v, { currency }));
114 + const fmtYFull = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(2)}%` : fullNumber(v, { currency }));
115 +
116 + // hover: nearest x across all series
117 + const hoverX = hover === null ? null : xMin + ((hover - pad.left) / iw) * (xMax - xMin);
118 + const hoverRows = hoverX === null
119 + ? []
120 + : prepared.map((s) => {
121 + let best = s.pts[0]!;
122 + let bd = Infinity;
123 + for (const p of s.pts) {
124 + const d = Math.abs(p.x - hoverX);
125 + if (d < bd) {
126 + bd = d;
127 + best = p;
128 + }
129 + }
130 + return { s, p: best };
131 + });
132 + const hoverDate = hoverRows.length ? hoverRows.reduce((a, r) => (Math.abs(r.p.x - hoverX!) < Math.abs(a - hoverX!) ? r.p.x : a), hoverRows[0]!.p.x) : null;
133 +
134 + const linePath = (pts: Array<{ x: number; y: number }>, step = false) =>
135 + pts.map((p, i) => (i === 0 ? `M${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}` : step ? `H${sx(p.x).toFixed(1)}V${sy(p.y).toFixed(1)}` : `L${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`)).join(' ');
136 +
137 + const tooltipLeft = hover !== null ? Math.min(Math.max(hover + 12, pad.left), W - 200) : 0;
138 +
139 + return (
140 + <div ref={ref} className={`relative ${className ?? ''}`}>
141 + {prepared.length > 1 ? (
142 + <ul className="mb-1 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted" aria-label="Legend">
143 + {prepared.map((s) => (
144 + <li key={s.id} className="flex items-center gap-1.5">
145 + <span className="inline-block h-0.5 w-4 rounded-full" style={{ background: s.color, ...(s.dashed ? { backgroundImage: `repeating-linear-gradient(90deg, ${s.color} 0 4px, transparent 4px 7px)` } : {}) }} />
146 + {s.label}
147 + </li>
148 + ))}
149 + </ul>
150 + ) : null}
151 + <svg
152 + width={W}
153 + height={H}
154 + viewBox={`0 0 ${W} ${H}`}
155 + role="img"
156 + aria-label={ariaLabel}
157 + className="block select-none"
158 + onMouseMove={(e) => {
159 + const rect = e.currentTarget.getBoundingClientRect();
160 + const x = e.clientX - rect.left;
161 + setHover(x >= pad.left && x <= pad.left + iw ? x : null);
162 + }}
163 + onMouseLeave={() => setHover(null)}
164 + onTouchMove={(e) => {
165 + const rect = e.currentTarget.getBoundingClientRect();
166 + const t = e.touches[0];
167 + if (!t) return;
168 + const x = t.clientX - rect.left;
169 + setHover(x >= pad.left && x <= pad.left + iw ? x : null);
170 + }}
171 + onTouchEnd={() => setHover(null)}
172 + >
173 + <defs>
174 + {prepared.map((s) => (
175 + <linearGradient key={s.id} id={`${uid}-g-${s.id}`} x1="0" x2="0" y1="0" y2="1">
176 + <stop offset="0%" stopColor={s.color} stopOpacity={0.14} />
177 + <stop offset="100%" stopColor={s.color} stopOpacity={0.02} />
178 + </linearGradient>
179 + ))}
180 + </defs>
181 + {ticks.map((t) => (
182 + <g key={t}>
183 + <line x1={pad.left} x2={pad.left + iw} y1={sy(t)} y2={sy(t)} stroke="var(--ri-border)" strokeWidth={1} shapeRendering="crispEdges" />
184 + <text x={W - pad.right + 6} y={sy(t)} dy="0.32em" fontSize={10} fill="var(--ri-fg-subtle)" className="num">
185 + {fmtY(t)}
186 + </text>
187 + </g>
188 + ))}
189 + {xTicks.map((t) => (
190 + <text key={t} x={sx(t)} y={H - 8} fontSize={10} textAnchor="middle" fill="var(--ri-fg-subtle)">
191 + {fmtDay(t, span)}
192 + </text>
193 + ))}
194 + {reference ? (
195 + <g>
196 + <line x1={pad.left} x2={pad.left + iw} y1={sy(indexed ? 0 : reference.value)} y2={sy(indexed ? 0 : reference.value)} stroke="var(--ri-fg-subtle)" strokeWidth={1} strokeDasharray="3 3" />
197 + <text x={pad.left + 4} y={sy(indexed ? 0 : reference.value) - 4} fontSize={10} fill="var(--ri-fg-muted)">
198 + {reference.label}
199 + </text>
200 + </g>
201 + ) : null}
202 + {prepared.map((s) => {
203 + const kind = s.kind ?? 'line';
204 + if (kind === 'dots') {
205 + return (
206 + <g key={s.id}>
207 + {s.pts.map((p, i) => (
208 + <circle key={i} cx={sx(p.x)} cy={sy(p.y)} r={3.5} fill={s.color} stroke="var(--ri-bg-elevated)" strokeWidth={1.5} opacity={0.85} />
209 + ))}
210 + </g>
211 + );
212 + }
213 + const d = linePath(s.pts, kind === 'step');
214 + const last = s.pts[s.pts.length - 1]!;
215 + return (
216 + <g key={s.id}>
217 + {kind === 'area' ? <path d={`${d} L${sx(last.x).toFixed(1)},${(pad.top + ih).toFixed(1)} L${sx(s.pts[0]!.x).toFixed(1)},${(pad.top + ih).toFixed(1)} Z`} fill={`url(#${uid}-g-${s.id})`} /> : null}
218 + <path d={d} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" strokeDasharray={s.dashed ? '4 4' : undefined} />
219 + <circle cx={sx(last.x)} cy={sy(last.y)} r={4} fill={s.color} stroke="var(--ri-bg-elevated)" strokeWidth={2} />
220 + </g>
221 + );
222 + })}
223 + {hover !== null && hoverDate !== null ? (
224 + <g>
225 + <line x1={sx(hoverDate)} x2={sx(hoverDate)} y1={pad.top} y2={pad.top + ih} stroke="var(--ri-border-strong)" strokeWidth={1} />
226 + {hoverRows.map(({ s, p }) => (
227 + <circle key={s.id} cx={sx(p.x)} cy={sy(p.y)} r={5} fill={s.color} stroke="var(--ri-bg-elevated)" strokeWidth={2} />
228 + ))}
229 + </g>
230 + ) : null}
231 + </svg>
232 + {hover !== null && hoverDate !== null ? (
233 + <div className="pointer-events-none absolute top-1 z-10 min-w-[160px] rounded-md border border-border bg-elevated px-2.5 py-2 text-[11px] shadow-pop" style={{ left: tooltipLeft }}>
234 + <div className="mb-1 font-medium text-fg">{fmtDayFull(hoverDate)}</div>
235 + {hoverRows.map(({ s, p }) => (
236 + <div key={s.id} className="flex items-center justify-between gap-3">
237 + <span className="flex items-center gap-1.5 text-muted">
238 + <span className="inline-block h-2 w-2 rounded-full" style={{ background: s.color }} />
239 + {s.label}
240 + </span>
241 + <span className="num font-medium text-fg">{fmtYFull(p.y)}</span>
242 + </div>
243 + ))}
244 + {hoverRows[0]?.p.meta ? <div className="mt-1 text-subtle">{hoverRows[0].p.meta}</div> : null}
245 + </div>
246 + ) : null}
247 + {yLabel ? <div className="mt-1 text-[10px] uppercase tracking-wider text-subtle">{yLabel}</div> : null}
248 + </div>
249 + );
250 +}
251 +
252 +function niceTimeTicks(min: number, max: number, count: number): number[] {
253 + if (max <= min) return [min];
254 + const span = max - min;
255 + const day = 86_400_000;
256 + const candidates = [day, 2 * day, 7 * day, 14 * day, 30 * day, 61 * day, 91 * day, 182 * day, 365 * day, 730 * day, 1826 * day];
257 + const step = candidates.find((c) => span / c <= count) ?? candidates[candidates.length - 1]!;
258 + const out: number[] = [];
259 + const start = Math.ceil(min / step) * step;
260 + for (let t = start; t <= max; t += step) out.push(t);
261 + if (!out.length) out.push(min, max);
262 + return out;
263 +}
added apps/web/src/components/charts/scale.ts +67 −0
@@ -0,0 +1,67 @@
1 +/** Tiny scale/format helpers shared by chart components (client-safe, no deps). */
2 +
3 +export const SERIES_COLORS = ['var(--ri-series-1)', 'var(--ri-series-2)', 'var(--ri-series-3)', 'var(--ri-series-4)'] as const;
4 +
5 +export function niceTicks(min: number, max: number, count = 4): number[] {
6 + if (!Number.isFinite(min) || !Number.isFinite(max)) return [];
7 + if (min === max) {
8 + const pad = Math.abs(min) * 0.1 || 1;
9 + min -= pad;
10 + max += pad;
11 + }
12 + const span = max - min;
13 + const step0 = span / Math.max(1, count);
14 + const mag = 10 ** Math.floor(Math.log10(step0));
15 + const norm = step0 / mag;
16 + const step = (norm >= 5 ? 10 : norm >= 2 ? 5 : norm >= 1 ? 2 : 1) * mag;
17 + const start = Math.ceil(min / step) * step;
18 + const out: number[] = [];
19 + for (let v = start; v <= max + 1e-9; v += step) out.push(Number(v.toFixed(10)));
20 + return out;
21 +}
22 +
23 +export function compactNumber(v: number, opts: { currency?: boolean } = {}): string {
24 + const abs = Math.abs(v);
25 + const sign = v < 0 ? '-' : '';
26 + const pre = opts.currency ? '$' : '';
27 + if (abs >= 1e9) return `${sign}${pre}${(abs / 1e9).toFixed(abs >= 1e10 ? 0 : 1)}B`;
28 + if (abs >= 1e6) return `${sign}${pre}${(abs / 1e6).toFixed(abs >= 1e7 ? 0 : 1)}M`;
29 + if (abs >= 1e3) return `${sign}${pre}${(abs / 1e3).toFixed(abs >= 1e4 ? 0 : 1)}K`;
30 + if (abs >= 100) return `${sign}${pre}${abs.toFixed(0)}`;
31 + if (abs >= 1) return `${sign}${pre}${abs.toFixed(abs % 1 === 0 ? 0 : 2)}`;
32 + return `${sign}${pre}${abs.toFixed(2)}`;
33 +}
34 +
35 +export function fullNumber(v: number, opts: { currency?: boolean; digits?: number } = {}): string {
36 + return new Intl.NumberFormat('en-US', { style: opts.currency ? 'currency' : 'decimal', currency: 'USD', maximumFractionDigits: opts.digits ?? (Math.abs(v) >= 1000 ? 0 : 2) }).format(v);
37 +}
38 +
39 +export function parseDay(d: string): number {
40 + return new Date(d.length === 10 ? `${d}T00:00:00Z` : d).getTime();
41 +}
42 +
43 +export function fmtDay(ms: number, span: number): string {
44 + const d = new Date(ms);
45 + if (span > 3 * 365 * 86_400_000) return d.toLocaleDateString('en-US', { year: 'numeric', timeZone: 'UTC' });
46 + if (span > 120 * 86_400_000) return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
47 + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
48 +}
49 +
50 +export function fmtDayFull(ms: number): string {
51 + return new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
52 +}
53 +
54 +/** Diverging colour for percentage change: loss → neutral → gain, symmetric around 0 (clamped at ±limit). */
55 +export function divergingColor(change: number | null | undefined, limit = 0.15): string {
56 + if (change === null || change === undefined || !Number.isFinite(change)) return 'var(--ri-bg-inset)';
57 + const t = Math.max(-1, Math.min(1, change / limit));
58 + const a = Math.abs(t);
59 + const alpha = 0.12 + a * 0.68;
60 + return t >= 0 ? `color-mix(in oklab, var(--ri-gain) ${Math.round(alpha * 100)}%, var(--ri-bg-elevated))` : `color-mix(in oklab, var(--ri-loss) ${Math.round(alpha * 100)}%, var(--ri-bg-elevated))`;
61 +}
62 +
63 +/** Sequential colour (single hue) for magnitude in [0,1]. */
64 +export function sequentialColor(t: number): string {
65 + const a = Math.max(0.08, Math.min(1, t));
66 + return `color-mix(in oklab, var(--ri-index) ${Math.round(a * 100)}%, var(--ri-bg-elevated))`;
67 +}
added apps/web/src/components/charts/sparkline.tsx +19 −0
@@ -0,0 +1,19 @@
1 +/** Server-renderable sparkline (no hover; used inside dense tables and stat tiles). */
2 +export function Sparkline({ values, width = 96, height = 24, tone = 'auto', className }: { values: number[]; width?: number; height?: number; tone?: 'auto' | 'neutral' | 'index'; className?: string }) {
3 + const v = values.filter((x) => Number.isFinite(x));
4 + if (v.length < 2) return <span className={`inline-block ${className ?? ''}`} style={{ width, height }} aria-hidden />;
5 + const min = Math.min(...v);
6 + const max = Math.max(...v);
7 + const pad = 2;
8 + const sx = (i: number) => pad + (i / (v.length - 1)) * (width - pad * 2);
9 + const sy = (y: number) => (max === min ? height / 2 : pad + (height - pad * 2) - ((y - min) / (max - min)) * (height - pad * 2));
10 + const d = v.map((y, i) => `${i === 0 ? 'M' : 'L'}${sx(i).toFixed(1)},${sy(y).toFixed(1)}`).join(' ');
11 + const up = v[v.length - 1]! >= v[0]!;
12 + const color = tone === 'neutral' ? 'var(--ri-fg-subtle)' : tone === 'index' ? 'var(--ri-index)' : up ? 'var(--ri-gain)' : 'var(--ri-loss)';
13 + return (
14 + <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={`inline-block align-middle ${className ?? ''}`} aria-hidden>
15 + <path d={d} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />
16 + <circle cx={sx(v.length - 1)} cy={sy(v[v.length - 1]!)} r={2} fill={color} />
17 + </svg>
18 + );
19 +}
added apps/web/src/components/charts/treemap.tsx +139 −0
@@ -0,0 +1,139 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { useEffect, useRef, useState } from 'react';
5 +import { compactNumber, divergingColor } from './scale';
6 +
7 +export interface TreemapNode {
8 + id: string;
9 + label: string;
10 + size: number;
11 + change: number | null;
12 + href?: string;
13 + sublabel?: string;
14 + /** whether the size is a real measurement (volume) or a fallback (asset count) */
15 + sizeLabel: string;
16 +}
17 +
18 +interface Rect {
19 + x: number;
20 + y: number;
21 + w: number;
22 + h: number;
23 +}
24 +
25 +/** Squarified treemap layout. */
26 +function squarify(items: TreemapNode[], rect: Rect): Array<{ node: TreemapNode; rect: Rect }> {
27 + const total = items.reduce((a, n) => a + n.size, 0);
28 + if (!total || !items.length) return [];
29 + const scaled = items.map((n) => ({ node: n, area: (n.size / total) * rect.w * rect.h }));
30 + const out: Array<{ node: TreemapNode; rect: Rect }> = [];
31 + let x = rect.x;
32 + let y = rect.y;
33 + let w = rect.w;
34 + let h = rect.h;
35 + let row: typeof scaled = [];
36 + const worst = (r: typeof scaled, side: number) => {
37 + const s = r.reduce((a, b) => a + b.area, 0);
38 + if (!s) return Infinity;
39 + const min = Math.min(...r.map((z) => z.area));
40 + const max = Math.max(...r.map((z) => z.area));
41 + return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min));
42 + };
43 + const layoutRow = (r: typeof scaled) => {
44 + const s = r.reduce((a, b) => a + b.area, 0);
45 + if (w >= h) {
46 + const rw = s / h;
47 + let cy = y;
48 + for (const it of r) {
49 + const rh = it.area / rw;
50 + out.push({ node: it.node, rect: { x, y: cy, w: rw, h: rh } });
51 + cy += rh;
52 + }
53 + x += rw;
54 + w -= rw;
55 + } else {
56 + const rh = s / w;
57 + let cx = x;
58 + for (const it of r) {
59 + const rw = it.area / rh;
60 + out.push({ node: it.node, rect: { x: cx, y, w: rw, h: rh } });
61 + cx += rw;
62 + }
63 + y += rh;
64 + h -= rh;
65 + }
66 + };
67 + for (const it of scaled) {
68 + const side = Math.min(w, h);
69 + if (!row.length || worst([...row, it], side) <= worst(row, side)) row.push(it);
70 + else {
71 + layoutRow(row);
72 + row = [it];
73 + }
74 + }
75 + if (row.length) layoutRow(row);
76 + return out;
77 +}
78 +
79 +/** Market heat map (§151): area = size measure, colour = diverging change around 0. */
80 +export function Treemap({ nodes, height = 420, ariaLabel, limit = 0.15 }: { nodes: TreemapNode[]; height?: number; ariaLabel: string; limit?: number }) {
81 + const ref = useRef<HTMLDivElement>(null);
82 + const [width, setWidth] = useState(960);
83 + const [hover, setHover] = useState<TreemapNode | null>(null);
84 + useEffect(() => {
85 + const el = ref.current;
86 + if (!el) return;
87 + const ro = new ResizeObserver((es) => {
88 + for (const e of es) setWidth(Math.max(280, Math.floor(e.contentRect.width)));
89 + });
90 + ro.observe(el);
91 + return () => ro.disconnect();
92 + }, []);
93 + const items = nodes.filter((n) => n.size > 0).sort((a, b) => b.size - a.size);
94 + const cells = squarify(items, { x: 0, y: 0, w: width, h: height });
95 + return (
96 + <div ref={ref} className="relative" role="img" aria-label={ariaLabel}>
97 + <div className="relative overflow-hidden rounded-md border border-border bg-elevated" style={{ height }}>
98 + {cells.map(({ node, rect }) => {
99 + const big = rect.w > 90 && rect.h > 44;
100 + const body = (
101 + <div
102 + className="absolute overflow-hidden p-1.5 transition-opacity"
103 + style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h, background: divergingColor(node.change, limit), boxShadow: 'inset 0 0 0 1px var(--ri-bg-elevated)', opacity: hover && hover.id !== node.id ? 0.7 : 1 }}
104 + onMouseEnter={() => setHover(node)}
105 + onMouseLeave={() => setHover(null)}
106 + >
107 + {rect.w > 48 && rect.h > 24 ? (
108 + <div className="flex h-full flex-col justify-between">
109 + <span className={`truncate font-medium text-fg ${big ? 'text-[12px]' : 'text-[10px]'}`}>{node.label}</span>
110 + {big ? <span className="num text-[11px] text-muted">{node.change === null ? 'n/a' : `${node.change > 0 ? '+' : ''}${(node.change * 100).toFixed(1)}%`}</span> : null}
111 + </div>
112 + ) : null}
113 + </div>
114 + );
115 + return node.href ? (
116 + <Link key={node.id} href={node.href} className="contents">
117 + {body}
118 + </Link>
119 + ) : (
120 + <div key={node.id} className="contents">
121 + {body}
122 + </div>
123 + );
124 + })}
125 + </div>
126 + <div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted">
127 + <span className="flex items-center gap-1.5">
128 + <span className="inline-block h-2.5 w-10 rounded-sm" style={{ background: `linear-gradient(90deg, ${divergingColor(-limit)}, var(--ri-bg-inset), ${divergingColor(limit)})` }} />
129 + −{Math.round(limit * 100)}% · 0 · +{Math.round(limit * 100)}% (period change) · area = {items[0]?.sizeLabel ?? 'size'}
130 + </span>
131 + {hover ? (
132 + <span className="num text-fg">
133 + {hover.label}: {hover.change === null ? 'change unavailable' : `${hover.change > 0 ? '+' : ''}${(hover.change * 100).toFixed(2)}%`} · {compactNumber(hover.size, { currency: hover.sizeLabel.includes('$') })} {hover.sublabel ? `· ${hover.sublabel}` : ''}
134 + </span>
135 + ) : null}
136 + </div>
137 + </div>
138 + );
139 +}
added apps/web/src/components/home/hero.tsx +50 −0
@@ -0,0 +1,50 @@
1 +import Link from 'next/link';
2 +import { Search } from 'lucide-react';
3 +import type { SiteStats } from '@/lib/queries/site';
4 +import { fmtNum, fmtRelative } from '@/lib/format';
5 +
6 +const EXAMPLES = ['1999 Charizard PSA 10', 'Rolex Daytona Panda', 'sealed Mario 64', 'first edition Harry Potter UK', 'Michael Jordan rookie PSA 10', 'LEGO Millennium Falcon retired'];
7 +
8 +export function Hero({ stats }: { stats: SiteStats }) {
9 + return (
10 + <section className="pt-8 sm:pt-14">
11 + <div className="mx-auto max-w-3xl text-center">
12 + <p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-subtle">RareIndex · Collectibles market intelligence</p>
13 + <h1 className="text-3xl font-semibold tracking-tight text-fg sm:text-5xl">The Global Market for Collectibles</h1>
14 + <p className="mx-auto mt-3 max-w-2xl text-sm leading-relaxed text-muted sm:text-base">Discover, price, track and analyze collectible assets across the world’s marketplaces and auction houses.</p>
15 + <form action="/search" role="search" className="mx-auto mt-6 flex max-w-2xl items-center gap-2">
16 + <label className="relative flex-1">
17 + <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-subtle" />
18 + <input name="q" type="search" autoComplete="off" placeholder="Search cards, watches, games, sneakers, LEGO, art…" className="h-12 w-full rounded-md border border-border bg-elevated pl-10 pr-3 text-[15px] text-fg shadow-card placeholder:text-subtle focus:border-border-strong focus:outline-none" aria-label="Search collectibles" />
19 + </label>
20 + <button type="submit" className="h-12 shrink-0 rounded-md bg-accent px-4 text-sm font-medium text-accent-fg hover:opacity-90">
21 + Search
22 + </button>
23 + </form>
24 + <ul className="mt-3 flex flex-wrap justify-center gap-1.5 text-[11px] text-muted">
25 + {EXAMPLES.map((e) => (
26 + <li key={e}>
27 + <Link href={`/search?q=${encodeURIComponent(e)}`} className="rounded-full border border-border px-2 py-0.5 hover:border-border-strong hover:text-fg">
28 + {e}
29 + </Link>
30 + </li>
31 + ))}
32 + </ul>
33 + </div>
34 + <dl className="mx-auto mt-8 grid max-w-4xl grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border text-center sm:grid-cols-5">
35 + {[
36 + ['Tracked assets', fmtNum(stats.assets)],
37 + ['Indexed sales', fmtNum(stats.sales)],
38 + ['Price observations', fmtNum(stats.observations)],
39 + ['Sources', fmtNum(stats.sources)],
40 + ['Last update', stats.lastUpdateAt ? fmtRelative(stats.lastUpdateAt) : '—'],
41 + ].map(([k, v]) => (
42 + <div key={k} className="bg-elevated px-3 py-3">
43 + <dt className="text-[10px] font-medium uppercase tracking-wider text-subtle">{k}</dt>
44 + <dd className="num mt-0.5 text-lg font-semibold text-fg">{v}</dd>
45 + </div>
46 + ))}
47 + </dl>
48 + </section>
49 + );
50 +}
added apps/web/src/components/home/sections.tsx +185 −0
@@ -0,0 +1,185 @@
1 +import Link from 'next/link';
2 +import type { AssetCard as AssetCardData, SaleRow } from '@/lib/queries/assets';
3 +import type { RadarRow, LotRow } from '@/lib/queries/market-lists';
4 +import type { LatestSale } from '@/lib/queries/site';
5 +import type { MarketRow } from '@/lib/queries/markets';
6 +import { fmtMoney, fmtRelative, fmtNum, cn, fmtDate } from '@/lib/format';
7 +import { catName, humanize } from '@/lib/taxonomy';
8 +import { Card, CardHeader, Delta, EmptyState } from '@/components/ui/primitives';
9 +import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits';
10 +import { heatStyle } from '@/components/charts/heat';
11 +
12 +function Row({ href, image, title, sub, right, rightSub }: { href: string; image?: string | null; title: string; sub?: string | null; right: React.ReactNode; rightSub?: React.ReactNode }) {
13 + return (
14 + <li>
15 + <Link href={href} className="flex items-center gap-2.5 px-4 py-2 hover:bg-sunken">
16 + <Thumb src={image} alt="" size={32} />
17 + <span className="min-w-0 flex-1">
18 + <span className="block truncate text-[13px] font-medium text-fg">{title}</span>
19 + {sub ? <span className="block truncate text-[11px] text-muted">{sub}</span> : null}
20 + </span>
21 + <span className="num flex shrink-0 flex-col items-end text-[12px] leading-tight">
22 + <span className="font-medium text-fg">{right}</span>
23 + {rightSub ? <span className="text-[11px]">{rightSub}</span> : null}
24 + </span>
25 + </Link>
26 + </li>
27 + );
28 +}
29 +
30 +const EMPTY = 'Populates as the pipeline ingests data.';
31 +
32 +export function AssetRail({ title, subtitle, href, items, metric, empty = EMPTY }: { title: string; subtitle?: string; href: string; items: AssetCardData[]; metric: 'change7d' | 'change1d' | 'change30d' | 'trending' | 'watchers' | 'riv' | 'opportunity' | 'new'; empty?: string }) {
33 + return (
34 + <Card>
35 + <CardHeader title={title} subtitle={subtitle} action={<Link href={href} className="text-muted hover:text-fg">View all →</Link>} />
36 + {items.length ? (
37 + <ul className="divide-y divide-border">
38 + {items.map((a) => (
39 + <Row
40 + key={a.id}
41 + href={`/asset/${a.slug}`}
42 + image={a.heroImageUrl}
43 + title={a.title}
44 + sub={`${catName(a.categorySlug)}${a.year ? ` · ${a.year}` : ''}`}
45 + right={a.rivUsd === null ? <span className="text-subtle">—</span> : fmtMoney(a.rivUsd)}
46 + rightSub={
47 + metric === 'trending' ? <span className="text-muted">score {a.trendingScore?.toFixed(1) ?? '—'}</span>
48 + : metric === 'watchers' ? <span className="text-muted">{fmtNum(a.watchers)} watching</span>
49 + : metric === 'new' ? <span className="text-muted">added {fmtRelative(a.updatedAt)}</span>
50 + : metric === 'riv' ? <span className="text-muted">{a.rivSampleSize} sales</span>
51 + : metric === 'opportunity' ? <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} />
52 + : <Delta value={metric === 'change1d' ? a.change1d : metric === 'change30d' ? a.change30d : a.change7d} />
53 + }
54 + />
55 + ))}
56 + </ul>
57 + ) : (
58 + <EmptyState title="Nothing to show yet" description={empty} className="py-8" />
59 + )}
60 + </Card>
61 + );
62 +}
63 +
64 +export function SalesRail({ title, subtitle, href, items }: { title: string; subtitle?: string; href: string; items: SaleRow[] }) {
65 + return (
66 + <Card>
67 + <CardHeader title={title} subtitle={subtitle} action={<Link href={href} className="text-muted hover:text-fg">View all →</Link>} />
68 + {items.length ? (
69 + <ul className="divide-y divide-border">
70 + {items.map((s) => (
71 + <Row key={s.id} href={s.assetSlug ? `/asset/${s.assetSlug}` : '/sales'} image={s.heroImageUrl ?? s.imageUrls[0]} title={s.assetTitle ?? s.rawTitle} sub={`${s.sourceName} · ${fmtDate(s.saleDate)}`} right={fmtMoney(s.priceUsd)} rightSub={<GradeBadge grader={s.grader} grade={s.grade} />} />
72 + ))}
73 + </ul>
74 + ) : (
75 + <EmptyState title="No verified sales yet" description="Record sales are drawn from verified transactions only (§154)." className="py-8" />
76 + )}
77 + </Card>
78 + );
79 +}
80 +
81 +export function RadarRail({ items }: { items: RadarRow[] }) {
82 + return (
83 + <Card>
84 + <CardHeader title="Rare Finds" subtitle="Rare Radar: unusual appearances and discrepancies" action={<Link href="/radar" className="text-muted hover:text-fg">Open radar →</Link>} />
85 + {items.length ? (
86 + <ul className="divide-y divide-border">
87 + {items.map((r) => (
88 + <Row key={r.id} href={`/asset/${r.assetSlug}`} image={r.heroImageUrl} title={r.assetTitle} sub={`${humanize(r.kind)} · ${fmtRelative(r.detectedAt)}`} right={r.rivUsd === null ? <span className="text-subtle">—</span> : fmtMoney(r.rivUsd)} rightSub={<span className="text-muted">signal {r.score.toFixed(0)}</span>} />
89 + ))}
90 + </ul>
91 + ) : (
92 + <EmptyState title="Radar is quiet" description="Findings appear when the pipeline detects first listings in years, ultra-low populations or price discrepancies." className="py-8" />
93 + )}
94 + </Card>
95 + );
96 +}
97 +
98 +export function LotsRail({ items }: { items: LotRow[] }) {
99 + return (
100 + <Card>
101 + <CardHeader title="Auctions Ending Soon" subtitle="Next 72 hours" action={<Link href="/auctions" className="text-muted hover:text-fg">All auctions →</Link>} />
102 + {items.length ? (
103 + <ul className="divide-y divide-border">
104 + {items.map((l) => (
105 + <li key={l.id}>
106 + <a href={l.url} target="_blank" rel="noopener nofollow" className="flex items-center gap-2.5 px-4 py-2 hover:bg-sunken">
107 + <Thumb src={l.imageUrls[0]} alt="" size={32} />
108 + <span className="min-w-0 flex-1">
109 + <span className="block truncate text-[13px] font-medium text-fg">{l.title}</span>
110 + <span className="block truncate text-[11px] text-muted">
111 + {l.auctionHouse} · ends {fmtRelative(l.endsAt)}
112 + </span>
113 + </span>
114 + <span className="num text-[12px] font-medium text-fg">{l.currentBid !== null ? fmtMoney(l.currentBid, l.currency ?? 'USD') : l.estimateHigh !== null ? `est. ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</span>
115 + </a>
116 + </li>
117 + ))}
118 + </ul>
119 + ) : (
120 + <EmptyState title="No lots ending soon" description="Auction lots appear once auction-house connectors are live." className="py-8" />
121 + )}
122 + </Card>
123 + );
124 +}
125 +
126 +export function MarketInsights({ rows }: { rows: MarketRow[] }) {
127 + const withData = rows.filter((r) => r.counts.assets > 0);
128 + return (
129 + <Card>
130 + <CardHeader title="Market Insights" subtitle="Category performance and activity" action={<Link href="/markets" className="text-muted hover:text-fg">Markets →</Link>} />
131 + {withData.length ? (
132 + <ul className="divide-y divide-border">
133 + {withData.slice(0, 10).map((r) => (
134 + <li key={r.node.slug}>
135 + <Link href={`/markets/${r.node.slug}`} className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-4 py-2 text-[12px] hover:bg-sunken">
136 + <span className="truncate font-medium text-fg">{r.node.name}</span>
137 + <span className="num text-muted">{fmtNum(r.counts.assets)} assets · {fmtNum(r.counts.sales)} sales</span>
138 + <span className="num rounded-sm px-1.5 py-0.5" style={heatStyle(r.snapshot?.change30d)}>
139 + <Delta value={r.snapshot?.change30d} />
140 + </span>
141 + </Link>
142 + </li>
143 + ))}
144 + </ul>
145 + ) : (
146 + <EmptyState title="No category data yet" description={EMPTY} className="py-8" />
147 + )}
148 + </Card>
149 + );
150 +}
151 +
152 +export function LatestTransactions({ items }: { items: LatestSale[] }) {
153 + return (
154 + <Card>
155 + <CardHeader title="Latest transactions" subtitle="Most recent verified sales across sources" action={<Link href="/sales" className="text-muted hover:text-fg">All sales →</Link>} />
156 + {items.length ? (
157 + <ul className="grid grid-cols-1 divide-y divide-border md:grid-cols-2 md:divide-y-0 lg:grid-cols-3">
158 + {items.map((s) => (
159 + <li key={s.id} className="border-b border-border md:[&:nth-child(2n)]:border-l lg:[&:nth-child(2n)]:border-l-0 lg:[&:nth-child(3n+2)]:border-l lg:[&:nth-child(3n)]:border-l">
160 + <Link href={`/asset/${s.assetSlug}`} className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-sunken">
161 + <Thumb src={s.heroImageUrl} alt="" size={36} />
162 + <span className="min-w-0 flex-1">
163 + <span className="block truncate text-[13px] font-medium text-fg">{s.assetTitle}</span>
164 + <span className="flex items-center gap-1.5 text-[11px] text-muted">
165 + <GradeBadge grader={s.grader} grade={s.grade} />
166 + <span className="truncate">
167 + {fmtDate(s.saleDate)} · <SourceLink name={s.sourceName} url={null} />
168 + </span>
169 + </span>
170 + </span>
171 + <span className="num text-[13px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span>
172 + </Link>
173 + </li>
174 + ))}
175 + </ul>
176 + ) : (
177 + <EmptyState title="No transactions yet" description="Verified sales stream in here as connectors run." className="py-8" />
178 + )}
179 + </Card>
180 + );
181 +}
182 +
183 +export function cls(...c: Array<string | false | undefined>): string {
184 + return cn(...c);
185 +}
modified apps/web/src/components/layout/header-search.tsx +89 −5
@@ -2,31 +2,115 @@
2 2
3 3 import { useRouter } from 'next/navigation';
4 4 import { Search } from 'lucide-react';
5 −import { useState } from 'react';
5 +import { useEffect, useRef, useState } from 'react';
6 6
7 −/** Compact header search; the full search experience lives at /search (owned by the search module). */
7 +interface Suggestion {
8 + type: string;
9 + label: string;
10 + sublabel: string | null;
11 + href: string;
12 +}
13 +
14 +/** Header search with instant suggestions (/api/search/suggest); full results at /search. */
8 15 export function HeaderSearch() {
9 16 const router = useRouter();
10 17 const [q, setQ] = useState('');
18 + const [items, setItems] = useState<Suggestion[]>([]);
19 + const [open, setOpen] = useState(false);
20 + const [active, setActive] = useState(-1);
21 + const box = useRef<HTMLFormElement>(null);
22 +
23 + useEffect(() => {
24 + if (q.trim().length < 2) return;
25 + const ctrl = new AbortController();
26 + const t = setTimeout(() => {
27 + fetch(`/api/search/suggest?q=${encodeURIComponent(q)}`, { signal: ctrl.signal })
28 + .then((r) => r.json())
29 + .then((d: { items: Suggestion[] }) => {
30 + setItems(d.items ?? []);
31 + setOpen(true);
32 + setActive(-1);
33 + })
34 + .catch(() => {});
35 + }, 120);
36 + return () => {
37 + clearTimeout(t);
38 + ctrl.abort();
39 + };
40 + }, [q]);
41 +
42 + useEffect(() => {
43 + const onDoc = (e: MouseEvent) => {
44 + if (!box.current?.contains(e.target as Node)) setOpen(false);
45 + };
46 + document.addEventListener('mousedown', onDoc);
47 + return () => document.removeEventListener('mousedown', onDoc);
48 + }, []);
49 +
50 + const go = (href: string) => {
51 + setOpen(false);
52 + router.push(href);
53 + };
54 +
11 55 return (
12 56 <form
57 + ref={box}
13 58 role="search"
14 59 className="relative hidden items-center md:flex"
15 60 onSubmit={(e) => {
16 61 e.preventDefault();
62 + if (active >= 0 && items[active]) return go(items[active]!.href);
17 63 const query = q.trim();
18 − if (query) router.push(`/search?q=${encodeURIComponent(query)}`);
64 + if (query) go(`/search?q=${encodeURIComponent(query)}`);
19 65 }}
20 66 >
21 67 <Search className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-subtle" />
22 68 <input
23 69 type="search"
24 70 value={q}
25 − onChange={(e) => setQ(e.target.value)}
71 + onChange={(e) => {
72 + setQ(e.target.value);
73 + if (e.target.value.trim().length < 2) {
74 + setItems([]);
75 + setOpen(false);
76 + }
77 + }}
78 + role="combobox"
79 + aria-controls="header-search-listbox"
80 + onFocus={() => items.length && setOpen(true)}
81 + onKeyDown={(e) => {
82 + if (e.key === 'ArrowDown') {
83 + e.preventDefault();
84 + setActive((a) => Math.min(items.length - 1, a + 1));
85 + } else if (e.key === 'ArrowUp') {
86 + e.preventDefault();
87 + setActive((a) => Math.max(-1, a - 1));
88 + } else if (e.key === 'Escape') setOpen(false);
89 + }}
26 90 placeholder="Search cards, watches, games, sneakers, LEGO…"
27 − className="h-8 w-64 rounded-md border border-border bg-sunken pl-8 pr-2 text-[13px] text-fg placeholder:text-subtle focus:w-80 focus:border-border-strong focus:outline-none transition-[width] xl:w-80 xl:focus:w-96"
91 + className="h-8 w-64 rounded-md border border-border bg-sunken pl-8 pr-2 text-[13px] text-fg placeholder:text-subtle transition-[width] focus:w-80 focus:border-border-strong focus:outline-none xl:w-80 xl:focus:w-96"
28 92 aria-label="Search collectibles"
93 + aria-autocomplete="list"
94 + aria-expanded={open}
95 + autoComplete="off"
29 96 />
97 + {open && items.length ? (
98 + <ul id="header-search-listbox" className="absolute left-0 top-full z-50 mt-1 w-[26rem] overflow-hidden rounded-md border border-border bg-elevated py-1 text-[13px] shadow-pop" role="listbox">
99 + {items.map((it, i) => (
100 + <li key={`${it.type}-${it.href}`} role="option" aria-selected={i === active}>
101 + <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => go(it.href)} className={`flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left ${i === active ? 'bg-inset' : 'hover:bg-sunken'}`}>
102 + <span className="truncate text-fg">{it.label}</span>
103 + <span className="shrink-0 text-[10px] uppercase tracking-wider text-subtle">{it.sublabel ?? it.type}</span>
104 + </button>
105 + </li>
106 + ))}
107 + <li>
108 + <button type="submit" className="w-full px-3 py-1.5 text-left text-[12px] text-muted hover:bg-sunken">
109 + Search all results for “{q}” →
110 + </button>
111 + </li>
112 + </ul>
113 + ) : null}
30 114 </form>
31 115 );
32 116 }
modified apps/web/src/components/layout/mobile-nav.tsx +6 −1
@@ -9,7 +9,12 @@ import type { NavItem } from '@/config/nav';
9 9 export function MobileNav({ items, groups }: { items: NavItem[]; groups: Array<{ id: NavItem['group']; label: string }> }) {
10 10 const [open, setOpen] = useState(false);
11 11 const pathname = usePathname();
12 − useEffect(() => setOpen(false), [pathname]);
12 + const [prevPath, setPrevPath] = useState(pathname);
13 + if (pathname !== prevPath) {
14 + // close the drawer on navigation (state adjustment during render, no effect needed)
15 + setPrevPath(pathname);
16 + setOpen(false);
17 + }
13 18 useEffect(() => {
14 19 document.body.style.overflow = open ? 'hidden' : '';
15 20 return () => {
modified apps/web/src/components/layout/theme-toggle.tsx +22 −12
@@ -1,10 +1,28 @@
1 1 'use client';
2 2
3 −import { useEffect, useState } from 'react';
3 +import { useSyncExternalStore } from 'react';
4 4 import { Moon, Sun } from 'lucide-react';
5 5
6 6 type Theme = 'light' | 'dark';
7 7
8 +const listeners = new Set<() => void>();
9 +function subscribe(cb: () => void) {
10 + listeners.add(cb);
11 + const mq = window.matchMedia('(prefers-color-scheme: dark)');
12 + mq.addEventListener('change', cb);
13 + return () => {
14 + listeners.delete(cb);
15 + mq.removeEventListener('change', cb);
16 + };
17 +}
18 +function getSnapshot(): Theme {
19 + const attr = document.documentElement.getAttribute('data-theme');
20 + if (attr === 'dark' || attr === 'light') return attr;
21 + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
22 +}
23 +function getServerSnapshot(): Theme {
24 + return 'light';
25 +}
8 26 function applyTheme(t: Theme) {
9 27 document.documentElement.setAttribute('data-theme', t);
10 28 try {
@@ -12,25 +30,17 @@ function applyTheme(t: Theme) {
12 30 } catch {
13 31 /* ignore */
14 32 }
33 + for (const l of listeners) l();
15 34 }
16 35
17 36 export function ThemeToggle({ className }: { className?: string }) {
18 − const [theme, setTheme] = useState<Theme>('light');
19 − useEffect(() => {
20 − const attr = document.documentElement.getAttribute('data-theme') as Theme | null;
21 − if (attr) setTheme(attr);
22 − else setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
23 − }, []);
37 + const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
24 38 return (
25 39 <button
26 40 type="button"
27 41 aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
28 42 className={className ?? 'inline-flex h-8 w-8 items-center justify-center rounded-md text-muted hover:bg-inset hover:text-fg'}
29 − onClick={() => {
30 − const next: Theme = theme === 'dark' ? 'light' : 'dark';
31 − setTheme(next);
32 − applyTheme(next);
33 − }}
43 + onClick={() => applyTheme(theme === 'dark' ? 'light' : 'dark')}
34 44 >
35 45 {theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
36 46 </button>
added apps/web/src/components/market/asset-list.tsx +192 −0
@@ -0,0 +1,192 @@
1 +import Link from 'next/link';
2 +import type { AssetCard as AssetCardData } from '@/lib/queries/assets';
3 +import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format';
4 +import { catName } from '@/lib/taxonomy';
5 +import { Delta, Table, th, td, tdNum, EmptyState } from '@/components/ui/primitives';
6 +import { Thumb } from './bits';
7 +
8 +export type AssetColumn = 'riv' | 'change1d' | 'change7d' | 'change30d' | 'change1y' | 'latestSale' | 'sales' | 'sales30d' | 'listings' | 'minAsk' | 'liquidity' | 'rarity' | 'trending' | 'opportunity' | 'watchers' | 'updated';
9 +
10 +const DEFAULT_COLUMNS: AssetColumn[] = ['riv', 'change7d', 'change30d', 'latestSale', 'sales', 'listings', 'liquidity'];
11 +
12 +const HEAD: Record<AssetColumn, string> = {
13 + riv: 'RIV (USD)',
14 + change1d: '1D',
15 + change7d: '7D',
16 + change30d: '30D',
17 + change1y: '1Y',
18 + latestSale: 'Last sale',
19 + sales: 'Sales',
20 + sales30d: 'Sales 30D',
21 + listings: 'Listings',
22 + minAsk: 'Low ask',
23 + liquidity: 'Liquidity',
24 + rarity: 'Rarity',
25 + trending: 'Trending',
26 + opportunity: 'Value opp.',
27 + watchers: 'Watchers',
28 + updated: 'Updated',
29 +};
30 +
31 +function cell(a: AssetCardData, c: AssetColumn) {
32 + switch (c) {
33 + case 'riv':
34 + return a.rivUsd === null ? (
35 + <span className="text-subtle" title="Insufficient evidence for a valuation">—</span>
36 + ) : (
37 + <span className="inline-flex flex-col items-end leading-tight">
38 + <span className="font-medium">{fmtMoney(a.rivUsd)}</span>
39 + <span className="text-[10px] text-subtle">
40 + {confidenceLabel(a.rivConfidence)} · n={a.rivSampleSize}
41 + </span>
42 + </span>
43 + );
44 + case 'change1d':
45 + return <Delta value={a.change1d} />;
46 + case 'change7d':
47 + return <Delta value={a.change7d} />;
48 + case 'change30d':
49 + return <Delta value={a.change30d} />;
50 + case 'change1y':
51 + return <Delta value={a.change1y} />;
52 + case 'latestSale':
53 + return a.latestSaleUsd === null ? (
54 + <span className="text-subtle">—</span>
55 + ) : (
56 + <span className="inline-flex flex-col items-end leading-tight">
57 + <span>{fmtMoney(a.latestSaleUsd)}</span>
58 + <span className="text-[10px] text-subtle">{fmtRelative(a.latestSaleAt)}</span>
59 + </span>
60 + );
61 + case 'sales':
62 + return fmtNum(a.salesCount);
63 + case 'sales30d':
64 + return fmtNum(a.sales30d);
65 + case 'listings':
66 + return fmtNum(a.activeListings);
67 + case 'minAsk':
68 + return fmtMoney(a.minAskUsd);
69 + case 'liquidity':
70 + return a.liquidityScore === null ? <span className="text-subtle">—</span> : Math.round(a.liquidityScore);
71 + case 'rarity':
72 + return a.rarityScore === null ? <span className="text-subtle">—</span> : Math.round(a.rarityScore);
73 + case 'trending':
74 + return a.trendingScore === null ? <span className="text-subtle">—</span> : a.trendingScore.toFixed(1);
75 + case 'opportunity':
76 + return a.valueOpportunity === null ? <span className="text-subtle">—</span> : <Delta value={-a.valueOpportunity} />;
77 + case 'watchers':
78 + return fmtNum(a.watchers);
79 + case 'updated':
80 + return <span className="text-muted">{fmtRelative(a.updatedAt)}</span>;
81 + }
82 +}
83 +
84 +export function AssetTable({ items, columns = DEFAULT_COLUMNS, rank = false, startRank = 1, className, emptyTitle = 'No assets yet', emptyDescription = 'Assets appear here as connectors ingest catalogues, sales and listings.' }: { items: AssetCardData[]; columns?: AssetColumn[]; rank?: boolean; startRank?: number; className?: string; emptyTitle?: string; emptyDescription?: string }) {
85 + if (!items.length) return <EmptyState title={emptyTitle} description={emptyDescription} />;
86 + return (
87 + <Table className={className}>
88 + <thead>
89 + <tr>
90 + {rank ? <th className={cn(th, 'w-8 text-right')}>#</th> : null}
91 + <th className={th}>Asset</th>
92 + {columns.map((c) => (
93 + <th key={c} className={cn(th, 'text-right')}>
94 + {HEAD[c]}
95 + </th>
96 + ))}
97 + </tr>
98 + </thead>
99 + <tbody>
100 + {items.map((a, i) => (
101 + <tr key={a.id} className="hover:bg-sunken">
102 + {rank ? <td className={cn(tdNum, 'text-subtle')}>{startRank + i}</td> : null}
103 + <td className={cn(td, 'max-w-[320px]')}>
104 + <span className="flex min-w-0 items-center gap-2.5">
105 + <Thumb src={a.heroImageUrl} alt="" size={32} />
106 + <span className="min-w-0">
107 + <Link href={`/asset/${a.slug}`} className="block truncate font-medium text-fg hover:underline" title={a.title}>
108 + {a.title}
109 + </Link>
110 + <span className="block truncate text-[11px] text-muted">
111 + {catName(a.categorySlug)}
112 + {a.setName ? ` · ${a.setName}` : ''}
113 + {a.year ? ` · ${a.year}` : ''}
114 + </span>
115 + </span>
116 + </span>
117 + </td>
118 + {columns.map((c) => (
119 + <td key={c} className={tdNum}>
120 + {cell(a, c)}
121 + </td>
122 + ))}
123 + </tr>
124 + ))}
125 + </tbody>
126 + </Table>
127 + );
128 +}
129 +
130 +/** Compact card for mobile grids and discovery rails. */
131 +export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCardData; className?: string; metric?: 'change30d' | 'change7d' | 'change1d' | 'latestSale' | 'opportunity' | 'trending' }) {
132 + const metricNode =
133 + metric === 'latestSale' ? (
134 + <span className="text-[11px] text-muted">Last sale {fmtMoney(a.latestSaleUsd)}</span>
135 + ) : metric === 'opportunity' ? (
136 + <span className="text-[11px] text-muted">
137 + Opportunity <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[11px]" />
138 + </span>
139 + ) : metric === 'trending' ? (
140 + <span className="text-[11px] text-muted">Trending {a.trendingScore === null ? '—' : a.trendingScore.toFixed(1)}</span>
141 + ) : (
142 + <span className="text-[11px] text-muted">
143 + {metric === 'change1d' ? '1D' : metric === 'change7d' ? '7D' : '30D'} <Delta value={metric === 'change1d' ? a.change1d : metric === 'change7d' ? a.change7d : a.change30d} className="text-[11px]" />
144 + </span>
145 + );
146 + return (
147 + <Link href={`/asset/${a.slug}`} className={cn('card flex gap-3 p-3 transition-colors hover:border-border-strong', className)}>
148 + <Thumb src={a.heroImageUrl} alt="" size={56} rounded="rounded-md" />
149 + <span className="flex min-w-0 flex-1 flex-col">
150 + <span className="truncate text-[13px] font-medium text-fg" title={a.title}>
151 + {a.title}
152 + </span>
153 + <span className="truncate text-[11px] text-muted">
154 + {catName(a.categorySlug)}
155 + {a.year ? ` · ${a.year}` : ''}
156 + </span>
157 + <span className="mt-auto flex items-baseline justify-between gap-2 pt-1">
158 + <span className="num text-sm font-semibold text-fg" title={a.rivUsd === null ? 'Insufficient evidence for a valuation' : `RareIndex Valuation · ${confidenceLabel(a.rivConfidence)} confidence · ${a.rivSampleSize} sales`}>
159 + {a.rivUsd === null ? <span className="text-subtle">RIV —</span> : fmtMoney(a.rivUsd)}
160 + </span>
161 + {metricNode}
162 + </span>
163 + </span>
164 + </Link>
165 + );
166 +}
167 +
168 +export function AssetCardGrid({ items, metric, className }: { items: AssetCardData[]; metric?: Parameters<typeof AssetCard>[0]['metric']; className?: string }) {
169 + if (!items.length) return null;
170 + return (
171 + <div className={cn('grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', className)}>
172 + {items.map((a) => (
173 + <AssetCard key={a.id} a={a} metric={metric} />
174 + ))}
175 + </div>
176 + );
177 +}
178 +
179 +/** Responsive list: cards on small screens, table from md up. */
180 +export function AssetList(props: Parameters<typeof AssetTable>[0] & { metric?: Parameters<typeof AssetCard>[0]['metric'] }) {
181 + if (!props.items.length) return <AssetTable {...props} />;
182 + return (
183 + <>
184 + <div className="md:hidden">
185 + <AssetCardGrid items={props.items} metric={props.metric} />
186 + </div>
187 + <div className="hidden md:block">
188 + <AssetTable {...props} />
189 + </div>
190 + </>
191 + );
192 +}
added apps/web/src/components/market/bits.tsx +73 −0
@@ -0,0 +1,73 @@
1 +import Image from 'next/image';
2 +import Link from 'next/link';
3 +import { ExternalLink } from 'lucide-react';
4 +import { cn, fmtMoney } from '@/lib/format';
5 +import { catName, gradeLabel } from '@/lib/taxonomy';
6 +import { Badge } from '@/components/ui/primitives';
7 +
8 +/** Small shared building blocks for market tables and cards. */
9 +
10 +export function Thumb({ src, alt, size = 40, className, rounded = 'rounded-sm' }: { src: string | null | undefined; alt: string; size?: number; className?: string; rounded?: string }) {
11 + return (
12 + <span className={cn('relative inline-block shrink-0 overflow-hidden bg-inset', rounded, className)} style={{ width: size, height: size }}>
13 + {src ? <Image src={src} alt={alt} fill sizes={`${size}px`} className="object-cover" unoptimized={src.endsWith('.svg')} /> : <span className="absolute inset-0 flex items-center justify-center text-[9px] uppercase tracking-wider text-subtle">n/a</span>}
14 + </span>
15 + );
16 +}
17 +
18 +export function CategoryTag({ slug, className }: { slug: string | null | undefined; className?: string }) {
19 + if (!slug) return null;
20 + return (
21 + <Link href={`/markets/${slug}`} className={cn('text-[11px] text-muted hover:text-fg', className)}>
22 + {catName(slug)}
23 + </Link>
24 + );
25 +}
26 +
27 +export function GradeBadge({ grader, grade, className }: { grader: string | null | undefined; grade: string | null | undefined; className?: string }) {
28 + const label = gradeLabel(grader, grade);
29 + if (!label) return <span className={cn('text-subtle', className)}>—</span>;
30 + return (
31 + <Badge tone={grader && grader !== 'raw' ? 'index' : 'neutral'} className={className}>
32 + {label}
33 + </Badge>
34 + );
35 +}
36 +
37 +/** §178: source attribution with outbound link. */
38 +export function SourceLink({ name, url, className }: { name: string; url: string | null | undefined; className?: string }) {
39 + if (!url) return <span className={cn('text-muted', className)}>{name}</span>;
40 + return (
41 + <a href={url} target="_blank" rel="noopener nofollow" className={cn('inline-flex items-center gap-1 text-muted hover:text-fg', className)} title={`View on ${name}`}>
42 + {name}
43 + <ExternalLink className="h-3 w-3" aria-hidden />
44 + </a>
45 + );
46 +}
47 +
48 +export function PriceCell({ usd, native, currency, className }: { usd: number | null | undefined; native?: number | null; currency?: string | null; className?: string }) {
49 + const showNative = native !== null && native !== undefined && currency && currency !== 'USD';
50 + return (
51 + <span className={cn('num inline-flex flex-col items-end leading-tight', className)}>
52 + <span className="font-medium text-fg">{fmtMoney(usd)}</span>
53 + {showNative ? <span className="text-[10px] text-subtle">{fmtMoney(native, currency!)}</span> : null}
54 + </span>
55 + );
56 +}
57 +
58 +export function AssetTitleCell({ slug, title, categorySlug, image, sub, size = 36 }: { slug: string; title: string; categorySlug?: string | null; image?: string | null; sub?: string | null; size?: number }) {
59 + return (
60 + <span className="flex min-w-0 items-center gap-2.5">
61 + <Thumb src={image} alt="" size={size} />
62 + <span className="min-w-0">
63 + <Link href={`/asset/${slug}`} className="block truncate font-medium text-fg hover:underline" title={title}>
64 + {title}
65 + </Link>
66 + <span className="block truncate text-[11px] text-muted">
67 + {categorySlug ? catName(categorySlug) : null}
68 + {sub ? (categorySlug ? ` · ${sub}` : sub) : null}
69 + </span>
70 + </span>
71 + </span>
72 + );
73 +}
added apps/web/src/components/market/index-strip.tsx +36 −0
@@ -0,0 +1,36 @@
1 +import Link from 'next/link';
2 +import type { IndexRow } from '@/lib/queries/indices';
3 +import { fmtNum, cn } from '@/lib/format';
4 +import { Delta } from '@/components/ui/primitives';
5 +
6 +/** Live tape (§163): marquee of indices; indices without a published value show their build status. */
7 +export function IndexTape({ indices, className }: { indices: IndexRow[]; className?: string }) {
8 + if (!indices.length) return null;
9 + const items = indices.map((i) => (
10 + <Link key={i.ticker} href={`/rareindex/${i.ticker}`} className="flex shrink-0 items-center gap-2 px-4 text-[12px] hover:bg-inset" style={{ borderLeft: `2px solid ${i.color ?? 'var(--ri-index)'}` }}>
11 + <span className="font-semibold tracking-wide text-fg">{i.ticker}</span>
12 + {i.latest ? (
13 + <>
14 + <span className="num text-muted">{fmtNum(i.latest.value, { digits: 2 })}</span>
15 + <Delta value={i.change1d} />
16 + </>
17 + ) : (
18 + <span className="text-subtle">
19 + building · {i.pricedAssets}/{i.minConstituents}
20 + </span>
21 + )}
22 + </Link>
23 + ));
24 + return (
25 + <div className={cn('relative overflow-hidden border-y border-border bg-elevated', className)} aria-label="Index tape">
26 + <div className="tape-track flex w-max py-1.5">
27 + {items}
28 + {items.map((it, k) => (
29 + <span key={`dup-${k}`} aria-hidden>
30 + {it}
31 + </span>
32 + ))}
33 + </div>
34 + </div>
35 + );
36 +}
added apps/web/src/components/market/market-table.tsx +70 −0
@@ -0,0 +1,70 @@
1 +import Link from 'next/link';
2 +import type { MarketRow } from '@/lib/queries/markets';
3 +import { fmtMoney, fmtNum, fmtRelative, cn } from '@/lib/format';
4 +import { Table, th, td, tdNum, Delta, Badge } from '@/components/ui/primitives';
5 +import { heatStyle } from '@/components/charts/heat';
6 +
7 +/** Heat table of categories (markets overview and child categories). */
8 +export function MarketTable({ rows, showPhase = true, className }: { rows: MarketRow[]; showPhase?: boolean; className?: string }) {
9 + const sorted = [...rows].sort((a, b) => b.counts.sales - a.counts.sales || b.counts.assets - a.counts.assets || a.node.sortOrder - b.node.sortOrder);
10 + return (
11 + <Table className={className}>
12 + <thead>
13 + <tr>
14 + <th className={th}>Category</th>
15 + <th className={cn(th, 'text-right')}>Index</th>
16 + <th className={cn(th, 'text-right')}>1D</th>
17 + <th className={cn(th, 'text-right')}>7D</th>
18 + <th className={cn(th, 'text-right')}>30D</th>
19 + <th className={cn(th, 'text-right')}>1Y</th>
20 + <th className={cn(th, 'text-right')}>Assets</th>
21 + <th className={cn(th, 'text-right')}>Priced</th>
22 + <th className={cn(th, 'text-right')}>Sales</th>
23 + <th className={cn(th, 'text-right')}>Sales 30D</th>
24 + <th className={cn(th, 'text-right')}>Volume 30D</th>
25 + <th className={cn(th, 'text-right')}>Listings</th>
26 + <th className={cn(th, 'text-right')}>Liquidity</th>
27 + <th className={cn(th, 'text-right')} title="Estimated population × representative value; shown only with a methodology confidence label">
28 + Market cap est.
29 + </th>
30 + <th className={th}>Last sale</th>
31 + </tr>
32 + </thead>
33 + <tbody>
34 + {sorted.map(({ node, snapshot, counts }) => {
35 + const inactive = counts.assets === 0;
36 + return (
37 + <tr key={node.slug} className={cn('hover:bg-sunken', inactive && 'text-subtle')}>
38 + <td className={td}>
39 + <Link href={`/markets/${node.slug}`} className={cn('font-medium hover:underline', inactive ? 'text-muted' : 'text-fg')}>
40 + {node.name}
41 + </Link>
42 + {showPhase ? (
43 + <Badge tone={node.phase === 1 ? 'index' : 'neutral'} className="ml-2">
44 + Phase {node.phase}
45 + </Badge>
46 + ) : null}
47 + {node.children.length ? <span className="ml-2 text-[10px] text-subtle">{node.children.length} sub</span> : null}
48 + </td>
49 + <td className={tdNum}>{snapshot?.indexValue != null ? snapshot.indexValue.toFixed(1) : <span className="text-subtle">—</span>}</td>
50 + {(['change1d', 'change7d', 'change30d', 'change1y'] as const).map((k) => (
51 + <td key={k} className={tdNum} style={heatStyle(snapshot?.[k])}>
52 + <Delta value={snapshot?.[k]} />
53 + </td>
54 + ))}
55 + <td className={tdNum}>{fmtNum(counts.assets)}</td>
56 + <td className={tdNum}>{fmtNum(counts.priced)}</td>
57 + <td className={tdNum}>{fmtNum(counts.sales)}</td>
58 + <td className={tdNum}>{fmtNum(counts.sales30d)}</td>
59 + <td className={tdNum}>{fmtMoney(counts.volume30dUsd, 'USD', { compact: true })}</td>
60 + <td className={tdNum}>{fmtNum(counts.listings)}</td>
61 + <td className={tdNum}>{snapshot?.liquidityScore != null ? Math.round(snapshot.liquidityScore) : <span className="text-subtle">—</span>}</td>
62 + <td className={tdNum}>{snapshot?.marketCapEstUsd != null ? fmtMoney(snapshot.marketCapEstUsd, 'USD', { compact: true }) : <span className="text-subtle">—</span>}</td>
63 + <td className={cn(td, 'text-muted')}>{counts.lastSaleAt ? fmtRelative(counts.lastSaleAt) : '—'}</td>
64 + </tr>
65 + );
66 + })}
67 + </tbody>
68 + </Table>
69 + );
70 +}
added apps/web/src/components/market/sales-table.tsx +193 −0
@@ -0,0 +1,193 @@
1 +import type { SaleRow, ListingRow } from '@/lib/queries/assets';
2 +import type { LotRow } from '@/lib/queries/market-lists';
3 +import { fmtDate, fmtMoney, fmtRelative, fmtPct, cn } from '@/lib/format';
4 +import { humanize } from '@/lib/taxonomy';
5 +import { Table, th, td, tdNum, EmptyState, Badge, Delta } from '@/components/ui/primitives';
6 +import { AssetTitleCell, GradeBadge, PriceCell, SourceLink, Thumb } from './bits';
7 +
8 +export function SalesTable({ items, showAsset = true, className, emptyDescription = 'Sales appear once connectors ingest verified transactions.' }: { items: SaleRow[]; showAsset?: boolean; className?: string; emptyDescription?: string }) {
9 + if (!items.length) return <EmptyState title="No sales recorded" description={emptyDescription} />;
10 + return (
11 + <Table className={className}>
12 + <thead>
13 + <tr>
14 + <th className={th}>Date</th>
15 + {showAsset ? <th className={th}>Asset</th> : <th className={th}>Listing title</th>}
16 + <th className={th}>Grade</th>
17 + <th className={cn(th, 'text-right')}>Price</th>
18 + <th className={th}>Type</th>
19 + <th className={th}>Source</th>
20 + <th className={th}>Quality</th>
21 + </tr>
22 + </thead>
23 + <tbody>
24 + {items.map((s) => (
25 + <tr key={s.id} className="hover:bg-sunken">
26 + <td className={cn(td, 'text-muted')} title={s.saleDate.toISOString()}>
27 + {fmtDate(s.saleDate)}
28 + </td>
29 + <td className={cn(td, 'max-w-[360px]')}>
30 + {showAsset && s.assetSlug ? (
31 + <AssetTitleCell slug={s.assetSlug} title={s.assetTitle ?? s.rawTitle} categorySlug={s.categorySlug} image={s.heroImageUrl ?? s.imageUrls[0]} sub={s.rawTitle !== s.assetTitle ? s.rawTitle : null} size={32} />
32 + ) : (
33 + <span className="flex items-center gap-2">
34 + <Thumb src={s.imageUrls[0]} alt="" size={28} />
35 + <span className="truncate text-muted" title={s.rawTitle}>
36 + {s.rawTitle}
37 + </span>
38 + </span>
39 + )}
40 + </td>
41 + <td className={td}>
42 + <GradeBadge grader={s.grader} grade={s.grade} />
43 + {s.condition ? <span className="ml-1 text-[11px] text-muted">{humanize(s.condition)}</span> : null}
44 + </td>
45 + <td className={tdNum}>
46 + <PriceCell usd={s.priceUsd} native={s.price} currency={s.currency} />
47 + </td>
48 + <td className={cn(td, 'text-muted')}>
49 + {humanize(s.saleType)}
50 + {s.auctionHouse ? <span className="block text-[10px] text-subtle">{s.auctionHouse}</span> : null}
51 + </td>
52 + <td className={td}>
53 + <SourceLink name={s.sourceName} url={s.sourceUrl} />
54 + </td>
55 + <td className={td}>
56 + {s.status !== 'valid' ? <Badge tone="alert">{s.status}</Badge> : <span className="num text-[11px] text-muted">{Math.round(s.confidence * 100)}%</span>}
57 + {s.flags.length ? <span className="ml-1 text-[10px] text-subtle" title={s.flags.join(', ')}>⚑</span> : null}
58 + </td>
59 + </tr>
60 + ))}
61 + </tbody>
62 + </Table>
63 + );
64 +}
65 +
66 +export function ListingsTable({ items, showAsset = true, className, emptyDescription = 'Live listings appear once marketplace connectors run.' }: { items: ListingRow[]; showAsset?: boolean; className?: string; emptyDescription?: string }) {
67 + if (!items.length) return <EmptyState title="No active listings" description={emptyDescription} />;
68 + return (
69 + <Table className={className}>
70 + <thead>
71 + <tr>
72 + {showAsset ? <th className={th}>Asset</th> : <th className={th}>Listing</th>}
73 + <th className={th}>Grade</th>
74 + <th className={cn(th, 'text-right')}>Ask</th>
75 + <th className={cn(th, 'text-right')} title="Discount of the asking price to the RareIndex Valuation. Analytical data, not advice.">
76 + vs RIV
77 + </th>
78 + <th className={th}>Type</th>
79 + <th className={th}>Seller · location</th>
80 + <th className={th}>Source</th>
81 + <th className={th}>Seen</th>
82 + </tr>
83 + </thead>
84 + <tbody>
85 + {items.map((l) => (
86 + <tr key={l.id} className="hover:bg-sunken">
87 + <td className={cn(td, 'max-w-[360px]')}>
88 + {showAsset && l.assetSlug ? (
89 + <AssetTitleCell slug={l.assetSlug} title={l.assetTitle ?? l.rawTitle} categorySlug={l.categorySlug} image={l.imageUrls[0]} sub={l.rawTitle !== l.assetTitle ? l.rawTitle : null} size={32} />
90 + ) : (
91 + <span className="flex items-center gap-2">
92 + <Thumb src={l.imageUrls[0]} alt="" size={28} />
93 + <span className="truncate text-muted" title={l.rawTitle}>
94 + {l.rawTitle}
95 + </span>
96 + </span>
97 + )}
98 + </td>
99 + <td className={td}>
100 + <GradeBadge grader={l.grader} grade={l.grade} />
101 + {l.condition ? <span className="ml-1 text-[11px] text-muted">{humanize(l.condition)}</span> : null}
102 + </td>
103 + <td className={tdNum}>
104 + <PriceCell usd={l.priceUsd} native={l.price} currency={l.currency} />
105 + {l.shippingCost ? <span className="block text-[10px] text-subtle">+{fmtMoney(l.shippingCost, l.currency ?? 'USD')} ship</span> : null}
106 + </td>
107 + <td className={tdNum}>
108 + {l.discountToRiv === null ? (
109 + <span className="text-subtle" title="No valuation to compare against">—</span>
110 + ) : (
111 + <span className="inline-flex flex-col items-end leading-tight">
112 + <Delta value={-l.discountToRiv} />
113 + {l.assetRivUsd !== undefined && l.assetRivUsd !== null ? <span className="text-[10px] text-subtle">RIV {fmtMoney(l.assetRivUsd)}</span> : null}
114 + </span>
115 + )}
116 + </td>
117 + <td className={cn(td, 'text-muted')}>
118 + {humanize(l.listingType)}
119 + {l.bidCount !== null ? <span className="block text-[10px] text-subtle">{l.bidCount} bids</span> : null}
120 + {l.endsAt ? <span className="block text-[10px] text-subtle">ends {fmtRelative(l.endsAt)}</span> : null}
121 + </td>
122 + <td className={cn(td, 'text-muted')}>
123 + {l.seller ?? '—'}
124 + {l.location ? <span className="block text-[10px] text-subtle">{l.location}</span> : null}
125 + </td>
126 + <td className={td}>
127 + <SourceLink name={l.sourceName} url={l.sourceUrl} />
128 + </td>
129 + <td className={cn(td, 'text-muted')}>{fmtRelative(l.lastSeenAt)}</td>
130 + </tr>
131 + ))}
132 + </tbody>
133 + </Table>
134 + );
135 +}
136 +
137 +export function LotsTable({ items, className, emptyDescription = 'Auction lots appear once auction-house connectors run.' }: { items: LotRow[]; className?: string; emptyDescription?: string }) {
138 + if (!items.length) return <EmptyState title="No auction lots" description={emptyDescription} />;
139 + return (
140 + <Table className={className}>
141 + <thead>
142 + <tr>
143 + <th className={th}>Lot</th>
144 + <th className={th}>Auction</th>
145 + <th className={cn(th, 'text-right')}>Estimate</th>
146 + <th className={cn(th, 'text-right')}>Bid / hammer</th>
147 + <th className={cn(th, 'text-right')}>RIV</th>
148 + <th className={th}>Ends</th>
149 + <th className={th}>Status</th>
150 + </tr>
151 + </thead>
152 + <tbody>
153 + {items.map((l) => (
154 + <tr key={l.id} className="hover:bg-sunken">
155 + <td className={cn(td, 'max-w-[360px]')}>
156 + <span className="flex items-center gap-2">
157 + <Thumb src={l.imageUrls[0]} alt="" size={32} />
158 + <span className="min-w-0">
159 + <a href={l.url} target="_blank" rel="noopener nofollow" className="block truncate font-medium text-fg hover:underline" title={l.title}>
160 + {l.lotNumber ? `Lot ${l.lotNumber} · ` : ''}
161 + {l.title}
162 + </a>
163 + {l.assetSlug ? (
164 + <a href={`/asset/${l.assetSlug}`} className="block truncate text-[11px] text-muted hover:text-fg">
165 + {l.assetTitle}
166 + </a>
167 + ) : (
168 + <span className="block text-[11px] text-subtle">Not yet matched to a canonical asset</span>
169 + )}
170 + </span>
171 + </span>
172 + </td>
173 + <td className={cn(td, 'text-muted')}>
174 + {l.auctionHouse}
175 + {l.auctionName ? <span className="block max-w-[200px] truncate text-[10px] text-subtle">{l.auctionName}</span> : null}
176 + </td>
177 + <td className={tdNum}>{l.estimateLow !== null || l.estimateHigh !== null ? `${fmtMoney(l.estimateLow, l.currency ?? 'USD')} – ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</td>
178 + <td className={tdNum}>{l.hammerPrice !== null ? <span className="font-medium">{fmtMoney(l.hammerPrice, l.currency ?? 'USD')}</span> : l.currentBid !== null ? fmtMoney(l.currentBid, l.currency ?? 'USD') : '—'}</td>
179 + <td className={tdNum}>{fmtMoney(l.rivUsd)}</td>
180 + <td className={cn(td, 'text-muted')}>{l.endsAt ? `${fmtDate(l.endsAt, { time: true })} (${fmtRelative(l.endsAt)})` : '—'}</td>
181 + <td className={td}>
182 + <Badge tone={l.status === 'live' ? 'gain' : l.status === 'upcoming' ? 'index' : 'neutral'}>{l.status}</Badge>
183 + </td>
184 + </tr>
185 + ))}
186 + </tbody>
187 + </Table>
188 + );
189 +}
190 +
191 +export function pctLabel(v: number | null): string {
192 + return fmtPct(v);
193 +}
added apps/web/src/components/ui/evidence.tsx +35 −0
@@ -0,0 +1,35 @@
1 +import { confidenceLabel, fmtRelative, cn } from '@/lib/format';
2 +import { Badge } from './primitives';
3 +
4 +/** §191: every estimate carries sample size, confidence and freshness. */
5 +export function Evidence({ confidence, sampleSize, updatedAt, sampleLabel = 'sales', className }: { confidence: number | null | undefined; sampleSize: number | null | undefined; updatedAt: Date | string | null | undefined; sampleLabel?: string; className?: string }) {
6 + const label = confidenceLabel(confidence);
7 + const tone = label === 'High' ? 'gain' : label === 'Medium' ? 'index' : label === 'Low' ? 'alert' : 'neutral';
8 + return (
9 + <div className={cn('flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted', className)}>
10 + <Badge tone={tone}>Confidence: {label}</Badge>
11 + {sampleSize !== null && sampleSize !== undefined ? (
12 + <span className="num">
13 + {sampleSize.toLocaleString('en-US')} {sampleLabel} used
14 + </span>
15 + ) : null}
16 + {updatedAt ? <span>Updated {fmtRelative(updatedAt)}</span> : null}
17 + </div>
18 + );
19 +}
20 +
21 +export function ScoreMeter({ value, label, tone = 'index', className }: { value: number | null | undefined; label: string; tone?: 'index' | 'rarity' | 'gain' | 'alert'; className?: string }) {
22 + const fill = tone === 'rarity' ? 'var(--ri-rarity)' : tone === 'gain' ? 'var(--ri-gain)' : tone === 'alert' ? 'var(--ri-alert)' : 'var(--ri-index)';
23 + const track = tone === 'rarity' ? 'var(--ri-rarity-bg)' : tone === 'gain' ? 'var(--ri-gain-bg)' : tone === 'alert' ? 'var(--ri-alert-bg)' : 'var(--ri-index-bg)';
24 + return (
25 + <div className={cn('flex flex-col gap-1', className)}>
26 + <div className="flex items-baseline justify-between text-[11px]">
27 + <span className="font-medium uppercase tracking-wider text-subtle">{label}</span>
28 + <span className="num font-semibold text-fg">{value === null || value === undefined ? '—' : `${Math.round(value)}/100`}</span>
29 + </div>
30 + <div className="h-1.5 w-full overflow-hidden rounded-full" style={{ background: track }} aria-hidden>
31 + {value !== null && value !== undefined ? <div className="h-full rounded-full" style={{ width: `${Math.max(0, Math.min(100, value))}%`, background: fill }} /> : null}
32 + </div>
33 + </div>
34 + );
35 +}
added apps/web/src/components/ui/filter-bar.tsx +82 −0
@@ -0,0 +1,82 @@
1 +'use client';
2 +
3 +import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4 +import { useTransition, type ReactNode } from 'react';
5 +import { cn } from '@/lib/format';
6 +
7 +export interface FilterField {
8 + name: string;
9 + label: string;
10 + type: 'select' | 'number' | 'text';
11 + options?: Array<{ value: string; label: string }>;
12 + placeholder?: string;
13 + width?: string;
14 +}
15 +
16 +/**
17 + * Generic URL-state filter bar. Every change updates the search params (resetting `page`), so
18 + * server components re-render with the new filters and every view is shareable.
19 + */
20 +export function FilterBar({ fields, className, children, resetKeys }: { fields: FilterField[]; className?: string; children?: ReactNode; resetKeys?: string[] }) {
21 + const router = useRouter();
22 + const pathname = usePathname();
23 + const sp = useSearchParams();
24 + const [pending, start] = useTransition();
25 +
26 + const update = (name: string, value: string) => {
27 + const next = new URLSearchParams(sp.toString());
28 + if (value) next.set(name, value);
29 + else next.delete(name);
30 + next.delete('page');
31 + start(() => router.replace(`${pathname}?${next.toString()}`, { scroll: false }));
32 + };
33 + const reset = () => {
34 + const next = new URLSearchParams(sp.toString());
35 + for (const f of fields) next.delete(f.name);
36 + for (const k of resetKeys ?? []) next.delete(k);
37 + next.delete('page');
38 + start(() => router.replace(`${pathname}${next.toString() ? `?${next}` : ''}`, { scroll: false }));
39 + };
40 + const hasAny = fields.some((f) => sp.get(f.name));
41 + const input = 'h-8 rounded-md border border-border bg-elevated px-2 text-[12px] text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none';
42 +
43 + return (
44 + <div className={cn('flex flex-wrap items-end gap-2', pending && 'opacity-70', className)}>
45 + {fields.map((f) => (
46 + <label key={f.name} className="flex flex-col gap-0.5 text-[10px] font-medium uppercase tracking-wider text-subtle">
47 + {f.label}
48 + {f.type === 'select' ? (
49 + <select className={cn(input, f.width ?? 'w-40')} value={sp.get(f.name) ?? ''} onChange={(e) => update(f.name, e.target.value)}>
50 + <option value="">{f.placeholder ?? 'All'}</option>
51 + {f.options?.map((o) => (
52 + <option key={o.value} value={o.value}>
53 + {o.label}
54 + </option>
55 + ))}
56 + </select>
57 + ) : (
58 + <input
59 + type={f.type}
60 + inputMode={f.type === 'number' ? 'decimal' : undefined}
61 + className={cn(input, f.width ?? 'w-28')}
62 + placeholder={f.placeholder}
63 + defaultValue={sp.get(f.name) ?? ''}
64 + onBlur={(e) => {
65 + if ((sp.get(f.name) ?? '') !== e.target.value) update(f.name, e.target.value);
66 + }}
67 + onKeyDown={(e) => {
68 + if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
69 + }}
70 + />
71 + )}
72 + </label>
73 + ))}
74 + {children}
75 + {hasAny ? (
76 + <button type="button" onClick={reset} className="h-8 rounded-md px-2 text-[12px] text-muted hover:text-fg">
77 + Clear
78 + </button>
79 + ) : null}
80 + </div>
81 + );
82 +}
added apps/web/src/components/ui/page-header.tsx +46 −0
@@ -0,0 +1,46 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/format';
4 +
5 +export interface Crumb {
6 + label: string;
7 + href?: string;
8 +}
9 +
10 +export function Breadcrumbs({ items, className }: { items: Crumb[]; className?: string }) {
11 + if (!items.length) return null;
12 + return (
13 + <nav aria-label="Breadcrumb" className={cn('mb-2 text-[11px] text-subtle', className)}>
14 + <ol className="flex flex-wrap items-center gap-1">
15 + {items.map((c, i) => (
16 + <li key={`${c.label}-${i}`} className="flex items-center gap-1">
17 + {i > 0 ? <span aria-hidden>/</span> : null}
18 + {c.href && i < items.length - 1 ? (
19 + <Link href={c.href} className="hover:text-fg">
20 + {c.label}
21 + </Link>
22 + ) : (
23 + <span className={i === items.length - 1 ? 'text-muted' : undefined}>{c.label}</span>
24 + )}
25 + </li>
26 + ))}
27 + </ol>
28 + </nav>
29 + );
30 +}
31 +
32 +export function PageHeader({ title, description, crumbs, actions, meta, className, compact }: { title: ReactNode; description?: ReactNode; crumbs?: Crumb[]; actions?: ReactNode; meta?: ReactNode; className?: string; compact?: boolean }) {
33 + return (
34 + <div className={cn(compact ? 'mb-4' : 'mb-6', className)}>
35 + {crumbs ? <Breadcrumbs items={crumbs} /> : null}
36 + <div className="flex flex-wrap items-end justify-between gap-3">
37 + <div className="min-w-0">
38 + <h1 className={cn('font-semibold tracking-tight text-fg', compact ? 'text-xl' : 'text-2xl sm:text-3xl')}>{title}</h1>
39 + {description ? <p className="mt-1 max-w-3xl text-sm leading-relaxed text-muted">{description}</p> : null}
40 + {meta ? <div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted">{meta}</div> : null}
41 + </div>
42 + {actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
43 + </div>
44 + </div>
45 + );
46 +}
added apps/web/src/components/ui/pagination.tsx +37 −0
@@ -0,0 +1,37 @@
1 +import Link from 'next/link';
2 +import { cn } from '@/lib/format';
3 +
4 +/** URL-driven pagination: keeps every other search param. */
5 +export function Pagination({ page, pageSize, total, basePath, params, className }: { page: number; pageSize: number; total: number; basePath: string; params: Record<string, string | undefined>; className?: string }) {
6 + const pages = Math.max(1, Math.ceil(total / pageSize));
7 + if (pages <= 1) return null;
8 + const href = (p: number) => {
9 + const sp = new URLSearchParams();
10 + for (const [k, v] of Object.entries(params)) if (v) sp.set(k, v);
11 + if (p > 1) sp.set('page', String(p));
12 + else sp.delete('page');
13 + const qs = sp.toString();
14 + return qs ? `${basePath}?${qs}` : basePath;
15 + };
16 + const from = (page - 1) * pageSize + 1;
17 + const to = Math.min(total, page * pageSize);
18 + const btn = 'inline-flex h-8 items-center rounded-md border border-border px-2.5 text-xs font-medium hover:bg-inset aria-disabled:pointer-events-none aria-disabled:opacity-40';
19 + return (
20 + <nav className={cn('mt-4 flex items-center justify-between gap-3 text-xs text-muted', className)} aria-label="Pagination">
21 + <span className="num">
22 + {from.toLocaleString('en-US')}–{to.toLocaleString('en-US')} of {total.toLocaleString('en-US')}
23 + </span>
24 + <div className="flex items-center gap-1.5">
25 + <Link href={href(Math.max(1, page - 1))} aria-disabled={page <= 1} className={btn}>
26 + Previous
27 + </Link>
28 + <span className="num px-2">
29 + {page} / {pages}
30 + </span>
31 + <Link href={href(Math.min(pages, page + 1))} aria-disabled={page >= pages} className={btn}>
32 + Next
33 + </Link>
34 + </div>
35 + </nav>
36 + );
37 +}
added apps/web/src/components/ui/tabs.tsx +49 −0
@@ -0,0 +1,49 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/format';
4 +
5 +export interface TabDef {
6 + id: string;
7 + label: ReactNode;
8 + count?: number | null;
9 + href: string;
10 +}
11 +
12 +/** Server-rendered, URL-driven tabs (no client state; deep-linkable; works without JS). */
13 +export function Tabs({ tabs, active, className }: { tabs: TabDef[]; active: string; className?: string }) {
14 + return (
15 + <div className={cn('scrollbar-none -mx-4 overflow-x-auto border-b border-border px-4 sm:mx-0 sm:px-0', className)} role="tablist">
16 + <div className="flex min-w-max gap-1">
17 + {tabs.map((t) => {
18 + const isActive = t.id === active;
19 + return (
20 + <Link
21 + key={t.id}
22 + href={t.href}
23 + role="tab"
24 + aria-selected={isActive}
25 + scroll={false}
26 + className={cn('-mb-px inline-flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-[13px] font-medium', isActive ? 'border-fg text-fg' : 'border-transparent text-muted hover:text-fg')}
27 + >
28 + {t.label}
29 + {t.count !== undefined && t.count !== null ? <span className="num rounded-sm bg-inset px-1 text-[10px] text-muted">{t.count.toLocaleString('en-US')}</span> : null}
30 + </Link>
31 + );
32 + })}
33 + </div>
34 + </div>
35 + );
36 +}
37 +
38 +/** Pill group for period selectors etc. */
39 +export function Segmented({ options, active, className }: { options: Array<{ id: string; label: string; href: string }>; active: string; className?: string }) {
40 + return (
41 + <div className={cn('inline-flex rounded-md border border-border bg-sunken p-0.5 text-[11px] font-medium', className)}>
42 + {options.map((o) => (
43 + <Link key={o.id} href={o.href} scroll={false} className={cn('rounded-[4px] px-2 py-1', o.id === active ? 'bg-elevated text-fg shadow-card' : 'text-muted hover:text-fg')} aria-current={o.id === active ? 'true' : undefined}>
44 + {o.label}
45 + </Link>
46 + ))}
47 + </div>
48 + );
49 +}
added apps/web/src/lib/queries/_util.ts +57 −0
@@ -0,0 +1,57 @@
1 +import 'server-only';
2 +import { sql, type SQL } from 'drizzle-orm';
3 +import { getDb } from '@rareindex/database';
4 +
5 +/** Run a raw SQL statement and return typed rows (drizzle `execute` returns row arrays for postgres-js). */
6 +export async function rows<T extends Record<string, unknown>>(query: SQL): Promise<T[]> {
7 + const res = await getDb().execute(query);
8 + return res as unknown as T[];
9 +}
10 +
11 +export async function one<T extends Record<string, unknown>>(query: SQL): Promise<T | null> {
12 + const r = await rows<T>(query);
13 + return r[0] ?? null;
14 +}
15 +
16 +export function num(v: unknown): number | null {
17 + if (v === null || v === undefined || v === '') return null;
18 + const n = Number(v);
19 + return Number.isFinite(n) ? n : null;
20 +}
21 +
22 +export function int(v: unknown): number {
23 + const n = Number(v);
24 + return Number.isFinite(n) ? Math.trunc(n) : 0;
25 +}
26 +
27 +export function str(v: unknown): string | null {
28 + return v === null || v === undefined ? null : String(v);
29 +}
30 +
31 +export function date(v: unknown): Date | null {
32 + if (!v) return null;
33 + const d = v instanceof Date ? v : new Date(String(v));
34 + return Number.isNaN(d.getTime()) ? null : d;
35 +}
36 +
37 +export function joinAnd(parts: SQL[]): SQL {
38 + return parts.length ? sql.join(parts, sql` AND `) : sql`true`;
39 +}
40 +
41 +/** Postgres text[] literal from a JS string array (drizzle expands bare arrays into tuples, not arrays). */
42 +export function textArray(values: string[]): SQL {
43 + if (!values.length) return sql`ARRAY[]::text[]`;
44 + return sql`ARRAY[${sql.join(values.map((v) => sql`${v}`), sql`, `)}]::text[]`;
45 +}
46 +
47 +/** ISO string for timestamp params (postgres-js `execute` does not serialise Date instances). */
48 +export function iso(d: Date): string {
49 + return d.toISOString();
50 +}
51 +
52 +export function clampPage(page: unknown, size = 50): { limit: number; offset: number; page: number } {
53 + const p = Math.max(1, Math.trunc(Number(page) || 1));
54 + return { limit: size, offset: (p - 1) * size, page: p };
55 +}
56 +
57 +export { sql };
added apps/web/src/lib/queries/assets.ts +646 −0
@@ -0,0 +1,646 @@
1 +import 'server-only';
2 +import { cache } from 'react';
3 +import type { SQL } from 'drizzle-orm';
4 +import { descendants } from '@rareindex/taxonomy';
5 +import { rows, one, sql, num, int, str, date, joinAnd } from './_util';
6 +
7 +export interface AssetCard {
8 + id: string;
9 + slug: string;
10 + title: string;
11 + name: string;
12 + categorySlug: string;
13 + familySlug: string;
14 + setName: string | null;
15 + setSlug: string | null;
16 + number: string | null;
17 + year: number | null;
18 + variant: string | null;
19 + edition: string | null;
20 + brand: string | null;
21 + heroImageUrl: string | null;
22 + rivUsd: number | null;
23 + rivLowUsd: number | null;
24 + rivHighUsd: number | null;
25 + rivConfidence: number | null;
26 + rivSampleSize: number;
27 + latestSaleUsd: number | null;
28 + latestSaleAt: Date | null;
29 + change1d: number | null;
30 + change7d: number | null;
31 + change30d: number | null;
32 + change1y: number | null;
33 + salesCount: number;
34 + sales30d: number;
35 + activeListings: number;
36 + minAskUsd: number | null;
37 + liquidityScore: number | null;
38 + rarityScore: number | null;
39 + momentum30d: number | null;
40 + trendingScore: number | null;
41 + valueOpportunity: number | null;
42 + dataQuality: number | null;
43 + watchers: number;
44 + observationsCount: number;
45 + updatedAt: Date | null;
46 +}
47 +
48 +export const ASSET_CARD_SELECT = sql`a.id, a.slug, a.title, a.name, a.category_slug, a.family_slug, a.set_name, a.set_slug, a.number, a.year, a.variant, a.edition, a.brand, a.hero_image_url,
49 + s.riv_usd, s.riv_low_usd, s.riv_high_usd, s.riv_confidence, coalesce(s.riv_sample_size, 0) AS riv_sample_size, s.latest_sale_usd, s.latest_sale_at, s.change_1d, s.change_7d, s.change_30d, s.change_1y,
50 + coalesce(s.sales_count, 0) AS sales_count, coalesce(s.sales_30d, 0) AS sales_30d, coalesce(s.active_listings, 0) AS active_listings, s.min_ask_usd, s.liquidity_score, s.rarity_score, s.momentum_30d, s.trending_score, s.value_opportunity, s.data_quality, coalesce(s.watchers, 0) AS watchers, coalesce(s.observations_count, 0) AS observations_count, s.updated_at AS stats_updated_at`;
51 +
52 +export function toAssetCard(x: Record<string, unknown>): AssetCard {
53 + return {
54 + id: String(x.id),
55 + slug: String(x.slug),
56 + title: String(x.title),
57 + name: String(x.name),
58 + categorySlug: String(x.category_slug),
59 + familySlug: String(x.family_slug),
60 + setName: str(x.set_name),
61 + setSlug: str(x.set_slug),
62 + number: str(x.number),
63 + year: num(x.year),
64 + variant: str(x.variant),
65 + edition: str(x.edition),
66 + brand: str(x.brand),
67 + heroImageUrl: str(x.hero_image_url),
68 + rivUsd: num(x.riv_usd),
69 + rivLowUsd: num(x.riv_low_usd),
70 + rivHighUsd: num(x.riv_high_usd),
71 + rivConfidence: num(x.riv_confidence),
72 + rivSampleSize: int(x.riv_sample_size),
73 + latestSaleUsd: num(x.latest_sale_usd),
74 + latestSaleAt: date(x.latest_sale_at),
75 + change1d: num(x.change_1d),
76 + change7d: num(x.change_7d),
77 + change30d: num(x.change_30d),
78 + change1y: num(x.change_1y),
79 + salesCount: int(x.sales_count),
80 + sales30d: int(x.sales_30d),
81 + activeListings: int(x.active_listings),
82 + minAskUsd: num(x.min_ask_usd),
83 + liquidityScore: num(x.liquidity_score),
84 + rarityScore: num(x.rarity_score),
85 + momentum30d: num(x.momentum_30d),
86 + trendingScore: num(x.trending_score),
87 + valueOpportunity: num(x.value_opportunity),
88 + dataQuality: num(x.data_quality),
89 + watchers: int(x.watchers),
90 + observationsCount: int(x.observations_count),
91 + updatedAt: date(x.stats_updated_at),
92 + };
93 +}
94 +
95 +export type ExploreSort = 'riv' | 'change30d' | 'change7d' | 'sales' | 'liquidity' | 'rarity' | 'trending' | 'newest' | 'latest_sale' | 'opportunity';
96 +
97 +export interface ExploreFilters {
98 + category?: string | null;
99 + grader?: string | null;
100 + grade?: string | null;
101 + priceMin?: number | null;
102 + priceMax?: number | null;
103 + liquidityMin?: number | null;
104 + rarityMin?: number | null;
105 + momentumMin?: number | null;
106 + yearFrom?: number | null;
107 + yearTo?: number | null;
108 + brand?: string | null;
109 + set?: string | null;
110 + hasValuation?: boolean;
111 + q?: string | null;
112 + sort?: ExploreSort;
113 + page?: number;
114 + pageSize?: number;
115 +}
116 +
117 +const SORTS: Record<ExploreSort, SQL> = {
118 + riv: sql`s.riv_usd DESC NULLS LAST`,
119 + change30d: sql`s.change_30d DESC NULLS LAST`,
120 + change7d: sql`s.change_7d DESC NULLS LAST`,
121 + sales: sql`s.sales_count DESC NULLS LAST`,
122 + liquidity: sql`s.liquidity_score DESC NULLS LAST`,
123 + rarity: sql`s.rarity_score DESC NULLS LAST`,
124 + trending: sql`s.trending_score DESC NULLS LAST`,
125 + newest: sql`a.created_at DESC`,
126 + latest_sale: sql`s.latest_sale_at DESC NULLS LAST`,
127 + opportunity: sql`s.value_opportunity DESC NULLS LAST`,
128 +};
129 +
130 +export function categoryScope(slug: string): string[] {
131 + return [slug, ...descendants(slug)];
132 +}
133 +
134 +export async function exploreAssets(f: ExploreFilters): Promise<{ items: AssetCard[]; total: number; page: number; pageSize: number }> {
135 + const pageSize = Math.min(Math.max(f.pageSize ?? 48, 1), 200);
136 + const page = Math.max(1, f.page ?? 1);
137 + const where: SQL[] = [];
138 + if (f.category) where.push(sql`a.category_slug IN ${categoryScope(f.category)}`);
139 + if (f.brand) where.push(sql`lower(a.brand) = lower(${f.brand})`);
140 + if (f.set) where.push(sql`a.set_slug = ${f.set}`);
141 + if (f.priceMin != null) where.push(sql`s.riv_usd >= ${f.priceMin}`);
142 + if (f.priceMax != null) where.push(sql`s.riv_usd <= ${f.priceMax}`);
143 + if (f.liquidityMin != null) where.push(sql`s.liquidity_score >= ${f.liquidityMin}`);
144 + if (f.rarityMin != null) where.push(sql`s.rarity_score >= ${f.rarityMin}`);
145 + if (f.momentumMin != null) where.push(sql`s.momentum_30d >= ${f.momentumMin}`);
146 + if (f.yearFrom != null) where.push(sql`a.year >= ${f.yearFrom}`);
147 + if (f.yearTo != null) where.push(sql`a.year <= ${f.yearTo}`);
148 + if (f.hasValuation) where.push(sql`s.riv_usd IS NOT NULL`);
149 + if (f.grader) where.push(sql`EXISTS (SELECT 1 FROM asset_variants v WHERE v.asset_id = a.id AND v.grader = ${f.grader} ${f.grade ? sql`AND v.grade = ${f.grade}` : sql``})`);
150 + if (f.q && f.q.trim().length >= 2) {
151 + const q = f.q.trim();
152 + where.push(sql`(a.title ILIKE ${'%' + q + '%'} OR a.title % ${q})`);
153 + }
154 + const order = SORTS[f.sort ?? 'riv'];
155 + const r = await rows<Record<string, unknown>>(sql`
156 + SELECT ${ASSET_CARD_SELECT}, count(*) OVER() AS total
157 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id
158 + WHERE ${joinAnd(where)}
159 + ORDER BY ${order}, a.title ASC
160 + LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
161 + `);
162 + return { items: r.map(toAssetCard), total: r.length ? int(r[0]!.total) : 0, page, pageSize };
163 +}
164 +
165 +/** Ranked lists for home/markets widgets. `scope` restricts to category slugs. */
166 +export async function rankedAssets(kind: 'trending' | 'gainers' | 'losers' | 'volume' | 'watched' | 'newest' | 'expensive' | 'opportunity' | 'liquid', opts: { scope?: string[]; limit?: number; window?: '1d' | '7d' | '30d' } = {}): Promise<AssetCard[]> {
167 + const limit = opts.limit ?? 10;
168 + const scope = opts.scope?.length ? sql`AND a.category_slug IN ${opts.scope}` : sql``;
169 + const chg = opts.window === '1d' ? sql`s.change_1d` : opts.window === '30d' ? sql`s.change_30d` : sql`s.change_7d`;
170 + const spec: Record<typeof kind, { where: SQL; order: SQL }> = {
171 + trending: { where: sql`s.trending_score IS NOT NULL`, order: sql`s.trending_score DESC` },
172 + gainers: { where: sql`${chg} IS NOT NULL AND ${chg} > 0 AND s.riv_sample_size >= 3`, order: sql`${chg} DESC` },
173 + losers: { where: sql`${chg} IS NOT NULL AND ${chg} < 0 AND s.riv_sample_size >= 3`, order: sql`${chg} ASC` },
174 + volume: { where: sql`s.volume_30d_usd IS NOT NULL AND s.volume_30d_usd > 0`, order: sql`s.volume_30d_usd DESC` },
175 + watched: { where: sql`s.watchers > 0`, order: sql`s.watchers DESC, s.views_30d DESC` },
176 + newest: { where: sql`true`, order: sql`a.created_at DESC` },
177 + expensive: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.riv_usd DESC` },
178 + opportunity: { where: sql`s.value_opportunity IS NOT NULL AND s.value_opportunity > 0`, order: sql`s.value_opportunity DESC` },
179 + liquid: { where: sql`s.liquidity_score IS NOT NULL`, order: sql`s.liquidity_score DESC` },
180 + };
181 + const s = spec[kind];
182 + const r = await rows<Record<string, unknown>>(sql`
183 + SELECT ${ASSET_CARD_SELECT} FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id
184 + WHERE ${s.where} ${scope} ORDER BY ${s.order} NULLS LAST LIMIT ${limit}
185 + `);
186 + return r.map(toAssetCard);
187 +}
188 +
189 +export interface AssetDetail extends AssetCard {
190 + canonicalKey: string;
191 + subcategorySlug: string | null;
192 + franchise: string | null;
193 + series: string | null;
194 + setCode: string | null;
195 + model: string | null;
196 + reference: string | null;
197 + language: string | null;
198 + region: string | null;
199 + country: string | null;
200 + material: string | null;
201 + size: string | null;
202 + color: string | null;
203 + rarity: string | null;
204 + productionQuantity: number | null;
205 + originalMsrp: number | null;
206 + originalMsrpCurrency: string | null;
207 + releaseDate: string | null;
208 + description: string | null;
209 + identifiers: Record<string, string>;
210 + metadata: Record<string, unknown>;
211 + verified: boolean;
212 + createdAt: Date;
213 + athUsd: number | null;
214 + athAt: Date | null;
215 + atlUsd: number | null;
216 + atlAt: Date | null;
217 + sales1y: number;
218 + volume30dUsd: number | null;
219 + sourcesCount: number;
220 + momentum7d: number | null;
221 + momentum90d: number | null;
222 + momentum1y: number | null;
223 + change90d: number | null;
224 + rivVariantId: string | null;
225 +}
226 +
227 +export const getAssetBySlug = cache(async (slug: string): Promise<AssetDetail | null> => {
228 + const x = await one<Record<string, unknown>>(sql`
229 + SELECT ${ASSET_CARD_SELECT}, a.canonical_key, a.subcategory_slug, a.franchise, a.series, a.set_code, a.model, a.reference, a.language, a.region, a.country, a.material, a.size, a.color, a.rarity, a.production_quantity, a.original_msrp, a.original_msrp_currency, a.release_date, a.description, a.identifiers, a.metadata, a.verified, a.created_at,
230 + s.ath_usd, s.ath_at, s.atl_usd, s.atl_at, coalesce(s.sales_1y, 0) AS sales_1y, s.volume_30d_usd, coalesce(s.sources_count, 0) AS sources_count, s.momentum_7d, s.momentum_90d, s.momentum_1y, s.change_90d, s.riv_variant_id
231 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id WHERE a.slug = ${slug} LIMIT 1
232 + `);
233 + if (!x) return null;
234 + return {
235 + ...toAssetCard(x),
236 + canonicalKey: String(x.canonical_key),
237 + subcategorySlug: str(x.subcategory_slug),
238 + franchise: str(x.franchise),
239 + series: str(x.series),
240 + setCode: str(x.set_code),
241 + model: str(x.model),
242 + reference: str(x.reference),
243 + language: str(x.language),
244 + region: str(x.region),
245 + country: str(x.country),
246 + material: str(x.material),
247 + size: str(x.size),
248 + color: str(x.color),
249 + rarity: str(x.rarity),
250 + productionQuantity: num(x.production_quantity),
251 + originalMsrp: num(x.original_msrp),
252 + originalMsrpCurrency: str(x.original_msrp_currency),
253 + releaseDate: str(x.release_date),
254 + description: str(x.description),
255 + identifiers: (x.identifiers as Record<string, string>) ?? {},
256 + metadata: (x.metadata as Record<string, unknown>) ?? {},
257 + verified: Boolean(x.verified),
258 + createdAt: date(x.created_at)!,
259 + athUsd: num(x.ath_usd),
260 + athAt: date(x.ath_at),
261 + atlUsd: num(x.atl_usd),
262 + atlAt: date(x.atl_at),
263 + sales1y: int(x.sales_1y),
264 + volume30dUsd: num(x.volume_30d_usd),
265 + sourcesCount: int(x.sources_count),
266 + momentum7d: num(x.momentum_7d),
267 + momentum90d: num(x.momentum_90d),
268 + momentum1y: num(x.momentum_1y),
269 + change90d: num(x.change_90d),
270 + rivVariantId: str(x.riv_variant_id),
271 + };
272 +});
273 +
274 +export interface VariantRow {
275 + id: string;
276 + variantKey: string;
277 + label: string;
278 + grader: string | null;
279 + grade: string | null;
280 + qualifier: string | null;
281 + condition: string | null;
282 + completeness: string | null;
283 + isDefault: boolean;
284 + rivUsd: number | null;
285 + rivLowUsd: number | null;
286 + rivHighUsd: number | null;
287 + rivConfidence: number | null;
288 + rivSampleSize: number;
289 + latestSaleUsd: number | null;
290 + latestSaleAt: Date | null;
291 + change30d: number | null;
292 + change1y: number | null;
293 + salesCount: number;
294 + sales30d: number;
295 + activeListings: number;
296 + minAskUsd: number | null;
297 + liquidityScore: number | null;
298 +}
299 +
300 +export const getAssetVariants = cache(async (assetId: string): Promise<VariantRow[]> => {
301 + const r = await rows<Record<string, unknown>>(sql`
302 + SELECT v.id, v.variant_key, v.label, v.grader, v.grade, v.qualifier, v.condition, v.completeness, v.is_default,
303 + vs.riv_usd, vs.riv_low_usd, vs.riv_high_usd, vs.riv_confidence, coalesce(vs.riv_sample_size, 0) AS riv_sample_size, vs.latest_sale_usd, vs.latest_sale_at, vs.change_30d, vs.change_1y,
304 + coalesce(vs.sales_count, 0) AS sales_count, coalesce(vs.sales_30d, 0) AS sales_30d, coalesce(vs.active_listings, 0) AS active_listings, vs.min_ask_usd, vs.liquidity_score
305 + FROM asset_variants v LEFT JOIN variant_stats vs ON vs.variant_id = v.id WHERE v.asset_id = ${assetId}
306 + ORDER BY vs.sales_count DESC NULLS LAST, v.is_default DESC, v.label
307 + `);
308 + return r.map((x) => ({ id: String(x.id), variantKey: String(x.variant_key), label: String(x.label), grader: str(x.grader), grade: str(x.grade), qualifier: str(x.qualifier), condition: str(x.condition), completeness: str(x.completeness), isDefault: Boolean(x.is_default), rivUsd: num(x.riv_usd), rivLowUsd: num(x.riv_low_usd), rivHighUsd: num(x.riv_high_usd), rivConfidence: num(x.riv_confidence), rivSampleSize: int(x.riv_sample_size), latestSaleUsd: num(x.latest_sale_usd), latestSaleAt: date(x.latest_sale_at), change30d: num(x.change_30d), change1y: num(x.change_1y), salesCount: int(x.sales_count), sales30d: int(x.sales_30d), activeListings: int(x.active_listings), minAskUsd: num(x.min_ask_usd), liquidityScore: num(x.liquidity_score) }));
309 +});
310 +
311 +export interface SaleRow {
312 + id: string;
313 + saleDate: Date;
314 + price: number;
315 + currency: string;
316 + priceUsd: number;
317 + saleType: string;
318 + grader: string | null;
319 + grade: string | null;
320 + condition: string | null;
321 + certificationNumber: string | null;
322 + sourceId: string;
323 + sourceName: string;
324 + sourceUrl: string;
325 + auctionHouse: string | null;
326 + location: string | null;
327 + rawTitle: string;
328 + imageUrls: string[];
329 + confidence: number;
330 + status: string;
331 + flags: string[];
332 + variantId: string | null;
333 + buyerPremiumIncluded: boolean | null;
334 + assetSlug?: string;
335 + assetTitle?: string;
336 + categorySlug?: string;
337 + heroImageUrl?: string | null;
338 +}
339 +
340 +export function toSaleRow(x: Record<string, unknown>): SaleRow {
341 + return {
342 + id: String(x.id),
343 + saleDate: date(x.sale_date)!,
344 + price: Number(x.price),
345 + currency: String(x.currency),
346 + priceUsd: Number(x.price_usd),
347 + saleType: String(x.sale_type),
348 + grader: str(x.grader),
349 + grade: str(x.grade),
350 + condition: str(x.condition),
351 + certificationNumber: str(x.certification_number),
352 + sourceId: String(x.source_id),
353 + sourceName: String(x.source_name ?? x.source_id),
354 + sourceUrl: String(x.source_url),
355 + auctionHouse: str(x.auction_house),
356 + location: str(x.location),
357 + rawTitle: String(x.raw_title),
358 + imageUrls: (x.image_urls as string[]) ?? [],
359 + confidence: Number(x.confidence ?? 0),
360 + status: String(x.status),
361 + flags: (x.flags as string[]) ?? [],
362 + variantId: str(x.variant_id),
363 + buyerPremiumIncluded: x.buyer_premium_included === null || x.buyer_premium_included === undefined ? null : Boolean(x.buyer_premium_included),
364 + assetSlug: x.asset_slug ? String(x.asset_slug) : undefined,
365 + assetTitle: x.asset_title ? String(x.asset_title) : undefined,
366 + categorySlug: x.category_slug ? String(x.category_slug) : undefined,
367 + heroImageUrl: x.hero_image_url === undefined ? undefined : str(x.hero_image_url),
368 + };
369 +}
370 +
371 +export const SALE_SELECT = sql`s.id, s.sale_date, s.price, s.currency, s.price_usd, s.sale_type, s.grader, s.grade, s.condition, s.certification_number, s.source_id, src.name AS source_name, s.source_url, s.auction_house, s.location, s.raw_title, s.image_urls, s.confidence, s.status, s.flags, s.variant_id, s.buyer_premium_included`;
372 +
373 +export async function getAssetSales(assetId: string, opts: { variantId?: string | null; limit?: number; offset?: number; includeFlagged?: boolean } = {}): Promise<{ items: SaleRow[]; total: number }> {
374 + const limit = opts.limit ?? 50;
375 + const r = await rows<Record<string, unknown>>(sql`
376 + SELECT ${SALE_SELECT}, count(*) OVER() AS total FROM sales s JOIN sources src ON src.id = s.source_id
377 + WHERE s.asset_id = ${assetId} ${opts.variantId ? sql`AND s.variant_id = ${opts.variantId}` : sql``} ${opts.includeFlagged ? sql`AND s.status <> 'excluded'` : sql`AND s.status = 'valid'`}
378 + ORDER BY s.sale_date DESC LIMIT ${limit} OFFSET ${opts.offset ?? 0}
379 + `);
380 + return { items: r.map(toSaleRow), total: r.length ? int(r[0]!.total) : 0 };
381 +}
382 +
383 +/** All valid sales (date, usd, variant) for the scatter chart, capped. */
384 +export const getAssetSalePoints = cache(async (assetId: string, limit = 2000): Promise<Array<{ date: string; usd: number; variantId: string | null; grader: string | null; grade: string | null; sourceName: string }>> => {
385 + const r = await rows<Record<string, unknown>>(sql`
386 + SELECT s.sale_date, s.price_usd, s.variant_id, s.grader, s.grade, src.name AS source_name FROM sales s JOIN sources src ON src.id = s.source_id
387 + WHERE s.asset_id = ${assetId} AND s.status = 'valid' ORDER BY s.sale_date DESC LIMIT ${limit}
388 + `);
389 + return r.map((x) => ({ date: date(x.sale_date)!.toISOString().slice(0, 10), usd: Number(x.price_usd), variantId: str(x.variant_id), grader: str(x.grader), grade: str(x.grade), sourceName: String(x.source_name) })).reverse();
390 +});
391 +
392 +export interface ListingRow {
393 + id: string;
394 + price: number | null;
395 + currency: string | null;
396 + priceUsd: number | null;
397 + listingType: string;
398 + seller: string | null;
399 + location: string | null;
400 + shippingCost: number | null;
401 + condition: string | null;
402 + grader: string | null;
403 + grade: string | null;
404 + sourceId: string;
405 + sourceName: string;
406 + sourceUrl: string;
407 + rawTitle: string;
408 + imageUrls: string[];
409 + listedAt: Date | null;
410 + endsAt: Date | null;
411 + availability: string;
412 + bidCount: number | null;
413 + firstSeenAt: Date;
414 + lastSeenAt: Date;
415 + discountToRiv: number | null;
416 + confidence: number;
417 + flags: string[];
418 + assetSlug?: string;
419 + assetTitle?: string;
420 + categorySlug?: string;
421 + assetRivUsd?: number | null;
422 + assetRivConfidence?: number | null;
423 +}
424 +
425 +export const LISTING_SELECT = sql`l.id, l.price, l.currency, l.price_usd, l.listing_type, l.seller, l.location, l.shipping_cost, l.condition, l.grader, l.grade, l.source_id, src.name AS source_name, l.source_url, l.raw_title, l.image_urls, l.listed_at, l.ends_at, l.availability, l.bid_count, l.first_seen_at, l.last_seen_at, l.discount_to_riv, l.confidence, l.flags`;
426 +
427 +export function toListingRow(x: Record<string, unknown>): ListingRow {
428 + return {
429 + id: String(x.id),
430 + price: num(x.price),
431 + currency: str(x.currency),
432 + priceUsd: num(x.price_usd),
433 + listingType: String(x.listing_type),
434 + seller: str(x.seller),
435 + location: str(x.location),
436 + shippingCost: num(x.shipping_cost),
437 + condition: str(x.condition),
438 + grader: str(x.grader),
439 + grade: str(x.grade),
440 + sourceId: String(x.source_id),
441 + sourceName: String(x.source_name ?? x.source_id),
442 + sourceUrl: String(x.source_url),
443 + rawTitle: String(x.raw_title),
444 + imageUrls: (x.image_urls as string[]) ?? [],
445 + listedAt: date(x.listed_at),
446 + endsAt: date(x.ends_at),
447 + availability: String(x.availability),
448 + bidCount: num(x.bid_count),
449 + firstSeenAt: date(x.first_seen_at)!,
450 + lastSeenAt: date(x.last_seen_at)!,
451 + discountToRiv: num(x.discount_to_riv),
452 + confidence: Number(x.confidence ?? 0),
453 + flags: (x.flags as string[]) ?? [],
454 + assetSlug: x.asset_slug ? String(x.asset_slug) : undefined,
455 + assetTitle: x.asset_title ? String(x.asset_title) : undefined,
456 + categorySlug: x.category_slug ? String(x.category_slug) : undefined,
457 + assetRivUsd: x.asset_riv_usd === undefined ? undefined : num(x.asset_riv_usd),
458 + assetRivConfidence: x.asset_riv_confidence === undefined ? undefined : num(x.asset_riv_confidence),
459 + };
460 +}
461 +
462 +export async function getAssetListings(assetId: string, opts: { variantId?: string | null; availability?: 'available' | 'all'; limit?: number } = {}): Promise<ListingRow[]> {
463 + const r = await rows<Record<string, unknown>>(sql`
464 + SELECT ${LISTING_SELECT} FROM listings l JOIN sources src ON src.id = l.source_id
465 + WHERE l.asset_id = ${assetId} ${opts.variantId ? sql`AND l.variant_id = ${opts.variantId}` : sql``} ${opts.availability === 'all' ? sql`` : sql`AND l.availability = 'available'`}
466 + ORDER BY l.price_usd ASC NULLS LAST LIMIT ${opts.limit ?? 50}
467 + `);
468 + return r.map(toListingRow);
469 +}
470 +
471 +export interface ObservationRow {
472 + id: string;
473 + priceKind: string;
474 + price: number;
475 + currency: string;
476 + priceUsd: number;
477 + observationDate: string;
478 + sampleSize: number | null;
479 + sourceId: string;
480 + sourceName: string;
481 + sourceUrl: string;
482 + variantId: string | null;
483 +}
484 +
485 +export const getAssetObservations = cache(async (assetId: string, limit = 200): Promise<ObservationRow[]> => {
486 + const r = await rows<Record<string, unknown>>(sql`
487 + SELECT o.id, o.price_kind, o.price, o.currency, o.price_usd, o.observation_date, o.sample_size, o.source_id, src.name AS source_name, o.source_url, o.variant_id
488 + FROM price_observations o JOIN sources src ON src.id = o.source_id WHERE o.asset_id = ${assetId}
489 + ORDER BY o.observation_date DESC, o.source_id, o.price_kind LIMIT ${limit}
490 + `);
491 + return r.map((x) => ({ id: String(x.id), priceKind: String(x.price_kind), price: Number(x.price), currency: String(x.currency), priceUsd: Number(x.price_usd), observationDate: String(x.observation_date), sampleSize: num(x.sample_size), sourceId: String(x.source_id), sourceName: String(x.source_name), sourceUrl: String(x.source_url), variantId: str(x.variant_id) }));
492 +});
493 +
494 +export interface SnapshotPoint {
495 + date: string;
496 + rivUsd: number | null;
497 + latestSaleUsd: number | null;
498 + medianUsd: number | null;
499 + salesCount: number;
500 + volumeUsd: number | null;
501 + listingsCount: number;
502 + minAskUsd: number | null;
503 + observationUsd: number | null;
504 +}
505 +
506 +export const getAssetSnapshots = cache(async (assetId: string, variantId = '', days: number | null = null): Promise<SnapshotPoint[]> => {
507 + const r = await rows<Record<string, unknown>>(sql`
508 + SELECT * FROM price_snapshots WHERE asset_id = ${assetId} AND variant_id = ${variantId} ${days ? sql`AND date >= current_date - ${days}::int` : sql``} ORDER BY date
509 + `);
510 + return r.map((x) => ({ date: String(x.date), rivUsd: num(x.riv_usd), latestSaleUsd: num(x.latest_sale_usd), medianUsd: num(x.median_usd), salesCount: int(x.sales_count), volumeUsd: num(x.volume_usd), listingsCount: int(x.listings_count), minAskUsd: num(x.min_ask_usd), observationUsd: num(x.observation_usd) }));
511 +});
512 +
513 +export interface ValuationRow {
514 + id: string;
515 + variantId: string | null;
516 + computedAt: Date;
517 + rivUsd: number | null;
518 + lowUsd: number | null;
519 + highUsd: number | null;
520 + confidence: number;
521 + confidenceLabel: string;
522 + sampleSize: number;
523 + windowDays: number;
524 + methods: Record<string, number | null>;
525 + observationsUsed: number;
526 + method: string;
527 + notes: string[];
528 +}
529 +
530 +export const getLatestValuation = cache(async (assetId: string, variantId: string | null = null): Promise<ValuationRow | null> => {
531 + const x = await one<Record<string, unknown>>(sql`
532 + SELECT * FROM valuations WHERE asset_id = ${assetId} ${variantId ? sql`AND variant_id = ${variantId}` : sql`AND variant_id IS NULL`} ORDER BY computed_at DESC LIMIT 1
533 + `);
534 + if (!x) return null;
535 + return { id: String(x.id), variantId: str(x.variant_id), computedAt: date(x.computed_at)!, rivUsd: num(x.riv_usd), lowUsd: num(x.low_usd), highUsd: num(x.high_usd), confidence: Number(x.confidence ?? 0), confidenceLabel: String(x.confidence_label), sampleSize: int(x.sample_size), windowDays: int(x.window_days), methods: (x.methods as Record<string, number | null>) ?? {}, observationsUsed: int(x.observations_used), method: String(x.method), notes: (x.notes as string[]) ?? [] };
536 +});
537 +
538 +export interface SourceBreakdown {
539 + sourceId: string;
540 + name: string;
541 + sourceType: string;
542 + url: string | null;
543 + trustScore: number;
544 + sales: number;
545 + listings: number;
546 + observations: number;
547 + lastSeen: Date | null;
548 +}
549 +
550 +export const getAssetSources = cache(async (assetId: string): Promise<SourceBreakdown[]> => {
551 + const r = await rows<Record<string, unknown>>(sql`
552 + WITH u AS (
553 + SELECT source_id, 'sale' AS k, created_at AS seen FROM sales WHERE asset_id = ${assetId}
554 + UNION ALL SELECT source_id, 'listing', last_seen_at FROM listings WHERE asset_id = ${assetId}
555 + UNION ALL SELECT source_id, 'observation', created_at FROM price_observations WHERE asset_id = ${assetId}
556 + )
557 + SELECT src.id AS source_id, src.name, src.source_type, src.url, src.trust_score,
558 + count(*) FILTER (WHERE k = 'sale') AS sales, count(*) FILTER (WHERE k = 'listing') AS listings, count(*) FILTER (WHERE k = 'observation') AS observations, max(seen) AS last_seen
559 + FROM u JOIN sources src ON src.id = u.source_id GROUP BY src.id, src.name, src.source_type, src.url, src.trust_score ORDER BY sales DESC, observations DESC
560 + `);
561 + return r.map((x) => ({ sourceId: String(x.source_id), name: String(x.name), sourceType: String(x.source_type), url: str(x.url), trustScore: Number(x.trust_score ?? 0), sales: int(x.sales), listings: int(x.listings), observations: int(x.observations), lastSeen: date(x.last_seen) }));
562 +});
563 +
564 +export const getGradeDistribution = cache(async (assetId: string): Promise<Array<{ grader: string; grade: string; sales: number; medianUsd: number | null; lastSaleUsd: number | null; lastSaleAt: Date | null; minUsd: number | null; maxUsd: number | null }>> => {
565 + const r = await rows<Record<string, unknown>>(sql`
566 + SELECT coalesce(grader, 'raw') AS grader, coalesce(grade, '') AS grade, count(*) AS sales,
567 + percentile_cont(0.5) WITHIN GROUP (ORDER BY price_usd) AS median_usd, min(price_usd) AS min_usd, max(price_usd) AS max_usd,
568 + (array_agg(price_usd ORDER BY sale_date DESC))[1] AS last_usd, max(sale_date) AS last_at
569 + FROM sales WHERE asset_id = ${assetId} AND status = 'valid' GROUP BY 1, 2 ORDER BY grader, grade DESC
570 + `);
571 + return r.map((x) => ({ grader: String(x.grader), grade: String(x.grade), sales: int(x.sales), medianUsd: num(x.median_usd), lastSaleUsd: num(x.last_usd), lastSaleAt: date(x.last_at), minUsd: num(x.min_usd), maxUsd: num(x.max_usd) }));
572 +});
573 +
574 +export const getMarketplaceDistribution = cache(async (assetId: string): Promise<Array<{ sourceName: string; sales: number; volumeUsd: number }>> => {
575 + const r = await rows<Record<string, unknown>>(sql`
576 + SELECT src.name AS source_name, count(*) AS sales, sum(s.price_usd) AS volume FROM sales s JOIN sources src ON src.id = s.source_id WHERE s.asset_id = ${assetId} AND s.status = 'valid' GROUP BY src.name ORDER BY sales DESC
577 + `);
578 + return r.map((x) => ({ sourceName: String(x.source_name), sales: int(x.sales), volumeUsd: Number(x.volume ?? 0) }));
579 +});
580 +
581 +export const getPopulation = cache(async (assetId: string): Promise<Array<{ grader: string; reportDate: string; total: number; byGrade: Record<string, number>; sourceUrl: string | null }>> => {
582 + const r = await rows<Record<string, unknown>>(sql`SELECT grader, report_date, total, by_grade, source_url FROM population_reports WHERE asset_id = ${assetId} ORDER BY grader, report_date`);
583 + return r.map((x) => ({ grader: String(x.grader), reportDate: String(x.report_date), total: int(x.total), byGrade: (x.by_grade as Record<string, number>) ?? {}, sourceUrl: str(x.source_url) }));
584 +});
585 +
586 +export const getAssetImages = cache(async (assetId: string, limit = 24): Promise<Array<{ id: string; url: string; role: string; attribution: string | null; sourceId: string | null }>> => {
587 + const r = await rows<Record<string, unknown>>(sql`SELECT id, url, role, attribution, source_id FROM images WHERE asset_id = ${assetId} ORDER BY (role = 'hero') DESC, created_at LIMIT ${limit}`);
588 + return r.map((x) => ({ id: String(x.id), url: String(x.url), role: String(x.role), attribution: str(x.attribution), sourceId: str(x.source_id) }));
589 +});
590 +
591 +/** Comparable assets: same set (or category) with closest RIV; similarity = crude score on shared attributes (§134). */
592 +export const getComparables = cache(async (asset: { id: string; categorySlug: string; setSlug: string | null; rivUsd: number | null; brand: string | null; year: number | null }, limit = 8): Promise<Array<AssetCard & { similarity: number }>> => {
593 + const r = await rows<Record<string, unknown>>(sql`
594 + SELECT ${ASSET_CARD_SELECT},
595 + ((CASE WHEN a.set_slug IS NOT NULL AND a.set_slug = ${asset.setSlug ?? ''} THEN 0.4 ELSE 0 END)
596 + + (CASE WHEN a.category_slug = ${asset.categorySlug} THEN 0.25 ELSE 0.1 END)
597 + + (CASE WHEN a.brand IS NOT NULL AND lower(a.brand) = lower(${asset.brand ?? ''}) THEN 0.1 ELSE 0 END)
598 + + (CASE WHEN a.year = ${asset.year ?? -1} THEN 0.1 ELSE 0 END)
599 + + (CASE WHEN s.riv_usd IS NOT NULL AND ${asset.rivUsd ?? 0} > 0 THEN greatest(0, 0.15 - abs(ln(s.riv_usd / ${asset.rivUsd ?? 1})) * 0.1) ELSE 0 END)) AS similarity
600 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id
601 + WHERE a.id <> ${asset.id} AND (a.category_slug = ${asset.categorySlug} OR a.set_slug = ${asset.setSlug ?? ''})
602 + ORDER BY similarity DESC, s.sales_count DESC NULLS LAST LIMIT ${limit}
603 + `);
604 + return r.map((x) => ({ ...toAssetCard(x), similarity: Number(x.similarity ?? 0) }));
605 +});
606 +
607 +export const getAssetSlugsForSitemap = cache(async (offset: number, limit: number): Promise<Array<{ slug: string; updatedAt: Date }>> => {
608 + const r = await rows<Record<string, unknown>>(sql`SELECT slug, updated_at FROM assets ORDER BY id LIMIT ${limit} OFFSET ${offset}`);
609 + return r.map((x) => ({ slug: String(x.slug), updatedAt: date(x.updated_at) ?? new Date() }));
610 +});
611 +
612 +/** Live counts straight from canonical tables — correct even before the valuation worker fills asset_stats. */
613 +export const getAssetLiveCounts = cache(async (assetId: string): Promise<{ sales: number; sales30d: number; listings: number; sources: number; latestSaleUsd: number | null; latestSaleAt: Date | null; minAskUsd: number | null }> => {
614 + const x = await one<Record<string, unknown>>(sql`
615 + SELECT
616 + (SELECT count(*) FROM sales s WHERE s.asset_id = ${assetId} AND s.status = 'valid') AS sales,
617 + (SELECT count(*) FROM sales s WHERE s.asset_id = ${assetId} AND s.status = 'valid' AND s.sale_date >= now() - interval '30 days') AS sales_30d,
618 + (SELECT count(*) FROM listings l WHERE l.asset_id = ${assetId} AND l.availability = 'available') AS listings,
619 + (SELECT count(DISTINCT source_id) FROM (SELECT source_id FROM sales WHERE asset_id = ${assetId} UNION SELECT source_id FROM listings WHERE asset_id = ${assetId} UNION SELECT source_id FROM price_observations WHERE asset_id = ${assetId}) u) AS sources,
620 + (SELECT price_usd FROM sales s WHERE s.asset_id = ${assetId} AND s.status = 'valid' ORDER BY s.sale_date DESC LIMIT 1) AS latest_sale_usd,
621 + (SELECT sale_date FROM sales s WHERE s.asset_id = ${assetId} AND s.status = 'valid' ORDER BY s.sale_date DESC LIMIT 1) AS latest_sale_at,
622 + (SELECT min(price_usd) FROM listings l WHERE l.asset_id = ${assetId} AND l.availability = 'available') AS min_ask_usd
623 + `);
624 + return { sales: int(x?.sales), sales30d: int(x?.sales_30d), listings: int(x?.listings), sources: int(x?.sources), latestSaleUsd: num(x?.latest_sale_usd), latestSaleAt: date(x?.latest_sale_at), minAskUsd: num(x?.min_ask_usd) };
625 +});
626 +
627 +export const countAssets = cache(async (): Promise<number> => int((await one<Record<string, unknown>>(sql`SELECT count(*) AS n FROM assets`))?.n));
628 +
629 +export const getAssetsBySlugs = cache(async (slugs: string[]): Promise<AssetCard[]> => {
630 + if (!slugs.length) return [];
631 + const r = await rows<Record<string, unknown>>(sql`SELECT ${ASSET_CARD_SELECT} FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id WHERE a.slug IN ${slugs}`);
632 + const map = new Map(r.map((x) => [String(x.slug), toAssetCard(x)]));
633 + return slugs.map((s) => map.get(s)).filter((x): x is AssetCard => Boolean(x));
634 +});
635 +
636 +export const getPriceDistribution = cache(async (assetId: string, variantId: string | null = null, days = 365): Promise<{ n: number; min: number; p25: number; median: number; p75: number; max: number; trimmedMean: number } | null> => {
637 + const x = await one<Record<string, unknown>>(sql`
638 + WITH v AS (SELECT price_usd FROM sales WHERE asset_id = ${assetId} AND status = 'valid' AND sale_date >= now() - (${days}::int || ' days')::interval ${variantId ? sql`AND variant_id = ${variantId}` : sql``}),
639 + b AS (SELECT percentile_cont(0.1) WITHIN GROUP (ORDER BY price_usd) AS lo, percentile_cont(0.9) WITHIN GROUP (ORDER BY price_usd) AS hi FROM v)
640 + SELECT count(*) AS n, min(price_usd) AS min, percentile_cont(0.25) WITHIN GROUP (ORDER BY price_usd) AS p25, percentile_cont(0.5) WITHIN GROUP (ORDER BY price_usd) AS median, percentile_cont(0.75) WITHIN GROUP (ORDER BY price_usd) AS p75, max(price_usd) AS max,
641 + (SELECT avg(price_usd) FROM v, b WHERE price_usd BETWEEN b.lo AND b.hi) AS trimmed
642 + FROM v
643 + `);
644 + if (!x || int(x.n) === 0) return null;
645 + return { n: int(x.n), min: Number(x.min), p25: Number(x.p25), median: Number(x.median), p75: Number(x.p75), max: Number(x.max), trimmedMean: Number(x.trimmed ?? x.median) };
646 +});
added apps/web/src/lib/queries/indices.ts +253 −0
@@ -0,0 +1,253 @@
1 +import 'server-only';
2 +import { cache } from 'react';
3 +import { maxDrawdown, volatility, pctChange } from '@rareindex/shared';
4 +import { rows, one, sql, num, int, str } from './_util';
5 +
6 +export interface IndexRow {
7 + id: string;
8 + ticker: string;
9 + name: string;
10 + description: string | null;
11 + color: string | null;
12 + isFlagship: boolean;
13 + parentTicker: string | null;
14 + familySlugs: string[];
15 + categorySlugs: string[];
16 + minConstituents: number;
17 + baseDate: string;
18 + baseValue: number;
19 + methodology: string;
20 + weighting: string;
21 + latest: IndexPoint | null;
22 + change1d: number | null;
23 + change7d: number | null;
24 + change30d: number | null;
25 + changeYtd: number | null;
26 + change1y: number | null;
27 + /** priced constituents today (from index_constituents active) */
28 + constituents: number;
29 + pricedAssets: number;
30 + firstDate: string | null;
31 + points: number;
32 +}
33 +
34 +export interface IndexPoint {
35 + date: string;
36 + value: number;
37 + constituentsCount: number;
38 + transactions: number;
39 + volumeUsd: number | null;
40 + medianSaleUsd: number | null;
41 + avgSaleUsd: number | null;
42 + marketCapEstUsd: number | null;
43 + marketCapConfidence: string | null;
44 + liquidityScore: number | null;
45 + momentum: number | null;
46 + breadth: number | null;
47 + trackedAssets: number | null;
48 + coverage: number | null;
49 +}
50 +
51 +function toPoint(x: Record<string, unknown>): IndexPoint {
52 + return {
53 + date: String(x.date),
54 + value: Number(x.value),
55 + constituentsCount: int(x.constituents_count),
56 + transactions: int(x.transactions),
57 + volumeUsd: num(x.volume_usd),
58 + medianSaleUsd: num(x.median_sale_usd),
59 + avgSaleUsd: num(x.avg_sale_usd),
60 + marketCapEstUsd: num(x.market_cap_est_usd),
61 + marketCapConfidence: str(x.market_cap_confidence),
62 + liquidityScore: num(x.liquidity_score),
63 + momentum: num(x.momentum),
64 + breadth: num(x.breadth),
65 + trackedAssets: num(x.tracked_assets),
66 + coverage: num(x.coverage),
67 + };
68 +}
69 +
70 +/** All indices with latest value and window changes computed from index_values. */
71 +export const listIndices = cache(async (): Promise<IndexRow[]> => {
72 + const r = await rows<Record<string, unknown>>(sql`
73 + WITH latest AS (
74 + SELECT DISTINCT ON (index_id) * FROM index_values ORDER BY index_id, date DESC
75 + ), agg AS (
76 + SELECT index_id, min(date) AS first_date, count(*) AS points FROM index_values GROUP BY index_id
77 + ), cons AS (
78 + SELECT index_id, count(*) AS n FROM index_constituents WHERE removed_at IS NULL GROUP BY index_id
79 + )
80 + SELECT i.*, to_jsonb(l.*) AS latest, g.first_date, coalesce(g.points, 0) AS points, coalesce(c.n, 0) AS constituents,
81 + (SELECT value FROM index_values v WHERE v.index_id = i.id AND v.date <= l.date - 1 ORDER BY v.date DESC LIMIT 1) AS v_1d,
82 + (SELECT value FROM index_values v WHERE v.index_id = i.id AND v.date <= l.date - 7 ORDER BY v.date DESC LIMIT 1) AS v_7d,
83 + (SELECT value FROM index_values v WHERE v.index_id = i.id AND v.date <= l.date - 30 ORDER BY v.date DESC LIMIT 1) AS v_30d,
84 + (SELECT value FROM index_values v WHERE v.index_id = i.id AND v.date <= date_trunc('year', l.date)::date ORDER BY v.date DESC LIMIT 1) AS v_ytd,
85 + (SELECT value FROM index_values v WHERE v.index_id = i.id AND v.date <= l.date - 365 ORDER BY v.date DESC LIMIT 1) AS v_1y,
86 + (SELECT count(*) FROM assets a JOIN asset_stats s ON s.asset_id = a.id WHERE s.riv_usd IS NOT NULL AND (i.is_flagship OR a.family_slug = ANY(i.family_slugs))) AS priced_assets
87 + FROM indices i
88 + LEFT JOIN latest l ON l.index_id = i.id
89 + LEFT JOIN agg g ON g.index_id = i.id
90 + LEFT JOIN cons c ON c.index_id = i.id
91 + WHERE i.active
92 + ORDER BY i.is_flagship DESC, i.ticker
93 + `);
94 + return r.map((x) => {
95 + const latest = x.latest ? toPoint(x.latest as Record<string, unknown>) : null;
96 + return {
97 + id: String(x.id),
98 + ticker: String(x.ticker),
99 + name: String(x.name),
100 + description: str(x.description),
101 + color: str(x.color),
102 + isFlagship: Boolean(x.is_flagship),
103 + parentTicker: str(x.parent_ticker),
104 + familySlugs: (x.family_slugs as string[]) ?? [],
105 + categorySlugs: (x.category_slugs as string[]) ?? [],
106 + minConstituents: int(x.min_constituents),
107 + baseDate: String(x.base_date),
108 + baseValue: Number(x.base_value),
109 + methodology: String(x.methodology),
110 + weighting: String(x.weighting),
111 + latest,
112 + change1d: latest ? pctChange(num(x.v_1d), latest.value) : null,
113 + change7d: latest ? pctChange(num(x.v_7d), latest.value) : null,
114 + change30d: latest ? pctChange(num(x.v_30d), latest.value) : null,
115 + changeYtd: latest ? pctChange(num(x.v_ytd), latest.value) : null,
116 + change1y: latest ? pctChange(num(x.v_1y), latest.value) : null,
117 + constituents: int(x.constituents),
118 + pricedAssets: int(x.priced_assets),
119 + firstDate: str(x.first_date),
120 + points: int(x.points),
121 + };
122 + });
123 +});
124 +
125 +export const getIndex = cache(async (ticker: string): Promise<IndexRow | null> => {
126 + const all = await listIndices();
127 + return all.find((i) => i.ticker.toLowerCase() === ticker.toLowerCase()) ?? null;
128 +});
129 +
130 +export const getIndexSeries = cache(async (indexId: string, days: number | null = null): Promise<IndexPoint[]> => {
131 + const r = await rows<Record<string, unknown>>(sql`
132 + SELECT * FROM index_values WHERE index_id = ${indexId} ${days ? sql`AND date >= current_date - ${days}::int` : sql``} ORDER BY date ASC
133 + `);
134 + return r.map(toPoint);
135 +});
136 +
137 +export interface IndexAnalytics {
138 + returns: Record<'1d' | '7d' | '30d' | 'ytd' | '1y' | '3y' | '5y' | '10y' | 'all', number | null>;
139 + volatility: number | null;
140 + maxDrawdown: number | null;
141 + high: { value: number; date: string } | null;
142 + low: { value: number; date: string } | null;
143 +}
144 +
145 +export function analyzeSeries(points: IndexPoint[]): IndexAnalytics {
146 + const empty: IndexAnalytics = { returns: { '1d': null, '7d': null, '30d': null, ytd: null, '1y': null, '3y': null, '5y': null, '10y': null, all: null }, volatility: null, maxDrawdown: null, high: null, low: null };
147 + if (!points.length) return empty;
148 + const last = points[points.length - 1]!;
149 + const lastDate = new Date(`${last.date}T00:00:00Z`);
150 + const at = (daysBack: number): number | null => {
151 + const target = lastDate.getTime() - daysBack * 86_400_000;
152 + let best: IndexPoint | null = null;
153 + for (const p of points) {
154 + if (new Date(`${p.date}T00:00:00Z`).getTime() <= target) best = p;
155 + else break;
156 + }
157 + return best?.value ?? null;
158 + };
159 + const ytdStart = points.find((p) => p.date >= `${last.date.slice(0, 4)}-01-01`);
160 + const values = points.map((p) => p.value);
161 + let high = points[0]!;
162 + let low = points[0]!;
163 + for (const p of points) {
164 + if (p.value > high.value) high = p;
165 + if (p.value < low.value) low = p;
166 + }
167 + return {
168 + returns: {
169 + '1d': pctChange(at(1), last.value),
170 + '7d': pctChange(at(7), last.value),
171 + '30d': pctChange(at(30), last.value),
172 + ytd: ytdStart && ytdStart !== last ? pctChange(ytdStart.value, last.value) : null,
173 + '1y': pctChange(at(365), last.value),
174 + '3y': pctChange(at(365 * 3), last.value),
175 + '5y': pctChange(at(365 * 5), last.value),
176 + '10y': pctChange(at(365 * 10), last.value),
177 + all: points.length > 1 ? pctChange(points[0]!.value, last.value) : null,
178 + },
179 + volatility: volatility(values),
180 + maxDrawdown: maxDrawdown(values),
181 + high: { value: high.value, date: high.date },
182 + low: { value: low.value, date: low.date },
183 + };
184 +}
185 +
186 +export interface ConstituentRow {
187 + assetId: string;
188 + slug: string;
189 + title: string;
190 + categorySlug: string;
191 + variantLabel: string | null;
192 + weight: number;
193 + addedAt: string;
194 + rivUsd: number | null;
195 + change30d: number | null;
196 + liquidityScore: number | null;
197 + salesCount: number;
198 +}
199 +
200 +export const getIndexConstituents = cache(async (indexId: string, limit = 100): Promise<ConstituentRow[]> => {
201 + const r = await rows<Record<string, unknown>>(sql`
202 + SELECT c.asset_id, a.slug, a.title, a.category_slug, v.label AS variant_label, c.weight, c.added_at, s.riv_usd, s.change_30d, s.liquidity_score, coalesce(s.sales_count, 0) AS sales_count
203 + FROM index_constituents c JOIN assets a ON a.id = c.asset_id
204 + LEFT JOIN asset_variants v ON v.id = c.variant_id LEFT JOIN asset_stats s ON s.asset_id = a.id
205 + WHERE c.index_id = ${indexId} AND c.removed_at IS NULL
206 + ORDER BY c.weight DESC, s.riv_usd DESC NULLS LAST LIMIT ${limit}
207 + `);
208 + return r.map((x) => ({ assetId: String(x.asset_id), slug: String(x.slug), title: String(x.title), categorySlug: String(x.category_slug), variantLabel: str(x.variant_label), weight: Number(x.weight), addedAt: String(x.added_at), rivUsd: num(x.riv_usd), change30d: num(x.change_30d), liquidityScore: num(x.liquidity_score), salesCount: int(x.sales_count) }));
209 +});
210 +
211 +export const getCorrelations = cache(async (ticker: string): Promise<Array<{ other: string; coefficient: number; observations: number; windowDays: number }>> => {
212 + const r = await rows<Record<string, unknown>>(sql`
213 + SELECT CASE WHEN a = ${ticker} THEN b ELSE a END AS other, coefficient, observations, window_days FROM correlations WHERE a = ${ticker} OR b = ${ticker} ORDER BY window_days, other
214 + `);
215 + return r.map((x) => ({ other: String(x.other), coefficient: Number(x.coefficient), observations: int(x.observations), windowDays: int(x.window_days) }));
216 +});
217 +
218 +export const getCorrelationMatrix = cache(async (windowDays = 365): Promise<{ tickers: string[]; matrix: Record<string, Record<string, number>> }> => {
219 + const r = await rows<Record<string, unknown>>(sql`SELECT a, b, coefficient FROM correlations WHERE window_days = ${windowDays}`);
220 + const tickers = new Set<string>();
221 + const matrix: Record<string, Record<string, number>> = {};
222 + for (const x of r) {
223 + const a = String(x.a);
224 + const b = String(x.b);
225 + tickers.add(a);
226 + tickers.add(b);
227 + (matrix[a] ??= {})[b] = Number(x.coefficient);
228 + (matrix[b] ??= {})[a] = Number(x.coefficient);
229 + }
230 + return { tickers: [...tickers].sort(), matrix };
231 +});
232 +
233 +export const getBenchmarkSeries = cache(async (ticker: string, days: number | null = null): Promise<Array<{ date: string; value: number; source: string }>> => {
234 + const r = await rows<Record<string, unknown>>(sql`SELECT date, value, source FROM benchmarks WHERE ticker = ${ticker} ${days ? sql`AND date >= current_date - ${days}::int` : sql``} ORDER BY date`);
235 + return r.map((x) => ({ date: String(x.date), value: Number(x.value), source: String(x.source) }));
236 +});
237 +
238 +export const listBenchmarkTickers = cache(async (): Promise<Array<{ ticker: string; source: string; points: number; last: string }>> => {
239 + const r = await rows<Record<string, unknown>>(sql`SELECT ticker, min(source) AS source, count(*) AS points, max(date) AS last FROM benchmarks GROUP BY ticker ORDER BY ticker`);
240 + return r.map((x) => ({ ticker: String(x.ticker), source: String(x.source), points: int(x.points), last: String(x.last) }));
241 +});
242 +
243 +export const getIndexLatestOnly = cache(async (): Promise<Record<string, { value: number; change1d: number | null; date: string }>> => {
244 + const all = await listIndices();
245 + const out: Record<string, { value: number; change1d: number | null; date: string }> = {};
246 + for (const i of all) if (i.latest) out[i.ticker] = { value: i.latest.value, change1d: i.change1d, date: i.latest.date };
247 + return out;
248 +});
249 +
250 +export async function getIndexById(id: string): Promise<IndexRow | null> {
251 + const r = await one<Record<string, unknown>>(sql`SELECT ticker FROM indices WHERE id = ${id}`);
252 + return r ? getIndex(String(r.ticker)) : null;
253 +}
added apps/web/src/lib/queries/market-lists.ts +303 −0
@@ -0,0 +1,303 @@
1 +import 'server-only';
2 +import { cache } from 'react';
3 +import type { SQL } from 'drizzle-orm';
4 +import { rows, one, sql, num, int, str, date, joinAnd, textArray, iso } from './_util';
5 +import { categoryScope, SALE_SELECT, LISTING_SELECT, toSaleRow, toListingRow, type SaleRow, type ListingRow } from './assets';
6 +
7 +// ---------- Sales ----------
8 +export interface SalesFilters {
9 + category?: string | null;
10 + source?: string | null;
11 + grader?: string | null;
12 + minUsd?: number | null;
13 + maxUsd?: number | null;
14 + days?: number | null;
15 + saleType?: string | null;
16 + q?: string | null;
17 + page?: number;
18 + pageSize?: number;
19 + sort?: 'date' | 'price';
20 +}
21 +
22 +export async function listSales(f: SalesFilters): Promise<{ items: SaleRow[]; total: number; page: number; pageSize: number }> {
23 + const pageSize = Math.min(f.pageSize ?? 50, 200);
24 + const page = Math.max(1, f.page ?? 1);
25 + const where: SQL[] = [sql`s.status = 'valid'`];
26 + if (f.category) where.push(sql`a.category_slug IN ${categoryScope(f.category)}`);
27 + if (f.source) where.push(sql`s.source_id = ${f.source}`);
28 + if (f.grader) where.push(sql`s.grader = ${f.grader}`);
29 + if (f.minUsd != null) where.push(sql`s.price_usd >= ${f.minUsd}`);
30 + if (f.maxUsd != null) where.push(sql`s.price_usd <= ${f.maxUsd}`);
31 + if (f.days) where.push(sql`s.sale_date >= now() - (${f.days}::int || ' days')::interval`);
32 + if (f.saleType) where.push(sql`s.sale_type = ${f.saleType}`);
33 + if (f.q && f.q.trim().length >= 2) where.push(sql`(a.title ILIKE ${'%' + f.q.trim() + '%'} OR s.raw_title ILIKE ${'%' + f.q.trim() + '%'})`);
34 + const order = f.sort === 'price' ? sql`s.price_usd DESC` : sql`s.sale_date DESC, s.created_at DESC`;
35 + const r = await rows<Record<string, unknown>>(sql`
36 + SELECT ${SALE_SELECT}, a.slug AS asset_slug, a.title AS asset_title, a.category_slug, a.hero_image_url, count(*) OVER() AS total
37 + FROM sales s JOIN assets a ON a.id = s.asset_id JOIN sources src ON src.id = s.source_id
38 + WHERE ${joinAnd(where)} ORDER BY ${order} LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
39 + `);
40 + return { items: r.map(toSaleRow), total: r.length ? int(r[0]!.total) : 0, page, pageSize };
41 +}
42 +
43 +export const listSaleSources = cache(async (): Promise<Array<{ id: string; name: string; sales: number }>> => {
44 + const r = await rows<Record<string, unknown>>(sql`SELECT src.id, src.name, count(*) AS sales FROM sales s JOIN sources src ON src.id = s.source_id WHERE s.status = 'valid' GROUP BY src.id, src.name ORDER BY sales DESC`);
45 + return r.map((x) => ({ id: String(x.id), name: String(x.name), sales: int(x.sales) }));
46 +});
47 +
48 +/** Record sales (§154): highest verified sale overall and per family/category. */
49 +export const getRecordSales = cache(async (): Promise<Array<SaleRow & { familySlug: string }>> => {
50 + const r = await rows<Record<string, unknown>>(sql`
51 + SELECT DISTINCT ON (a.family_slug) ${SALE_SELECT}, a.slug AS asset_slug, a.title AS asset_title, a.category_slug, a.family_slug, a.hero_image_url
52 + FROM sales s JOIN assets a ON a.id = s.asset_id JOIN sources src ON src.id = s.source_id
53 + WHERE s.status = 'valid' AND s.confidence >= 0.8 ORDER BY a.family_slug, s.price_usd DESC
54 + `);
55 + return r.map((x) => ({ ...toSaleRow(x), familySlug: String(x.family_slug) })).sort((a, b) => b.priceUsd - a.priceUsd);
56 +});
57 +
58 +export const getTopSales = cache(async (limit = 50, scope?: string[]): Promise<SaleRow[]> => {
59 + const r = await rows<Record<string, unknown>>(sql`
60 + SELECT ${SALE_SELECT}, a.slug AS asset_slug, a.title AS asset_title, a.category_slug, a.hero_image_url
61 + FROM sales s JOIN assets a ON a.id = s.asset_id JOIN sources src ON src.id = s.source_id
62 + WHERE s.status = 'valid' AND s.confidence >= 0.8 ${scope?.length ? sql`AND a.category_slug IN ${scope}` : sql``} ORDER BY s.price_usd DESC LIMIT ${limit}
63 + `);
64 + return r.map(toSaleRow);
65 +});
66 +
67 +export const getRecentSalesInScope = cache(async (scope: string[], limit = 20): Promise<SaleRow[]> => {
68 + const r = await rows<Record<string, unknown>>(sql`
69 + SELECT ${SALE_SELECT}, a.slug AS asset_slug, a.title AS asset_title, a.category_slug, a.hero_image_url
70 + FROM sales s JOIN assets a ON a.id = s.asset_id JOIN sources src ON src.id = s.source_id
71 + WHERE s.status = 'valid' AND a.category_slug IN ${scope} ORDER BY s.sale_date DESC LIMIT ${limit}
72 + `);
73 + return r.map(toSaleRow);
74 +});
75 +
76 +// ---------- Listings ----------
77 +export interface ListingsFilters {
78 + category?: string | null;
79 + source?: string | null;
80 + grader?: string | null;
81 + minUsd?: number | null;
82 + maxUsd?: number | null;
83 + minDiscount?: number | null;
84 + listingType?: string | null;
85 + q?: string | null;
86 + sort?: 'newest' | 'price_asc' | 'price_desc' | 'discount' | 'ending';
87 + page?: number;
88 + pageSize?: number;
89 +}
90 +
91 +export async function listListings(f: ListingsFilters): Promise<{ items: ListingRow[]; total: number; page: number; pageSize: number }> {
92 + const pageSize = Math.min(f.pageSize ?? 50, 200);
93 + const page = Math.max(1, f.page ?? 1);
94 + const where: SQL[] = [sql`l.availability = 'available'`];
95 + if (f.category) where.push(sql`a.category_slug IN ${categoryScope(f.category)}`);
96 + if (f.source) where.push(sql`l.source_id = ${f.source}`);
97 + if (f.grader) where.push(sql`l.grader = ${f.grader}`);
98 + if (f.minUsd != null) where.push(sql`l.price_usd >= ${f.minUsd}`);
99 + if (f.maxUsd != null) where.push(sql`l.price_usd <= ${f.maxUsd}`);
100 + if (f.minDiscount != null) where.push(sql`l.discount_to_riv >= ${f.minDiscount}`);
101 + if (f.listingType) where.push(sql`l.listing_type = ${f.listingType}`);
102 + if (f.q && f.q.trim().length >= 2) where.push(sql`(a.title ILIKE ${'%' + f.q.trim() + '%'} OR l.raw_title ILIKE ${'%' + f.q.trim() + '%'})`);
103 + const orders: Record<NonNullable<ListingsFilters['sort']>, SQL> = {
104 + newest: sql`l.first_seen_at DESC`,
105 + price_asc: sql`l.price_usd ASC NULLS LAST`,
106 + price_desc: sql`l.price_usd DESC NULLS LAST`,
107 + discount: sql`l.discount_to_riv DESC NULLS LAST`,
108 + ending: sql`l.ends_at ASC NULLS LAST`,
109 + };
110 + const r = await rows<Record<string, unknown>>(sql`
111 + SELECT ${LISTING_SELECT}, a.slug AS asset_slug, a.title AS asset_title, a.category_slug, st.riv_usd AS asset_riv_usd, st.riv_confidence AS asset_riv_confidence, count(*) OVER() AS total
112 + FROM listings l JOIN assets a ON a.id = l.asset_id JOIN sources src ON src.id = l.source_id LEFT JOIN asset_stats st ON st.asset_id = a.id
113 + WHERE ${joinAnd(where)} ORDER BY ${orders[f.sort ?? 'newest']} LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
114 + `);
115 + return { items: r.map(toListingRow), total: r.length ? int(r[0]!.total) : 0, page, pageSize };
116 +}
117 +
118 +export const listListingSources = cache(async (): Promise<Array<{ id: string; name: string; listings: number }>> => {
119 + const r = await rows<Record<string, unknown>>(sql`SELECT src.id, src.name, count(*) AS listings FROM listings l JOIN sources src ON src.id = l.source_id WHERE l.availability = 'available' GROUP BY src.id, src.name ORDER BY listings DESC`);
120 + return r.map((x) => ({ id: String(x.id), name: String(x.name), listings: int(x.listings) }));
121 +});
122 +
123 +// ---------- Auctions ----------
124 +export interface AuctionRow {
125 + id: string;
126 + sourceId: string;
127 + auctionHouse: string;
128 + name: string;
129 + url: string;
130 + startsAt: Date | null;
131 + endsAt: Date | null;
132 + location: string | null;
133 + categorySlugs: string[];
134 + lotCount: number | null;
135 + status: string;
136 + currency: string | null;
137 + lotsTracked: number;
138 +}
139 +
140 +export async function listAuctions(opts: { status?: 'upcoming' | 'live' | 'ended' | null; from?: Date; to?: Date; category?: string | null; house?: string | null; limit?: number } = {}): Promise<AuctionRow[]> {
141 + const where: SQL[] = [];
142 + if (opts.status) where.push(sql`au.status = ${opts.status}`);
143 + if (opts.from) where.push(sql`coalesce(au.ends_at, au.starts_at) >= ${iso(opts.from)}::timestamptz`);
144 + if (opts.to) where.push(sql`coalesce(au.starts_at, au.ends_at) <= ${iso(opts.to)}::timestamptz`);
145 + if (opts.category) where.push(sql`au.category_slugs && ${textArray(categoryScope(opts.category))}`);
146 + if (opts.house) where.push(sql`au.auction_house = ${opts.house}`);
147 + const r = await rows<Record<string, unknown>>(sql`
148 + SELECT au.*, (SELECT count(*) FROM auction_lots l WHERE l.auction_id = au.id) AS lots_tracked FROM auctions au WHERE ${joinAnd(where)}
149 + ORDER BY CASE au.status WHEN 'live' THEN 0 WHEN 'upcoming' THEN 1 ELSE 2 END, coalesce(au.ends_at, au.starts_at) ASC NULLS LAST LIMIT ${opts.limit ?? 100}
150 + `);
151 + return r.map((x) => ({ id: String(x.id), sourceId: String(x.source_id), auctionHouse: String(x.auction_house), name: String(x.name), url: String(x.url), startsAt: date(x.starts_at), endsAt: date(x.ends_at), location: str(x.location), categorySlugs: (x.category_slugs as string[]) ?? [], lotCount: num(x.lot_count), status: String(x.status), currency: str(x.currency), lotsTracked: int(x.lots_tracked) }));
152 +}
153 +
154 +export interface LotRow {
155 + id: string;
156 + auctionId: string;
157 + auctionName: string | null;
158 + auctionHouse: string | null;
159 + assetSlug: string | null;
160 + assetTitle: string | null;
161 + categorySlug: string | null;
162 + lotNumber: string | null;
163 + title: string;
164 + url: string;
165 + estimateLow: number | null;
166 + estimateHigh: number | null;
167 + currentBid: number | null;
168 + hammerPrice: number | null;
169 + currency: string | null;
170 + bidCount: number | null;
171 + startsAt: Date | null;
172 + endsAt: Date | null;
173 + status: string;
174 + imageUrls: string[];
175 + grader: string | null;
176 + grade: string | null;
177 + rivUsd: number | null;
178 +}
179 +
180 +export async function listLots(opts: { status?: 'upcoming' | 'live' | 'ended' | null; endingWithinHours?: number | null; auctionId?: string | null; category?: string | null; limit?: number; sort?: 'ending' | 'value' } = {}): Promise<LotRow[]> {
181 + const where: SQL[] = [];
182 + if (opts.status) where.push(sql`l.status = ${opts.status}`);
183 + if (opts.endingWithinHours) where.push(sql`l.ends_at BETWEEN now() AND now() + (${opts.endingWithinHours}::int || ' hours')::interval`);
184 + if (opts.auctionId) where.push(sql`l.auction_id = ${opts.auctionId}`);
185 + if (opts.category) where.push(sql`a.category_slug IN ${categoryScope(opts.category)}`);
186 + const order = opts.sort === 'value' ? sql`coalesce(l.hammer_price, l.current_bid, l.estimate_high) DESC NULLS LAST` : sql`l.ends_at ASC NULLS LAST`;
187 + const r = await rows<Record<string, unknown>>(sql`
188 + SELECT l.*, au.name AS auction_name, au.auction_house, a.slug AS asset_slug, a.title AS asset_title, a.category_slug, st.riv_usd
189 + FROM auction_lots l LEFT JOIN auctions au ON au.id = l.auction_id LEFT JOIN assets a ON a.id = l.asset_id LEFT JOIN asset_stats st ON st.asset_id = a.id
190 + WHERE ${joinAnd(where)} ORDER BY ${order} LIMIT ${opts.limit ?? 50}
191 + `);
192 + return r.map((x) => ({ id: String(x.id), auctionId: String(x.auction_id), auctionName: str(x.auction_name), auctionHouse: str(x.auction_house), assetSlug: str(x.asset_slug), assetTitle: str(x.asset_title), categorySlug: str(x.category_slug), lotNumber: str(x.lot_number), title: String(x.title), url: String(x.url), estimateLow: num(x.estimate_low), estimateHigh: num(x.estimate_high), currentBid: num(x.current_bid), hammerPrice: num(x.hammer_price), currency: str(x.currency), bidCount: num(x.bid_count), startsAt: date(x.starts_at), endsAt: date(x.ends_at), status: String(x.status), imageUrls: (x.image_urls as string[]) ?? [], grader: str(x.grader), grade: str(x.grade), rivUsd: num(x.riv_usd) }));
193 +}
194 +
195 +export const listAuctionHouses = cache(async (): Promise<Array<{ house: string; auctions: number; lots: number; upcoming: number }>> => {
196 + const r = await rows<Record<string, unknown>>(sql`
197 + SELECT au.auction_house AS house, count(DISTINCT au.id) AS auctions, count(l.id) AS lots, count(DISTINCT au.id) FILTER (WHERE au.status <> 'ended') AS upcoming
198 + FROM auctions au LEFT JOIN auction_lots l ON l.auction_id = au.id GROUP BY au.auction_house ORDER BY auctions DESC
199 + `);
200 + return r.map((x) => ({ house: String(x.house), auctions: int(x.auctions), lots: int(x.lots), upcoming: int(x.upcoming) }));
201 +});
202 +
203 +// ---------- Radar / records / news ----------
204 +export interface RadarRow {
205 + id: string;
206 + kind: string;
207 + score: number;
208 + evidence: Record<string, unknown>;
209 + entityType: string | null;
210 + entityId: string | null;
211 + detectedAt: Date;
212 + assetSlug: string;
213 + assetTitle: string;
214 + categorySlug: string;
215 + heroImageUrl: string | null;
216 + rivUsd: number | null;
217 +}
218 +
219 +export async function listRadar(opts: { kind?: string | null; limit?: number; scope?: string[] } = {}): Promise<RadarRow[]> {
220 + const r = await rows<Record<string, unknown>>(sql`
221 + SELECT r.*, a.slug AS asset_slug, a.title AS asset_title, a.category_slug, a.hero_image_url, st.riv_usd
222 + FROM radar_findings r JOIN assets a ON a.id = r.asset_id LEFT JOIN asset_stats st ON st.asset_id = a.id
223 + WHERE (r.expires_at IS NULL OR r.expires_at > now()) ${opts.kind ? sql`AND r.kind = ${opts.kind}` : sql``} ${opts.scope?.length ? sql`AND a.category_slug IN ${opts.scope}` : sql``}
224 + ORDER BY r.detected_at DESC, r.score DESC LIMIT ${opts.limit ?? 50}
225 + `);
226 + return r.map((x) => ({ id: String(x.id), kind: String(x.kind), score: Number(x.score), evidence: (x.evidence as Record<string, unknown>) ?? {}, entityType: str(x.entity_type), entityId: str(x.entity_id), detectedAt: date(x.detected_at)!, assetSlug: String(x.asset_slug), assetTitle: String(x.asset_title), categorySlug: String(x.category_slug), heroImageUrl: str(x.hero_image_url), rivUsd: num(x.riv_usd) }));
227 +}
228 +
229 +export interface NewsRow {
230 + id: string;
231 + sourceId: string;
232 + sourceName: string;
233 + url: string;
234 + title: string;
235 + summary: string | null;
236 + aiSummary: string | null;
237 + publishedAt: Date | null;
238 + categorySlugs: string[];
239 + newsType: string | null;
240 + imageUrl: string | null;
241 +}
242 +
243 +export async function listNews(opts: { type?: string | null; category?: string | null; limit?: number; page?: number } = {}): Promise<{ items: NewsRow[]; total: number }> {
244 + const limit = opts.limit ?? 40;
245 + const page = Math.max(1, opts.page ?? 1);
246 + const where: SQL[] = [];
247 + if (opts.type) where.push(sql`n.news_type = ${opts.type}`);
248 + if (opts.category) where.push(sql`n.category_slugs && ${textArray(categoryScope(opts.category))}`);
249 + const r = await rows<Record<string, unknown>>(sql`
250 + SELECT n.*, src.name AS source_name, count(*) OVER() AS total FROM news n JOIN sources src ON src.id = n.source_id WHERE ${joinAnd(where)}
251 + ORDER BY n.published_at DESC NULLS LAST, n.fetched_at DESC LIMIT ${limit} OFFSET ${(page - 1) * limit}
252 + `);
253 + return { items: r.map((x) => ({ id: String(x.id), sourceId: String(x.source_id), sourceName: String(x.source_name), url: String(x.url), title: String(x.title), summary: str(x.summary), aiSummary: str(x.ai_summary), publishedAt: date(x.published_at), categorySlugs: (x.category_slugs as string[]) ?? [], newsType: str(x.news_type), imageUrl: str(x.image_url) })), total: r.length ? int(r[0]!.total) : 0 };
254 +}
255 +
256 +// ---------- Grading ----------
257 +export const getGradePremiums = cache(async (category?: string | null): Promise<Array<{ categorySlug: string; grader: string; grade: string; marketMultiplier: number; sampleSize: number; computedAt: Date }>> => {
258 + const r = await rows<Record<string, unknown>>(sql`SELECT * FROM grade_premiums ${category ? sql`WHERE category_slug IN ${categoryScope(category)}` : sql``} ORDER BY category_slug, grader, market_multiplier DESC`);
259 + return r.map((x) => ({ categorySlug: String(x.category_slug), grader: String(x.grader), grade: String(x.grade), marketMultiplier: Number(x.market_multiplier), sampleSize: int(x.sample_size), computedAt: date(x.computed_at)! }));
260 +});
261 +
262 +export const getGraderActivity = cache(async (): Promise<Array<{ grader: string; sales: number; assets: number; medianUsd: number | null; populationReports: number }>> => {
263 + const r = await rows<Record<string, unknown>>(sql`
264 + SELECT g.slug AS grader,
265 + (SELECT count(*) FROM sales s WHERE s.grader = g.slug AND s.status = 'valid') AS sales,
266 + (SELECT count(DISTINCT s.asset_id) FROM sales s WHERE s.grader = g.slug AND s.status = 'valid') AS assets,
267 + (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY s.price_usd) FROM sales s WHERE s.grader = g.slug AND s.status = 'valid') AS median_usd,
268 + (SELECT count(*) FROM population_reports p WHERE p.grader = g.slug) AS population_reports
269 + FROM graders g WHERE g.active ORDER BY sales DESC, g.slug
270 + `);
271 + return r.map((x) => ({ grader: String(x.grader), sales: int(x.sales), assets: int(x.assets), medianUsd: num(x.median_usd), populationReports: int(x.population_reports) }));
272 +});
273 +
274 +export const getDbGraders = cache(async () => {
275 + const r = await rows<Record<string, unknown>>(sql`SELECT slug, name, category_slugs, scale, population_url, verify_url FROM graders WHERE active ORDER BY slug`);
276 + return r.map((x) => ({ slug: String(x.slug), name: String(x.name), categorySlugs: (x.category_slugs as string[]) ?? [], scale: (x.scale as { values: string[]; top: string; type: string }) ?? { values: [], top: '', type: 'numeric' }, populationUrl: str(x.population_url), verifyUrl: str(x.verify_url) }));
277 +});
278 +
279 +// ---------- Coverage (Data page) ----------
280 +export const getConnectorCoverage = cache(async () => {
281 + const r = await rows<Record<string, unknown>>(sql`
282 + SELECT c.id, c.display_name, c.source_id, src.name AS source_name, src.source_type, src.url AS source_url, src.trust_score, c.categories, c.status, c.refresh_frequency_minutes, c.last_success_at, c.supports_sold, c.supports_listings, c.supports_catalog, c.supports_auctions, c.supports_population,
283 + (SELECT count(*) FROM raw_records rr WHERE rr.connector_id = c.id) AS raw_records,
284 + (SELECT count(*) FROM sales s WHERE s.connector_id = c.id) AS sales,
285 + (SELECT count(*) FROM listings l WHERE l.connector_id = c.id) AS listings,
286 + (SELECT count(*) FROM price_observations o WHERE o.connector_id = c.id) AS observations,
287 + h.status AS health_status, h.health
288 + FROM connectors c JOIN sources src ON src.id = c.source_id LEFT JOIN connector_health h ON h.connector_id = c.id ORDER BY sales DESC, observations DESC, c.id
289 + `);
290 + return r.map((x) => ({ id: String(x.id), displayName: String(x.display_name), sourceId: String(x.source_id), sourceName: String(x.source_name), sourceType: String(x.source_type), sourceUrl: str(x.source_url), trustScore: Number(x.trust_score ?? 0), categories: (x.categories as string[]) ?? [], status: String(x.status), refreshMinutes: int(x.refresh_frequency_minutes), lastSuccessAt: date(x.last_success_at), supportsSold: Boolean(x.supports_sold), supportsListings: Boolean(x.supports_listings), supportsCatalog: Boolean(x.supports_catalog), supportsAuctions: Boolean(x.supports_auctions), supportsPopulation: Boolean(x.supports_population), rawRecords: int(x.raw_records), sales: int(x.sales), listings: int(x.listings), observations: int(x.observations), healthStatus: str(x.health_status), health: (x.health as Record<string, unknown>) ?? null }));
291 +});
292 +
293 +export const getFxCoverage = cache(async () => {
294 + const x = await one<Record<string, unknown>>(sql`SELECT count(*) AS n, min(date) AS first, max(date) AS last, count(DISTINCT quote) AS quotes FROM fx_rates`);
295 + return { rows: int(x?.n), first: str(x?.first), last: str(x?.last), quotes: int(x?.quotes) };
296 +});
297 +
298 +export const getTrendingCategories = cache(async (limit = 8) => {
299 + const r = await rows<Record<string, unknown>>(sql`
300 + SELECT a.category_slug, avg(s.trending_score) AS trending, avg(s.momentum_30d) AS momentum, count(*) AS assets FROM asset_stats s JOIN assets a ON a.id = s.asset_id WHERE s.trending_score IS NOT NULL GROUP BY a.category_slug ORDER BY trending DESC LIMIT ${limit}
301 + `);
302 + return r.map((x) => ({ categorySlug: String(x.category_slug), trending: Number(x.trending), momentum: num(x.momentum), assets: int(x.assets) }));
303 +});
added apps/web/src/lib/queries/markets.ts +166 −0
@@ -0,0 +1,166 @@
1 +import 'server-only';
2 +import { cache } from 'react';
3 +import { CATEGORIES, FAMILIES, getCategory, type CategoryNode } from '@rareindex/taxonomy';
4 +import { rows, one, sql, num, int, str, date } from './_util';
5 +import { categoryScope } from './assets';
6 +
7 +export interface CategorySnapshot {
8 + categorySlug: string;
9 + date: string;
10 + indexValue: number | null;
11 + trackedAssets: number;
12 + assetsWithValuation: number;
13 + sales: number;
14 + volumeUsd: number | null;
15 + medianSaleUsd: number | null;
16 + activeListings: number;
17 + marketCapEstUsd: number | null;
18 + liquidityScore: number | null;
19 + change1d: number | null;
20 + change7d: number | null;
21 + change30d: number | null;
22 + change1y: number | null;
23 +}
24 +
25 +function toSnap(x: Record<string, unknown>): CategorySnapshot {
26 + return { categorySlug: String(x.category_slug), date: String(x.date), indexValue: num(x.index_value), trackedAssets: int(x.tracked_assets), assetsWithValuation: int(x.assets_with_valuation), sales: int(x.sales), volumeUsd: num(x.volume_usd), medianSaleUsd: num(x.median_sale_usd), activeListings: int(x.active_listings), marketCapEstUsd: num(x.market_cap_est_usd), liquidityScore: num(x.liquidity_score), change1d: num(x.change_1d), change7d: num(x.change_7d), change30d: num(x.change_30d), change1y: num(x.change_1y) };
27 +}
28 +
29 +/** Latest snapshot per category (may be missing for categories without data). */
30 +export const getLatestCategorySnapshots = cache(async (): Promise<Map<string, CategorySnapshot>> => {
31 + const r = await rows<Record<string, unknown>>(sql`SELECT DISTINCT ON (category_slug) * FROM category_snapshots ORDER BY category_slug, date DESC`);
32 + return new Map(r.map((x) => [String(x.category_slug), toSnap(x)]));
33 +});
34 +
35 +/** Live counts straight from the canonical tables (works even before snapshots exist). */
36 +export interface CategoryCounts {
37 + slug: string;
38 + assets: number;
39 + priced: number;
40 + sales: number;
41 + sales30d: number;
42 + volume30dUsd: number | null;
43 + listings: number;
44 + observations: number;
45 + medianRivUsd: number | null;
46 + lastSaleAt: Date | null;
47 +}
48 +
49 +export const getCategoryCounts = cache(async (): Promise<Map<string, CategoryCounts>> => {
50 + // Counts come from the canonical tables directly (not asset_stats) so they are right even before the valuation worker runs.
51 + const r = await rows<Record<string, unknown>>(sql`
52 + WITH sa AS (
53 + SELECT a.category_slug AS slug, count(*) AS sales, count(*) FILTER (WHERE s.sale_date >= now() - interval '30 days') AS sales_30d,
54 + sum(s.price_usd) FILTER (WHERE s.sale_date >= now() - interval '30 days') AS volume_30d, max(s.sale_date) AS last_sale_at
55 + FROM sales s JOIN assets a ON a.id = s.asset_id WHERE s.status = 'valid' GROUP BY a.category_slug
56 + ), li AS (
57 + SELECT a.category_slug AS slug, count(*) AS listings FROM listings l JOIN assets a ON a.id = l.asset_id WHERE l.availability = 'available' GROUP BY a.category_slug
58 + ), ob AS (
59 + SELECT a.category_slug AS slug, count(*) AS observations FROM price_observations o JOIN assets a ON a.id = o.asset_id GROUP BY a.category_slug
60 + ), st AS (
61 + SELECT a.category_slug AS slug, count(*) AS assets, count(s.riv_usd) AS priced, percentile_cont(0.5) WITHIN GROUP (ORDER BY s.riv_usd) AS median_riv
62 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id GROUP BY a.category_slug
63 + )
64 + SELECT st.slug, st.assets, st.priced, coalesce(sa.sales, 0) AS sales, coalesce(sa.sales_30d, 0) AS sales_30d, sa.volume_30d, coalesce(li.listings, 0) AS listings,
65 + coalesce(ob.observations, 0) AS observations, st.median_riv, sa.last_sale_at
66 + FROM st LEFT JOIN sa ON sa.slug = st.slug LEFT JOIN li ON li.slug = st.slug LEFT JOIN ob ON ob.slug = st.slug
67 + `);
68 + return new Map(r.map((x) => [String(x.slug), { slug: String(x.slug), assets: int(x.assets), priced: int(x.priced), sales: int(x.sales), sales30d: int(x.sales_30d), volume30dUsd: num(x.volume_30d), listings: int(x.listings), observations: int(x.observations), medianRivUsd: num(x.median_riv), lastSaleAt: date(x.last_sale_at) }]));
69 +});
70 +
71 +export interface MarketRow {
72 + node: CategoryNode;
73 + snapshot: CategorySnapshot | null;
74 + counts: CategoryCounts; // aggregated over the node and its descendants
75 +}
76 +
77 +function aggregate(slugs: string[], counts: Map<string, CategoryCounts>, slug: string): CategoryCounts {
78 + const out: CategoryCounts = { slug, assets: 0, priced: 0, sales: 0, sales30d: 0, volume30dUsd: null, listings: 0, observations: 0, medianRivUsd: null, lastSaleAt: null };
79 + for (const s of slugs) {
80 + const c = counts.get(s);
81 + if (!c) continue;
82 + out.assets += c.assets;
83 + out.priced += c.priced;
84 + out.sales += c.sales;
85 + out.sales30d += c.sales30d;
86 + out.listings += c.listings;
87 + out.observations += c.observations;
88 + if (c.volume30dUsd != null) out.volume30dUsd = (out.volume30dUsd ?? 0) + c.volume30dUsd;
89 + if (c.lastSaleAt && (!out.lastSaleAt || c.lastSaleAt > out.lastSaleAt)) out.lastSaleAt = c.lastSaleAt;
90 + if (out.medianRivUsd == null && c.medianRivUsd != null) out.medianRivUsd = c.medianRivUsd;
91 + }
92 + return out;
93 +}
94 +
95 +/** Market overview rows for families (level 0) or children of a family. */
96 +export const getMarketRows = cache(async (parent: string | null = null): Promise<MarketRow[]> => {
97 + const [snaps, counts] = await Promise.all([getLatestCategorySnapshots(), getCategoryCounts()]);
98 + const nodes = parent ? CATEGORIES.filter((c) => c.parent === parent) : FAMILIES;
99 + return nodes.map((node) => ({ node, snapshot: snaps.get(node.slug) ?? null, counts: aggregate(categoryScope(node.slug), counts, node.slug) }));
100 +});
101 +
102 +export const getCategorySeries = cache(async (slug: string, days: number | null = null): Promise<CategorySnapshot[]> => {
103 + const r = await rows<Record<string, unknown>>(sql`SELECT * FROM category_snapshots WHERE category_slug = ${slug} ${days ? sql`AND date >= current_date - ${days}::int` : sql``} ORDER BY date`);
104 + return r.map(toSnap);
105 +});
106 +
107 +export const getCategoryRow = cache(async (slug: string): Promise<MarketRow | null> => {
108 + const node = getCategory(slug);
109 + if (!node) return null;
110 + const [snaps, counts] = await Promise.all([getLatestCategorySnapshots(), getCategoryCounts()]);
111 + return { node, snapshot: snaps.get(slug) ?? null, counts: aggregate(categoryScope(slug), counts, slug) };
112 +});
113 +
114 +export const getSetsInCategory = cache(async (slug: string, limit = 60): Promise<Array<{ slug: string; name: string; code: string | null; releaseYear: number | null; assets: number; priced: number; sales: number }>> => {
115 + const r = await rows<Record<string, unknown>>(sql`
116 + SELECT st.slug, st.name, st.code, st.release_year, count(a.id) AS assets, count(s.riv_usd) AS priced, coalesce(sum(s.sales_count), 0) AS sales
117 + FROM sets st LEFT JOIN assets a ON a.set_slug = st.slug LEFT JOIN asset_stats s ON s.asset_id = a.id
118 + WHERE st.category_slug IN ${categoryScope(slug)} GROUP BY st.slug, st.name, st.code, st.release_year ORDER BY sales DESC, assets DESC, st.release_year DESC NULLS LAST LIMIT ${limit}
119 + `);
120 + return r.map((x) => ({ slug: String(x.slug), name: String(x.name), code: str(x.code), releaseYear: num(x.release_year), assets: int(x.assets), priced: int(x.priced), sales: int(x.sales) }));
121 +});
122 +
123 +export const getBrandsInCategory = cache(async (slug: string, limit = 40): Promise<Array<{ brand: string; assets: number; priced: number; sales: number; medianRivUsd: number | null }>> => {
124 + const r = await rows<Record<string, unknown>>(sql`
125 + SELECT a.brand, count(*) AS assets, count(s.riv_usd) AS priced, coalesce(sum(s.sales_count), 0) AS sales, percentile_cont(0.5) WITHIN GROUP (ORDER BY s.riv_usd) AS median_riv
126 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id WHERE a.brand IS NOT NULL AND a.category_slug IN ${categoryScope(slug)}
127 + GROUP BY a.brand ORDER BY sales DESC, assets DESC LIMIT ${limit}
128 + `);
129 + return r.map((x) => ({ brand: String(x.brand), assets: int(x.assets), priced: int(x.priced), sales: int(x.sales), medianRivUsd: num(x.median_riv) }));
130 +});
131 +
132 +export const getSet = cache(async (slug: string) => {
133 + const x = await one<Record<string, unknown>>(sql`SELECT * FROM sets WHERE slug = ${slug}`);
134 + if (!x) return null;
135 + return { slug: String(x.slug), name: String(x.name), code: str(x.code), categorySlug: String(x.category_slug), franchiseSlug: str(x.franchise_slug), brandSlug: str(x.brand_slug), releaseYear: num(x.release_year), releaseDate: str(x.release_date), language: str(x.language), totalItems: num(x.total_items), identifiers: (x.identifiers as Record<string, string>) ?? {}, metadata: (x.metadata as Record<string, unknown>) ?? {} };
136 +});
137 +
138 +export const getSetStats = cache(async (slug: string) => {
139 + const x = await one<Record<string, unknown>>(sql`
140 + SELECT count(a.id) AS assets, count(s.riv_usd) AS priced, coalesce(sum(s.sales_count), 0) AS sales, coalesce(sum(s.active_listings), 0) AS listings, sum(s.riv_usd) AS sum_riv, max(s.riv_usd) AS max_riv, percentile_cont(0.5) WITHIN GROUP (ORDER BY s.riv_usd) AS median_riv, max(s.latest_sale_at) AS last_sale_at
141 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id WHERE a.set_slug = ${slug}
142 + `);
143 + return { assets: int(x?.assets), priced: int(x?.priced), sales: int(x?.sales), listings: int(x?.listings), sumRivUsd: num(x?.sum_riv), maxRivUsd: num(x?.max_riv), medianRivUsd: num(x?.median_riv), lastSaleAt: date(x?.last_sale_at) };
144 +});
145 +
146 +export const getBrandStats = cache(async (brand: string) => {
147 + const x = await one<Record<string, unknown>>(sql`
148 + SELECT min(a.brand) AS brand, count(a.id) AS assets, count(s.riv_usd) AS priced, coalesce(sum(s.sales_count), 0) AS sales, coalesce(sum(s.active_listings), 0) AS listings, max(s.riv_usd) AS max_riv, percentile_cont(0.5) WITHIN GROUP (ORDER BY s.riv_usd) AS median_riv, array_agg(DISTINCT a.category_slug) AS categories
149 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id WHERE lower(a.brand) = lower(${brand})
150 + `);
151 + if (!x || int(x.assets) === 0) return null;
152 + return { brand: String(x.brand), assets: int(x.assets), priced: int(x.priced), sales: int(x.sales), listings: int(x.listings), maxRivUsd: num(x.max_riv), medianRivUsd: num(x.median_riv), categories: (x.categories as string[]) ?? [] };
153 +});
154 +
155 +export const getSetSlugsForSitemap = cache(async (): Promise<string[]> => {
156 + const r = await rows<Record<string, unknown>>(sql`SELECT slug FROM sets ORDER BY slug LIMIT 20000`);
157 + return r.map((x) => String(x.slug));
158 +});
159 +
160 +export const getPopulationTrend = cache(async (slug: string): Promise<Array<{ grader: string; reportDate: string; total: number; assets: number }>> => {
161 + const r = await rows<Record<string, unknown>>(sql`
162 + SELECT p.grader, p.report_date, sum(p.total) AS total, count(DISTINCT p.asset_id) AS assets FROM population_reports p JOIN assets a ON a.id = p.asset_id
163 + WHERE a.category_slug IN ${categoryScope(slug)} GROUP BY p.grader, p.report_date ORDER BY p.grader, p.report_date
164 + `);
165 + return r.map((x) => ({ grader: String(x.grader), reportDate: String(x.report_date), total: int(x.total), assets: int(x.assets) }));
166 +});
added apps/web/src/lib/queries/site.ts +86 −0
@@ -0,0 +1,86 @@
1 +import 'server-only';
2 +import { cache } from 'react';
3 +import { one, rows, sql, int, date } from './_util';
4 +
5 +export interface SiteStats {
6 + assets: number;
7 + sales: number;
8 + observations: number;
9 + activeListings: number;
10 + sources: number;
11 + connectors: number;
12 + categoriesWithData: number;
13 + lastSaleAt: Date | null;
14 + lastUpdateAt: Date | null;
15 +}
16 +
17 +/** Real counts for the homepage stat strip and the Data page. Never estimated. */
18 +export const getSiteStats = cache(async (): Promise<SiteStats> => {
19 + const r = await one<Record<string, unknown>>(sql`
20 + SELECT
21 + (SELECT count(*) FROM assets) AS assets,
22 + (SELECT count(*) FROM sales WHERE status <> 'excluded') AS sales,
23 + (SELECT count(*) FROM price_observations) AS observations,
24 + (SELECT count(*) FROM listings WHERE availability = 'available') AS active_listings,
25 + (SELECT count(*) FROM sources WHERE active) AS sources,
26 + (SELECT count(*) FROM connectors WHERE status = 'active') AS connectors,
27 + (SELECT count(DISTINCT category_slug) FROM assets) AS categories_with_data,
28 + (SELECT max(sale_date) FROM sales) AS last_sale_at,
29 + GREATEST((SELECT max(created_at) FROM sales), (SELECT max(created_at) FROM price_observations), (SELECT max(updated_at) FROM asset_stats)) AS last_update_at
30 + `);
31 + return {
32 + assets: int(r?.assets),
33 + sales: int(r?.sales),
34 + observations: int(r?.observations),
35 + activeListings: int(r?.active_listings),
36 + sources: int(r?.sources),
37 + connectors: int(r?.connectors),
38 + categoriesWithData: int(r?.categories_with_data),
39 + lastSaleAt: date(r?.last_sale_at),
40 + lastUpdateAt: date(r?.last_update_at),
41 + };
42 +});
43 +
44 +export interface LatestSale {
45 + id: string;
46 + assetSlug: string;
47 + assetTitle: string;
48 + categorySlug: string;
49 + heroImageUrl: string | null;
50 + saleDate: Date;
51 + price: number;
52 + currency: string;
53 + priceUsd: number;
54 + grader: string | null;
55 + grade: string | null;
56 + sourceId: string;
57 + sourceName: string;
58 + sourceUrl: string;
59 + saleType: string;
60 +}
61 +
62 +export const getLatestSales = cache(async (limit = 12): Promise<LatestSale[]> => {
63 + const r = await rows<Record<string, unknown>>(sql`
64 + SELECT s.id, a.slug AS asset_slug, a.title AS asset_title, a.category_slug, a.hero_image_url, s.sale_date, s.price, s.currency, s.price_usd, s.grader, s.grade, s.source_id, src.name AS source_name, s.source_url, s.sale_type
65 + FROM sales s JOIN assets a ON a.id = s.asset_id JOIN sources src ON src.id = s.source_id
66 + WHERE s.status = 'valid'
67 + ORDER BY s.sale_date DESC, s.created_at DESC LIMIT ${limit}
68 + `);
69 + return r.map((x) => ({
70 + id: String(x.id),
71 + assetSlug: String(x.asset_slug),
72 + assetTitle: String(x.asset_title),
73 + categorySlug: String(x.category_slug),
74 + heroImageUrl: (x.hero_image_url as string | null) ?? null,
75 + saleDate: date(x.sale_date)!,
76 + price: Number(x.price),
77 + currency: String(x.currency),
78 + priceUsd: Number(x.price_usd),
79 + grader: (x.grader as string | null) ?? null,
80 + grade: (x.grade as string | null) ?? null,
81 + sourceId: String(x.source_id),
82 + sourceName: String(x.source_name),
83 + sourceUrl: String(x.source_url),
84 + saleType: String(x.sale_type),
85 + }));
86 +});
added apps/web/src/lib/search-params.ts +38 −0
@@ -0,0 +1,38 @@
1 +/** Helpers for reading Next.js searchParams (Promise<Record<string, string | string[] | undefined>>). */
2 +export type SP = Record<string, string | string[] | undefined>;
3 +
4 +export function sp1(sp: SP, key: string): string | undefined {
5 + const v = sp[key];
6 + const s = Array.isArray(v) ? v[0] : v;
7 + return s && s.length ? s : undefined;
8 +}
9 +
10 +export function spNum(sp: SP, key: string): number | null {
11 + const s = sp1(sp, key);
12 + if (s === undefined) return null;
13 + const n = Number(s.replace(/[,$\s]/g, ''));
14 + return Number.isFinite(n) ? n : null;
15 +}
16 +
17 +export function spInt(sp: SP, key: string, fallback = 1): number {
18 + const n = spNum(sp, key);
19 + return n === null ? fallback : Math.max(1, Math.trunc(n));
20 +}
21 +
22 +export function spEnum<T extends string>(sp: SP, key: string, allowed: readonly T[], fallback: T): T {
23 + const s = sp1(sp, key);
24 + return s && (allowed as readonly string[]).includes(s) ? (s as T) : fallback;
25 +}
26 +
27 +export function pick(sp: SP, keys: string[]): Record<string, string | undefined> {
28 + const out: Record<string, string | undefined> = {};
29 + for (const k of keys) out[k] = sp1(sp, k);
30 + return out;
31 +}
32 +
33 +export function withParams(base: string, params: Record<string, string | number | null | undefined>): string {
34 + const u = new URLSearchParams();
35 + for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') u.set(k, String(v));
36 + const qs = u.toString();
37 + return qs ? `${base}?${qs}` : base;
38 +}
added apps/web/src/lib/taxonomy.ts +23 −0
@@ -0,0 +1,23 @@
1 +import { getCategory, categoryPath, FAMILIES, CATEGORIES, getGrader, humanize } from '@rareindex/taxonomy';
2 +
3 +export { getCategory, categoryPath, FAMILIES, CATEGORIES, getGrader, humanize };
4 +
5 +export function catName(slug: string | null | undefined): string {
6 + if (!slug) return '—';
7 + return getCategory(slug)?.name ?? humanize(slug);
8 +}
9 +
10 +export function familyName(slug: string | null | undefined): string {
11 + if (!slug) return '—';
12 + const c = getCategory(slug);
13 + return c ? (getCategory(c.familySlug)?.name ?? c.name) : humanize(slug);
14 +}
15 +
16 +export function gradeLabel(grader: string | null | undefined, grade: string | null | undefined): string | null {
17 + if (!grader || grader === 'raw') return grade ? grade : grader === 'raw' ? 'Raw' : null;
18 + return `${getGrader(grader)?.name ?? grader.toUpperCase()} ${grade ?? ''}`.trim();
19 +}
20 +
21 +export function categoryCrumbs(slug: string): Array<{ label: string; href: string }> {
22 + return categoryPath(slug).map((c) => ({ label: c.name, href: `/markets/${c.slug}` }));
23 +}
added docs/METHODOLOGY.md +61 −0
@@ -0,0 +1,61 @@
1 +# RareIndex methodology
2 +
3 +This page describes how RareIndex turns fragmented collectible markets into structured market data. Every number on the site carries its sample size, confidence and freshness; when the evidence is insufficient we display "Data unavailable" rather than an estimate.
4 +
5 +## Data sources and provenance
6 +
7 +Data is collected from public marketplaces, auction houses, dealers, price guides, grading companies, manufacturer catalogues and official datasets through a connector framework. Each connector declares what it collects (sales, listings, catalogue, population, auctions), its engine priority (direct API → structured feed → Firecrawl → Scrapfly) and its trust score. Every stored record keeps its source URL, crawl timestamp, native currency, parser version and connector version, so any displayed datapoint can be traced back to its origin.
8 +
9 +RareIndex never bypasses logins, paywalls or access controls, respects robots policies and rate limits, and attributes third‑party data to its source.
10 +
11 +## Canonical assets and variants
12 +
13 +A canonical **asset** is the collectible object independent of its condition (e.g. *Charizard #4/102, Base Set 1st Edition Holo, 1999*). A **variant** is a grade/condition slice of that asset (e.g. *PSA 10*, *CIB*, *deadstock size 10*). Sales, listings and valuations are attached to both.
14 +
15 +Entity resolution uses deterministic identifiers first (card numbers, set codes, model references, UPC/ISBN/SKU, certification numbers), then a normalised canonical key, then fuzzy text similarity and, for ambiguous cases, embedding and LLM verification. Merges are recorded and auditable.
16 +
17 +## Sales versus listings versus guide prices
18 +
19 +- **Sales** are observed transactions with a date and a price. They drive valuations and indices.
20 +- **Listings** are asking prices. They are never treated as market value; the *vs RIV* column shows the discount or premium of an ask to the valuation.
21 +- **Price observations** are guide values published by price guides or card databases. They are informative priors, weighted below transactions, and are labelled as such.
22 +
23 +Prices are stored in their native currency and converted to USD at the historical exchange rate of the sale date (ECB reference rates). Historical sales are never converted at today's rate.
24 +
25 +## RareIndex Valuation (RIV)
26 +
27 +RIV is an ensemble estimate computed per asset and per variant from verified sales within a rolling window:
28 +
29 +- latest sale, medians of the last 5 / 10 / 20 sales, volume‑weighted price, trimmed mean and exponentially weighted price (recent sales weigh more);
30 +- comparable-, grade- and condition-adjusted prices when the asset has too few direct sales;
31 +- a low / high range from the dispersion of the sample;
32 +- a **confidence** derived from sample size, recency, dispersion and source trust.
33 +
34 +Outliers (data errors, bundles, cancelled auctions, misidentified items, abnormal prices) are flagged and excluded from the calculation but never deleted; the audit log records every decision.
35 +
36 +## Grading
37 +
38 +Grades are tracked per grading company. A PSA 10, a BGS 10 and a CGC 10 are different variants; their relative premiums are measured empirically from sales of the same asset in different grades and published with their sample sizes. Population reports, when available, feed rarity and *population momentum* (growth of supply).
39 +
40 +## Scores
41 +
42 +- **Rarity (0–100)** combines production quantity, surviving and graded population, listing and sale frequency, variant rarity and distribution exclusivity. Unknown inputs stay unknown.
43 +- **Liquidity (0–100)** combines transactions per month, number of marketplaces, active listings, days between transactions and the spread between asks and sales.
44 +- **Momentum** measures price and transaction acceleration over 7d / 30d / 90d / 1y.
45 +- **Trending** is the normalised product of price, volume, search, listing and news momentum.
46 +- **Value opportunity** flags listings materially below RIV, with confidence. It is analytical data, not advice.
47 +- **Data quality (0–100)** combines source trust, field completeness, entity confidence, image match, date and price confidence.
48 +
49 +## Indices
50 +
51 +Each subindex (RARE‑TCG, RARE‑WATCH, …) is a chain‑linked index of priced constituents in its families, base 1,000 on 2024‑01‑01, equal‑weighted by default. The flagship **RARE** aggregates the subindices. Repeat‑sales methods are applied where the same identifiable asset trades repeatedly, reducing composition bias. An index publishes nothing until it reaches its minimum number of priced constituents, and every daily value stores its coverage (share of constituents with a fresh valuation).
52 +
53 +Benchmarks (S&P 500, gold, Bitcoin, inflation) are shown for context only: collectibles trade infrequently, carry high transaction costs and are valued from observed sales rather than continuous quotes.
54 +
55 +## Market capitalisation
56 +
57 +Category market capitalisation is estimated as *estimated population × representative value* summed over priced assets, and is always displayed with its confidence label. It is indicative, never precise.
58 +
59 +## Disclaimers
60 +
61 +Valuations are estimates. Listing prices are not confirmed transactions. Past performance does not guarantee future results. RareIndex does not authenticate items. Third‑party names and marks belong to their owners.
added packages/search/package.json +32 −0
@@ -0,0 +1,32 @@
1 +{
2 + "name": "@rareindex/search",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "exports": {
7 + ".": {
8 + "types": "./src/index.ts",
9 + "default": "./src/index.ts"
10 + },
11 + "./parse": {
12 + "types": "./src/parse.ts",
13 + "default": "./src/parse.ts"
14 + }
15 + },
16 + "scripts": {
17 + "build": "tsc -p tsconfig.json --noEmit",
18 + "typecheck": "tsc -p tsconfig.json --noEmit",
19 + "test": "vitest run --passWithNoTests"
20 + },
21 + "dependencies": {
22 + "@rareindex/database": "workspace:*",
23 + "@rareindex/shared": "workspace:*",
24 + "@rareindex/taxonomy": "workspace:*",
25 + "drizzle-orm": "^0.45.0"
26 + },
27 + "devDependencies": {
28 + "@types/node": "^24.0.0",
29 + "typescript": "^5.9.3",
30 + "vitest": "^3.2.0"
31 + }
32 +}
added packages/search/src/index.ts +169 −0
@@ -0,0 +1,169 @@
1 +import { sql } from 'drizzle-orm';
2 +import { getDb } from '@rareindex/database';
3 +import { descendants, getCategory, CATEGORIES } from '@rareindex/taxonomy';
4 +import { parseQuery, toTsQuery, type ParsedQuery } from './parse.js';
5 +
6 +export { parseQuery, toTsQuery, type ParsedQuery };
7 +
8 +/**
9 + * Hybrid asset search (§138): lexical (tsvector, weighted A/B/C) + trigram similarity on the title
10 + * + popularity (sales count, watchers, observations) + recency, with structured filters parsed
11 + * from the natural-language query. Runs entirely in PostgreSQL; vector re-ranking can be added
12 + * on top when `asset_embeddings` is populated.
13 + */
14 +export interface SearchOptions {
15 + limit?: number;
16 + offset?: number;
17 + categorySlug?: string | null;
18 + grader?: string | null;
19 + grade?: string | null;
20 + priceMinUsd?: number | null;
21 + priceMaxUsd?: number | null;
22 + yearFrom?: number | null;
23 + yearTo?: number | null;
24 +}
25 +
26 +export interface SearchHit {
27 + id: string;
28 + slug: string;
29 + title: string;
30 + name: string;
31 + categorySlug: string;
32 + familySlug: string;
33 + setName: string | null;
34 + number: string | null;
35 + year: number | null;
36 + variant: string | null;
37 + heroImageUrl: string | null;
38 + rivUsd: number | null;
39 + rivConfidence: number | null;
40 + latestSaleUsd: number | null;
41 + change30d: number | null;
42 + salesCount: number;
43 + liquidityScore: number | null;
44 + score: number;
45 +}
46 +
47 +export interface SearchResult {
48 + parsed: ParsedQuery;
49 + hits: SearchHit[];
50 + total: number;
51 + tookMs: number;
52 + categories: Array<{ slug: string; name: string; count: number }>;
53 +}
54 +
55 +export async function searchAssets(query: string, opts: SearchOptions = {}): Promise<SearchResult> {
56 + const t0 = Date.now();
57 + const parsed = parseQuery(query);
58 + const limit = Math.min(Math.max(opts.limit ?? 24, 1), 100);
59 + const offset = Math.max(opts.offset ?? 0, 0);
60 + const db = getDb();
61 +
62 + const tsq = toTsQuery(parsed.text || parsed.raw);
63 + const likeText = (parsed.text || parsed.raw).trim();
64 + const catSlug = opts.categorySlug ?? (parsed.categorySlugs.length === 1 ? parsed.categorySlugs[0]! : null);
65 + const catSlugs = catSlug ? [catSlug, ...descendants(catSlug)] : parsed.categorySlugs.length > 1 ? parsed.categorySlugs.flatMap((s) => [s, ...descendants(s)]) : null;
66 + const grader = opts.grader ?? parsed.grader;
67 + const grade = opts.grade ?? parsed.grade;
68 + const priceMin = opts.priceMinUsd ?? (parsed.currency === 'USD' || parsed.currency === null ? parsed.priceMin : null);
69 + const priceMax = opts.priceMaxUsd ?? (parsed.currency === 'USD' || parsed.currency === null ? parsed.priceMax : null);
70 +
71 + const conditions = [sql`true`];
72 + if (tsq && likeText.length >= 2) {
73 + conditions.push(sql`(a.search @@ to_tsquery('simple', ${tsq}) OR a.title % ${likeText} OR a.title ILIKE ${'%' + likeText + '%'})`);
74 + }
75 + if (catSlugs?.length) conditions.push(sql`a.category_slug IN ${catSlugs}`);
76 + if (opts.yearFrom) conditions.push(sql`a.year >= ${opts.yearFrom}`);
77 + if (opts.yearTo) conditions.push(sql`a.year <= ${opts.yearTo}`);
78 + if (priceMin !== null && priceMin !== undefined) conditions.push(sql`s.riv_usd >= ${priceMin}`);
79 + if (priceMax !== null && priceMax !== undefined) conditions.push(sql`s.riv_usd <= ${priceMax}`);
80 + if (grader && grader !== 'raw') {
81 + conditions.push(sql`EXISTS (SELECT 1 FROM asset_variants v WHERE v.asset_id = a.id AND v.grader = ${grader} ${grade ? sql`AND v.grade = ${grade}` : sql``})`);
82 + }
83 + const where = sql.join(conditions, sql` AND `);
84 +
85 + const rank = tsq
86 + ? sql`(coalesce(ts_rank_cd(a.search, to_tsquery('simple', ${tsq})), 0) * 4 + coalesce(similarity(a.title, ${likeText}), 0) * 3 + ln(1 + coalesce(s.sales_count, 0)) * 0.35 + ln(1 + coalesce(s.observations_count, 0)) * 0.1 + ln(1 + coalesce(s.watchers, 0)) * 0.2 + (CASE WHEN a.year = ${parsed.year ?? -1} THEN 0.8 ELSE 0 END) + (CASE WHEN s.riv_usd IS NOT NULL THEN 0.3 ELSE 0 END))`
87 + : sql`(ln(1 + coalesce(s.sales_count, 0)) * 0.35 + ln(1 + coalesce(s.watchers, 0)) * 0.2 + (CASE WHEN s.riv_usd IS NOT NULL THEN 0.3 ELSE 0 END))`;
88 +
89 + const rows = (await db.execute(sql`
90 + SELECT a.id, a.slug, a.title, a.name, a.category_slug, a.family_slug, a.set_name, a.number, a.year, a.variant, a.hero_image_url,
91 + s.riv_usd, s.riv_confidence, s.latest_sale_usd, s.change_30d, coalesce(s.sales_count, 0) AS sales_count, s.liquidity_score,
92 + ${rank} AS score, count(*) OVER() AS total
93 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id
94 + WHERE ${where}
95 + ORDER BY score DESC, a.updated_at DESC
96 + LIMIT ${limit} OFFSET ${offset}
97 + `)) as unknown as Array<Record<string, unknown>>;
98 +
99 + const hits: SearchHit[] = rows.map((r) => ({
100 + id: String(r.id),
101 + slug: String(r.slug),
102 + title: String(r.title),
103 + name: String(r.name),
104 + categorySlug: String(r.category_slug),
105 + familySlug: String(r.family_slug),
106 + setName: (r.set_name as string | null) ?? null,
107 + number: (r.number as string | null) ?? null,
108 + year: r.year === null ? null : Number(r.year),
109 + variant: (r.variant as string | null) ?? null,
110 + heroImageUrl: (r.hero_image_url as string | null) ?? null,
111 + rivUsd: r.riv_usd === null || r.riv_usd === undefined ? null : Number(r.riv_usd),
112 + rivConfidence: r.riv_confidence === null || r.riv_confidence === undefined ? null : Number(r.riv_confidence),
113 + latestSaleUsd: r.latest_sale_usd === null || r.latest_sale_usd === undefined ? null : Number(r.latest_sale_usd),
114 + change30d: r.change_30d === null || r.change_30d === undefined ? null : Number(r.change_30d),
115 + salesCount: Number(r.sales_count ?? 0),
116 + liquidityScore: r.liquidity_score === null || r.liquidity_score === undefined ? null : Number(r.liquidity_score),
117 + score: Number(r.score ?? 0),
118 + }));
119 + const total = rows.length ? Number(rows[0]!.total) : 0;
120 +
121 + // Category facets for the current lexical match (cheap, capped)
122 + let categories: SearchResult['categories'] = [];
123 + if (tsq && likeText.length >= 2) {
124 + const facets = (await db.execute(sql`
125 + SELECT a.category_slug AS slug, count(*)::int AS count FROM assets a
126 + WHERE (a.search @@ to_tsquery('simple', ${tsq}) OR a.title % ${likeText})
127 + GROUP BY a.category_slug ORDER BY count DESC LIMIT 8
128 + `)) as unknown as Array<{ slug: string; count: number }>;
129 + categories = facets.map((f) => ({ slug: f.slug, name: getCategory(f.slug)?.name ?? f.slug, count: Number(f.count) }));
130 + }
131 +
132 + return { parsed, hits, total, tookMs: Date.now() - t0, categories };
133 +}
134 +
135 +export interface Suggestion {
136 + type: 'asset' | 'category' | 'set' | 'brand' | 'index';
137 + label: string;
138 + sublabel: string | null;
139 + href: string;
140 +}
141 +
142 +/** Instant suggestions for the header search (prefix / trigram). */
143 +export async function suggest(prefix: string, limit = 8): Promise<Suggestion[]> {
144 + const q = prefix.trim();
145 + if (q.length < 2) return [];
146 + const db = getDb();
147 + const out: Suggestion[] = [];
148 + const lower = q.toLowerCase();
149 + for (const c of CATEGORIES) {
150 + if (c.name.toLowerCase().startsWith(lower) || c.name.toLowerCase().includes(` ${lower}`)) {
151 + out.push({ type: 'category', label: c.name, sublabel: c.level === 0 ? 'Category' : `${getCategory(c.familySlug)?.name ?? ''} · Category`, href: `/markets/${c.slug}` });
152 + if (out.length >= 3) break;
153 + }
154 + }
155 + const tsq = toTsQuery(q);
156 + const rows = (await db.execute(sql`
157 + SELECT a.slug, a.title, a.category_slug, s.riv_usd
158 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id
159 + WHERE a.title ILIKE ${q + '%'} OR a.title ILIKE ${'% ' + q + '%'} ${tsq ? sql`OR a.search @@ to_tsquery('simple', ${tsq})` : sql``}
160 + ORDER BY coalesce(s.sales_count, 0) DESC, similarity(a.title, ${q}) DESC
161 + LIMIT ${limit}
162 + `)) as unknown as Array<{ slug: string; title: string; category_slug: string; riv_usd: number | null }>;
163 + for (const r of rows) {
164 + out.push({ type: 'asset', label: r.title, sublabel: getCategory(r.category_slug)?.name ?? r.category_slug, href: `/asset/${r.slug}` });
165 + }
166 + const sets = (await db.execute(sql`SELECT slug, name, category_slug FROM sets WHERE name ILIKE ${q + '%'} ORDER BY name LIMIT 3`)) as unknown as Array<{ slug: string; name: string; category_slug: string }>;
167 + for (const s of sets) out.push({ type: 'set', label: s.name, sublabel: `Set · ${getCategory(s.category_slug)?.name ?? s.category_slug}`, href: `/set/${s.slug}` });
168 + return out.slice(0, limit + 3);
169 +}
added packages/search/src/parse.test.ts +40 −0
@@ -0,0 +1,40 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { parseQuery, toTsQuery } from './parse.js';
3 +
4 +describe('parseQuery', () => {
5 + it('extracts grade, year and category', () => {
6 + const q = parseQuery('1999 Charizard PSA 10');
7 + expect(q.grader).toBe('psa');
8 + expect(q.grade).toBe('10');
9 + expect(q.year).toBe(1999);
10 + expect(q.text).toContain('Charizard');
11 + expect(q.text.toLowerCase()).not.toContain('psa');
12 + });
13 + it('extracts price bounds', () => {
14 + expect(parseQuery('Rolex Daytona under $20,000')).toMatchObject({ priceMax: 20000, currency: 'USD', categorySlugs: ['rolex'] });
15 + expect(parseQuery('pokemon cards between $500 and $1,000')).toMatchObject({ priceMin: 500, priceMax: 1000 });
16 + expect(parseQuery('lego over 2k')).toMatchObject({ priceMin: 2000 });
17 + expect(parseQuery('Rolex Daytona under $20,000').text.toLowerCase()).not.toContain('under');
18 + });
19 + it('detects conditions and editions', () => {
20 + const q = parseQuery('sealed Mario 64');
21 + expect(q.conditions).toContain('sealed');
22 + expect(q.categorySlugs).toContain('nintendo_games');
23 + const h = parseQuery('first edition Harry Potter UK');
24 + expect(h.editions).toContain('1st edition');
25 + expect(h.categorySlugs).toContain('harry_potter');
26 + expect(parseQuery('Michael Jordan rookie PSA 10').editions).toContain('rookie');
27 + expect(parseQuery('LEGO Millennium Falcon retired').categorySlugs).toContain('lego');
28 + });
29 + it('does not invent filters', () => {
30 + const q = parseQuery('Charizard');
31 + expect(q.grader).toBeNull();
32 + expect(q.priceMax).toBeNull();
33 + expect(q.year).toBeNull();
34 + });
35 + it('builds tsquery', () => {
36 + expect(toTsQuery('Charizard 4/102')).toBe('charizard:* & 4/102:*');
37 + expect(toTsQuery('')).toBeNull();
38 + expect(toTsQuery("o'neal")).toBe('oneal:*');
39 + });
40 +});
added packages/search/src/parse.ts +192 −0
@@ -0,0 +1,192 @@
1 +import { CATEGORIES, GRADERS, getCategory, parseGradeFromTitle } from '@rareindex/taxonomy';
2 +
3 +/**
4 + * Natural-language query parsing (§127). Extracts structured filters from free text such as
5 + * "1999 Charizard PSA 10", "Rolex Daytona under $20,000", "sealed Mario 64", "first edition Harry Potter UK".
6 + * Anything not recognised stays in `text` for lexical/semantic matching. Nothing is guessed:
7 + * a filter is only set when the token pattern is unambiguous.
8 + */
9 +export interface ParsedQuery {
10 + raw: string;
11 + /** residual free text used for FTS / trigram */
12 + text: string;
13 + grader: string | null;
14 + grade: string | null;
15 + year: number | null;
16 + priceMin: number | null;
17 + priceMax: number | null;
18 + currency: string | null;
19 + /** taxonomy slugs whose names appear in the query (family or subcategory) */
20 + categorySlugs: string[];
21 + /** normalised condition/completeness hints: sealed, cib, loose, raw, deadstock… */
22 + conditions: string[];
23 + /** edition hints kept for ranking: 1st edition, shadowless, unlimited, first printing… */
24 + editions: string[];
25 +}
26 +
27 +const CATEGORY_ALIASES: Array<[RegExp, string]> = [
28 + [/\bpok[eé]mon\b/i, 'pokemon'],
29 + [/\b(mtg|magic(?:\s*:\s*| )the gathering|magic cards?)\b/i, 'magic_the_gathering'],
30 + [/\b(yu-?gi-?oh!?|ygo)\b/i, 'yugioh'],
31 + [/\bone piece (?:card|tcg)/i, 'one_piece_card_game'],
32 + [/\blorcana\b/i, 'disney_lorcana'],
33 + [/\b(sports? cards?)\b/i, 'sports_cards'],
34 + [/\b(baseball cards?|topps|bowman)\b/i, 'baseball_cards'],
35 + [/\b(basketball cards?|prizm|nba cards?)\b/i, 'basketball_cards'],
36 + [/\b(hockey cards?|nhl cards?|o-pee-chee)\b/i, 'hockey_cards'],
37 + [/\b(football cards?|nfl cards?)\b/i, 'football_cards'],
38 + [/\b(soccer cards?)\b/i, 'soccer_cards'],
39 + [/\bcomics?\b/i, 'comics'],
40 + [/\bmanga\b/i, 'manga'],
41 + [/\b(video ?games?|nes|snes|n64|nintendo(?: 64)?|game ?boy|gamecube|famicom|switch game|mario|zelda|metroid|kirby|earthbound)\b/i, 'nintendo_games'],
42 + [/\b(sega|genesis|dreamcast|saturn|mega drive)\b/i, 'sega_games'],
43 + [/\b(playstation|ps[1-5]|psp|vita)\b/i, 'playstation_games'],
44 + [/\bxbox\b/i, 'xbox_games'],
45 + [/\b(sneakers?|jordan|air jordan|nike|dunk|yeezy|new balance)\b/i, 'sneakers'],
46 + [/\b(watch(?:es)?|rolex|patek|audemars|omega|tudor|cartier|daytona|submariner|nautilus|royal oak|speedmaster)\b/i, 'watches'],
47 + [/\blego\b/i, 'lego'],
48 + [/\bminifig(?:ure)?s?\b/i, 'lego_minifigures'],
49 + [/\bfunko|pop! ?vinyl\b/i, 'funko'],
50 + [/\b(bearbrick|be@rbrick|kaws|labubu|pop mart|designer toys?)\b/i, 'designer_toys'],
51 + [/\b(coins?|morgan dollar|double eagle|pcgs|ngc)\b/i, 'coins'],
52 + [/\b(handbags?|birkin|kelly bag|chanel flap|hermès|hermes)\b/i, 'luxury_handbags'],
53 + [/\b(wine|bordeaux|burgundy|champagne)\b/i, 'wine'],
54 + [/\b(whisky|whiskey|scotch|bourbon|macallan|yamazaki)\b/i, 'whisky'],
55 + [/\b(vinyl|records?|lp|pressing)\b/i, 'music'],
56 + [/\b(first edition|1st edition) (?:book|hardcover|harry potter|tolkien)/i, 'books'],
57 + [/\bharry potter\b/i, 'harry_potter'],
58 + [/\b(art|print|lithograph|screenprint|banksy|murakami|basquiat|haring)\b/i, 'art'],
59 + [/\b(camera|leica|hasselblad|rolleiflex)\b/i, 'cameras'],
60 + [/\b(apple[- ]1|macintosh|iphone|ipod|apple collectibles?)\b/i, 'apple_collectibles'],
61 + [/\bstamps?\b/i, 'stamps'],
62 + [/\bbanknotes?\b/i, 'banknotes'],
63 + [/\bfossils?|trilobite|ammonite|dinosaur\b/i, 'fossils'],
64 + [/\bmeteorites?\b/i, 'meteorites'],
65 + [/\bmilitaria|medals?\b/i, 'militaria'],
66 + [/\bpinball|arcade cabinet\b/i, 'arcade_pinball'],
67 + [/\bhot wheels|matchbox|diecast|model cars?\b/i, 'model_cars'],
68 + [/\b(action figures?|hot toys|figuarts|nendoroid)\b/i, 'action_figures'],
69 + [/\b(vintage toys?|kenner|he-man|transformers|g\.?i\.? joe)\b/i, 'vintage_toys'],
70 +];
71 +
72 +const CONDITION_TERMS: Array<[RegExp, string]> = [
73 + [/\bsealed\b|\bfactory sealed\b|\bnew in box\b|\bmisb\b|\bnisb\b/i, 'sealed'],
74 + [/\bcib\b|\bcomplete in box\b/i, 'cib'],
75 + [/\bloose\b|\bcart only\b/i, 'loose'],
76 + [/\braw\b|\bungraded\b/i, 'raw'],
77 + [/\bdeadstock\b|\bds\b/i, 'deadstock'],
78 + [/\bunworn\b/i, 'unworn'],
79 + [/\bfull set\b|\bbox and papers\b|\bbox & papers\b/i, 'full_set'],
80 + [/\bnear mint\b|\bnm\b/i, 'near_mint'],
81 +];
82 +
83 +const EDITION_TERMS: Array<[RegExp, string]> = [
84 + [/\b(1st|first) edition\b/i, '1st edition'],
85 + [/\bshadowless\b/i, 'shadowless'],
86 + [/\bunlimited\b/i, 'unlimited'],
87 + [/\b(1st|first) printing\b/i, 'first printing'],
88 + [/\bholo(?:graphic)?\b/i, 'holo'],
89 + [/\breverse holo\b/i, 'reverse holo'],
90 + [/\balt(?:ernate)? art\b/i, 'alternate art'],
91 + [/\brookie\b|\brc\b/i, 'rookie'],
92 + [/\bauto(?:graph)?\b/i, 'autograph'],
93 + [/\bprototype\b/i, 'prototype'],
94 + [/\bretired\b/i, 'retired'],
95 + [/\bjapanese\b|\bjpn\b/i, 'japanese'],
96 + [/\buk\b|\bbloomsbury\b/i, 'uk'],
97 +];
98 +
99 +const MONEY = String.raw`\$?\s*(\d[\d,]*(?:\.\d+)?)\s*([kKmM])?\s*(usd|cad|eur|gbp|jpy|\$|€|£)?`;
100 +
101 +function toNumber(num: string, suffix?: string): number {
102 + let n = Number(num.replace(/,/g, ''));
103 + if (suffix?.toLowerCase() === 'k') n *= 1_000;
104 + if (suffix?.toLowerCase() === 'm') n *= 1_000_000;
105 + return n;
106 +}
107 +
108 +function currencyOf(sym?: string): string | null {
109 + if (!sym) return null;
110 + const s = sym.toLowerCase();
111 + if (s === '$' || s === 'usd') return 'USD';
112 + if (s === '€' || s === 'eur') return 'EUR';
113 + if (s === '£' || s === 'gbp') return 'GBP';
114 + return s.toUpperCase();
115 +}
116 +
117 +export function parseQuery(raw: string): ParsedQuery {
118 + let text = raw.replace(/\s+/g, ' ').trim();
119 + const out: ParsedQuery = { raw, text, grader: null, grade: null, year: null, priceMin: null, priceMax: null, currency: null, categorySlugs: [], conditions: [], editions: [] };
120 + if (!text) return out;
121 +
122 + // Price bounds: "under $5,000", "below 2k", "over $100", "between $500 and $1,000", "$100-$500"
123 + const between = text.match(new RegExp(String.raw`\b(?:between|from)\s+${MONEY}\s+(?:and|to|-)\s+${MONEY}`, 'i'));
124 + if (between) {
125 + out.priceMin = toNumber(between[1]!, between[2]);
126 + out.priceMax = toNumber(between[4]!, between[5]);
127 + out.currency = currencyOf(between[3] ?? between[6]) ?? (/\$/.test(between[0]) ? 'USD' : null);
128 + text = text.replace(between[0], ' ');
129 + } else {
130 + const range = text.match(new RegExp(String.raw`${MONEY}\s*(?:-|–|to)\s*${MONEY}`, 'i'));
131 + if (range && /\$|usd|eur|gbp|£|€|k\b/i.test(range[0])) {
132 + out.priceMin = toNumber(range[1]!, range[2]);
133 + out.priceMax = toNumber(range[4]!, range[5]);
134 + out.currency = currencyOf(range[3] ?? range[6]) ?? (/\$/.test(range[0]) ? 'USD' : null);
135 + text = text.replace(range[0], ' ');
136 + } else {
137 + const under = text.match(new RegExp(String.raw`\b(under|below|less than|up to|max(?:imum)?|<)\s+${MONEY}`, 'i'));
138 + if (under) {
139 + out.priceMax = toNumber(under[2]!, under[3]);
140 + out.currency = currencyOf(under[4]) ?? (/\$/.test(under[0]) ? 'USD' : null);
141 + text = text.replace(under[0], ' ');
142 + }
143 + const over = text.match(new RegExp(String.raw`\b(over|above|more than|at least|min(?:imum)?|>)\s+${MONEY}`, 'i'));
144 + if (over) {
145 + out.priceMin = toNumber(over[2]!, over[3]);
146 + out.currency = out.currency ?? currencyOf(over[4]) ?? (/\$/.test(over[0]) ? 'USD' : null);
147 + text = text.replace(over[0], ' ');
148 + }
149 + }
150 + }
151 +
152 + // Grade
153 + const g = parseGradeFromTitle(text);
154 + if (g.grader && (g.grade || g.grader === 'raw')) {
155 + out.grader = g.grader;
156 + out.grade = g.grade;
157 + const graderNames = GRADERS.flatMap((x) => [x.slug, ...x.aliases]).filter((a) => a.length >= 3).join('|');
158 + text = text.replace(new RegExp(String.raw`\b(${graderNames})\b[\s:-]*(?:gem\s*mt|gem\s*mint|mint|black\s*label|pristine)?[\s:-]*${g.grade ? g.grade.replace('.', '\\.') : ''}`, 'i'), ' ');
159 + }
160 +
161 + // Year (kept in text too — it is a strong lexical signal)
162 + const y = text.match(/\b(18\d{2}|19\d{2}|20[0-4]\d)\b/);
163 + if (y) out.year = Number(y[1]);
164 +
165 + for (const [re, slug] of CONDITION_TERMS) if (re.test(text)) out.conditions.push(slug);
166 + for (const [re, ed] of EDITION_TERMS) if (re.test(text)) out.editions.push(ed);
167 +
168 + const cats = new Set<string>();
169 + for (const [re, slug] of CATEGORY_ALIASES) if (re.test(text)) cats.add(slug);
170 + // exact taxonomy names ("Yu-Gi-Oh!", "Designer Toys")
171 + for (const c of CATEGORIES) {
172 + if (c.name.length >= 4 && new RegExp(`\\b${c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(text)) cats.add(c.slug);
173 + }
174 + // keep the most specific nodes only (drop ancestors of other matches)
175 + out.categorySlugs = [...cats].filter((slug) => ![...cats].some((other) => other !== slug && getCategory(other)?.familySlug === slug && getCategory(other)?.level! > 0));
176 +
177 + out.text = text.replace(/\s+/g, ' ').trim();
178 + return out;
179 +}
180 +
181 +/** Build a websearch-style tsquery string: every token prefix-matched, safe for to_tsquery. */
182 +export function toTsQuery(text: string): string | null {
183 + const toks = text
184 + .toLowerCase()
185 + .replace(/['’]/g, '')
186 + .replace(/[^a-z0-9#/.\s-]+/g, ' ')
187 + .split(/\s+/)
188 + .map((t) => t.replace(/^[-#/.]+|[-#/.]+$/g, ''))
189 + .filter((t) => t.length > 0);
190 + if (!toks.length) return null;
191 + return toks.map((t) => `${t.replace(/[':&|!()<>]/g, '')}:*`).filter((t) => t !== ':*').join(' & ') || null;
192 +}
added packages/search/tsconfig.json +9 −0
@@ -0,0 +1,9 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": {
4 + "rootDir": "src",
5 + "outDir": "dist",
6 + "noEmit": true
7 + },
8 + "include": ["src"]
9 +}
added packages/search/vitest.config.ts +5 −0
@@ -0,0 +1,5 @@
1 +import { defineConfig } from 'vitest/config';
2 +
3 +export default defineConfig({
4 + test: { include: ['src/**/*.test.ts'] },
5 +});
modified pnpm-lock.yaml +31 −0
@@ -45,6 +45,9 @@ importers:
45 45 '@rareindex/database':
46 46 specifier: workspace:*
47 47 version: link:../../packages/database
48 + '@rareindex/search':
49 + specifier: workspace:*
50 + version: link:../../packages/search
48 51 '@rareindex/shared':
49 52 specifier: workspace:*
50 53 version: link:../../packages/shared
@@ -54,6 +57,9 @@ importers:
54 57 '@tanstack/react-query':
55 58 specifier: ^5.90.0
56 59 version: 5.102.8(react@19.2.8)
60 + drizzle-orm:
61 + specifier: ^0.45.0
62 + version: 0.45.2(postgres@3.4.9)
57 63 lucide-react:
58 64 specifier: ^1.0.0
59 65 version: 1.41.0(react@19.2.8)
@@ -182,6 +188,31 @@ importers:
182 188 specifier: ^3.2.0
183 189 version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
184 190
191 + packages/search:
192 + dependencies:
193 + '@rareindex/database':
194 + specifier: workspace:*
195 + version: link:../database
196 + '@rareindex/shared':
197 + specifier: workspace:*
198 + version: link:../shared
199 + '@rareindex/taxonomy':
200 + specifier: workspace:*
201 + version: link:../taxonomy
202 + drizzle-orm:
203 + specifier: ^0.45.0
204 + version: 0.45.2(postgres@3.4.9)
205 + devDependencies:
206 + '@types/node':
207 + specifier: ^24.0.0
208 + version: 24.13.3
209 + typescript:
210 + specifier: ^5.9.3
211 + version: 5.9.3
212 + vitest:
213 + specifier: ^3.2.0
214 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
215 +
185 216 packages/shared:
186 217 dependencies:
187 218 pino:
188 219