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