SPB Git

spb/rareindex

Public

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

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 import { Skeleton } from '@/components/ui/primitives'; 5 import { Skeleton } from '@/components/ui/primitives';
6 import { AssetHeader } from '@/components/asset/asset-header'; 6 import { AssetHeader } from '@/components/asset/asset-header';
7 import { ASSET_TABS, AnalysisTab, ComparablesTab, GradesTab, HistoryTab, ImagesTab, ListingsTab, OverviewTab, PopulationTab, SalesTab, SourcesTab, type AssetTab } from '@/components/asset/asset-tabs'; 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 import { catName, categoryPath } from '@/lib/taxonomy'; 9 import { catName, categoryPath } from '@/lib/taxonomy';
10 import { sp1, spEnum, spInt, type SP } from '@/lib/search-params'; 10 import { sp1, spEnum, spInt, type SP } from '@/lib/search-params';
11 import { fmtMoney } from '@/lib/format'; 11 import { fmtMoney } from '@/lib/format';
@@ -18,7 +18,8 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
18 const { slug } = await params; 18 const { slug } = await params;
19 const a = await getAssetBySlug(slug); 19 const a = await getAssetBySlug(slug);
20 if (!a) return { title: 'Asset not found' }; 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 const description = `${a.title}${price}. ${catName(a.categorySlug)} price history, verified sales, live listings, rarity and liquidity on RareIndex.`; 23 const description = `${a.title}${price}. ${catName(a.categorySlug)} price history, verified sales, live listings, rarity and liquidity on RareIndex.`;
23 return { 24 return {
24 title: a.title, 25 title: a.title,
@@ -51,7 +52,7 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
51 const variant = vParam ? variants.find((v) => v.id === vParam) ?? null : null; 52 const variant = vParam ? variants.find((v) => v.id === vParam) ?? null : null;
52 const page = spInt(sp, 'page'); 53 const page = spInt(sp, 'page');
53 const href = (t: AssetTab, v: string | null = variant?.id ?? null) => `/asset/${asset.slug}?tab=${t}${v ? `&v=${v}` : ''}`; 54 const 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 const crumbs = categoryPath(asset.categorySlug); 56 const crumbs = categoryPath(asset.categorySlug);
56 57
57 const jsonLd = [ 58 const jsonLd = [
@@ -89,7 +90,7 @@ export default async function AssetPage({ params, searchParams }: { params: Prom
89 return ( 90 return (
90 <div> 91 <div>
91 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> 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 <Tabs 94 <Tabs
94 className="mt-6 mb-4" 95 className="mt-6 mb-4"
95 active={tab} 96 active={tab}
modified apps/web/src/app/brand/[brand]/page.tsx +10 −12
@@ -2,14 +2,14 @@ import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3 import { notFound } from 'next/navigation'; 3 import { notFound } from 'next/navigation';
4 import { PageHeader } from '@/components/ui/page-header'; 4 import { PageHeader } from '@/components/ui/page-header';
5−import { Card, Stat } from '@/components/ui/primitives'; 5+import { Stat } from '@/components/ui/primitives';
6−import { AssetList } from '@/components/market/asset-list'; 6+import { AssetBrowser } from '@/components/market/asset-browser';
7−import { Pagination } from '@/components/ui/pagination'; 7+import { Suspense } from 'react';
8+import { Skeleton } from '@/components/ui/primitives';
8 import { getBrandStats } from '@/lib/queries/markets'; 9 import { getBrandStats } from '@/lib/queries/markets';
9−import { exploreAssets, type ExploreSort } from '@/lib/queries/assets';
10 import { catName } from '@/lib/taxonomy'; 10 import { catName } from '@/lib/taxonomy';
11 import { fmtMoney, fmtNum } from '@/lib/format'; 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 export const revalidate = 300; 14 export const revalidate = 300;
15 15
@@ -25,9 +25,6 @@ export default async function BrandPage({ params, searchParams }: { params: Prom
25 const name = decodeURIComponent(brand); 25 const name = decodeURIComponent(brand);
26 const stats = await getBrandStats(name); 26 const stats = await getBrandStats(name);
27 if (!stats) notFound(); 27 if (!stats) notFound();
28− const sort = spEnum<ExploreSort>(sp, 'sort', ['riv', 'change30d', 'sales', 'newest', 'latest_sale'], 'riv');
29− const page = spInt(sp, 'page');
30− const res = await exploreAssets({ brand: name, sort, page, pageSize: 60 });
31 return ( 28 return (
32 <div> 29 <div>
33 <PageHeader 30 <PageHeader
@@ -54,10 +51,11 @@ export default async function BrandPage({ params, searchParams }: { params: Prom
54 </div> 51 </div>
55 ))} 52 ))}
56 </section> 53 </section>
57− <Card className="mt-4 overflow-hidden"> 54+ <div className="mt-4">
58− <AssetList items={res.items} rank startRank={(page - 1) * 60 + 1} /> 55+ <Suspense fallback={<Skeleton className="h-96" />}>
59− </Card> 56+ <AssetBrowser scope={{ brand: name }} sp={sp} basePath={`/brand/${brand}`} title={`${stats.brand} assets`} pageSize={60} />
60− <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath={`/brand/${brand}`} params={{ sort }} /> 57+ </Suspense>
58+ </div>
61 </div> 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 import { PageHeader } from '@/components/ui/page-header'; 3 import { PageHeader } from '@/components/ui/page-header';
4 import { Badge } from '@/components/ui/primitives'; 4 import { Badge } from '@/components/ui/primitives';
5 import { getCategoryCounts } from '@/lib/queries/markets'; 5 import { getCategoryCounts } from '@/lib/queries/markets';
6+import { getCategoryThumbnails } from '@/lib/queries/assets';
7+import { Thumb } from '@/components/market/bits';
6 import { FAMILIES, getCategory } from '@rareindex/taxonomy'; 8 import { FAMILIES, getCategory } from '@rareindex/taxonomy';
7 import { fmtNum } from '@/lib/format'; 9 import { fmtNum } from '@/lib/format';
8 import { INDICES } from '@rareindex/taxonomy'; 10 import { INDICES } from '@rareindex/taxonomy';
@@ -12,6 +14,7 @@ export const revalidate = 300;
12 14
13 export default async function CategoriesPage() { 15 export default async function CategoriesPage() {
14 const counts = await getCategoryCounts(); 16 const counts = await getCategoryCounts();
17+ const thumbs = await getCategoryThumbnails(FAMILIES.flatMap((f) => [f.slug, ...f.children]));
15 const total = (slugs: string[]) => slugs.reduce((a, s) => a + (counts.get(s)?.assets ?? 0), 0); 18 const total = (slugs: string[]) => slugs.reduce((a, s) => a + (counts.get(s)?.assets ?? 0), 0);
16 const groups = [1, 2, 3].map((phase) => ({ phase, families: FAMILIES.filter((f) => f.phase === phase) })); 19 const groups = [1, 2, 3].map((phase) => ({ phase, families: FAMILIES.filter((f) => f.phase === phase) }));
17 return ( 20 return (
@@ -31,8 +34,9 @@ export default async function CategoriesPage() {
31 return ( 34 return (
32 <div key={f.slug} className="card flex flex-col p-3"> 35 <div key={f.slug} className="card flex flex-col p-3">
33 <div className="flex items-start justify-between gap-2"> 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"> 37+ <Link href={`/markets/${f.slug}`} className="flex min-w-0 items-center gap-2 text-[13px] font-semibold text-fg hover:underline">
35− {f.name} 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 </Link> 40 </Link>
37 {idx ? ( 41 {idx ? (
38 <Link href={`/rareindex/${idx.ticker}`} className="shrink-0"> 42 <Link href={`/rareindex/${idx.ticker}`} className="shrink-0">
@@ -59,7 +63,13 @@ export default async function CategoriesPage() {
59 ) : null} 63 ) : null}
60 <div className="mt-auto flex items-center justify-between pt-2 text-[11px] text-subtle"> 64 <div className="mt-auto flex items-center justify-between pt-2 text-[11px] text-subtle">
61 <span className="num">{assets ? `${fmtNum(assets)} assets` : 'no data yet'}</span> 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 </div> 73 </div>
64 </div> 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 import { Segmented } from '@/components/ui/tabs'; 6 import { Segmented } from '@/components/ui/tabs';
7 import { AssetCard, AssetList } from '@/components/market/asset-list'; 7 import { AssetCard, AssetList } from '@/components/market/asset-list';
8 import { Skeleton } from '@/components/ui/primitives'; 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 import { CATEGORIES, FAMILIES, GRADERS } from '@rareindex/taxonomy'; 11 import { CATEGORIES, FAMILIES, GRADERS } from '@rareindex/taxonomy';
11 import { pick, sp1, spEnum, spInt, spNum, withParams, type SP } from '@/lib/search-params'; 12 import { pick, sp1, spEnum, spInt, spNum, withParams, type SP } from '@/lib/search-params';
12 13
13 export const metadata: Metadata = { title: 'Explore collectibles', description: 'Browse tracked collectible assets across every category, filtered by grade, price, liquidity, rarity and momentum.' }; 14 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 SORTS: ExploreSort[] = ['relevance', 'riv', 'change7d', 'change30d', 'sales', 'liquidity', 'rarity', 'trending', 'newest', 'latest_sale', 'opportunity', 'name'];
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 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' }];
17−const KEYS = ['category', 'grader', 'grade', 'min', 'max', 'liq', 'rar', 'mom', 'from', 'to', 'brand', 'set', 'q', 'sort', 'view']; 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 export default async function ExplorePage({ searchParams }: { searchParams: Promise<SP> }) { 21 export default async function ExplorePage({ searchParams }: { searchParams: Promise<SP> }) {
20 const sp = await searchParams; 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 const view = spEnum(sp, 'view', ['table', 'grid'] as const, 'table'); 25 const view = spEnum(sp, 'view', ['table', 'grid'] as const, 'table');
23 const page = spInt(sp, 'page'); 26 const page = spInt(sp, 'page');
24 const filters = { 27 const filters = {
@@ -35,6 +38,7 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
35 brand: sp1(sp, 'brand') ?? null, 38 brand: sp1(sp, 'brand') ?? null,
36 set: sp1(sp, 'set') ?? null, 39 set: sp1(sp, 'set') ?? null,
37 q: sp1(sp, 'q') ?? null, 40 q: sp1(sp, 'q') ?? null,
41+ has: has || null,
38 sort, 42 sort,
39 page, 43 page,
40 pageSize: 48, 44 pageSize: 48,
@@ -43,7 +47,15 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
43 const params = pick(sp, KEYS); 47 const params = pick(sp, KEYS);
44 return ( 48 return (
45 <div> 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 <div className="mb-3 flex flex-wrap items-end justify-between gap-3"> 59 <div className="mb-3 flex flex-wrap items-end justify-between gap-3">
48 <Suspense> 60 <Suspense>
49 <FilterBar 61 <FilterBar
@@ -97,11 +109,16 @@ function SortSelect({ current, params }: { current: ExploreSort; params: Record<
97 } 109 }
98 110
99 async function Results({ filters, view, params }: { filters: Parameters<typeof exploreAssets>[0]; view: 'table' | 'grid'; params: Record<string, string | undefined> }) { 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 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); 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 return ( 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 {view === 'grid' ? ( 122 {view === 'grid' ? (
106 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> 123 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
107 {res.items.map((a) => ( 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 import { PageHeader } from '@/components/ui/page-header'; 5 import { PageHeader } from '@/components/ui/page-header';
6 import { Card, CardHeader, Delta, EmptyState, Skeleton, Stat, Badge, Unavailable } from '@/components/ui/primitives'; 6 import { Card, CardHeader, Delta, EmptyState, Skeleton, Stat, Badge, Unavailable } from '@/components/ui/primitives';
7 import { MarketTable } from '@/components/market/market-table'; 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 import { AssetTable } from '@/components/market/asset-list'; 11 import { AssetTable } from '@/components/market/asset-list';
9 import { SalesTable, ListingsTable, LotsTable } from '@/components/market/sales-table'; 12 import { SalesTable, ListingsTable, LotsTable } from '@/components/market/sales-table';
10 import { LineChart } from '@/components/charts/line-chart'; 13 import { LineChart } from '@/components/charts/line-chart';
@@ -30,12 +33,14 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
30 return { title: `${c.name} market`, description: `${c.name} collectibles market on RareIndex: index, sales, volume, top movers, most valuable assets, listings and news.` }; 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 const { slug } = await params; 37 const { slug } = await params;
38+ const sp = await searchParams;
35 const row = await getCategoryRow(slug); 39 const row = await getCategoryRow(slug);
36 if (!row) notFound(); 40 if (!row) notFound();
37 const { node, snapshot, counts } = row; 41 const { node, snapshot, counts } = row;
38 const index = node.index ? await getIndex(node.index) : null; 42 const index = node.index ? await getIndex(node.index) : null;
43+ const subcategories = CATEGORIES.filter((c) => c.parent === slug);
39 return ( 44 return (
40 <div> 45 <div>
41 <PageHeader 46 <PageHeader
@@ -75,6 +80,17 @@ export default async function MarketPage({ params }: { params: Promise<{ slug: s
75 ))} 80 ))}
76 </section> 81 </section>
77 {snapshot?.marketCapEstUsd != null ? <p className="mt-1 text-[11px] text-subtle">Market cap estimate = estimated population × representative RIV per asset, summed; indicative only (§124).</p> : null} 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 <Suspense fallback={<Skeleton className="mt-4 h-72" />}> 95 <Suspense fallback={<Skeleton className="mt-4 h-72" />}>
80 <IndexPanel slug={slug} /> 96 <IndexPanel slug={slug} />
@@ -244,17 +260,19 @@ async function Structure({ slug }: { slug: string }) {
244 ) : null} 260 ) : null}
245 <div className="grid gap-4 lg:grid-cols-2"> 261 <div className="grid gap-4 lg:grid-cols-2">
246 <Card> 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 {sets.length ? ( 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 {sets.map((s) => ( 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"> 267+ <li key={s.slug} className="flex items-center 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"> 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 {s.name} 270 {s.name}
271+ {s.code ? <span className="ml-1 text-subtle">{s.code}</span> : null}
254 {s.releaseYear ? <span className="ml-1 text-subtle">{s.releaseYear}</span> : null} 272 {s.releaseYear ? <span className="ml-1 text-subtle">{s.releaseYear}</span> : null}
255 </Link> 273 </Link>
256 <span className="num shrink-0 text-muted"> 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 </span> 276 </span>
259 </li> 277 </li>
260 ))} 278 ))}
modified apps/web/src/app/page.tsx +50 −13
@@ -3,6 +3,9 @@ import Link from 'next/link';
3 import { Hero } from '@/components/home/hero'; 3 import { Hero } from '@/components/home/hero';
4 import { IndexTape } from '@/components/market/index-strip'; 4 import { IndexTape } from '@/components/market/index-strip';
5 import { AssetRail, LatestTransactions, LotsRail, MarketInsights, RadarRail, SalesRail } from '@/components/home/sections'; 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 import { Skeleton } from '@/components/ui/primitives'; 9 import { Skeleton } from '@/components/ui/primitives';
7 import { getLatestSales, getSiteStats } from '@/lib/queries/site'; 10 import { getLatestSales, getSiteStats } from '@/lib/queries/site';
8 import { listIndices } from '@/lib/queries/indices'; 11 import { listIndices } from '@/lib/queries/indices';
@@ -95,7 +98,7 @@ async function FlagshipPanel() {
95 } 98 }
96 99
97 async function Discovery() { 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 rankedAssets('trending', { limit: 8 }), 102 rankedAssets('trending', { limit: 8 }),
100 rankedAssets('gainers', { limit: 8, window: '7d' }), 103 rankedAssets('gainers', { limit: 8, window: '7d' }),
101 rankedAssets('losers', { limit: 8, window: '7d' }), 104 rankedAssets('losers', { limit: 8, window: '7d' }),
@@ -105,27 +108,61 @@ async function Discovery() {
105 rankedAssets('watched', { limit: 8 }), 108 rankedAssets('watched', { limit: 8 }),
106 rankedAssets('newest', { limit: 8 }), 109 rankedAssets('newest', { limit: 8 }),
107 getMarketRows(), 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 return ( 131 return (
110− <section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3"> 132+ <>
111− <AssetRail title="Trending Now" subtitle="Price × volume × listing momentum" href="/trending" items={trending} metric="trending" /> 133+ <section className="mt-6">
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." /> 134+ <div className="mb-2 flex items-baseline justify-between">
113− <SalesRail title="Record Sales" subtitle="Highest verified transactions" href="/records" items={records} /> 135+ <h2 className="text-base font-semibold tracking-tight text-fg">Browse by category</h2>
114− <RadarRail items={radar} /> 136+ <span className="num text-[11px] text-muted">
115− <LotsRail items={lots} /> 137+ {fmtNum(families.length)} families with data ·{' '}
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." /> 138+ <Link href="/categories" className="hover:text-fg">
117− <AssetRail title="Newly Added" subtitle="Latest canonical assets" href="/explore?sort=newest" items={newest} metric="new" /> 139+ all {fmtNum(markets.length)} →
118− <MarketInsights rows={markets} /> 140+ </Link>
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." /> 141+ </span>
120− </section> 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 async function Latest() { 160 async function Latest() {
125 const items = await getLatestSales(12); 161 const items = await getLatestSales(12);
162+ const observations = items.length ? [] : await getLatestObservationsFeed(12);
126 return ( 163 return (
127 <section className="mt-6"> 164 <section className="mt-6">
128− <LatestTransactions items={items} /> 165+ <LatestTransactions items={items} observations={observations} />
129 </section> 166 </section>
130 ); 167 );
131 } 168 }
modified apps/web/src/app/search/page.tsx +30 −20
@@ -1,12 +1,14 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3 import { Suspense } from 'react'; 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 import { PageHeader } from '@/components/ui/page-header'; 7 import { PageHeader } from '@/components/ui/page-header';
6 import { Card, CardHeader, EmptyState, Badge, Skeleton } from '@/components/ui/primitives'; 8 import { Card, CardHeader, EmptyState, Badge, Skeleton } from '@/components/ui/primitives';
7 import { Pagination } from '@/components/ui/pagination'; 9 import { Pagination } from '@/components/ui/pagination';
8 import { AssetList } from '@/components/market/asset-list'; 10 import { AssetList } from '@/components/market/asset-list';
9−import { getAssetsBySlugs } from '@/lib/queries/assets'; 11+import { attachGuidePrices, getAssetsBySlugs } from '@/lib/queries/assets';
10 import { rows, sql } from '@/lib/queries/_util'; 12 import { rows, sql } from '@/lib/queries/_util';
11 import { CATEGORIES, INDICES, getGrader } from '@rareindex/taxonomy'; 13 import { CATEGORIES, INDICES, getGrader } from '@rareindex/taxonomy';
12 import { catName } from '@/lib/taxonomy'; 14 import { catName } from '@/lib/taxonomy';
@@ -44,13 +46,12 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi
44 46
45 async function Results({ q, category, page }: { q: string; category: string | null; page: number }) { 47 async function Results({ q, category, page }: { q: string; category: string | null; page: number }) {
46 const pageSize = 24; 48 const pageSize = 24;
47− const res = await searchAssets(q, { limit: pageSize, offset: (page - 1) * pageSize, categorySlug: category }); 49+ const [res, setHits] = await Promise.all([searchAssets(q, { limit: pageSize, offset: (page - 1) * pageSize, categorySlug: category }), searchSets(q, 8)]);
48− const items = await getAssetsBySlugs(res.hits.map((h) => h.slug)); 50+ const items = await attachGuidePrices(await getAssetsBySlugs(res.hits.map((h) => h.slug)));
49 const p = res.parsed; 51 const p = res.parsed;
50 const lower = q.toLowerCase(); 52 const lower = q.toLowerCase();
51 const matchedCats = CATEGORIES.filter((c) => c.name.toLowerCase().includes(lower) || p.categorySlugs.includes(c.slug)).slice(0, 6); 53 const matchedCats = CATEGORIES.filter((c) => c.name.toLowerCase().includes(lower) || p.categorySlugs.includes(c.slug)).slice(0, 6);
52 const matchedIdx = INDICES.filter((i) => i.ticker.toLowerCase().includes(lower) || i.name.toLowerCase().includes(lower)).slice(0, 3); 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 const brands = (await rows<{ brand: string; n: number }>(sql`SELECT brand, count(*) AS n FROM assets WHERE brand ILIKE ${'%' + q + '%'} GROUP BY brand ORDER BY n DESC LIMIT 6`)).map((b) => ({ ...b })); 55 const brands = (await rows<{ brand: string; n: number }>(sql`SELECT brand, count(*) AS n FROM assets WHERE brand ILIKE ${'%' + q + '%'} GROUP BY brand ORDER BY n DESC LIMIT 6`)).map((b) => ({ ...b }));
55 const sources = (await rows<{ id: string; name: string }>(sql`SELECT id, name FROM sources WHERE name ILIKE ${'%' + q + '%'} LIMIT 4`)).map((s) => ({ ...s })); 56 const sources = (await rows<{ id: string; name: string }>(sql`SELECT id, name FROM sources WHERE name ILIKE ${'%' + q + '%'} LIMIT 4`)).map((s) => ({ ...s }));
56 const chips = [ 57 const chips = [
@@ -77,8 +78,31 @@ async function Results({ q, category, page }: { q: string; category: string | nu
77 ))} 78 ))}
78 {p.text && p.text !== q ? <span>· text “{p.text}”</span> : null} 79 {p.text && p.text !== q ? <span>· text “{p.text}”</span> : null}
79 </div> 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 <Card className="overflow-hidden"> 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 </Card> 106 </Card>
83 <Pagination page={page} pageSize={pageSize} total={res.total} basePath="/search" params={{ q, category: category ?? undefined }} /> 107 <Pagination page={page} pageSize={pageSize} total={res.total} basePath="/search" params={{ q, category: category ?? undefined }} />
84 </div> 108 </div>
@@ -126,20 +150,6 @@ async function Results({ q, category, page }: { q: string; category: string | nu
126 </ul> 150 </ul>
127 </Card> 151 </Card>
128 ) : null} 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 {brands.length ? ( 153 {brands.length ? (
144 <Card> 154 <Card>
145 <CardHeader title="Brands" /> 155 <CardHeader title="Brands" />
modified apps/web/src/app/set/[slug]/page.tsx +11 −12
@@ -1,14 +1,14 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import { notFound } from 'next/navigation'; 2 import { notFound } from 'next/navigation';
3 import { PageHeader } from '@/components/ui/page-header'; 3 import { PageHeader } from '@/components/ui/page-header';
4−import { Card, Stat } from '@/components/ui/primitives'; 4+import { Stat } from '@/components/ui/primitives';
5−import { AssetList } from '@/components/market/asset-list'; 5+import { AssetBrowser } from '@/components/market/asset-browser';
6−import { Pagination } from '@/components/ui/pagination'; 6+import { Suspense } from 'react';
7+import { Skeleton } from '@/components/ui/primitives';
7 import { getSet, getSetStats } from '@/lib/queries/markets'; 8 import { getSet, getSetStats } from '@/lib/queries/markets';
8−import { exploreAssets, type ExploreSort } from '@/lib/queries/assets';
9 import { catName, categoryCrumbs } from '@/lib/taxonomy'; 9 import { catName, categoryCrumbs } from '@/lib/taxonomy';
10 import { fmtMoney, fmtNum, fmtRelative } from '@/lib/format'; 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 export const revalidate = 300; 13 export const revalidate = 300;
14 14
@@ -24,9 +24,7 @@ export default async function SetPage({ params, searchParams }: { params: Promis
24 const sp = await searchParams; 24 const sp = await searchParams;
25 const set = await getSet(slug); 25 const set = await getSet(slug);
26 if (!set) notFound(); 26 if (!set) notFound();
27− const sort = spEnum<ExploreSort>(sp, 'sort', ['riv', 'change30d', 'sales', 'newest', 'latest_sale'], 'riv'); 27+ const stats = await getSetStats(slug);
28− const page = spInt(sp, 'page');
29− const [stats, res] = await Promise.all([getSetStats(slug), exploreAssets({ set: slug, sort, page, pageSize: 60 })]);
30 return ( 28 return (
31 <div> 29 <div>
32 <PageHeader 30 <PageHeader
@@ -50,10 +48,11 @@ export default async function SetPage({ params, searchParams }: { params: Promis
50 ))} 48 ))}
51 </section> 49 </section>
52 {stats.sumRivUsd != null && stats.priced === stats.assets && stats.assets > 0 ? <p className="mt-1 text-[11px] text-subtle">Sum of RIV across all {stats.assets} priced assets: {fmtMoney(stats.sumRivUsd)} (a complete-set value indication, not a market cap).</p> : null} 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"> 51+ <div className="mt-4">
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" /> 52+ <Suspense fallback={<Skeleton className="h-96" />}>
55− </Card> 53+ <AssetBrowser scope={{ set: slug }} sp={{ ...sp, sort: sp.sort ?? 'number' }} basePath={`/set/${slug}`} title={`Assets in ${set.name}`} pageSize={60} />
56− <Pagination page={res.page} pageSize={res.pageSize} total={res.total} basePath={`/set/${slug}`} params={{ sort }} /> 54+ </Suspense>
55+ </div>
57 </div> 56 </div>
58 ); 57 );
59 } 58 }
modified apps/web/src/components/asset/asset-header.tsx +20 −3
@@ -1,13 +1,13 @@
1 import Image from 'next/image'; 1 import Image from 'next/image';
2 import Link from 'next/link'; 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 import { fmtMoney, fmtNum, fmtRelative, fmtDate, cn, confidenceLabel } from '@/lib/format'; 4 import { fmtMoney, fmtNum, fmtRelative, fmtDate, cn, confidenceLabel } from '@/lib/format';
5 import { catName, categoryCrumbs } from '@/lib/taxonomy'; 5 import { catName, categoryCrumbs } from '@/lib/taxonomy';
6 import { Breadcrumbs } from '@/components/ui/page-header'; 6 import { Breadcrumbs } from '@/components/ui/page-header';
7 import { Badge, Delta, Stat } from '@/components/ui/primitives'; 7 import { Badge, Delta, Stat } from '@/components/ui/primitives';
8 import { Evidence, ScoreMeter } from '@/components/ui/evidence'; 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 const v = activeVariant; 11 const v = activeVariant;
12 const riv = v ? v.rivUsd : asset.rivUsd; 12 const riv = v ? v.rivUsd : asset.rivUsd;
13 const low = v ? v.rivLowUsd : asset.rivLowUsd; 13 const low = v ? v.rivLowUsd : asset.rivLowUsd;
@@ -87,10 +87,27 @@ export function AssetHeader({ asset, variants, activeVariant, tabHref }: { asset
87 Range {fmtMoney(low)} – {fmtMoney(high)} 87 Range {fmtMoney(low)} – {fmtMoney(high)}
88 </div> 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 <div className="mt-1"> 108 <div className="mt-1">
92 <div className="text-2xl font-semibold text-subtle">Data unavailable</div> 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 </div> 111 </div>
95 )} 112 )}
96 <Evidence className="mt-2" confidence={conf} sampleSize={n} updatedAt={asset.updatedAt} /> 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 import Image from 'next/image'; 1 import Image from 'next/image';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3 import type { AssetDetail, VariantRow } from '@/lib/queries/assets'; 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 import { fmtDate, fmtMoney, fmtNum, fmtRelative, cn } from '@/lib/format'; 6 import { fmtDate, fmtMoney, fmtNum, fmtRelative, cn } from '@/lib/format';
6 import { gradeLabel, humanize, catName } from '@/lib/taxonomy'; 7 import { gradeLabel, humanize, catName } from '@/lib/taxonomy';
7 import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge, Delta } from '@/components/ui/primitives'; 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 export async function OverviewTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) { 18 export async function OverviewTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {
18 const vid = variant?.id ?? ''; 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 getAssetSnapshots(asset.id, vid, 730), 21 getAssetSnapshots(asset.id, vid, 730),
21 getAssetSalePoints(asset.id), 22 getAssetSalePoints(asset.id),
22 getPriceDistribution(asset.id, variant?.id ?? null), 23 getPriceDistribution(asset.id, variant?.id ?? null),
@@ -24,7 +25,10 @@ export async function OverviewTab({ asset, variant }: { asset: AssetDetail; vari
24 getAssetListings(asset.id, { variantId: variant?.id ?? null, limit: 6 }), 25 getAssetListings(asset.id, { variantId: variant?.id ?? null, limit: 6 }),
25 getAssetObservations(asset.id, 12), 26 getAssetObservations(asset.id, 12),
26 getLatestValuation(asset.id, variant?.id ?? null), 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 const scatter = variant ? points.filter((p) => p.variantId === variant.id) : points; 32 const scatter = variant ? points.filter((p) => p.variantId === variant.id) : points;
29 const rivSeries = snaps.filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! })); 33 const rivSeries = snaps.filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! }));
30 const askSeries = snaps.filter((p) => p.minAskUsd != null).map((p) => ({ x: p.date, y: p.minAskUsd! })); 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 <p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed text-muted">{asset.description}</p> 154 <p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed text-muted">{asset.description}</p>
151 </Card> 155 </Card>
152 ) : null} 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 </div> 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 const EMPTY = 'Populates as the pipeline ingests data.'; 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 return ( 33 return (
34 <Card> 34 <Card>
35 <CardHeader title={title} subtitle={subtitle} action={<Link href={href} className="text-muted hover:text-fg">View all →</Link>} /> 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 image={a.heroImageUrl} 42 image={a.heroImageUrl}
43 title={a.title} 43 title={a.title}
44 sub={`${catName(a.categorySlug)}${a.year ? ` · ${a.year}` : ''}`} 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 rightSub={ 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 : metric === 'watchers' ? <span className="text-muted">{fmtNum(a.watchers)} watching</span> 49 : metric === 'watchers' ? <span className="text-muted">{fmtNum(a.watchers)} watching</span>
49 : metric === 'new' ? <span className="text-muted">added {fmtRelative(a.updatedAt)}</span> 50 : metric === 'new' ? <span className="text-muted">added {fmtRelative(a.updatedAt)}</span>
50 : metric === 'riv' ? <span className="text-muted">{a.rivSampleSize} sales</span> 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 return ( 67 return (
66 <Card> 68 <Card>
67 <CardHeader title={title} subtitle={subtitle} action={<Link href={href} className="text-muted hover:text-fg">View all →</Link>} /> 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 return ( 85 return (
83 <Card> 86 <Card>
84 <CardHeader title="Rare Finds" subtitle="Rare Radar: unusual appearances and discrepancies" action={<Link href="/radar" className="text-muted hover:text-fg">Open radar →</Link>} /> 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 return ( 103 return (
100 <Card> 104 <Card>
101 <CardHeader title="Auctions Ending Soon" subtitle="Next 72 hours" action={<Link href="/auctions" className="text-muted hover:text-fg">All auctions →</Link>} /> 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 return ( 192 return (
154 <Card> 193 <Card>
155 <CardHeader title="Latest transactions" subtitle="Most recent verified sales across sources" action={<Link href="/sales" className="text-muted hover:text-fg">All sales →</Link>} /> 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 switch (c) { 32 switch (c) {
33 case 'riv': 33 case 'riv':
34 return a.rivUsd === null ? ( 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 <span className="inline-flex flex-col items-end leading-tight"> 44 <span className="inline-flex flex-col items-end leading-tight">
38 <span className="font-medium">{fmtMoney(a.rivUsd)}</span> 45 <span className="font-medium">{fmtMoney(a.rivUsd)}</span>
@@ -131,7 +138,7 @@ export function AssetTable({ items, columns = DEFAULT_COLUMNS, rank = false, sta
131 export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCardData; className?: string; metric?: 'change30d' | 'change7d' | 'change1d' | 'latestSale' | 'opportunity' | 'trending' }) { 138 export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCardData; className?: string; metric?: 'change30d' | 'change7d' | 'change1d' | 'latestSale' | 'opportunity' | 'trending' }) {
132 const metricNode = 139 const metricNode =
133 metric === 'latestSale' ? ( 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 ) : metric === 'opportunity' ? ( 142 ) : metric === 'opportunity' ? (
136 <span className="text-[11px] text-muted"> 143 <span className="text-[11px] text-muted">
137 Opportunity <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[11px]" /> 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 </span> 159 </span>
153 <span className="truncate text-[11px] text-muted"> 160 <span className="truncate text-[11px] text-muted">
154 {catName(a.categorySlug)} 161 {catName(a.categorySlug)}
162+ {a.setName ? ` · ${a.setName}` : ''}
155 {a.year ? ` · ${a.year}` : ''} 163 {a.year ? ` · ${a.year}` : ''}
156 </span> 164 </span>
157 <span className="mt-auto flex items-baseline justify-between gap-2 pt-1"> 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`}> 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`}>
159− {a.rivUsd === null ? <span className="text-subtle">RIV —</span> : fmtMoney(a.rivUsd)} 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 </span> 168 </span>
161 {metricNode} 169 {metricNode}
162 </span> 170 </span>
@@ -177,8 +185,9 @@ export function AssetCardGrid({ items, metric, className }: { items: AssetCardDa
177 } 185 }
178 186
179 /** Responsive list: cards on small screens, table from md up. */ 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 if (!props.items.length) return <AssetTable {...props} />; 189 if (!props.items.length) return <AssetTable {...props} />;
190+ if (props.forceCards) return <AssetCardGrid items={props.items} metric={props.metric} />;
182 return ( 191 return (
183 <> 192 <>
184 <div className="md:hidden"> 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 /** Minimal, dense UI primitives. Keep them boring; the data is the design. */ 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' }) { 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)}>{children}</Tag>; 8+ return <Tag className={cn('card', className)} id={id}>{children}</Tag>;
9 } 9 }
10 10
11 export function CardHeader({ title, subtitle, action, className }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode; className?: string }) { 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 import { cache } from 'react'; 2 import { cache } from 'react';
3 import type { SQL } from 'drizzle-orm'; 3 import type { SQL } from 'drizzle-orm';
4 import { descendants } from '@rareindex/taxonomy'; 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 export interface AssetCard { 7 export interface AssetCard {
8 id: string; 8 id: string;
@@ -43,6 +43,11 @@ export interface AssetCard {
43 watchers: number; 43 watchers: number;
44 observationsCount: number; 44 observationsCount: number;
45 updatedAt: Date | null; 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 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, 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 export interface ExploreFilters { 103 export interface ExploreFilters {
98 category?: string | null; 104 category?: string | null;
@@ -108,6 +114,9 @@ export interface ExploreFilters {
108 brand?: string | null; 114 brand?: string | null;
109 set?: string | null; 115 set?: string | null;
110 hasValuation?: boolean; 116 hasValuation?: boolean;
117+ has?: HasFilter | null;
118+ /** explicit list of category slugs (overrides `category` scope) */
119+ scope?: string[] | null;
111 q?: string | null; 120 q?: string | null;
112 sort?: ExploreSort; 121 sort?: ExploreSort;
113 page?: number; 122 page?: number;
@@ -115,6 +124,8 @@ export interface ExploreFilters {
115 } 124 }
116 125
117 const SORTS: Record<ExploreSort, SQL> = { 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 riv: sql`s.riv_usd DESC NULLS LAST`, 129 riv: sql`s.riv_usd DESC NULLS LAST`,
119 change30d: sql`s.change_30d DESC NULLS LAST`, 130 change30d: sql`s.change_30d DESC NULLS LAST`,
120 change7d: sql`s.change_7d DESC NULLS LAST`, 131 change7d: sql`s.change_7d DESC NULLS LAST`,
@@ -125,6 +136,16 @@ const SORTS: Record<ExploreSort, SQL> = {
125 newest: sql`a.created_at DESC`, 136 newest: sql`a.created_at DESC`,
126 latest_sale: sql`s.latest_sale_at DESC NULLS LAST`, 137 latest_sale: sql`s.latest_sale_at DESC NULLS LAST`,
127 opportunity: sql`s.value_opportunity DESC NULLS LAST`, 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 export function categoryScope(slug: string): string[] { 151 export function categoryScope(slug: string): string[] {
@@ -135,7 +156,8 @@ export async function exploreAssets(f: ExploreFilters): Promise<{ items: AssetCa
135 const pageSize = Math.min(Math.max(f.pageSize ?? 48, 1), 200); 156 const pageSize = Math.min(Math.max(f.pageSize ?? 48, 1), 200);
136 const page = Math.max(1, f.page ?? 1); 157 const page = Math.max(1, f.page ?? 1);
137 const where: SQL[] = []; 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 if (f.brand) where.push(sql`lower(a.brand) = lower(${f.brand})`); 161 if (f.brand) where.push(sql`lower(a.brand) = lower(${f.brand})`);
140 if (f.set) where.push(sql`a.set_slug = ${f.set}`); 162 if (f.set) where.push(sql`a.set_slug = ${f.set}`);
141 if (f.priceMin != null) where.push(sql`s.riv_usd >= ${f.priceMin}`); 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 if (f.yearFrom != null) where.push(sql`a.year >= ${f.yearFrom}`); 168 if (f.yearFrom != null) where.push(sql`a.year >= ${f.yearFrom}`);
147 if (f.yearTo != null) where.push(sql`a.year <= ${f.yearTo}`); 169 if (f.yearTo != null) where.push(sql`a.year <= ${f.yearTo}`);
148 if (f.hasValuation) where.push(sql`s.riv_usd IS NOT NULL`); 170 if (f.hasValuation) where.push(sql`s.riv_usd IS NOT NULL`);
171+ if (f.has) where.push(sql`(${HAS[f.has]})`);
149 if (f.grader) where.push(sql`EXISTS (SELECT 1 FROM asset_variants v WHERE v.asset_id = a.id AND v.grader = ${f.grader} ${f.grade ? sql`AND v.grade = ${f.grade}` : sql``})`); 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 if (f.q && f.q.trim().length >= 2) { 173 if (f.q && f.q.trim().length >= 2) {
151 const q = f.q.trim(); 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 const r = await rows<Record<string, unknown>>(sql` 178 const r = await rows<Record<string, unknown>>(sql`
156 SELECT ${ASSET_CARD_SELECT}, count(*) OVER() AS total 179 SELECT ${ASSET_CARD_SELECT}, count(*) OVER() AS total
157 FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id 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 /** Ranked lists for home/markets widgets. `scope` restricts to category slugs. */ 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 const limit = opts.limit ?? 10; 191 const limit = opts.limit ?? 10;
168 const scope = opts.scope?.length ? sql`AND a.category_slug IN ${opts.scope}` : sql``; 192 const scope = opts.scope?.length ? sql`AND a.category_slug IN ${opts.scope}` : sql``;
169 const chg = opts.window === '1d' ? sql`s.change_1d` : opts.window === '30d' ? sql`s.change_30d` : sql`s.change_7d`; 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 expensive: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.riv_usd DESC` }, 201 expensive: { where: sql`s.riv_usd IS NOT NULL`, order: sql`s.riv_usd DESC` },
178 opportunity: { where: sql`s.value_opportunity IS NOT NULL AND s.value_opportunity > 0`, order: sql`s.value_opportunity DESC` }, 202 opportunity: { where: sql`s.value_opportunity IS NOT NULL AND s.value_opportunity > 0`, order: sql`s.value_opportunity DESC` },
179 liquid: { where: sql`s.liquidity_score IS NOT NULL`, order: sql`s.liquidity_score DESC` }, 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 const s = spec[kind]; 209 const s = spec[kind];
182 const r = await rows<Record<string, unknown>>(sql` 210 const r = await rows<Record<string, unknown>>(sql`
@@ -644,3 +672,147 @@ export const getPriceDistribution = cache(async (assetId: string, variantId: str
644 if (!x || int(x.n) === 0) return null; 672 if (!x || int(x.n) === 0) return null;
645 return { n: int(x.n), min: Number(x.min), p25: Number(x.p25), median: Number(x.median), p75: Number(x.p75), max: Number(x.max), trimmedMean: Number(x.trimmed ?? x.median) }; 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 import { cache } from 'react'; 2 import { cache } from 'react';
3 import { CATEGORIES, FAMILIES, getCategory, type CategoryNode } from '@rareindex/taxonomy'; 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 } from './_util';
5−import { categoryScope } from './assets'; 5+import { categoryScope, getSetFromAssets } from './assets';
6 6
7 export interface CategorySnapshot { 7 export interface CategorySnapshot {
8 categorySlug: string; 8 categorySlug: string;
@@ -111,13 +111,17 @@ export const getCategoryRow = cache(async (slug: string): Promise<MarketRow | nu
111 return { node, snapshot: snaps.get(slug) ?? null, counts: aggregate(categoryScope(slug), counts, slug) }; 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 const r = await rows<Record<string, unknown>>(sql` 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+ 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,
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+ count(*) AS assets, count(s.riv_usd) AS priced, coalesce(sum(s.sales_count), 0) AS sales,
118− WHERE st.category_slug IN ${categoryScope(slug)} GROUP BY st.slug, st.name, st.code, st.release_year ORDER BY sales DESC, assets DESC, st.release_year DESC NULLS LAST LIMIT ${limit} 119+ (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 export const getBrandsInCategory = cache(async (slug: string, limit = 40): Promise<Array<{ brand: string; assets: number; priced: number; sales: number; medianRivUsd: number | null }>> => { 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 export const getSet = cache(async (slug: string) => { 136 export const getSet = cache(async (slug: string) => {
133 const x = await one<Record<string, unknown>>(sql`SELECT * FROM sets WHERE slug = ${slug}`); 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 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>) ?? {} }; 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 export const getSetSlugsForSitemap = cache(async (): Promise<string[]> => { 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 return r.map((x) => String(x.slug)); 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 const priceMax = opts.priceMaxUsd ?? (parsed.currency === 'USD' || parsed.currency === null ? parsed.priceMax : null); 69 const priceMax = opts.priceMaxUsd ?? (parsed.currency === 'USD' || parsed.currency === null ? parsed.priceMax : null);
70 70
71 const conditions = [sql`true`]; 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 if (tsq && likeText.length >= 2) { 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 if (catSlugs?.length) conditions.push(sql`a.category_slug IN ${catSlugs}`); 82 if (catSlugs?.length) conditions.push(sql`a.category_slug IN ${catSlugs}`);
76 if (opts.yearFrom) conditions.push(sql`a.year >= ${opts.yearFrom}`); 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 const where = sql.join(conditions, sql` AND `); 90 const where = sql.join(conditions, sql` AND `);
84 91
85 const rank = tsq 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 : 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))`; 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 const rows = (await db.execute(sql` 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 const tsq = toTsQuery(q); 162 const tsq = toTsQuery(q);
163+ const code = codeLike(q);
156 const rows = (await db.execute(sql` 164 const rows = (await db.execute(sql`
157 SELECT a.slug, a.title, a.category_slug, s.riv_usd 165 SELECT a.slug, a.title, a.category_slug, s.riv_usd
158 FROM assets a LEFT JOIN asset_stats s ON s.asset_id = a.id 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``} 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``}
160− ORDER BY coalesce(s.sales_count, 0) DESC, similarity(a.title, ${q}) DESC 168+ ORDER BY (a.hero_image_url IS NOT NULL) DESC, coalesce(s.sales_count, 0) DESC, similarity(a.title, ${q}) DESC
161 LIMIT ${limit} 169 LIMIT ${limit}
162 `)) as unknown as Array<{ slug: string; title: string; category_slug: string; riv_usd: number | null }>; 170 `)) as unknown as Array<{ slug: string; title: string; category_slug: string; riv_usd: number | null }>;
163 for (const r of rows) { 171 for (const r of rows) {
164 out.push({ type: 'asset', label: r.title, sublabel: getCategory(r.category_slug)?.name ?? r.category_slug, href: `/asset/${r.slug}` }); 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 }>; 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 }>;
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}` }); 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 return out.slice(0, limit + 3); 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
Menu

Theme