SPB Git

spb/rareindex

Public
9.0 KB · 202 lines tsx
1import Link from 'next/link';2import type { AssetCard as AssetCardData } from '@/lib/queries/assets';3import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format';4import { catName } from '@/lib/taxonomy';5import { Delta, Table, th, td, tdNum, EmptyState } from '@/components/ui/primitives';6import { Thumb } from './bits';78export type AssetColumn = 'riv' | 'change1d' | 'change7d' | 'change30d' | 'change1y' | 'latestSale' | 'sales' | 'sales30d' | 'listings' | 'minAsk' | 'liquidity' | 'rarity' | 'trending' | 'opportunity' | 'watchers' | 'updated';910const DEFAULT_COLUMNS: AssetColumn[] = ['riv', 'change7d', 'change30d', 'latestSale', 'sales', 'listings', 'liquidity'];1112const HEAD: Record<AssetColumn, string> = {13  riv: 'RIV (USD)',14  change1d: '1D',15  change7d: '7D',16  change30d: '30D',17  change1y: '1Y',18  latestSale: 'Last sale',19  sales: 'Sales',20  sales30d: 'Sales 30D',21  listings: 'Listings',22  minAsk: 'Low ask',23  liquidity: 'Liquidity',24  rarity: 'Rarity',25  trending: 'Trending',26  opportunity: 'Value opp.',27  watchers: 'Watchers',28  updated: 'Updated',29};3031function cell(a: AssetCardData, c: AssetColumn) {32  switch (c) {33    case 'riv':34      return a.rivUsd === null ? (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        )43      ) : (44        <span className="inline-flex flex-col items-end leading-tight">45          <span className="font-medium">{fmtMoney(a.rivUsd)}</span>46          <span className="text-[10px] text-subtle">47            {confidenceLabel(a.rivConfidence)} · n={a.rivSampleSize}48          </span>49        </span>50      );51    case 'change1d':52      return <Delta value={a.change1d} />;53    case 'change7d':54      return <Delta value={a.change7d} />;55    case 'change30d':56      return <Delta value={a.change30d} />;57    case 'change1y':58      return <Delta value={a.change1y} />;59    case 'latestSale':60      return a.latestSaleUsd === null ? (61        <span className="text-subtle">—</span>62      ) : (63        <span className="inline-flex flex-col items-end leading-tight">64          <span>{fmtMoney(a.latestSaleUsd)}</span>65          <span className="text-[10px] text-subtle">{fmtRelative(a.latestSaleAt)}</span>66        </span>67      );68    case 'sales':69      return fmtNum(a.salesCount);70    case 'sales30d':71      return fmtNum(a.sales30d);72    case 'listings':73      return fmtNum(a.activeListings);74    case 'minAsk':75      return fmtMoney(a.minAskUsd);76    case 'liquidity':77      return a.liquidityScore === null ? <span className="text-subtle">—</span> : Math.round(a.liquidityScore);78    case 'rarity':79      return a.rarityScore === null ? <span className="text-subtle">—</span> : Math.round(a.rarityScore);80    case 'trending':81      return a.trendingScore === null ? <span className="text-subtle">—</span> : a.trendingScore.toFixed(1);82    case 'opportunity':83      return a.valueOpportunity === null ? <span className="text-subtle">—</span> : <Delta value={-a.valueOpportunity} />;84    case 'watchers':85      return fmtNum(a.watchers);86    case 'updated':87      return <span className="text-muted">{fmtRelative(a.updatedAt)}</span>;88  }89}9091export function AssetTable({ items, columns = DEFAULT_COLUMNS, rank = false, startRank = 1, className, emptyTitle = 'No assets yet', emptyDescription = 'Assets appear here as connectors ingest catalogues, sales and listings.' }: { items: AssetCardData[]; columns?: AssetColumn[]; rank?: boolean; startRank?: number; className?: string; emptyTitle?: string; emptyDescription?: string }) {92  if (!items.length) return <EmptyState title={emptyTitle} description={emptyDescription} />;93  return (94    <Table className={className}>95      <thead>96        <tr>97          {rank ? <th className={cn(th, 'w-8 text-right')}>#</th> : null}98          <th className={th}>Asset</th>99          {columns.map((c) => (100            <th key={c} className={cn(th, 'text-right')}>101              {HEAD[c]}102            </th>103          ))}104        </tr>105      </thead>106      <tbody>107        {items.map((a, i) => (108          <tr key={a.id} className="hover:bg-sunken">109            {rank ? <td className={cn(tdNum, 'text-subtle')}>{startRank + i}</td> : null}110            <td className={cn(td, 'max-w-[320px]')}>111              <span className="flex min-w-0 items-center gap-2.5">112                <Thumb src={a.heroImageUrl} alt="" size={32} />113                <span className="min-w-0">114                  <Link href={`/asset/${a.slug}`} className="block truncate font-medium text-fg hover:underline" title={a.title}>115                    {a.title}116                  </Link>117                  <span className="block truncate text-[11px] text-muted">118                    {catName(a.categorySlug)}119                    {a.setName ? ` · ${a.setName}` : ''}120                    {a.year ? ` · ${a.year}` : ''}121                  </span>122                </span>123              </span>124            </td>125            {columns.map((c) => (126              <td key={c} className={tdNum}>127                {cell(a, c)}128              </td>129            ))}130          </tr>131        ))}132      </tbody>133    </Table>134  );135}136137/** Compact card for mobile grids and discovery rails. */138export function AssetCard({ a, className, metric = 'change30d' }: { a: AssetCardData; className?: string; metric?: 'change30d' | 'change7d' | 'change1d' | 'latestSale' | 'opportunity' | 'trending' }) {139  const metricNode =140    metric === 'latestSale' ? (141      <span className="text-[11px] text-muted">{a.latestSaleUsd === null ? (a.number ? `#${a.number}` : a.setName ?? '') : `Last sale ${fmtMoney(a.latestSaleUsd)}`}</span>142    ) : metric === 'opportunity' ? (143      <span className="text-[11px] text-muted">144        Opportunity <Delta value={a.valueOpportunity === null ? null : -a.valueOpportunity} className="text-[11px]" />145      </span>146    ) : metric === 'trending' ? (147      <span className="text-[11px] text-muted">Trending {a.trendingScore === null ? '—' : a.trendingScore.toFixed(1)}</span>148    ) : (149      <span className="text-[11px] text-muted">150        {metric === 'change1d' ? '1D' : metric === 'change7d' ? '7D' : '30D'} <Delta value={metric === 'change1d' ? a.change1d : metric === 'change7d' ? a.change7d : a.change30d} className="text-[11px]" />151      </span>152    );153  return (154    <Link href={`/asset/${a.slug}`} className={cn('card flex gap-3 p-3 transition-colors hover:border-border-strong', className)}>155      <Thumb src={a.heroImageUrl} alt="" size={56} rounded="rounded-md" />156      <span className="flex min-w-0 flex-1 flex-col">157        <span className="truncate text-[13px] font-medium text-fg" title={a.title}>158          {a.title}159        </span>160        <span className="truncate text-[11px] text-muted">161          {catName(a.categorySlug)}162          {a.setName ? ` · ${a.setName}` : ''}163          {a.year ? ` · ${a.year}` : ''}164        </span>165        <span className="mt-auto flex items-baseline justify-between gap-2 pt-1">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)}168          </span>169          {metricNode}170        </span>171      </span>172    </Link>173  );174}175176export function AssetCardGrid({ items, metric, className }: { items: AssetCardData[]; metric?: Parameters<typeof AssetCard>[0]['metric']; className?: string }) {177  if (!items.length) return null;178  return (179    <div className={cn('grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', className)}>180      {items.map((a) => (181        <AssetCard key={a.id} a={a} metric={metric} />182      ))}183    </div>184  );185}186187/** Responsive list: cards on small screens, table from md up. */188export function AssetList(props: Parameters<typeof AssetTable>[0] & { metric?: Parameters<typeof AssetCard>[0]['metric']; forceCards?: boolean }) {189  if (!props.items.length) return <AssetTable {...props} />;190  if (props.forceCards) return <AssetCardGrid items={props.items} metric={props.metric} />;191  return (192    <>193      <div className="md:hidden">194        <AssetCardGrid items={props.items} metric={props.metric} />195      </div>196      <div className="hidden md:block">197        <AssetTable {...props} />198      </div>199    </>200  );201}202
Files · d93392862678cbf30e175697fcb812bc4ff0b5a7
Menu

Theme