1import Link from 'next/link';2import { imageProps } from '@/lib/images';3import { SmartImage } from '@/components/ui/smart-image';4import { glyphForFamily } from '@/lib/image-glyph';5import type { AssetDetail, VariantRow } from '@/lib/queries/assets';6import { getAssetImages, getAssetListings, getAssetObservations, getAssetSalePoints, getAssetSales, getAssetSnapshots, getAssetSources, getComparables, getGradeDistribution, getLatestValuation, getMarketplaceDistribution, getPopulation, getPriceDistribution, getSetSiblings, getSimilarAssets, attachGuidePrices, getValuationHistory } from '@/lib/queries/assets';7import { AssetCardGrid } from '@/components/market/asset-list';8import { fmtDate, fmtMoney, fmtNum, fmtRelative, cn } from '@/lib/format';9import { gradeLabel, humanize, catName } from '@/lib/taxonomy';10import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge, Delta } from '@/components/ui/primitives';11import { LineChart } from '@/components/charts/line-chart';12import { PriceChart } from '@/components/charts/price-chart';13import { SalesCards, ListingsCards } from './sales-cards';14import { BarChart } from '@/components/charts/bar-chart';15import { SalesTable, ListingsTable } from '@/components/market/sales-table';16import { SourceLink, Thumb } from '@/components/market/bits';17import { Evidence } from '@/components/ui/evidence';1819export const ASSET_TABS = ['overview', 'sales', 'listings', 'grades', 'population', 'images', 'history', 'comparables', 'analysis', 'sources'] as const;20export type AssetTab = (typeof ASSET_TABS)[number];2122export async function OverviewTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {23 const vid = variant?.id ?? '';24 const [snaps, points, dist, sales, listings, obs, valuation, siblings, similar, valHist] = await Promise.all([25 getAssetSnapshots(asset.id, vid),26 getAssetSalePoints(asset.id),27 getPriceDistribution(asset.id, variant?.id ?? null),28 getAssetSales(asset.id, { variantId: variant?.id ?? null, limit: 8 }),29 getAssetListings(asset.id, { variantId: variant?.id ?? null, limit: 6 }),30 getAssetObservations(asset.id, 12),31 getLatestValuation(asset.id, variant?.id ?? null),32 getSetSiblings(asset, 8).then(attachGuidePrices),33 getSimilarAssets(asset, 8).then(attachGuidePrices),34 getValuationHistory(asset.id, variant?.id ?? null),35 ]);36 const bandByDate = new Map(valHist.map((h) => [h.date, h]));37 const similarOnly = similar.filter((x) => !siblings.some((y) => y.id === x.id));38 const scatter = variant ? points.filter((p) => p.variantId === variant.id) : points;39 const rivSeries = snaps.filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! }));40 const askSeries = snaps.filter((p) => p.minAskUsd != null).map((p) => ({ x: p.date, y: p.minAskUsd! }));41 const obsSeries = snaps.filter((p) => p.observationUsd != null).map((p) => ({ x: p.date, y: p.observationUsd! }));42 const riv = variant ? variant.rivUsd : asset.rivUsd;43 return (44 <div className="grid gap-4">45 <Card className="p-4">46 <div className="flex flex-wrap items-baseline justify-between gap-2">47 <div>48 <h2 className="text-sm font-semibold">Price history</h2>49 <p className="text-xs text-muted">Verified sales (dots) with the daily RareIndex Valuation, lowest ask and guide observations when available. USD at historical FX.</p>50 </div>51 <Link href={`/price-history?asset=${asset.slug}`} className="text-xs text-muted hover:text-fg">52 Full history →53 </Link>54 </div>55 <PriceChart56 ariaLabel={`Price history for ${asset.title}`}57 height={320}58 className="mt-3"59 defaultPeriod={scatter.length > 30 || rivSeries.length > 400 ? '1Y' : 'ALL'}60 reference={riv !== null ? { value: riv, label: `RIV ${fmtMoney(riv)}` } : null}61 sales={scatter.map((p) => ({ date: p.date, usd: p.usd, label: `${gradeLabel(p.grader, p.grade) ?? 'raw'} · ${p.sourceName}`, variantId: p.variantId }))}62 riv={rivSeries.map((p) => { const b = bandByDate.get(p.x); return { x: p.x, y: p.y, low: b?.lowUsd ?? null, high: b?.highUsd ?? null }; })}63 ask={askSeries}64 guide={obsSeries}65 emptyLabel="No priced observations yet for this asset"66 />67 </Card>68 <div className="grid gap-4 lg:grid-cols-3">69 <Card>70 <CardHeader title="Valuation breakdown" subtitle={valuation ? `${valuation.method} · window ${valuation.windowDays}d` : 'No valuation computed'} />71 {valuation ? (72 <div className="p-4">73 <Evidence confidence={valuation.confidence} sampleSize={valuation.sampleSize} updatedAt={valuation.computedAt} />74 <Table className="mt-3">75 <tbody>76 {Object.entries(valuation.methods).map(([k, v]) => (77 <tr key={k}>78 <td className={cn(td, 'text-muted')}>{humanize(k)}</td>79 <td className={tdNum}>{v === null ? <span className="text-subtle">—</span> : fmtMoney(v)}</td>80 </tr>81 ))}82 <tr>83 <td className={cn(td, 'font-medium')}>RIV</td>84 <td className={cn(tdNum, 'font-semibold')}>{fmtMoney(valuation.rivUsd)}</td>85 </tr>86 <tr>87 <td className={cn(td, 'text-muted')}>Low – high</td>88 <td className={tdNum}>89 {fmtMoney(valuation.lowUsd)} – {fmtMoney(valuation.highUsd)}90 </td>91 </tr>92 </tbody>93 </Table>94 {valuation.notes.length ? <ul className="mt-2 list-disc pl-4 text-[11px] text-muted">{valuation.notes.map((n, i) => <li key={i}>{n}</li>)}</ul> : null}95 {valuation.observationsUsed ? <p className="mt-2 text-[11px] text-subtle">{valuation.observationsUsed} guide observations were used as a weak prior (weighted below transactions).</p> : null}96 </div>97 ) : (98 <EmptyState title="Data unavailable" description="RIV is published only once enough verified sales exist (§115, §192)." className="py-8" />99 )}100 </Card>101 <Card>102 <CardHeader title="Price distribution · 1Y" subtitle="Dispersion of verified sale prices, USD (§135)" />103 {dist ? (104 <dl className="grid grid-cols-3 gap-3 p-4 text-[12px]">105 {[106 ['Sales', fmtNum(dist.n)],107 ['Min', fmtMoney(dist.min)],108 ['P25', fmtMoney(dist.p25)],109 ['Median', fmtMoney(dist.median)],110 ['P75', fmtMoney(dist.p75)],111 ['Max', fmtMoney(dist.max)],112 ['Trimmed mean', fmtMoney(dist.trimmedMean)],113 ].map(([k, v]) => (114 <div key={k}>115 <dt className="text-[10px] uppercase tracking-wider text-subtle">{k}</dt>116 <dd className="num font-medium text-fg">{v}</dd>117 </div>118 ))}119 </dl>120 ) : (121 <EmptyState title="No sales in the last year" className="py-8" />122 )}123 </Card>124 <Card>125 <CardHeader title="Guide prices" subtitle="Price-guide observations — informative, not transactions" />126 {obs.length ? (127 <ul className="divide-y divide-border text-[12px]">128 {obs.slice(0, 8).map((o) => (129 <li key={o.id} className="flex items-center justify-between gap-2 px-4 py-1.5">130 <span className="text-muted">131 <SourceLink name={o.sourceName} url={o.sourceUrl} /> · {humanize(o.priceKind)}132 </span>133 <span className="num">134 <span className="font-medium">{fmtMoney(o.priceUsd)}</span>135 <span className="ml-1 text-[10px] text-subtle">{o.observationDate}</span>136 </span>137 </li>138 ))}139 </ul>140 ) : (141 <EmptyState title="No guide observations" className="py-8" />142 )}143 </Card>144 </div>145 <div className="grid gap-4 lg:grid-cols-2">146 <Card className="overflow-hidden">147 <CardHeader title="Recent sales" action={<Link href={`?tab=sales${variant ? `&v=${variant.id}` : ''}`} className="text-muted hover:text-fg">All {sales.total} →</Link>} />148 <div className="md:hidden"><SalesCards items={sales.items} /></div>149 <div className="hidden md:block"><SalesTable items={sales.items} showAsset={false} /></div>150 </Card>151 <Card className="overflow-hidden">152 <CardHeader title="Live listings" subtitle="Asking prices, not market value" action={<Link href={`?tab=listings${variant ? `&v=${variant.id}` : ''}`} className="text-muted hover:text-fg">All →</Link>} />153 <div className="md:hidden"><ListingsCards items={listings} /></div>154 <div className="hidden md:block"><ListingsTable items={listings} showAsset={false} /></div>155 </Card>156 </div>157 {asset.description ? (158 <Card className="p-4">159 <h2 className="text-sm font-semibold">About</h2>160 <p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed text-muted">{asset.description}</p>161 </Card>162 ) : null}163 {siblings.length ? (164 <section>165 <div className="mb-2 flex items-baseline justify-between">166 <h2 className="text-sm font-semibold">Other assets in {asset.setName ?? 'this set'}</h2>167 {asset.setSlug ? (168 <Link href={`/set/${asset.setSlug}`} className="text-xs text-muted hover:text-fg">169 Whole set →170 </Link>171 ) : null}172 </div>173 <AssetCardGrid items={siblings} metric="latestSale" />174 </section>175 ) : null}176 {similarOnly.length ? (177 <section>178 <div className="mb-2 flex items-baseline justify-between">179 <h2 className="text-sm font-semibold">Similar assets</h2>180 <Link href={`/search?q=${encodeURIComponent(asset.name)}`} className="text-xs text-muted hover:text-fg">181 Search “{asset.name}” →182 </Link>183 </div>184 <AssetCardGrid items={similarOnly} metric="latestSale" />185 </section>186 ) : null}187 </div>188 );189}190191export async function SalesTab({ asset, variant, page }: { asset: AssetDetail; variant: VariantRow | null; page: number }) {192 const pageSize = 50;193 const res = await getAssetSales(asset.id, { variantId: variant?.id ?? null, limit: pageSize, offset: (page - 1) * pageSize, includeFlagged: true });194 return (195 <Card className="overflow-hidden">196 <CardHeader title={`Sales${variant ? ` · ${variant.label}` : ''}`} subtitle={`${res.total.toLocaleString('en-US')} observed transactions · flagged rows are kept for audit (§116) and excluded from valuation`} />197 <div className="md:hidden"><SalesCards items={res.items} /></div>198 <div className="hidden md:block"><SalesTable items={res.items} showAsset={false} /></div>199 {res.total > pageSize ? (200 <div className="flex items-center justify-between px-4 py-3 text-xs text-muted">201 <span className="num">202 page {page} / {Math.ceil(res.total / pageSize)}203 </span>204 <span className="flex gap-2">205 {page > 1 ? (206 <Link href={`?tab=sales&page=${page - 1}${variant ? `&v=${variant.id}` : ''}`} className="inline-flex h-9 items-center rounded-md border border-border px-3 font-medium text-fg hover:bg-inset">207 ← Newer208 </Link>209 ) : null}210 {page * pageSize < res.total ? (211 <Link href={`?tab=sales&page=${page + 1}${variant ? `&v=${variant.id}` : ''}`} className="inline-flex h-9 items-center rounded-md border border-border px-3 font-medium text-fg hover:bg-inset">212 Older →213 </Link>214 ) : null}215 </span>216 </div>217 ) : null}218 </Card>219 );220}221222export async function ListingsTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {223 const [live, all] = await Promise.all([getAssetListings(asset.id, { variantId: variant?.id ?? null, limit: 100 }), getAssetListings(asset.id, { variantId: variant?.id ?? null, availability: 'all', limit: 100 })]);224 const ended = all.filter((l) => l.availability !== 'available');225 return (226 <div className="grid gap-4">227 <Card className="overflow-hidden">228 <CardHeader title="Active listings" subtitle="Sorted by asking price (USD). Cross-listings of the same physical item are grouped when detected." />229 <div className="md:hidden"><ListingsCards items={live} /></div>230 <div className="hidden md:block"><ListingsTable items={live} showAsset={false} /></div>231 </Card>232 {ended.length ? (233 <Card className="overflow-hidden">234 <CardHeader title="Ended / removed listings" subtitle="Listing lifecycle is retained (new → price changed → sold/removed)" />235 <div className="md:hidden"><ListingsCards items={ended} /></div>236 <div className="hidden md:block"><ListingsTable items={ended} showAsset={false} /></div>237 </Card>238 ) : null}239 </div>240 );241}242243export async function GradesTab({ asset }: { asset: AssetDetail }) {244 const rows = await getGradeDistribution(asset.id);245 const graded = rows.filter((r) => r.grader !== 'raw');246 return (247 <div className="grid gap-4 lg:grid-cols-[3fr_2fr]">248 <Card className="overflow-hidden">249 <CardHeader title="Sales by grade" subtitle="Median and last sale per grader/grade — premiums are observed, never assumed across graders (§118)" />250 {rows.length ? (251 <Table>252 <thead>253 <tr>254 <th className={th}>Grade</th>255 <th className={cn(th, 'text-right')}>Sales</th>256 <th className={cn(th, 'text-right')}>Median</th>257 <th className={cn(th, 'text-right')}>Min</th>258 <th className={cn(th, 'text-right')}>Max</th>259 <th className={cn(th, 'text-right')}>Last sale</th>260 </tr>261 </thead>262 <tbody>263 {rows.map((r) => (264 <tr key={`${r.grader}-${r.grade}`} className="hover:bg-sunken">265 <td className={td}>266 <Badge tone={r.grader === 'raw' ? 'neutral' : 'index'}>{gradeLabel(r.grader, r.grade) ?? 'Raw'}</Badge>267 </td>268 <td className={tdNum}>{fmtNum(r.sales)}</td>269 <td className={cn(tdNum, 'font-medium')}>{fmtMoney(r.medianUsd)}</td>270 <td className={tdNum}>{fmtMoney(r.minUsd)}</td>271 <td className={tdNum}>{fmtMoney(r.maxUsd)}</td>272 <td className={tdNum}>273 {fmtMoney(r.lastSaleUsd)}274 <span className="block text-[10px] text-subtle">{r.lastSaleAt ? fmtRelative(r.lastSaleAt) : ''}</span>275 </td>276 </tr>277 ))}278 </tbody>279 </Table>280 ) : (281 <EmptyState title="No graded sales" description="Grade distribution appears once sales with grader and grade are ingested." />282 )}283 </Card>284 <Card>285 <CardHeader title="Median price by grade" />286 <div className="p-4">287 <BarChart ariaLabel="Median price by grade" currency data={graded.filter((g) => g.medianUsd != null).map((g) => ({ label: gradeLabel(g.grader, g.grade) ?? g.grade, value: g.medianUsd!, sublabel: `n=${g.sales}` }))} />288 </div>289 </Card>290 </div>291 );292}293294export async function PopulationTab({ asset }: { asset: AssetDetail }) {295 const pops = await getPopulation(asset.id);296 const byGrader = new Map<string, typeof pops>();297 for (const p of pops) (byGrader.get(p.grader) ?? byGrader.set(p.grader, []).get(p.grader)!).push(p);298 if (!pops.length) return <Card><EmptyState title="Population data unavailable" description="Population reports appear when grading-company connectors publish a census for this asset. Counts are never estimated (§119, §192)." /></Card>;299 return (300 <div className="grid gap-4 lg:grid-cols-2">301 {[...byGrader.entries()].map(([grader, reports]) => {302 const latest = reports[reports.length - 1]!;303 const first = reports[0]!;304 const grades = Object.entries(latest.byGrade).sort((a, b) => Number(b[0]) - Number(a[0]));305 const top = grades[0];306 const gemRate = top && latest.total ? (top[1] / latest.total) : null;307 const growth = reports.length > 1 && first.total ? latest.total / first.total - 1 : null;308 return (309 <Card key={grader}>310 <CardHeader title={`${grader.toUpperCase()} population`} subtitle={`Report ${latest.reportDate}${latest.sourceUrl ? ' · ' : ''}`} action={latest.sourceUrl ? <SourceLink name="source" url={latest.sourceUrl} /> : null} />311 <dl className="grid grid-cols-4 gap-3 border-b border-border px-4 py-3 text-[12px]">312 <div><dt className="text-[10px] uppercase tracking-wider text-subtle">Total graded</dt><dd className="num font-semibold">{fmtNum(latest.total)}</dd></div>313 <div><dt className="text-[10px] uppercase tracking-wider text-subtle">Highest grade</dt><dd className="num font-semibold">{top ? `${top[0]} (${fmtNum(top[1])})` : '—'}</dd></div>314 <div><dt className="text-[10px] uppercase tracking-wider text-subtle">Top-grade rate</dt><dd className="num font-semibold">{gemRate === null ? '—' : `${(gemRate * 100).toFixed(1)}%`}</dd></div>315 <div><dt className="text-[10px] uppercase tracking-wider text-subtle">Population momentum</dt><dd className="num font-semibold"><Delta value={growth} /></dd></div>316 </dl>317 <div className="p-4">318 <BarChart ariaLabel={`${grader} population by grade`} data={grades.map(([g, n]) => ({ label: `Grade ${g}`, value: n }))} maxBars={20} />319 {reports.length > 1 ? <LineChart ariaLabel="Population over time" height={160} className="mt-4" series={[{ id: 'pop', label: 'Total population', points: reports.map((r) => ({ x: r.reportDate, y: r.total })) }]} /> : null}320 </div>321 </Card>322 );323 })}324 </div>325 );326}327328export async function ImagesTab({ asset }: { asset: AssetDetail }) {329 const imgs = await getAssetImages(asset.id, 48);330 if (!imgs.length) return <Card><EmptyState title="No images stored" description="Images are referenced from their source with attribution; none have been captured for this asset yet." /></Card>;331 return (332 <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">333 {imgs.map((im) => (334 <figure key={im.id} className="card overflow-hidden">335 <div className="relative aspect-square bg-inset">336 {(() => {337 const ip = imageProps(im.url, { maxWidth: 384 });338 return <SmartImage src={ip?.src ?? null} srcSet={ip?.srcSet ?? null} sizes="(max-width: 640px) 50vw, 200px" alt={asset.title} placeholder={{ glyph: glyphForFamily(asset.familySlug) }} />;339 })()}340 </div>341 <figcaption className="truncate px-2 py-1 text-[10px] text-subtle">342 {im.role}343 {im.attribution ? ` · ${im.attribution}` : im.sourceId ? ` · ${im.sourceId}` : ''}344 </figcaption>345 </figure>346 ))}347 </div>348 );349}350351export async function HistoryTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {352 const snaps = await getAssetSnapshots(asset.id, variant?.id ?? '');353 return (354 <div className="grid gap-4">355 <Card className="p-4">356 <h2 className="text-sm font-semibold">Daily series</h2>357 <p className="text-xs text-muted">RIV, median sale, lowest ask and volume from daily snapshots{variant ? ` · ${variant.label}` : ''}</p>358 <LineChart ariaLabel="RIV and median history" height={260} currency className="mt-3" series={[{ id: 'riv', label: 'RIV', points: snaps.filter((s) => s.rivUsd != null).map((s) => ({ x: s.date, y: s.rivUsd! })) }, { id: 'median', label: 'Median sale', dashed: true, points: snaps.filter((s) => s.medianUsd != null).map((s) => ({ x: s.date, y: s.medianUsd! })) }, { id: 'ask', label: 'Lowest ask', dashed: true, points: snaps.filter((s) => s.minAskUsd != null).map((s) => ({ x: s.date, y: s.minAskUsd! })) }]} emptyLabel="No snapshot history yet" />359 <div className="mt-4 grid gap-4 md:grid-cols-2">360 <LineChart ariaLabel="Transaction volume" height={160} currency series={[{ id: 'vol', label: 'Volume (USD)', kind: 'step', points: snaps.map((s) => ({ x: s.date, y: s.volumeUsd ?? 0 })) }]} emptyLabel="No volume history" />361 <LineChart ariaLabel="Active listings" height={160} series={[{ id: 'lst', label: 'Active listings', kind: 'step', points: snaps.map((s) => ({ x: s.date, y: s.listingsCount })) }]} emptyLabel="No listings history" />362 </div>363 </Card>364 <Card className="overflow-hidden">365 <CardHeader title="Snapshot table" subtitle={`${snaps.length} days`} />366 {snaps.length ? (367 <Table>368 <thead>369 <tr>370 <th className={th}>Date</th>371 <th className={cn(th, 'text-right')}>RIV</th>372 <th className={cn(th, 'text-right')}>Median</th>373 <th className={cn(th, 'text-right')}>Last sale</th>374 <th className={cn(th, 'text-right')}>Sales</th>375 <th className={cn(th, 'text-right')}>Volume</th>376 <th className={cn(th, 'text-right')}>Listings</th>377 <th className={cn(th, 'text-right')}>Low ask</th>378 </tr>379 </thead>380 <tbody>381 {[...snaps].reverse().slice(0, 120).map((s) => (382 <tr key={s.date}>383 <td className={cn(td, 'text-muted')}>{s.date}</td>384 <td className={tdNum}>{fmtMoney(s.rivUsd)}</td>385 <td className={tdNum}>{fmtMoney(s.medianUsd)}</td>386 <td className={tdNum}>{fmtMoney(s.latestSaleUsd)}</td>387 <td className={tdNum}>{s.salesCount}</td>388 <td className={tdNum}>{fmtMoney(s.volumeUsd)}</td>389 <td className={tdNum}>{s.listingsCount}</td>390 <td className={tdNum}>{fmtMoney(s.minAskUsd)}</td>391 </tr>392 ))}393 </tbody>394 </Table>395 ) : (396 <EmptyState title="No snapshots yet" description="Daily snapshots begin once the valuation worker has run for this asset." />397 )}398 </Card>399 </div>400 );401}402403export async function ComparablesTab({ asset }: { asset: AssetDetail }) {404 const comps = await getComparables(asset, 12);405 return (406 <Card className="overflow-hidden">407 <CardHeader title="Comparable assets" subtitle="Same set/category, close valuation; similarity is a transparent attribute score (§134)" />408 {comps.length ? (409 <Table>410 <thead>411 <tr>412 <th className={th}>Asset</th>413 <th className={cn(th, 'text-right')}>Similarity</th>414 <th className={cn(th, 'text-right')}>RIV</th>415 <th className={cn(th, 'text-right')}>Last sale</th>416 <th className={cn(th, 'text-right')}>30D</th>417 <th className={cn(th, 'text-right')}>Sales</th>418 <th className={cn(th, 'text-right')}>Liquidity</th>419 </tr>420 </thead>421 <tbody>422 {comps.map((c) => (423 <tr key={c.id} className="hover:bg-sunken">424 <td className={cn(td, 'max-w-[360px]')}>425 <span className="flex items-center gap-2">426 <Thumb src={c.heroImageUrl} alt="" size={32} />427 <span className="min-w-0">428 <Link href={`/asset/${c.slug}`} className="block truncate font-medium text-fg hover:underline">429 {c.title}430 </Link>431 <span className="text-[11px] text-muted">{catName(c.categorySlug)}</span>432 </span>433 </span>434 </td>435 <td className={tdNum}>{Math.round(c.similarity * 100)}%</td>436 <td className={tdNum}>{fmtMoney(c.rivUsd)}</td>437 <td className={tdNum}>{fmtMoney(c.latestSaleUsd)}</td>438 <td className={tdNum}><Delta value={c.change30d} /></td>439 <td className={tdNum}>{fmtNum(c.salesCount)}</td>440 <td className={tdNum}>{c.liquidityScore != null ? Math.round(c.liquidityScore) : '—'}</td>441 </tr>442 ))}443 </tbody>444 </Table>445 ) : (446 <EmptyState title="No comparables yet" description="Comparables need other catalogued assets in the same set or category." />447 )}448 </Card>449 );450}451452export function AnalysisTab({ asset }: { asset: AssetDetail }) {453 return (454 <Card>455 <EmptyState456 title="AI analysis"457 description="Ask the RareIndex research assistant about this asset. Answers are generated from RareIndex's structured data (sales, listings, valuations, indices) with citations — never from model memory."458 action={459 <Link href={`/research?asset=${asset.slug}`} className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg hover:opacity-90">460 Open in AI Research →461 </Link>462 }463 />464 </Card>465 );466}467468export async function SourcesTab({ asset }: { asset: AssetDetail }) {469 const [sources, mkt] = await Promise.all([getAssetSources(asset.id), getMarketplaceDistribution(asset.id)]);470 return (471 <div className="grid gap-4 lg:grid-cols-[3fr_2fr]">472 <Card className="overflow-hidden">473 <CardHeader title="Data provenance" subtitle="Every datapoint on this page traces back to one of these sources (§142, §178)" />474 {sources.length ? (475 <Table>476 <thead>477 <tr>478 <th className={th}>Source</th>479 <th className={th}>Type</th>480 <th className={cn(th, 'text-right')}>Trust</th>481 <th className={cn(th, 'text-right')}>Sales</th>482 <th className={cn(th, 'text-right')}>Listings</th>483 <th className={cn(th, 'text-right')}>Observations</th>484 <th className={th}>Last seen</th>485 </tr>486 </thead>487 <tbody>488 {sources.map((s) => (489 <tr key={s.sourceId} className="hover:bg-sunken">490 <td className={cn(td, 'font-medium')}>491 <SourceLink name={s.name} url={s.url} />492 </td>493 <td className={cn(td, 'text-muted')}>{humanize(s.sourceType)}</td>494 <td className={tdNum}>{Math.round(s.trustScore * 100)}%</td>495 <td className={tdNum}>{fmtNum(s.sales)}</td>496 <td className={tdNum}>{fmtNum(s.listings)}</td>497 <td className={tdNum}>{fmtNum(s.observations)}</td>498 <td className={cn(td, 'text-muted')}>{s.lastSeen ? fmtRelative(s.lastSeen) : '—'}</td>499 </tr>500 ))}501 </tbody>502 </Table>503 ) : (504 <EmptyState title="No source records yet" />505 )}506 <div className="border-t border-border px-4 py-2 text-[11px] text-subtle">507 Canonical key <code className="rounded-sm bg-inset px-1">{asset.canonicalKey}</code> · identifiers:{' '}508 {Object.keys(asset.identifiers).length ? Object.entries(asset.identifiers).map(([k, v]) => <code key={k} className="mr-1 rounded-sm bg-inset px-1">{k}={v}</code>) : 'none'} · created {fmtDate(asset.createdAt)}509 </div>510 </Card>511 <Card>512 <CardHeader title="Marketplace distribution" subtitle="Share of verified sales by source" />513 <div className="p-4">514 <BarChart ariaLabel="Sales by marketplace" data={mkt.map((m) => ({ label: m.sourceName, value: m.sales, sublabel: `volume ${fmtMoney(m.volumeUsd)}` }))} />515 </div>516 </Card>517 </div>518 );519}520