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%

Frontend: design system, mobile shell + tab bar, search sheet, home tiles, touch charts, asset page, compare, account polish (agents P, Q)

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

69 changed files +3,073 −893

added apps/web/public/apple-touch-icon.png +0 −0

Binary file not shown.

added apps/web/public/icon-192.png +0 −0

Binary file not shown.

added apps/web/public/icon-512.png +0 −0

Binary file not shown.

added apps/web/public/icon-maskable-512.png +0 −0

Binary file not shown.

added apps/web/public/icon.svg +1 −0
@@ -0,0 +1 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#0a0a0b"/><path d="M20 46V18h13.5c6.4 0 10.5 3.6 10.5 9.2 0 4.2-2.3 7.2-6.1 8.4L45 46h-7.3l-6.2-9.4H26.4V46H20zm6.4-14.6h6.5c3.2 0 5.1-1.6 5.1-4.2s-1.9-4.1-5.1-4.1h-6.5v8.3z" fill="#ffffff"/><path d="M12 52l9-9 6 5 11-12 8 7" stroke="#3ccb82" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
modified apps/web/src/app/(account)/layout.tsx +2 −2
@@ -12,8 +12,8 @@ export default async function AccountLayout({ children }: { children: ReactNode
12 12 const rows = await db().select({ n: count() }).from(notifications).where(and(eq(notifications.userId, user.id), isNull(notifications.readAt)));
13 13 const n = rows[0]?.n ?? 0;
14 14 return (
15 − <div className="grid gap-6 lg:grid-cols-[200px_minmax(0,1fr)]">
16 − <aside className="lg:sticky lg:top-[72px] lg:self-start">
15 + <div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[200px_minmax(0,1fr)]">
16 + <aside className="min-w-0 lg:sticky lg:top-[calc(var(--ri-header-h,52px)+16px)] lg:self-start">
17 17 <div className="mb-3 hidden items-center gap-2 px-2 lg:flex">
18 18 <span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-accent-fg">{(user.name ?? user.email).slice(0, 1).toUpperCase()}</span>
19 19 <div className="min-w-0">
modified apps/web/src/app/about/page.tsx +1 −1
@@ -8,7 +8,7 @@ export const metadata: Metadata = { title: 'About', description: 'RareIndex is t
8 8 export default function AboutPage() {
9 9 return (
10 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." />
11 + <PageHeader kicker="Company" 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 12 <Card className="p-6" as="article">
13 13 <h2 className="text-base font-semibold">Mission</h2>
14 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>
modified apps/web/src/app/api-docs/page.tsx +1 −1
@@ -29,7 +29,7 @@ const TIERS = [
29 29 export default function ApiDocsPage() {
30 30 return (
31 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." />
32 + <PageHeader kicker="Developers" 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 33 <Card className="p-5">
34 34 <h2 className="text-sm font-semibold">Base URL & authentication</h2>
35 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
modified apps/web/src/app/asset/[slug]/page.tsx +29 −21
@@ -5,7 +5,8 @@ import { Tabs } from '@/components/ui/tabs';
5 5 import { Skeleton } from '@/components/ui/primitives';
6 6 import { AssetHeader } from '@/components/asset/asset-header';
7 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, getLatestGuidePrice } from '@/lib/queries/assets';
8 +import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts, getLatestGuidePrice, getAssetImages, isWatchedBy } from '@/lib/queries/assets';
9 +import { getCurrentUser } from '@/lib/auth/session';
9 10 import { catName, categoryPath } from '@/lib/taxonomy';
10 11 import { sp1, spEnum, spInt, type SP } from '@/lib/search-params';
11 12 import { fmtMoney } from '@/lib/format';
@@ -36,7 +37,8 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
36 37 const found = await getAssetBySlug(slug);
37 38 if (!found) notFound();
38 39 const tab = spEnum<AssetTab>(sp, 'tab', ASSET_TABS, 'overview');
39 − const [variants, live] = await Promise.all([getAssetVariants(found.id), getAssetLiveCounts(found.id)]);
40 + const [variants, live, imgs, user] = await Promise.all([getAssetVariants(found.id), getAssetLiveCounts(found.id), getAssetImages(found.id, 12), getCurrentUser().catch(() => null)]);
41 + const watched = await isWatchedBy(user?.id ?? null, found.id);
40 42 // asset_stats is rebuilt by the valuation worker; until then fall back to live counts from canonical tables.
41 43 const asset = {
42 44 ...found,
@@ -54,13 +56,17 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
54 56 const href = (t: AssetTab, v: string | null = variant?.id ?? null) => `/asset/${asset.slug}?tab=${t}${v ? `&v=${v}` : ''}`;
55 57 const [listingsForLd, guide] = await Promise.all([getAssetListings(asset.id, { limit: 20 }), (variant ? variant.rivUsd : asset.rivUsd) === null ? getLatestGuidePrice(asset.id, variant?.id ?? null) : Promise.resolve(null)]);
56 58 const crumbs = categoryPath(asset.categorySlug);
59 + const seen = new Set<string>();
60 + const images = [asset.heroImageUrl ? { url: asset.heroImageUrl, caption: null } : null, ...imgs.map((im) => ({ url: im.url, caption: im.attribution ?? im.sourceId ?? null }))]
61 + .filter((im): im is { url: string; caption: string | null } => Boolean(im))
62 + .filter((im) => (seen.has(im.url) ? false : (seen.add(im.url), true)));
57 63
58 64 const jsonLd = [
59 65 {
60 66 '@context': 'https://schema.org',
61 67 '@type': 'Product',
62 68 name: asset.title,
63 − image: asset.heroImageUrl ?? undefined,
69 + image: images.slice(0, 5).map((i) => i.url),
64 70 description: asset.description ?? `${asset.title} — ${catName(asset.categorySlug)} collectible tracked by RareIndex.`,
65 71 brand: asset.brand ? { '@type': 'Brand', name: asset.brand } : undefined,
66 72 category: crumbs.map((c) => c.name).join(' > '),
@@ -88,25 +94,27 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
88 94 ];
89 95
90 96 return (
91 − <div>
97 + <div className="pb-16 md:pb-0">
92 98 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
93 − <AssetHeader asset={asset} variants={variants} activeVariant={variant} tabHref={(v) => href(tab, v)} guide={guide} />
94 − <Tabs
95 − className="mt-6 mb-4"
96 − active={tab}
97 − tabs={[
98 − { id: 'overview', label: 'Overview', href: href('overview') },
99 − { id: 'sales', label: 'Sales', count: variant ? variant.salesCount : asset.salesCount, href: href('sales') },
100 − { id: 'listings', label: 'Listings', count: variant ? variant.activeListings : asset.activeListings, href: href('listings') },
101 − { id: 'grades', label: 'Grades', count: variants.filter((v) => v.grader && v.grader !== 'raw').length || null, href: href('grades') },
102 − { id: 'population', label: 'Population', href: href('population') },
103 − { id: 'images', label: 'Images', href: href('images') },
104 − { id: 'history', label: 'History', href: href('history') },
105 − { id: 'comparables', label: 'Comparables', href: href('comparables') },
106 − { id: 'analysis', label: 'AI Analysis', href: href('analysis') },
107 − { id: 'sources', label: 'Sources', count: asset.sourcesCount || null, href: href('sources') },
108 − ]}
109 − />
99 + <AssetHeader asset={asset} variants={variants} activeVariant={variant} tabHref={(v) => href(tab, v)} guide={guide} images={images} watched={watched} />
100 + <div className="sticky top-[var(--ri-header-h,56px)] z-30 -mx-4 bg-bg/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-bg/80 sm:static sm:mx-0 sm:bg-transparent sm:px-0 sm:backdrop-blur-none">
101 + <Tabs
102 + className="mt-6 mb-4"
103 + active={tab}
104 + tabs={[
105 + { id: 'overview', label: 'Overview', href: href('overview') },
106 + { id: 'sales', label: 'Sales', count: variant ? variant.salesCount : asset.salesCount, href: href('sales') },
107 + { id: 'listings', label: 'Listings', count: variant ? variant.activeListings : asset.activeListings, href: href('listings') },
108 + { id: 'grades', label: 'Grades', count: variants.filter((v) => v.grader && v.grader !== 'raw').length || null, href: href('grades') },
109 + { id: 'population', label: 'Population', href: href('population') },
110 + { id: 'images', label: 'Images', count: images.length || null, href: href('images') },
111 + { id: 'history', label: 'History', href: href('history') },
112 + { id: 'comparables', label: 'Comparables', href: href('comparables') },
113 + { id: 'analysis', label: 'AI Analysis', href: href('analysis') },
114 + { id: 'sources', label: 'Sources', count: asset.sourcesCount || null, href: href('sources') },
115 + ]}
116 + />
117 + </div>
110 118 <Suspense key={`${tab}-${variant?.id ?? ''}-${page}`} fallback={<Skeleton className="h-96" />}>
111 119 {tab === 'overview' ? <OverviewTab asset={asset} variant={variant} /> : null}
112 120 {tab === 'sales' ? <SalesTab asset={asset} variant={variant} page={page} /> : null}
modified apps/web/src/app/auctions/calendar/page.tsx +1 −1
@@ -28,7 +28,7 @@ export default async function CalendarPage({ searchParams }: { searchParams: Pro
28 28 const params = { category: category ?? undefined, house: house ?? undefined };
29 29 return (
30 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 />
31 + <PageHeader kicker="Auction houses" crumbs={[{ label: 'Auctions', href: '/auctions' }, { label: 'Calendar' }]} title="Auction calendar" description="Auctions closing in the selected window, grouped by day (UTC)." compact />
32 32 <div className="mb-4 flex flex-wrap items-end justify-between gap-3">
33 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 34 <Suspense>
modified apps/web/src/app/auctions/page.tsx +1 −1
@@ -22,7 +22,7 @@ export default async function AuctionsPage({ searchParams }: { searchParams: Pro
22 22 ]);
23 23 return (
24 24 <div>
25 − <PageHeader
25 + <PageHeader kicker="Auction houses"
26 26 title="Auctions"
27 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 28 compact
modified apps/web/src/app/categories/page.tsx +1 −1
@@ -19,7 +19,7 @@ export default async function CategoriesPage() {
19 19 const groups = [1, 2, 3].map((phase) => ({ phase, families: FAMILIES.filter((f) => f.phase === phase) }));
20 20 return (
21 21 <div>
22 − <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.`} />
22 + <PageHeader kicker="Taxonomy" 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.`} />
23 23 {groups.map((g) => (
24 24 <section key={g.phase} className="mb-8">
25 25 <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
modified apps/web/src/app/compare/compare-form.tsx +75 −28
@@ -1,7 +1,8 @@
1 1 'use client';
2 2
3 −import { useRouter } from 'next/navigation';
3 +import { useRouter, useSearchParams } from 'next/navigation';
4 4 import { useEffect, useState } from 'react';
5 +import { Check, Link2, Search, X } from 'lucide-react';
5 6
6 7 interface Suggestion {
7 8 type: string;
@@ -15,6 +16,7 @@ const SLOTS = ['a', 'b', 'c', 'd'] as const;
15 16 /** Four slot pickers with suggestions; values are asset slugs or index tickers, stored in the URL. */
16 17 export function CompareForm({ initial }: { initial: string[] }) {
17 18 const router = useRouter();
19 + const params = useSearchParams();
18 20 const [values, setValues] = useState<string[]>([...initial, '', '', '', ''].slice(0, 4));
19 21 const [typing, setTyping] = useState<{ i: number; q: string } | null>(null);
20 22 const [items, setItems] = useState<Suggestion[]>([]);
@@ -25,7 +27,7 @@ export function CompareForm({ initial }: { initial: string[] }) {
25 27 const t = setTimeout(() => {
26 28 fetch(`/api/search/suggest?q=${encodeURIComponent(typing.q)}`, { signal: ctrl.signal })
27 29 .then((r) => r.json())
28 − .then((d: { items: Suggestion[] }) => setItems((d.items ?? []).filter((x) => x.type === 'asset' || x.type === 'index')))
30 + .then((d: { items: Suggestion[] }) => setItems((d.items ?? []).filter((x) => x.type === 'asset' || x.type === 'index').slice(0, 8)))
29 31 .catch(() => {});
30 32 }, 150);
31 33 return () => {
@@ -37,42 +39,63 @@ export function CompareForm({ initial }: { initial: string[] }) {
37 39 const apply = (vals: string[]) => {
38 40 const sp = new URLSearchParams();
39 41 vals.forEach((v, i) => {
40 − if (v) sp.set(SLOTS[i]!, v);
42 + if (v.trim()) sp.set(SLOTS[i]!, v.trim());
41 43 });
44 + const w = params?.get('w');
45 + if (w) sp.set('w', w);
42 46 router.push(`/compare?${sp.toString()}`);
43 47 };
44 48
45 49 return (
46 50 <form
47 − className="card grid gap-2 p-3 sm:grid-cols-4"
51 + className="card grid gap-2 p-3 sm:grid-cols-2 lg:grid-cols-4"
48 52 onSubmit={(e) => {
49 53 e.preventDefault();
50 54 apply(values);
51 55 }}
52 56 >
53 57 {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">
58 + <label key={s} className="relative flex flex-col gap-1 text-[10px] font-medium uppercase tracking-wider text-subtle">
55 59 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 − />
60 + <span className="relative">
61 + <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-subtle" />
62 + <input
63 + value={values[i] ?? ''}
64 + onChange={(e) => {
65 + const v = [...values];
66 + v[i] = e.target.value;
67 + setValues(v);
68 + setTyping({ i, q: e.target.value });
69 + if (e.target.value.trim().length < 2) setItems([]);
70 + }}
71 + onBlur={() =>
72 + setTimeout(() => {
73 + setTyping(null);
74 + setItems([]);
75 + }, 150)
76 + }
77 + placeholder="Search an asset or RARE-TCG"
78 + autoComplete="off"
79 + className="h-10 w-full rounded-md border border-border bg-elevated pl-8 pr-8 text-base normal-case tracking-normal text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none md:h-9 md:text-[13px]"
80 + />
81 + {values[i] ? (
82 + <button
83 + type="button"
84 + aria-label={`Clear slot ${s.toUpperCase()}`}
85 + onClick={() => {
86 + const v = [...values];
87 + v[i] = '';
88 + setValues(v);
89 + apply(v);
90 + }}
91 + className="absolute right-1.5 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-subtle hover:bg-inset hover:text-fg"
92 + >
93 + <X className="h-3.5 w-3.5" />
94 + </button>
95 + ) : null}
96 + </span>
74 97 {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">
98 + <ul className="absolute left-0 top-full z-20 mt-1 w-full overflow-hidden rounded-md border border-border bg-elevated text-[13px] normal-case tracking-normal shadow-pop">
76 99 {items.map((it) => {
77 100 const val = it.href.startsWith('/asset/') ? it.href.slice(7) : it.href.startsWith('/rareindex/') ? it.href.slice(11) : it.label;
78 101 return (
@@ -87,7 +110,7 @@ export function CompareForm({ initial }: { initial: string[] }) {
87 110 setTyping(null);
88 111 apply(v);
89 112 }}
90 − className="block w-full truncate px-2 py-1.5 text-left text-fg hover:bg-sunken"
113 + className="block w-full truncate px-2.5 py-2 text-left text-fg hover:bg-sunken"
91 114 >
92 115 {it.label} <span className="text-subtle">· {it.sublabel}</span>
93 116 </button>
@@ -98,12 +121,36 @@ export function CompareForm({ initial }: { initial: string[] }) {
98 121 ) : null}
99 122 </label>
100 123 ))}
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">
124 + <div className="flex items-center justify-between gap-3 text-[11px] text-subtle sm:col-span-2 lg:col-span-4">
125 + <span className="hidden sm:inline">Tickers: RARE, RARE-TCG, RARE-SPORT, RARE-WATCH, RARE-SNEAKER, RARE-LEGO, RARE-COMIC, RARE-GAME…</span>
126 + <button type="submit" className="inline-flex h-10 items-center rounded-md bg-accent px-4 text-xs font-medium text-accent-fg md:h-9">
104 127 Compare
105 128 </button>
106 129 </div>
107 130 </form>
108 131 );
109 132 }
133 +
134 +export function ShareButton() {
135 + const [done, setDone] = useState(false);
136 + return (
137 + <button
138 + type="button"
139 + onClick={async () => {
140 + const url = window.location.href;
141 + try {
142 + if (navigator.share) await navigator.share({ title: 'RareIndex — Compare', url });
143 + else await navigator.clipboard.writeText(url);
144 + setDone(true);
145 + setTimeout(() => setDone(false), 1800);
146 + } catch {
147 + /* cancelled */
148 + }
149 + }}
150 + className="inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-elevated px-3 text-[12px] font-medium text-fg hover:bg-inset"
151 + >
152 + {done ? <Check className="h-3.5 w-3.5 text-gain" /> : <Link2 className="h-3.5 w-3.5" />}
153 + {done ? 'Link copied' : 'Share'}
154 + </button>
155 + );
156 +}
modified apps/web/src/app/compare/page.tsx +150 −53
@@ -1,88 +1,159 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import { PageHeader } from '@/components/ui/page-header';
4 −import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum } from '@/components/ui/primitives';
4 +import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
5 5 import { LineChart } from '@/components/charts/line-chart';
6 6 import { getAssetsBySlugs, getAssetSnapshots } from '@/lib/queries/assets';
7 7 import { getIndex, getIndexSeries } from '@/lib/queries/indices';
8 8 import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format';
9 9 import { catName } from '@/lib/taxonomy';
10 10 import type { SP } from '@/lib/search-params';
11 −import { CompareForm } from './compare-form';
11 +import { Thumb } from '@/components/market/bits';
12 +import { CompareForm, ShareButton } from './compare-form';
12 13
13 14 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
15 16 const SLOTS = ['a', 'b', 'c', 'd'] as const;
17 +const SERIES = ['var(--ri-series-1, #2f5bd6)', 'var(--ri-series-2, #c2410c)', 'var(--ri-series-3, #0e9384)', 'var(--ri-series-4, #7c3aed)'];
18 +const WINDOWS: Array<{ id: string; days: number; label: string }> = [
19 + { id: '3m', days: 92, label: '3M' },
20 + { id: '1y', days: 366, label: '1Y' },
21 + { id: '2y', days: 731, label: '2Y' },
22 + { id: '5y', days: 1827, label: '5Y' },
23 +];
24 +
25 +type Column = { key: string; label: string; short: string; href: string; color: string; kind: 'asset' | 'index'; image?: string | null; rows: Array<[string, React.ReactNode]> };
16 26
17 27 export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {
18 28 const sp = await searchParams;
19 29 const keys = SLOTS.map((k) => (Array.isArray(sp[k]) ? sp[k]![0] : sp[k])).filter((v): v is string => Boolean(v));
30 + const win = WINDOWS.find((w) => w.id === (Array.isArray(sp.w) ? sp.w[0] : sp.w)) ?? WINDOWS[2]!;
20 31 const assetSlugs = keys.filter((k) => !k.toUpperCase().startsWith('RARE'));
21 32 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;
33 + const [assets, indicesRaw] = await Promise.all([getAssetsBySlugs(assetSlugs), Promise.all(tickers.map((t) => getIndex(t)))]);
34 + const indices = indicesRaw.filter((i): i is NonNullable<typeof i> => Boolean(i));
35 + // keep the URL order so colours follow the entity, never the rank
36 + const ordered = keys.map((k) => assets.find((a) => a.slug === k) ?? indices.find((i) => i.ticker.toUpperCase() === k.toUpperCase()) ?? null).filter((x): x is NonNullable<typeof x> => Boolean(x));
37 +
38 + const series = await Promise.all(
39 + ordered.map(async (item, i) => {
40 + if ('slug' in item) {
41 + const pts = (await getAssetSnapshots(item.id, '', win.days)).filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! }));
42 + return { id: item.slug, label: item.title.length > 34 ? `${item.title.slice(0, 33)}…` : item.title, color: SERIES[i % 4], points: pts };
43 + }
44 + const pts = (await getIndexSeries(item.id, win.days)).map((p) => ({ x: p.date, y: p.value }));
45 + return { id: item.ticker, label: item.ticker, dashed: true, color: SERIES[i % 4], points: pts };
46 + }),
47 + );
48 +
49 + const columns: Column[] = ordered.map((item, i) => {
50 + if ('slug' in item) {
51 + const a = item;
52 + return {
53 + key: a.slug,
54 + label: a.title,
55 + short: a.title.length > 28 ? `${a.title.slice(0, 27)}…` : a.title,
56 + href: `/asset/${a.slug}`,
57 + color: SERIES[i % 4]!,
58 + kind: 'asset',
59 + image: a.heroImageUrl,
60 + rows: [
61 + ['Category', catName(a.categorySlug)],
62 + ['RIV', a.rivUsd === null ? <span className="text-subtle">—</span> : <span className="font-semibold">{fmtMoney(a.rivUsd)}</span>],
63 + ['Confidence', a.rivSampleSize ? `${confidenceLabel(a.rivConfidence)} · n=${a.rivSampleSize}` : <span className="text-subtle">—</span>],
64 + ['1D', <Delta key="d1" value={a.change1d} />],
65 + ['7D', <Delta key="d7" value={a.change7d} />],
66 + ['30D', <Delta key="d30" value={a.change30d} />],
67 + ['1Y', <Delta key="d1y" value={a.change1y} />],
68 + ['Latest sale', a.latestSaleUsd === null ? <span className="text-subtle">—</span> : `${fmtMoney(a.latestSaleUsd)} · ${fmtRelative(a.latestSaleAt)}`],
69 + ['Sales 30D / all', `${fmtNum(a.sales30d)} / ${fmtNum(a.salesCount)}`],
70 + ['Active listings', fmtNum(a.activeListings)],
71 + ['Liquidity', a.liquidityScore == null ? <span className="text-subtle">—</span> : `${Math.round(a.liquidityScore)}/100`],
72 + ['Rarity', a.rarityScore == null ? <span className="text-subtle">—</span> : `${Math.round(a.rarityScore)}/100`],
73 + ['Trending', a.trendingScore == null ? <span className="text-subtle">—</span> : a.trendingScore.toFixed(1)],
74 + ],
75 + };
76 + }
77 + const ix = item;
78 + return {
79 + key: ix.ticker,
80 + label: ix.name,
81 + short: ix.ticker,
82 + href: `/rareindex/${ix.ticker}`,
83 + color: SERIES[i % 4]!,
84 + kind: 'index',
85 + rows: [
86 + ['Category', 'Index'],
87 + ['Value', ix.latest ? <span className="font-semibold">{ix.latest.value.toFixed(2)}</span> : <Badge>building</Badge>],
88 + ['Coverage', ix.latest?.coverage != null ? `${Math.round(ix.latest.coverage * 100)}%` : <span className="text-subtle">—</span>],
89 + ['1D', <Delta key="d1" value={ix.change1d} />],
90 + ['7D', <Delta key="d7" value={ix.change7d} />],
91 + ['30D', <Delta key="d30" value={ix.change30d} />],
92 + ['1Y', <Delta key="d1y" value={ix.change1y} />],
93 + ['Median sale', ix.latest?.medianSaleUsd != null ? fmtMoney(ix.latest.medianSaleUsd) : <span className="text-subtle">—</span>],
94 + ['Transactions', ix.latest ? fmtNum(ix.latest.transactions) : <span className="text-subtle">—</span>],
95 + ['Constituents', ix.latest ? fmtNum(ix.latest.constituentsCount) : <span className="text-subtle">—</span>],
96 + ['Liquidity', ix.latest?.liquidityScore != null ? `${Math.round(ix.latest.liquidityScore)}/100` : <span className="text-subtle">—</span>],
97 + ['Rarity', <span key="r" className="text-subtle">—</span>],
98 + ['Momentum', ix.latest?.momentum != null ? ix.latest.momentum.toFixed(2) : <span className="text-subtle">—</span>],
99 + ],
100 + };
101 + });
102 +
103 + const qs = (w: string) => {
104 + const p = new URLSearchParams();
105 + keys.forEach((k, i) => p.set(SLOTS[i]!, k));
106 + p.set('w', w);
107 + return `/compare?${p.toString()}`;
108 + };
109 + const metricLabels = columns[0]?.rows.map((r) => r[0]) ?? [];
110 +
28 111 return (
29 112 <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 />
113 + <PageHeader title="Compare" description="Up to four assets or indices. Performance is rebased to 0% at the start of the window using each item's daily RIV or index value; metrics sit side by side with their evidence." compact actions={keys.length ? <ShareButton /> : null} />
31 114 <CompareForm initial={keys} />
32 − {found ? (
115 + {columns.length ? (
33 116 <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" />
117 + <Card className="p-3 sm:p-4">
118 + <div className="flex flex-wrap items-center justify-between gap-2">
119 + <h2 className="text-sm font-semibold">Indexed performance</h2>
120 + <div className="inline-flex rounded-md border border-border bg-sunken p-0.5 text-[11px] font-medium" role="group" aria-label="Window">
121 + {WINDOWS.map((w) => (
122 + <Link key={w.id} href={qs(w.id)} scroll={false} className={cn('num h-7 rounded-[4px] px-2.5 leading-7', w.id === win.id ? 'bg-elevated text-fg shadow-card' : 'text-muted hover:text-fg')} aria-current={w.id === win.id ? 'true' : undefined}>
123 + {w.label}
124 + </Link>
125 + ))}
126 + </div>
127 + </div>
128 + <LineChart ariaLabel="Indexed performance comparison" height={300} indexed className="mt-2" series={series} emptyLabel="No daily history yet for the selected items in this window" />
37 129 </Card>
38 − <Card className="overflow-hidden">
39 − <CardHeader title="Key metrics" />
130 +
131 + {/* desktop: matrix */}
132 + <Card className="hidden overflow-hidden md:block">
133 + <CardHeader title="Key metrics" subtitle="Every estimate carries its evidence; “—” means data unavailable, never zero." />
40 134 <Table>
41 135 <thead>
42 136 <tr>
43 137 <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>
138 + {columns.map((c) => (
139 + <th key={c.key} className={cn(th, 'text-right')}>
140 + <span className="inline-flex items-center gap-1.5">
141 + <span className="inline-block h-2 w-2 rounded-full" style={{ background: c.color }} />
142 + <Link href={c.href} className="hover:underline">
143 + {c.short}
144 + </Link>
145 + </span>
56 146 </th>
57 147 ))}
58 148 </tr>
59 149 </thead>
60 150 <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}
151 + {metricLabels.map((label, ri) => (
152 + <tr key={label} className="hover:bg-sunken">
153 + <td className={cn(td, 'text-muted')}>{label}</td>
154 + {columns.map((c) => (
155 + <td key={c.key} className={tdNum}>
156 + {c.rows[ri]?.[1] ?? <span className="text-subtle">—</span>}
86 157 </td>
87 158 ))}
88 159 </tr>
@@ -90,10 +161,36 @@ export default async function ComparePage({ searchParams }: { searchParams: Prom
90 161 </tbody>
91 162 </Table>
92 163 </Card>
164 +
165 + {/* phone: one card per item, swipeable */}
166 + <div className="md:hidden">
167 + <h2 className="mb-2 text-sm font-semibold">Key metrics</h2>
168 + <div className="scrollbar-none -mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-1">
169 + {columns.map((c) => (
170 + <article key={c.key} className="card w-[86vw] max-w-sm shrink-0 snap-center overflow-hidden">
171 + <header className="flex items-center gap-2.5 border-b border-border px-3 py-2.5" style={{ boxShadow: `inset 3px 0 0 ${c.color}` }}>
172 + {c.image !== undefined ? <Thumb src={c.image} alt="" size={36} rounded="rounded-md" /> : null}
173 + <Link href={c.href} className="min-w-0 flex-1 truncate text-[13px] font-semibold text-fg">
174 + {c.label}
175 + </Link>
176 + </header>
177 + <dl className="divide-y divide-border text-[12px]">
178 + {c.rows.map(([k, v]) => (
179 + <div key={k} className="flex items-center justify-between gap-3 px-3 py-1.5">
180 + <dt className="text-muted">{k}</dt>
181 + <dd className="num text-right text-fg">{v}</dd>
182 + </div>
183 + ))}
184 + </dl>
185 + </article>
186 + ))}
187 + </div>
188 + <p className="mt-1 text-center text-[10px] text-subtle">Swipe to compare</p>
189 + </div>
93 190 </div>
94 191 ) : (
95 192 <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." />
193 + <EmptyState title="Pick items to compare" description="Search an asset name or use index tickers such as RARE, RARE-TCG, RARE-WATCH. Up to four items; the URL is shareable." />
97 194 </Card>
98 195 )}
99 196 </div>
modified apps/web/src/app/data/page.tsx +1 −1
@@ -50,7 +50,7 @@ export default async function DataPage() {
50 50 const active = rows.filter((r) => r.counts.assets > 0);
51 51 return (
52 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." />
53 + <PageHeader kicker="Transparency" title="Data" description="What RareIndex covers today, where it comes from, and how the numbers are computed. Coverage figures are live counts." />
54 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 55 {[
56 56 ['Assets', fmtNum(stats.assets)],
modified apps/web/src/app/explore/page.tsx +45 −40
@@ -47,38 +47,36 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
47 47 const params = pick(sp, KEYS);
48 48 return (
49 49 <div>
50 − <PageHeader title="Explore" description="Every tracked collectible asset — priced or not — sortable by data richness, RareIndex Valuation, momentum, liquidity or rarity. Filters are reflected in the URL." compact />
51 − <div className="mb-2 flex flex-wrap items-center gap-1 text-[11px]">
52 − <span className="mr-1 text-subtle">Show</span>
53 − {HAS_OPTIONS.map((h) => (
54 − <a key={h.id || 'all'} href={withParams('/explore', { ...pick(sp, KEYS), has: h.id || null, page: null })} className={`rounded-full border px-2 py-0.5 ${has === h.id ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:border-border-strong hover:text-fg'}`}>
55 − {h.label}
56 − </a>
57 − ))}
58 − </div>
59 − <div className="mb-3 flex flex-wrap items-end justify-between gap-3">
60 − <Suspense>
61 − <FilterBar
62 − fields={[
63 − { name: 'q', label: 'Search', type: 'text', placeholder: 'title contains…', width: 'w-44' },
64 − { name: 'category', label: 'Category', type: 'select', options: categoryOptions, width: 'w-52' },
65 − { name: 'grader', label: 'Grader', type: 'select', options: GRADERS.filter((g) => g.slug !== 'raw').map((g) => ({ value: g.slug, label: g.name })), width: 'w-32' },
66 − { name: 'grade', label: 'Grade', type: 'text', placeholder: 'e.g. 10', width: 'w-20' },
67 − { name: 'min', label: 'Min RIV $', type: 'number', placeholder: '0', width: 'w-24' },
68 − { name: 'max', label: 'Max RIV $', type: 'number', placeholder: '∞', width: 'w-24' },
69 − { name: 'liq', label: 'Liquidity ≥', type: 'number', placeholder: '0–100', width: 'w-20' },
70 − { name: 'rar', label: 'Rarity ≥', type: 'number', placeholder: '0–100', width: 'w-20' },
71 − { name: 'from', label: 'Year from', type: 'number', placeholder: '1900', width: 'w-20' },
72 − { name: 'to', label: 'Year to', type: 'number', placeholder: '2026', width: 'w-20' },
73 − ]}
74 − />
75 − </Suspense>
76 − <div className="flex items-center gap-2">
77 − <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 }) }]} />
78 − <label className="flex items-center gap-1 text-[11px] text-subtle">
79 − Sort
80 − <SortSelect current={sort} params={params} />
81 − </label>
50 + <PageHeader kicker="Browse" title="Explore" description="Every tracked collectible asset — priced or not — sortable by data richness, RareIndex Valuation, momentum, liquidity or rarity. Filters are reflected in the URL." compact />
51 + <div className="mb-3 flex flex-col gap-2">
52 + <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial">
53 + {HAS_OPTIONS.map((h) => (
54 + <a key={h.id || 'all'} href={withParams('/explore', { ...pick(sp, KEYS), has: h.id || null, page: null })} className={`chip ${has === h.id ? 'chip-active' : ''}`}>
55 + {h.label}
56 + </a>
57 + ))}
58 + </div>
59 + <div className="flex flex-wrap items-end justify-between gap-2">
60 + <Suspense>
61 + <FilterBar
62 + fields={[
63 + { name: 'q', label: 'Search', type: 'text', placeholder: 'title contains…', width: 'w-44' },
64 + { name: 'category', label: 'Category', type: 'select', options: categoryOptions, width: 'w-52' },
65 + { name: 'grader', label: 'Grader', type: 'select', options: GRADERS.filter((g) => g.slug !== 'raw').map((g) => ({ value: g.slug, label: g.name })), width: 'w-32' },
66 + { name: 'grade', label: 'Grade', type: 'text', placeholder: 'e.g. 10', width: 'w-20' },
67 + { name: 'min', label: 'Min RIV $', type: 'number', placeholder: '0', width: 'w-24' },
68 + { name: 'max', label: 'Max RIV $', type: 'number', placeholder: '∞', width: 'w-24' },
69 + { name: 'liq', label: 'Liquidity ≥', type: 'number', placeholder: '0–100', width: 'w-20' },
70 + { name: 'rar', label: 'Rarity ≥', type: 'number', placeholder: '0–100', width: 'w-20' },
71 + { name: 'from', label: 'Year from', type: 'number', placeholder: '1900', width: 'w-20' },
72 + { name: 'to', label: 'Year to', type: 'number', placeholder: '2026', width: 'w-20' },
73 + ]}
74 + />
75 + </Suspense>
76 + <div className="flex items-center gap-2">
77 + <SortMenu current={sort} params={params} />
78 + <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 }) }]} />
79 + </div>
82 80 </div>
83 81 </div>
84 82 <Suspense fallback={<Skeleton className="h-96" />}>
@@ -96,15 +94,22 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
96 94 );
97 95 }
98 96
99 −function SortSelect({ current, params }: { current: ExploreSort; params: Record<string, string | undefined> }) {
97 +function SortMenu({ current, params }: { current: ExploreSort; params: Record<string, string | undefined> }) {
100 98 return (
101 − <span className="inline-flex flex-wrap gap-1">
102 − {SORTS.map((s) => (
103 − <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'}`}>
104 − {SORT_LABEL[s]}
105 − </a>
106 − ))}
107 − </span>
99 + <details className="relative">
100 + <summary className="plain btn btn-sm h-8 cursor-pointer gap-1.5 text-[12px]">
101 + <span className="text-subtle">Sort</span>
102 + <span className="font-medium text-fg">{SORT_LABEL[current]}</span>
103 + <span aria-hidden className="text-subtle">▾</span>
104 + </summary>
105 + <div className="absolute right-0 top-full z-30 mt-1 w-56 overflow-hidden rounded-md border border-border bg-elevated py-1 shadow-pop">
106 + {SORTS.map((s) => (
107 + <a key={s} href={withParams('/explore', { ...params, sort: s, page: null })} className={`flex min-h-[40px] items-center px-3 text-[13px] hover:bg-sunken md:min-h-[32px] ${s === current ? 'font-medium text-fg' : 'text-muted'}`} aria-current={s === current ? 'true' : undefined}>
108 + {SORT_LABEL[s]}
109 + </a>
110 + ))}
111 + </div>
112 + </details>
108 113 );
109 114 }
110 115
deleted apps/web/src/app/favicon.ico +0 −0

Binary file not shown.

modified apps/web/src/app/globals.css +373 −84
@@ -2,116 +2,121 @@
2 2
3 3 /*
4 4 RareIndex design language (§160–§161): institutional market terminal × modern luxury house.
5 − Neutral base, accents reserved for gains / losses / index identity / rarity / alerts.
6 − Light and dark are first-class; `data-theme` on <html> overrides system preference.
5 + Neutral base; accents reserved for gains / losses / index identity / rarity / alerts.
6 + Light and dark are first-class; `data-theme` on <html> overrides the system preference.
7 + Rhythm: 4-pt spacing, hairline borders, 6 px radii, tabular numerals, 160 ms ease-out motion.
7 8 */
8 9
9 10 :root {
10 − --ri-bg: #fafaf9;
11 + --ri-bg: #f7f7f5;
11 12 --ri-bg-elevated: #ffffff;
12 − --ri-bg-sunken: #f4f4f3;
13 − --ri-bg-inset: #ececea;
14 − --ri-fg: #0b0b0c;
15 − --ri-fg-muted: #55555b;
16 − --ri-fg-subtle: #8a8a91;
17 − --ri-border: #e4e4e2;
18 − --ri-border-strong: #c9c9c5;
19 − --ri-accent: #0b0b0c;
13 + --ri-bg-sunken: #f1f1ef;
14 + --ri-bg-inset: #e9e9e6;
15 + --ri-fg: #0a0a0b;
16 + --ri-fg-muted: #4f4f56;
17 + --ri-fg-subtle: #7d7d86;
18 + --ri-border: #e3e3e0;
19 + --ri-border-strong: #c4c4bf;
20 + --ri-accent: #0a0a0b;
20 21 --ri-accent-fg: #ffffff;
21 − --ri-gain: #0f8a4a;
22 − --ri-gain-bg: #e6f5ec;
23 − --ri-loss: #c8262c;
24 − --ri-loss-bg: #fbe9ea;
22 + --ri-gain: #0d7a42;
23 + --ri-gain-bg: #e3f3ea;
24 + --ri-loss: #bf2229;
25 + --ri-loss-bg: #fbe7e8;
25 26 --ri-flat: #6b6b72;
26 − --ri-index: #1f3a8a;
27 − --ri-index-bg: #e8edfa;
28 − --ri-rarity: #7c3aed;
29 − --ri-rarity-bg: #f0e9fe;
30 − --ri-alert: #b45309;
31 − --ri-alert-bg: #fdf1e3;
32 − --ri-gold: #a16207;
27 + --ri-index: #1e3a8a;
28 + --ri-index-bg: #e6ebf9;
29 + --ri-rarity: #6d28d9;
30 + --ri-rarity-bg: #eee7fd;
31 + --ri-alert: #a34a06;
32 + --ri-alert-bg: #fdefe0;
33 + --ri-gold: #96620a;
33 34 --ri-series-1: #2f5bd6;
34 35 --ri-series-2: #c2410c;
35 36 --ri-series-3: #0e9384;
36 37 --ri-series-4: #7c3aed;
37 38 --ri-series-muted: #b8b8bd;
38 − --ri-ring: rgba(11, 11, 12, 0.18);
39 − --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 1px rgba(0, 0, 0, 0.03);
40 − --ri-shadow-lg: 0 10px 30px -12px rgba(0, 0, 0, 0.18);
39 + --ri-ring: rgba(10, 10, 11, 0.22);
40 + --ri-shadow: 0 1px 1px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.04);
41 + --ri-shadow-lg: 0 12px 32px -12px rgba(0, 0, 0, 0.22), 0 2px 6px rgba(0, 0, 0, 0.06);
41 42 --ri-radius: 6px;
42 43 --ri-radius-sm: 4px;
43 44 --ri-radius-lg: 10px;
45 + --ri-ease: cubic-bezier(0.2, 0.7, 0.2, 1);
46 + --ri-dur: 160ms;
47 + --ri-header-h: 52px;
48 + --ri-tabbar-h: 56px;
44 49 color-scheme: light;
45 50 }
46 51
47 52 html[data-theme="dark"] {
48 − --ri-bg: #0b0c0e;
49 − --ri-bg-elevated: #121316;
50 − --ri-bg-sunken: #08090a;
51 − --ri-bg-inset: #1a1b1f;
52 − --ri-fg: #f2f2f0;
53 − --ri-fg-muted: #a8a8ad;
54 − --ri-fg-subtle: #6f6f76;
55 − --ri-border: #24252a;
56 − --ri-border-strong: #3a3b42;
57 − --ri-accent: #f2f2f0;
58 − --ri-accent-fg: #0b0c0e;
59 − --ri-gain: #34c47c;
60 − --ri-gain-bg: rgba(52, 196, 124, 0.12);
61 − --ri-loss: #ef5a5f;
62 − --ri-loss-bg: rgba(239, 90, 95, 0.12);
53 + --ri-bg: #0a0b0d;
54 + --ri-bg-elevated: #111215;
55 + --ri-bg-sunken: #070809;
56 + --ri-bg-inset: #191a1e;
57 + --ri-fg: #f3f3f1;
58 + --ri-fg-muted: #ababb1;
59 + --ri-fg-subtle: #75757d;
60 + --ri-border: #232429;
61 + --ri-border-strong: #3c3d44;
62 + --ri-accent: #f3f3f1;
63 + --ri-accent-fg: #0a0b0d;
64 + --ri-gain: #3ccb82;
65 + --ri-gain-bg: rgba(60, 203, 130, 0.13);
66 + --ri-loss: #f26065;
67 + --ri-loss-bg: rgba(242, 96, 101, 0.13);
63 68 --ri-flat: #8b8b93;
64 − --ri-index: #7c9cf5;
65 − --ri-index-bg: rgba(124, 156, 245, 0.12);
66 − --ri-rarity: #a78bfa;
67 − --ri-rarity-bg: rgba(167, 139, 250, 0.12);
68 − --ri-alert: #f0a34c;
69 − --ri-alert-bg: rgba(240, 163, 76, 0.12);
70 − --ri-gold: #d4a53a;
69 + --ri-index: #86a3f7;
70 + --ri-index-bg: rgba(134, 163, 247, 0.13);
71 + --ri-rarity: #b19bfb;
72 + --ri-rarity-bg: rgba(177, 155, 251, 0.13);
73 + --ri-alert: #f2a852;
74 + --ri-alert-bg: rgba(242, 168, 82, 0.13);
75 + --ri-gold: #d9ad45;
71 76 --ri-series-1: #5b7fe6;
72 77 --ri-series-2: #cf7a1f;
73 78 --ri-series-3: #199c8a;
74 79 --ri-series-4: #8f6fe8;
75 80 --ri-series-muted: #4a4b52;
76 − --ri-ring: rgba(242, 242, 240, 0.22);
77 − --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
78 − --ri-shadow-lg: 0 14px 40px -14px rgba(0, 0, 0, 0.7);
81 + --ri-ring: rgba(243, 243, 241, 0.26);
82 + --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
83 + --ri-shadow-lg: 0 16px 44px -16px rgba(0, 0, 0, 0.8), 0 2px 8px rgba(0, 0, 0, 0.4);
79 84 color-scheme: dark;
80 85 }
81 86
82 87 @media (prefers-color-scheme: dark) {
83 88 html:not([data-theme="light"]) {
84 − --ri-bg: #0b0c0e;
85 − --ri-bg-elevated: #121316;
86 − --ri-bg-sunken: #08090a;
87 − --ri-bg-inset: #1a1b1f;
88 − --ri-fg: #f2f2f0;
89 − --ri-fg-muted: #a8a8ad;
90 − --ri-fg-subtle: #6f6f76;
91 − --ri-border: #24252a;
92 − --ri-border-strong: #3a3b42;
93 − --ri-accent: #f2f2f0;
94 − --ri-accent-fg: #0b0c0e;
95 − --ri-gain: #34c47c;
96 − --ri-gain-bg: rgba(52, 196, 124, 0.12);
97 − --ri-loss: #ef5a5f;
98 − --ri-loss-bg: rgba(239, 90, 95, 0.12);
89 + --ri-bg: #0a0b0d;
90 + --ri-bg-elevated: #111215;
91 + --ri-bg-sunken: #070809;
92 + --ri-bg-inset: #191a1e;
93 + --ri-fg: #f3f3f1;
94 + --ri-fg-muted: #ababb1;
95 + --ri-fg-subtle: #75757d;
96 + --ri-border: #232429;
97 + --ri-border-strong: #3c3d44;
98 + --ri-accent: #f3f3f1;
99 + --ri-accent-fg: #0a0b0d;
100 + --ri-gain: #3ccb82;
101 + --ri-gain-bg: rgba(60, 203, 130, 0.13);
102 + --ri-loss: #f26065;
103 + --ri-loss-bg: rgba(242, 96, 101, 0.13);
99 104 --ri-flat: #8b8b93;
100 − --ri-index: #7c9cf5;
101 − --ri-index-bg: rgba(124, 156, 245, 0.12);
102 − --ri-rarity: #a78bfa;
103 − --ri-rarity-bg: rgba(167, 139, 250, 0.12);
104 − --ri-alert: #f0a34c;
105 − --ri-alert-bg: rgba(240, 163, 76, 0.12);
106 − --ri-gold: #d4a53a;
105 + --ri-index: #86a3f7;
106 + --ri-index-bg: rgba(134, 163, 247, 0.13);
107 + --ri-rarity: #b19bfb;
108 + --ri-rarity-bg: rgba(177, 155, 251, 0.13);
109 + --ri-alert: #f2a852;
110 + --ri-alert-bg: rgba(242, 168, 82, 0.13);
111 + --ri-gold: #d9ad45;
107 112 --ri-series-1: #5b7fe6;
108 113 --ri-series-2: #cf7a1f;
109 114 --ri-series-3: #199c8a;
110 115 --ri-series-4: #8f6fe8;
111 116 --ri-series-muted: #4a4b52;
112 − --ri-ring: rgba(242, 242, 240, 0.22);
113 − --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
114 − --ri-shadow-lg: 0 14px 40px -14px rgba(0, 0, 0, 0.7);
117 + --ri-ring: rgba(243, 243, 241, 0.26);
118 + --ri-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
119 + --ri-shadow-lg: 0 16px 44px -16px rgba(0, 0, 0, 0.8), 0 2px 8px rgba(0, 0, 0, 0.4);
115 120 color-scheme: dark;
116 121 }
117 122 }
@@ -151,6 +156,7 @@ html[data-theme="dark"] {
151 156 --radius-lg: var(--ri-radius-lg);
152 157 --shadow-card: var(--ri-shadow);
153 158 --shadow-pop: var(--ri-shadow-lg);
159 + --ease-out-soft: var(--ri-ease);
154 160 }
155 161
156 162 @layer base {
@@ -158,15 +164,20 @@ html[data-theme="dark"] {
158 164 height: 100%;
159 165 -webkit-text-size-adjust: 100%;
160 166 scroll-behavior: smooth;
167 + scroll-padding-top: calc(var(--ri-header-h) + 8px);
161 168 }
162 169 body {
163 170 min-height: 100%;
164 171 background: var(--ri-bg);
165 172 color: var(--ri-fg);
166 173 font-family: var(--font-sans);
167 − font-feature-settings: "cv11", "ss01", "tnum";
174 + font-size: 14px;
175 + line-height: 1.45;
176 + font-feature-settings: "cv11", "ss01", "tnum", "calt";
168 177 text-rendering: optimizeLegibility;
169 178 -webkit-font-smoothing: antialiased;
179 + -moz-osx-font-smoothing: grayscale;
180 + overflow-x: clip;
170 181 }
171 182 ::selection {
172 183 background: var(--ri-index-bg);
@@ -174,6 +185,11 @@ html[data-theme="dark"] {
174 185 :focus-visible {
175 186 outline: 2px solid var(--ri-ring);
176 187 outline-offset: 2px;
188 + border-radius: var(--ri-radius-sm);
189 + }
190 + a,
191 + button {
192 + transition: color var(--ri-dur) var(--ri-ease), background-color var(--ri-dur) var(--ri-ease), border-color var(--ri-dur) var(--ri-ease), box-shadow var(--ri-dur) var(--ri-ease), opacity var(--ri-dur) var(--ri-ease);
177 193 }
178 194 input,
179 195 select,
@@ -184,14 +200,84 @@ html[data-theme="dark"] {
184 200 input,
185 201 select,
186 202 textarea {
187 − font-size: 14px;
203 + font-size: 13px;
188 204 }
189 205 }
190 206 h1, h2, h3, h4 {
191 − letter-spacing: -0.015em;
207 + letter-spacing: -0.02em;
208 + text-wrap: balance;
209 + }
210 + p {
211 + text-wrap: pretty;
212 + }
213 + img,
214 + svg {
215 + display: inline-block;
216 + vertical-align: middle;
192 217 }
218 + table {
219 + font-variant-numeric: tabular-nums;
220 + }
221 + @media (prefers-reduced-motion: reduce) {
222 + *,
223 + *::before,
224 + *::after {
225 + animation-duration: 0.01ms !important;
226 + animation-iteration-count: 1 !important;
227 + transition-duration: 0.01ms !important;
228 + scroll-behavior: auto !important;
229 + }
230 + }
231 +}
232 +
233 +/* ---------- Type scale ---------- */
234 +@utility t-display {
235 + font-size: clamp(2.25rem, 1.4rem + 3.6vw, 4rem);
236 + line-height: 1.02;
237 + font-weight: 600;
238 + letter-spacing: -0.035em;
239 +}
240 +@utility t-headline {
241 + font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2rem);
242 + line-height: 1.1;
243 + font-weight: 600;
244 + letter-spacing: -0.025em;
245 +}
246 +@utility t-title {
247 + font-size: 1.0625rem;
248 + line-height: 1.3;
249 + font-weight: 600;
250 + letter-spacing: -0.015em;
251 +}
252 +@utility t-subtitle {
253 + font-size: 0.875rem;
254 + line-height: 1.35;
255 + font-weight: 600;
256 + letter-spacing: -0.01em;
257 +}
258 +@utility t-body {
259 + font-size: 0.875rem;
260 + line-height: 1.5;
261 +}
262 +@utility t-small {
263 + font-size: 0.8125rem;
264 + line-height: 1.4;
265 +}
266 +@utility t-caption {
267 + font-size: 0.6875rem;
268 + line-height: 1.3;
269 + color: var(--ri-fg-subtle);
270 +}
271 +@utility t-label {
272 + font-size: 0.625rem;
273 + line-height: 1.2;
274 + font-weight: 600;
275 + letter-spacing: 0.08em;
276 + text-transform: uppercase;
277 + color: var(--ri-fg-subtle);
193 278 }
194 279
280 +/* ---------- Numbers ---------- */
195 281 @utility num {
196 282 font-variant-numeric: tabular-nums lining-nums;
197 283 font-feature-settings: "tnum", "lnum";
@@ -199,7 +285,10 @@ html[data-theme="dark"] {
199 285 @utility mono-num {
200 286 font-family: var(--font-mono);
201 287 font-variant-numeric: tabular-nums;
288 + letter-spacing: -0.01em;
202 289 }
290 +
291 +/* ---------- Surfaces ---------- */
203 292 @utility hairline {
204 293 border-color: var(--ri-border);
205 294 }
@@ -209,22 +298,222 @@ html[data-theme="dark"] {
209 298 border-radius: var(--ri-radius);
210 299 box-shadow: var(--ri-shadow);
211 300 }
301 +@utility card-hover {
302 + transition: border-color var(--ri-dur) var(--ri-ease), box-shadow var(--ri-dur) var(--ri-ease), transform var(--ri-dur) var(--ri-ease);
303 + &:hover {
304 + border-color: var(--ri-border-strong);
305 + box-shadow: var(--ri-shadow-lg);
306 + }
307 + &:active {
308 + transform: translateY(0.5px);
309 + }
310 +}
311 +@utility surface-sunken {
312 + background: var(--ri-bg-sunken);
313 +}
314 +@utility btn {
315 + display: inline-flex;
316 + align-items: center;
317 + justify-content: center;
318 + gap: 0.375rem;
319 + height: 2.25rem;
320 + padding-inline: 0.75rem;
321 + border-radius: var(--ri-radius);
322 + border: 1px solid var(--ri-border);
323 + background: var(--ri-bg-elevated);
324 + color: var(--ri-fg);
325 + font-size: 0.8125rem;
326 + font-weight: 500;
327 + white-space: nowrap;
328 + &:hover {
329 + border-color: var(--ri-border-strong);
330 + background: var(--ri-bg-sunken);
331 + }
332 +}
333 +@utility btn-primary {
334 + display: inline-flex;
335 + align-items: center;
336 + justify-content: center;
337 + gap: 0.375rem;
338 + height: 2.25rem;
339 + padding-inline: 0.875rem;
340 + border-radius: var(--ri-radius);
341 + border: 1px solid var(--ri-accent);
342 + background: var(--ri-accent);
343 + color: var(--ri-accent-fg);
344 + font-size: 0.8125rem;
345 + font-weight: 600;
346 + white-space: nowrap;
347 + &:hover {
348 + opacity: 0.9;
349 + }
350 +}
351 +@utility btn-sm {
352 + height: 1.75rem;
353 + padding-inline: 0.5rem;
354 + font-size: 0.75rem;
355 +}
356 +@utility chip {
357 + display: inline-flex;
358 + align-items: center;
359 + gap: 0.25rem;
360 + height: 1.625rem;
361 + padding-inline: 0.625rem;
362 + border-radius: 999px;
363 + border: 1px solid var(--ri-border);
364 + background: var(--ri-bg-elevated);
365 + color: var(--ri-fg-muted);
366 + font-size: 0.75rem;
367 + font-weight: 500;
368 + white-space: nowrap;
369 + &:hover {
370 + border-color: var(--ri-border-strong);
371 + color: var(--ri-fg);
372 + }
373 +}
374 +@utility chip-active {
375 + border-color: var(--ri-fg);
376 + background: var(--ri-accent);
377 + color: var(--ri-accent-fg);
378 + &:hover {
379 + color: var(--ri-accent-fg);
380 + }
381 +}
382 +
383 +/* ---------- Layout helpers ---------- */
212 384 @utility scrollbar-none {
213 385 scrollbar-width: none;
214 − &::-webkit-scrollbar { display: none; }
386 + &::-webkit-scrollbar {
387 + display: none;
388 + }
215 389 }
216 390 @utility safe-bottom {
217 391 padding-bottom: max(env(safe-area-inset-bottom), 0.75rem);
218 392 }
393 +/* Horizontal snap rail: full-bleed on mobile, gutters preserved. */
394 +@utility rail {
395 + display: flex;
396 + gap: 0.5rem;
397 + overflow-x: auto;
398 + overscroll-behavior-x: contain;
399 + scroll-snap-type: x mandatory;
400 + scroll-padding-inline: 1rem;
401 + padding-inline: 1rem;
402 + margin-inline: -1rem;
403 + scrollbar-width: none;
404 + -webkit-overflow-scrolling: touch;
405 + &::-webkit-scrollbar {
406 + display: none;
407 + }
408 + & > * {
409 + scroll-snap-align: start;
410 + flex: 0 0 auto;
411 + }
412 + @media (min-width: 640px) {
413 + scroll-padding-inline: 1.5rem;
414 + padding-inline: 1.5rem;
415 + margin-inline: -1.5rem;
416 + }
417 +}
418 +/* Dense tables: sticky first column on narrow screens, hairline rows. */
419 +@utility table-sticky {
420 + & th:first-child,
421 + & td:first-child {
422 + position: sticky;
423 + left: 0;
424 + z-index: 2;
425 + background: var(--ri-bg-elevated);
426 + box-shadow: inset -1px 0 0 var(--ri-border);
427 + }
428 + & thead th:first-child {
429 + z-index: 3;
430 + }
431 + & tbody tr:hover td:first-child {
432 + background: var(--ri-bg-sunken);
433 + }
434 +}
435 +/* Space for the mobile tab bar. */
436 +@utility tabbar-pad {
437 + padding-bottom: calc(var(--ri-tabbar-h) + env(safe-area-inset-bottom) + 1rem);
438 + @media (min-width: 768px) {
439 + padding-bottom: 4rem;
440 + }
441 +}
442 +@utility line-clamp-2 {
443 + display: -webkit-box;
444 + -webkit-line-clamp: 2;
445 + -webkit-box-orient: vertical;
446 + overflow: hidden;
447 +}
219 448
449 +/* ---------- Motion ---------- */
220 450 @keyframes ri-tape {
221 − from { transform: translateX(0); }
222 − to { transform: translateX(-50%); }
451 + from {
452 + transform: translateX(0);
453 + }
454 + to {
455 + transform: translateX(-50%);
456 + }
223 457 }
224 458 .tape-track {
225 − animation: ri-tape 60s linear infinite;
459 + animation: ri-tape 70s linear infinite;
460 + will-change: transform;
461 +}
462 +.tape-track:hover,
463 +.tape-track:focus-within {
464 + animation-play-state: paused;
465 +}
466 +@keyframes ri-fade-up {
467 + from {
468 + opacity: 0;
469 + transform: translateY(6px);
470 + }
471 + to {
472 + opacity: 1;
473 + transform: none;
474 + }
475 +}
476 +.fade-up {
477 + animation: ri-fade-up 320ms var(--ri-ease) both;
478 +}
479 +@keyframes ri-sheet-in {
480 + from {
481 + opacity: 0;
482 + transform: translateY(12px);
483 + }
484 + to {
485 + opacity: 1;
486 + transform: none;
487 + }
488 +}
489 +.sheet-in {
490 + animation: ri-sheet-in 200ms var(--ri-ease) both;
491 +}
492 +@keyframes ri-pulse-dot {
493 + 0%,
494 + 100% {
495 + opacity: 1;
496 + }
497 + 50% {
498 + opacity: 0.35;
499 + }
500 +}
501 +.live-dot {
502 + animation: ri-pulse-dot 1.6s ease-in-out infinite;
226 503 }
227 −.tape-track:hover { animation-play-state: paused; }
228 504 @media (prefers-reduced-motion: reduce) {
229 − .tape-track { animation: none; }
505 + .tape-track,
506 + .fade-up,
507 + .sheet-in,
508 + .live-dot {
509 + animation: none;
510 + }
511 +}
512 +
513 +/* Native <details> caret hidden where we render our own. */
514 +summary.plain {
515 + list-style: none;
516 +}
517 +summary.plain::-webkit-details-marker {
518 + display: none;
230 519 }
modified apps/web/src/app/grading/page.tsx +1 −1
@@ -17,7 +17,7 @@ export default async function GradingPage() {
17 17 for (const p of premiums) (byCat.get(p.categorySlug) ?? byCat.set(p.categorySlug, []).get(p.categorySlug)!).push(p);
18 18 return (
19 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." />
20 + <PageHeader kicker="Reference" 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 21 <Card className="overflow-hidden">
22 22 <CardHeader title="Grading companies" subtitle="Scale, categories and observed activity in RareIndex" />
23 23 <Table>
added apps/web/src/app/icon.png +0 −0

Binary file not shown.

modified apps/web/src/app/layout.tsx +15 −5
@@ -3,10 +3,11 @@ import { Geist, Geist_Mono } from 'next/font/google';
3 3 import './globals.css';
4 4 import { SiteHeader } from '@/components/layout/site-header';
5 5 import { SiteFooter } from '@/components/layout/site-footer';
6 +import { TabBar } from '@/components/layout/tab-bar';
6 7 import { THEME_SCRIPT } from '@/components/layout/theme-toggle';
7 8
8 −const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] });
9 −const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] });
9 +const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'], display: 'swap' });
10 +const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'], display: 'swap' });
10 11
11 12 const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io';
12 13
@@ -15,6 +16,9 @@ export const metadata: Metadata = {
15 16 title: { default: 'RareIndex — The Global Market for Collectibles', template: '%s · RareIndex' },
16 17 description: 'Discover, price, track and analyze collectible assets across the world’s marketplaces and auction houses.',
17 18 applicationName: 'RareIndex',
19 + manifest: '/manifest.webmanifest',
20 + appleWebApp: { capable: true, statusBarStyle: 'default', title: 'RareIndex' },
21 + formatDetection: { telephone: false },
18 22 openGraph: { type: 'website', siteName: 'RareIndex', url: SITE_URL, title: 'RareIndex — The Global Market for Collectibles', description: 'Prices, sales, listings, rarity, liquidity and indices for collectible assets.' },
19 23 twitter: { card: 'summary_large_image', title: 'RareIndex', description: 'The global market for collectibles.' },
20 24 robots: { index: true, follow: true },
@@ -25,8 +29,8 @@ export const viewport: Viewport = {
25 29 initialScale: 1,
26 30 viewportFit: 'cover',
27 31 themeColor: [
28 − { media: '(prefers-color-scheme: light)', color: '#fafaf9' },
29 − { media: '(prefers-color-scheme: dark)', color: '#0b0c0e' },
32 + { media: '(prefers-color-scheme: light)', color: '#f7f7f5' },
33 + { media: '(prefers-color-scheme: dark)', color: '#0a0b0d' },
30 34 ],
31 35 };
32 36
@@ -37,9 +41,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
37 41 <script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
38 42 </head>
39 43 <body className="flex min-h-full flex-col">
44 + <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[100] focus:rounded-md focus:bg-elevated focus:px-3 focus:py-2 focus:text-sm focus:shadow-pop">
45 + Skip to content
46 + </a>
40 47 <SiteHeader />
41 − <main className="mx-auto w-full max-w-[1440px] flex-1 px-4 pb-16 pt-4 sm:px-6">{children}</main>
48 + <main id="main" className="tabbar-pad mx-auto w-full max-w-[1440px] flex-1 px-4 pt-4 sm:px-6">
49 + {children}
50 + </main>
42 51 <SiteFooter />
52 + <TabBar />
43 53 </body>
44 54 </html>
45 55 );
modified apps/web/src/app/listings/page.tsx +1 −1
@@ -19,7 +19,7 @@ export default async function ListingsPage({ searchParams }: { searchParams: Pro
19 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 20 return (
21 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 />
22 + <PageHeader kicker="Marketplaces" 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 23 <Suspense>
24 24 <FilterBar
25 25 className="mb-3"
added apps/web/src/app/manifest.ts +26 −0
@@ -0,0 +1,26 @@
1 +import type { MetadataRoute } from 'next';
2 +
3 +export default function manifest(): MetadataRoute.Manifest {
4 + return {
5 + name: 'RareIndex — The Global Market for Collectibles',
6 + short_name: 'RareIndex',
7 + description: 'Prices, sales, listings, rarity, liquidity and indices for collectible assets.',
8 + start_url: '/',
9 + display: 'standalone',
10 + orientation: 'portrait',
11 + background_color: '#f7f7f5',
12 + theme_color: '#0a0a0b',
13 + categories: ['finance', 'shopping', 'news'],
14 + icons: [
15 + { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
16 + { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
17 + { src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
18 + { src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
19 + ],
20 + shortcuts: [
21 + { name: 'Scanner', url: '/scanner', description: 'Identify and price a collectible from a photo' },
22 + { name: 'Markets', url: '/markets', description: 'Category markets and indices' },
23 + { name: 'Watchlist', url: '/watchlist', description: 'Assets you follow' },
24 + ],
25 + };
26 +}
modified apps/web/src/app/market-map/page.tsx +1 −1
@@ -32,7 +32,7 @@ export default async function MarketMapPage({ searchParams }: { searchParams: Pr
32 32 const q = (p: string, s: string) => `/market-map?p=${p}&size=${s}${parent ? `&family=${parent}` : ''}`;
33 33 return (
34 34 <div>
35 − <PageHeader
35 + <PageHeader kicker="Heatmap"
36 36 crumbs={parent ? [{ label: 'Market map', href: '/market-map' }, { label: getCategory(parent)?.name ?? parent }] : undefined}
37 37 title={parent ? `${getCategory(parent)?.name ?? parent} map` : 'Market map'}
38 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."
modified apps/web/src/app/markets/[slug]/page.tsx +23 −9
@@ -92,17 +92,31 @@ export default async function MarketPage({ params, searchParams }: { params: Pro
92 92 </Suspense>
93 93 </div>
94 94
95 − <Suspense fallback={<Skeleton className="mt-4 h-72" />}>
96 − <IndexPanel slug={slug} />
97 − </Suspense>
95 + {counts.sales > 0 || counts.listings > 0 ? (
96 + <>
97 + <Suspense fallback={<Skeleton className="mt-4 h-72" />}>
98 + <IndexPanel slug={slug} />
99 + </Suspense>
98 100
99 − <Suspense fallback={<Skeleton className="mt-4 h-72" />}>
100 − <Movers slug={slug} />
101 − </Suspense>
101 + <Suspense fallback={<Skeleton className="mt-4 h-72" />}>
102 + <Movers slug={slug} />
103 + </Suspense>
102 104
103 − <Suspense fallback={<Skeleton className="mt-4 h-72" />}>
104 − <Activity slug={slug} />
105 − </Suspense>
105 + <Suspense fallback={<Skeleton className="mt-4 h-72" />}>
106 + <Activity slug={slug} />
107 + </Suspense>
108 + </>
109 + ) : (
110 + <Card className="mt-4 p-4">
111 + <p className="t-subtitle text-fg">Market analytics publish once verified sales or live listings exist in {node.name}.</p>
112 + <p className="mt-1 text-[13px] leading-relaxed text-muted">
113 + Movers, volume, records, value opportunities, auctions and the category index all derive from observed transactions. Until then the catalogue above is complete and guide prices are shown where a price-guide source reports them (labelled, never presented as market value).{' '}
114 + <Link href="/data" className="underline-offset-2 hover:text-fg hover:underline">
115 + How coverage grows →
116 + </Link>
117 + </p>
118 + </Card>
119 + )}
106 120
107 121 <Suspense fallback={<Skeleton className="mt-4 h-40" />}>
108 122 <Structure slug={slug} />
modified apps/web/src/app/markets/page.tsx +28 −9
@@ -2,9 +2,12 @@ import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import { PageHeader } from '@/components/ui/page-header';
4 4 import { MarketTable } from '@/components/market/market-table';
5 −import { getMarketRows } from '@/lib/queries/markets';
5 +import { CategoryGrid } from '@/components/market/category-grid';
6 +import { Card, CardHeader } from '@/components/ui/primitives';
7 +import { getCategorySparklines, getMarketRows } from '@/lib/queries/markets';
8 +import { getCategoryThumbnails } from '@/lib/queries/assets';
6 9 import { listIndices } from '@/lib/queries/indices';
7 −import { fmtNum } from '@/lib/format';
10 +import { fmtMoney, fmtNum } from '@/lib/format';
8 11
9 12 export const metadata: Metadata = { title: 'Markets', description: 'Collectible category markets: index performance, sales, volume, listings, liquidity and market capitalization estimates.' };
10 13 export const revalidate = 300;
@@ -12,30 +15,46 @@ export const revalidate = 300;
12 15 export default async function MarketsPage() {
13 16 const [rows, indices] = await Promise.all([getMarketRows(), listIndices()]);
14 17 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 });
18 + const [thumbs, sparklines] = await Promise.all([getCategoryThumbnails(active.map((r) => r.node.slug)), getCategorySparklines(active.map((r) => r.node.slug), 90)]);
19 + const totals = active.reduce((a, r) => ({ assets: a.assets + r.counts.assets, priced: a.priced + r.counts.priced, sales: a.sales + r.counts.sales, sales30d: a.sales30d + r.counts.sales30d, volume: a.volume + (r.counts.volume30dUsd ?? 0), listings: a.listings + r.counts.listings }), { assets: 0, priced: 0, sales: 0, sales30d: 0, volume: 0, listings: 0 });
16 20 const published = indices.filter((i) => i.latest).length;
17 21 return (
18 22 <div>
19 23 <PageHeader
20 − title="Markets"
24 + kicker="Markets"
25 + title="Category markets"
21 26 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 27 meta={
23 28 <>
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 29 <Link href="/rareindex" className="hover:text-fg">
27 30 {published}/{indices.length} indices published
28 31 </Link>
29 − <span>·</span>
32 + <span aria-hidden>·</span>
30 33 <Link href="/market-map" className="hover:text-fg">
31 34 Heatmap view →
32 35 </Link>
36 + <span aria-hidden>·</span>
37 + <Link href="/categories" className="hover:text-fg">
38 + Full taxonomy →
39 + </Link>
33 40 </>
34 41 }
42 + stats={[
43 + { label: 'Families with data', value: fmtNum(active.length), sub: `of ${fmtNum(rows.length)}` },
44 + { label: 'Tracked assets', value: fmtNum(totals.assets), sub: `${fmtNum(totals.priced)} with valuation` },
45 + { label: 'Sales (all time)', value: fmtNum(totals.sales) },
46 + { label: 'Sales · 30D', value: fmtNum(totals.sales30d) },
47 + { label: 'Volume · 30D', value: totals.volume ? fmtMoney(totals.volume, 'USD', { compact: true }) : '—' },
48 + { label: 'Active listings', value: fmtNum(totals.listings) },
49 + ]}
35 50 />
36 − <div className="card overflow-hidden">
51 + <section className="mb-6">
52 + <CategoryGrid rows={active} thumbs={thumbs} sparklines={sparklines} mobileLimit={8} />
53 + </section>
54 + <Card className="overflow-hidden">
55 + <CardHeader title="All families" subtitle="Heat cells are period changes of the category index; grey means not yet computable" />
37 56 <MarketTable rows={rows} />
38 − </div>
57 + </Card>
39 58 <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 59 </div>
41 60 );
modified apps/web/src/app/news/page.tsx +1 −1
@@ -20,7 +20,7 @@ export default async function NewsPage({ searchParams }: { searchParams: Promise
20 20 const res = await listNews({ type, category, page, limit: 40 });
21 21 return (
22 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 />
23 + <PageHeader kicker="Editorial" 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 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 25 {res.items.length ? (
26 26 <>
modified apps/web/src/app/page.tsx +124 −73
@@ -4,16 +4,15 @@ import { Hero } from '@/components/home/hero';
4 4 import { IndexTape } from '@/components/market/index-strip';
5 5 import { AssetRail, LatestTransactions, LotsRail, MarketInsights, RadarRail, SalesRail } from '@/components/home/sections';
6 6 import { CategoryGrid } from '@/components/market/category-grid';
7 −import { attachGuidePrices, getCategoryThumbnails, getLatestObservationsFeed } from '@/lib/queries/assets';
7 +import { TileSkeleton } from '@/components/market/asset-tile';
8 +import { attachGuidePrices, getCategoryThumbnails, getLatestObservationsFeed, rankedAssets } from '@/lib/queries/assets';
8 9 import { fmtNum } from '@/lib/format';
9 −import { Skeleton } from '@/components/ui/primitives';
10 +import { Card, Delta, Skeleton, TableSkeleton } from '@/components/ui/primitives';
10 11 import { getLatestSales, getSiteStats } from '@/lib/queries/site';
11 −import { listIndices } from '@/lib/queries/indices';
12 −import { rankedAssets } from '@/lib/queries/assets';
13 −import { getMarketRows } from '@/lib/queries/markets';
12 +import { getIndexSeries, listIndices } from '@/lib/queries/indices';
13 +import { getCategorySparklines, getMarketRows } from '@/lib/queries/markets';
14 14 import { getTopSales, listLots, listRadar } from '@/lib/queries/market-lists';
15 15 import { LineChart } from '@/components/charts/line-chart';
16 −import { getIndexSeries } from '@/lib/queries/indices';
17 16
18 17 export const revalidate = 120;
19 18
@@ -27,14 +26,17 @@ export default async function HomePage() {
27 26 <div className="mt-8">
28 27 <IndexTape indices={indices} />
29 28 </div>
30 − <div className="px-4 pt-6 sm:px-6">
31 − <Suspense fallback={<Skeleton className="h-64 w-full" />}>
32 − <FlagshipPanel />
29 + <div className="flex flex-col gap-10 px-4 pt-8 sm:px-6">
30 + <Suspense fallback={<Skeleton className="h-40" />}>
31 + <Categories />
33 32 </Suspense>
34 − <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>}>
33 + <Suspense fallback={<RailFallback />}>
35 34 <Discovery />
36 35 </Suspense>
37 − <Suspense fallback={<Skeleton className="mt-4 h-48" />}>
36 + <Suspense fallback={<Skeleton className="h-72 w-full" />}>
37 + <FlagshipPanel />
38 + </Suspense>
39 + <Suspense fallback={<TableSkeleton rows={10} />}>
38 40 <Latest />
39 41 </Suspense>
40 42 </div>
@@ -42,18 +44,67 @@ export default async function HomePage() {
42 44 );
43 45 }
44 46
47 +function RailFallback() {
48 + return (
49 + <div className="flex flex-col gap-10">
50 + {[0, 1].map((k) => (
51 + <div key={k}>
52 + <Skeleton className="mb-3 h-5 w-40" />
53 + <div className="rail lg:mx-0 lg:grid lg:grid-cols-4 lg:overflow-visible lg:px-0 xl:grid-cols-6 2xl:grid-cols-8 lg:[&>*]:w-auto">
54 + {Array.from({ length: 8 }).map((_, i) => (
55 + <TileSkeleton key={i} />
56 + ))}
57 + </div>
58 + </div>
59 + ))}
60 + </div>
61 + );
62 +}
63 +
64 +async function Categories() {
65 + const markets = await getMarketRows();
66 + const families = markets.filter((r) => r.counts.assets > 0);
67 + const [thumbs, sparklines] = await Promise.all([getCategoryThumbnails(markets.map((r) => r.node.slug)), getCategorySparklines(markets.map((r) => r.node.slug), 90)]);
68 + return (
69 + <section>
70 + <div className="mb-3 flex items-end justify-between gap-3">
71 + <div>
72 + <h2 className="t-title text-fg">Browse by category</h2>
73 + <p className="mt-0.5 text-xs text-muted">Live counts from the canonical tables · 90-day category index where published</p>
74 + </div>
75 + <Link href="/categories" className="num shrink-0 text-xs font-medium text-muted hover:text-fg">
76 + {fmtNum(families.length)} of {fmtNum(markets.length)} families with data →
77 + </Link>
78 + </div>
79 + <CategoryGrid rows={families} thumbs={thumbs} sparklines={sparklines} limit={12} mobileLimit={6} />
80 + </section>
81 + );
82 +}
83 +
45 84 async function FlagshipPanel() {
46 85 const indices = await listIndices();
47 86 const rare = indices.find((i) => i.isFlagship);
48 − const subs = indices.filter((i) => !i.isFlagship && i.latest);
87 + const subs = indices.filter((i) => !i.isFlagship);
88 + const published = subs.filter((i) => i.latest);
89 + const building = subs.filter((i) => !i.latest).sort((a, b) => b.pricedAssets / b.minConstituents - a.pricedAssets / a.minConstituents);
49 90 const series = rare ? await getIndexSeries(rare.id, 365) : [];
50 91 return (
51 − <section className="grid gap-4 lg:grid-cols-[2fr_1fr]">
52 − <div className="card p-4">
53 − <div className="flex items-baseline justify-between gap-3">
92 + <section className="grid gap-4 lg:grid-cols-[3fr_2fr]">
93 + <Card className="p-4">
94 + <div className="flex flex-wrap items-baseline justify-between gap-3">
54 95 <div>
55 − <h2 className="text-sm font-semibold text-fg">RARE · RareIndex Global Collectibles Index</h2>
56 − <p className="text-xs text-muted">Chain-linked, equal-weighted across subindices · base 1,000 on 2024-01-01</p>
96 + <p className="t-label">RARE · Global Collectibles Index</p>
97 + {rare?.latest ? (
98 + <p className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
99 + <span className="num text-2xl font-semibold text-fg">{rare.latest.value.toFixed(2)}</span>
100 + <Delta value={rare.change1d} arrow className="text-sm" />
101 + <span className="text-[11px] text-subtle">1D</span>
102 + <Delta value={rare.change30d} className="text-sm" />
103 + <span className="text-[11px] text-subtle">30D</span>
104 + </p>
105 + ) : (
106 + <p className="mt-1 text-sm text-muted">Chain-linked, equal-weighted across subindices · base 1,000 on 2024-01-01</p>
107 + )}
57 108 </div>
58 109 <Link href="/rareindex/RARE" className="text-xs font-medium text-muted hover:text-fg">
59 110 Index detail →
@@ -62,37 +113,57 @@ async function FlagshipPanel() {
62 113 <div className="mt-3">
63 114 <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'} />
64 115 </div>
65 − </div>
66 − <div className="card p-4">
67 − <h2 className="text-sm font-semibold text-fg">Subindices</h2>
68 − <p className="text-xs text-muted">Daily change · latest published value</p>
69 − {subs.length ? (
116 + </Card>
117 + <Card className="flex flex-col p-4">
118 + <div className="flex items-baseline justify-between">
119 + <p className="t-label">Subindices</p>
120 + <Link href="/rareindex" className="text-xs font-medium text-muted hover:text-fg">
121 + All indices →
122 + </Link>
123 + </div>
124 + {published.length ? (
70 125 <ul className="mt-2 divide-y divide-border">
71 − {subs.slice(0, 10).map((i) => (
126 + {published.slice(0, 8).map((i) => (
72 127 <li key={i.ticker}>
73 − <Link href={`/rareindex/${i.ticker}`} className="flex items-center justify-between py-1.5 text-[12px] hover:bg-sunken">
74 − <span className="flex items-center gap-2">
75 − <span className="inline-block h-2 w-2 rounded-full" style={{ background: i.color ?? 'var(--ri-index)' }} />
76 − <span className="font-medium text-fg">{i.ticker}</span>
128 + <Link href={`/rareindex/${i.ticker}`} className="flex min-h-[40px] items-center justify-between gap-3 text-[12px] hover:bg-sunken">
129 + <span className="flex min-w-0 items-center gap-2">
130 + <span className="inline-block h-2 w-2 shrink-0 rounded-[2px]" style={{ background: i.color ?? 'var(--ri-index)' }} />
131 + <span className="mono-num font-semibold text-fg">{i.ticker}</span>
132 + <span className="truncate text-muted">{i.name.replace('RareIndex ', '')}</span>
77 133 </span>
78 − <span className="num flex items-center gap-3">
134 + <span className="num flex shrink-0 items-center gap-3">
79 135 <span className="text-muted">{i.latest!.value.toFixed(2)}</span>
80 − <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>
136 + <Delta value={i.change1d} arrow />
81 137 </span>
82 138 </Link>
83 139 </li>
84 140 ))}
85 141 </ul>
86 − ) : (
87 − <p className="mt-6 text-center text-xs text-subtle">
88 − No subindex has reached its minimum constituent count yet.
89 − <br />
90 − <Link href="/rareindex" className="underline hover:text-fg">
91 − See build progress
92 − </Link>
93 − </p>
94 − )}
95 − </div>
142 + ) : null}
143 + {building.length ? (
144 + <div className="mt-3 border-t border-border pt-3">
145 + <p className="t-caption mb-1.5">Building · priced constituents vs. minimum</p>
146 + <ul className="grid grid-cols-2 gap-x-4 gap-y-1.5">
147 + {building.slice(0, 8).map((i) => {
148 + const pct = Math.min(100, Math.round((i.pricedAssets / Math.max(1, i.minConstituents)) * 100));
149 + return (
150 + <li key={i.ticker} className="text-[11px]">
151 + <span className="flex items-center justify-between">
152 + <span className="mono-num text-muted">{i.ticker.replace('RARE-', '')}</span>
153 + <span className="num text-subtle">
154 + {i.pricedAssets}/{i.minConstituents}
155 + </span>
156 + </span>
157 + <span className="mt-0.5 block h-1 w-full overflow-hidden rounded-full bg-inset">
158 + <span className="block h-full rounded-full bg-index" style={{ width: `${pct}%` }} />
159 + </span>
160 + </li>
161 + );
162 + })}
163 + </ul>
164 + </div>
165 + ) : null}
166 + </Card>
96 167 </section>
97 168 );
98 169 }
@@ -113,46 +184,30 @@ async function Discovery() {
113 184 rankedAssets('documented', { limit: 8 }),
114 185 rankedAssets('with_sales', { limit: 8 }),
115 186 ]);
116 − await Promise.all([attachGuidePrices(newest), attachGuidePrices(documented)]);
117 − const families = markets.filter((r) => r.counts.assets > 0);
118 − const thumbs = await getCategoryThumbnails(markets.map((r) => r.node.slug));
187 + await Promise.all([attachGuidePrices(newest), attachGuidePrices(documented), attachGuidePrices(trending), attachGuidePrices(newestPriced)]);
119 188 // Honest fallbacks while valuations are still being computed: each rail states what it shows.
120 189 const trendingRail = trending.length
121 190 ? { title: 'Trending Now', subtitle: 'Price × volume × listing momentum', items: trending, metric: 'trending' as const, href: '/trending' }
122 191 : newestPriced.length
123 − ? { title: 'Recently valued', subtitle: 'Newest RareIndex Valuations (trending needs momentum history)', items: newestPriced, metric: 'riv' as const, href: '/explore?has=valuation&sort=riv' }
124 − : { title: 'Best-documented assets', subtitle: 'Images plus sales or guide prices — trending publishes once momentum history exists', items: documented, metric: 'guide' as const, href: '/explore?has=observations' };
192 + ? { title: 'Recently valued', subtitle: 'Newest RareIndex Valuations — trending publishes once momentum history exists', items: newestPriced, metric: 'riv' as const, href: '/explore?has=valuation&sort=riv' }
193 + : { title: 'Best-documented assets', subtitle: 'Images plus sales or guide prices', items: documented, metric: 'guide' as const, href: '/explore?has=observations' };
125 194 const moversItems = movers.length ? movers : losers;
126 195 const moversRail = moversItems.length
127 196 ? { title: 'Biggest Movers', subtitle: '7-day change in RareIndex Valuation', items: moversItems, metric: 'change7d' as const, href: '/explore?sort=change7d' }
128 197 : withSales.length
129 − ? { title: 'Latest sold assets', subtitle: 'Most recent verified sale per asset — movers need a 7-day valuation history', items: withSales, metric: 'riv' as const, href: '/sales' }
198 + ? { title: 'Latest sold assets', subtitle: 'Most recent verified sale per asset — movers need a 7-day valuation history', items: withSales, metric: 'latestSale' as const, href: '/sales' }
130 199 : null;
131 200 return (
132 201 <>
133 − <section className="mt-6">
134 − <div className="mb-2 flex items-baseline justify-between">
135 − <h2 className="text-base font-semibold tracking-tight text-fg">Browse by category</h2>
136 − <span className="num text-[11px] text-muted">
137 − {fmtNum(families.length)} families with data ·{' '}
138 − <Link href="/categories" className="hover:text-fg">
139 − all {fmtNum(markets.length)} →
140 − </Link>
141 − </span>
142 − </div>
143 − <CategoryGrid rows={markets} thumbs={thumbs} limit={12} />
144 − </section>
145 − <section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
146 − <AssetRail title={trendingRail.title} subtitle={trendingRail.subtitle} href={trendingRail.href} items={trendingRail.items} metric={trendingRail.metric} />
147 − {moversRail ? <AssetRail title={moversRail.title} subtitle={moversRail.subtitle} href={moversRail.href} items={moversRail.items} metric={moversRail.metric} /> : null}
148 − <SalesRail title="Record Sales" subtitle="Highest verified transactions" href="/records" items={records} hideWhenEmpty />
149 − <RadarRail items={radar} hideWhenEmpty />
150 − <LotsRail items={lots} hideWhenEmpty />
151 − <AssetRail title="Newly Added" subtitle="Latest canonical assets — guide prices labelled, RIV when available" href="/explore?sort=newest" items={newest} metric="guide" />
152 − <MarketInsights rows={markets} />
153 − {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null}
154 − {opportunities.length ? <AssetRail title="Value Opportunities" subtitle="Listings materially below RIV (analytical, not advice)" href="/listings?sort=discount" items={opportunities} metric="opportunity" /> : null}
155 − </section>
202 + <AssetRail title={trendingRail.title} subtitle={trendingRail.subtitle} href={trendingRail.href} items={trendingRail.items} metric={trendingRail.metric} priority />
203 + {moversRail ? <AssetRail title={moversRail.title} subtitle={moversRail.subtitle} href={moversRail.href} items={moversRail.items} metric={moversRail.metric} /> : null}
204 + <SalesRail title="Record Sales" subtitle="Highest verified transactions" href="/records" items={records} hideWhenEmpty />
205 + <RadarRail items={radar} hideWhenEmpty />
206 + <LotsRail items={lots} hideWhenEmpty />
207 + <AssetRail title="Newly Added" subtitle="Latest canonical assets — guide prices labelled, RIV when available" href="/explore?sort=newest" items={newest} metric="guide" />
208 + {opportunities.length ? <AssetRail title="Value Opportunities" subtitle="Listings materially below RIV — analytical data, not advice" href="/listings?sort=discount" items={opportunities} metric="opportunity" /> : null}
209 + {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null}
210 + <MarketInsights rows={markets} />
156 211 </>
157 212 );
158 213 }
@@ -160,9 +215,5 @@ async function Discovery() {
160 215 async function Latest() {
161 216 const items = await getLatestSales(12);
162 217 const observations = items.length ? [] : await getLatestObservationsFeed(12);
163 − return (
164 − <section className="mt-6">
165 − <LatestTransactions items={items} observations={observations} />
166 − </section>
167 − );
218 + return <LatestTransactions items={items} observations={observations} />;
168 219 }
modified apps/web/src/app/price-history/page.tsx +1 −1
@@ -31,7 +31,7 @@ export default async function PriceHistoryPage({ searchParams }: { searchParams:
31 31 const asset = slug ? await getAssetBySlug(slug) : null;
32 32 return (
33 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 />
34 + <PageHeader kicker="Series" 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 35 <form action="/price-history" className="mb-4 flex max-w-xl gap-2">
36 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 37 <button type="submit" className="h-10 rounded-md bg-accent px-4 text-sm font-medium text-accent-fg">
modified apps/web/src/app/radar/page.tsx +1 −1
@@ -19,7 +19,7 @@ export default async function RadarPage({ searchParams }: { searchParams: Promis
19 19 const items = await listRadar({ kind, limit: 100 });
20 20 return (
21 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 />
22 + <PageHeader kicker="Signals" 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 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 24 {items.length ? (
25 25 <ul className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
modified apps/web/src/app/rareindex/page.tsx +1 −1
@@ -20,7 +20,7 @@ export default async function RareIndexPage() {
20 20 const corr = await getCorrelationMatrix(365);
21 21 return (
22 22 <div>
23 − <PageHeader
23 + <PageHeader kicker="Indices"
24 24 title="RareIndex"
25 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 26 meta={
modified apps/web/src/app/records/page.tsx +1 −1
@@ -15,7 +15,7 @@ export default async function RecordsPage() {
15 15 const [records, top] = await Promise.all([getRecordSales(), getTopSales(100)]);
16 16 return (
17 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." />
18 + <PageHeader kicker="Records" 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 19 {records.length ? (
20 20 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
21 21 {records.map((r) => (
modified apps/web/src/app/sales/page.tsx +1 −1
@@ -19,7 +19,7 @@ export default async function SalesPage({ searchParams }: { searchParams: Promis
19 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 20 return (
21 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 />
22 + <PageHeader kicker="Transactions" 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 23 <Suspense>
24 24 <FilterBar
25 25 className="mb-3"
modified apps/web/src/app/search/page.tsx +1 −1
@@ -23,7 +23,7 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi
23 23 const page = spInt(sp, 'page');
24 24 return (
25 25 <div>
26 − <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 />
26 + <PageHeader kicker="Search" 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 />
27 27 <form action="/search" role="search" className="mb-4 flex max-w-2xl gap-2">
28 28 <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" />
29 29 {category ? <input type="hidden" name="category" value={category} /> : null}
modified apps/web/src/app/trending/page.tsx +1 −1
@@ -20,7 +20,7 @@ export default async function TrendingPage({ searchParams }: { searchParams: Pro
20 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 21 return (
22 22 <div>
23 − <PageHeader
23 + <PageHeader kicker="Momentum"
24 24 title="Trending"
25 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 26 meta={
modified apps/web/src/components/account/charts.tsx +6 −4
@@ -4,7 +4,8 @@
4 4 */
5 5 import { cn } from '@/lib/format';
6 6
7 −const PALETTE = ['var(--ri-index)', 'var(--ri-gold)', 'var(--ri-rarity)', 'var(--ri-gain)', 'var(--ri-alert)', 'var(--ri-fg-muted)', 'var(--ri-loss)', 'var(--ri-fg-subtle)'];
7 +// Categorical identity: the four validated series tokens in fixed order; beyond four, fold into muted steps ('Other').
8 +const PALETTE = ['var(--ri-series-1, #2f5bd6)', 'var(--ri-series-2, #c2410c)', 'var(--ri-series-3, #0e9384)', 'var(--ri-series-4, #7c3aed)', 'var(--ri-fg-muted)', 'var(--ri-fg-subtle)', 'var(--ri-border-strong)', 'var(--ri-border)'];
8 9
9 10 export function Donut({ data, size = 120, thickness = 14, className, centerLabel, centerValue }: { data: Array<{ label: string; value: number }>; size?: number; thickness?: number; className?: string; centerLabel?: string; centerValue?: string }) {
10 11 const total = data.reduce((a, d) => a + d.value, 0);
@@ -18,7 +19,8 @@ export function Donut({ data, size = 120, thickness = 14, className, centerLabel
18 19 {total > 0
19 20 ? data.map((d, i) => {
20 21 const len = (d.value / total) * c;
21 − const el = <circle key={d.label} cx={size / 2} cy={size / 2} r={r} fill="none" stroke={PALETTE[i % PALETTE.length]} strokeWidth={thickness} strokeDasharray={`${len} ${c - len}`} strokeDashoffset={-offset} transform={`rotate(-90 ${size / 2} ${size / 2})`} />;
22 + const gap = data.length > 1 ? Math.min(2, len / 2) : 0; // 2px surface gap between segments
23 + const el = <circle key={d.label} cx={size / 2} cy={size / 2} r={r} fill="none" stroke={PALETTE[i % PALETTE.length]} strokeWidth={thickness} strokeDasharray={`${Math.max(0, len - gap)} ${c - len + gap}`} strokeDashoffset={-offset} transform={`rotate(-90 ${size / 2} ${size / 2})`} />;
22 24 offset += len;
23 25 return el;
24 26 })
@@ -85,7 +87,7 @@ export function LineChart({ series, height = 180, className, formatY = (v: numbe
85 87 <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full', className)} role="img" aria-label={ariaLabel} preserveAspectRatio="none">
86 88 {ticks.map((t) => (
87 89 <g key={t}>
88 − <line x1={padL} x2={W - padR} y1={y(t)} y2={y(t)} stroke="var(--ri-border)" strokeDasharray="2 3" />
90 + <line x1={padL} x2={W - padR} y1={y(t)} y2={y(t)} stroke="var(--ri-border)" />
89 91 <text x={padL - 6} y={y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ri-fg-subtle)" style={{ fontVariantNumeric: 'tabular-nums' }}>
90 92 {formatY(t)}
91 93 </text>
@@ -99,7 +101,7 @@ export function LineChart({ series, height = 180, className, formatY = (v: numbe
99 101 return (
100 102 <g key={s.name}>
101 103 {showArea && i === 0 ? <path d={`${d} L${x(last.date).toFixed(1)},${H - padB} L${x(pts[0]!.date).toFixed(1)},${H - padB} Z`} fill={color} opacity={0.06} /> : null}
102 − <path d={d} fill="none" stroke={color} strokeWidth={1.6} strokeDasharray={s.dashed ? '4 3' : undefined} vectorEffect="non-scaling-stroke" />
104 + <path d={d} fill="none" stroke={color} strokeWidth={2} strokeDasharray={s.dashed ? '4 3' : undefined} vectorEffect="non-scaling-stroke" />
103 105 <circle cx={x(last.date)} cy={y(last.value)} r={2.5} fill={color} />
104 106 </g>
105 107 );
modified apps/web/src/components/account/form.tsx +1 −1
@@ -7,7 +7,7 @@ import { passwordStrength } from '@/lib/auth/password-strength';
7 7 import type { ActionState } from '@/lib/auth/state';
8 8 import { cn } from '@/lib/format';
9 9
10 −export const inputClass = 'block w-full rounded-md border border-border bg-elevated px-3 py-2 text-[15px] text-fg shadow-none placeholder:text-subtle focus:border-border-strong focus:outline-none focus:ring-2 focus:ring-[var(--ri-ring)] md:text-sm disabled:opacity-60';
10 +export const inputClass = 'block w-full rounded-md border border-border bg-elevated px-3 py-2 text-base text-fg shadow-none placeholder:text-subtle focus:border-border-strong focus:outline-none focus:ring-2 focus:ring-[var(--ri-ring)] md:text-sm disabled:opacity-60';
11 11
12 12 export function Field({ label, name, error, hint, children, className }: { label: ReactNode; name: string; error?: string | null; hint?: ReactNode; children: ReactNode; className?: string }) {
13 13 return (
added apps/web/src/components/asset/asset-actions.tsx +41 −0
@@ -0,0 +1,41 @@
1 +import Link from 'next/link';
2 +import { Bookmark, FolderPlus, GitCompareArrows, Sparkles } from 'lucide-react';
3 +import { toggleWatchAction } from '@/lib/account/actions';
4 +import { cn } from '@/lib/format';
5 +
6 +/**
7 + * Asset actions: Watch (server action, signs in first when anonymous), Add to collection, Compare,
8 + * Ask AI. Rendered inline on desktop and as a sticky bottom bar on phones (§165).
9 + */
10 +export function AssetActions({ assetId, slug, watched = false, variant = 'inline', className }: { assetId: string; slug: string; watched?: boolean; variant?: 'inline' | 'bar'; className?: string }) {
11 + const back = `/asset/${slug}`;
12 + const btn = variant === 'bar' ? 'flex flex-1 flex-col items-center justify-center gap-0.5 rounded-md py-1.5 text-[10px] font-medium text-muted active:bg-inset' : 'inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-elevated px-3 text-[12px] font-medium text-fg hover:bg-inset';
13 + const icon = variant === 'bar' ? 'h-5 w-5' : 'h-3.5 w-3.5';
14 + return (
15 + <div className={cn(variant === 'bar' ? 'fixed inset-x-0 z-40 border-t border-border bg-elevated/95 backdrop-blur supports-[backdrop-filter]:bg-elevated/85 md:hidden' : 'flex flex-wrap gap-2', className)} style={variant === 'bar' ? { bottom: 'calc(var(--ri-tabbar-h, 0px) + env(safe-area-inset-bottom))' } : undefined}>
16 + <div className={cn(variant === 'bar' ? 'mx-auto flex max-w-lg items-stretch gap-1 px-2 py-1' : 'contents')}>
17 + <form action={toggleWatchAction} className={variant === 'bar' ? 'flex flex-1' : 'contents'}>
18 + <input type="hidden" name="targetType" value="asset" />
19 + <input type="hidden" name="targetId" value={assetId} />
20 + <input type="hidden" name="back" value={back} />
21 + <button type="submit" className={cn(btn, watched && (variant === 'bar' ? 'text-fg' : 'border-fg'))} aria-pressed={watched}>
22 + <Bookmark className={cn(icon, watched && 'fill-current')} />
23 + {watched ? 'Watching' : 'Watch'}
24 + </button>
25 + </form>
26 + <Link href={`/collections?add=${assetId}`} className={btn}>
27 + <FolderPlus className={icon} />
28 + {variant === 'bar' ? 'Collect' : 'Add to collection'}
29 + </Link>
30 + <Link href={`/compare?a=${slug}`} className={btn}>
31 + <GitCompareArrows className={icon} />
32 + Compare
33 + </Link>
34 + <Link href={`/research?asset=${slug}`} className={cn(btn, variant === 'inline' && 'border-transparent bg-accent text-accent-fg hover:opacity-90')}>
35 + <Sparkles className={icon} />
36 + Ask AI
37 + </Link>
38 + </div>
39 + </div>
40 + );
41 +}
added apps/web/src/components/asset/asset-gallery.tsx +112 −0
@@ -0,0 +1,112 @@
1 +'use client';
2 +
3 +import Image from 'next/image';
4 +import { useCallback, useEffect, useRef, useState } from 'react';
5 +import { ChevronLeft, ChevronRight, Maximize2, X } from 'lucide-react';
6 +import { cn } from '@/lib/format';
7 +
8 +export interface GalleryImage {
9 + url: string;
10 + caption?: string | null;
11 +}
12 +
13 +/**
14 + * Hero image with a full-screen, swipeable gallery (scroll-snap + keyboard). Images are shown
15 + * `object-contain` on a neutral surface so cards, watches and boxes keep their proportions.
16 + */
17 +export function AssetGallery({ images, alt, className, priority = true }: { images: GalleryImage[]; alt: string; className?: string; priority?: boolean }) {
18 + const [open, setOpen] = useState(false);
19 + const [index, setIndex] = useState(0);
20 + const track = useRef<HTMLDivElement>(null);
21 + const hero = images[0];
22 +
23 + const go = useCallback(
24 + (i: number) => {
25 + const n = images.length;
26 + const next = ((i % n) + n) % n;
27 + setIndex(next);
28 + track.current?.children[next]?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
29 + },
30 + [images.length],
31 + );
32 +
33 + useEffect(() => {
34 + if (!open) return;
35 + const onKey = (e: KeyboardEvent) => {
36 + if (e.key === 'Escape') setOpen(false);
37 + if (e.key === 'ArrowRight') go(index + 1);
38 + if (e.key === 'ArrowLeft') go(index - 1);
39 + };
40 + document.addEventListener('keydown', onKey);
41 + document.body.style.overflow = 'hidden';
42 + return () => {
43 + document.removeEventListener('keydown', onKey);
44 + document.body.style.overflow = '';
45 + };
46 + }, [open, index, go]);
47 +
48 + if (!hero) {
49 + return (
50 + <div className={cn('relative flex items-center justify-center overflow-hidden rounded-lg border border-border bg-inset', className)}>
51 + <span className="text-[10px] font-medium uppercase tracking-wider text-subtle">No image</span>
52 + </div>
53 + );
54 + }
55 +
56 + return (
57 + <>
58 + <button type="button" onClick={() => setOpen(true)} className={cn('group relative block overflow-hidden rounded-lg border border-border bg-inset text-left', className)} aria-label={`Open image gallery for ${alt}`}>
59 + <Image src={hero.url} alt={alt} fill sizes="(max-width: 768px) 100vw, 360px" className="object-contain p-2 transition-transform duration-300 group-hover:scale-[1.02]" priority={priority} />
60 + <span className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-md bg-black/55 px-1.5 py-1 text-[10px] font-medium text-white backdrop-blur-sm">
61 + <Maximize2 className="h-3 w-3" />
62 + {images.length > 1 ? `${images.length} photos` : 'View'}
63 + </span>
64 + </button>
65 + {open ? (
66 + <div className="fixed inset-0 z-[60] flex flex-col bg-black/95 text-white" role="dialog" aria-modal="true" aria-label={`${alt} gallery`}>
67 + <div className="flex items-center justify-between px-3 pt-[max(env(safe-area-inset-top),0.5rem)] pb-2 text-xs">
68 + <span className="num text-white/80">
69 + {index + 1} / {images.length}
70 + </span>
71 + <button type="button" onClick={() => setOpen(false)} aria-label="Close gallery" className="inline-flex h-9 w-9 items-center justify-center rounded-md hover:bg-white/10">
72 + <X className="h-5 w-5" />
73 + </button>
74 + </div>
75 + <div
76 + ref={track}
77 + className="scrollbar-none flex flex-1 snap-x snap-mandatory overflow-x-auto"
78 + onScroll={(e) => {
79 + const el = e.currentTarget;
80 + const i = Math.round(el.scrollLeft / el.clientWidth);
81 + if (i !== index) setIndex(i);
82 + }}
83 + >
84 + {images.map((im, i) => (
85 + <figure key={`${im.url}-${i}`} className="relative flex h-full w-full shrink-0 snap-center items-center justify-center p-2">
86 + <Image src={im.url} alt={`${alt} — image ${i + 1}`} fill sizes="100vw" className="object-contain" unoptimized />
87 + {im.caption ? <figcaption className="absolute bottom-3 left-3 right-3 truncate text-center text-[11px] text-white/70">{im.caption}</figcaption> : null}
88 + </figure>
89 + ))}
90 + </div>
91 + {images.length > 1 ? (
92 + <div className="flex items-center justify-center gap-6 px-3 pb-[max(env(safe-area-inset-bottom),0.75rem)] pt-2">
93 + <button type="button" onClick={() => go(index - 1)} aria-label="Previous image" className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 hover:bg-white/20">
94 + <ChevronLeft className="h-5 w-5" />
95 + </button>
96 + <div className="flex gap-1.5">
97 + {images.slice(0, 12).map((_, i) => (
98 + <span key={i} className={cn('h-1.5 w-1.5 rounded-full', i === index ? 'bg-white' : 'bg-white/30')} />
99 + ))}
100 + </div>
101 + <button type="button" onClick={() => go(index + 1)} aria-label="Next image" className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 hover:bg-white/20">
102 + <ChevronRight className="h-5 w-5" />
103 + </button>
104 + </div>
105 + ) : (
106 + <div className="pb-[max(env(safe-area-inset-bottom),0.75rem)]" />
107 + )}
108 + </div>
109 + ) : null}
110 + </>
111 + );
112 +}
modified apps/web/src/components/asset/asset-header.tsx +118 −61
@@ -1,13 +1,19 @@
1 −import Image from 'next/image';
2 1 import Link from 'next/link';
3 2 import type { AssetDetail, VariantRow, GuidePrice } from '@/lib/queries/assets';
4 3 import { fmtMoney, fmtNum, fmtRelative, fmtDate, cn, confidenceLabel } from '@/lib/format';
5 4 import { catName, categoryCrumbs } from '@/lib/taxonomy';
6 5 import { Breadcrumbs } from '@/components/ui/page-header';
7 −import { Badge, Delta, Stat } from '@/components/ui/primitives';
8 −import { Evidence, ScoreMeter } from '@/components/ui/evidence';
6 +import { Badge, Delta } from '@/components/ui/primitives';
7 +import { ScoreMeter } from '@/components/ui/evidence';
8 +import { AssetGallery, type GalleryImage } from './asset-gallery';
9 +import { AssetActions } from './asset-actions';
10 +import { VariantTable } from './variant-table';
9 11
10 −export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = null }: { asset: AssetDetail; variants: VariantRow[]; activeVariant: VariantRow | null; tabHref: (v: string | null) => string; guide?: GuidePrice | null }) {
12 +/**
13 + * Asset page header (§126): gallery + identity on the left, the valuation panel on the right.
14 + * Mobile-first: gallery → title → variant chips → valuation → metrics; desktop: two columns.
15 + */
16 +export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = null, images, watched = false }: { asset: AssetDetail; variants: VariantRow[]; activeVariant: VariantRow | null; tabHref: (v: string | null) => string; guide?: GuidePrice | null; images: GalleryImage[]; watched?: boolean }) {
11 17 const v = activeVariant;
12 18 const riv = v ? v.rivUsd : asset.rivUsd;
13 19 const low = v ? v.rivLowUsd : asset.rivLowUsd;
@@ -16,17 +22,33 @@ export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = n
16 22 const n = v ? v.rivSampleSize : asset.rivSampleSize;
17 23 const latest = v ? v.latestSaleUsd : asset.latestSaleUsd;
18 24 const latestAt = v ? v.latestSaleAt : asset.latestSaleAt;
25 + const confLabel = confidenceLabel(conf);
26 + const confTone = confLabel === 'High' ? 'gain' : confLabel === 'Medium' ? 'index' : confLabel === 'Low' ? 'alert' : 'neutral';
27 +
28 + const facts: Array<[string, React.ReactNode]> = (
29 + [
30 + ['Set', asset.setName ? <Link key="s" href={asset.setSlug ? `/set/${asset.setSlug}` : '#'} className="hover:underline">{asset.setName}{asset.setCode ? ` (${asset.setCode})` : ''}</Link> : null],
31 + ['Number', asset.number],
32 + ['Brand', asset.brand ? <Link key="b" href={`/brand/${encodeURIComponent(asset.brand)}`} className="hover:underline">{asset.brand}</Link> : null],
33 + ['Franchise', asset.franchise],
34 + ['Reference', asset.reference],
35 + ['Model', asset.model],
36 + ['Release', asset.releaseDate],
37 + ['Production', asset.productionQuantity != null ? fmtNum(asset.productionQuantity) : null],
38 + ['Original MSRP', asset.originalMsrp != null ? fmtMoney(asset.originalMsrp, asset.originalMsrpCurrency ?? 'USD') : null],
39 + ] as Array<[string, React.ReactNode]>
40 + ).filter(([, val]) => val);
41 +
19 42 return (
20 43 <header>
21 44 <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>
45 + <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start">
46 + {/* identity */}
47 + <div className="grid gap-4 sm:grid-cols-[200px_minmax(0,1fr)] md:grid-cols-[240px_minmax(0,1fr)]">
48 + <AssetGallery images={images} alt={asset.title} className="aspect-[4/3] w-full sm:aspect-square" />
27 49 <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]">
50 + <h1 className="text-[22px] font-semibold leading-tight tracking-tight text-fg sm:text-2xl lg:text-[26px]">{asset.title}</h1>
51 + <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px]">
30 52 <Link href={`/markets/${asset.categorySlug}`}>
31 53 <Badge tone="index">{catName(asset.categorySlug)}</Badge>
32 54 </Link>
@@ -37,98 +59,133 @@ export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = n
37 59 {asset.rarity ? <Badge tone="rarity">{asset.rarity}</Badge> : null}
38 60 {asset.verified ? <Badge tone="gain">Verified record</Badge> : null}
39 61 </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">
62 + {facts.length ? (
63 + <dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[12px] sm:grid-cols-3">
64 + {facts.slice(0, 9).map(([k, val]) => (
65 + <div key={k} className="min-w-0">
55 66 <dt className="text-[10px] uppercase tracking-wider text-subtle">{k}</dt>
56 67 <dd className="truncate text-fg">{val}</dd>
57 68 </div>
58 69 ))}
59 − </dl>
70 + </dl>
71 + ) : null}
60 72 {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')}>
73 + <div className="mt-4">
74 + <p className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-subtle">Variant / grade</p>
75 + <div className="scrollbar-none -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:px-0">
76 + <Chip href={tabHref(null)} active={!v}>
65 77 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`}>
78 + </Chip>
79 + {variants.slice(0, 16).map((x) => (
80 + <Chip key={x.id} href={tabHref(x.id)} active={v?.id === x.id} title={`${x.salesCount} sales`}>
69 81 {x.label}
70 − {x.salesCount ? <span className="num ml-1 opacity-70">{x.salesCount}</span> : null}
71 − </Link>
82 + {x.salesCount ? <span className={cn('num ml-1.5 rounded-sm px-1 text-[10px]', v?.id === x.id ? 'bg-white/20' : 'bg-inset text-subtle')}>{x.salesCount}</span> : null}
83 + </Chip>
72 84 ))}
73 85 </div>
74 86 </div>
75 87 ) : null}
88 + <div className="mt-4 hidden md:block">
89 + <AssetActions assetId={asset.id} slug={asset.slug} watched={watched} />
90 + </div>
91 + </div>
92 + <div className="hidden sm:col-span-2 lg:block">
93 + <VariantTable variants={variants} href={tabHref} activeId={v?.id ?? null} />
76 94 </div>
77 95 </div>
96 +
97 + {/* valuation panel */}
78 98 <aside className="card p-4">
79 99 <div className="flex items-baseline justify-between">
80 100 <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>
101 + <Badge tone={confTone}>{confLabel === 'Insufficient' ? 'No RIV' : `${confLabel} confidence`}</Badge>
82 102 </div>
83 103 {riv !== null ? (
84 104 <>
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)}
105 + <div className="mt-1 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(riv)}</div>
106 + <div className="num mt-1.5 text-[12px] text-muted">
107 + Range {fmtMoney(low)} – {fmtMoney(high)} · <span className="text-fg">{fmtNum(n)}</span> sales used · updated {fmtRelative(asset.updatedAt)}
88 108 </div>
109 + <RangeBar low={low} high={high} value={riv} latest={latest} />
89 110 </>
90 111 ) : guide ? (
91 112 <div className="mt-1">
92 − <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">No RIV yet · latest guide price</div>
93 − <div className="num mt-0.5 text-3xl font-semibold tracking-tight text-fg">{fmtMoney(guide.priceUsd)}</div>
94 − <p className="num text-[11px] text-muted">
113 + <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">Latest guide price</div>
114 + <div className="mt-0.5 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(guide.priceUsd)}</div>
115 + <p className="num mt-1.5 text-[11px] text-muted">
95 116 {guide.sourceName} · {guide.priceKind.replace(/_/g, ' ')}
96 117 {guide.currency !== 'USD' ? ` · ${fmtMoney(guide.price, guide.currency)}` : ''}
97 118 {guide.variantLabel ? ` · ${guide.variantLabel}` : ''} · observed {fmtDate(guide.observationDate)}
98 119 </p>
99 − <p className="mt-1 text-[11px] leading-snug text-subtle">A price-guide observation, not a transaction and not a RareIndex valuation. RIV publishes once enough verified sales exist.</p>
120 + <p className="mt-1.5 text-[11px] leading-snug text-subtle">A price-guide observation, not a transaction and not a RareIndex valuation. RIV publishes once enough verified sales exist.</p>
100 121 </div>
101 122 ) : asset.minAskUsd !== null ? (
102 123 <div className="mt-1">
103 − <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">No RIV yet · lowest current ask</div>
104 − <div className="num mt-0.5 text-3xl font-semibold tracking-tight text-fg">{fmtMoney(asset.minAskUsd)}</div>
105 − <p className="mt-1 text-[11px] leading-snug text-subtle">An asking price across {fmtNum(asset.activeListings)} live listing{asset.activeListings === 1 ? '' : 's'} — not a confirmed transaction.</p>
124 + <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">Lowest current ask</div>
125 + <div className="mt-0.5 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(asset.minAskUsd)}</div>
126 + <p className="mt-1.5 text-[11px] leading-snug text-subtle">An asking price across {fmtNum(asset.activeListings)} live listing{asset.activeListings === 1 ? '' : 's'} — not a confirmed transaction.</p>
106 127 </div>
107 128 ) : (
108 129 <div className="mt-1">
109 − <div className="text-2xl font-semibold text-subtle">Data unavailable</div>
110 − <p className="text-[11px] text-muted">Catalogued, but no verified sale, guide price or listing has been observed yet. Watch this asset to be notified when evidence arrives.</p>
130 + <div className="text-xl font-semibold text-subtle">Data unavailable</div>
131 + <p className="mt-1 text-[11px] leading-snug text-muted">Catalogued, but no verified sale, guide price or listing has been observed yet. Watch this asset to be notified when evidence arrives.</p>
111 132 </div>
112 133 )}
113 − <Evidence className="mt-2" confidence={conf} sampleSize={n} updatedAt={asset.updatedAt} />
114 − <dl className="mt-3 grid grid-cols-2 gap-3 border-t border-border pt-3">
115 − <Stat label="Latest sale" value={latest !== null ? fmtMoney(latest) : '—'} sub={latestAt ? fmtRelative(latestAt) : undefined} />
116 − <Stat label="30D change" value={<Delta value={v ? v.change30d : asset.change30d} />} />
117 − <Stat label="1Y change" value={<Delta value={v ? v.change1y : asset.change1y} />} />
118 − <Stat label="1D change" value={<Delta value={asset.change1d} />} />
119 − <Stat label="All-time high" value={asset.athUsd !== null ? fmtMoney(asset.athUsd) : '—'} sub={asset.athAt ? fmtDate(asset.athAt) : undefined} />
120 − <Stat label="All-time low" value={asset.atlUsd !== null ? fmtMoney(asset.atlUsd) : '—'} sub={asset.atlAt ? fmtDate(asset.atlAt) : undefined} />
121 − <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} />
122 − <Stat label="Sales 30D" value={fmtNum(v ? v.sales30d : asset.sales30d)} sub={`${fmtNum(v ? v.salesCount : asset.salesCount)} all time`} />
134 +
135 + <dl className="mt-4 grid grid-cols-2 gap-x-3 gap-y-3 border-t border-border pt-3 sm:grid-cols-4 lg:grid-cols-2">
136 + <Metric label="Latest sale" value={latest !== null ? fmtMoney(latest) : null} sub={latestAt ? fmtRelative(latestAt) : undefined} />
137 + <Metric label="1D · 30D · 1Y" value={<span className="flex flex-wrap gap-x-1.5"><Delta value={asset.change1d} digits={1} /><Delta value={v ? v.change30d : asset.change30d} digits={1} /><Delta value={v ? v.change1y : asset.change1y} digits={1} /></span>} />
138 + <Metric label="All-time high" value={asset.athUsd !== null ? fmtMoney(asset.athUsd) : null} sub={asset.athAt ? fmtDate(asset.athAt) : undefined} />
139 + <Metric label="All-time low" value={asset.atlUsd !== null ? fmtMoney(asset.atlUsd) : null} sub={asset.atlAt ? fmtDate(asset.atlAt) : undefined} />
140 + <Metric 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} />
141 + <Metric label="Sales 30D" value={fmtNum(v ? v.sales30d : asset.sales30d)} sub={`${fmtNum(v ? v.salesCount : asset.salesCount)} all time`} />
123 142 </dl>
124 − <div className="mt-3 grid grid-cols-1 gap-2 border-t border-border pt-3">
143 + <div className="mt-3 grid grid-cols-1 gap-2.5 border-t border-border pt-3 sm:grid-cols-3 lg:grid-cols-1">
125 144 <ScoreMeter label="Liquidity" value={v ? v.liquidityScore : asset.liquidityScore} />
126 145 <ScoreMeter label="Rarity" value={asset.rarityScore} tone="rarity" />
127 146 <ScoreMeter label="Data quality" value={asset.dataQuality} tone="gain" />
128 147 </div>
129 − <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>
148 + <p className="mt-3 text-[10px] leading-relaxed text-subtle">Estimate from observed public sales, not an offer or appraisal. RareIndex does not authenticate items.</p>
130 149 </aside>
131 150 </div>
151 + <AssetActions assetId={asset.id} slug={asset.slug} watched={watched} variant="bar" />
132 152 </header>
133 153 );
134 154 }
155 +
156 +function Chip({ href, active, children, title }: { href: string; active: boolean; children: React.ReactNode; title?: string }) {
157 + return (
158 + <Link href={href} scroll={false} title={title} className={cn('inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full border px-3 text-[12px] font-medium', active ? 'border-fg bg-accent text-accent-fg' : 'border-border bg-elevated text-muted hover:border-border-strong hover:text-fg')} aria-current={active ? 'true' : undefined}>
159 + {children}
160 + </Link>
161 + );
162 +}
163 +
164 +function Metric({ label, value, sub }: { label: string; value: React.ReactNode | null; sub?: string }) {
165 + return (
166 + <div className="min-w-0">
167 + <dt className="text-[10px] font-medium uppercase tracking-wider text-subtle">{label}</dt>
168 + <dd className="num mt-0.5 truncate text-[15px] font-semibold leading-tight text-fg">{value ?? <span className="text-subtle" title="Data unavailable">—</span>}</dd>
169 + {sub ? <dd className="truncate text-[11px] text-muted">{sub}</dd> : null}
170 + </div>
171 + );
172 +}
173 +
174 +/** Low–high band with RIV and latest sale markers (a meter, not a chart: shows where the estimate sits). */
175 +function RangeBar({ low, high, value, latest }: { low: number | null; high: number | null; value: number; latest: number | null }) {
176 + if (low === null || high === null || high <= low) return null;
177 + const pos = (x: number) => `${Math.max(2, Math.min(98, ((x - low) / (high - low)) * 100))}%`;
178 + return (
179 + <div className="mt-3" aria-hidden>
180 + <div className="relative h-1.5 w-full rounded-full bg-inset">
181 + <div className="absolute inset-y-0 rounded-full" style={{ left: '0%', right: '0%', background: 'var(--ri-index-bg)' }} />
182 + <span className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-elevated" style={{ left: pos(value), background: 'var(--ri-index)' }} title="RIV" />
183 + {latest !== null && latest >= low && latest <= high ? <span className="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-elevated" style={{ left: pos(latest), background: 'var(--ri-series-2, #c2410c)' }} title="Latest sale" /> : null}
184 + </div>
185 + <div className="num mt-1 flex justify-between text-[10px] text-subtle">
186 + <span>low {fmtMoney(low)}</span>
187 + <span>high {fmtMoney(high)}</span>
188 + </div>
189 + </div>
190 + );
191 +}
modified apps/web/src/components/asset/asset-tabs.tsx +27 −20
@@ -1,12 +1,14 @@
1 1 import Image from 'next/image';
2 2 import Link from 'next/link';
3 3 import type { AssetDetail, VariantRow } from '@/lib/queries/assets';
4 −import { getAssetImages, getAssetListings, getAssetObservations, getAssetSalePoints, getAssetSales, getAssetSnapshots, getAssetSources, getComparables, getGradeDistribution, getLatestValuation, getMarketplaceDistribution, getPopulation, getPriceDistribution, getSetSiblings, getSimilarAssets, attachGuidePrices } from '@/lib/queries/assets';
4 +import { getAssetImages, getAssetListings, getAssetObservations, getAssetSalePoints, getAssetSales, getAssetSnapshots, getAssetSources, getComparables, getGradeDistribution, getLatestValuation, getMarketplaceDistribution, getPopulation, getPriceDistribution, getSetSiblings, getSimilarAssets, attachGuidePrices, getValuationHistory } from '@/lib/queries/assets';
5 5 import { AssetCardGrid } from '@/components/market/asset-list';
6 6 import { fmtDate, fmtMoney, fmtNum, fmtRelative, cn } from '@/lib/format';
7 7 import { gradeLabel, humanize, catName } from '@/lib/taxonomy';
8 8 import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge, Delta } from '@/components/ui/primitives';
9 9 import { LineChart } from '@/components/charts/line-chart';
10 +import { PriceChart } from '@/components/charts/price-chart';
11 +import { SalesCards, ListingsCards } from './sales-cards';
10 12 import { BarChart } from '@/components/charts/bar-chart';
11 13 import { SalesTable, ListingsTable } from '@/components/market/sales-table';
12 14 import { SourceLink, Thumb } from '@/components/market/bits';
@@ -17,8 +19,8 @@ export type AssetTab = (typeof ASSET_TABS)[number];
17 19
18 20 export async function OverviewTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {
19 21 const vid = variant?.id ?? '';
20 − const [snaps, points, dist, sales, listings, obs, valuation, siblings, similar] = await Promise.all([
21 − getAssetSnapshots(asset.id, vid, 730),
22 + const [snaps, points, dist, sales, listings, obs, valuation, siblings, similar, valHist] = await Promise.all([
23 + getAssetSnapshots(asset.id, vid),
22 24 getAssetSalePoints(asset.id),
23 25 getPriceDistribution(asset.id, variant?.id ?? null),
24 26 getAssetSales(asset.id, { variantId: variant?.id ?? null, limit: 8 }),
@@ -27,7 +29,9 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari
27 29 getLatestValuation(asset.id, variant?.id ?? null),
28 30 getSetSiblings(asset, 8).then(attachGuidePrices),
29 31 getSimilarAssets(asset, 8).then(attachGuidePrices),
32 + getValuationHistory(asset.id, variant?.id ?? null),
30 33 ]);
34 + const bandByDate = new Map(valHist.map((h) => [h.date, h]));
31 35 const similarOnly = similar.filter((x) => !siblings.some((y) => y.id === x.id));
32 36 const scatter = variant ? points.filter((p) => p.variantId === variant.id) : points;
33 37 const rivSeries = snaps.filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! }));
@@ -46,18 +50,16 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari
46 50 Full history →
47 51 </Link>
48 52 </div>
49 − <LineChart
53 + <PriceChart
50 54 ariaLabel={`Price history for ${asset.title}`}
51 − height={300}
52 − currency
55 + height={320}
53 56 className="mt-3"
57 + defaultPeriod={scatter.length > 30 || rivSeries.length > 400 ? '1Y' : 'ALL'}
54 58 reference={riv !== null ? { value: riv, label: `RIV ${fmtMoney(riv)}` } : null}
55 − series={[
56 − { 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}` })) },
57 − ...(rivSeries.length ? [{ id: 'riv', label: 'RIV', points: rivSeries }] : []),
58 − ...(askSeries.length ? [{ id: 'ask', label: 'Lowest ask', dashed: true, points: askSeries }] : []),
59 − ...(obsSeries.length ? [{ id: 'obs', label: 'Guide price', dashed: true, points: obsSeries }] : []),
60 − ]}
59 + sales={scatter.map((p) => ({ date: p.date, usd: p.usd, label: `${gradeLabel(p.grader, p.grade) ?? 'raw'} · ${p.sourceName}`, variantId: p.variantId }))}
60 + riv={rivSeries.map((p) => { const b = bandByDate.get(p.x); return { x: p.x, y: p.y, low: b?.lowUsd ?? null, high: b?.highUsd ?? null }; })}
61 + ask={askSeries}
62 + guide={obsSeries}
61 63 emptyLabel="No priced observations yet for this asset"
62 64 />
63 65 </Card>
@@ -141,11 +143,13 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari
141 143 <div className="grid gap-4 lg:grid-cols-2">
142 144 <Card className="overflow-hidden">
143 145 <CardHeader title="Recent sales" action={<Link href={`?tab=sales${variant ? `&v=${variant.id}` : ''}`} className="text-muted hover:text-fg">All {sales.total} →</Link>} />
144 − <SalesTable items={sales.items} showAsset={false} />
146 + <div className="md:hidden"><SalesCards items={sales.items} /></div>
147 + <div className="hidden md:block"><SalesTable items={sales.items} showAsset={false} /></div>
145 148 </Card>
146 149 <Card className="overflow-hidden">
147 150 <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>} />
148 − <ListingsTable items={listings} showAsset={false} />
151 + <div className="md:hidden"><ListingsCards items={listings} /></div>
152 + <div className="hidden md:block"><ListingsTable items={listings} showAsset={false} /></div>
149 153 </Card>
150 154 </div>
151 155 {asset.description ? (
@@ -188,20 +192,21 @@ export async function SalesTab({ asset, variant, page }: { asset: AssetDetail; v
188 192 return (
189 193 <Card className="overflow-hidden">
190 194 <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`} />
191 − <SalesTable items={res.items} showAsset={false} />
195 + <div className="md:hidden"><SalesCards items={res.items} /></div>
196 + <div className="hidden md:block"><SalesTable items={res.items} showAsset={false} /></div>
192 197 {res.total > pageSize ? (
193 − <div className="flex items-center justify-between px-4 py-2 text-xs text-muted">
198 + <div className="flex items-center justify-between px-4 py-3 text-xs text-muted">
194 199 <span className="num">
195 200 page {page} / {Math.ceil(res.total / pageSize)}
196 201 </span>
197 202 <span className="flex gap-2">
198 203 {page > 1 ? (
199 − <Link href={`?tab=sales&page=${page - 1}${variant ? `&v=${variant.id}` : ''}`} className="hover:text-fg">
204 + <Link href={`?tab=sales&page=${page - 1}${variant ? `&v=${variant.id}` : ''}`} className="inline-flex h-9 items-center rounded-md border border-border px-3 font-medium text-fg hover:bg-inset">
200 205 ← Newer
201 206 </Link>
202 207 ) : null}
203 208 {page * pageSize < res.total ? (
204 − <Link href={`?tab=sales&page=${page + 1}${variant ? `&v=${variant.id}` : ''}`} className="hover:text-fg">
209 + <Link href={`?tab=sales&page=${page + 1}${variant ? `&v=${variant.id}` : ''}`} className="inline-flex h-9 items-center rounded-md border border-border px-3 font-medium text-fg hover:bg-inset">
205 210 Older →
206 211 </Link>
207 212 ) : null}
@@ -219,12 +224,14 @@ export async function ListingsTab({ asset, variant }: { asset: AssetDetail; vari
219 224 <div className="grid gap-4">
220 225 <Card className="overflow-hidden">
221 226 <CardHeader title="Active listings" subtitle="Sorted by asking price (USD). Cross-listings of the same physical item are grouped when detected." />
222 − <ListingsTable items={live} showAsset={false} />
227 + <div className="md:hidden"><ListingsCards items={live} /></div>
228 + <div className="hidden md:block"><ListingsTable items={live} showAsset={false} /></div>
223 229 </Card>
224 230 {ended.length ? (
225 231 <Card className="overflow-hidden">
226 232 <CardHeader title="Ended / removed listings" subtitle="Listing lifecycle is retained (new → price changed → sold/removed)" />
227 − <ListingsTable items={ended} showAsset={false} />
233 + <div className="md:hidden"><ListingsCards items={ended} /></div>
234 + <div className="hidden md:block"><ListingsTable items={ended} showAsset={false} /></div>
228 235 </Card>
229 236 ) : null}
230 237 </div>
added apps/web/src/components/asset/sales-cards.tsx +75 −0
@@ -0,0 +1,75 @@
1 +import type { SaleRow, ListingRow } from '@/lib/queries/assets';
2 +import { fmtDate, fmtMoney, fmtRelative, cn } from '@/lib/format';
3 +import { humanize } from '@/lib/taxonomy';
4 +import { Badge, Delta, EmptyState } from '@/components/ui/primitives';
5 +import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits';
6 +
7 +/** Phone-first card lists for sales and listings (tables stay for md+). */
8 +export function SalesCards({ items, className, emptyDescription = 'Sales appear once connectors ingest verified transactions.' }: { items: SaleRow[]; className?: string; emptyDescription?: string }) {
9 + if (!items.length) return <EmptyState title="No sales recorded" description={emptyDescription} />;
10 + return (
11 + <ul className={cn('divide-y divide-border', className)}>
12 + {items.map((s) => (
13 + <li key={s.id} className="flex items-start gap-3 px-3 py-2.5">
14 + <Thumb src={s.imageUrls[0]} alt="" size={44} rounded="rounded-md" />
15 + <div className="min-w-0 flex-1">
16 + <div className="flex items-baseline justify-between gap-2">
17 + <span className="num text-[15px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span>
18 + <span className="text-[11px] text-muted" title={s.saleDate.toISOString()}>
19 + {fmtDate(s.saleDate)}
20 + </span>
21 + </div>
22 + {s.price !== null && s.currency && s.currency !== 'USD' ? <div className="num text-[10px] text-subtle">{fmtMoney(s.price, s.currency)} native</div> : null}
23 + <p className="mt-0.5 truncate text-[12px] text-muted" title={s.rawTitle}>
24 + {s.rawTitle}
25 + </p>
26 + <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted">
27 + <GradeBadge grader={s.grader} grade={s.grade} />
28 + {s.condition ? <span>{humanize(s.condition)}</span> : null}
29 + <span>{humanize(s.saleType)}</span>
30 + <SourceLink name={s.sourceName} url={s.sourceUrl} />
31 + {s.status !== 'valid' ? <Badge tone="alert">{s.status}</Badge> : null}
32 + </div>
33 + </div>
34 + </li>
35 + ))}
36 + </ul>
37 + );
38 +}
39 +
40 +export function ListingsCards({ items, className, emptyDescription = 'Live listings appear once marketplace connectors run.' }: { items: ListingRow[]; className?: string; emptyDescription?: string }) {
41 + if (!items.length) return <EmptyState title="No active listings" description={emptyDescription} />;
42 + return (
43 + <ul className={cn('divide-y divide-border', className)}>
44 + {items.map((l) => (
45 + <li key={l.id} className="flex items-start gap-3 px-3 py-2.5">
46 + <Thumb src={l.imageUrls[0]} alt="" size={44} rounded="rounded-md" />
47 + <div className="min-w-0 flex-1">
48 + <div className="flex items-baseline justify-between gap-2">
49 + <span className="num text-[15px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span>
50 + {l.discountToRiv !== null ? (
51 + <span className="inline-flex items-center gap-1 text-[11px]">
52 + <span className="text-subtle">vs RIV</span>
53 + <Delta value={-l.discountToRiv} />
54 + </span>
55 + ) : (
56 + <span className="text-[11px] text-subtle">{fmtRelative(l.lastSeenAt)}</span>
57 + )}
58 + </div>
59 + {l.price !== null && l.currency && l.currency !== 'USD' ? <div className="num text-[10px] text-subtle">{fmtMoney(l.price, l.currency)} native</div> : null}
60 + <p className="mt-0.5 truncate text-[12px] text-muted" title={l.rawTitle}>
61 + {l.rawTitle}
62 + </p>
63 + <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted">
64 + <GradeBadge grader={l.grader} grade={l.grade} />
65 + <span>{humanize(l.listingType)}</span>
66 + {l.seller ? <span className="truncate">{l.seller}</span> : null}
67 + {l.location ? <span className="text-subtle">{l.location}</span> : null}
68 + <SourceLink name={l.sourceName} url={l.sourceUrl} />
69 + </div>
70 + </div>
71 + </li>
72 + ))}
73 + </ul>
74 + );
75 +}
added apps/web/src/components/asset/variant-table.tsx +57 −0
@@ -0,0 +1,57 @@
1 +import Link from 'next/link';
2 +import type { VariantRow } from '@/lib/queries/assets';
3 +import { fmtMoney, fmtNum, fmtRelative, cn, confidenceLabel } from '@/lib/format';
4 +import { Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
5 +
6 +/**
7 + * Dense variant/grade matrix for md+ screens (phones use the chip selector): RIV with its band and
8 + * confidence, latest sale, sales and listings per variant. Rows link to the variant view.
9 + */
10 +export function VariantTable({ variants, href, activeId, className }: { variants: VariantRow[]; href: (id: string | null) => string; activeId: string | null; className?: string }) {
11 + const rows = variants.slice(0, 8);
12 + if (rows.length < 2) return null;
13 + return (
14 + <div className={cn('card overflow-hidden', className)}>
15 + <div className="flex items-baseline justify-between border-b border-border px-3 py-2">
16 + <h2 className="text-[12px] font-semibold text-fg">By variant / grade</h2>
17 + <span className="text-[10px] text-subtle">{variants.length} tracked · click a row to focus</span>
18 + </div>
19 + <Table>
20 + <thead>
21 + <tr>
22 + <th className={th}>Variant</th>
23 + <th className={cn(th, 'text-right')}>RIV</th>
24 + <th className={cn(th, 'text-right')}>Range</th>
25 + <th className={th}>Confidence</th>
26 + <th className={cn(th, 'text-right')}>Latest sale</th>
27 + <th className={cn(th, 'text-right')}>Sales</th>
28 + <th className={cn(th, 'text-right')}>Listings</th>
29 + </tr>
30 + </thead>
31 + <tbody>
32 + {rows.map((v) => {
33 + const label = confidenceLabel(v.rivConfidence);
34 + return (
35 + <tr key={v.id} className={cn('hover:bg-sunken', activeId === v.id && 'bg-inset')}>
36 + <td className={cn(td, 'font-medium')}>
37 + <Link href={href(v.id)} scroll={false} className="hover:underline">
38 + {v.label}
39 + </Link>
40 + </td>
41 + <td className={cn(tdNum, 'font-semibold')}>{v.rivUsd === null ? <span className="text-subtle">—</span> : fmtMoney(v.rivUsd)}</td>
42 + <td className={cn(tdNum, 'text-muted')}>{v.rivLowUsd !== null && v.rivHighUsd !== null ? `${fmtMoney(v.rivLowUsd)} – ${fmtMoney(v.rivHighUsd)}` : <span className="text-subtle">—</span>}</td>
43 + <td className={td}>{v.rivUsd === null ? <span className="text-subtle">—</span> : <Badge tone={label === 'High' ? 'gain' : label === 'Medium' ? 'index' : label === 'Low' ? 'alert' : 'neutral'}>{label} · n={v.rivSampleSize}</Badge>}</td>
44 + <td className={tdNum}>
45 + {v.latestSaleUsd === null ? <span className="text-subtle">—</span> : fmtMoney(v.latestSaleUsd)}
46 + {v.latestSaleAt ? <span className="block text-[10px] text-subtle">{fmtRelative(v.latestSaleAt)}</span> : null}
47 + </td>
48 + <td className={tdNum}>{fmtNum(v.salesCount)}</td>
49 + <td className={tdNum}>{fmtNum(v.activeListings)}</td>
50 + </tr>
51 + );
52 + })}
53 + </tbody>
54 + </Table>
55 + </div>
56 + );
57 +}
modified apps/web/src/components/charts/line-chart.tsx +38 −16
@@ -105,11 +105,11 @@ export function LineChart({ series, height = 260, currency = false, indexed = fa
105 105 const yPad = (yMax - yMin) * 0.08;
106 106 yMin -= yPad;
107 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;
108 + const ticks = niceTicks(yMin, yMax, W < 480 ? 3 : 4);
109 + const sx = (x: number) => Math.round((pad.left + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw)) * 100) / 100;
110 + const sy = (y: number) => Math.round((pad.top + ih - ((y - yMin) / (yMax - yMin)) * ih) * 100) / 100;
111 111 const span = xMax - xMin;
112 − const xTicks = niceTimeTicks(xMin, xMax, Math.max(2, Math.floor(iw / 110)));
112 + const xTicks = niceTimeTicks(xMin, xMax, Math.max(2, Math.min(5, Math.floor(iw / 100))));
113 113 const fmtY = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(Math.abs(v) < 10 ? 1 : 0)}%` : compactNumber(v, { currency }));
114 114 const fmtYFull = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(2)}%` : fullNumber(v, { currency }));
115 115
@@ -134,10 +134,11 @@ export function LineChart({ series, height = 260, currency = false, indexed = fa
134 134 const linePath = (pts: Array<{ x: number; y: number }>, step = false) =>
135 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 136
137 − const tooltipLeft = hover !== null ? Math.min(Math.max(hover + 12, pad.left), W - 200) : 0;
137 + const narrow = W < 560;
138 + const tooltipLeft = hover !== null ? (narrow ? pad.left : Math.min(Math.max(hover + 12, pad.left), W - 200)) : 0;
138 139
139 140 return (
140 − <div ref={ref} className={`relative ${className ?? ''}`}>
141 + <div ref={ref} className={`relative min-w-0 max-w-full overflow-hidden ${className ?? ''}`}>
141 142 {prepared.length > 1 ? (
142 143 <ul className="mb-1 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted" aria-label="Legend">
143 144 {prepared.map((s) => (
@@ -149,26 +150,28 @@ export function LineChart({ series, height = 260, currency = false, indexed = fa
149 150 </ul>
150 151 ) : null}
151 152 <svg
152 − width={W}
153 + width="100%"
153 154 height={H}
154 155 viewBox={`0 0 ${W} ${H}`}
156 + preserveAspectRatio="none"
155 157 role="img"
156 158 aria-label={ariaLabel}
157 − className="block select-none"
158 − onMouseMove={(e) => {
159 + className="block max-w-full select-none"
160 + style={{ touchAction: 'pan-y' }}
161 + onPointerDown={(e) => {
159 162 const rect = e.currentTarget.getBoundingClientRect();
160 − const x = e.clientX - rect.left;
163 + const x = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W;
161 164 setHover(x >= pad.left && x <= pad.left + iw ? x : null);
162 165 }}
163 − onMouseLeave={() => setHover(null)}
164 − onTouchMove={(e) => {
166 + onPointerMove={(e) => {
167 + if (e.pointerType !== 'mouse' && e.buttons === 0) return;
165 168 const rect = e.currentTarget.getBoundingClientRect();
166 − const t = e.touches[0];
167 − if (!t) return;
168 − const x = t.clientX - rect.left;
169 + const x = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W;
169 170 setHover(x >= pad.left && x <= pad.left + iw ? x : null);
170 171 }}
171 − onTouchEnd={() => setHover(null)}
172 + onPointerLeave={(e) => {
173 + if (e.pointerType === 'mouse') setHover(null);
174 + }}
172 175 >
173 176 <defs>
174 177 {prepared.map((s) => (
@@ -245,6 +248,25 @@ export function LineChart({ series, height = 260, currency = false, indexed = fa
245 248 </div>
246 249 ) : null}
247 250 {yLabel ? <div className="mt-1 text-[10px] uppercase tracking-wider text-subtle">{yLabel}</div> : null}
251 + <table className="sr-only">
252 + <caption>{ariaLabel}</caption>
253 + <thead>
254 + <tr>
255 + <th>Series</th>
256 + <th>Date</th>
257 + <th>Value</th>
258 + </tr>
259 + </thead>
260 + <tbody>
261 + {prepared.flatMap((s) => s.pts.slice(-120).map((p, i) => (
262 + <tr key={`${s.id}-${i}`}>
263 + <td>{s.label}</td>
264 + <td>{fmtDayFull(p.x)}</td>
265 + <td>{fmtYFull(p.y)}</td>
266 + </tr>
267 + )))}
268 + </tbody>
269 + </table>
248 270 </div>
249 271 );
250 272 }
added apps/web/src/components/charts/price-chart.tsx +400 −0
@@ -0,0 +1,400 @@
1 +'use client';
2 +
3 +import { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
4 +import { compactNumber, fmtDay, fmtDayFull, fullNumber, niceTicks, parseDay } from './scale';
5 +
6 +/**
7 + * Price-history chart for asset pages: verified sales (dots), daily RareIndex Valuation (line) with its
8 + * low–high band, lowest ask and guide observations (dashed), a current-RIV reference line, period
9 + * control (URL hash), log/linear toggle and a touch-first crosshair. Follows the dataviz spec:
10 + * 2px lines, ≥8px markers with a surface ring, hairline solid grid, ≤5 axis ticks, text in text tokens,
11 + * legend for ≥2 series, hidden data table for assistive tech.
12 + */
13 +
14 +export interface SalePoint {
15 + date: string;
16 + usd: number;
17 + label?: string;
18 + variantId?: string | null;
19 +}
20 +export interface DailyPoint {
21 + x: string;
22 + y: number;
23 + low?: number | null;
24 + high?: number | null;
25 +}
26 +export type Period = '1M' | '3M' | '6M' | '1Y' | '3Y' | '5Y' | 'ALL';
27 +const PERIODS: Array<{ id: Period; days: number | null }> = [
28 + { id: '1M', days: 31 },
29 + { id: '3M', days: 92 },
30 + { id: '6M', days: 183 },
31 + { id: '1Y', days: 366 },
32 + { id: '3Y', days: 1096 },
33 + { id: '5Y', days: 1827 },
34 + { id: 'ALL', days: null },
35 +];
36 +
37 +export interface PriceChartProps {
38 + sales: SalePoint[];
39 + riv?: DailyPoint[];
40 + ask?: DailyPoint[];
41 + guide?: DailyPoint[];
42 + reference?: { value: number; label: string } | null;
43 + height?: number;
44 + ariaLabel: string;
45 + emptyLabel?: string;
46 + /** URL hash key used to persist the period (default "p") */
47 + hashKey?: string;
48 + defaultPeriod?: Period;
49 + className?: string;
50 +}
51 +
52 +const C = {
53 + riv: 'var(--ri-series-1, #2f5bd6)',
54 + sales: 'var(--ri-series-2, #c2410c)',
55 + ask: 'var(--ri-series-3, #0e9384)',
56 + guide: 'var(--ri-series-4, #7c3aed)',
57 +};
58 +
59 +function writeHash(key: string, value: string) {
60 + if (typeof window === 'undefined') return;
61 + const parts = window.location.hash.replace(/^#/, '').split('&').filter((kv) => kv && !kv.startsWith(`${key}=`));
62 + parts.push(`${key}=${encodeURIComponent(value)}`);
63 + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${parts.join('&')}`);
64 + for (const l of hashListeners) l();
65 +}
66 +const hashListeners = new Set<() => void>();
67 +function subscribeHash(cb: () => void) {
68 + hashListeners.add(cb);
69 + window.addEventListener('hashchange', cb);
70 + return () => {
71 + hashListeners.delete(cb);
72 + window.removeEventListener('hashchange', cb);
73 + };
74 +}
75 +
76 +export function PriceChart({ sales, riv = [], ask = [], guide = [], reference = null, height = 300, ariaLabel, emptyLabel = 'No priced observations yet for this asset', hashKey = 'p', defaultPeriod = 'ALL', className }: PriceChartProps) {
77 + const wrap = useRef<HTMLDivElement>(null);
78 + const [width, setWidth] = useState(720);
79 + // Period/scale live in the URL hash (shareable, survives reloads). Read through an external store so
80 + // the server render (no hash) and the client stay consistent without effects.
81 + const hash = useSyncExternalStore(subscribeHash, () => (typeof window === 'undefined' ? '' : window.location.hash), () => '');
82 + const hashPeriod = (hash.replace(/^#/, '').split('&').find((kv) => kv.startsWith(`${hashKey}=`))?.slice(hashKey.length + 1) ?? null) as Period | null;
83 + const period: Period = hashPeriod && PERIODS.some((x) => x.id === hashPeriod) ? hashPeriod : defaultPeriod;
84 + const scaleChoice = hash.includes('scale=log') ? 'log' : hash.includes('scale=linear') ? 'linear' : null;
85 + const [hover, setHover] = useState<{ px: number; sticky: boolean } | null>(null);
86 + const uid = useId();
87 +
88 + useEffect(() => {
89 + const el = wrap.current;
90 + if (!el) return;
91 + const ro = new ResizeObserver((entries) => {
92 + for (const e of entries) setWidth(Math.max(280, Math.floor(e.contentRect.width)));
93 + });
94 + ro.observe(el);
95 + return () => ro.disconnect();
96 + }, []);
97 +
98 + const data = useMemo(() => {
99 + const s = sales.map((p) => ({ x: parseDay(p.date), y: p.usd, label: p.label ?? null })).filter((p) => Number.isFinite(p.x) && p.y > 0).sort((a, b) => a.x - b.x);
100 + const line = (pts: DailyPoint[]) => pts.map((p) => ({ x: parseDay(p.x), y: p.y, low: p.low ?? null, high: p.high ?? null })).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && p.y > 0).sort((a, b) => a.x - b.x);
101 + return { sales: s, riv: line(riv), ask: line(ask), guide: line(guide) };
102 + }, [sales, riv, ask, guide]);
103 +
104 + const all = [...data.sales, ...data.riv, ...data.ask, ...data.guide];
105 + const lastX = all.length ? Math.max(...all.map((p) => p.x)) : 0;
106 + const days = PERIODS.find((p) => p.id === period)?.days ?? null;
107 + const cutoff = days ? lastX - days * 86_400_000 : -Infinity;
108 + const vis = {
109 + sales: data.sales.filter((p) => p.x >= cutoff),
110 + riv: data.riv.filter((p) => p.x >= cutoff),
111 + ask: data.ask.filter((p) => p.x >= cutoff),
112 + guide: data.guide.filter((p) => p.x >= cutoff),
113 + };
114 + const visAll = [...vis.sales, ...vis.riv, ...vis.ask, ...vis.guide];
115 + const available = PERIODS.filter((p) => p.days === null || all.some((pt) => pt.x < lastX - p.days! * 86_400_000) || p.id === period);
116 +
117 + const setPeriodPersist = useCallback((p: Period) => writeHash(hashKey, p), [hashKey]);
118 + const setLog = (v: boolean) => writeHash('scale', v ? 'log' : 'linear');
119 +
120 + if (all.length === 0) {
121 + return (
122 + <div ref={wrap} className={className}>
123 + <div className="flex items-center justify-center rounded-md border border-dashed border-border text-xs text-subtle" style={{ height: Math.min(height, 180) }}>
124 + {emptyLabel}
125 + </div>
126 + </div>
127 + );
128 + }
129 +
130 + // ---- scales
131 + const pad = { top: 14, right: 58, bottom: 26, left: 6 };
132 + const W = width;
133 + const H = height;
134 + const iw = W - pad.left - pad.right;
135 + const ih = H - pad.top - pad.bottom;
136 + const xsAll = visAll.length ? visAll.map((p) => p.x) : [lastX];
137 + let xMin = Math.min(...xsAll);
138 + let xMax = Math.max(...xsAll);
139 + if (days) xMin = Math.min(xMin, cutoff);
140 + if (xMax - xMin < 7 * 86_400_000) {
141 + xMin -= 15 * 86_400_000;
142 + xMax += 15 * 86_400_000;
143 + }
144 + const ysRaw = visAll.flatMap((p) => ('low' in p && p.low != null && p.high != null ? [p.y, p.low, p.high] : [p.y])).concat(reference ? [reference.value] : []);
145 + const positive = ysRaw.filter((y) => y > 0);
146 + const spread = positive.length ? Math.max(...positive) / Math.min(...positive) : 1;
147 + const log = scaleChoice ? scaleChoice === 'log' : spread > 40;
148 + const canLog = log && ysRaw.every((y) => y > 0);
149 + const ty = (y: number) => (canLog ? Math.log10(y) : y);
150 + const fy = (v: number) => (canLog ? 10 ** v : v);
151 + let yMin = Math.min(...ysRaw.map(ty));
152 + let yMax = Math.max(...ysRaw.map(ty));
153 + if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) {
154 + yMin = 0;
155 + yMax = 1;
156 + }
157 + if (yMin === yMax) {
158 + yMin -= Math.abs(yMin) * 0.05 || 1;
159 + yMax += Math.abs(yMax) * 0.05 || 1;
160 + }
161 + const yPad = (yMax - yMin) * 0.08;
162 + yMin -= yPad;
163 + yMax += yPad;
164 + const ticks = canLog ? logTicks(fy(yMin), fy(yMax)) : niceTicks(yMin, yMax, W < 480 ? 3 : 4);
165 + const sx = (x: number) => Math.round((pad.left + ((x - xMin) / (xMax - xMin)) * iw) * 100) / 100;
166 + const sy = (y: number) => Math.round((pad.top + ih - ((ty(y) - yMin) / (yMax - yMin)) * ih) * 100) / 100;
167 + const span = xMax - xMin;
168 + const xTicks = timeTicks(xMin, xMax, Math.max(2, Math.min(5, Math.floor(iw / 96))));
169 + const linePath = (pts: Array<{ x: number; y: number }>) => pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' ');
170 + const band = vis.riv.filter((p) => p.low != null && p.high != null);
171 + const bandPath = band.length >= 2 ? `${band.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.high!).toFixed(1)}`).join(' ')} ${[...band].reverse().map((p) => `L${sx(p.x).toFixed(1)},${sy(p.low!).toFixed(1)}`).join(' ')} Z` : null;
172 +
173 + // ---- hover: nearest date across series
174 + const hoverX = hover ? xMin + ((hover.px - pad.left) / iw) * (xMax - xMin) : null;
175 + const nearest = <T extends { x: number }>(pts: T[]): T | null => {
176 + if (hoverX === null || !pts.length) return null;
177 + let best = pts[0]!;
178 + let bd = Infinity;
179 + for (const p of pts) {
180 + const d = Math.abs(p.x - hoverX);
181 + if (d < bd) {
182 + bd = d;
183 + best = p;
184 + }
185 + }
186 + return best;
187 + };
188 + const hRiv = nearest(vis.riv);
189 + const hAsk = nearest(vis.ask);
190 + const hGuide = nearest(vis.guide);
191 + // sales: nearest within 3% of the span so a finger lands on a dot, not a random one far away
192 + const hSaleCand = nearest(vis.sales);
193 + const hSale = hSaleCand && hoverX !== null && Math.abs(hSaleCand.x - hoverX) <= Math.max(span * 0.03, 86_400_000) ? hSaleCand : null;
194 + const hDate = hover ? [hSale?.x, hRiv?.x, hAsk?.x, hGuide?.x].filter((x): x is number => x !== undefined).sort((a, b) => Math.abs(a - hoverX!) - Math.abs(b - hoverX!))[0] ?? null : null;
195 +
196 + const onPointer = (e: React.PointerEvent<SVGSVGElement>, sticky: boolean) => {
197 + const rect = e.currentTarget.getBoundingClientRect();
198 + const px = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W;
199 + if (px >= pad.left - 8 && px <= pad.left + iw + 8) setHover({ px: Math.min(pad.left + iw, Math.max(pad.left, px)), sticky });
200 + };
201 +
202 + const series = [
203 + vis.sales.length ? { id: 'sales', label: `Sales (${vis.sales.length})`, color: C.sales, kind: 'dots' as const } : null,
204 + vis.riv.length ? { id: 'riv', label: 'RIV (daily)', color: C.riv, kind: 'line' as const } : null,
205 + bandPath ? { id: 'band', label: 'RIV low–high', color: C.riv, kind: 'band' as const } : null,
206 + vis.ask.length ? { id: 'ask', label: 'Lowest ask', color: C.ask, kind: 'dashed' as const } : null,
207 + vis.guide.length ? { id: 'guide', label: 'Guide price', color: C.guide, kind: 'dashed' as const } : null,
208 + ].filter((s): s is NonNullable<typeof s> => Boolean(s));
209 +
210 + const readout = hDate !== null;
211 + const fmtFull = (v: number) => fullNumber(v, { currency: true });
212 +
213 + return (
214 + <div ref={wrap} className={`relative min-w-0 max-w-full overflow-hidden ${className ?? ''}`}>
215 + {/* controls */}
216 + <div className="mb-2 flex items-center justify-between gap-2">
217 + <div className="scrollbar-none -mx-1 flex min-w-0 flex-1 gap-0.5 overflow-x-auto px-1" role="group" aria-label="Period">
218 + {available.map((p) => (
219 + <button key={p.id} type="button" aria-pressed={period === p.id} onClick={() => setPeriodPersist(p.id)} className={`num h-8 min-w-[40px] shrink-0 rounded-md px-2 text-[12px] font-medium ${period === p.id ? 'bg-accent text-accent-fg' : 'text-muted hover:bg-inset hover:text-fg'}`}>
220 + {p.id}
221 + </button>
222 + ))}
223 + </div>
224 + <div className="flex shrink-0 items-center gap-1 text-[11px]">
225 + <button type="button" aria-pressed={canLog} onClick={() => setLog(!log)} disabled={!ysRaw.every((y) => y > 0)} className={`h-8 rounded-md border px-2 font-medium ${canLog ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:text-fg'} disabled:opacity-40`} title="Logarithmic scale">
226 + Log
227 + </button>
228 + </div>
229 + </div>
230 +
231 + {/* readout (touch-friendly: never under the finger) */}
232 + <div className="mb-1 flex min-h-[34px] flex-wrap items-center gap-x-3 gap-y-0.5 rounded-md bg-sunken px-2.5 py-1.5 text-[11px]" aria-live="polite">
233 + {readout ? (
234 + <>
235 + <span className="font-medium text-fg">{fmtDayFull(hDate!)}</span>
236 + {hSale ? (
237 + <span className="inline-flex items-center gap-1 text-muted">
238 + <i className="inline-block h-2 w-2 rounded-full" style={{ background: C.sales }} />
239 + Sale <b className="num font-semibold text-fg">{fmtFull(hSale.y)}</b>
240 + {hSale.label ? <span className="text-subtle">· {hSale.label}</span> : null}
241 + </span>
242 + ) : null}
243 + {hRiv ? (
244 + <span className="inline-flex items-center gap-1 text-muted">
245 + <i className="inline-block h-0.5 w-3 rounded-full" style={{ background: C.riv }} />
246 + RIV <b className="num font-semibold text-fg">{fmtFull(hRiv.y)}</b>
247 + {hRiv.low != null && hRiv.high != null ? <span className="num text-subtle">({compactNumber(hRiv.low, { currency: true })}–{compactNumber(hRiv.high, { currency: true })})</span> : null}
248 + </span>
249 + ) : null}
250 + {hAsk ? (
251 + <span className="inline-flex items-center gap-1 text-muted">
252 + <i className="inline-block h-0.5 w-3 rounded-full" style={{ background: C.ask }} />
253 + Ask <b className="num font-semibold text-fg">{fmtFull(hAsk.y)}</b>
254 + </span>
255 + ) : null}
256 + {hGuide ? (
257 + <span className="inline-flex items-center gap-1 text-muted">
258 + <i className="inline-block h-0.5 w-3 rounded-full" style={{ background: C.guide }} />
259 + Guide <b className="num font-semibold text-fg">{fmtFull(hGuide.y)}</b>
260 + </span>
261 + ) : null}
262 + </>
263 + ) : (
264 + <span className="text-subtle">
265 + {visAll.length ? <>Touch or hover the chart · {vis.sales.length.toLocaleString('en-US')} sales · {vis.riv.length.toLocaleString('en-US')} daily valuations{canLog ? ' · log scale' : ''}</> : 'No observations in this period'}
266 + </span>
267 + )}
268 + </div>
269 +
270 + <svg
271 + width="100%"
272 + height={H}
273 + viewBox={`0 0 ${W} ${H}`}
274 + preserveAspectRatio="none"
275 + role="img"
276 + aria-label={ariaLabel}
277 + className="block max-w-full select-none"
278 + style={{ touchAction: 'pan-y' }}
279 + onPointerDown={(e) => onPointer(e, e.pointerType !== 'mouse')}
280 + onPointerMove={(e) => {
281 + if (e.pointerType === 'mouse' || e.buttons > 0) onPointer(e, e.pointerType !== 'mouse');
282 + }}
283 + onPointerLeave={(e) => {
284 + if (e.pointerType === 'mouse') setHover(null);
285 + }}
286 + >
287 + <defs>
288 + <clipPath id={`${uid}-clip`}>
289 + <rect x={pad.left - 6} y={0} width={iw + 12} height={H} />
290 + </clipPath>
291 + </defs>
292 + {ticks.map((t) => (
293 + <g key={t}>
294 + <line x1={pad.left} x2={pad.left + iw} y1={sy(t)} y2={sy(t)} stroke="var(--ri-border)" strokeWidth={1} shapeRendering="crispEdges" />
295 + <text x={W - pad.right + 6} y={sy(t)} dy="0.32em" fontSize={10} fill="var(--ri-fg-subtle)" className="num">
296 + {compactNumber(t, { currency: true })}
297 + </text>
298 + </g>
299 + ))}
300 + {xTicks.map((t) => (
301 + <text key={t} x={sx(t)} y={H - 8} fontSize={10} textAnchor="middle" fill="var(--ri-fg-subtle)">
302 + {fmtDay(t, span)}
303 + </text>
304 + ))}
305 + <g clipPath={`url(#${uid}-clip)`}>
306 + {bandPath ? <path d={bandPath} fill={C.riv} opacity={0.1} /> : null}
307 + {vis.guide.length >= 2 ? <path d={linePath(vis.guide)} fill="none" stroke={C.guide} strokeWidth={2} strokeDasharray="4 4" strokeLinejoin="round" strokeLinecap="round" /> : null}
308 + {vis.ask.length >= 2 ? <path d={linePath(vis.ask)} fill="none" stroke={C.ask} strokeWidth={2} strokeDasharray="4 4" strokeLinejoin="round" strokeLinecap="round" /> : null}
309 + {vis.riv.length >= 2 ? <path d={linePath(vis.riv)} fill="none" stroke={C.riv} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" /> : null}
310 + {vis.riv.length === 1 ? <circle cx={sx(vis.riv[0]!.x)} cy={sy(vis.riv[0]!.y)} r={4} fill={C.riv} stroke="var(--ri-bg-elevated)" strokeWidth={2} /> : null}
311 + {reference ? (
312 + <g>
313 + <line x1={pad.left} x2={pad.left + iw} y1={sy(reference.value)} y2={sy(reference.value)} stroke="var(--ri-fg-subtle)" strokeWidth={1} strokeDasharray="2 3" />
314 + <text x={pad.left + 4} y={sy(reference.value) - 4} fontSize={10} fill="var(--ri-fg-muted)">
315 + {reference.label}
316 + </text>
317 + </g>
318 + ) : null}
319 + {vis.sales.map((p, i) => (
320 + <circle key={i} cx={sx(p.x)} cy={sy(p.y)} r={vis.sales.length > 400 ? 3 : 4} fill={C.sales} stroke="var(--ri-bg-elevated)" strokeWidth={vis.sales.length > 400 ? 1 : 1.5} opacity={0.9} />
321 + ))}
322 + {hDate !== null ? <line x1={sx(hDate)} x2={sx(hDate)} y1={pad.top} y2={pad.top + ih} stroke="var(--ri-border-strong)" strokeWidth={1} /> : null}
323 + {hSale ? <circle cx={sx(hSale.x)} cy={sy(hSale.y)} r={6} fill={C.sales} stroke="var(--ri-bg-elevated)" strokeWidth={2} /> : null}
324 + {hRiv && hDate !== null ? <circle cx={sx(hRiv.x)} cy={sy(hRiv.y)} r={5} fill={C.riv} stroke="var(--ri-bg-elevated)" strokeWidth={2} /> : null}
325 + </g>
326 + </svg>
327 +
328 + {series.length > 1 ? (
329 + <ul className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted" aria-label="Legend">
330 + {series.map((s) => (
331 + <li key={s.id} className="flex items-center gap-1.5">
332 + {s.kind === 'dots' ? <span className="inline-block h-2 w-2 rounded-full" style={{ background: s.color }} /> : s.kind === 'band' ? <span className="inline-block h-2.5 w-4 rounded-sm" style={{ background: s.color, opacity: 0.2 }} /> : <span className="inline-block h-0.5 w-4 rounded-full" style={{ background: s.color, ...(s.kind === 'dashed' ? { backgroundImage: `repeating-linear-gradient(90deg, ${s.color} 0 4px, transparent 4px 7px)` } : {}) }} />}
333 + {s.label}
334 + </li>
335 + ))}
336 + </ul>
337 + ) : null}
338 +
339 + {/* accessible data table */}
340 + <table className="sr-only">
341 + <caption>{ariaLabel}</caption>
342 + <thead>
343 + <tr>
344 + <th>Date</th>
345 + <th>Series</th>
346 + <th>USD</th>
347 + </tr>
348 + </thead>
349 + <tbody>
350 + {vis.sales.slice(-200).map((p, i) => (
351 + <tr key={`s${i}`}>
352 + <td>{fmtDayFull(p.x)}</td>
353 + <td>Sale{p.label ? ` (${p.label})` : ''}</td>
354 + <td>{fmtFull(p.y)}</td>
355 + </tr>
356 + ))}
357 + {vis.riv.slice(-200).map((p, i) => (
358 + <tr key={`r${i}`}>
359 + <td>{fmtDayFull(p.x)}</td>
360 + <td>RIV</td>
361 + <td>{fmtFull(p.y)}</td>
362 + </tr>
363 + ))}
364 + </tbody>
365 + </table>
366 + </div>
367 + );
368 +}
369 +
370 +function timeTicks(min: number, max: number, count: number): number[] {
371 + if (max <= min) return [min];
372 + const span = max - min;
373 + const day = 86_400_000;
374 + const candidates = [day, 2 * day, 7 * day, 14 * day, 30 * day, 61 * day, 91 * day, 182 * day, 365 * day, 730 * day, 1826 * day, 3652 * day];
375 + const want = Math.max(3, count);
376 + // largest step that still yields at least `want` ticks; otherwise the smallest step
377 + let step = candidates[0]!;
378 + for (const c of candidates) if (span / c >= want - 1) step = c;
379 + const out: number[] = [];
380 + const start = Math.ceil(min / step) * step;
381 + for (let t = start; t <= max; t += step) out.push(t);
382 + while (out.length > count + 1) out.splice(1, 1); // thin if the step produced too many
383 + if (out.length < 2) return [min + span * 0.15, min + span * 0.5, min + span * 0.85];
384 + return out;
385 +}
386 +
387 +function logTicks(min: number, max: number): number[] {
388 + const out: number[] = [];
389 + const lo = Math.floor(Math.log10(Math.max(min, 1e-9)));
390 + const hi = Math.ceil(Math.log10(Math.max(max, 1e-9)));
391 + for (let e = lo; e <= hi; e++) {
392 + for (const m of [1, 2, 5]) {
393 + const v = m * 10 ** e;
394 + if (v >= min && v <= max) out.push(v);
395 + }
396 + }
397 + if (out.length <= 5) return out;
398 + const n = out.length;
399 + return [0, 1, 2, 3, 4].map((i) => out[Math.round((i * (n - 1)) / 4)]!);
400 +}
modified apps/web/src/components/home/hero.tsx +29 −38
@@ -1,50 +1,41 @@
1 1 import Link from 'next/link';
2 −import { Search } from 'lucide-react';
3 2 import type { SiteStats } from '@/lib/queries/site';
4 3 import { fmtNum, fmtRelative } from '@/lib/format';
4 +import { HeroSearch } from '@/components/search/search-sheet';
5 +import { StatStrip } from '@/components/ui/primitives';
5 6
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 +const CHIPS = ['1999 Charizard PSA 10', 'Rolex Daytona 116500LN', 'LEGO 10179', 'Black Lotus Alpha', 'Super Mario 64 sealed', 'Amazing Spider-Man 300'];
7 8
8 9 export function Hero({ stats }: { stats: SiteStats }) {
9 10 return (
10 − <section className="pt-8 sm:pt-14">
11 + <section className="fade-up pt-6 sm:pt-12">
11 12 <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>
13 + <p className="t-label mb-3 flex items-center justify-center gap-2">
14 + <span className="live-dot inline-block h-1.5 w-1.5 rounded-full bg-gain" aria-hidden />
15 + Collectibles market intelligence
16 + </p>
17 + <h1 className="t-display text-fg">The Global Market for Collectibles</h1>
18 + <p className="mx-auto mt-3 max-w-xl text-[15px] leading-relaxed text-muted sm:text-base">Discover, price, track and analyze collectible assets across the world’s marketplaces and auction houses.</p>
19 + <div className="mt-6">
20 + <HeroSearch chips={CHIPS} />
21 + </div>
33 22 </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>
23 + <StatStrip
24 + className="mx-auto mt-8 max-w-4xl"
25 + items={[
26 + { label: 'Tracked assets', value: fmtNum(stats.assets), href: '/explore' },
27 + { label: 'Indexed sales', value: fmtNum(stats.sales), href: '/sales' },
28 + { label: 'Price observations', value: fmtNum(stats.observations), href: '/explore?has=observations' },
29 + { label: 'Live listings', value: fmtNum(stats.activeListings), href: '/listings' },
30 + { label: 'Last update', value: stats.lastUpdateAt ? fmtRelative(stats.lastUpdateAt) : '—', sub: `${fmtNum(stats.sources)} sources · ${fmtNum(stats.connectors)} connectors` },
31 + ]}
32 + />
33 + <p className="mt-2 text-center text-[11px] text-subtle">
34 + Counts are live from the canonical tables · valuations carry confidence and sample size ·{' '}
35 + <Link href="/data" className="underline-offset-2 hover:text-fg hover:underline">
36 + methodology
37 + </Link>
38 + </p>
48 39 </section>
49 40 );
50 41 }
modified apps/web/src/components/home/sections.tsx +192 −128
@@ -5,149 +5,172 @@ import type { LatestSale } from '@/lib/queries/site';
5 5 import type { MarketRow } from '@/lib/queries/markets';
6 6 import { fmtMoney, fmtRelative, fmtNum, cn, fmtDate } from '@/lib/format';
7 7 import { catName, humanize } from '@/lib/taxonomy';
8 −import { Card, CardHeader, Delta, EmptyState } from '@/components/ui/primitives';
8 +import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
9 9 import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits';
10 +import { AssetTile, Rail, RailHeader, type TileMetric } from '@/components/market/asset-tile';
10 11 import { heatStyle } from '@/components/charts/heat';
11 12
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 13 const EMPTY = 'Populates as the pipeline ingests data.';
31 14
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' | 'guide'; empty?: string }) {
15 +/** Editorial rail of poster tiles (snap-scroll on mobile, grid from lg). */
16 +export function AssetRail({ title, subtitle, href, items, metric, empty = EMPTY, priority = false }: { title: string; subtitle?: string; href: string; items: AssetCardData[]; metric: TileMetric; empty?: string; priority?: boolean }) {
33 17 return (
34 − <Card>
35 − <CardHeader title={title} subtitle={subtitle} action={<Link href={href} className="text-muted hover:text-fg">View all →</Link>} />
18 + <section>
19 + <RailHeader title={title} subtitle={subtitle} href={href} />
36 20 {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 ? (a.guideUsd != null ? <span className="text-muted" title="Price-guide observation, not a transaction">{fmtMoney(a.guideUsd)}</span> : <span className="text-subtle">—</span>) : fmtMoney(a.rivUsd)}
46 − rightSub={
47 − metric === 'guide' ? <span className="text-muted">{a.rivUsd === null && a.guideUsd != null ? `guide · ${a.guideSource ?? ''}` : a.rivUsd !== null ? `RIV · ${a.rivSampleSize} sales` : 'catalogued'}</span>
48 − : metric === 'trending' ? <span className="text-muted">score {a.trendingScore?.toFixed(1) ?? '—'}</span>
49 − : metric === 'watchers' ? <span className="text-muted">{fmtNum(a.watchers)} watching</span>
50 − : metric === 'new' ? <span className="text-muted">added {fmtRelative(a.updatedAt)}</span>
51 − : metric === 'riv' ? <span className="text-muted">{a.rivSampleSize} sales</span>
52 − : metric === 'opportunity' ? <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} />
53 − : <Delta value={metric === 'change1d' ? a.change1d : metric === 'change30d' ? a.change30d : a.change7d} />
54 − }
55 − />
21 + <Rail>
22 + {items.slice(0, 8).map((a, i) => (
23 + <AssetTile key={a.id} a={a} metric={metric} priority={priority && i < 4} />
56 24 ))}
57 − </ul>
25 + </Rail>
58 26 ) : (
59 − <EmptyState title="Nothing to show yet" description={empty} className="py-8" />
27 + <Card>
28 + <EmptyState title="Nothing to show yet" description={empty} compact />
29 + </Card>
60 30 )}
61 − </Card>
31 + </section>
32 + );
33 +}
34 +
35 +function SaleTile({ s }: { s: SaleRow }) {
36 + return (
37 + <Link href={s.assetSlug ? `/asset/${s.assetSlug}` : '/sales'} className="card card-hover group flex w-[220px] flex-col gap-2 p-2 sm:w-[240px]">
38 + <span className="flex items-start gap-2.5">
39 + <Thumb src={s.heroImageUrl ?? s.imageUrls[0]} alt="" size={56} rounded="rounded-[5px]" />
40 + <span className="min-w-0 flex-1">
41 + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg" title={s.assetTitle ?? s.rawTitle}>
42 + {s.assetTitle ?? s.rawTitle}
43 + </span>
44 + <span className="mt-0.5 block truncate text-[11px] text-muted">{catName(s.categorySlug)}</span>
45 + </span>
46 + </span>
47 + <span className="flex items-end justify-between gap-2 border-t border-border pt-2">
48 + <span className="flex flex-col leading-tight">
49 + <span className="num text-[15px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span>
50 + <span className="t-caption">
51 + {fmtDate(s.saleDate)} · {s.sourceName}
52 + </span>
53 + </span>
54 + <GradeBadge grader={s.grader} grade={s.grade} />
55 + </span>
56 + </Link>
62 57 );
63 58 }
64 59
65 60 export function SalesRail({ title, subtitle, href, items, hideWhenEmpty = false }: { title: string; subtitle?: string; href: string; items: SaleRow[]; hideWhenEmpty?: boolean }) {
66 61 if (hideWhenEmpty && !items.length) return null;
67 62 return (
68 − <Card>
69 − <CardHeader title={title} subtitle={subtitle} action={<Link href={href} className="text-muted hover:text-fg">View all →</Link>} />
63 + <section>
64 + <RailHeader title={title} subtitle={subtitle} href={href} />
70 65 {items.length ? (
71 − <ul className="divide-y divide-border">
72 − {items.map((s) => (
73 − <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} />} />
66 + <Rail cols="lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6">
67 + {items.slice(0, 8).map((s) => (
68 + <SaleTile key={s.id} s={s} />
74 69 ))}
75 − </ul>
70 + </Rail>
76 71 ) : (
77 − <EmptyState title="No verified sales yet" description="Record sales are drawn from verified transactions only (§154)." className="py-8" />
72 + <Card>
73 + <EmptyState title="No verified sales yet" description="Record sales are drawn from verified transactions only (§154)." compact />
74 + </Card>
78 75 )}
79 − </Card>
76 + </section>
80 77 );
81 78 }
82 79
83 80 export function RadarRail({ items, hideWhenEmpty = false }: { items: RadarRow[]; hideWhenEmpty?: boolean }) {
84 81 if (hideWhenEmpty && !items.length) return null;
85 82 return (
86 − <Card>
87 − <CardHeader title="Rare Finds" subtitle="Rare Radar: unusual appearances and discrepancies" action={<Link href="/radar" className="text-muted hover:text-fg">Open radar →</Link>} />
83 + <section>
84 + <RailHeader title="Rare Finds" subtitle="Rare Radar: unusual appearances and discrepancies" href="/radar" hrefLabel="Open radar" />
88 85 {items.length ? (
89 − <ul className="divide-y divide-border">
90 − {items.map((r) => (
91 − <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>} />
86 + <Rail cols="lg:grid-cols-3 xl:grid-cols-4">
87 + {items.slice(0, 8).map((r) => (
88 + <Link key={r.id} href={`/asset/${r.assetSlug}`} className="card card-hover flex w-[240px] gap-2.5 p-2.5 sm:w-[260px]">
89 + <Thumb src={r.heroImageUrl} alt="" size={52} rounded="rounded-[5px]" />
90 + <span className="min-w-0 flex-1">
91 + <Badge tone={r.kind === 'record_sale' ? 'gold' : r.kind === 'price_discrepancy' ? 'alert' : 'rarity'}>{humanize(r.kind)}</Badge>
92 + <span className="mt-1 line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{r.assetTitle}</span>
93 + <span className="num block text-[11px] text-muted">
94 + {r.rivUsd === null ? 'RIV —' : `RIV ${fmtMoney(r.rivUsd)}`} · signal {r.score.toFixed(0)} · {fmtRelative(r.detectedAt)}
95 + </span>
96 + </span>
97 + </Link>
92 98 ))}
93 − </ul>
99 + </Rail>
94 100 ) : (
95 − <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" />
101 + <Card>
102 + <EmptyState title="Radar is quiet" description="Findings appear when the pipeline detects first listings in years, ultra-low populations or price discrepancies." compact />
103 + </Card>
96 104 )}
97 − </Card>
105 + </section>
98 106 );
99 107 }
100 108
101 109 export function LotsRail({ items, hideWhenEmpty = false }: { items: LotRow[]; hideWhenEmpty?: boolean }) {
102 110 if (hideWhenEmpty && !items.length) return null;
103 111 return (
104 − <Card>
105 − <CardHeader title="Auctions Ending Soon" subtitle="Next 72 hours" action={<Link href="/auctions" className="text-muted hover:text-fg">All auctions →</Link>} />
112 + <section>
113 + <RailHeader title="Auctions Ending Soon" subtitle="Next 72 hours · estimates and bids are the house’s own figures" href="/auctions" hrefLabel="All auctions" />
106 114 {items.length ? (
107 − <ul className="divide-y divide-border">
108 − {items.map((l) => (
109 − <li key={l.id}>
110 − <a href={l.url} target="_blank" rel="noopener nofollow" className="flex items-center gap-2.5 px-4 py-2 hover:bg-sunken">
111 − <Thumb src={l.imageUrls[0]} alt="" size={32} />
112 − <span className="min-w-0 flex-1">
113 − <span className="block truncate text-[13px] font-medium text-fg">{l.title}</span>
114 − <span className="block truncate text-[11px] text-muted">
115 − {l.auctionHouse} · ends {fmtRelative(l.endsAt)}
116 − </span>
115 + <Rail cols="lg:grid-cols-3 xl:grid-cols-4">
116 + {items.slice(0, 8).map((l) => (
117 + <a key={l.id} href={l.url} target="_blank" rel="noopener nofollow" className="card card-hover flex w-[240px] gap-2.5 p-2.5 sm:w-[260px]">
118 + <Thumb src={l.imageUrls[0]} alt="" size={52} rounded="rounded-[5px]" />
119 + <span className="min-w-0 flex-1">
120 + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{l.title}</span>
121 + <span className="block truncate text-[11px] text-muted">
122 + {l.auctionHouse} · ends {fmtRelative(l.endsAt)}
117 123 </span>
118 − <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>
119 − </a>
120 − </li>
124 + <span className="num mt-1 block text-[13px] font-semibold text-fg">{l.currentBid !== null ? fmtMoney(l.currentBid, l.currency ?? 'USD') : l.estimateHigh !== null ? `est. ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</span>
125 + </span>
126 + </a>
121 127 ))}
122 − </ul>
128 + </Rail>
123 129 ) : (
124 − <EmptyState title="No lots ending soon" description="Auction lots appear once auction-house connectors are live." className="py-8" />
130 + <Card>
131 + <EmptyState title="No lots ending soon" description="Auction lots appear once auction-house connectors are live." compact />
132 + </Card>
125 133 )}
126 − </Card>
134 + </section>
127 135 );
128 136 }
129 137
130 138 export function MarketInsights({ rows }: { rows: MarketRow[] }) {
131 − const withData = rows.filter((r) => r.counts.assets > 0);
139 + const withData = rows.filter((r) => r.counts.assets > 0).sort((a, b) => b.counts.sales - a.counts.sales || b.counts.assets - a.counts.assets);
132 140 return (
133 141 <Card>
134 − <CardHeader title="Market Insights" subtitle="Category performance and activity" action={<Link href="/markets" className="text-muted hover:text-fg">Markets →</Link>} />
142 + <CardHeader title="Market Insights" subtitle="Category activity · 30-day change of the category index when published" action={<Link href="/markets" className="text-muted hover:text-fg">Markets →</Link>} />
135 143 {withData.length ? (
136 − <ul className="divide-y divide-border">
137 − {withData.slice(0, 10).map((r) => (
138 − <li key={r.node.slug}>
139 − <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">
140 − <span className="truncate font-medium text-fg">{r.node.name}</span>
141 − <span className="num text-muted">{fmtNum(r.counts.assets)} assets · {fmtNum(r.counts.sales)} sales</span>
142 − <span className="num rounded-sm px-1.5 py-0.5" style={heatStyle(r.snapshot?.change30d)}>
144 + <Table sticky={false}>
145 + <thead>
146 + <tr>
147 + <th className={th}>Category</th>
148 + <th className={cn(th, 'text-right')}>Assets</th>
149 + <th className={cn(th, 'text-right')}>Sales</th>
150 + <th className={cn(th, 'text-right')}>Listings</th>
151 + <th className={cn(th, 'text-right')}>30D</th>
152 + </tr>
153 + </thead>
154 + <tbody>
155 + {withData.slice(0, 10).map((r) => (
156 + <tr key={r.node.slug} className="hover:bg-sunken">
157 + <td className={td}>
158 + <Link href={`/markets/${r.node.slug}`} className="font-medium text-fg hover:underline">
159 + {r.node.name}
160 + </Link>
161 + </td>
162 + <td className={tdNum}>{fmtNum(r.counts.assets)}</td>
163 + <td className={tdNum}>{fmtNum(r.counts.sales)}</td>
164 + <td className={tdNum}>{fmtNum(r.counts.listings)}</td>
165 + <td className={tdNum} style={heatStyle(r.snapshot?.change30d)}>
143 166 <Delta value={r.snapshot?.change30d} />
144 − </span>
145 − </Link>
146 − </li>
147 − ))}
148 − </ul>
167 + </td>
168 + </tr>
169 + ))}
170 + </tbody>
171 + </Table>
149 172 ) : (
150 − <EmptyState title="No category data yet" description={EMPTY} className="py-8" />
173 + <EmptyState title="No category data yet" description={EMPTY} compact />
151 174 )}
152 175 </Card>
153 176 );
@@ -165,55 +188,96 @@ export interface LatestObservation {
165 188 sourceName: string;
166 189 }
167 190
191 +/** Latest transactions as a dense, attributed table; falls back to guide-price observations when no sale exists yet. */
168 192 export function LatestTransactions({ items, observations = [] }: { items: LatestSale[]; observations?: LatestObservation[] }) {
169 193 if (!items.length && observations.length) {
170 194 return (
171 195 <Card>
172 − <CardHeader title="Latest guide price observations" subtitle="No verified sales indexed yet — these are price-guide observations (not transactions), shown with their source and date" action={<Link href="/explore?has=observations" className="text-muted hover:text-fg">Browse →</Link>} />
173 − <ul className="grid grid-cols-1 divide-y divide-border md:grid-cols-2 md:divide-y-0 lg:grid-cols-3">
174 − {observations.map((o) => (
175 − <li key={o.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">
176 − <Link href={`/asset/${o.assetSlug}`} className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-sunken">
177 − <Thumb src={o.heroImageUrl} alt="" size={36} />
178 − <span className="min-w-0 flex-1">
179 − <span className="block truncate text-[13px] font-medium text-fg">{o.assetTitle}</span>
180 − <span className="block truncate text-[11px] text-muted">
181 − {catName(o.categorySlug)} · {o.sourceName} · {humanize(o.priceKind)} · {fmtDate(o.observationDate)}
196 + <CardHeader title="Latest guide price observations" subtitle="No verified sales indexed yet — price-guide observations (not transactions), with source and date" action={<Link href="/explore?has=observations" className="text-muted hover:text-fg">Browse →</Link>} />
197 + <Table>
198 + <thead>
199 + <tr>
200 + <th className={th}>Asset</th>
201 + <th className={th}>Kind</th>
202 + <th className={th}>Source</th>
203 + <th className={th}>Date</th>
204 + <th className={cn(th, 'text-right')}>Price</th>
205 + </tr>
206 + </thead>
207 + <tbody>
208 + {observations.map((o) => (
209 + <tr key={o.id} className="hover:bg-sunken">
210 + <td className={cn(td, 'max-w-[320px]')}>
211 + <span className="flex items-center gap-2.5">
212 + <Thumb src={o.heroImageUrl} alt="" size={32} />
213 + <span className="min-w-0">
214 + <Link href={`/asset/${o.assetSlug}`} className="block truncate font-medium text-fg hover:underline">
215 + {o.assetTitle}
216 + </Link>
217 + <span className="block truncate text-[11px] text-muted">{catName(o.categorySlug)}</span>
218 + </span>
182 219 </span>
183 − </span>
184 − <span className="num text-[13px] font-semibold text-muted">{fmtMoney(o.priceUsd)}</span>
185 − </Link>
186 − </li>
187 − ))}
188 − </ul>
220 + </td>
221 + <td className={cn(td, 'text-muted')}>{humanize(o.priceKind)}</td>
222 + <td className={cn(td, 'text-muted')}>{o.sourceName}</td>
223 + <td className={cn(td, 'text-muted')}>{fmtDate(o.observationDate)}</td>
224 + <td className={cn(tdNum, 'font-semibold text-muted')}>{fmtMoney(o.priceUsd)}</td>
225 + </tr>
226 + ))}
227 + </tbody>
228 + </Table>
189 229 </Card>
190 230 );
191 231 }
192 232 return (
193 233 <Card>
194 − <CardHeader title="Latest transactions" subtitle="Most recent verified sales across sources" action={<Link href="/sales" className="text-muted hover:text-fg">All sales →</Link>} />
234 + <CardHeader title="Latest transactions" subtitle="Most recent verified sales across sources · USD at the sale-date exchange rate" action={<Link href="/sales" className="text-muted hover:text-fg">All sales →</Link>} />
195 235 {items.length ? (
196 − <ul className="grid grid-cols-1 divide-y divide-border md:grid-cols-2 md:divide-y-0 lg:grid-cols-3">
197 − {items.map((s) => (
198 − <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">
199 − <Link href={`/asset/${s.assetSlug}`} className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-sunken">
200 − <Thumb src={s.heroImageUrl} alt="" size={36} />
201 − <span className="min-w-0 flex-1">
202 − <span className="block truncate text-[13px] font-medium text-fg">{s.assetTitle}</span>
203 − <span className="flex items-center gap-1.5 text-[11px] text-muted">
204 − <GradeBadge grader={s.grader} grade={s.grade} />
205 − <span className="truncate">
206 − {fmtDate(s.saleDate)} · <SourceLink name={s.sourceName} url={null} />
236 + <Table>
237 + <thead>
238 + <tr>
239 + <th className={th}>Asset</th>
240 + <th className={th}>Grade</th>
241 + <th className={th}>Type</th>
242 + <th className={th}>Source</th>
243 + <th className={th}>Date</th>
244 + <th className={cn(th, 'text-right')}>Price</th>
245 + </tr>
246 + </thead>
247 + <tbody>
248 + {items.map((s) => (
249 + <tr key={s.id} className="hover:bg-sunken">
250 + <td className={cn(td, 'max-w-[320px]')}>
251 + <span className="flex items-center gap-2.5">
252 + <Thumb src={s.heroImageUrl} alt="" size={32} />
253 + <span className="min-w-0">
254 + <Link href={`/asset/${s.assetSlug}`} className="block truncate font-medium text-fg hover:underline">
255 + {s.assetTitle}
256 + </Link>
257 + <span className="block truncate text-[11px] text-muted">{catName(s.categorySlug)}</span>
207 258 </span>
208 259 </span>
209 − </span>
210 − <span className="num text-[13px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span>
211 − </Link>
212 − </li>
213 − ))}
214 − </ul>
260 + </td>
261 + <td className={td}>
262 + <GradeBadge grader={s.grader} grade={s.grade} />
263 + </td>
264 + <td className={cn(td, 'text-muted')}>{humanize(s.saleType)}</td>
265 + <td className={td}>
266 + <SourceLink name={s.sourceName} url={s.sourceUrl} />
267 + </td>
268 + <td className={cn(td, 'text-muted')}>{fmtDate(s.saleDate)}</td>
269 + <td className={tdNum}>
270 + <span className="flex flex-col items-end leading-tight">
271 + <span className="font-semibold text-fg">{fmtMoney(s.priceUsd)}</span>
272 + {s.currency !== 'USD' ? <span className="text-[10px] text-subtle">{fmtMoney(s.price, s.currency)}</span> : null}
273 + </span>
274 + </td>
275 + </tr>
276 + ))}
277 + </tbody>
278 + </Table>
215 279 ) : (
216 − <EmptyState title="No transactions yet" description="Verified sales stream in here as connectors run." className="py-8" />
280 + <EmptyState title="No transactions yet" description="Verified sales stream in here as connectors run." compact />
217 281 )}
218 282 </Card>
219 283 );
modified apps/web/src/components/layout/header-search.tsx +10 −109
@@ -1,116 +1,17 @@
1 1 'use client';
2 2
3 −import { useRouter } from 'next/navigation';
4 −import { Search } from 'lucide-react';
5 −import { useEffect, useRef, useState } from 'react';
3 +import { SearchTrigger } from '@/components/search/search-sheet';
6 4
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. */
5 +/** Header search: a ⌘K field on desktop, an icon on mobile — both open the full search sheet. */
15 6 export function HeaderSearch() {
16 − const router = useRouter();
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 −
55 7 return (
56 − <form
57 − ref={box}
58 − role="search"
59 − className="relative hidden items-center md:flex"
60 − onSubmit={(e) => {
61 − e.preventDefault();
62 − if (active >= 0 && items[active]) return go(items[active]!.href);
63 − const query = q.trim();
64 − if (query) go(`/search?q=${encodeURIComponent(query)}`);
65 − }}
66 − >
67 − <Search className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-subtle" />
68 − <input
69 − type="search"
70 − value={q}
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 − }}
90 − placeholder="Search cards, watches, games, sneakers, LEGO…"
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"
92 − aria-label="Search collectibles"
93 − aria-autocomplete="list"
94 − aria-expanded={open}
95 − autoComplete="off"
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}
114 − </form>
8 + <>
9 + <div className="hidden md:block">
10 + <SearchTrigger variant="field" />
11 + </div>
12 + <div className="md:hidden">
13 + <SearchTrigger variant="icon" />
14 + </div>
15 + </>
115 16 );
116 17 }
added apps/web/src/components/layout/logo.tsx +10 −0
@@ -0,0 +1,10 @@
1 +/** The RareIndex mark: a square with an "R" and a rising tick — used in the header, PWA icons and OG images. */
2 +export function LogoMark({ className }: { className?: string }) {
3 + return (
4 + <svg viewBox="0 0 64 64" className={className} aria-hidden focusable="false">
5 + <rect width="64" height="64" rx="12" fill="var(--ri-accent, #0a0a0b)" />
6 + <path d="M20 46V18h13.5c6.4 0 10.5 3.6 10.5 9.2 0 4.2-2.3 7.2-6.1 8.4L45 46h-7.3l-6.2-9.4H26.4V46H20zm6.4-14.6h6.5c3.2 0 5.1-1.6 5.1-4.2s-1.9-4.1-5.1-4.1h-6.5v8.3z" fill="var(--ri-accent-fg, #ffffff)" />
7 + <path d="M12 52l9-9 6 5 11-12 8 7" stroke="var(--ri-gain, #3ccb82)" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" opacity="0.95" />
8 + </svg>
9 + );
10 +}
modified apps/web/src/components/layout/mobile-nav.tsx +39 −18
@@ -3,8 +3,9 @@
3 3 import Link from 'next/link';
4 4 import { useEffect, useState } from 'react';
5 5 import { usePathname } from 'next/navigation';
6 −import { Menu, X } from 'lucide-react';
6 +import { ChevronRight, Menu, X } from 'lucide-react';
7 7 import type { NavItem } from '@/config/nav';
8 +import { LogoMark } from './logo';
8 9
9 10 export function MobileNav({ items, groups }: { items: NavItem[]; groups: Array<{ id: NavItem['group']; label: string }> }) {
10 11 const [open, setOpen] = useState(false);
@@ -17,41 +18,61 @@ export function MobileNav({ items, groups }: { items: NavItem[]; groups: Array<{
17 18 }
18 19 useEffect(() => {
19 20 document.body.style.overflow = open ? 'hidden' : '';
21 + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
22 + window.addEventListener('keydown', onKey);
20 23 return () => {
21 24 document.body.style.overflow = '';
25 + window.removeEventListener('keydown', onKey);
22 26 };
23 27 }, [open]);
24 28 return (
25 29 <div className="lg:hidden">
26 − <button type="button" aria-label="Open menu" aria-expanded={open} className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted hover:bg-inset hover:text-fg" onClick={() => setOpen(true)}>
30 + <button type="button" aria-label="Open menu" aria-expanded={open} className="-ml-1 inline-flex h-10 w-10 items-center justify-center rounded-md text-fg hover:bg-inset" onClick={() => setOpen(true)}>
27 31 <Menu className="h-5 w-5" />
28 32 </button>
29 33 {open ? (
30 − <div className="fixed inset-0 z-50 flex" role="dialog" aria-modal="true">
31 − <div className="absolute inset-0 bg-black/40" onClick={() => setOpen(false)} />
32 − <div className="relative flex h-[100dvh] w-[86vw] max-w-sm flex-col overflow-y-auto bg-elevated shadow-pop safe-bottom">
33 − <div className="flex h-14 items-center justify-between border-b border-border px-4">
34 − <span className="text-sm font-semibold">Menu</span>
35 − <button type="button" aria-label="Close menu" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted hover:bg-inset" onClick={() => setOpen(false)}>
34 + <div className="fixed inset-0 z-[65] flex" role="dialog" aria-modal="true" aria-label="Menu">
35 + <div className="absolute inset-0 bg-black/45 backdrop-blur-[2px]" onClick={() => setOpen(false)} />
36 + <div className="sheet-in relative flex h-[100dvh] w-[88vw] max-w-sm flex-col bg-elevated shadow-pop">
37 + <div className="flex h-[var(--ri-header-h)] items-center justify-between border-b border-border px-4" style={{ marginTop: 'env(safe-area-inset-top)' }}>
38 + <span className="flex items-center gap-2">
39 + <LogoMark className="h-6 w-6" />
40 + <span className="text-[15px] font-semibold tracking-tight">
41 + Rare<span className="text-muted">Index</span>
42 + </span>
43 + </span>
44 + <button type="button" aria-label="Close menu" className="inline-flex h-10 w-10 items-center justify-center rounded-md text-muted hover:bg-inset" onClick={() => setOpen(false)}>
36 45 <X className="h-5 w-5" />
37 46 </button>
38 47 </div>
39 − <nav className="flex-1 px-2 py-3">
48 + <nav className="flex-1 overflow-y-auto overscroll-contain px-2 py-3 safe-bottom">
40 49 {groups.map((g) => (
41 50 <div key={g.id} className="mb-3">
42 − <p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-subtle">{g.label}</p>
51 + <p className="t-label px-2 pb-1">{g.label}</p>
43 52 {items
44 53 .filter((n) => n.group === g.id)
45 − .map((n) => (
46 − <Link key={n.href} href={n.href} className={`block rounded-md px-2 py-2 text-[15px] ${pathname === n.href ? 'bg-inset font-medium text-fg' : 'text-muted'}`}>
47 − {n.label}
48 − </Link>
49 − ))}
54 + .map((n) => {
55 + const active = pathname === n.href;
56 + return (
57 + <Link key={n.href} href={n.href} className={`flex min-h-[44px] items-center justify-between gap-3 rounded-md px-2 py-2 ${active ? 'bg-inset' : 'hover:bg-sunken'}`}>
58 + <span className="min-w-0">
59 + <span className={`block text-[15px] ${active ? 'font-medium text-fg' : 'text-fg'}`}>{n.label}</span>
60 + <span className="block truncate text-[11px] text-subtle">{n.description}</span>
61 + </span>
62 + <ChevronRight className="h-4 w-4 shrink-0 text-subtle" />
63 + </Link>
64 + );
65 + })}
50 66 </div>
51 67 ))}
52 − <Link href="/account" className="mt-2 block rounded-md border border-border px-3 py-2 text-center text-sm font-medium">
53 − Sign in
54 − </Link>
68 + <div className="mt-2 grid grid-cols-2 gap-2 px-1">
69 + <Link href="/login" className="btn h-11">
70 + Sign in
71 + </Link>
72 + <Link href="/signup" className="btn-primary h-11">
73 + Create account
74 + </Link>
75 + </div>
55 76 </nav>
56 77 </div>
57 78 </div>
modified apps/web/src/components/layout/site-footer.tsx +19 −4
@@ -6,15 +6,19 @@ export function SiteFooter() {
6 6 return (
7 7 <footer className="mt-16 border-t border-border bg-sunken">
8 8 <div className="mx-auto max-w-[1440px] px-4 py-10 sm:px-6">
9 − <div className="grid grid-cols-2 gap-8 md:grid-cols-6">
9 + <div className="grid grid-cols-2 gap-x-6 gap-y-8 md:grid-cols-6">
10 10 <div className="col-span-2 md:col-span-2">
11 11 <Logo />
12 12 <p className="mt-3 max-w-xs text-xs leading-relaxed text-muted">The global data layer for collectible assets: prices, sales, listings, rarity, liquidity and indices across marketplaces and auction houses.</p>
13 + <p className="mt-3 flex items-center gap-1.5 text-[11px] text-subtle">
14 + <span className="live-dot inline-block h-1.5 w-1.5 rounded-full bg-gain" aria-hidden />
15 + Pipelines running · data refreshed continuously
16 + </p>
13 17 </div>
14 18 {NAV_GROUPS.filter((g) => g.id !== 'discover').map((g) => (
15 19 <div key={g.id}>
16 − <p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-subtle">{g.label}</p>
17 − <ul className="space-y-1">
20 + <p className="t-label mb-2">{g.label}</p>
21 + <ul className="space-y-1.5">
18 22 {NAV.filter((n) => n.group === g.id).map((n) => (
19 23 <li key={n.href}>
20 24 <Link href={n.href} className="text-xs text-muted hover:text-fg">
@@ -34,7 +38,18 @@ export function SiteFooter() {
34 38 </Link>
35 39 .
36 40 </p>
37 − <p className="mt-2">© {new Date().getFullYear()} RareIndex.io</p>
41 + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
42 + <span>© {new Date().getFullYear()} RareIndex.io</span>
43 + <Link href="/data" className="hover:text-fg">
44 + Methodology
45 + </Link>
46 + <Link href="/api-docs" className="hover:text-fg">
47 + API
48 + </Link>
49 + <Link href="/about" className="hover:text-fg">
50 + About
51 + </Link>
52 + </p>
38 53 </div>
39 54 </div>
40 55 </footer>
modified apps/web/src/components/layout/site-header.tsx +18 −14
@@ -4,41 +4,45 @@ import { ThemeToggle } from './theme-toggle';
4 4 import { MobileNav } from './mobile-nav';
5 5 import { HeaderSearch } from './header-search';
6 6 import { UserMenu } from '@/components/account/user-menu';
7 +import { LogoMark } from './logo';
7 8
8 −export function Logo({ className }: { className?: string }) {
9 +export function Logo({ className, compact = false }: { className?: string; compact?: boolean }) {
9 10 return (
10 11 <Link href="/" className={`flex items-center gap-2 ${className ?? ''}`} aria-label="RareIndex home">
11 − <span className="inline-flex h-6 w-6 items-center justify-center rounded-sm bg-accent text-[11px] font-bold text-accent-fg">R</span>
12 − <span className="text-[15px] font-semibold tracking-tight text-fg">
13 − Rare<span className="text-muted">Index</span>
14 − </span>
12 + <LogoMark className="h-6 w-6" />
13 + {!compact ? (
14 + <span className="text-[15px] font-semibold tracking-tight text-fg">
15 + Rare<span className="text-muted">Index</span>
16 + </span>
17 + ) : null}
15 18 </Link>
16 19 );
17 20 }
18 21
19 22 export function SiteHeader() {
20 23 return (
21 − <header className="sticky top-0 z-40 border-b border-border bg-bg/85 backdrop-blur supports-[backdrop-filter]:bg-bg/70">
22 − <div className="mx-auto flex h-14 max-w-[1440px] items-center gap-3 px-4 sm:px-6">
24 + <header className="sticky top-0 z-40 border-b border-border bg-bg/85 backdrop-blur-md supports-[backdrop-filter]:bg-bg/70" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
25 + <div className="mx-auto flex h-[var(--ri-header-h)] max-w-[1440px] items-center gap-2 px-3 sm:px-6">
23 26 <MobileNav items={NAV} groups={NAV_GROUPS} />
24 27 <Logo />
25 − <nav className="ml-4 hidden items-center gap-1 lg:flex" aria-label="Primary">
28 + <nav className="ml-3 hidden items-center gap-0.5 lg:flex" aria-label="Primary">
26 29 {PRIMARY_NAV.map((n) => (
27 30 <Link key={n.href} href={n.href} className="rounded-md px-2.5 py-1.5 text-[13px] font-medium text-muted hover:bg-inset hover:text-fg">
28 31 {n.label}
29 32 </Link>
30 33 ))}
31 34 <details className="group relative">
32 − <summary className="cursor-pointer list-none rounded-md px-2.5 py-1.5 text-[13px] font-medium text-muted hover:bg-inset hover:text-fg [&::-webkit-details-marker]:hidden">More</summary>
33 − <div className="absolute left-0 top-full z-50 mt-1 grid w-[640px] grid-cols-3 gap-4 rounded-lg border border-border bg-elevated p-4 shadow-pop">
35 + <summary className="plain cursor-pointer rounded-md px-2.5 py-1.5 text-[13px] font-medium text-muted hover:bg-inset hover:text-fg group-open:bg-inset group-open:text-fg">More</summary>
36 + <div className="absolute left-0 top-full z-50 mt-1.5 grid w-[680px] grid-cols-3 gap-5 rounded-lg border border-border bg-elevated p-4 shadow-pop">
34 37 {NAV_GROUPS.map((g) => (
35 38 <div key={g.id}>
36 − <p className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-subtle">{g.label}</p>
39 + <p className="t-label mb-1.5">{g.label}</p>
37 40 <ul className="space-y-0.5">
38 41 {NAV.filter((n) => n.group === g.id).map((n) => (
39 42 <li key={n.href}>
40 − <Link href={n.href} className="block rounded-sm px-1.5 py-1 text-[13px] text-muted hover:bg-inset hover:text-fg">
41 − {n.label}
43 + <Link href={n.href} className="block rounded-sm px-1.5 py-1 hover:bg-inset">
44 + <span className="block text-[13px] text-fg">{n.label}</span>
45 + <span className="block truncate text-[11px] text-subtle">{n.description}</span>
42 46 </Link>
43 47 </li>
44 48 ))}
@@ -48,7 +52,7 @@ export function SiteHeader() {
48 52 </div>
49 53 </details>
50 54 </nav>
51 − <div className="ml-auto flex items-center gap-1.5">
55 + <div className="ml-auto flex items-center gap-1">
52 56 <HeaderSearch />
53 57 <ThemeToggle />
54 58 <UserMenu />
added apps/web/src/components/layout/tab-bar.tsx +40 −0
@@ -0,0 +1,40 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { usePathname } from 'next/navigation';
5 +import { Compass, Home, LineChart, ScanLine, UserRound } from 'lucide-react';
6 +import { cn } from '@/lib/format';
7 +
8 +const TABS = [
9 + { href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
10 + { href: '/explore', label: 'Explore', icon: Compass, match: (p: string) => p.startsWith('/explore') || p.startsWith('/search') || p.startsWith('/asset') || p.startsWith('/set') || p.startsWith('/brand') },
11 + { href: '/markets', label: 'Markets', icon: LineChart, match: (p: string) => p.startsWith('/markets') || p.startsWith('/rareindex') || p.startsWith('/trending') || p.startsWith('/sales') || p.startsWith('/listings') || p.startsWith('/auctions') || p.startsWith('/market-map') },
12 + { href: '/scanner', label: 'Scanner', icon: ScanLine, match: (p: string) => p.startsWith('/scanner') || p.startsWith('/research') },
13 + { href: '/account', label: 'Account', icon: UserRound, match: (p: string) => p.startsWith('/account') || p.startsWith('/collections') || p.startsWith('/watchlist') || p.startsWith('/alerts') || p.startsWith('/login') || p.startsWith('/signup') || p.startsWith('/notifications') },
14 +];
15 +
16 +/** Mobile bottom tab bar (§165): five primary destinations, safe-area aware, hidden from md up. */
17 +export function TabBar() {
18 + const pathname = usePathname() ?? '/';
19 + if (pathname.startsWith('/admin')) return null;
20 + return (
21 + <nav className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-elevated/92 backdrop-blur-md md:hidden" aria-label="Primary mobile" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
22 + <ul className="grid h-[var(--ri-tabbar-h)] grid-cols-5">
23 + {TABS.map((t) => {
24 + const active = t.match(pathname);
25 + const Icon = t.icon;
26 + return (
27 + <li key={t.href}>
28 + <Link href={t.href} aria-current={active ? 'page' : undefined} className={cn('flex h-full flex-col items-center justify-center gap-0.5 text-[10px] font-medium', active ? 'text-fg' : 'text-subtle')}>
29 + <span className={cn('flex h-7 w-12 items-center justify-center rounded-full transition-colors', active && 'bg-inset')}>
30 + <Icon className="h-[18px] w-[18px]" strokeWidth={active ? 2.25 : 1.75} />
31 + </span>
32 + {t.label}
33 + </Link>
34 + </li>
35 + );
36 + })}
37 + </ul>
38 + </nav>
39 + );
40 +}
modified apps/web/src/components/market/asset-browser.tsx +5 −5
@@ -71,9 +71,9 @@ export async function AssetBrowser({ scope, sp, basePath, title = 'Assets in thi
71 71 </form>
72 72 }
73 73 />
74 − <div className="flex flex-col gap-2 border-b border-border px-4 py-2 text-[11px]">
74 + <div className="flex flex-col gap-1.5 border-b border-border px-4 py-2 text-[11px]">
75 75 {subcategories.length ? (
76 − <div className="flex flex-wrap items-center gap-1">
76 + <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial items-center gap-1">
77 77 <span className="mr-1 text-subtle">Subcategory</span>
78 78 <Chip href={link({ sub: null })} active={!sub}>
79 79 All
@@ -85,7 +85,7 @@ export async function AssetBrowser({ scope, sp, basePath, title = 'Assets in thi
85 85 ))}
86 86 </div>
87 87 ) : null}
88 − <div className="flex flex-wrap items-center gap-1">
88 + <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial items-center gap-1">
89 89 <span className="mr-1 text-subtle">Show</span>
90 90 {HAS.map((h) => (
91 91 <Chip key={h.id || 'all'} href={link({ has: h.id || null })} active={(has ?? '') === h.id}>
@@ -93,7 +93,7 @@ export async function AssetBrowser({ scope, sp, basePath, title = 'Assets in thi
93 93 </Chip>
94 94 ))}
95 95 </div>
96 − <div className="flex flex-wrap items-center gap-1">
96 + <div className="rail sm:mx-0 sm:flex-wrap sm:px-0 sm:[&>*]:flex-initial items-center gap-1">
97 97 <span className="mr-1 text-subtle">Sort</span>
98 98 {SORTS.map((s) => (
99 99 <Chip key={s} href={link({ sort: s === 'relevance' ? null : s })} active={sort === s}>
@@ -134,7 +134,7 @@ export async function AssetBrowser({ scope, sp, basePath, title = 'Assets in thi
134 134
135 135 function Chip({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) {
136 136 return (
137 − <Link href={href} scroll={false} className={cn('rounded-full border px-2 py-0.5', active ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:border-border-strong hover:text-fg')}>
137 + <Link href={href} scroll={false} className={cn('chip h-7 text-[11px]', active && 'chip-active')}>
138 138 {children}
139 139 </Link>
140 140 );
modified apps/web/src/components/market/asset-list.tsx +20 −18
@@ -4,6 +4,7 @@ import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format
4 4 import { catName } from '@/lib/taxonomy';
5 5 import { Delta, Table, th, td, tdNum, EmptyState } from '@/components/ui/primitives';
6 6 import { Thumb } from './bits';
7 +import { PriceLine, TileImage } from './asset-tile';
7 8
8 9 export type AssetColumn = 'riv' | 'change1d' | 'change7d' | 'change30d' | 'change1y' | 'latestSale' | 'sales' | 'sales30d' | 'listings' | 'minAsk' | 'liquidity' | 'rarity' | 'trending' | 'opportunity' | 'watchers' | 'updated';
9 10
@@ -134,39 +135,40 @@ export function AssetTable({ items, columns = DEFAULT_COLUMNS, rank = false, sta
134 135 );
135 136 }
136 137
137 −/** Compact card for mobile grids and discovery rails. */
138 +/** Compact horizontal card for mobile lists and grids: image, title, set/year, honest price line, one metric. */
138 139 export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCardData; className?: string; metric?: 'change30d' | 'change7d' | 'change1d' | 'latestSale' | 'opportunity' | 'trending' }) {
140 + const value = metric === 'change1d' ? a.change1d : metric === 'change7d' ? a.change7d : metric === 'change30d' ? a.change30d : null;
139 141 const metricNode =
140 142 metric === 'latestSale' ? (
141 − <span className="text-[11px] text-muted">{a.latestSaleUsd === null ? (a.number ? `#${a.number}` : a.setName ?? '') : `Last sale ${fmtMoney(a.latestSaleUsd)}`}</span>
143 + a.latestSaleUsd !== null && a.rivUsd !== null ? <span className="num text-[11px] text-muted">last {fmtMoney(a.latestSaleUsd)}</span> : a.number ? <span className="num text-[11px] text-subtle">#{a.number}</span> : null
142 144 ) : metric === 'opportunity' ? (
143 − <span className="text-[11px] text-muted">
144 − Opportunity <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[11px]" />
145 − </span>
145 + a.valueOpportunity !== null ? <span className="text-[11px] text-muted">vs RIV <Delta value={-a.valueOpportunity} className="text-[11px]" /></span> : null
146 146 ) : metric === 'trending' ? (
147 − <span className="text-[11px] text-muted">Trending {a.trendingScore === null ? '—' : a.trendingScore.toFixed(1)}</span>
148 − ) : (
147 + a.trendingScore !== null ? <span className="num text-[11px] text-muted">trend {a.trendingScore.toFixed(1)}</span> : null
148 + ) : value !== null ? (
149 149 <span className="text-[11px] text-muted">
150 − {metric === 'change1d' ? '1D' : metric === 'change7d' ? '7D' : '30D'} <Delta value={metric === 'change1d' ? a.change1d : metric === 'change7d' ? a.change7d : a.change30d} className="text-[11px]" />
150 + {metric === 'change1d' ? '1D' : metric === 'change7d' ? '7D' : '30D'} <Delta value={value} arrow className="text-[11px]" />
151 151 </span>
152 − );
152 + ) : a.salesCount ? (
153 + <span className="num text-[11px] text-subtle">{fmtNum(a.salesCount)} sales</span>
154 + ) : null;
153 155 return (
154 − <Link href={`/asset/${a.slug}`} className={cn('card flex gap-3 p-3 transition-colors hover:border-border-strong', className)}>
155 − <Thumb src={a.heroImageUrl} alt="" size={56} rounded="rounded-md" />
156 + <Link href={`/asset/${a.slug}`} className={cn('card card-hover group flex min-h-[88px] gap-3 p-2.5', className)}>
157 + <span className="w-16 shrink-0">
158 + <TileImage src={a.heroImageUrl} alt="" familySlug={a.familySlug} sizes="64px" />
159 + </span>
156 160 <span className="flex min-w-0 flex-1 flex-col">
157 − <span className="truncate text-[13px] font-medium text-fg" title={a.title}>
161 + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg" title={a.title}>
158 162 {a.title}
159 163 </span>
160 − <span className="truncate text-[11px] text-muted">
164 + <span className="mt-0.5 truncate text-[11px] text-muted">
161 165 {catName(a.categorySlug)}
162 166 {a.setName ? ` · ${a.setName}` : ''}
163 167 {a.year ? ` · ${a.year}` : ''}
164 168 </span>
165 − <span className="mt-auto flex items-baseline justify-between gap-2 pt-1">
166 − <span className="num text-sm font-semibold text-fg" title={a.rivUsd === null ? (a.guideUsd != null ? `Price-guide observation from ${a.guideSource ?? 'a guide'} (${a.guideDate ?? ''}) — not a transaction` : 'Insufficient evidence for a valuation') : `RareIndex Valuation · ${confidenceLabel(a.rivConfidence)} confidence · ${a.rivSampleSize} sales`}>
167 − {a.rivUsd === null ? (a.guideUsd != null ? <span className="font-medium text-muted">{fmtMoney(a.guideUsd)} <span className="text-[10px] font-normal text-subtle">guide</span></span> : <span className="text-subtle">RIV —</span>) : fmtMoney(a.rivUsd)}
168 − </span>
169 − {metricNode}
169 + <span className="mt-auto flex items-end justify-between gap-2 pt-1.5">
170 + <PriceLine a={a} size="sm" />
171 + <span className="shrink-0 text-right">{metricNode}</span>
170 172 </span>
171 173 </span>
172 174 </Link>
added apps/web/src/components/market/asset-tile.tsx +150 −0
@@ -0,0 +1,150 @@
1 +import Image from 'next/image';
2 +import Link from 'next/link';
3 +import type { ReactNode } from 'react';
4 +import type { AssetCard as AssetCardData } from '@/lib/queries/assets';
5 +import { cn, confidenceLabel, fmtMoney, fmtNum, fmtRelative } from '@/lib/format';
6 +import { catName } from '@/lib/taxonomy';
7 +import { Delta } from '@/components/ui/primitives';
8 +
9 +export type TileMetric = 'change7d' | 'change1d' | 'change30d' | 'trending' | 'watchers' | 'riv' | 'opportunity' | 'new' | 'guide' | 'latestSale';
10 +
11 +/** Object image: cards/comics are portrait (4:5), everything else squares; always `contain` on a neutral field. */
12 +function aspectFor(familySlug: string): string {
13 + return familySlug === 'trading_cards' || familySlug === 'sports_cards' || familySlug === 'comics' || familySlug === 'manga' || familySlug === 'books' ? 'aspect-[4/5]' : 'aspect-square';
14 +}
15 +
16 +export function TileImage({ src, alt, familySlug, sizes = '(min-width: 1280px) 200px, (min-width: 768px) 25vw, 45vw', priority = false, className }: { src: string | null | undefined; alt: string; familySlug: string; sizes?: string; priority?: boolean; className?: string }) {
17 + return (
18 + <span className={cn('relative block w-full overflow-hidden rounded-[5px] bg-sunken', aspectFor(familySlug), className)}>
19 + {src ? (
20 + <Image src={src} alt={alt} fill sizes={sizes} priority={priority} loading={priority ? undefined : 'lazy'} className="object-contain p-2 transition-transform duration-300 ease-out group-hover:scale-[1.03]" unoptimized={src.endsWith('.svg')} />
21 + ) : (
22 + <span className="absolute inset-0 flex items-center justify-center">
23 + <span className="t-label">No image</span>
24 + </span>
25 + )}
26 + </span>
27 + );
28 +}
29 +
30 +/** Price line: RIV with confidence, or the guide price honestly labelled, or the latest sale. */
31 +export function PriceLine({ a, size = 'md', className }: { a: AssetCardData; size?: 'sm' | 'md' | 'lg'; className?: string }) {
32 + const valueCls = size === 'lg' ? 'text-xl' : size === 'sm' ? 'text-[13px]' : 'text-[15px]';
33 + if (a.rivUsd !== null) {
34 + return (
35 + <span className={cn('flex flex-col leading-tight', className)} title={`RareIndex Valuation · ${confidenceLabel(a.rivConfidence)} confidence · ${a.rivSampleSize} sales`}>
36 + <span className={cn('num font-semibold text-fg', valueCls)}>{fmtMoney(a.rivUsd)}</span>
37 + <span className="t-caption">
38 + RIV · {confidenceLabel(a.rivConfidence)} · n={a.rivSampleSize}
39 + </span>
40 + </span>
41 + );
42 + }
43 + if (a.guideUsd !== null && a.guideUsd !== undefined) {
44 + return (
45 + <span className={cn('flex flex-col leading-tight', className)} title={`Price-guide observation from ${a.guideSource ?? 'a guide'} (${a.guideDate ?? ''}) — not a transaction and not a valuation`}>
46 + <span className={cn('num font-semibold text-muted', valueCls)}>{fmtMoney(a.guideUsd)}</span>
47 + <span className="t-caption">Guide · {a.guideSource ?? a.guideKind ?? 'observed'}</span>
48 + </span>
49 + );
50 + }
51 + if (a.latestSaleUsd !== null) {
52 + return (
53 + <span className={cn('flex flex-col leading-tight', className)}>
54 + <span className={cn('num font-semibold text-fg', valueCls)}>{fmtMoney(a.latestSaleUsd)}</span>
55 + <span className="t-caption">Last sale · {fmtRelative(a.latestSaleAt)}</span>
56 + </span>
57 + );
58 + }
59 + return (
60 + <span className={cn('flex flex-col leading-tight', className)} title="Insufficient evidence for a valuation">
61 + <span className={cn('num font-semibold text-subtle', valueCls)}>—</span>
62 + <span className="t-caption">Catalogued · no price yet</span>
63 + </span>
64 + );
65 +}
66 +
67 +function MetricNode({ a, metric }: { a: AssetCardData; metric: TileMetric }) {
68 + switch (metric) {
69 + case 'change1d':
70 + return <Delta value={a.change1d} arrow className="text-[12px]" />;
71 + case 'change7d':
72 + return <Delta value={a.change7d} arrow className="text-[12px]" />;
73 + case 'change30d':
74 + return <Delta value={a.change30d} arrow className="text-[12px]" />;
75 + case 'trending':
76 + return <span className="num text-[11px] text-muted">score {a.trendingScore?.toFixed(1) ?? '—'}</span>;
77 + case 'watchers':
78 + return <span className="num text-[11px] text-muted">{fmtNum(a.watchers)} watching</span>;
79 + case 'opportunity':
80 + return (
81 + <span className="text-[11px] text-muted">
82 + vs RIV <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[12px]" />
83 + </span>
84 + );
85 + case 'new':
86 + return <span className="text-[11px] text-muted">added {fmtRelative(a.updatedAt)}</span>;
87 + case 'riv':
88 + return <span className="num text-[11px] text-muted">{fmtNum(a.salesCount)} sales</span>;
89 + case 'latestSale':
90 + return a.latestSaleUsd !== null ? <span className="num text-[11px] text-muted">last {fmtMoney(a.latestSaleUsd)}</span> : null;
91 + default:
92 + return null;
93 + }
94 +}
95 +
96 +/** Poster-style asset tile for rails and grids. */
97 +export function AssetTile({ a, metric = 'change30d', className, priority = false, width = 'w-[164px] sm:w-[188px]' }: { a: AssetCardData; metric?: TileMetric; className?: string; priority?: boolean; width?: string }) {
98 + return (
99 + <Link href={`/asset/${a.slug}`} className={cn('card card-hover group flex flex-col gap-2 p-2', width, className)}>
100 + <TileImage src={a.heroImageUrl} alt="" familySlug={a.familySlug} priority={priority} />
101 + <span className="flex min-h-[2.6em] flex-col">
102 + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg" title={a.title}>
103 + {a.title}
104 + </span>
105 + <span className="mt-0.5 truncate text-[11px] text-muted">
106 + {a.setName ?? catName(a.categorySlug)}
107 + {a.year ? ` · ${a.year}` : ''}
108 + </span>
109 + </span>
110 + <span className="mt-auto flex items-end justify-between gap-2 border-t border-border pt-2">
111 + <PriceLine a={a} size="sm" />
112 + <span className="shrink-0 text-right">
113 + <MetricNode a={a} metric={metric} />
114 + </span>
115 + </span>
116 + </Link>
117 + );
118 +}
119 +
120 +/** Horizontal snap rail on small screens; wraps into a grid from lg. */
121 +export function Rail({ children, className, cols = 'lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8' }: { children: ReactNode; className?: string; cols?: string }) {
122 + return <div className={cn('rail lg:mx-0 lg:grid lg:overflow-visible lg:px-0 lg:[&>*]:w-auto', cols, className)}>{children}</div>;
123 +}
124 +
125 +export function RailHeader({ title, subtitle, href, hrefLabel = 'View all', className }: { title: ReactNode; subtitle?: ReactNode; href?: string; hrefLabel?: string; className?: string }) {
126 + return (
127 + <div className={cn('mb-3 flex items-end justify-between gap-3', className)}>
128 + <div className="min-w-0">
129 + <h2 className="t-title text-fg">{title}</h2>
130 + {subtitle ? <p className="mt-0.5 truncate text-xs text-muted">{subtitle}</p> : null}
131 + </div>
132 + {href ? (
133 + <Link href={href} className="shrink-0 text-xs font-medium text-muted hover:text-fg">
134 + {hrefLabel} →
135 + </Link>
136 + ) : null}
137 + </div>
138 + );
139 +}
140 +
141 +export function TileSkeleton({ width = 'w-[164px] sm:w-[188px]' }: { width?: string }) {
142 + return (
143 + <div className={cn('card flex flex-col gap-2 p-2', width)} aria-hidden>
144 + <div className="aspect-[4/5] w-full animate-pulse rounded-[5px] bg-inset" />
145 + <div className="h-3 w-4/5 animate-pulse rounded-sm bg-inset" />
146 + <div className="h-3 w-1/2 animate-pulse rounded-sm bg-inset" />
147 + <div className="mt-2 h-4 w-1/3 animate-pulse rounded-sm bg-inset" />
148 + </div>
149 + );
150 +}
modified apps/web/src/components/market/category-grid.tsx +24 −14
@@ -1,27 +1,37 @@
1 +import Image from 'next/image';
1 2 import Link from 'next/link';
2 3 import type { MarketRow } from '@/lib/queries/markets';
3 4 import { fmtNum, cn } from '@/lib/format';
4 −import { Thumb } from '@/components/market/bits';
5 −import { Badge } from '@/components/ui/primitives';
5 +import { Delta } from '@/components/ui/primitives';
6 +import { Sparkline } from '@/components/charts/sparkline';
6 7
7 −/** Browse-by-category grid: thumbnail of a representative asset, live counts, coverage state. */
8 −export function CategoryGrid({ rows, thumbs, className, limit }: { rows: MarketRow[]; thumbs: Map<string, string>; className?: string; limit?: number }) {
9 − const sorted = [...rows].sort((a, b) => b.counts.assets - a.counts.assets || a.node.sortOrder - b.node.sortOrder);
8 +/** Browse-by-category tiles: representative image, live counts, 30-day change and an optional sparkline. */
9 +export function CategoryGrid({ rows, thumbs, className, limit, sparklines, mobileLimit }: { rows: MarketRow[]; thumbs: Map<string, string>; className?: string; limit?: number; sparklines?: Map<string, number[]>; /** hide tiles beyond this index below the sm breakpoint */ mobileLimit?: number }) {
10 + 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 11 const shown = limit ? sorted.slice(0, limit) : sorted;
11 12 return (
12 13 <div className={cn('grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6', className)}>
13 − {shown.map(({ node, counts, snapshot }) => {
14 + {shown.map(({ node, counts, snapshot }, idx) => {
15 + const hiddenOnMobile = mobileLimit !== undefined && idx >= mobileLimit;
14 16 const empty = counts.assets === 0;
17 + const src = thumbs.get(node.slug);
18 + const spark = sparklines?.get(node.slug);
15 19 return (
16 − <Link key={node.slug} href={`/markets/${node.slug}`} className={cn('card flex items-center gap-2.5 p-2.5 transition-colors hover:border-border-strong', empty && 'opacity-60')}>
17 − <Thumb src={thumbs.get(node.slug)} alt="" size={44} rounded="rounded-md" />
18 − <span className="min-w-0 flex-1">
19 − <span className="block truncate text-[13px] font-medium text-fg">{node.name}</span>
20 − <span className="num block truncate text-[11px] text-muted">{empty ? 'no data yet' : `${fmtNum(counts.assets, { compact: true })} assets${counts.sales ? ` · ${fmtNum(counts.sales, { compact: true })} sales` : ''}`}</span>
21 − <span className="mt-0.5 flex items-center gap-1 text-[10px]">
22 − {counts.priced ? <Badge tone="gain">{fmtNum(counts.priced, { compact: true })} priced</Badge> : !empty ? <Badge>catalog</Badge> : null}
23 − {snapshot?.indexValue != null ? <Badge tone="index">index</Badge> : null}
20 + <Link key={node.slug} href={`/markets/${node.slug}`} className={cn('card card-hover group flex flex-col overflow-hidden', empty && 'opacity-60', hiddenOnMobile && 'hidden sm:flex')}>
21 + <span className="relative block aspect-[5/3] w-full overflow-hidden bg-sunken">
22 + {src ? <Image src={src} alt="" fill sizes="(min-width: 1280px) 220px, (min-width: 640px) 33vw, 50vw" className="object-contain p-3 transition-transform duration-300 ease-out group-hover:scale-[1.04]" unoptimized={src.endsWith('.svg')} /> : <span className="absolute inset-0 flex items-center justify-center"><span className="t-label">{empty ? 'No data yet' : node.short ?? node.name}</span></span>}
23 + {spark && spark.length > 2 ? (
24 + <span className="absolute bottom-1.5 right-1.5 rounded-sm bg-elevated/80 px-1 backdrop-blur-sm">
25 + <Sparkline values={spark} width={64} height={18} />
26 + </span>
27 + ) : null}
28 + </span>
29 + <span className="flex flex-col gap-0.5 px-2.5 py-2">
30 + <span className="flex items-center justify-between gap-2">
31 + <span className="truncate text-[13px] font-semibold text-fg">{node.name}</span>
32 + {snapshot?.change30d != null ? <Delta value={snapshot.change30d} className="text-[11px]" /> : null}
24 33 </span>
34 + <span className="num truncate text-[11px] text-muted">{empty ? 'catalogue pending' : `${fmtNum(counts.assets, { compact: true })} assets${counts.sales ? ` · ${fmtNum(counts.sales, { compact: true })} sales` : counts.priced ? ` · ${fmtNum(counts.priced, { compact: true })} priced` : ''}`}</span>
25 35 </span>
26 36 </Link>
27 37 );
modified apps/web/src/components/market/index-strip.tsx +33 −21
@@ -1,32 +1,44 @@
1 1 import Link from 'next/link';
2 2 import type { IndexRow } from '@/lib/queries/indices';
3 −import { fmtNum, cn } from '@/lib/format';
4 −import { Delta } from '@/components/ui/primitives';
3 +import { fmtNum, cn, fmtPct, deltaClass } from '@/lib/format';
5 4
6 −/** Live tape (§163): marquee of indices; indices without a published value show their build status. */
5 +/**
6 + * Live tape (§163): a terminal-style ticker of the indices. Published indices show value + daily change;
7 + * indices still building show their constituent progress. Pauses on hover/focus; static under reduced motion.
8 + */
7 9 export function IndexTape({ indices, className }: { indices: IndexRow[]; className?: string }) {
8 10 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 − ));
11 + const ordered = [...indices].sort((a, b) => Number(Boolean(b.latest)) - Number(Boolean(a.latest)) || (a.isFlagship ? -1 : 0) - (b.isFlagship ? -1 : 0));
12 + const items = ordered.map((i) => {
13 + const up = (i.change1d ?? 0) > 0;
14 + const down = (i.change1d ?? 0) < 0;
15 + return (
16 + <Link key={i.ticker} href={`/rareindex/${i.ticker}`} className="mono-num flex h-9 shrink-0 items-center gap-2.5 border-r border-border px-4 text-[12px] hover:bg-inset" aria-label={`${i.ticker}${i.latest ? ` ${fmtNum(i.latest.value, { digits: 2 })} ${fmtPct(i.change1d)}` : ' building'}`}>
17 + <span className="inline-block h-2 w-2 rounded-[2px]" style={{ background: i.color ?? 'var(--ri-index)' }} aria-hidden />
18 + <span className="font-semibold tracking-wide text-fg">{i.ticker}</span>
19 + {i.latest ? (
20 + <>
21 + <span className="text-muted">{fmtNum(i.latest.value, { digits: 2 })}</span>
22 + <span className={cn('font-medium', deltaClass(i.change1d))}>
23 + {up ? '▲' : down ? '▼' : '■'} {fmtPct(i.change1d)}
24 + </span>
25 + </>
26 + ) : (
27 + <span className="text-subtle">
28 + building {i.pricedAssets}/{i.minConstituents}
29 + </span>
30 + )}
31 + </Link>
32 + );
33 + });
24 34 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">
35 + <div className={cn('relative overflow-hidden border-y border-border bg-elevated', className)} aria-label="Index tape" role="marquee">
36 + <div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-elevated to-transparent" aria-hidden />
37 + <div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-elevated to-transparent" aria-hidden />
38 + <div className="tape-track flex w-max">
27 39 {items}
28 40 {items.map((it, k) => (
29 − <span key={`dup-${k}`} aria-hidden>
41 + <span key={`dup-${k}`} aria-hidden className="contents">
30 42 {it}
31 43 </span>
32 44 ))}
modified apps/web/src/components/market/sales-table.tsx +79 −2
@@ -5,8 +5,47 @@ import { humanize } from '@/lib/taxonomy';
5 5 import { Table, th, td, tdNum, EmptyState, Badge, Delta } from '@/components/ui/primitives';
6 6 import { AssetTitleCell, GradeBadge, PriceCell, SourceLink, Thumb } from './bits';
7 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 }) {
8 +/** Card list for sales on small screens (image, title, grade, price, source). */
9 +export function SalesCards({ items, className }: { items: SaleRow[]; className?: string }) {
10 + return (
11 + <ul className={cn('divide-y divide-border', className)}>
12 + {items.map((s) => (
13 + <li key={s.id}>
14 + <a href={s.assetSlug ? `/asset/${s.assetSlug}` : s.sourceUrl} className="flex min-h-[64px] items-center gap-3 px-3 py-2.5 hover:bg-sunken">
15 + <Thumb src={s.heroImageUrl ?? s.imageUrls[0]} alt="" size={44} rounded="rounded-[5px]" />
16 + <span className="min-w-0 flex-1">
17 + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{s.assetTitle ?? s.rawTitle}</span>
18 + <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted">
19 + <GradeBadge grader={s.grader} grade={s.grade} />
20 + <span>{fmtDate(s.saleDate)}</span>
21 + <span>· {s.sourceName}</span>
22 + </span>
23 + </span>
24 + <span className="num flex shrink-0 flex-col items-end leading-tight">
25 + <span className="text-[14px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span>
26 + {s.currency !== 'USD' ? <span className="text-[10px] text-subtle">{fmtMoney(s.price, s.currency)}</span> : <span className="text-[10px] text-subtle">{humanize(s.saleType)}</span>}
27 + </span>
28 + </a>
29 + </li>
30 + ))}
31 + </ul>
32 + );
33 +}
34 +
35 +export function SalesTable({ items, showAsset = true, className, emptyDescription = 'Sales appear once connectors ingest verified transactions.', responsive = true }: { items: SaleRow[]; showAsset?: boolean; className?: string; emptyDescription?: string; responsive?: boolean }) {
9 36 if (!items.length) return <EmptyState title="No sales recorded" description={emptyDescription} />;
37 + if (responsive) {
38 + return (
39 + <>
40 + <div className="md:hidden">
41 + <SalesCards items={items} />
42 + </div>
43 + <div className="hidden md:block">
44 + <SalesTable items={items} showAsset={showAsset} className={className} responsive={false} />
45 + </div>
46 + </>
47 + );
48 + }
10 49 return (
11 50 <Table className={className}>
12 51 <thead>
@@ -63,8 +102,46 @@ export function SalesTable({ items, showAsset = true, className, emptyDescriptio
63 102 );
64 103 }
65 104
66 −export function ListingsTable({ items, showAsset = true, className, emptyDescription = 'Live listings appear once marketplace connectors run.' }: { items: ListingRow[]; showAsset?: boolean; className?: string; emptyDescription?: string }) {
105 +export function ListingsCards({ items, className }: { items: ListingRow[]; className?: string }) {
106 + return (
107 + <ul className={cn('divide-y divide-border', className)}>
108 + {items.map((l) => (
109 + <li key={l.id}>
110 + <a href={l.assetSlug ? `/asset/${l.assetSlug}` : l.sourceUrl} className="flex min-h-[64px] items-center gap-3 px-3 py-2.5 hover:bg-sunken">
111 + <Thumb src={l.imageUrls[0]} alt="" size={44} rounded="rounded-[5px]" />
112 + <span className="min-w-0 flex-1">
113 + <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{l.assetTitle ?? l.rawTitle}</span>
114 + <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted">
115 + <GradeBadge grader={l.grader} grade={l.grade} />
116 + <span>{l.sourceName}</span>
117 + {l.location ? <span>· {l.location}</span> : null}
118 + </span>
119 + </span>
120 + <span className="num flex shrink-0 flex-col items-end leading-tight">
121 + <span className="text-[14px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span>
122 + {l.discountToRiv !== null ? <Delta value={-l.discountToRiv} className="text-[11px]" /> : <span className="text-[10px] text-subtle">{humanize(l.listingType)}</span>}
123 + </span>
124 + </a>
125 + </li>
126 + ))}
127 + </ul>
128 + );
129 +}
130 +
131 +export function ListingsTable({ items, showAsset = true, className, emptyDescription = 'Live listings appear once marketplace connectors run.', responsive = true }: { items: ListingRow[]; showAsset?: boolean; className?: string; emptyDescription?: string; responsive?: boolean }) {
67 132 if (!items.length) return <EmptyState title="No active listings" description={emptyDescription} />;
133 + if (responsive) {
134 + return (
135 + <>
136 + <div className="md:hidden">
137 + <ListingsCards items={items} />
138 + </div>
139 + <div className="hidden md:block">
140 + <ListingsTable items={items} showAsset={showAsset} className={className} responsive={false} />
141 + </div>
142 + </>
143 + );
144 + }
68 145 return (
69 146 <Table className={className}>
70 147 <thead>
modified apps/web/src/components/research/research-client.tsx +6 −6
@@ -127,12 +127,12 @@ export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiRea
127 127 ))}
128 128 </ul>
129 129 </aside>
130 − <section className="card flex min-h-[70dvh] flex-col">
130 + <section className="card flex h-[calc(100dvh-var(--ri-header-h,52px)-var(--ri-tabbar-h,56px)-7rem)] min-h-[420px] flex-col md:h-[calc(100dvh-var(--ri-header-h,52px)-9rem)]">
131 131 <div className="flex items-center justify-between border-b border-border px-4 py-2 text-xs text-muted">
132 132 <span className="inline-flex items-center gap-1.5"><Sparkles className="h-3.5 w-3.5" /> {aiReady ? `Grounded research · ${model}` : 'AI provider not configured'}</span>
133 133 <span>Tool calls are shown inline</span>
134 134 </div>
135 − <div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
135 + <div className="flex-1 space-y-4 overflow-y-auto overscroll-contain px-3 py-3 sm:px-4 sm:py-4">
136 136 {turns.length === 0 ? (
137 137 <div>
138 138 <p className="mb-3 text-sm text-muted">Try one of these:</p>
@@ -147,7 +147,7 @@ export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiRea
147 147 ) : null}
148 148 {turns.map((t) => (
149 149 <div key={t.id} className={cn('flex', t.role === 'user' ? 'justify-end' : 'justify-start')}>
150 − <div className={cn('max-w-[92%] rounded-lg px-3.5 py-2.5 text-sm', t.role === 'user' ? 'bg-accent text-accent-fg' : 'bg-sunken')}>
150 + <div className={cn('max-w-[94%] overflow-x-auto rounded-lg px-3.5 py-2.5 text-sm sm:max-w-[88%]', t.role === 'user' ? 'bg-accent text-accent-fg' : 'bg-sunken')}>
151 151 {t.tools.length ? (
152 152 <ul className="mb-2 space-y-1">
153 153 {t.tools.map((tool) => (
@@ -165,7 +165,7 @@ export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiRea
165 165 <div ref={bottom} />
166 166 </div>
167 167 <form
168 − className="flex items-end gap-2 border-t border-border p-3"
168 + className="sticky bottom-0 flex items-end gap-2 border-t border-border bg-elevated p-2 sm:p-3"
169 169 onSubmit={(e) => {
170 170 e.preventDefault();
171 171 void send(input);
@@ -183,9 +183,9 @@ export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiRea
183 183 rows={2}
184 184 placeholder={aiReady ? 'Ask about an asset, a category, an index, movers or a screen…' : 'AI provider not configured'}
185 185 disabled={!aiReady}
186 − className="min-h-[44px] flex-1 resize-none rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none"
186 + className="max-h-40 min-h-[44px] flex-1 resize-none rounded-md border border-border bg-sunken px-3 py-2 text-base text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none md:text-sm"
187 187 />
188 − <button type="submit" disabled={!aiReady || busy || !input.trim()} aria-label="Send" className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-accent text-accent-fg disabled:opacity-50">
188 + <button type="submit" disabled={!aiReady || busy || !input.trim()} aria-label="Send" className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-md bg-accent text-accent-fg disabled:opacity-50">
189 189 {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
190 190 </button>
191 191 </form>
modified apps/web/src/components/scanner/scanner-client.tsx +8 −10
@@ -150,15 +150,13 @@ export function ScannerClient({ aiReady }: { aiReady: boolean }) {
150 150 void addFiles(e.dataTransfer.files);
151 151 }}
152 152 >
153 − <p className="text-xs text-muted">Drop up to 5 photos: front, back, label, seal, serial.</p>
154 − <div className="flex gap-2">
155 − <button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border bg-elevated px-3 py-1.5 text-xs font-medium hover:bg-inset" onClick={() => fileRef.current?.click()}>
156 − <ImagePlus className="h-3.5 w-3.5" /> Upload
157 − </button>
158 − <button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border bg-elevated px-3 py-1.5 text-xs font-medium hover:bg-inset md:hidden" onClick={() => camRef.current?.click()}>
159 − <Camera className="h-3.5 w-3.5" /> Take photo
160 − </button>
161 − </div>
153 + <button type="button" className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-semibold text-accent-fg active:opacity-90 md:hidden" onClick={() => camRef.current?.click()}>
154 + <Camera className="h-5 w-5" /> Take a photo
155 + </button>
156 + <p className="text-xs text-muted"><span className="hidden md:inline">Drop up to 5 photos here: </span><span className="md:hidden">Up to 5 photos: </span>front, back, label, seal, serial.</p>
157 + <button type="button" className="inline-flex h-10 items-center gap-1.5 rounded-md border border-border bg-elevated px-3 text-xs font-medium hover:bg-inset md:h-9" onClick={() => fileRef.current?.click()}>
158 + <ImagePlus className="h-3.5 w-3.5" /> Upload from library
159 + </button>
162 160 <input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void addFiles(e.target.files)} />
163 161 <input ref={camRef} type="file" accept="image/*" capture="environment" hidden onChange={(e) => void addFiles(e.target.files)} />
164 162 </div>
@@ -188,7 +186,7 @@ export function ScannerClient({ aiReady }: { aiReady: boolean }) {
188 186 <textarea value={text} onChange={(e) => setText(e.target.value)} rows={5} placeholder="e.g. 1999 Pokémon Base Set Charizard holo, 1st edition stamp, PSA 9 slab #12345678" className="mt-1 w-full rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" />
189 187 </label>
190 188 )}
191 − <button type="button" disabled={!canSubmit} onClick={() => void submit()} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-3 py-2 text-sm font-medium text-accent-fg disabled:opacity-50">
189 + <button type="button" disabled={!canSubmit} onClick={() => void submit()} className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-md bg-accent px-3 text-sm font-semibold text-accent-fg disabled:opacity-50">
192 190 {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null} {busy ? 'Identifying…' : 'Identify & price'}
193 191 </button>
194 192 {error ? <p className="rounded-md bg-loss-bg px-3 py-2 text-xs text-loss">{error}</p> : null}
added apps/web/src/components/search/search-sheet.tsx +330 −0
@@ -0,0 +1,330 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { useRouter } from 'next/navigation';
5 +import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6 +import { ArrowUpRight, Clock, Search, TrendingUp, X } from 'lucide-react';
7 +import { cn } from '@/lib/format';
8 +
9 +export interface Suggestion {
10 + type: string;
11 + label: string;
12 + sublabel: string | null;
13 + href: string;
14 +}
15 +
16 +const RECENT_KEY = 'ri-recent-searches';
17 +const TRENDING = ['1999 Charizard PSA 10', 'Rolex Daytona 116500LN', 'LEGO 10179', 'Black Lotus Alpha', 'Blue-Eyes White Dragon LOB', 'Super Mario 64 sealed', 'Amazing Spider-Man 300', 'Patek Philippe Nautilus 5711'];
18 +const GROUP_LABEL: Record<string, string> = { asset: 'Assets', set: 'Sets & releases', category: 'Categories', brand: 'Brands', source: 'Sources', index: 'Indices' };
19 +const GROUP_ORDER = ['asset', 'set', 'category', 'brand', 'index', 'source'];
20 +
21 +function readRecent(): string[] {
22 + try {
23 + const raw = localStorage.getItem(RECENT_KEY);
24 + const arr = raw ? (JSON.parse(raw) as unknown) : [];
25 + return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string').slice(0, 8) : [];
26 + } catch {
27 + return [];
28 + }
29 +}
30 +export function pushRecent(q: string) {
31 + try {
32 + const next = [q, ...readRecent().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8);
33 + localStorage.setItem(RECENT_KEY, JSON.stringify(next));
34 + } catch {
35 + /* ignore */
36 + }
37 +}
38 +
39 +/** Highlight query tokens inside a label (case-insensitive, word-agnostic). */
40 +export function Highlight({ text, q }: { text: string; q: string }) {
41 + const tokens = q.toLowerCase().split(/\s+/).filter((t) => t.length >= 2);
42 + if (!tokens.length) return <>{text}</>;
43 + const re = new RegExp(`(${tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'ig');
44 + const parts = text.split(re);
45 + return (
46 + <>
47 + {parts.map((p, i) => (re.test(p) ? <mark key={i} className="rounded-[2px] bg-index-bg text-fg">{p}</mark> : <span key={i}>{p}</span>))}
48 + </>
49 + );
50 +}
51 +
52 +/**
53 + * Full-screen search sheet (mobile) / command palette (desktop, ⌘K). Grouped instant results from
54 + * /api/search/suggest, recent searches (localStorage), trending prompts and keyboard navigation.
55 + */
56 +export function SearchSheet({ open, onClose, initialQuery = '' }: { open: boolean; onClose: () => void; initialQuery?: string }) {
57 + const router = useRouter();
58 + const [q, setQ] = useState(initialQuery);
59 + const [items, setItems] = useState<Suggestion[]>([]);
60 + const [loading, setLoading] = useState(false);
61 + const [active, setActive] = useState(-1);
62 + const inputRef = useRef<HTMLInputElement>(null);
63 + // Only rendered client-side once opened (SSR renders nothing), so reading localStorage here is safe.
64 + const recent = useMemo(() => (open && typeof window !== 'undefined' ? readRecent() : []), [open]);
65 +
66 + useEffect(() => {
67 + if (!open) return;
68 + const t = setTimeout(() => inputRef.current?.focus(), 30);
69 + document.body.style.overflow = 'hidden';
70 + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
71 + window.addEventListener('keydown', onKey);
72 + return () => {
73 + clearTimeout(t);
74 + document.body.style.overflow = '';
75 + window.removeEventListener('keydown', onKey);
76 + };
77 + }, [open, onClose]);
78 +
79 + useEffect(() => {
80 + if (!open) return;
81 + const query = q.trim();
82 + if (query.length < 2) return;
83 + const ctrl = new AbortController();
84 + const t = setTimeout(() => {
85 + setLoading(true);
86 + fetch(`/api/search/suggest?q=${encodeURIComponent(query)}`, { signal: ctrl.signal })
87 + .then((r) => r.json())
88 + .then((d: { items: Suggestion[] }) => {
89 + setItems(d.items ?? []);
90 + setActive(-1);
91 + })
92 + .catch(() => {})
93 + .finally(() => setLoading(false));
94 + }, 110);
95 + return () => {
96 + clearTimeout(t);
97 + ctrl.abort();
98 + };
99 + }, [q, open]);
100 +
101 + const groups = useMemo(() => {
102 + const m = new Map<string, Suggestion[]>();
103 + for (const it of items) (m.get(it.type) ?? m.set(it.type, []).get(it.type)!).push(it);
104 + return [...m.entries()].sort((a, b) => GROUP_ORDER.indexOf(a[0]) - GROUP_ORDER.indexOf(b[0]));
105 + }, [items]);
106 + const flat = useMemo(() => groups.flatMap(([, g]) => g), [groups]);
107 +
108 + const go = useCallback(
109 + (href: string, label?: string) => {
110 + if (label) pushRecent(label);
111 + onClose();
112 + router.push(href);
113 + },
114 + [onClose, router],
115 + );
116 + const submit = () => {
117 + const query = q.trim();
118 + if (active >= 0 && flat[active]) return go(flat[active]!.href, query || flat[active]!.label);
119 + if (query) go(`/search?q=${encodeURIComponent(query)}`, query);
120 + };
121 +
122 + if (!open) return null;
123 + const showIdle = q.trim().length < 2;
124 + return (
125 + <div className="fixed inset-0 z-[70]" role="dialog" aria-modal="true" aria-label="Search">
126 + <div className="absolute inset-0 bg-black/45 backdrop-blur-[2px]" onClick={onClose} />
127 + <div className="sheet-in absolute inset-x-0 top-0 mx-auto flex h-[100dvh] w-full flex-col bg-elevated shadow-pop md:top-[8vh] md:h-auto md:max-h-[76vh] md:max-w-2xl md:rounded-xl md:border md:border-border">
128 + <form
129 + role="search"
130 + className="flex items-center gap-2 border-b border-border px-3 py-2 md:px-4"
131 + onSubmit={(e) => {
132 + e.preventDefault();
133 + submit();
134 + }}
135 + >
136 + <Search className="h-5 w-5 shrink-0 text-subtle" />
137 + <input
138 + ref={inputRef}
139 + value={q}
140 + onChange={(e) => {
141 + setQ(e.target.value);
142 + if (e.target.value.trim().length < 2) {
143 + setItems([]);
144 + setActive(-1);
145 + }
146 + }}
147 + onKeyDown={(e) => {
148 + if (e.key === 'ArrowDown') {
149 + e.preventDefault();
150 + setActive((a) => Math.min(flat.length - 1, a + 1));
151 + } else if (e.key === 'ArrowUp') {
152 + e.preventDefault();
153 + setActive((a) => Math.max(-1, a - 1));
154 + }
155 + }}
156 + type="search"
157 + enterKeyHint="search"
158 + autoComplete="off"
159 + autoCapitalize="off"
160 + spellCheck={false}
161 + placeholder="Search cards, watches, games, sneakers, LEGO, art…"
162 + aria-label="Search collectibles"
163 + aria-autocomplete="list"
164 + aria-controls="search-sheet-list"
165 + className="h-12 min-w-0 flex-1 bg-transparent text-[16px] text-fg placeholder:text-subtle focus:outline-none md:h-11 md:text-[15px]"
166 + />
167 + {q ? (
168 + <button type="button" onClick={() => setQ('')} className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted hover:bg-inset" aria-label="Clear">
169 + <X className="h-4 w-4" />
170 + </button>
171 + ) : null}
172 + <button type="button" onClick={onClose} className="inline-flex h-10 items-center rounded-md border border-border px-2.5 text-[12px] font-medium text-muted hover:bg-inset md:h-8">
173 + <span className="md:hidden">Cancel</span>
174 + <span className="hidden md:inline">Esc</span>
175 + </button>
176 + </form>
177 +
178 + <div id="search-sheet-list" className="flex-1 overflow-y-auto overscroll-contain safe-bottom" role="listbox">
179 + {showIdle ? (
180 + <div className="px-3 py-3 md:px-4">
181 + {recent.length ? (
182 + <section className="mb-4">
183 + <p className="t-label mb-1.5 flex items-center gap-1.5">
184 + <Clock className="h-3 w-3" /> Recent
185 + </p>
186 + <ul className="flex flex-wrap gap-1.5">
187 + {recent.map((r) => (
188 + <li key={r}>
189 + <button type="button" onClick={() => go(`/search?q=${encodeURIComponent(r)}`, r)} className="chip">
190 + {r}
191 + </button>
192 + </li>
193 + ))}
194 + </ul>
195 + </section>
196 + ) : null}
197 + <section>
198 + <p className="t-label mb-1.5 flex items-center gap-1.5">
199 + <TrendingUp className="h-3 w-3" /> Try
200 + </p>
201 + <ul className="divide-y divide-border">
202 + {TRENDING.map((t) => (
203 + <li key={t}>
204 + <button type="button" onClick={() => go(`/search?q=${encodeURIComponent(t)}`, t)} className="flex min-h-[44px] w-full items-center justify-between gap-3 px-1 text-left text-[14px] text-fg hover:bg-sunken">
205 + <span>{t}</span>
206 + <ArrowUpRight className="h-4 w-4 text-subtle" />
207 + </button>
208 + </li>
209 + ))}
210 + </ul>
211 + </section>
212 + <p className="mt-4 text-[11px] leading-relaxed text-subtle">Understands graders and grades (“PSA 10”), years, price bounds (“under $5,000”), conditions (“sealed”, “CIB”) and set codes (“LOB-001”, “116500LN”).</p>
213 + </div>
214 + ) : (
215 + <div className="py-1">
216 + {groups.map(([type, g]) => (
217 + <section key={type} className="pb-1">
218 + <p className="t-label px-4 pb-1 pt-2">{GROUP_LABEL[type] ?? type}</p>
219 + <ul>
220 + {g.map((it) => {
221 + const idx = flat.indexOf(it);
222 + return (
223 + <li key={`${it.type}-${it.href}`} role="option" aria-selected={idx === active}>
224 + <button type="button" onMouseEnter={() => setActive(idx)} onClick={() => go(it.href, q.trim())} className={cn('flex min-h-[44px] w-full items-center justify-between gap-3 px-4 py-1.5 text-left', idx === active ? 'bg-inset' : 'hover:bg-sunken')}>
225 + <span className="min-w-0">
226 + <span className="block truncate text-[14px] text-fg">
227 + <Highlight text={it.label} q={q} />
228 + </span>
229 + {it.sublabel ? <span className="block truncate text-[11px] text-muted">{it.sublabel}</span> : null}
230 + </span>
231 + <ArrowUpRight className="h-4 w-4 shrink-0 text-subtle" />
232 + </button>
233 + </li>
234 + );
235 + })}
236 + </ul>
237 + </section>
238 + ))}
239 + {!loading && !flat.length ? <p className="px-4 py-6 text-center text-[13px] text-muted">No instant matches. Press Enter to run a full search.</p> : null}
240 + <button type="button" onClick={submit} className="mt-1 flex min-h-[48px] w-full items-center justify-between border-t border-border px-4 text-left text-[13px] text-muted hover:bg-sunken">
241 + <span>
242 + Search all results for <span className="font-medium text-fg">“{q.trim()}”</span>
243 + </span>
244 + <kbd className="rounded-sm border border-border px-1.5 text-[10px] text-subtle">↵</kbd>
245 + </button>
246 + </div>
247 + )}
248 + </div>
249 + </div>
250 + </div>
251 + );
252 +}
253 +
254 +/** Icon/button trigger that opens the sheet; global ⌘K / Ctrl+K shortcut. */
255 +export function SearchTrigger({ variant = 'icon', className, initialQuery }: { variant?: 'icon' | 'field' | 'hero'; className?: string; initialQuery?: string }) {
256 + const [open, setOpen] = useState(false);
257 + const close = useCallback(() => setOpen(false), []);
258 + useEffect(() => {
259 + const onKey = (e: KeyboardEvent) => {
260 + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
261 + e.preventDefault();
262 + setOpen(true);
263 + }
264 + };
265 + window.addEventListener('keydown', onKey);
266 + return () => window.removeEventListener('keydown', onKey);
267 + }, []);
268 + return (
269 + <>
270 + {variant === 'icon' ? (
271 + <button type="button" onClick={() => setOpen(true)} className={cn('inline-flex h-10 w-10 items-center justify-center rounded-md text-fg hover:bg-inset md:h-8 md:w-8', className)} aria-label="Search">
272 + <Search className="h-5 w-5" />
273 + </button>
274 + ) : variant === 'field' ? (
275 + <button type="button" onClick={() => setOpen(true)} className={cn('group flex h-8 w-64 items-center gap-2 rounded-md border border-border bg-sunken px-2.5 text-left text-[13px] text-subtle hover:border-border-strong xl:w-80', className)} aria-label="Search (⌘K)">
276 + <Search className="h-3.5 w-3.5" />
277 + <span className="flex-1 truncate">Search cards, watches, games…</span>
278 + <kbd className="rounded-sm border border-border bg-elevated px-1 text-[10px] text-subtle">⌘K</kbd>
279 + </button>
280 + ) : (
281 + <button type="button" onClick={() => setOpen(true)} className={cn('flex h-13 w-full items-center gap-3 rounded-lg border border-border bg-elevated px-4 text-left shadow-card hover:border-border-strong', className)} aria-label="Search collectibles">
282 + <Search className="h-5 w-5 text-subtle" />
283 + <span className="flex-1 truncate text-[16px] text-subtle">Search cards, watches, games, sneakers, LEGO, art…</span>
284 + <span className="btn-primary btn-sm hidden h-8 sm:inline-flex">Search</span>
285 + </button>
286 + )}
287 + <SearchSheet open={open} onClose={close} initialQuery={initialQuery} />
288 + </>
289 + );
290 +}
291 +
292 +/** Non-JS friendly hero search: a real form that submits to /search; enhanced with the sheet on focus. */
293 +export function HeroSearch({ chips }: { chips: string[] }) {
294 + const [open, setOpen] = useState(false);
295 + const close = useCallback(() => setOpen(false), []);
296 + return (
297 + <div className="mx-auto w-full max-w-2xl">
298 + <form action="/search" role="search" className="relative flex items-center">
299 + <Search className="pointer-events-none absolute left-4 h-5 w-5 text-subtle" />
300 + <input
301 + name="q"
302 + type="search"
303 + autoComplete="off"
304 + enterKeyHint="search"
305 + placeholder="Search cards, watches, games, sneakers, LEGO, art…"
306 + aria-label="Search collectibles"
307 + onFocus={(e) => {
308 + // Use the rich sheet when JS is available; keep the plain form as the fallback.
309 + e.currentTarget.blur();
310 + setOpen(true);
311 + }}
312 + className="h-13 w-full rounded-lg border border-border bg-elevated pl-12 pr-24 text-[16px] text-fg shadow-card placeholder:text-subtle focus:border-border-strong focus:outline-none"
313 + />
314 + <button type="submit" className="btn-primary absolute right-1.5 h-10 px-4 text-[13px]">
315 + Search
316 + </button>
317 + </form>
318 + <ul className="mt-3 flex flex-wrap items-center justify-center gap-1.5">
319 + {chips.map((c) => (
320 + <li key={c}>
321 + <Link href={`/search?q=${encodeURIComponent(c)}`} className="chip" onClick={() => pushRecent(c)}>
322 + {c}
323 + </Link>
324 + </li>
325 + ))}
326 + </ul>
327 + <SearchSheet open={open} onClose={close} />
328 + </div>
329 + );
330 +}
modified apps/web/src/components/ui/filter-bar.tsx +71 −11
@@ -1,7 +1,8 @@
1 1 'use client';
2 2
3 3 import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4 −import { useTransition, type ReactNode } from 'react';
4 +import { useEffect, useState, useTransition, type ReactNode } from 'react';
5 +import { SlidersHorizontal, X } from 'lucide-react';
5 6 import { cn } from '@/lib/format';
6 7
7 8 export interface FilterField {
@@ -16,12 +17,25 @@ export interface FilterField {
16 17 /**
17 18 * Generic URL-state filter bar. Every change updates the search params (resetting `page`), so
18 19 * server components re-render with the new filters and every view is shareable.
20 + * On small screens the fields collapse behind a "Filters" button that opens a bottom sheet.
19 21 */
20 22 export function FilterBar({ fields, className, children, resetKeys }: { fields: FilterField[]; className?: string; children?: ReactNode; resetKeys?: string[] }) {
21 23 const router = useRouter();
22 24 const pathname = usePathname();
23 25 const sp = useSearchParams();
24 26 const [pending, start] = useTransition();
27 + const [open, setOpen] = useState(false);
28 +
29 + useEffect(() => {
30 + if (!open) return;
31 + document.body.style.overflow = 'hidden';
32 + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
33 + window.addEventListener('keydown', onKey);
34 + return () => {
35 + document.body.style.overflow = '';
36 + window.removeEventListener('keydown', onKey);
37 + };
38 + }, [open]);
25 39
26 40 const update = (name: string, value: string) => {
27 41 const next = new URLSearchParams(sp.toString());
@@ -37,16 +51,16 @@ export function FilterBar({ fields, className, children, resetKeys }: { fields:
37 51 next.delete('page');
38 52 start(() => router.replace(`${pathname}${next.toString() ? `?${next}` : ''}`, { scroll: false }));
39 53 };
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';
54 + const activeCount = fields.filter((f) => sp.get(f.name)).length;
55 + const input = 'h-9 md:h-8 rounded-md border border-border bg-elevated px-2 text-[16px] md:text-[12px] text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none';
42 56
43 − return (
44 − <div className={cn('flex flex-wrap items-end gap-2', pending && 'opacity-70', className)}>
57 + const controls = (stacked: boolean) => (
58 + <>
45 59 {fields.map((f) => (
46 − <label key={f.name} className="flex flex-col gap-0.5 text-[10px] font-medium uppercase tracking-wider text-subtle">
60 + <label key={f.name} className={cn('flex flex-col gap-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-subtle', stacked && 'w-full')}>
47 61 {f.label}
48 62 {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)}>
63 + <select className={cn(input, stacked ? 'w-full' : (f.width ?? 'w-40'))} value={sp.get(f.name) ?? ''} onChange={(e) => update(f.name, e.target.value)}>
50 64 <option value="">{f.placeholder ?? 'All'}</option>
51 65 {f.options?.map((o) => (
52 66 <option key={o.value} value={o.value}>
@@ -58,7 +72,7 @@ export function FilterBar({ fields, className, children, resetKeys }: { fields:
58 72 <input
59 73 type={f.type}
60 74 inputMode={f.type === 'number' ? 'decimal' : undefined}
61 − className={cn(input, f.width ?? 'w-28')}
75 + className={cn(input, stacked ? 'w-full' : (f.width ?? 'w-28'))}
62 76 placeholder={f.placeholder}
63 77 defaultValue={sp.get(f.name) ?? ''}
64 78 onBlur={(e) => {
@@ -72,10 +86,56 @@ export function FilterBar({ fields, className, children, resetKeys }: { fields:
72 86 </label>
73 87 ))}
74 88 {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
89 + </>
90 + );
91 +
92 + return (
93 + <div className={cn(pending && 'opacity-70', className)}>
94 + {/* Desktop / tablet: inline */}
95 + <div className="hidden flex-wrap items-end gap-2 md:flex">
96 + {controls(false)}
97 + {activeCount ? (
98 + <button type="button" onClick={reset} className="h-8 rounded-md px-2 text-[12px] text-muted hover:text-fg">
99 + Clear
100 + </button>
101 + ) : null}
102 + </div>
103 + {/* Mobile: button + bottom sheet */}
104 + <div className="flex items-center gap-2 md:hidden">
105 + <button type="button" onClick={() => setOpen(true)} className="btn h-10 min-h-[44px] gap-2" aria-haspopup="dialog" aria-expanded={open}>
106 + <SlidersHorizontal className="h-4 w-4" />
107 + Filters
108 + {activeCount ? <span className="num rounded-full bg-accent px-1.5 text-[10px] font-semibold text-accent-fg">{activeCount}</span> : null}
78 109 </button>
110 + {activeCount ? (
111 + <button type="button" onClick={reset} className="h-10 rounded-md px-2 text-[13px] text-muted hover:text-fg">
112 + Clear
113 + </button>
114 + ) : null}
115 + </div>
116 + {open ? (
117 + <div className="fixed inset-0 z-[60] md:hidden" role="dialog" aria-modal="true" aria-label="Filters">
118 + <div className="absolute inset-0 bg-black/45" onClick={() => setOpen(false)} />
119 + <div className="sheet-in absolute inset-x-0 bottom-0 max-h-[88dvh] overflow-y-auto rounded-t-2xl border-t border-border bg-elevated shadow-pop safe-bottom">
120 + <div className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-elevated px-4 py-3">
121 + <span className="t-subtitle">Filters</span>
122 + <button type="button" onClick={() => setOpen(false)} className="inline-flex h-10 w-10 items-center justify-center rounded-md text-muted hover:bg-inset" aria-label="Close filters">
123 + <X className="h-5 w-5" />
124 + </button>
125 + </div>
126 + <div className="flex flex-col gap-3 px-4 py-4">{controls(true)}</div>
127 + <div className="sticky bottom-0 flex gap-2 border-t border-border bg-elevated px-4 py-3">
128 + {activeCount ? (
129 + <button type="button" onClick={reset} className="btn h-11 flex-1">
130 + Clear all
131 + </button>
132 + ) : null}
133 + <button type="button" onClick={() => setOpen(false)} className="btn-primary h-11 flex-1">
134 + Show results
135 + </button>
136 + </div>
137 + </div>
138 + </div>
79 139 ) : null}
80 140 </div>
81 141 );
modified apps/web/src/components/ui/page-header.tsx +20 −10
@@ -1,6 +1,7 @@
1 1 import Link from 'next/link';
2 2 import type { ReactNode } from 'react';
3 3 import { cn } from '@/lib/format';
4 +import { StatStrip } from './primitives';
4 5
5 6 export interface Crumb {
6 7 label: string;
@@ -10,11 +11,15 @@ export interface Crumb {
10 11 export function Breadcrumbs({ items, className }: { items: Crumb[]; className?: string }) {
11 12 if (!items.length) return null;
12 13 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">
14 + <nav aria-label="Breadcrumb" className={cn('mb-2 overflow-x-auto scrollbar-none text-[11px] text-subtle', className)}>
15 + <ol className="flex min-w-max items-center gap-1">
15 16 {items.map((c, i) => (
16 17 <li key={`${c.label}-${i}`} className="flex items-center gap-1">
17 − {i > 0 ? <span aria-hidden>/</span> : null}
18 + {i > 0 ? (
19 + <span aria-hidden className="text-border-strong">
20 + /
21 + </span>
22 + ) : null}
18 23 {c.href && i < items.length - 1 ? (
19 24 <Link href={c.href} className="hover:text-fg">
20 25 {c.label}
@@ -29,18 +34,23 @@ export function Breadcrumbs({ items, className }: { items: Crumb[]; className?:
29 34 );
30 35 }
31 36
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 }) {
37 +/**
38 + * Consistent page header: breadcrumb → kicker → title → one-line context → meta row → optional KPI strip.
39 + */
40 +export function PageHeader({ title, description, crumbs, actions, meta, className, compact, kicker, stats }: { title: ReactNode; description?: ReactNode; crumbs?: Crumb[]; actions?: ReactNode; meta?: ReactNode; className?: string; compact?: boolean; kicker?: ReactNode; stats?: Array<{ label: ReactNode; value: ReactNode; sub?: ReactNode; href?: string }> }) {
33 41 return (
34 42 <div className={cn(compact ? 'mb-4' : 'mb-6', className)}>
35 43 {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}
44 + <div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
45 + <div className="min-w-0 flex-1">
46 + {kicker ? <p className="t-label mb-1.5">{kicker}</p> : null}
47 + <h1 className={cn('text-fg', compact ? 't-title sm:text-xl' : 't-headline')}>{title}</h1>
48 + {description ? <p className={cn('mt-1.5 max-w-3xl text-muted', compact ? 'text-[13px] leading-relaxed' : 'text-sm leading-relaxed')}>{description}</p> : null}
49 + {meta ? <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">{meta}</div> : null}
41 50 </div>
42 − {actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
51 + {actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
43 52 </div>
53 + {stats?.length ? <StatStrip items={stats} className="mt-4" /> : null}
44 54 </div>
45 55 );
46 56 }
modified apps/web/src/components/ui/pagination.tsx +34 −11
@@ -1,7 +1,8 @@
1 1 import Link from 'next/link';
2 +import { ChevronLeft, ChevronRight } from 'lucide-react';
2 3 import { cn } from '@/lib/format';
3 4
4 −/** URL-driven pagination: keeps every other search param. */
5 +/** URL-driven pagination: keeps every other search param. Touch targets ≥ 44 px on mobile. */
5 6 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 7 const pages = Math.max(1, Math.ceil(total / pageSize));
7 8 if (pages <= 1) return null;
@@ -15,21 +16,43 @@ export function Pagination({ page, pageSize, total, basePath, params, className
15 16 };
16 17 const from = (page - 1) * pageSize + 1;
17 18 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 + const btn = 'inline-flex h-10 min-w-[44px] items-center justify-center gap-1 rounded-md border border-border bg-elevated px-3 text-xs font-medium hover:bg-sunken aria-disabled:pointer-events-none aria-disabled:opacity-40 md:h-8 md:min-w-0';
20 + const around: number[] = [];
21 + for (let p = Math.max(1, page - 2); p <= Math.min(pages, page + 2); p++) around.push(p);
19 22 return (
20 − <nav className={cn('mt-4 flex items-center justify-between gap-3 text-xs text-muted', className)} aria-label="Pagination">
23 + <nav className={cn('mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-muted', className)} aria-label="Pagination">
21 24 <span className="num">
22 25 {from.toLocaleString('en-US')}–{to.toLocaleString('en-US')} of {total.toLocaleString('en-US')}
23 26 </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 + <div className="flex items-center gap-1">
28 + <Link href={href(Math.max(1, page - 1))} aria-disabled={page <= 1} className={btn} aria-label="Previous page">
29 + <ChevronLeft className="h-3.5 w-3.5" />
30 + <span className="hidden sm:inline">Prev</span>
27 31 </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
32 + {around[0]! > 1 ? (
33 + <>
34 + <Link href={href(1)} className={cn(btn, 'num')}>
35 + 1
36 + </Link>
37 + {around[0]! > 2 ? <span className="px-1 text-subtle">…</span> : null}
38 + </>
39 + ) : null}
40 + {around.map((p) => (
41 + <Link key={p} href={href(p)} aria-current={p === page ? 'page' : undefined} className={cn(btn, 'num', p === page && 'border-fg bg-accent text-accent-fg hover:bg-accent')}>
42 + {p}
43 + </Link>
44 + ))}
45 + {around[around.length - 1]! < pages ? (
46 + <>
47 + {around[around.length - 1]! < pages - 1 ? <span className="px-1 text-subtle">…</span> : null}
48 + <Link href={href(pages)} className={cn(btn, 'num')}>
49 + {pages.toLocaleString('en-US')}
50 + </Link>
51 + </>
52 + ) : null}
53 + <Link href={href(Math.min(pages, page + 1))} aria-disabled={page >= pages} className={btn} aria-label="Next page">
54 + <span className="hidden sm:inline">Next</span>
55 + <ChevronRight className="h-3.5 w-3.5" />
33 56 </Link>
34 57 </div>
35 58 </nav>
modified apps/web/src/components/ui/primitives.tsx +105 −25
@@ -5,26 +5,40 @@ import { cn, deltaClass, fmtPct } from '@/lib/format';
5 5 /** Minimal, dense UI primitives. Keep them boring; the data is the design. */
6 6
7 7 export function Card({ children, className, as: Tag = 'section', id }: { children: ReactNode; className?: string; as?: 'section' | 'div' | 'article'; id?: string }) {
8 − return <Tag className={cn('card', className)} id={id}>{children}</Tag>;
8 + return (
9 + <Tag className={cn('card', className)} id={id}>
10 + {children}
11 + </Tag>
12 + );
9 13 }
10 14
11 −export function CardHeader({ title, subtitle, action, className }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode; className?: string }) {
15 +export function CardHeader({ title, subtitle, action, className, icon }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode; className?: string; icon?: ReactNode }) {
12 16 return (
13 17 <header className={cn('flex items-start justify-between gap-3 border-b border-border px-4 py-3', className)}>
14 − <div className="min-w-0">
15 − <h2 className="text-sm font-semibold tracking-tight text-fg">{title}</h2>
16 − {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}
18 + <div className="flex min-w-0 items-start gap-2">
19 + {icon ? <span className="mt-0.5 text-subtle">{icon}</span> : null}
20 + <div className="min-w-0">
21 + <h2 className="t-subtitle text-fg">{title}</h2>
22 + {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}
23 + </div>
17 24 </div>
18 25 {action ? <div className="shrink-0 text-xs">{action}</div> : null}
19 26 </header>
20 27 );
21 28 }
22 29
23 −export function Delta({ value, digits = 2, className }: { value: number | null | undefined; digits?: number; className?: string }) {
24 − return <span className={cn('num font-medium', deltaClass(value), className)}>{fmtPct(value, digits)}</span>;
30 +export function Delta({ value, digits = 2, className, arrow = false }: { value: number | null | undefined; digits?: number; className?: string; arrow?: boolean }) {
31 + const has = value !== null && value !== undefined && Number.isFinite(value);
32 + const glyph = !has || Math.abs(value) < 0.00005 ? '' : value > 0 ? '▲ ' : '▼ ';
33 + return (
34 + <span className={cn('num font-medium', deltaClass(value), className)}>
35 + {arrow && glyph ? <span className="text-[0.7em]">{glyph}</span> : null}
36 + {fmtPct(value, digits)}
37 + </span>
38 + );
25 39 }
26 40
27 −export function Badge({ children, tone = 'neutral', className }: { children: ReactNode; tone?: 'neutral' | 'gain' | 'loss' | 'index' | 'rarity' | 'alert' | 'gold'; className?: string }) {
41 +export function Badge({ children, tone = 'neutral', className, dot = false }: { children: ReactNode; tone?: 'neutral' | 'gain' | 'loss' | 'index' | 'rarity' | 'alert' | 'gold'; className?: string; dot?: boolean }) {
28 42 const tones: Record<string, string> = {
29 43 neutral: 'bg-inset text-muted',
30 44 gain: 'bg-gain-bg text-gain',
@@ -34,22 +48,55 @@ export function Badge({ children, tone = 'neutral', className }: { children: Rea
34 48 alert: 'bg-alert-bg text-alert',
35 49 gold: 'bg-alert-bg text-gold',
36 50 };
37 − return <span className={cn('inline-flex items-center rounded-sm px-1.5 py-0.5 text-[11px] font-medium leading-4 tracking-wide', tones[tone], className)}>{children}</span>;
51 + return (
52 + <span className={cn('inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[11px] font-medium leading-4 tracking-wide', tones[tone], className)}>
53 + {dot ? <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden /> : null}
54 + {children}
55 + </span>
56 + );
38 57 }
39 58
40 −export function Stat({ label, value, sub, className }: { label: ReactNode; value: ReactNode; sub?: ReactNode; className?: string }) {
59 +export function Stat({ label, value, sub, className, size = 'md' }: { label: ReactNode; value: ReactNode; sub?: ReactNode; className?: string; size?: 'sm' | 'md' | 'lg' }) {
41 60 return (
42 − <div className={cn('flex flex-col gap-0.5', className)}>
43 − <span className="text-[11px] font-medium uppercase tracking-wider text-subtle">{label}</span>
44 − <span className="num text-lg font-semibold leading-tight text-fg">{value}</span>
45 − {sub ? <span className="text-xs text-muted">{sub}</span> : null}
61 + <div className={cn('flex min-w-0 flex-col gap-0.5', className)}>
62 + <span className="t-label">{label}</span>
63 + <span className={cn('num truncate font-semibold leading-tight text-fg', size === 'lg' ? 'text-2xl' : size === 'sm' ? 'text-sm' : 'text-lg')}>{value}</span>
64 + {sub ? <span className="truncate text-xs text-muted">{sub}</span> : null}
46 65 </div>
47 66 );
48 67 }
49 68
50 −export function EmptyState({ title, description, action, className }: { title: ReactNode; description?: ReactNode; action?: ReactNode; className?: string }) {
69 +/** Dense KPI strip used under page headers and in the hero. */
70 +export function StatStrip({ items, className, cols }: { items: Array<{ label: ReactNode; value: ReactNode; sub?: ReactNode; href?: string }>; className?: string; cols?: string }) {
71 + const grid = cols ?? (items.length >= 8 ? 'grid-cols-2 sm:grid-cols-4 lg:grid-cols-8' : items.length >= 6 ? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6' : items.length === 5 ? 'grid-cols-2 sm:grid-cols-5' : items.length === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2 sm:grid-cols-3');
72 + return (
73 + <dl className={cn('grid gap-px overflow-hidden rounded-md border border-border bg-border', grid, className)}>
74 + {items.map((it, i) => {
75 + const inner = (
76 + <>
77 + <dt className="t-label">{it.label}</dt>
78 + <dd className="num mt-1 truncate text-[17px] font-semibold leading-none text-fg sm:text-lg">{it.value}</dd>
79 + {it.sub ? <dd className="mt-1 truncate text-[11px] text-muted">{it.sub}</dd> : null}
80 + </>
81 + );
82 + return it.href ? (
83 + <Link key={i} href={it.href} className="block min-w-0 bg-elevated px-3 py-2.5 hover:bg-sunken">
84 + {inner}
85 + </Link>
86 + ) : (
87 + <div key={i} className="min-w-0 bg-elevated px-3 py-2.5">
88 + {inner}
89 + </div>
90 + );
91 + })}
92 + </dl>
93 + );
94 +}
95 +
96 +export function EmptyState({ title, description, action, className, compact }: { title: ReactNode; description?: ReactNode; action?: ReactNode; className?: string; compact?: boolean }) {
51 97 return (
52 − <div className={cn('flex flex-col items-center justify-center gap-2 px-6 py-12 text-center', className)}>
98 + <div className={cn('flex flex-col items-center justify-center gap-1.5 px-6 text-center', compact ? 'py-6' : 'py-12', className)}>
99 + <span className="mb-1 h-1 w-8 rounded-full bg-inset" aria-hidden />
53 100 <p className="text-sm font-medium text-fg">{title}</p>
54 101 {description ? <p className="max-w-md text-xs leading-relaxed text-muted">{description}</p> : null}
55 102 {action ? <div className="mt-2">{action}</div> : null}
@@ -59,15 +106,22 @@ export function EmptyState({ title, description, action, className }: { title: R
59 106
60 107 /** Honest placeholder for metrics without enough evidence (§191–§192). */
61 108 export function Unavailable({ reason = 'Data unavailable', className }: { reason?: string; className?: string }) {
62 − return <span className={cn('text-subtle', className)} title={reason}>—</span>;
109 + return (
110 + <span className={cn('text-subtle', className)} title={reason}>
111 + —
112 + </span>
113 + );
63 114 }
64 115
65 −export function SectionTitle({ children, href, hrefLabel = 'View all', className }: { children: ReactNode; href?: string; hrefLabel?: string; className?: string }) {
116 +export function SectionTitle({ children, href, hrefLabel = 'View all', className, subtitle }: { children: ReactNode; href?: string; hrefLabel?: string; className?: string; subtitle?: ReactNode }) {
66 117 return (
67 − <div className={cn('mb-3 flex items-baseline justify-between gap-3', className)}>
68 − <h2 className="text-base font-semibold tracking-tight text-fg">{children}</h2>
118 + <div className={cn('mb-3 flex items-end justify-between gap-3', className)}>
119 + <div className="min-w-0">
120 + <h2 className="t-title text-fg">{children}</h2>
121 + {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}
122 + </div>
69 123 {href ? (
70 − <Link href={href} className="text-xs font-medium text-muted hover:text-fg">
124 + <Link href={href} className="shrink-0 text-xs font-medium text-muted hover:text-fg">
71 125 {hrefLabel} →
72 126 </Link>
73 127 ) : null}
@@ -79,13 +133,39 @@ export function Skeleton({ className }: { className?: string }) {
79 133 return <div className={cn('animate-pulse rounded-sm bg-inset', className)} aria-hidden />;
80 134 }
81 135
82 −export function Table({ children, className, dense = true }: { children: ReactNode; className?: string; dense?: boolean }) {
136 +/** Skeleton matching a dense table (header + n rows) to avoid layout shift. */
137 +export function TableSkeleton({ rows = 8, className }: { rows?: number; className?: string }) {
83 138 return (
84 − <div className={cn('w-full overflow-x-auto scrollbar-none', className)}>
85 − <table className={cn('w-full border-collapse text-left', dense ? 'text-[13px]' : 'text-sm')}>{children}</table>
139 + <div className={cn('card overflow-hidden', className)} aria-hidden>
140 + <div className="h-9 border-b border-border bg-elevated" />
141 + {Array.from({ length: rows }).map((_, i) => (
142 + <div key={i} className="flex items-center gap-3 border-b border-border px-3 py-2 last:border-b-0">
143 + <Skeleton className="h-8 w-8" />
144 + <Skeleton className="h-3 w-1/3" />
145 + <Skeleton className="ml-auto h-3 w-16" />
146 + <Skeleton className="h-3 w-12" />
147 + </div>
148 + ))}
86 149 </div>
87 150 );
88 151 }
89 −export const th = 'sticky top-0 z-[1] whitespace-nowrap border-b border-border bg-elevated px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-subtle';
152 +
153 +export function Table({ children, className, dense = true, sticky = true }: { children: ReactNode; className?: string; dense?: boolean; sticky?: boolean }) {
154 + return (
155 + <div className={cn('w-full overflow-x-auto overscroll-x-contain scrollbar-none', className)}>
156 + <table className={cn('w-full border-collapse text-left', dense ? 'text-[13px]' : 'text-sm', sticky && 'table-sticky md:[&_td:first-child]:static md:[&_th:first-child]:static md:[&_td:first-child]:shadow-none md:[&_th:first-child]:shadow-none')}>{children}</table>
157 + </div>
158 + );
159 +}
160 +export const th = 'sticky top-0 z-[1] whitespace-nowrap border-b border-border bg-elevated px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-subtle';
90 161 export const td = 'whitespace-nowrap border-b border-border px-3 py-2 align-middle';
91 162 export const tdNum = `${td} num text-right`;
163 +
164 +/** Sequential page "kicker" — small uppercase label above a title. */
165 +export function Kicker({ children, className }: { children: ReactNode; className?: string }) {
166 + return <p className={cn('t-label mb-2', className)}>{children}</p>;
167 +}
168 +
169 +export function Divider({ className }: { className?: string }) {
170 + return <hr className={cn('border-0 border-t border-border', className)} />;
171 +}
modified apps/web/src/components/ui/tabs.tsx +4 −4
@@ -13,7 +13,7 @@ export interface TabDef {
13 13 export function Tabs({ tabs, active, className }: { tabs: TabDef[]; active: string; className?: string }) {
14 14 return (
15 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">
16 + <div className="flex min-w-max gap-0.5">
17 17 {tabs.map((t) => {
18 18 const isActive = t.id === active;
19 19 return (
@@ -23,10 +23,10 @@ export function Tabs({ tabs, active, className }: { tabs: TabDef[]; active: stri
23 23 role="tab"
24 24 aria-selected={isActive}
25 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')}
26 + className={cn('-mb-px inline-flex min-h-[44px] items-center gap-1.5 whitespace-nowrap border-b-2 px-3 text-[13px] font-medium md:min-h-[38px]', isActive ? 'border-fg text-fg' : 'border-transparent text-muted hover:border-border-strong hover:text-fg')}
27 27 >
28 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}
29 + {t.count !== undefined && t.count !== null ? <span className={cn('num rounded-sm px-1 text-[10px]', isActive ? 'bg-accent text-accent-fg' : 'bg-inset text-muted')}>{t.count.toLocaleString('en-US')}</span> : null}
30 30 </Link>
31 31 );
32 32 })}
@@ -40,7 +40,7 @@ export function Segmented({ options, active, className }: { options: Array<{ id:
40 40 return (
41 41 <div className={cn('inline-flex rounded-md border border-border bg-sunken p-0.5 text-[11px] font-medium', className)}>
42 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}>
43 + <Link key={o.id} href={o.href} scroll={false} className={cn('inline-flex min-h-[28px] items-center rounded-[4px] px-2.5', o.id === active ? 'bg-elevated text-fg shadow-card' : 'text-muted hover:text-fg')} aria-current={o.id === active ? 'true' : undefined}>
44 44 {o.label}
45 45 </Link>
46 46 ))}
modified apps/web/src/lib/queries/assets.ts +26 −1
@@ -782,7 +782,11 @@ export const getSetFromAssets = cache(async (slug: string) => {
782 782 export const getCategoryThumbnails = cache(async (slugs: string[]): Promise<Map<string, string>> => {
783 783 if (!slugs.length) return new Map();
784 784 const r = await rows<Record<string, unknown>>(sql`
785 − SELECT c.slug, (SELECT a.hero_image_url FROM assets a WHERE a.category_slug = c.slug AND a.hero_image_url IS NOT NULL ORDER BY a.created_at DESC LIMIT 1) AS url
785 + SELECT c.slug, (
786 + SELECT a.hero_image_url FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id
787 + WHERE (a.category_slug = c.slug OR a.family_slug = c.slug) AND a.hero_image_url IS NOT NULL
788 + ORDER BY s.sales_count DESC NULLS LAST, s.riv_usd DESC NULLS LAST, a.created_at DESC LIMIT 1
789 + ) AS url
786 790 FROM unnest(${textArray(slugs)}) AS c(slug)
787 791 `);
788 792 return new Map(r.filter((x) => x.url).map((x) => [String(x.slug), String(x.url)]));
@@ -816,3 +820,24 @@ export const getScopeCounts = cache(async (f: { scope?: string[] | null; set?: s
816 820 `);
817 821 return { assets: int(x?.assets), priced: int(x?.priced), withSales: int(x?.with_sales), withObservations: int(x?.with_obs), withImages: int(x?.with_images), listings: int(x?.listings) };
818 822 });
823 +
824 +/** Valuation history (RIV with low/high band) from the valuations table, one point per day. */
825 +export const getValuationHistory = cache(async (assetId: string, variantId: string | null = null, days = 1830): Promise<Array<{ date: string; rivUsd: number; lowUsd: number | null; highUsd: number | null; confidence: number; sampleSize: number }>> => {
826 + const r = await rows<Record<string, unknown>>(sql`
827 + SELECT DISTINCT ON (computed_at::date) computed_at::date AS d, riv_usd, low_usd, high_usd, confidence, sample_size
828 + FROM valuations
829 + WHERE asset_id = ${assetId} AND ${variantId ? sql`variant_id = ${variantId}` : sql`variant_id IS NULL`} AND riv_usd IS NOT NULL AND computed_at >= now() - (${days}::int || ' days')::interval
830 + ORDER BY computed_at::date, computed_at DESC
831 + `);
832 + return r.map((x) => ({ date: String(x.d), rivUsd: Number(x.riv_usd), lowUsd: num(x.low_usd), highUsd: num(x.high_usd), confidence: Number(x.confidence ?? 0), sampleSize: int(x.sample_size) }));
833 +});
834 +
835 +/** Whether the signed-in user watches this asset (null when anonymous). */
836 +export async function isWatchedBy(userId: string | null, assetId: string): Promise<boolean> {
837 + if (!userId) return false;
838 + const x = await one<Record<string, unknown>>(sql`
839 + SELECT 1 AS ok FROM watchlist_items wi JOIN watchlists w ON w.id = wi.watchlist_id
840 + WHERE w.user_id = ${userId} AND wi.target_type = 'asset' AND wi.target_id = ${assetId} LIMIT 1
841 + `);
842 + return Boolean(x);
843 +}
modified apps/web/src/lib/queries/markets.ts +18 −1
@@ -1,7 +1,7 @@
1 1 import 'server-only';
2 2 import { cache } from 'react';
3 3 import { CATEGORIES, FAMILIES, getCategory, type CategoryNode } from '@rareindex/taxonomy';
4 −import { rows, one, sql, num, int, str, date } from './_util';
4 +import { rows, one, sql, num, int, str, date, textArray } from './_util';
5 5 import { categoryScope, getSetFromAssets } from './assets';
6 6
7 7 export interface CategorySnapshot {
@@ -104,6 +104,23 @@ export const getCategorySeries = cache(async (slug: string, days: number | null
104 104 return r.map(toSnap);
105 105 });
106 106
107 +/** Compact per-category index series (values only) for sparklines; categories without snapshots are absent. */
108 +export const getCategorySparklines = cache(async (slugs: string[], days = 90): Promise<Map<string, number[]>> => {
109 + if (!slugs.length) return new Map();
110 + const r = await rows<{ category_slug: string; values: unknown }>(sql`
111 + SELECT category_slug, array_agg(index_value ORDER BY date) AS values
112 + FROM category_snapshots
113 + WHERE category_slug = ANY(${textArray(slugs)}) AND index_value IS NOT NULL AND date >= current_date - ${days}::int
114 + GROUP BY category_slug
115 + `);
116 + const out = new Map<string, number[]>();
117 + for (const x of r) {
118 + const vals = (Array.isArray(x.values) ? x.values : []).map((v) => Number(v)).filter((v) => Number.isFinite(v));
119 + if (vals.length >= 3) out.set(String(x.category_slug), vals);
120 + }
121 + return out;
122 +});
123 +
107 124 export const getCategoryRow = cache(async (slug: string): Promise<MarketRow | null> => {
108 125 const node = getCategory(slug);
109 126 if (!node) return null;
110 127