SPB Git

spb/rareindex

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

Theme