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%

web: populate market/home/asset/search pages from catalog data before valuations (agent E2)

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

19 changed files +717 −117

modified apps/web/src/app/asset/[slug]/page.tsx +5 −4
@@ -5,7 +5,7 @@ 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 } from '@/lib/queries/assets';
8 +import { getAssetBySlug, getAssetVariants, getAssetListings, getAssetLiveCounts, getLatestGuidePrice } from '@/lib/queries/assets';
9 9 import { catName, categoryPath } from '@/lib/taxonomy';
10 10 import { sp1, spEnum, spInt, type SP } from '@/lib/search-params';
11 11 import { fmtMoney } from '@/lib/format';
@@ -18,7 +18,8 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
18 18 const { slug } = await params;
19 19 const a = await getAssetBySlug(slug);
20 20 if (!a) return { title: 'Asset not found' };
21 const price = a.rivUsd !== null ? ` · RIV ${fmtMoney(a.rivUsd)}` : a.latestSaleUsd !== null ? ` · last sale ${fmtMoney(a.latestSaleUsd)}` : '';
21 + const guide = a.rivUsd === null && a.latestSaleUsd === null ? await getLatestGuidePrice(a.id) : null;
22 + const price = a.rivUsd !== null ? ` · RIV ${fmtMoney(a.rivUsd)}` : a.latestSaleUsd !== null ? ` · last sale ${fmtMoney(a.latestSaleUsd)}` : guide ? ` · guide price ${fmtMoney(guide.priceUsd)} (${guide.sourceName})` : '';
22 23 const description = `${a.title}${price}. ${catName(a.categorySlug)} price history, verified sales, live listings, rarity and liquidity on RareIndex.`;
23 24 return {
24 25 title: a.title,
@@ -51,7 +52,7 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
51 52 const variant = vParam ? variants.find((v) => v.id === vParam) ?? null : null;
52 53 const page = spInt(sp, 'page');
53 54 const href = (t: AssetTab, v: string | null = variant?.id ?? null) => `/asset/${asset.slug}?tab=${t}${v ? `&v=${v}` : ''}`;
54 const listingsForLd = await getAssetListings(asset.id, { limit: 20 });
55 + const [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)]);
55 56 const crumbs = categoryPath(asset.categorySlug);
56 57
57 58 const jsonLd = [
@@ -89,7 +90,7 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
89 90 return (
90 91 <div>
91 92 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
92 <AssetHeader asset={asset} variants={variants} activeVariant={variant} tabHref={(v) => href(tab, v)} />
93 + <AssetHeader asset={asset} variants={variants} activeVariant={variant} tabHref={(v) => href(tab, v)} guide={guide} />
93 94 <Tabs
94 95 className="mt-6 mb-4"
95 96 active={tab}
modified apps/web/src/app/brand/[brand]/page.tsx +10 −12
@@ -2,14 +2,14 @@ import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import { notFound } from 'next/navigation';
4 4 import { PageHeader } from '@/components/ui/page-header';
5 import { Card, Stat } from '@/components/ui/primitives';
6 import { AssetList } from '@/components/market/asset-list';
7 import { Pagination } from '@/components/ui/pagination';
5 +import { Stat } from '@/components/ui/primitives';
6 +import { AssetBrowser } from '@/components/market/asset-browser';
7 +import { Suspense } from 'react';
8 +import { Skeleton } from '@/components/ui/primitives';
8 9 import { getBrandStats } from '@/lib/queries/markets';
9 import { exploreAssets, type ExploreSort } from '@/lib/queries/assets';
10 10 import { catName } from '@/lib/taxonomy';
11 11 import { fmtMoney, fmtNum } from '@/lib/format';
12 import { spEnum, spInt, type SP } from '@/lib/search-params';
12 +import type { SP } from '@/lib/search-params';
13 13
14 14 export const revalidate = 300;
15 15
@@ -25,9 +25,6 @@ export default async function BrandPage({ params, searchParams }: { params: Prom
25 25 const name = decodeURIComponent(brand);
26 26 const stats = await getBrandStats(name);
27 27 if (!stats) notFound();
28 const sort = spEnum<ExploreSort>(sp, 'sort', ['riv', 'change30d', 'sales', 'newest', 'latest_sale'], 'riv');
29 const page = spInt(sp, 'page');
30 const res = await exploreAssets({ brand: name, sort, page, pageSize: 60 });
31 28 return (
32 29 <div>
33 30 <PageHeader
@@ -54,10 +51,11 @@ export default async function BrandPage({ params, searchParams }: { params: Prom
54 51 </div>
55 52 ))}
56 53 </section>
57 <Card className="mt-4 overflow-hidden">
58 <AssetList items={res.items} rank startRank={(page - 1) * 60 + 1} />
59 </Card>
60 <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath={`/brand/${brand}`} params={{ sort }} />
54 + <div className="mt-4">
55 + <Suspense fallback={<Skeleton className="h-96" />}>
56 + <AssetBrowser scope={{ brand: name }} sp={sp} basePath={`/brand/${brand}`} title={`${stats.brand} assets`} pageSize={60} />
57 + </Suspense>
58 + </div>
61 59 </div>
62 60 );
63 61 }
modified apps/web/src/app/categories/page.tsx +13 −3
@@ -3,6 +3,8 @@ import Link from 'next/link';
3 3 import { PageHeader } from '@/components/ui/page-header';
4 4 import { Badge } from '@/components/ui/primitives';
5 5 import { getCategoryCounts } from '@/lib/queries/markets';
6 +import { getCategoryThumbnails } from '@/lib/queries/assets';
7 +import { Thumb } from '@/components/market/bits';
6 8 import { FAMILIES, getCategory } from '@rareindex/taxonomy';
7 9 import { fmtNum } from '@/lib/format';
8 10 import { INDICES } from '@rareindex/taxonomy';
@@ -12,6 +14,7 @@ export const revalidate = 300;
12 14
13 15 export default async function CategoriesPage() {
14 16 const counts = await getCategoryCounts();
17 + const thumbs = await getCategoryThumbnails(FAMILIES.flatMap((f) => [f.slug, ...f.children]));
15 18 const total = (slugs: string[]) => slugs.reduce((a, s) => a + (counts.get(s)?.assets ?? 0), 0);
16 19 const groups = [1, 2, 3].map((phase) => ({ phase, families: FAMILIES.filter((f) => f.phase === phase) }));
17 20 return (
@@ -31,8 +34,9 @@ export default async function CategoriesPage() {
31 34 return (
32 35 <div key={f.slug} className="card flex flex-col p-3">
33 36 <div className="flex items-start justify-between gap-2">
34 <Link href={`/markets/${f.slug}`} className="text-[13px] font-semibold text-fg hover:underline">
35 {f.name}
37 + <Link href={`/markets/${f.slug}`} className="flex min-w-0 items-center gap-2 text-[13px] font-semibold text-fg hover:underline">
38 + <Thumb src={thumbs.get(f.slug) ?? f.children.map((c) => thumbs.get(c)).find(Boolean)} alt="" size={28} rounded="rounded-md" />
39 + <span className="truncate">{f.name}</span>
36 40 </Link>
37 41 {idx ? (
38 42 <Link href={`/rareindex/${idx.ticker}`} className="shrink-0">
@@ -59,7 +63,13 @@ export default async function CategoriesPage() {
59 63 ) : null}
60 64 <div className="mt-auto flex items-center justify-between pt-2 text-[11px] text-subtle">
61 65 <span className="num">{assets ? `${fmtNum(assets)} assets` : 'no data yet'}</span>
62 <span>{f.conditionScale ? `scale: ${f.conditionScale}` : ''}</span>
66 + {assets ? (
67 + <Link href={`/markets/${f.slug}#assets`} className="text-muted hover:text-fg">
68 + Browse →
69 + </Link>
70 + ) : (
71 + <span>{f.conditionScale ? `scale: ${f.conditionScale}` : ''}</span>
72 + )}
63 73 </div>
64 74 </div>
65 75 );
modified apps/web/src/app/explore/page.tsx +25 −8
@@ -6,19 +6,22 @@ import { Pagination } from '@/components/ui/pagination';
6 6 import { Segmented } from '@/components/ui/tabs';
7 7 import { AssetCard, AssetList } from '@/components/market/asset-list';
8 8 import { Skeleton } from '@/components/ui/primitives';
9 import { exploreAssets, type ExploreSort } from '@/lib/queries/assets';
9 +import { attachGuidePrices, exploreAssets, getScopeCounts, categoryScope, type ExploreSort, type HasFilter } from '@/lib/queries/assets';
10 +import { fmtNum } from '@/lib/format';
10 11 import { CATEGORIES, FAMILIES, GRADERS } from '@rareindex/taxonomy';
11 12 import { pick, sp1, spEnum, spInt, spNum, withParams, type SP } from '@/lib/search-params';
12 13
13 14 export const metadata: Metadata = { title: 'Explore collectibles', description: 'Browse tracked collectible assets across every category, filtered by grade, price, liquidity, rarity and momentum.' };
14 15
15 const SORTS: ExploreSort[] = ['riv', 'change7d', 'change30d', 'sales', 'liquidity', 'rarity', 'trending', 'newest', 'latest_sale', 'opportunity'];
16 const SORT_LABEL: Record<ExploreSort, string> = { riv: 'Valuation', change7d: '7D change', change30d: '30D change', sales: 'Sales', liquidity: 'Liquidity', rarity: 'Rarity', trending: 'Trending', newest: 'Newest', latest_sale: 'Last sale', opportunity: 'Value opportunity' };
17 const KEYS = ['category', 'grader', 'grade', 'min', 'max', 'liq', 'rar', 'mom', 'from', 'to', 'brand', 'set', 'q', 'sort', 'view'];
16 +const SORTS: ExploreSort[] = ['relevance', 'riv', 'change7d', 'change30d', 'sales', 'liquidity', 'rarity', 'trending', 'newest', 'latest_sale', 'opportunity', 'name'];
17 +const HAS_OPTIONS: Array<{ id: HasFilter | ''; label: string }> = [{ id: '', label: 'All' }, { id: 'sales', label: 'With sales' }, { id: 'valuation', label: 'With valuation' }, { id: 'listings', label: 'With listings' }, { id: 'observations', label: 'With guide price' }, { id: 'images', label: 'With image' }];
18 +const SORT_LABEL: Record<ExploreSort, string> = { relevance: 'Most data', name: 'Name', number: 'Set · number', riv: 'Valuation', change7d: '7D change', change30d: '30D change', sales: 'Sales', liquidity: 'Liquidity', rarity: 'Rarity', trending: 'Trending', newest: 'Newest', latest_sale: 'Last sale', opportunity: 'Value opportunity' };
19 +const KEYS = ['category', 'grader', 'grade', 'min', 'max', 'liq', 'rar', 'mom', 'from', 'to', 'brand', 'set', 'q', 'sort', 'view', 'has'];
18 20
19 21 export default async function ExplorePage({ searchParams }: { searchParams: Promise<SP> }) {
20 22 const sp = await searchParams;
21 const sort = spEnum(sp, 'sort', SORTS, 'riv');
23 + const sort = spEnum(sp, 'sort', SORTS, 'relevance');
24 + const has = spEnum<HasFilter | ''>(sp, 'has', ['', 'sales', 'valuation', 'listings', 'observations', 'images'] as const, '');
22 25 const view = spEnum(sp, 'view', ['table', 'grid'] as const, 'table');
23 26 const page = spInt(sp, 'page');
24 27 const filters = {
@@ -35,6 +38,7 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
35 38 brand: sp1(sp, 'brand') ?? null,
36 39 set: sp1(sp, 'set') ?? null,
37 40 q: sp1(sp, 'q') ?? null,
41 + has: has || null,
38 42 sort,
39 43 page,
40 44 pageSize: 48,
@@ -43,7 +47,15 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
43 47 const params = pick(sp, KEYS);
44 48 return (
45 49 <div>
46 <PageHeader title="Explore" description="Every tracked collectible asset, ranked by RareIndex Valuation, momentum, liquidity or rarity. Filters are reflected in the URL." compact />
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>
47 59 <div className="mb-3 flex flex-wrap items-end justify-between gap-3">
48 60 <Suspense>
49 61 <FilterBar
@@ -97,11 +109,16 @@ function SortSelect({ current, params }: { current: ExploreSort; params: Record<
97 109 }
98 110
99 111 async function Results({ filters, view, params }: { filters: Parameters<typeof exploreAssets>[0]; view: 'table' | 'grid'; params: Record<string, string | undefined> }) {
100 const res = await exploreAssets(filters);
112 + const [res, counts] = await Promise.all([exploreAssets(filters), getScopeCounts({ scope: filters.category ? categoryScope(filters.category) : null, set: filters.set ?? null, brand: filters.brand ?? null })]);
113 + await attachGuidePrices(res.items);
101 114 const columns = filters.sort === 'rarity' || filters.sort === 'trending' || filters.sort === 'opportunity' ? (['riv', 'change30d', 'sales', 'liquidity', 'rarity', 'trending', 'opportunity'] as const) : (['riv', 'change1d', 'change7d', 'change30d', 'latestSale', 'sales', 'listings', 'liquidity'] as const);
102 115 return (
103 116 <>
104 <p className="mb-2 num text-[11px] text-subtle">{res.total.toLocaleString('en-US')} assets</p>
117 + <p className="mb-2 num text-[11px] text-subtle">
118 + {res.total.toLocaleString('en-US')} assets match · {fmtNum(counts.priced)} of {fmtNum(counts.assets)} in scope have a valuation
119 + {counts.withObservations ? ` · ${fmtNum(counts.withObservations)} carry guide prices` : ''}
120 + {counts.assets > counts.priced ? ' · valuations publish as verified sales accrue' : ''}
121 + </p>
105 122 {view === 'grid' ? (
106 123 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
107 124 {res.items.map((a) => (
modified apps/web/src/app/markets/[slug]/page.tsx +24 −6
@@ -5,6 +5,9 @@ import { Suspense } from 'react';
5 5 import { PageHeader } from '@/components/ui/page-header';
6 6 import { Card, CardHeader, Delta, EmptyState, Skeleton, Stat, Badge, Unavailable } from '@/components/ui/primitives';
7 7 import { MarketTable } from '@/components/market/market-table';
8 +import { AssetBrowser } from '@/components/market/asset-browser';
9 +import { Thumb } from '@/components/market/bits';
10 +import type { SP } from '@/lib/search-params';
8 11 import { AssetTable } from '@/components/market/asset-list';
9 12 import { SalesTable, ListingsTable, LotsTable } from '@/components/market/sales-table';
10 13 import { LineChart } from '@/components/charts/line-chart';
@@ -30,12 +33,14 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
30 33 return { title: `${c.name} market`, description: `${c.name} collectibles market on RareIndex: index, sales, volume, top movers, most valuable assets, listings and news.` };
31 34 }
32 35
33 export default async function MarketPage({ params }: { params: Promise<{ slug: string }> }) {
36 +export default async function MarketPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {
34 37 const { slug } = await params;
38 + const sp = await searchParams;
35 39 const row = await getCategoryRow(slug);
36 40 if (!row) notFound();
37 41 const { node, snapshot, counts } = row;
38 42 const index = node.index ? await getIndex(node.index) : null;
43 + const subcategories = CATEGORIES.filter((c) => c.parent === slug);
39 44 return (
40 45 <div>
41 46 <PageHeader
@@ -75,6 +80,17 @@ export default async function MarketPage({ params }: { params: Promise<{ slug: s
75 80 ))}
76 81 </section>
77 82 {snapshot?.marketCapEstUsd != null ? <p className="mt-1 text-[11px] text-subtle">Market cap estimate = estimated population × representative RIV per asset, summed; indicative only (§124).</p> : null}
83 + {counts.assets > 0 && counts.priced === 0 ? (
84 + <p className="mt-2 rounded-md border border-border bg-sunken px-3 py-2 text-[12px] text-muted">
85 + <span className="font-medium text-fg">{fmtNum(counts.assets)} {node.name} assets are catalogued.</span> Valuations publish per asset once enough verified sales exist; guide prices from price-guide sources are shown in the meantime, labelled as such (never presented as market value).
86 + </p>
87 + ) : null}
88 +
89 + <div className="mt-4">
90 + <Suspense fallback={<Skeleton className="h-[32rem]" />}>
91 + <AssetBrowser scope={{ category: slug }} sp={sp} basePath={`/markets/${slug}`} title={`Assets in ${node.name}`} subcategories={subcategories} />
92 + </Suspense>
93 + </div>
78 94
79 95 <Suspense fallback={<Skeleton className="mt-4 h-72" />}>
80 96 <IndexPanel slug={slug} />
@@ -244,17 +260,19 @@ async function Structure({ slug }: { slug: string }) {
244 260 ) : null}
245 261 <div className="grid gap-4 lg:grid-cols-2">
246 262 <Card>
247 <CardHeader title="Sets & releases" subtitle="Ranked by sales" />
263 + <CardHeader title="Sets & releases" subtitle={`${fmtNum(sets.length)}${sets.length >= 40 ? '+' : ''} releases catalogued · ranked by sales, then size`} action={<Link href={`/markets/${slug}?sort=number#assets`} className="text-muted hover:text-fg">Browse by set →</Link>} />
248 264 {sets.length ? (
249 <ul className="grid grid-cols-1 divide-y divide-border text-[12px] sm:grid-cols-2 sm:divide-y-0">
265 + <ul className="grid grid-cols-1 text-[12px] sm:grid-cols-2">
250 266 {sets.map((s) => (
251 <li key={s.slug} className="flex items-center justify-between gap-2 border-b border-border px-4 py-1.5">
252 <Link href={`/set/${s.slug}`} className="truncate font-medium text-fg hover:underline">
267 + <li key={s.slug} className="flex items-center gap-2 border-b border-border px-4 py-1.5">
268 + <Thumb src={s.thumb} alt="" size={28} />
269 + <Link href={`/set/${s.slug}`} className="min-w-0 flex-1 truncate font-medium text-fg hover:underline">
253 270 {s.name}
271 + {s.code ? <span className="ml-1 text-subtle">{s.code}</span> : null}
254 272 {s.releaseYear ? <span className="ml-1 text-subtle">{s.releaseYear}</span> : null}
255 273 </Link>
256 274 <span className="num shrink-0 text-muted">
257 {fmtNum(s.assets)} assets · {fmtNum(s.sales)} sales
275 + {fmtNum(s.assets)}{s.sales ? ` · ${fmtNum(s.sales)} sales` : ''}{s.priced ? ` · ${fmtNum(s.priced)} priced` : ''}
258 276 </span>
259 277 </li>
260 278 ))}
modified apps/web/src/app/page.tsx +50 −13
@@ -3,6 +3,9 @@ import Link from 'next/link';
3 3 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 +import { CategoryGrid } from '@/components/market/category-grid';
7 +import { attachGuidePrices, getCategoryThumbnails, getLatestObservationsFeed } from '@/lib/queries/assets';
8 +import { fmtNum } from '@/lib/format';
6 9 import { Skeleton } from '@/components/ui/primitives';
7 10 import { getLatestSales, getSiteStats } from '@/lib/queries/site';
8 11 import { listIndices } from '@/lib/queries/indices';
@@ -95,7 +98,7 @@ async function FlagshipPanel() {
95 98 }
96 99
97 100 async function Discovery() {
98 const [trending, movers, losers, records, radar, lots, watched, newest, markets] = await Promise.all([
101 + const [trending, movers, losers, records, radar, lots, watched, newest, markets, opportunities, newestPriced, documented, withSales] = await Promise.all([
99 102 rankedAssets('trending', { limit: 8 }),
100 103 rankedAssets('gainers', { limit: 8, window: '7d' }),
101 104 rankedAssets('losers', { limit: 8, window: '7d' }),
@@ -105,27 +108,61 @@ async function Discovery() {
105 108 rankedAssets('watched', { limit: 8 }),
106 109 rankedAssets('newest', { limit: 8 }),
107 110 getMarketRows(),
111 + rankedAssets('opportunity', { limit: 8 }),
112 + rankedAssets('newest_priced', { limit: 8 }),
113 + rankedAssets('documented', { limit: 8 }),
114 + rankedAssets('with_sales', { limit: 8 }),
108 115 ]);
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));
119 + // Honest fallbacks while valuations are still being computed: each rail states what it shows.
120 + const trendingRail = trending.length
121 + ? { title: 'Trending Now', subtitle: 'Price × volume × listing momentum', items: trending, metric: 'trending' as const, href: '/trending' }
122 + : 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' };
125 + const moversItems = movers.length ? movers : losers;
126 + const moversRail = moversItems.length
127 + ? { title: 'Biggest Movers', subtitle: '7-day change in RareIndex Valuation', items: moversItems, metric: 'change7d' as const, href: '/explore?sort=change7d' }
128 + : 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' }
130 + : null;
109 131 return (
110 <section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
111 <AssetRail title="Trending Now" subtitle="Price × volume × listing momentum" href="/trending" items={trending} metric="trending" />
112 <AssetRail title="Biggest Movers" subtitle="7-day change in RareIndex Valuation" href="/explore?sort=change7d" items={movers.length ? movers : losers} metric="change7d" empty="Movers appear when assets have ≥3 sales and a 7-day history." />
113 <SalesRail title="Record Sales" subtitle="Highest verified transactions" href="/records" items={records} />
114 <RadarRail items={radar} />
115 <LotsRail items={lots} />
116 <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" empty="Watch an asset from its page to see it here." />
117 <AssetRail title="Newly Added" subtitle="Latest canonical assets" href="/explore?sort=newest" items={newest} metric="new" />
118 <MarketInsights rows={markets} />
119 <AssetRail title="Value Opportunities" subtitle="Listings materially below RIV (analytical, not advice)" href="/listings?sort=discount" items={await rankedAssets('opportunity', { limit: 8 })} metric="opportunity" empty="Requires valuations and active listings." />
120 </section>
132 + <>
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>
156 + </>
121 157 );
122 158 }
123 159
124 160 async function Latest() {
125 161 const items = await getLatestSales(12);
162 + const observations = items.length ? [] : await getLatestObservationsFeed(12);
126 163 return (
127 164 <section className="mt-6">
128 <LatestTransactions items={items} />
165 + <LatestTransactions items={items} observations={observations} />
129 166 </section>
130 167 );
131 168 }
modified apps/web/src/app/search/page.tsx +30 −20
@@ -1,12 +1,14 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import { Suspense } from 'react';
4 import { searchAssets } from '@rareindex/search';
4 +import { searchAssets, searchSets } from '@rareindex/search';
5 +import { Thumb } from '@/components/market/bits';
6 +import { fmtNum } from '@/lib/format';
5 7 import { PageHeader } from '@/components/ui/page-header';
6 8 import { Card, CardHeader, EmptyState, Badge, Skeleton } from '@/components/ui/primitives';
7 9 import { Pagination } from '@/components/ui/pagination';
8 10 import { AssetList } from '@/components/market/asset-list';
9 import { getAssetsBySlugs } from '@/lib/queries/assets';
11 +import { attachGuidePrices, getAssetsBySlugs } from '@/lib/queries/assets';
10 12 import { rows, sql } from '@/lib/queries/_util';
11 13 import { CATEGORIES, INDICES, getGrader } from '@rareindex/taxonomy';
12 14 import { catName } from '@/lib/taxonomy';
@@ -44,13 +46,12 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi
44 46
45 47 async function Results({ q, category, page }: { q: string; category: string | null; page: number }) {
46 48 const pageSize = 24;
47 const res = await searchAssets(q, { limit: pageSize, offset: (page - 1) * pageSize, categorySlug: category });
48 const items = await getAssetsBySlugs(res.hits.map((h) => h.slug));
49 + const [res, setHits] = await Promise.all([searchAssets(q, { limit: pageSize, offset: (page - 1) * pageSize, categorySlug: category }), searchSets(q, 8)]);
50 + const items = await attachGuidePrices(await getAssetsBySlugs(res.hits.map((h) => h.slug)));
49 51 const p = res.parsed;
50 52 const lower = q.toLowerCase();
51 53 const matchedCats = CATEGORIES.filter((c) => c.name.toLowerCase().includes(lower) || p.categorySlugs.includes(c.slug)).slice(0, 6);
52 54 const matchedIdx = INDICES.filter((i) => i.ticker.toLowerCase().includes(lower) || i.name.toLowerCase().includes(lower)).slice(0, 3);
53 const sets = (await rows<{ slug: string; name: string; category_slug: string }>(sql`SELECT slug, name, category_slug FROM sets WHERE name ILIKE ${'%' + q + '%'} ORDER BY name LIMIT 6`)).map((s) => ({ ...s }));
54 55 const brands = (await rows<{ brand: string; n: number }>(sql`SELECT brand, count(*) AS n FROM assets WHERE brand ILIKE ${'%' + q + '%'} GROUP BY brand ORDER BY n DESC LIMIT 6`)).map((b) => ({ ...b }));
55 56 const sources = (await rows<{ id: string; name: string }>(sql`SELECT id, name FROM sources WHERE name ILIKE ${'%' + q + '%'} LIMIT 4`)).map((s) => ({ ...s }));
56 57 const chips = [
@@ -77,8 +78,31 @@ async function Results({ q, category, page }: { q: string; category: string | nu
77 78 ))}
78 79 {p.text && p.text !== q ? <span>· text “{p.text}”</span> : null}
79 80 </div>
81 + {setHits.length ? (
82 + <Card className="mb-3">
83 + <CardHeader title="Sets & releases" subtitle="Matching releases — open one to browse every card or item in it" />
84 + <ul className="grid grid-cols-1 text-[12px] sm:grid-cols-2">
85 + {setHits.map((s) => (
86 + <li key={s.slug} className="border-b border-border">
87 + <Link href={`/set/${s.slug}`} className="flex items-center gap-2 px-4 py-1.5 hover:bg-sunken">
88 + <Thumb src={s.thumb} alt="" size={28} />
89 + <span className="min-w-0 flex-1 truncate font-medium text-fg">
90 + {s.name}
91 + {s.code ? <span className="ml-1 text-subtle">{s.code}</span> : null}
92 + {s.releaseYear ? <span className="ml-1 text-subtle">{s.releaseYear}</span> : null}
93 + </span>
94 + <span className="num shrink-0 text-muted">
95 + {catName(s.categorySlug)} · {fmtNum(s.assets)} assets
96 + </span>
97 + </Link>
98 + </li>
99 + ))}
100 + </ul>
101 + </Card>
102 + ) : null}
80 103 <Card className="overflow-hidden">
81 <AssetList items={items} rank startRank={(page - 1) * pageSize + 1} emptyTitle="No matching assets" emptyDescription="Try fewer words, or browse the category pages. New assets are added continuously by the connectors." />
104 + <CardHeader title="Assets" subtitle={res.total ? `${res.total.toLocaleString('en-US')} matching canonical assets · guide prices labelled, RIV when available` : undefined} />
105 + <AssetList items={items} rank startRank={(page - 1) * pageSize + 1} columns={['riv', 'latestSale', 'change30d', 'sales', 'listings']} emptyTitle="No matching assets" emptyDescription="Try fewer words, a set name or a collector number (e.g. “base set”, “LOB-001”, “116500LN”), or browse the category pages. New assets are added continuously by the connectors." />
82 106 </Card>
83 107 <Pagination page={page} pageSize={pageSize} total={res.total} basePath="/search" params={{ q, category: category ?? undefined }} />
84 108 </div>
@@ -126,20 +150,6 @@ async function Results({ q, category, page }: { q: string; category: string | nu
126 150 </ul>
127 151 </Card>
128 152 ) : null}
129 {sets.length ? (
130 <Card>
131 <CardHeader title="Sets" />
132 <ul className="divide-y divide-border text-[12px]">
133 {sets.map((s) => (
134 <li key={s.slug}>
135 <Link href={`/set/${s.slug}`} className="block px-4 py-1.5 text-fg hover:bg-sunken">
136 {s.name} <span className="text-subtle">{catName(s.category_slug)}</span>
137 </Link>
138 </li>
139 ))}
140 </ul>
141 </Card>
142 ) : null}
143 153 {brands.length ? (
144 154 <Card>
145 155 <CardHeader title="Brands" />
modified apps/web/src/app/set/[slug]/page.tsx +11 −12
@@ -1,14 +1,14 @@
1 1 import type { Metadata } from 'next';
2 2 import { notFound } from 'next/navigation';
3 3 import { PageHeader } from '@/components/ui/page-header';
4 import { Card, Stat } from '@/components/ui/primitives';
5 import { AssetList } from '@/components/market/asset-list';
6 import { Pagination } from '@/components/ui/pagination';
4 +import { Stat } from '@/components/ui/primitives';
5 +import { AssetBrowser } from '@/components/market/asset-browser';
6 +import { Suspense } from 'react';
7 +import { Skeleton } from '@/components/ui/primitives';
7 8 import { getSet, getSetStats } from '@/lib/queries/markets';
8 import { exploreAssets, type ExploreSort } from '@/lib/queries/assets';
9 9 import { catName, categoryCrumbs } from '@/lib/taxonomy';
10 10 import { fmtMoney, fmtNum, fmtRelative } from '@/lib/format';
11 import { spEnum, spInt, type SP } from '@/lib/search-params';
11 +import type { SP } from '@/lib/search-params';
12 12
13 13 export const revalidate = 300;
14 14
@@ -24,9 +24,7 @@ export default async function SetPage({ params, searchParams }: { params: Promis
24 24 const sp = await searchParams;
25 25 const set = await getSet(slug);
26 26 if (!set) notFound();
27 const sort = spEnum<ExploreSort>(sp, 'sort', ['riv', 'change30d', 'sales', 'newest', 'latest_sale'], 'riv');
28 const page = spInt(sp, 'page');
29 const [stats, res] = await Promise.all([getSetStats(slug), exploreAssets({ set: slug, sort, page, pageSize: 60 })]);
27 + const stats = await getSetStats(slug);
30 28 return (
31 29 <div>
32 30 <PageHeader
@@ -50,10 +48,11 @@ export default async function SetPage({ params, searchParams }: { params: Promis
50 48 ))}
51 49 </section>
52 50 {stats.sumRivUsd != null && stats.priced === stats.assets && stats.assets > 0 ? <p className="mt-1 text-[11px] text-subtle">Sum of RIV across all {stats.assets} priced assets: {fmtMoney(stats.sumRivUsd)} (a complete-set value indication, not a market cap).</p> : null}
53 <Card className="mt-4 overflow-hidden">
54 <AssetList items={res.items} rank startRank={(page - 1) * 60 + 1} columns={['riv', 'change30d', 'change1y', 'latestSale', 'sales', 'listings', 'rarity']} emptyTitle="No assets catalogued in this set yet" />
55 </Card>
56 <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath={`/set/${slug}`} params={{ sort }} />
51 + <div className="mt-4">
52 + <Suspense fallback={<Skeleton className="h-96" />}>
53 + <AssetBrowser scope={{ set: slug }} sp={{ ...sp, sort: sp.sort ?? 'number' }} basePath={`/set/${slug}`} title={`Assets in ${set.name}`} pageSize={60} />
54 + </Suspense>
55 + </div>
57 56 </div>
58 57 );
59 58 }
modified apps/web/src/components/asset/asset-header.tsx +20 −3
@@ -1,13 +1,13 @@
1 1 import Image from 'next/image';
2 2 import Link from 'next/link';
3 import type { AssetDetail, VariantRow } from '@/lib/queries/assets';
3 +import type { AssetDetail, VariantRow, GuidePrice } from '@/lib/queries/assets';
4 4 import { fmtMoney, fmtNum, fmtRelative, fmtDate, cn, confidenceLabel } from '@/lib/format';
5 5 import { catName, categoryCrumbs } from '@/lib/taxonomy';
6 6 import { Breadcrumbs } from '@/components/ui/page-header';
7 7 import { Badge, Delta, Stat } from '@/components/ui/primitives';
8 8 import { Evidence, ScoreMeter } from '@/components/ui/evidence';
9 9
10 export function AssetHeader({ asset, variants, activeVariant, tabHref }: { asset: AssetDetail; variants: VariantRow[]; activeVariant: VariantRow | null; tabHref: (v: string | null) => string }) {
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 }) {
11 11 const v = activeVariant;
12 12 const riv = v ? v.rivUsd : asset.rivUsd;
13 13 const low = v ? v.rivLowUsd : asset.rivLowUsd;
@@ -87,10 +87,27 @@ export function AssetHeader({ asset, variants, activeVariant, tabHref }: { asset
87 87 Range {fmtMoney(low)} – {fmtMoney(high)}
88 88 </div>
89 89 </>
90 + ) : guide ? (
91 + <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">
95 + {guide.sourceName} · {guide.priceKind.replace(/_/g, ' ')}
96 + {guide.currency !== 'USD' ? ` · ${fmtMoney(guide.price, guide.currency)}` : ''}
97 + {guide.variantLabel ? ` · ${guide.variantLabel}` : ''} · observed {fmtDate(guide.observationDate)}
98 + </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>
100 + </div>
101 + ) : asset.minAskUsd !== null ? (
102 + <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>
106 + </div>
90 107 ) : (
91 108 <div className="mt-1">
92 109 <div className="text-2xl font-semibold text-subtle">Data unavailable</div>
93 <p className="text-[11px] text-muted">Insufficient verified evidence for a valuation. Guide prices and listings, if any, are shown below without being presented as market value.</p>
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>
94 111 </div>
95 112 )}
96 113 <Evidence className="mt-2" confidence={conf} sampleSize={n} updatedAt={asset.updatedAt} />
modified apps/web/src/components/asset/asset-tabs.tsx +30 −2
@@ -1,7 +1,8 @@
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 } 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';
5 +import { AssetCardGrid } from '@/components/market/asset-list';
5 6 import { fmtDate, fmtMoney, fmtNum, fmtRelative, cn } from '@/lib/format';
6 7 import { gradeLabel, humanize, catName } from '@/lib/taxonomy';
7 8 import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge, Delta } from '@/components/ui/primitives';
@@ -16,7 +17,7 @@ export type AssetTab = (typeof ASSET_TABS)[number];
16 17
17 18 export async function OverviewTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {
18 19 const vid = variant?.id ?? '';
19 const [snaps, points, dist, sales, listings, obs, valuation] = await Promise.all([
20 + const [snaps, points, dist, sales, listings, obs, valuation, siblings, similar] = await Promise.all([
20 21 getAssetSnapshots(asset.id, vid, 730),
21 22 getAssetSalePoints(asset.id),
22 23 getPriceDistribution(asset.id, variant?.id ?? null),
@@ -24,7 +25,10 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari
24 25 getAssetListings(asset.id, { variantId: variant?.id ?? null, limit: 6 }),
25 26 getAssetObservations(asset.id, 12),
26 27 getLatestValuation(asset.id, variant?.id ?? null),
28 + getSetSiblings(asset, 8).then(attachGuidePrices),
29 + getSimilarAssets(asset, 8).then(attachGuidePrices),
27 30 ]);
31 + const similarOnly = similar.filter((x) => !siblings.some((y) => y.id === x.id));
28 32 const scatter = variant ? points.filter((p) => p.variantId === variant.id) : points;
29 33 const rivSeries = snaps.filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! }));
30 34 const askSeries = snaps.filter((p) => p.minAskUsd != null).map((p) => ({ x: p.date, y: p.minAskUsd! }));
@@ -150,6 +154,30 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari
150 154 <p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed text-muted">{asset.description}</p>
151 155 </Card>
152 156 ) : null}
157 + {siblings.length ? (
158 + <section>
159 + <div className="mb-2 flex items-baseline justify-between">
160 + <h2 className="text-sm font-semibold">Other assets in {asset.setName ?? 'this set'}</h2>
161 + {asset.setSlug ? (
162 + <Link href={`/set/${asset.setSlug}`} className="text-xs text-muted hover:text-fg">
163 + Whole set →
164 + </Link>
165 + ) : null}
166 + </div>
167 + <AssetCardGrid items={siblings} metric="latestSale" />
168 + </section>
169 + ) : null}
170 + {similarOnly.length ? (
171 + <section>
172 + <div className="mb-2 flex items-baseline justify-between">
173 + <h2 className="text-sm font-semibold">Similar assets</h2>
174 + <Link href={`/search?q=${encodeURIComponent(asset.name)}`} className="text-xs text-muted hover:text-fg">
175 + Search “{asset.name}” →
176 + </Link>
177 + </div>
178 + <AssetCardGrid items={similarOnly} metric="latestSale" />
179 + </section>
180 + ) : null}
153 181 </div>
154 182 );
155 183 }
modified apps/web/src/components/home/sections.tsx +46 −7
@@ -29,7 +29,7 @@ function Row({ href, image, title, sub, right, rightSub }: { href: string; image
29 29
30 30 const EMPTY = 'Populates as the pipeline ingests data.';
31 31
32 export function AssetRail({ title, subtitle, href, items, metric, empty = EMPTY }: { title: string; subtitle?: string; href: string; items: AssetCardData[]; metric: 'change7d' | 'change1d' | 'change30d' | 'trending' | 'watchers' | 'riv' | 'opportunity' | 'new'; empty?: string }) {
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 }) {
33 33 return (
34 34 <Card>
35 35 <CardHeader title={title} subtitle={subtitle} action={<Link href={href} className="text-muted hover:text-fg">View all →</Link>} />
@@ -42,9 +42,10 @@ export function AssetRail({ title, subtitle, href, items, metric, empty = EMPTY
42 42 image={a.heroImageUrl}
43 43 title={a.title}
44 44 sub={`${catName(a.categorySlug)}${a.year ? ` · ${a.year}` : ''}`}
45 right={a.rivUsd === null ? <span className="text-subtle">—</span> : fmtMoney(a.rivUsd)}
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 46 rightSub={
47 metric === 'trending' ? <span className="text-muted">score {a.trendingScore?.toFixed(1) ?? '—'}</span>
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>
48 49 : metric === 'watchers' ? <span className="text-muted">{fmtNum(a.watchers)} watching</span>
49 50 : metric === 'new' ? <span className="text-muted">added {fmtRelative(a.updatedAt)}</span>
50 51 : metric === 'riv' ? <span className="text-muted">{a.rivSampleSize} sales</span>
@@ -61,7 +62,8 @@ export function AssetRail({ title, subtitle, href, items, metric, empty = EMPTY
61 62 );
62 63 }
63 64
64 export function SalesRail({ title, subtitle, href, items }: { title: string; subtitle?: string; href: string; items: SaleRow[] }) {
65 +export function SalesRail({ title, subtitle, href, items, hideWhenEmpty = false }: { title: string; subtitle?: string; href: string; items: SaleRow[]; hideWhenEmpty?: boolean }) {
66 + if (hideWhenEmpty && !items.length) return null;
65 67 return (
66 68 <Card>
67 69 <CardHeader title={title} subtitle={subtitle} action={<Link href={href} className="text-muted hover:text-fg">View all →</Link>} />
@@ -78,7 +80,8 @@ export function SalesRail({ title, subtitle, href, items }: { title: string; sub
78 80 );
79 81 }
80 82
81 export function RadarRail({ items }: { items: RadarRow[] }) {
83 +export function RadarRail({ items, hideWhenEmpty = false }: { items: RadarRow[]; hideWhenEmpty?: boolean }) {
84 + if (hideWhenEmpty && !items.length) return null;
82 85 return (
83 86 <Card>
84 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>} />
@@ -95,7 +98,8 @@ export function RadarRail({ items }: { items: RadarRow[] }) {
95 98 );
96 99 }
97 100
98 export function LotsRail({ items }: { items: LotRow[] }) {
101 +export function LotsRail({ items, hideWhenEmpty = false }: { items: LotRow[]; hideWhenEmpty?: boolean }) {
102 + if (hideWhenEmpty && !items.length) return null;
99 103 return (
100 104 <Card>
101 105 <CardHeader title="Auctions Ending Soon" subtitle="Next 72 hours" action={<Link href="/auctions" className="text-muted hover:text-fg">All auctions →</Link>} />
@@ -149,7 +153,42 @@ export function MarketInsights({ rows }: { rows: MarketRow[] }) {
149 153 );
150 154 }
151 155
152 export function LatestTransactions({ items }: { items: LatestSale[] }) {
156 +export interface LatestObservation {
157 + id: string;
158 + assetSlug: string;
159 + assetTitle: string;
160 + categorySlug: string;
161 + heroImageUrl: string | null;
162 + priceUsd: number;
163 + priceKind: string;
164 + observationDate: string;
165 + sourceName: string;
166 +}
167 +
168 +export function LatestTransactions({ items, observations = [] }: { items: LatestSale[]; observations?: LatestObservation[] }) {
169 + if (!items.length && observations.length) {
170 + return (
171 + <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)}
182 + </span>
183 + </span>
184 + <span className="num text-[13px] font-semibold text-muted">{fmtMoney(o.priceUsd)}</span>
185 + </Link>
186 + </li>
187 + ))}
188 + </ul>
189 + </Card>
190 + );
191 + }
153 192 return (
154 193 <Card>
155 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>} />
added apps/web/src/components/market/asset-browser.tsx +141 −0
@@ -0,0 +1,141 @@
1 +import Link from 'next/link';
2 +import { Search } from 'lucide-react';
3 +import type { CategoryNode } from '@rareindex/taxonomy';
4 +import { AssetList } from '@/components/market/asset-list';
5 +import { Pagination } from '@/components/ui/pagination';
6 +import { Card, CardHeader } from '@/components/ui/primitives';
7 +import { attachGuidePrices, exploreAssets, getScopeCounts, categoryScope, type ExploreSort, type HasFilter } from '@/lib/queries/assets';
8 +import { fmtNum, cn } from '@/lib/format';
9 +import { sp1, spEnum, spInt, withParams, type SP } from '@/lib/search-params';
10 +
11 +/**
12 + * Paginated, filterable browser over canonical assets for a market, set or brand. Lists every
13 + * catalogued asset — with or without valuation — and states plainly how many are priced yet.
14 + */
15 +export interface BrowserScope {
16 + category?: string | null;
17 + set?: string | null;
18 + brand?: string | null;
19 +}
20 +
21 +const SORTS: ExploreSort[] = ['relevance', 'riv', 'sales', 'latest_sale', 'change30d', 'newest', 'number', 'name'];
22 +const SORT_LABEL: Record<string, string> = { relevance: 'Most data', riv: 'Valuation', sales: 'Sales', latest_sale: 'Last sale', change30d: '30D change', newest: 'Newest', number: 'Set · number', name: 'Name' };
23 +const HAS: Array<{ id: HasFilter | ''; label: string }> = [
24 + { id: '', label: 'All' },
25 + { id: 'sales', label: 'With sales' },
26 + { id: 'valuation', label: 'With valuation' },
27 + { id: 'listings', label: 'With listings' },
28 + { id: 'observations', label: 'With guide price' },
29 + { id: 'images', label: 'With image' },
30 +];
31 +export const BROWSER_KEYS = ['sort', 'has', 'sub', 'bq', 'view', 'page'];
32 +
33 +export async function AssetBrowser({ scope, sp, basePath, title = 'Assets in this market', subcategories = [], pageSize = 48, anchor = 'assets' }: { scope: BrowserScope; sp: SP; basePath: string; title?: string; subcategories?: CategoryNode[]; pageSize?: number; anchor?: string }) {
34 + const sort = spEnum<ExploreSort>(sp, 'sort', SORTS, 'relevance');
35 + const has = (sp1(sp, 'has') as HasFilter | undefined) ?? null;
36 + const sub = sp1(sp, 'sub') ?? null;
37 + const q = sp1(sp, 'bq') ?? null;
38 + const page = spInt(sp, 'page');
39 + const view = spEnum(sp, 'view', ['table', 'grid'] as const, 'table');
40 + const categorySlugs = sub && subcategories.some((c) => c.slug === sub) ? categoryScope(sub) : scope.category ? categoryScope(scope.category) : null;
41 + const filters = { scope: categorySlugs, set: scope.set ?? null, brand: scope.brand ?? null, has: HAS.some((h) => h.id === has) ? has : null, q, sort, page, pageSize };
42 + const [res, counts] = await Promise.all([exploreAssets(filters), getScopeCounts({ scope: scope.category ? categoryScope(scope.category) : null, set: scope.set ?? null, brand: scope.brand ?? null })]);
43 + await attachGuidePrices(res.items);
44 + const params: Record<string, string | undefined> = { sort: sort === 'relevance' ? undefined : sort, has: has ?? undefined, sub: sub ?? undefined, bq: q ?? undefined, view: view === 'table' ? undefined : view };
45 + const link = (patch: Record<string, string | null | undefined>) => `${withParams(basePath, { ...params, ...patch, page: null })}#${anchor}`;
46 + const inProgress = counts.assets > 0 && counts.priced < counts.assets;
47 + const summary = [
48 + `${fmtNum(counts.assets)} assets`,
49 + `${fmtNum(counts.priced)} with valuation`,
50 + counts.withSales ? `${fmtNum(counts.withSales)} with sales` : null,
51 + counts.withObservations ? `${fmtNum(counts.withObservations)} with guide prices` : null,
52 + counts.listings ? `${fmtNum(counts.listings)} live listings` : null,
53 + ].filter(Boolean).join(' · ');
54 + const columns = sort === 'change30d' ? (['riv', 'change7d', 'change30d', 'latestSale', 'sales'] as const) : (['riv', 'latestSale', 'change30d', 'sales', 'listings'] as const);
55 +
56 + return (
57 + <Card as="section" className="overflow-hidden" id={anchor}>
58 + <CardHeader
59 + title={title}
60 + subtitle={
61 + <span className="num">
62 + {summary}
63 + {inProgress ? <span className="ml-1 text-subtle">· valuations in progress</span> : null}
64 + </span>
65 + }
66 + action={
67 + <form action={basePath} method="get" role="search" className="relative hidden sm:block">
68 + {Object.entries(params).map(([k, v]) => (v && k !== 'bq' ? <input key={k} type="hidden" name={k} value={v} /> : null))}
69 + <Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-subtle" />
70 + <input name="bq" defaultValue={q ?? ''} placeholder="Filter by name, number, set…" className="h-7 w-52 rounded-md border border-border bg-sunken pl-7 pr-2 text-[12px] text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" aria-label="Filter assets" />
71 + </form>
72 + }
73 + />
74 + <div className="flex flex-col gap-2 border-b border-border px-4 py-2 text-[11px]">
75 + {subcategories.length ? (
76 + <div className="flex flex-wrap items-center gap-1">
77 + <span className="mr-1 text-subtle">Subcategory</span>
78 + <Chip href={link({ sub: null })} active={!sub}>
79 + All
80 + </Chip>
81 + {subcategories.map((c) => (
82 + <Chip key={c.slug} href={link({ sub: c.slug })} active={sub === c.slug}>
83 + {c.name}
84 + </Chip>
85 + ))}
86 + </div>
87 + ) : null}
88 + <div className="flex flex-wrap items-center gap-1">
89 + <span className="mr-1 text-subtle">Show</span>
90 + {HAS.map((h) => (
91 + <Chip key={h.id || 'all'} href={link({ has: h.id || null })} active={(has ?? '') === h.id}>
92 + {h.label}
93 + </Chip>
94 + ))}
95 + </div>
96 + <div className="flex flex-wrap items-center gap-1">
97 + <span className="mr-1 text-subtle">Sort</span>
98 + {SORTS.map((s) => (
99 + <Chip key={s} href={link({ sort: s === 'relevance' ? null : s })} active={sort === s}>
100 + {SORT_LABEL[s]}
101 + </Chip>
102 + ))}
103 + <span className="ml-auto inline-flex gap-1">
104 + <Chip href={link({ view: null })} active={view === 'table'}>
105 + Table
106 + </Chip>
107 + <Chip href={link({ view: 'grid' })} active={view === 'grid'}>
108 + Cards
109 + </Chip>
110 + </span>
111 + </div>
112 + {q ? (
113 + <p className="text-muted">
114 + Filtering by “{q}” ·{' '}
115 + <Link href={link({ bq: null })} className="underline hover:text-fg">
116 + clear
117 + </Link>
118 + </p>
119 + ) : null}
120 + </div>
121 + {view === 'grid' ? (
122 + <div className="p-3">
123 + <AssetList items={res.items} metric="latestSale" forceCards emptyTitle="No assets match these filters" emptyDescription="Clear a filter or pick another sort. Catalog records appear here as soon as connectors ingest them." />
124 + </div>
125 + ) : (
126 + <AssetList items={res.items} columns={[...columns]} rank startRank={(res.page - 1) * res.pageSize + 1} metric="latestSale" emptyTitle="No assets match these filters" emptyDescription="Clear a filter or pick another sort. Catalog records appear here as soon as connectors ingest them." />
127 + )}
128 + <div className="px-4 pb-3">
129 + <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath={basePath} params={params} />
130 + </div>
131 + </Card>
132 + );
133 +}
134 +
135 +function Chip({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) {
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')}>
138 + {children}
139 + </Link>
140 + );
141 +}
modified apps/web/src/components/market/asset-list.tsx +14 −5
@@ -32,7 +32,14 @@ function cell(a: AssetCardData, c: AssetColumn) {
32 32 switch (c) {
33 33 case 'riv':
34 34 return a.rivUsd === null ? (
35 <span className="text-subtle" title="Insufficient evidence for a valuation">—</span>
35 + a.guideUsd != null ? (
36 + <span className="inline-flex flex-col items-end leading-tight" title={`Price-guide observation (${a.guideSource ?? 'guide'} · ${a.guideKind ?? ''} · ${a.guideDate ?? ''}) — not a transaction and not a RareIndex valuation`}>
37 + <span className="text-muted">{fmtMoney(a.guideUsd)}</span>
38 + <span className="text-[10px] text-subtle">guide · {a.guideSource ?? a.guideKind ?? 'observed'}</span>
39 + </span>
40 + ) : (
41 + <span className="text-subtle" title="Insufficient evidence for a valuation">—</span>
42 + )
36 43 ) : (
37 44 <span className="inline-flex flex-col items-end leading-tight">
38 45 <span className="font-medium">{fmtMoney(a.rivUsd)}</span>
@@ -131,7 +138,7 @@ export function AssetTable({ items, columns = DEFAULT_COLUMNS, rank = false, sta
131 138 export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCardData; className?: string; metric?: 'change30d' | 'change7d' | 'change1d' | 'latestSale' | 'opportunity' | 'trending' }) {
132 139 const metricNode =
133 140 metric === 'latestSale' ? (
134 <span className="text-[11px] text-muted">Last sale {fmtMoney(a.latestSaleUsd)}</span>
141 + <span className="text-[11px] text-muted">{a.latestSaleUsd === null ? (a.number ? `#${a.number}` : a.setName ?? '') : `Last sale ${fmtMoney(a.latestSaleUsd)}`}</span>
135 142 ) : metric === 'opportunity' ? (
136 143 <span className="text-[11px] text-muted">
137 144 Opportunity <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[11px]" />
@@ -152,11 +159,12 @@ export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCard
152 159 </span>
153 160 <span className="truncate text-[11px] text-muted">
154 161 {catName(a.categorySlug)}
162 + {a.setName ? ` · ${a.setName}` : ''}
155 163 {a.year ? ` · ${a.year}` : ''}
156 164 </span>
157 165 <span className="mt-auto flex items-baseline justify-between gap-2 pt-1">
158 <span className="num text-sm font-semibold text-fg" title={a.rivUsd === null ? 'Insufficient evidence for a valuation' : `RareIndex Valuation · ${confidenceLabel(a.rivConfidence)} confidence · ${a.rivSampleSize} sales`}>
159 {a.rivUsd === null ? <span className="text-subtle">RIV —</span> : fmtMoney(a.rivUsd)}
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)}
160 168 </span>
161 169 {metricNode}
162 170 </span>
@@ -177,8 +185,9 @@ export function AssetCardGrid({ items, metric, className }: { items: AssetCardDa
177 185 }
178 186
179 187 /** Responsive list: cards on small screens, table from md up. */
180 export function AssetList(props: Parameters<typeof AssetTable>[0] & { metric?: Parameters<typeof AssetCard>[0]['metric'] }) {
188 +export function AssetList(props: Parameters<typeof AssetTable>[0] & { metric?: Parameters<typeof AssetCard>[0]['metric']; forceCards?: boolean }) {
181 189 if (!props.items.length) return <AssetTable {...props} />;
190 + if (props.forceCards) return <AssetCardGrid items={props.items} metric={props.metric} />;
182 191 return (
183 192 <>
184 193 <div className="md:hidden">
added apps/web/src/components/market/category-grid.tsx +31 −0
@@ -0,0 +1,31 @@
1 +import Link from 'next/link';
2 +import type { MarketRow } from '@/lib/queries/markets';
3 +import { fmtNum, cn } from '@/lib/format';
4 +import { Thumb } from '@/components/market/bits';
5 +import { Badge } from '@/components/ui/primitives';
6 +
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);
10 + const shown = limit ? sorted.slice(0, limit) : sorted;
11 + return (
12 + <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 + const empty = counts.assets === 0;
15 + 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}
24 + </span>
25 + </span>
26 + </Link>
27 + );
28 + })}
29 + </div>
30 + );
31 +}
modified apps/web/src/components/ui/primitives.tsx +2 −2
@@ -4,8 +4,8 @@ import { cn, deltaClass, fmtPct } from '@/lib/format';
4 4
5 5 /** Minimal, dense UI primitives. Keep them boring; the data is the design. */
6 6
7 export function Card({ children, className, as: Tag = 'section' }: { children: ReactNode; className?: string; as?: 'section' | 'div' | 'article' }) {
8 return <Tag className={cn('card', className)}>{children}</Tag>;
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>;
9 9 }
10 10
11 11 export function CardHeader({ title, subtitle, action, className }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode; className?: string }) {
modified apps/web/src/lib/queries/assets.ts +178 −6
@@ -2,7 +2,7 @@ import 'server-only';
2 2 import { cache } from 'react';
3 3 import type { SQL } from 'drizzle-orm';
4 4 import { descendants } from '@rareindex/taxonomy';
5 import { rows, one, sql, num, int, str, date, joinAnd } from './_util';
5 +import { rows, one, sql, num, int, str, date, joinAnd, textArray } from './_util';
6 6
7 7 export interface AssetCard {
8 8 id: string;
@@ -43,6 +43,11 @@ export interface AssetCard {
43 43 watchers: number;
44 44 observationsCount: number;
45 45 updatedAt: Date | null;
46 + /** latest price-guide observation (never a transaction); attached on demand by attachGuidePrices() */
47 + guideUsd?: number | null;
48 + guideKind?: string | null;
49 + guideDate?: string | null;
50 + guideSource?: string | null;
46 51 }
47 52
48 53 export const ASSET_CARD_SELECT = sql`a.id, a.slug, a.title, a.name, a.category_slug, a.family_slug, a.set_name, a.set_slug, a.number, a.year, a.variant, a.edition, a.brand, a.hero_image_url,
@@ -92,7 +97,8 @@ export function toAssetCard(x: Record<string, unknown>): AssetCard {
92 97 };
93 98 }
94 99
95 export type ExploreSort = 'riv' | 'change30d' | 'change7d' | 'sales' | 'liquidity' | 'rarity' | 'trending' | 'newest' | 'latest_sale' | 'opportunity';
100 +export type ExploreSort = 'relevance' | 'riv' | 'change30d' | 'change7d' | 'sales' | 'liquidity' | 'rarity' | 'trending' | 'newest' | 'latest_sale' | 'opportunity' | 'name' | 'number';
101 +export type HasFilter = 'sales' | 'valuation' | 'listings' | 'observations' | 'images';
96 102
97 103 export interface ExploreFilters {
98 104 category?: string | null;
@@ -108,6 +114,9 @@ export interface ExploreFilters {
108 114 brand?: string | null;
109 115 set?: string | null;
110 116 hasValuation?: boolean;
117 + has?: HasFilter | null;
118 + /** explicit list of category slugs (overrides `category` scope) */
119 + scope?: string[] | null;
111 120 q?: string | null;
112 121 sort?: ExploreSort;
113 122 page?: number;
@@ -115,6 +124,8 @@ export interface ExploreFilters {
115 124 }
116 125
117 126 const SORTS: Record<ExploreSort, SQL> = {
127 + // Data-rich records first, catalog-only records last — never hidden.
128 + relevance: sql`(s.riv_usd IS NOT NULL) DESC, coalesce(s.sales_count, 0) DESC, coalesce(s.observations_count, 0) DESC, (a.hero_image_url IS NOT NULL) DESC, a.created_at DESC`,
118 129 riv: sql`s.riv_usd DESC NULLS LAST`,
119 130 change30d: sql`s.change_30d DESC NULLS LAST`,
120 131 change7d: sql`s.change_7d DESC NULLS LAST`,
@@ -125,6 +136,16 @@ const SORTS: Record<ExploreSort, SQL> = {
125 136 newest: sql`a.created_at DESC`,
126 137 latest_sale: sql`s.latest_sale_at DESC NULLS LAST`,
127 138 opportunity: sql`s.value_opportunity DESC NULLS LAST`,
139 + name: sql`a.name ASC`,
140 + number: sql`a.set_name ASC NULLS LAST, (substring(a.number from '^[0-9]+'))::int ASC NULLS LAST, a.number ASC NULLS LAST`,
141 +};
142 +
143 +const HAS: Record<HasFilter, SQL> = {
144 + sales: sql`coalesce(s.sales_count, 0) > 0`,
145 + valuation: sql`s.riv_usd IS NOT NULL`,
146 + listings: sql`coalesce(s.active_listings, 0) > 0`,
147 + observations: sql`coalesce(s.observations_count, 0) > 0 OR EXISTS (SELECT 1 FROM price_observations o WHERE o.asset_id = a.id)`,
148 + images: sql`a.hero_image_url IS NOT NULL`,
128 149 };
129 150
130 151 export function categoryScope(slug: string): string[] {
@@ -135,7 +156,8 @@ export async function exploreAssets(f: ExploreFilters): Promise<{ items: AssetCa
135 156 const pageSize = Math.min(Math.max(f.pageSize ?? 48, 1), 200);
136 157 const page = Math.max(1, f.page ?? 1);
137 158 const where: SQL[] = [];
138 if (f.category) where.push(sql`a.category_slug IN ${categoryScope(f.category)}`);
159 + if (f.scope?.length) where.push(sql`a.category_slug IN ${f.scope}`);
160 + else if (f.category) where.push(sql`a.category_slug IN ${categoryScope(f.category)}`);
139 161 if (f.brand) where.push(sql`lower(a.brand) = lower(${f.brand})`);
140 162 if (f.set) where.push(sql`a.set_slug = ${f.set}`);
141 163 if (f.priceMin != null) where.push(sql`s.riv_usd >= ${f.priceMin}`);
@@ -146,12 +168,13 @@ export async function exploreAssets(f: ExploreFilters): Promise<{ items: AssetCa
146 168 if (f.yearFrom != null) where.push(sql`a.year >= ${f.yearFrom}`);
147 169 if (f.yearTo != null) where.push(sql`a.year <= ${f.yearTo}`);
148 170 if (f.hasValuation) where.push(sql`s.riv_usd IS NOT NULL`);
171 + if (f.has) where.push(sql`(${HAS[f.has]})`);
149 172 if (f.grader) where.push(sql`EXISTS (SELECT 1 FROM asset_variants v WHERE v.asset_id = a.id AND v.grader = ${f.grader} ${f.grade ? sql`AND v.grade = ${f.grade}` : sql``})`);
150 173 if (f.q && f.q.trim().length >= 2) {
151 174 const q = f.q.trim();
152 where.push(sql`(a.title ILIKE ${'%' + q + '%'} OR a.title % ${q})`);
175 + where.push(sql`(a.title ILIKE ${'%' + q + '%'} OR a.title % ${q} OR a.set_name ILIKE ${'%' + q + '%'} OR a.set_code ILIKE ${q} OR a.number ILIKE ${q} OR a.reference ILIKE ${q})`);
153 176 }
154 const order = SORTS[f.sort ?? 'riv'];
177 + const order = SORTS[f.sort ?? 'relevance'];
155 178 const r = await rows<Record<string, unknown>>(sql`
156 179 SELECT ${ASSET_CARD_SELECT}, count(*) OVER() AS total
157 180 FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id
@@ -163,7 +186,8 @@ export async function exploreAssets(f: ExploreFilters): Promise<{ items: AssetCa
163 186 }
164 187
165 188 /** Ranked lists for home/markets widgets. `scope` restricts to category slugs. */
166 export async function rankedAssets(kind: 'trending' | 'gainers' | 'losers' | 'volume' | 'watched' | 'newest' | 'expensive' | 'opportunity' | 'liquid', opts: { scope?: string[]; limit?: number; window?: '1d' | '7d' | '30d' } = {}): Promise<AssetCard[]> {
189 +export type RankedKind = 'trending' | 'gainers' | 'losers' | 'volume' | 'watched' | 'newest' | 'expensive' | 'opportunity' | 'liquid' | 'newest_priced' | 'documented' | 'with_sales';
190 +export async function rankedAssets(kind: RankedKind, opts: { scope?: string[]; limit?: number; window?: '1d' | '7d' | '30d' } = {}): Promise<AssetCard[]> {
167 191 const limit = opts.limit ?? 10;
168 192 const scope = opts.scope?.length ? sql`AND a.category_slug IN ${opts.scope}` : sql``;
169 193 const chg = opts.window === '1d' ? sql`s.change_1d` : opts.window === '30d' ? sql`s.change_30d` : sql`s.change_7d`;
@@ -177,6 +201,10 @@ export async function rankedAssets(kind: 'trending' | 'gainers' | 'losers' | 'vo
177 201 expensive: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.riv_usd DESC` },
178 202 opportunity: { where: sql`s.value_opportunity IS NOT NULL AND s.value_opportunity > 0`, order: sql`s.value_opportunity DESC` },
179 203 liquid: { where: sql`s.liquidity_score IS NOT NULL`, order: sql`s.liquidity_score DESC` },
204 + // Fallback rails used while valuations are still being computed (§192: honest, data-backed)
205 + newest_priced: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.updated_at DESC` },
206 + documented: { where: sql`a.hero_image_url IS NOT NULL AND (coalesce(s.observations_count, 0) > 0 OR coalesce(s.sales_count, 0) > 0)`, order: sql`coalesce(s.sales_count, 0) DESC, coalesce(s.observations_count, 0) DESC, s.updated_at DESC` },
207 + with_sales: { where: sql`coalesce(s.sales_count, 0) > 0`, order: sql`s.latest_sale_at DESC` },
180 208 };
181 209 const s = spec[kind];
182 210 const r = await rows<Record<string, unknown>>(sql`
@@ -644,3 +672,147 @@ export const getPriceDistribution = cache(async (assetId: string, variantId: str
644 672 if (!x || int(x.n) === 0) return null;
645 673 return { n: int(x.n), min: Number(x.min), p25: Number(x.p25), median: Number(x.median), p75: Number(x.p75), max: Number(x.max), trimmedMean: Number(x.trimmed ?? x.median) };
646 674 });
675 +
676 +/** Latest price-guide observation per asset (batch). Attached to cards so catalog-only assets still show a labelled guide price. */
677 +export async function attachGuidePrices<T extends AssetCard>(cards: T[]): Promise<T[]> {
678 + const ids = cards.filter((c) => c.rivUsd === null).map((c) => c.id);
679 + if (!ids.length) return cards;
680 + const r = await rows<Record<string, unknown>>(sql`
681 + SELECT DISTINCT ON (o.asset_id) o.asset_id, o.price_usd, o.price_kind, o.observation_date, src.name AS source_name
682 + FROM price_observations o JOIN sources src ON src.id = o.source_id
683 + WHERE o.asset_id IN ${ids}
684 + ORDER BY o.asset_id, o.observation_date DESC, (o.price_kind = 'market') DESC, (o.currency = 'USD') DESC
685 + `);
686 + const map = new Map(r.map((x) => [String(x.asset_id), x]));
687 + for (const c of cards) {
688 + const g = map.get(c.id);
689 + if (g) {
690 + c.guideUsd = num(g.price_usd);
691 + c.guideKind = str(g.price_kind);
692 + c.guideDate = str(g.observation_date);
693 + c.guideSource = str(g.source_name);
694 + }
695 + }
696 + return cards;
697 +}
698 +
699 +export interface GuidePrice {
700 + priceUsd: number;
701 + price: number;
702 + currency: string;
703 + priceKind: string;
704 + observationDate: string;
705 + sourceName: string;
706 + sourceUrl: string;
707 + variantId: string | null;
708 + variantLabel: string | null;
709 +}
710 +
711 +/** Best current guide price for the header: newest observation, preferring 'market' in USD. */
712 +export const getLatestGuidePrice = cache(async (assetId: string, variantId: string | null = null): Promise<GuidePrice | null> => {
713 + const x = await one<Record<string, unknown>>(sql`
714 + SELECT o.price_usd, o.price, o.currency, o.price_kind, o.observation_date, src.name AS source_name, o.source_url, o.variant_id, v.label AS variant_label
715 + FROM price_observations o JOIN sources src ON src.id = o.source_id LEFT JOIN asset_variants v ON v.id = o.variant_id
716 + WHERE o.asset_id = ${assetId} ${variantId ? sql`AND o.variant_id = ${variantId}` : sql``}
717 + ORDER BY o.observation_date DESC, (o.price_kind = 'market') DESC, (o.currency = 'USD') DESC LIMIT 1
718 + `);
719 + if (!x) return null;
720 + return { priceUsd: Number(x.price_usd), price: Number(x.price), currency: String(x.currency), priceKind: String(x.price_kind), observationDate: String(x.observation_date), sourceName: String(x.source_name), sourceUrl: String(x.source_url), variantId: str(x.variant_id), variantLabel: str(x.variant_label) };
721 +});
722 +
723 +/** Other assets in the same set, ordered by collector number (never a dead end on catalog-only pages). */
724 +export const getSetSiblings = cache(async (asset: { id: string; setSlug: string | null }, limit = 12): Promise<AssetCard[]> => {
725 + if (!asset.setSlug) return [];
726 + const r = await rows<Record<string, unknown>>(sql`
727 + SELECT ${ASSET_CARD_SELECT} FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id
728 + WHERE a.set_slug = ${asset.setSlug} AND a.id <> ${asset.id}
729 + ORDER BY (s.riv_usd IS NOT NULL) DESC, coalesce(s.sales_count, 0) DESC, (substring(a.number from '^[0-9]+'))::int ASC NULLS LAST, a.number ASC NULLS LAST LIMIT ${limit}
730 + `);
731 + return r.map(toAssetCard);
732 +});
733 +
734 +/** Similar assets by title trigram within the category (works for any catalog record). */
735 +export const getSimilarAssets = cache(async (asset: { id: string; name: string; categorySlug: string }, limit = 8): Promise<AssetCard[]> => {
736 + const r = await rows<Record<string, unknown>>(sql`
737 + SELECT ${ASSET_CARD_SELECT}, similarity(a.title, ${asset.name}) AS sim FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id
738 + WHERE a.id <> ${asset.id} AND a.category_slug = ${asset.categorySlug} AND a.title % ${asset.name}
739 + ORDER BY sim DESC, coalesce(s.sales_count, 0) DESC LIMIT ${limit}
740 + `);
741 + return r.map(toAssetCard);
742 +});
743 +
744 +/** Sets/releases derived from the assets table itself (correct even when the `sets` table is sparse). */
745 +export interface SetGroup {
746 + slug: string;
747 + name: string;
748 + code: string | null;
749 + year: number | null;
750 + assets: number;
751 + priced: number;
752 + sales: number;
753 + withImage: number;
754 + thumb: string | null;
755 +}
756 +export const getSetGroups = cache(async (scope: string[], opts: { limit?: number; offset?: number; sort?: 'assets' | 'sales' | 'year' | 'name' } = {}): Promise<{ items: SetGroup[]; total: number }> => {
757 + const order = opts.sort === 'sales' ? sql`sales DESC, assets DESC` : opts.sort === 'year' ? sql`year DESC NULLS LAST, assets DESC` : opts.sort === 'name' ? sql`name ASC` : sql`assets DESC, sales DESC`;
758 + const r = await rows<Record<string, unknown>>(sql`
759 + SELECT a.set_slug AS slug, coalesce(st.name, min(a.set_name)) AS name, coalesce(st.code, min(a.set_code)) AS code, coalesce(st.release_year, min(a.year)) AS year,
760 + count(*) AS assets, count(s.riv_usd) AS priced, coalesce(sum(s.sales_count), 0) AS sales, count(a.hero_image_url) AS with_image,
761 + (array_agg(a.hero_image_url ORDER BY (substring(a.number from '^[0-9]+'))::int NULLS LAST) FILTER (WHERE a.hero_image_url IS NOT NULL))[1] AS thumb,
762 + count(*) OVER() AS total
763 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id LEFT JOIN sets st ON st.slug = a.set_slug
764 + WHERE a.category_slug IN ${scope} AND a.set_slug IS NOT NULL
765 + GROUP BY a.set_slug, st.name, st.code, st.release_year
766 + ORDER BY ${order} LIMIT ${opts.limit ?? 60} OFFSET ${opts.offset ?? 0}
767 + `);
768 + return { items: r.map((x) => ({ slug: String(x.slug), name: String(x.name ?? x.slug), code: str(x.code), year: num(x.year), assets: int(x.assets), priced: int(x.priced), sales: int(x.sales), withImage: int(x.with_image), thumb: str(x.thumb) })), total: r.length ? int(r[0]!.total) : 0 };
769 +});
770 +
771 +/** Set facts derived from its assets when the `sets` table has no row (fallback for /set/[slug]). */
772 +export const getSetFromAssets = cache(async (slug: string) => {
773 + const x = await one<Record<string, unknown>>(sql`
774 + SELECT min(a.set_name) AS name, min(a.set_code) AS code, min(a.category_slug) AS category_slug, min(a.year) AS year, count(*) AS assets, min(a.language) AS language
775 + FROM assets a WHERE a.set_slug = ${slug}
776 + `);
777 + if (!x || int(x.assets) === 0) return null;
778 + return { slug, name: String(x.name ?? slug), code: str(x.code), categorySlug: String(x.category_slug), franchiseSlug: null, brandSlug: null, releaseYear: num(x.year), releaseDate: null, language: str(x.language), totalItems: null, identifiers: {} as Record<string, string>, metadata: {} as Record<string, unknown> };
779 +});
780 +
781 +/** Representative thumbnail per category (one cheap index lookup per slug). */
782 +export const getCategoryThumbnails = cache(async (slugs: string[]): Promise<Map<string, string>> => {
783 + if (!slugs.length) return new Map();
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
786 + FROM unnest(${textArray(slugs)}) AS c(slug)
787 + `);
788 + return new Map(r.filter((x) => x.url).map((x) => [String(x.slug), String(x.url)]));
789 +});
790 +
791 +/** Latest guide observations across the site (fallback for the home "latest transactions" block until sales exist). */
792 +export const getLatestObservationsFeed = cache(async (limit = 12): Promise<Array<{ id: string; assetSlug: string; assetTitle: string; categorySlug: string; heroImageUrl: string | null; priceUsd: number; priceKind: string; observationDate: string; sourceName: string }>> => {
793 + const r = await rows<Record<string, unknown>>(sql`
794 + WITH recent AS (
795 + SELECT a.id, a.slug, a.title, a.category_slug, a.hero_image_url FROM assets a JOIN asset_stats s ON s.asset_id = a.id
796 + WHERE coalesce(s.observations_count, 0) > 0 ORDER BY s.updated_at DESC LIMIT ${limit * 3}
797 + )
798 + SELECT o.id, r.slug AS asset_slug, r.title AS asset_title, r.category_slug, r.hero_image_url, o.price_usd, o.price_kind, o.observation_date, src.name AS source_name
799 + FROM recent r JOIN LATERAL (SELECT * FROM price_observations o WHERE o.asset_id = r.id ORDER BY o.observation_date DESC, (o.price_kind = 'market') DESC LIMIT 1) o ON true
800 + JOIN sources src ON src.id = o.source_id
801 + ORDER BY o.observation_date DESC LIMIT ${limit}
802 + `);
803 + return r.map((x) => ({ id: String(x.id), assetSlug: String(x.asset_slug), assetTitle: String(x.asset_title), categorySlug: String(x.category_slug), heroImageUrl: str(x.hero_image_url), priceUsd: Number(x.price_usd), priceKind: String(x.price_kind), observationDate: String(x.observation_date), sourceName: String(x.source_name) }));
804 +});
805 +
806 +/** Counts for a browse scope, straight from canonical tables (for "13,384 assets · 0 priced yet" lines). */
807 +export const getScopeCounts = cache(async (f: { scope?: string[] | null; set?: string | null; brand?: string | null }): Promise<{ assets: number; priced: number; withSales: number; withObservations: number; withImages: number; listings: number }> => {
808 + const where: SQL[] = [];
809 + if (f.scope?.length) where.push(sql`a.category_slug IN ${f.scope}`);
810 + if (f.set) where.push(sql`a.set_slug = ${f.set}`);
811 + if (f.brand) where.push(sql`lower(a.brand) = lower(${f.brand})`);
812 + const x = await one<Record<string, unknown>>(sql`
813 + SELECT count(*) AS assets, count(s.riv_usd) AS priced, count(*) FILTER (WHERE coalesce(s.sales_count, 0) > 0) AS with_sales,
814 + count(*) FILTER (WHERE coalesce(s.observations_count, 0) > 0) AS with_obs, count(a.hero_image_url) AS with_images, coalesce(sum(s.active_listings), 0) AS listings
815 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id WHERE ${joinAnd(where)}
816 + `);
817 + 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 +});
modified apps/web/src/lib/queries/markets.ts +12 −8
@@ -2,7 +2,7 @@ import 'server-only';
2 2 import { cache } from 'react';
3 3 import { CATEGORIES, FAMILIES, getCategory, type CategoryNode } from '@rareindex/taxonomy';
4 4 import { rows, one, sql, num, int, str, date } from './_util';
5 import { categoryScope } from './assets';
5 +import { categoryScope, getSetFromAssets } from './assets';
6 6
7 7 export interface CategorySnapshot {
8 8 categorySlug: string;
@@ -111,13 +111,17 @@ export const getCategoryRow = cache(async (slug: string): Promise<MarketRow | nu
111 111 return { node, snapshot: snaps.get(slug) ?? null, counts: aggregate(categoryScope(slug), counts, slug) };
112 112 });
113 113
114 export const getSetsInCategory = cache(async (slug: string, limit = 60): Promise<Array<{ slug: string; name: string; code: string | null; releaseYear: number | null; assets: number; priced: number; sales: number }>> => {
114 +export const getSetsInCategory = cache(async (slug: string, limit = 60): Promise<Array<{ slug: string; name: string; code: string | null; releaseYear: number | null; assets: number; priced: number; sales: number; thumb: string | null }>> => {
115 + // Grouped from the assets table (LEFT JOIN sets for metadata) so releases appear as soon as catalog items land.
115 116 const r = await rows<Record<string, unknown>>(sql`
116 SELECT st.slug, st.name, st.code, st.release_year, count(a.id) AS assets, count(s.riv_usd) AS priced, coalesce(sum(s.sales_count), 0) AS sales
117 FROM sets st LEFT JOIN assets a ON a.set_slug = st.slug LEFT JOIN asset_stats s ON s.asset_id = a.id
118 WHERE st.category_slug IN ${categoryScope(slug)} GROUP BY st.slug, st.name, st.code, st.release_year ORDER BY sales DESC, assets DESC, st.release_year DESC NULLS LAST LIMIT ${limit}
117 + SELECT a.set_slug AS slug, coalesce(st.name, min(a.set_name)) AS name, coalesce(st.code, min(a.set_code)) AS code, coalesce(st.release_year, min(a.year)) AS release_year,
118 + count(*) AS assets, count(s.riv_usd) AS priced, coalesce(sum(s.sales_count), 0) AS sales,
119 + (array_agg(a.hero_image_url) FILTER (WHERE a.hero_image_url IS NOT NULL))[1] AS thumb
120 + FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id LEFT JOIN sets st ON st.slug = a.set_slug
121 + WHERE a.category_slug IN ${categoryScope(slug)} AND a.set_slug IS NOT NULL
122 + GROUP BY a.set_slug, st.name, st.code, st.release_year ORDER BY sales DESC, assets DESC, release_year DESC NULLS LAST LIMIT ${limit}
119 123 `);
120 return r.map((x) => ({ slug: String(x.slug), name: String(x.name), code: str(x.code), releaseYear: num(x.release_year), assets: int(x.assets), priced: int(x.priced), sales: int(x.sales) }));
124 + return r.map((x) => ({ slug: String(x.slug), name: String(x.name ?? x.slug), code: str(x.code), releaseYear: num(x.release_year), assets: int(x.assets), priced: int(x.priced), sales: int(x.sales), thumb: str(x.thumb) }));
121 125 });
122 126
123 127 export const getBrandsInCategory = cache(async (slug: string, limit = 40): Promise<Array<{ brand: string; assets: number; priced: number; sales: number; medianRivUsd: number | null }>> => {
@@ -131,7 +135,7 @@ export const getBrandsInCategory = cache(async (slug: string, limit = 40): Promi
131 135
132 136 export const getSet = cache(async (slug: string) => {
133 137 const x = await one<Record<string, unknown>>(sql`SELECT * FROM sets WHERE slug = ${slug}`);
134 if (!x) return null;
138 + if (!x) return getSetFromAssets(slug);
135 139 return { slug: String(x.slug), name: String(x.name), code: str(x.code), categorySlug: String(x.category_slug), franchiseSlug: str(x.franchise_slug), brandSlug: str(x.brand_slug), releaseYear: num(x.release_year), releaseDate: str(x.release_date), language: str(x.language), totalItems: num(x.total_items), identifiers: (x.identifiers as Record<string, string>) ?? {}, metadata: (x.metadata as Record<string, unknown>) ?? {} };
136 140 });
137 141
@@ -153,7 +157,7 @@ export const getBrandStats = cache(async (brand: string) => {
153 157 });
154 158
155 159 export const getSetSlugsForSitemap = cache(async (): Promise<string[]> => {
156 const r = await rows<Record<string, unknown>>(sql`SELECT slug FROM sets ORDER BY slug LIMIT 20000`);
160 + const r = await rows<Record<string, unknown>>(sql`SELECT slug FROM sets UNION SELECT DISTINCT set_slug FROM assets WHERE set_slug IS NOT NULL ORDER BY 1 LIMIT 20000`);
157 161 return r.map((x) => String(x.slug));
158 162 });
159 163
added packages/search/src/code.test.ts +15 −0
@@ -0,0 +1,15 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { codeLike } from './index.js';
3 +
4 +describe('codeLike', () => {
5 + it('recognises collector numbers, references and set codes', () => {
6 + expect(codeLike('LOB-001')).toBe('LOB-001');
7 + expect(codeLike('116500LN')).toBe('116500LN');
8 + expect(codeLike('4/102')).toBe('4/102');
9 + expect(codeLike('#4')).toBe('4');
10 + expect(codeLike('2XM')).toBe('2XM');
11 + expect(codeLike('LOB')).toBe('LOB');
12 + expect(codeLike('base set')).toBeNull();
13 + expect(codeLike('charizard')).toBeNull();
14 + });
15 +});
modified packages/search/src/index.ts +60 −6
@@ -69,8 +69,15 @@ export async function searchAssets(query: string, opts: SearchOptions = {}): Pro
69 69 const priceMax = opts.priceMaxUsd ?? (parsed.currency === 'USD' || parsed.currency === null ? parsed.priceMax : null);
70 70
71 71 const conditions = [sql`true`];
72 + // Sets and codes: resolve matching releases first (small table, indexed join on assets.set_slug) so
73 + // "base set", "neo genesis", "LOB" or "2XM" find every card of the release, not only titles.
74 + const setSlugs = likeText.length >= 2 ? await matchingSetSlugs(db, likeText) : [];
75 + const codeToken = codeLike(likeText);
72 76 if (tsq && likeText.length >= 2) {
73 conditions.push(sql`(a.search @@ to_tsquery('simple', ${tsq}) OR a.title % ${likeText} OR a.title ILIKE ${'%' + likeText + '%'})`);
77 + const parts = [sql`a.search @@ to_tsquery('simple', ${tsq})`, sql`a.title % ${likeText}`, sql`a.title ILIKE ${'%' + likeText + '%'}`];
78 + if (setSlugs.length) parts.push(sql`a.set_slug IN ${setSlugs}`);
79 + if (codeToken) parts.push(sql`(a.number ILIKE ${codeToken} OR a.reference ILIKE ${codeToken} OR a.set_code ILIKE ${codeToken} OR a.identifiers @> ${JSON.stringify({ style_code: likeText })}::jsonb OR a.identifiers @> ${JSON.stringify({ reference: likeText })}::jsonb OR a.identifiers @> ${JSON.stringify({ lego_set_number: likeText })}::jsonb)`);
80 + conditions.push(sql`(${sql.join(parts, sql` OR `)})`);
74 81 }
75 82 if (catSlugs?.length) conditions.push(sql`a.category_slug IN ${catSlugs}`);
76 83 if (opts.yearFrom) conditions.push(sql`a.year >= ${opts.yearFrom}`);
@@ -83,7 +90,7 @@ export async function searchAssets(query: string, opts: SearchOptions = {}): Pro
83 90 const where = sql.join(conditions, sql` AND `);
84 91
85 92 const rank = tsq
86 ? sql`(coalesce(ts_rank_cd(a.search, to_tsquery('simple', ${tsq})), 0) * 4 + coalesce(similarity(a.title, ${likeText}), 0) * 3 + ln(1 + coalesce(s.sales_count, 0)) * 0.35 + ln(1 + coalesce(s.observations_count, 0)) * 0.1 + ln(1 + coalesce(s.watchers, 0)) * 0.2 + (CASE WHEN a.year = ${parsed.year ?? -1} THEN 0.8 ELSE 0 END) + (CASE WHEN s.riv_usd IS NOT NULL THEN 0.3 ELSE 0 END))`
93 + ? sql`(coalesce(ts_rank_cd(a.search, to_tsquery('simple', ${tsq})), 0) * 4 + coalesce(similarity(a.title, ${likeText}), 0) * 3 + ln(1 + coalesce(s.sales_count, 0)) * 0.35 + ln(1 + coalesce(s.observations_count, 0)) * 0.1 + ln(1 + coalesce(s.watchers, 0)) * 0.2 + (CASE WHEN a.year = ${parsed.year ?? -1} THEN 0.8 ELSE 0 END) + (CASE WHEN s.riv_usd IS NOT NULL THEN 0.3 ELSE 0 END) + (CASE WHEN a.hero_image_url IS NOT NULL THEN 0.15 ELSE 0 END) + (CASE WHEN ${codeToken ?? ''} <> '' AND (a.number ILIKE ${codeToken ?? ''} OR a.reference ILIKE ${codeToken ?? ''}) THEN 3 ELSE 0 END))`
87 94 : sql`(ln(1 + coalesce(s.sales_count, 0)) * 0.35 + ln(1 + coalesce(s.watchers, 0)) * 0.2 + (CASE WHEN s.riv_usd IS NOT NULL THEN 0.3 ELSE 0 END))`;
88 95
89 96 const rows = (await db.execute(sql`
@@ -153,17 +160,64 @@ export async function suggest(prefix: string, limit = 8): Promise<Suggestion[]>
153 160 }
154 161 }
155 162 const tsq = toTsQuery(q);
163 + const code = codeLike(q);
156 164 const rows = (await db.execute(sql`
157 165 SELECT a.slug, a.title, a.category_slug, s.riv_usd
158 166 FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id
159 WHERE a.title ILIKE ${q + '%'} OR a.title ILIKE ${'% ' + q + '%'} ${tsq ? sql`OR a.search @@ to_tsquery('simple', ${tsq})` : sql``}
160 ORDER BY coalesce(s.sales_count, 0) DESC, similarity(a.title, ${q}) DESC
167 + WHERE a.title ILIKE ${q + '%'} OR a.title ILIKE ${'% ' + q + '%'} ${tsq ? sql`OR a.search @@ to_tsquery('simple', ${tsq})` : sql``} ${code ? sql`OR a.number ILIKE ${code} OR a.reference ILIKE ${code}` : sql``}
168 + ORDER BY (a.hero_image_url IS NOT NULL) DESC, coalesce(s.sales_count, 0) DESC, similarity(a.title, ${q}) DESC
161 169 LIMIT ${limit}
162 170 `)) as unknown as Array<{ slug: string; title: string; category_slug: string; riv_usd: number | null }>;
163 171 for (const r of rows) {
164 172 out.push({ type: 'asset', label: r.title, sublabel: getCategory(r.category_slug)?.name ?? r.category_slug, href: `/asset/${r.slug}` });
165 173 }
166 const sets = (await db.execute(sql`SELECT slug, name, category_slug FROM sets WHERE name ILIKE ${q + '%'} ORDER BY name LIMIT 3`)) as unknown as Array<{ slug: string; name: string; category_slug: string }>;
167 for (const s of sets) out.push({ type: 'set', label: s.name, sublabel: `Set · ${getCategory(s.category_slug)?.name ?? s.category_slug}`, href: `/set/${s.slug}` });
174 + const sets = (await db.execute(sql`SELECT slug, name, code, category_slug FROM sets WHERE name ILIKE ${q + '%'} OR name ILIKE ${'% ' + q + '%'} OR code ILIKE ${q} ORDER BY (code ILIKE ${q}) DESC, name LIMIT 3`)) as unknown as Array<{ slug: string; name: string; code: string | null; category_slug: string }>;
175 + for (const s of sets) out.push({ type: 'set', label: `${s.name}${s.code ? ` (${s.code})` : ''}`, sublabel: `Set · ${getCategory(s.category_slug)?.name ?? s.category_slug}`, href: `/set/${s.slug}` });
168 176 return out.slice(0, limit + 3);
169 177 }
178 +
179 +/** A token that looks like a collector number, reference or set code (e.g. "LOB-001", "116500LN", "4/102", "10179"). */
180 +export function codeLike(text: string): string | null {
181 + const t = text.trim();
182 + if (!t || t.length > 24 || /\s/.test(t)) return null;
183 + if (!/\d/.test(t)) return /^[A-Z]{2,6}$/i.test(t) ? t : null; // bare set codes like "LOB", "2XM" handled via sets; short caps allowed
184 + if (!/^[A-Za-z0-9./#-]+$/.test(t)) return null;
185 + return t.replace(/^#/, '');
186 +}
187 +
188 +/** Set slugs whose name or code matches the text (sets table is small and cached per query). */
189 +export async function matchingSetSlugs(db: ReturnType<typeof getDb>, text: string, limit = 25): Promise<string[]> {
190 + const t = text.trim();
191 + if (t.length < 2) return [];
192 + const rows = (await db.execute(sql`
193 + SELECT slug FROM sets WHERE code ILIKE ${t} OR name ILIKE ${t} OR name ILIKE ${t + '%'} OR name ILIKE ${'% ' + t + '%'} OR name ILIKE ${'% ' + t}
194 + ORDER BY (code ILIKE ${t}) DESC, (name ILIKE ${t}) DESC, name LIMIT ${limit}
195 + `)) as unknown as Array<{ slug: string }>;
196 + return rows.map((r) => r.slug);
197 +}
198 +
199 +export interface SetHit {
200 + slug: string;
201 + name: string;
202 + code: string | null;
203 + categorySlug: string;
204 + releaseYear: number | null;
205 + assets: number;
206 + thumb: string | null;
207 +}
208 +
209 +/** Sets matching a query, with asset counts (grouped results block). */
210 +export async function searchSets(query: string, limit = 8): Promise<SetHit[]> {
211 + const t = query.trim();
212 + if (t.length < 2) return [];
213 + const db = getDb();
214 + const rows = (await db.execute(sql`
215 + SELECT st.slug, st.name, st.code, st.category_slug, st.release_year,
216 + (SELECT count(*) FROM assets a WHERE a.set_slug = st.slug) AS assets,
217 + (SELECT a.hero_image_url FROM assets a WHERE a.set_slug = st.slug AND a.hero_image_url IS NOT NULL LIMIT 1) AS thumb
218 + FROM sets st
219 + WHERE st.code ILIKE ${t} OR st.name ILIKE ${'%' + t + '%'}
220 + ORDER BY (st.code ILIKE ${t}) DESC, (st.name ILIKE ${t}) DESC, assets DESC LIMIT ${limit}
221 + `)) as unknown as Array<Record<string, unknown>>;
222 + return rows.map((r) => ({ slug: String(r.slug), name: String(r.name), code: (r.code as string | null) ?? null, categorySlug: String(r.category_slug), releaseYear: r.release_year == null ? null : Number(r.release_year), assets: Number(r.assets ?? 0), thumb: (r.thumb as string | null) ?? null }));
223 +}
170 224