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