1import type { SaleRow, ListingRow } from '@/lib/queries/assets';2import type { LotRow } from '@/lib/queries/market-lists';3import { fmtDate, fmtMoney, fmtRelative, fmtPct, cn } from '@/lib/format';4import { humanize } from '@/lib/taxonomy';5import { Table, th, td, tdNum, EmptyState, Badge, Delta } from '@/components/ui/primitives';6import { AssetTitleCell, GradeBadge, PriceCell, SourceLink, Thumb } from './bits';78export function SalesTable({ items, showAsset = true, className, emptyDescription = 'Sales appear once connectors ingest verified transactions.' }: { items: SaleRow[]; showAsset?: boolean; className?: string; emptyDescription?: string }) {9 if (!items.length) return <EmptyState title="No sales recorded" description={emptyDescription} />;10 return (11 <Table className={className}>12 <thead>13 <tr>14 <th className={th}>Date</th>15 {showAsset ? <th className={th}>Asset</th> : <th className={th}>Listing title</th>}16 <th className={th}>Grade</th>17 <th className={cn(th, 'text-right')}>Price</th>18 <th className={th}>Type</th>19 <th className={th}>Source</th>20 <th className={th}>Quality</th>21 </tr>22 </thead>23 <tbody>24 {items.map((s) => (25 <tr key={s.id} className="hover:bg-sunken">26 <td className={cn(td, 'text-muted')} title={s.saleDate.toISOString()}>27 {fmtDate(s.saleDate)}28 </td>29 <td className={cn(td, 'max-w-[360px]')}>30 {showAsset && s.assetSlug ? (31 <AssetTitleCell slug={s.assetSlug} title={s.assetTitle ?? s.rawTitle} categorySlug={s.categorySlug} image={s.heroImageUrl ?? s.imageUrls[0]} sub={s.rawTitle !== s.assetTitle ? s.rawTitle : null} size={32} />32 ) : (33 <span className="flex items-center gap-2">34 <Thumb src={s.imageUrls[0]} alt="" size={28} />35 <span className="truncate text-muted" title={s.rawTitle}>36 {s.rawTitle}37 </span>38 </span>39 )}40 </td>41 <td className={td}>42 <GradeBadge grader={s.grader} grade={s.grade} />43 {s.condition ? <span className="ml-1 text-[11px] text-muted">{humanize(s.condition)}</span> : null}44 </td>45 <td className={tdNum}>46 <PriceCell usd={s.priceUsd} native={s.price} currency={s.currency} />47 </td>48 <td className={cn(td, 'text-muted')}>49 {humanize(s.saleType)}50 {s.auctionHouse ? <span className="block text-[10px] text-subtle">{s.auctionHouse}</span> : null}51 </td>52 <td className={td}>53 <SourceLink name={s.sourceName} url={s.sourceUrl} />54 </td>55 <td className={td}>56 {s.status !== 'valid' ? <Badge tone="alert">{s.status}</Badge> : <span className="num text-[11px] text-muted">{Math.round(s.confidence * 100)}%</span>}57 {s.flags.length ? <span className="ml-1 text-[10px] text-subtle" title={s.flags.join(', ')}>⚑</span> : null}58 </td>59 </tr>60 ))}61 </tbody>62 </Table>63 );64}6566export function ListingsTable({ items, showAsset = true, className, emptyDescription = 'Live listings appear once marketplace connectors run.' }: { items: ListingRow[]; showAsset?: boolean; className?: string; emptyDescription?: string }) {67 if (!items.length) return <EmptyState title="No active listings" description={emptyDescription} />;68 return (69 <Table className={className}>70 <thead>71 <tr>72 {showAsset ? <th className={th}>Asset</th> : <th className={th}>Listing</th>}73 <th className={th}>Grade</th>74 <th className={cn(th, 'text-right')}>Ask</th>75 <th className={cn(th, 'text-right')} title="Discount of the asking price to the RareIndex Valuation. Analytical data, not advice.">76 vs RIV77 </th>78 <th className={th}>Type</th>79 <th className={th}>Seller · location</th>80 <th className={th}>Source</th>81 <th className={th}>Seen</th>82 </tr>83 </thead>84 <tbody>85 {items.map((l) => (86 <tr key={l.id} className="hover:bg-sunken">87 <td className={cn(td, 'max-w-[360px]')}>88 {showAsset && l.assetSlug ? (89 <AssetTitleCell slug={l.assetSlug} title={l.assetTitle ?? l.rawTitle} categorySlug={l.categorySlug} image={l.imageUrls[0]} sub={l.rawTitle !== l.assetTitle ? l.rawTitle : null} size={32} />90 ) : (91 <span className="flex items-center gap-2">92 <Thumb src={l.imageUrls[0]} alt="" size={28} />93 <span className="truncate text-muted" title={l.rawTitle}>94 {l.rawTitle}95 </span>96 </span>97 )}98 </td>99 <td className={td}>100 <GradeBadge grader={l.grader} grade={l.grade} />101 {l.condition ? <span className="ml-1 text-[11px] text-muted">{humanize(l.condition)}</span> : null}102 </td>103 <td className={tdNum}>104 <PriceCell usd={l.priceUsd} native={l.price} currency={l.currency} />105 {l.shippingCost ? <span className="block text-[10px] text-subtle">+{fmtMoney(l.shippingCost, l.currency ?? 'USD')} ship</span> : null}106 </td>107 <td className={tdNum}>108 {l.discountToRiv === null ? (109 <span className="text-subtle" title="No valuation to compare against">—</span>110 ) : (111 <span className="inline-flex flex-col items-end leading-tight">112 <Delta value={-l.discountToRiv} />113 {l.assetRivUsd !== undefined && l.assetRivUsd !== null ? <span className="text-[10px] text-subtle">RIV {fmtMoney(l.assetRivUsd)}</span> : null}114 </span>115 )}116 </td>117 <td className={cn(td, 'text-muted')}>118 {humanize(l.listingType)}119 {l.bidCount !== null ? <span className="block text-[10px] text-subtle">{l.bidCount} bids</span> : null}120 {l.endsAt ? <span className="block text-[10px] text-subtle">ends {fmtRelative(l.endsAt)}</span> : null}121 </td>122 <td className={cn(td, 'text-muted')}>123 {l.seller ?? '—'}124 {l.location ? <span className="block text-[10px] text-subtle">{l.location}</span> : null}125 </td>126 <td className={td}>127 <SourceLink name={l.sourceName} url={l.sourceUrl} />128 </td>129 <td className={cn(td, 'text-muted')}>{fmtRelative(l.lastSeenAt)}</td>130 </tr>131 ))}132 </tbody>133 </Table>134 );135}136137export function LotsTable({ items, className, emptyDescription = 'Auction lots appear once auction-house connectors run.' }: { items: LotRow[]; className?: string; emptyDescription?: string }) {138 if (!items.length) return <EmptyState title="No auction lots" description={emptyDescription} />;139 return (140 <Table className={className}>141 <thead>142 <tr>143 <th className={th}>Lot</th>144 <th className={th}>Auction</th>145 <th className={cn(th, 'text-right')}>Estimate</th>146 <th className={cn(th, 'text-right')}>Bid / hammer</th>147 <th className={cn(th, 'text-right')}>RIV</th>148 <th className={th}>Ends</th>149 <th className={th}>Status</th>150 </tr>151 </thead>152 <tbody>153 {items.map((l) => (154 <tr key={l.id} className="hover:bg-sunken">155 <td className={cn(td, 'max-w-[360px]')}>156 <span className="flex items-center gap-2">157 <Thumb src={l.imageUrls[0]} alt="" size={32} />158 <span className="min-w-0">159 <a href={l.url} target="_blank" rel="noopener nofollow" className="block truncate font-medium text-fg hover:underline" title={l.title}>160 {l.lotNumber ? `Lot ${l.lotNumber} · ` : ''}161 {l.title}162 </a>163 {l.assetSlug ? (164 <a href={`/asset/${l.assetSlug}`} className="block truncate text-[11px] text-muted hover:text-fg">165 {l.assetTitle}166 </a>167 ) : (168 <span className="block text-[11px] text-subtle">Not yet matched to a canonical asset</span>169 )}170 </span>171 </span>172 </td>173 <td className={cn(td, 'text-muted')}>174 {l.auctionHouse}175 {l.auctionName ? <span className="block max-w-[200px] truncate text-[10px] text-subtle">{l.auctionName}</span> : null}176 </td>177 <td className={tdNum}>{l.estimateLow !== null || l.estimateHigh !== null ? `${fmtMoney(l.estimateLow, l.currency ?? 'USD')} – ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</td>178 <td className={tdNum}>{l.hammerPrice !== null ? <span className="font-medium">{fmtMoney(l.hammerPrice, l.currency ?? 'USD')}</span> : l.currentBid !== null ? fmtMoney(l.currentBid, l.currency ?? 'USD') : '—'}</td>179 <td className={tdNum}>{fmtMoney(l.rivUsd)}</td>180 <td className={cn(td, 'text-muted')}>{l.endsAt ? `${fmtDate(l.endsAt, { time: true })} (${fmtRelative(l.endsAt)})` : '—'}</td>181 <td className={td}>182 <Badge tone={l.status === 'live' ? 'gain' : l.status === 'upcoming' ? 'index' : 'neutral'}>{l.status}</Badge>183 </td>184 </tr>185 ))}186 </tbody>187 </Table>188 );189}190191export function pctLabel(v: number | null): string {192 return fmtPct(v);193}194