SPB Git

spb/rareindex

Public

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 (Sep 7, 2026) parent ea481cc Browse files

76 changed files +7,445 −25

76 changed files
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
Large diff (285 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/markets/page.tsx +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/news/page.tsx +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/page.tsx +129 −4
Large diff (135 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/price-history/page.tsx +127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/radar/page.tsx +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/rareindex/[ticker]/page.tsx +214 −0
Large diff (214 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/rareindex/page.tsx +144 −0
Large diff (144 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/records/page.tsx +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/robots.ts +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sales/page.tsx +57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/search/page.tsx +174 −0
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/set/[slug]/page.tsx +59 −0
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sitemap-index.xml/route.ts +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sitemap.ts +45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/trending/page.tsx +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/asset/asset-header.tsx +117 −0
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/asset/asset-tabs.tsx +479 −0
Large diff (479 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/bar-chart.tsx +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/heat.ts +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/line-chart.tsx +263 −0
Large diff (263 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/scale.ts +67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/sparkline.tsx +19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/charts/treemap.tsx +139 −0
Large diff (139 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/hero.tsx +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/sections.tsx +185 −0
Large diff (185 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/header-search.tsx +89 −5
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/mobile-nav.tsx +6 −1
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/theme-toggle.tsx +22 −12
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/asset-list.tsx +192 −0
Large diff (192 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/bits.tsx +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/index-strip.tsx +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/market-table.tsx +70 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/market/sales-table.tsx +193 −0
Large diff (193 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/evidence.tsx +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/filter-bar.tsx +82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/page-header.tsx +46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/pagination.tsx +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/tabs.tsx +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/queries/_util.ts +57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/queries/assets.ts +646 −0
Large diff (646 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/queries/indices.ts +253 −0
Large diff (253 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/queries/market-lists.ts +303 −0
Large diff (303 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/queries/markets.ts +166 −0
Large diff (166 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/queries/site.ts +86 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/search-params.ts +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/taxonomy.ts +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added docs/METHODOLOGY.md +61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added packages/search/package.json +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added packages/search/src/index.ts +169 −0
Large diff (169 lines) is not loaded by default to keep the page fast.Load diff
added packages/search/src/parse.test.ts +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added packages/search/src/parse.ts +192 −0
Large diff (192 lines) is not loaded by default to keep the page fast.Load diff
added packages/search/tsconfig.json +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added packages/search/vitest.config.ts +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
modified pnpm-lock.yaml +31 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme