SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
30.6 KB · 560 lines tsx
Raw Blame History
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';18import { getAssetDepth, getAssetLiquidation } from '@/lib/queries/depth';19import { DepthLiquidationCard } from './depth-card';20import { countVerification } from '@rareindex/valuation';21import { listAssetLots } from '@/lib/queries/market-lists';22import { LotsIntelCards, LotsIntelTable } from '@/components/market/lots-table';2324export const ASSET_TABS = ['overview', 'sales', 'listings', 'auctions', 'grades', 'population', 'images', 'history', 'comparables', 'analysis', 'sources'] as const;25export type AssetTab = (typeof ASSET_TABS)[number];2627export async function OverviewTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {28  const vid = variant?.id ?? '';29  const [snaps, points, dist, sales, listings, obs, valuation, siblings, similar, valHist] = await Promise.all([30    getAssetSnapshots(asset.id, vid),31    getAssetSalePoints(asset.id),32    getPriceDistribution(asset.id, variant?.id ?? null),33    getAssetSales(asset.id, { variantId: variant?.id ?? null, limit: 8 }),34    getAssetListings(asset.id, { variantId: variant?.id ?? null, limit: 6 }),35    getAssetObservations(asset.id, 12),36    getLatestValuation(asset.id, variant?.id ?? null),37    getSetSiblings(asset, 8).then(attachGuidePrices),38    getSimilarAssets(asset, 8).then(attachGuidePrices),39    getValuationHistory(asset.id, variant?.id ?? null),40  ]);41  const lots = await listAssetLots(asset.id, 6);42  const [depth, liquidation] = await Promise.all([43    getAssetDepth(asset.id, variant?.id ?? null),44    getAssetLiquidation({ id: asset.id, categorySlug: asset.categorySlug, rivUsd: variant ? variant.rivUsd : asset.rivUsd, rivLowUsd: variant ? variant.rivLowUsd : asset.rivLowUsd, rivHighUsd: variant ? variant.rivHighUsd : asset.rivHighUsd }),45  ]);46  const verification = countVerification(sales.items.map((s) => s.verification));47  const bandByDate = new Map(valHist.map((h) => [h.date, h]));48  const similarOnly = similar.filter((x) => !siblings.some((y) => y.id === x.id));49  const scatter = variant ? points.filter((p) => p.variantId === variant.id) : points;50  const rivSeries = snaps.filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! }));51  const askSeries = snaps.filter((p) => p.minAskUsd != null).map((p) => ({ x: p.date, y: p.minAskUsd! }));52  const obsSeries = snaps.filter((p) => p.observationUsd != null).map((p) => ({ x: p.date, y: p.observationUsd! }));53  const riv = variant ? variant.rivUsd : asset.rivUsd;54  return (55    <div className="grid gap-4">56      <Card className="p-4">57        <div className="flex flex-wrap items-baseline justify-between gap-2">58          <div>59            <h2 className="text-sm font-semibold">Price history</h2>60            <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>61          </div>62          <Link href={`/price-history?asset=${asset.slug}`} className="text-xs text-muted hover:text-fg">63            Full history →64          </Link>65        </div>66        <PriceChart67          ariaLabel={`Price history for ${asset.title}`}68          height={320}69          className="mt-3"70          defaultPeriod={scatter.length > 30 || rivSeries.length > 400 ? '1Y' : 'ALL'}71          reference={riv !== null ? { value: riv, label: `RIV ${fmtMoney(riv)}` } : null}72          sales={scatter.map((p) => ({ date: p.date, usd: p.usd, label: `${gradeLabel(p.grader, p.grade) ?? 'raw'} · ${p.sourceName}`, variantId: p.variantId }))}73          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 }; })}74          ask={askSeries}75          guide={obsSeries}76          emptyLabel="No priced observations yet for this asset"77        />78      </Card>79      <div className="grid gap-4 lg:grid-cols-3">80        <Card>81          <CardHeader title="Valuation breakdown" subtitle={valuation ? `${valuation.method} · window ${valuation.windowDays}d` : 'No valuation computed'} />82          {valuation ? (83            <div className="p-4">84              <Evidence confidence={valuation.confidence} sampleSize={valuation.sampleSize} updatedAt={valuation.computedAt} />85              {sales.items.length ? (86                <p className="mt-1 text-[11px] text-subtle" title="Heuristic labels on the recent sales shown below: source type, sale type, match confidence, source trust (§39)">87                  Recent sales: {verification.verified} verified · {verification.likely} likely · {verification.unverified} unverified{verification.excluded ? ` · ${verification.excluded} excluded` : ''}88                </p>89              ) : null}90              <Table className="mt-3">91                <tbody>92                  {Object.entries(valuation.methods).map(([k, v]) => (93                    <tr key={k}>94                      <td className={cn(td, 'text-muted')}>{humanize(k)}</td>95                      <td className={tdNum}>{v === null ? <span className="text-subtle">—</span> : fmtMoney(v)}</td>96                    </tr>97                  ))}98                  <tr>99                    <td className={cn(td, 'font-medium')}>RIV</td>100                    <td className={cn(tdNum, 'font-semibold')}>{fmtMoney(valuation.rivUsd)}</td>101                  </tr>102                  <tr>103                    <td className={cn(td, 'text-muted')}>Low – high</td>104                    <td className={tdNum}>105                      {fmtMoney(valuation.lowUsd)} – {fmtMoney(valuation.highUsd)}106                    </td>107                  </tr>108                </tbody>109              </Table>110              {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}111              {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}112            </div>113          ) : (114            <EmptyState title="Data unavailable" description="RIV is published only once enough verified sales exist (§115, §192)." className="py-8" />115          )}116        </Card>117        <Card>118          <CardHeader title="Price distribution · 1Y" subtitle="Dispersion of verified sale prices, USD (§135)" />119          {dist ? (120            <dl className="grid grid-cols-3 gap-3 p-4 text-[12px]">121              {[122                ['Sales', fmtNum(dist.n)],123                ['Min', fmtMoney(dist.min)],124                ['P25', fmtMoney(dist.p25)],125                ['Median', fmtMoney(dist.median)],126                ['P75', fmtMoney(dist.p75)],127                ['Max', fmtMoney(dist.max)],128                ['Trimmed mean', fmtMoney(dist.trimmedMean)],129              ].map(([k, v]) => (130                <div key={k}>131                  <dt className="text-[10px] uppercase tracking-wider text-subtle">{k}</dt>132                  <dd className="num font-medium text-fg">{v}</dd>133                </div>134              ))}135            </dl>136          ) : (137            <EmptyState title="No sales in the last year" className="py-8" />138          )}139        </Card>140        <Card>141          <CardHeader title="Guide prices" subtitle="Price-guide observations — informative, not transactions" />142          {obs.length ? (143            <ul className="divide-y divide-border text-[12px]">144              {obs.slice(0, 8).map((o) => (145                <li key={o.id} className="flex items-center justify-between gap-2 px-4 py-1.5">146                  <span className="text-muted">147                    <SourceLink name={o.sourceName} url={o.sourceUrl} /> · {humanize(o.priceKind)}148                  </span>149                  <span className="num">150                    <span className="font-medium">{fmtMoney(o.priceUsd)}</span>151                    <span className="ml-1 text-[10px] text-subtle">{o.observationDate}</span>152                  </span>153                </li>154              ))}155            </ul>156          ) : (157            <EmptyState title="No guide observations" className="py-8" />158          )}159        </Card>160      </div>161      <DepthLiquidationCard depth={depth} liquidation={liquidation} riv={riv} variantLabel={variant?.label ?? null} />162      {lots.length ? (163        <Card className="overflow-hidden">164          <CardHeader title="Live auctions" subtitle="All-in = hammer + buyer's premium (house schedule); bid vs RIV gated like listings — model analytics, not advice" action={<Link href="?tab=auctions" className="text-muted hover:text-fg">All →</Link>} />165          <div className="md:hidden"><LotsIntelCards items={lots} /></div>166          <div className="hidden md:block"><LotsIntelTable items={lots} /></div>167        </Card>168      ) : null}169      <div className="grid gap-4 lg:grid-cols-2">170        <Card className="overflow-hidden">171          <CardHeader title="Recent sales" action={<Link href={`?tab=sales${variant ? `&v=${variant.id}` : ''}`} className="text-muted hover:text-fg">All {sales.total} →</Link>} />172          <div className="md:hidden"><SalesCards items={sales.items} /></div>173          <div className="hidden md:block"><SalesTable items={sales.items} showAsset={false} /></div>174        </Card>175        <Card className="overflow-hidden">176          <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>} />177          <div className="md:hidden"><ListingsCards items={listings} /></div>178          <div className="hidden md:block"><ListingsTable items={listings} showAsset={false} /></div>179        </Card>180      </div>181      {asset.description ? (182        <Card className="p-4">183          <h2 className="text-sm font-semibold">About</h2>184          <p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed text-muted">{asset.description}</p>185        </Card>186      ) : null}187      {siblings.length ? (188        <section>189          <div className="mb-2 flex items-baseline justify-between">190            <h2 className="text-sm font-semibold">Other assets in {asset.setName ?? 'this set'}</h2>191            {asset.setSlug ? (192              <Link href={`/set/${asset.setSlug}`} className="text-xs text-muted hover:text-fg">193                Whole set →194              </Link>195            ) : null}196          </div>197          <AssetCardGrid items={siblings} metric="latestSale" />198        </section>199      ) : null}200      {similarOnly.length ? (201        <section>202          <div className="mb-2 flex items-baseline justify-between">203            <h2 className="text-sm font-semibold">Similar assets</h2>204            <Link href={`/search?q=${encodeURIComponent(asset.name)}`} className="text-xs text-muted hover:text-fg">205              Search “{asset.name}” →206            </Link>207          </div>208          <AssetCardGrid items={similarOnly} metric="latestSale" />209        </section>210      ) : null}211    </div>212  );213}214215export async function SalesTab({ asset, variant, page }: { asset: AssetDetail; variant: VariantRow | null; page: number }) {216  const pageSize = 50;217  const res = await getAssetSales(asset.id, { variantId: variant?.id ?? null, limit: pageSize, offset: (page - 1) * pageSize, includeFlagged: true });218  return (219    <Card className="overflow-hidden">220      <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`} />221      <div className="md:hidden"><SalesCards items={res.items} /></div>222      <div className="hidden md:block"><SalesTable items={res.items} showAsset={false} /></div>223      {res.total > pageSize ? (224        <div className="flex items-center justify-between px-4 py-3 text-xs text-muted">225          <span className="num">226            page {page} / {Math.ceil(res.total / pageSize)}227          </span>228          <span className="flex gap-2">229            {page > 1 ? (230              <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">231                ← Newer232              </Link>233            ) : null}234            {page * pageSize < res.total ? (235              <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">236                Older →237              </Link>238            ) : null}239          </span>240        </div>241      ) : null}242    </Card>243  );244}245246export async function ListingsTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {247  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 })]);248  const ended = all.filter((l) => l.availability !== 'available');249  return (250    <div className="grid gap-4">251      <Card className="overflow-hidden">252        <CardHeader title="Active listings" subtitle="Sorted by asking price (USD). Cross-listings of the same physical item are grouped when detected." />253        <div className="md:hidden"><ListingsCards items={live} /></div>254        <div className="hidden md:block"><ListingsTable items={live} showAsset={false} /></div>255      </Card>256      {ended.length ? (257        <Card className="overflow-hidden">258          <CardHeader title="Ended / removed listings" subtitle="Listing lifecycle is retained (new → price changed → sold/removed)" />259          <div className="md:hidden"><ListingsCards items={ended} /></div>260          <div className="hidden md:block"><ListingsTable items={ended} showAsset={false} /></div>261        </Card>262      ) : null}263    </div>264  );265}266267export async function AuctionsTab({ asset }: { asset: AssetDetail }) {268  const lots = await listAssetLots(asset.id, 100);269  return (270    <Card className="overflow-hidden">271      <CardHeader title="Live and upcoming auction lots" subtitle="The house's estimate and current bid in native currency; the all-in column adds the buyer's premium from the house's published or estimated schedule (VAT/duties/shipping excluded). Bid vs RIV uses the same gates as listings. Model analytics, not advice." />272      <div className="md:hidden">273        <LotsIntelCards items={lots} />274        {!lots.length ? <EmptyState title="No open auction lot" description="Lots matched to this asset appear when auction-house connectors publish catalogues." /> : null}275      </div>276      <div className="hidden md:block">277        <LotsIntelTable items={lots} emptyTitle="No open auction lot" emptyDescription="Lots matched to this asset appear when auction-house connectors publish catalogues." />278      </div>279    </Card>280  );281}282283export async function GradesTab({ asset }: { asset: AssetDetail }) {284  const rows = await getGradeDistribution(asset.id);285  const graded = rows.filter((r) => r.grader !== 'raw');286  return (287    <div className="grid gap-4 lg:grid-cols-[3fr_2fr]">288      <Card className="overflow-hidden">289        <CardHeader title="Sales by grade" subtitle="Median and last sale per grader/grade — premiums are observed, never assumed across graders (§118)" />290        {rows.length ? (291          <Table>292            <thead>293              <tr>294                <th className={th}>Grade</th>295                <th className={cn(th, 'text-right')}>Sales</th>296                <th className={cn(th, 'text-right')}>Median</th>297                <th className={cn(th, 'text-right')}>Min</th>298                <th className={cn(th, 'text-right')}>Max</th>299                <th className={cn(th, 'text-right')}>Last sale</th>300              </tr>301            </thead>302            <tbody>303              {rows.map((r) => (304                <tr key={`${r.grader}-${r.grade}`} className="hover:bg-sunken">305                  <td className={td}>306                    <Badge tone={r.grader === 'raw' ? 'neutral' : 'index'}>{gradeLabel(r.grader, r.grade) ?? 'Raw'}</Badge>307                  </td>308                  <td className={tdNum}>{fmtNum(r.sales)}</td>309                  <td className={cn(tdNum, 'font-medium')}>{fmtMoney(r.medianUsd)}</td>310                  <td className={tdNum}>{fmtMoney(r.minUsd)}</td>311                  <td className={tdNum}>{fmtMoney(r.maxUsd)}</td>312                  <td className={tdNum}>313                    {fmtMoney(r.lastSaleUsd)}314                    <span className="block text-[10px] text-subtle">{r.lastSaleAt ? fmtRelative(r.lastSaleAt) : ''}</span>315                  </td>316                </tr>317              ))}318            </tbody>319          </Table>320        ) : (321          <EmptyState title="No graded sales" description="Grade distribution appears once sales with grader and grade are ingested." />322        )}323      </Card>324      <Card>325        <CardHeader title="Median price by grade" />326        <div className="p-4">327          <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}` }))} />328        </div>329      </Card>330    </div>331  );332}333334export async function PopulationTab({ asset }: { asset: AssetDetail }) {335  const pops = await getPopulation(asset.id);336  const byGrader = new Map<string, typeof pops>();337  for (const p of pops) (byGrader.get(p.grader) ?? byGrader.set(p.grader, []).get(p.grader)!).push(p);338  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>;339  return (340    <div className="grid gap-4 lg:grid-cols-2">341      {[...byGrader.entries()].map(([grader, reports]) => {342        const latest = reports[reports.length - 1]!;343        const first = reports[0]!;344        const grades = Object.entries(latest.byGrade).sort((a, b) => Number(b[0]) - Number(a[0]));345        const top = grades[0];346        const gemRate = top && latest.total ? (top[1] / latest.total) : null;347        const growth = reports.length > 1 && first.total ? latest.total / first.total - 1 : null;348        return (349          <Card key={grader}>350            <CardHeader title={`${grader.toUpperCase()} population`} subtitle={`Report ${latest.reportDate}${latest.sourceUrl ? ' · ' : ''}`} action={latest.sourceUrl ? <SourceLink name="source" url={latest.sourceUrl} /> : null} />351            <dl className="grid grid-cols-4 gap-3 border-b border-border px-4 py-3 text-[12px]">352              <div><dt className="text-[10px] uppercase tracking-wider text-subtle">Total graded</dt><dd className="num font-semibold">{fmtNum(latest.total)}</dd></div>353              <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>354              <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>355              <div><dt className="text-[10px] uppercase tracking-wider text-subtle">Population momentum</dt><dd className="num font-semibold"><Delta value={growth} /></dd></div>356            </dl>357            <div className="p-4">358              <BarChart ariaLabel={`${grader} population by grade`} data={grades.map(([g, n]) => ({ label: `Grade ${g}`, value: n }))} maxBars={20} />359              {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}360            </div>361          </Card>362        );363      })}364    </div>365  );366}367368export async function ImagesTab({ asset }: { asset: AssetDetail }) {369  const imgs = await getAssetImages(asset.id, 48);370  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>;371  return (372    <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">373      {imgs.map((im) => (374        <figure key={im.id} className="card overflow-hidden">375          <div className="relative aspect-square bg-inset">376            {(() => {377              const ip = imageProps(im.url, { maxWidth: 384 });378              return <SmartImage src={ip?.src ?? null} srcSet={ip?.srcSet ?? null} sizes="(max-width: 640px) 50vw, 200px" alt={asset.title} placeholder={{ glyph: glyphForFamily(asset.familySlug) }} />;379            })()}380          </div>381          <figcaption className="truncate px-2 py-1 text-[10px] text-subtle">382            {im.role}383            {im.attribution ? ` · ${im.attribution}` : im.sourceId ? ` · ${im.sourceId}` : ''}384          </figcaption>385        </figure>386      ))}387    </div>388  );389}390391export async function HistoryTab({ asset, variant }: { asset: AssetDetail; variant: VariantRow | null }) {392  const snaps = await getAssetSnapshots(asset.id, variant?.id ?? '');393  return (394    <div className="grid gap-4">395      <Card className="p-4">396        <h2 className="text-sm font-semibold">Daily series</h2>397        <p className="text-xs text-muted">RIV, median sale, lowest ask and volume from daily snapshots{variant ? ` · ${variant.label}` : ''}</p>398        <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" />399        <div className="mt-4 grid gap-4 md:grid-cols-2">400          <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" />401          <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" />402        </div>403      </Card>404      <Card className="overflow-hidden">405        <CardHeader title="Snapshot table" subtitle={`${snaps.length} days`} />406        {snaps.length ? (407          <Table>408            <thead>409              <tr>410                <th className={th}>Date</th>411                <th className={cn(th, 'text-right')}>RIV</th>412                <th className={cn(th, 'text-right')}>Median</th>413                <th className={cn(th, 'text-right')}>Last sale</th>414                <th className={cn(th, 'text-right')}>Sales</th>415                <th className={cn(th, 'text-right')}>Volume</th>416                <th className={cn(th, 'text-right')}>Listings</th>417                <th className={cn(th, 'text-right')}>Low ask</th>418              </tr>419            </thead>420            <tbody>421              {[...snaps].reverse().slice(0, 120).map((s) => (422                <tr key={s.date}>423                  <td className={cn(td, 'text-muted')}>{s.date}</td>424                  <td className={tdNum}>{fmtMoney(s.rivUsd)}</td>425                  <td className={tdNum}>{fmtMoney(s.medianUsd)}</td>426                  <td className={tdNum}>{fmtMoney(s.latestSaleUsd)}</td>427                  <td className={tdNum}>{s.salesCount}</td>428                  <td className={tdNum}>{fmtMoney(s.volumeUsd)}</td>429                  <td className={tdNum}>{s.listingsCount}</td>430                  <td className={tdNum}>{fmtMoney(s.minAskUsd)}</td>431                </tr>432              ))}433            </tbody>434          </Table>435        ) : (436          <EmptyState title="No snapshots yet" description="Daily snapshots begin once the valuation worker has run for this asset." />437        )}438      </Card>439    </div>440  );441}442443export async function ComparablesTab({ asset }: { asset: AssetDetail }) {444  const comps = await getComparables(asset, 12);445  return (446    <Card className="overflow-hidden">447      <CardHeader title="Comparable assets" subtitle="Same set/category, close valuation; similarity is a transparent attribute score (§134)" />448      {comps.length ? (449        <Table>450          <thead>451            <tr>452              <th className={th}>Asset</th>453              <th className={cn(th, 'text-right')}>Similarity</th>454              <th className={cn(th, 'text-right')}>RIV</th>455              <th className={cn(th, 'text-right')}>Last sale</th>456              <th className={cn(th, 'text-right')}>30D</th>457              <th className={cn(th, 'text-right')}>Sales</th>458              <th className={cn(th, 'text-right')}>Liquidity</th>459            </tr>460          </thead>461          <tbody>462            {comps.map((c) => (463              <tr key={c.id} className="hover:bg-sunken">464                <td className={cn(td, 'max-w-[360px]')}>465                  <span className="flex items-center gap-2">466                    <Thumb src={c.heroImageUrl} alt="" size={32} />467                    <span className="min-w-0">468                      <Link href={`/asset/${c.slug}`} className="block truncate font-medium text-fg hover:underline">469                        {c.title}470                      </Link>471                      <span className="text-[11px] text-muted">{catName(c.categorySlug)}</span>472                    </span>473                  </span>474                </td>475                <td className={tdNum}>{Math.round(c.similarity * 100)}%</td>476                <td className={tdNum}>{fmtMoney(c.rivUsd)}</td>477                <td className={tdNum}>{fmtMoney(c.latestSaleUsd)}</td>478                <td className={tdNum}><Delta value={c.change30d} /></td>479                <td className={tdNum}>{fmtNum(c.salesCount)}</td>480                <td className={tdNum}>{c.liquidityScore != null ? Math.round(c.liquidityScore) : '—'}</td>481              </tr>482            ))}483          </tbody>484        </Table>485      ) : (486        <EmptyState title="No comparables yet" description="Comparables need other catalogued assets in the same set or category." />487      )}488    </Card>489  );490}491492export function AnalysisTab({ asset }: { asset: AssetDetail }) {493  return (494    <Card>495      <EmptyState496        title="AI analysis"497        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."498        action={499          <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">500            Open in AI Research →501          </Link>502        }503      />504    </Card>505  );506}507508export async function SourcesTab({ asset }: { asset: AssetDetail }) {509  const [sources, mkt] = await Promise.all([getAssetSources(asset.id), getMarketplaceDistribution(asset.id)]);510  return (511    <div className="grid gap-4 lg:grid-cols-[3fr_2fr]">512      <Card className="overflow-hidden">513        <CardHeader title="Data provenance" subtitle="Every datapoint on this page traces back to one of these sources (§142, §178)" />514        {sources.length ? (515          <Table>516            <thead>517              <tr>518                <th className={th}>Source</th>519                <th className={th}>Type</th>520                <th className={cn(th, 'text-right')}>Trust</th>521                <th className={cn(th, 'text-right')}>Sales</th>522                <th className={cn(th, 'text-right')}>Listings</th>523                <th className={cn(th, 'text-right')}>Observations</th>524                <th className={th}>Last seen</th>525              </tr>526            </thead>527            <tbody>528              {sources.map((s) => (529                <tr key={s.sourceId} className="hover:bg-sunken">530                  <td className={cn(td, 'font-medium')}>531                    <SourceLink name={s.name} url={s.url} />532                  </td>533                  <td className={cn(td, 'text-muted')}>{humanize(s.sourceType)}</td>534                  <td className={tdNum}>{Math.round(s.trustScore * 100)}%</td>535                  <td className={tdNum}>{fmtNum(s.sales)}</td>536                  <td className={tdNum}>{fmtNum(s.listings)}</td>537                  <td className={tdNum}>{fmtNum(s.observations)}</td>538                  <td className={cn(td, 'text-muted')}>{s.lastSeen ? fmtRelative(s.lastSeen) : '—'}</td>539                </tr>540              ))}541            </tbody>542          </Table>543        ) : (544          <EmptyState title="No source records yet" />545        )}546        <div className="border-t border-border px-4 py-2 text-[11px] text-subtle">547          Canonical key <code className="rounded-sm bg-inset px-1">{asset.canonicalKey}</code> · identifiers:{' '}548          {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)}549        </div>550      </Card>551      <Card>552        <CardHeader title="Marketplace distribution" subtitle="Share of verified sales by source" />553        <div className="p-4">554          <BarChart ariaLabel="Sales by marketplace" data={mkt.map((m) => ({ label: m.sourceName, value: m.sales, sublabel: `volume ${fmtMoney(m.volumeUsd)}` }))} />555        </div>556      </Card>557    </div>558  );559}560