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';78/** Card list for sales on small screens (image, title, grade, price, source). */9export function SalesCards({ items, className }: { items: SaleRow[]; className?: string }) {10 return (11 <ul className={cn('divide-y divide-border', className)}>12 {items.map((s) => (13 <li key={s.id}>14 <a href={s.assetSlug ? `/asset/${s.assetSlug}` : s.sourceUrl} className="flex min-h-[64px] items-center gap-3 px-3 py-2.5 hover:bg-sunken">15 <Thumb src={s.heroImageUrl ?? s.imageUrls[0]} alt="" size={44} rounded="rounded-[5px]" />16 <span className="min-w-0 flex-1">17 <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{s.assetTitle ?? s.rawTitle}</span>18 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted">19 <GradeBadge grader={s.grader} grade={s.grade} />20 <span>{fmtDate(s.saleDate)}</span>21 <span>· {s.sourceName}</span>22 </span>23 </span>24 <span className="num flex shrink-0 flex-col items-end leading-tight">25 <span className="text-[14px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span>26 {s.currency !== 'USD' ? <span className="text-[10px] text-subtle">{fmtMoney(s.price, s.currency)}</span> : <span className="text-[10px] text-subtle">{humanize(s.saleType)}</span>}27 </span>28 </a>29 </li>30 ))}31 </ul>32 );33}3435export function SalesTable({ items, showAsset = true, className, emptyDescription = 'Sales appear once connectors ingest verified transactions.', responsive = true }: { items: SaleRow[]; showAsset?: boolean; className?: string; emptyDescription?: string; responsive?: boolean }) {36 if (!items.length) return <EmptyState title="No sales recorded" description={emptyDescription} />;37 if (responsive) {38 return (39 <>40 <div className="md:hidden">41 <SalesCards items={items} />42 </div>43 <div className="hidden md:block">44 <SalesTable items={items} showAsset={showAsset} className={className} responsive={false} />45 </div>46 </>47 );48 }49 return (50 <Table className={className}>51 <thead>52 <tr>53 <th className={th}>Date</th>54 {showAsset ? <th className={th}>Asset</th> : <th className={th}>Listing title</th>}55 <th className={th}>Grade</th>56 <th className={cn(th, 'text-right')}>Price</th>57 <th className={th}>Type</th>58 <th className={th}>Source</th>59 <th className={th}>Quality</th>60 </tr>61 </thead>62 <tbody>63 {items.map((s) => (64 <tr key={s.id} className="hover:bg-sunken">65 <td className={cn(td, 'text-muted')} title={s.saleDate.toISOString()}>66 {fmtDate(s.saleDate)}67 </td>68 <td className={cn(td, 'max-w-[360px]')}>69 {showAsset && s.assetSlug ? (70 <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} />71 ) : (72 <span className="flex items-center gap-2">73 <Thumb src={s.imageUrls[0]} alt="" size={28} />74 <span className="truncate text-muted" title={s.rawTitle}>75 {s.rawTitle}76 </span>77 </span>78 )}79 </td>80 <td className={td}>81 <GradeBadge grader={s.grader} grade={s.grade} />82 {s.condition ? <span className="ml-1 text-[11px] text-muted">{humanize(s.condition)}</span> : null}83 </td>84 <td className={tdNum}>85 <PriceCell usd={s.priceUsd} native={s.price} currency={s.currency} />86 </td>87 <td className={cn(td, 'text-muted')}>88 {humanize(s.saleType)}89 {s.auctionHouse ? <span className="block text-[10px] text-subtle">{s.auctionHouse}</span> : null}90 </td>91 <td className={td}>92 <SourceLink name={s.sourceName} url={s.sourceUrl} />93 </td>94 <td className={td}>95 {s.status !== 'valid' ? <Badge tone="alert">{s.status}</Badge> : <span className="num text-[11px] text-muted">{Math.round(s.confidence * 100)}%</span>}96 {s.flags.length ? <span className="ml-1 text-[10px] text-subtle" title={s.flags.join(', ')}>⚑</span> : null}97 </td>98 </tr>99 ))}100 </tbody>101 </Table>102 );103}104105export function ListingsCards({ items, className }: { items: ListingRow[]; className?: string }) {106 return (107 <ul className={cn('divide-y divide-border', className)}>108 {items.map((l) => (109 <li key={l.id}>110 <a href={l.assetSlug ? `/asset/${l.assetSlug}` : l.sourceUrl} className="flex min-h-[64px] items-center gap-3 px-3 py-2.5 hover:bg-sunken">111 <Thumb src={l.imageUrls[0]} alt="" size={44} rounded="rounded-[5px]" />112 <span className="min-w-0 flex-1">113 <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{l.assetTitle ?? l.rawTitle}</span>114 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted">115 <GradeBadge grader={l.grader} grade={l.grade} />116 <span>{l.sourceName}</span>117 {l.location ? <span>· {l.location}</span> : null}118 </span>119 </span>120 <span className="num flex shrink-0 flex-col items-end leading-tight">121 <span className="text-[14px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span>122 {l.discountToRiv !== null ? <Delta value={-l.discountToRiv} className="text-[11px]" /> : <span className="text-[10px] text-subtle">{humanize(l.listingType)}</span>}123 </span>124 </a>125 </li>126 ))}127 </ul>128 );129}130131export function ListingsTable({ items, showAsset = true, className, emptyDescription = 'Live listings appear once marketplace connectors run.', responsive = true }: { items: ListingRow[]; showAsset?: boolean; className?: string; emptyDescription?: string; responsive?: boolean }) {132 if (!items.length) return <EmptyState title="No active listings" description={emptyDescription} />;133 if (responsive) {134 return (135 <>136 <div className="md:hidden">137 <ListingsCards items={items} />138 </div>139 <div className="hidden md:block">140 <ListingsTable items={items} showAsset={showAsset} className={className} responsive={false} />141 </div>142 </>143 );144 }145 return (146 <Table className={className}>147 <thead>148 <tr>149 {showAsset ? <th className={th}>Asset</th> : <th className={th}>Listing</th>}150 <th className={th}>Grade</th>151 <th className={cn(th, 'text-right')}>Ask</th>152 <th className={cn(th, 'text-right')} title="Discount of the asking price to the RareIndex Valuation. Analytical data, not advice.">153 vs RIV154 </th>155 <th className={th}>Type</th>156 <th className={th}>Seller · location</th>157 <th className={th}>Source</th>158 <th className={th}>Seen</th>159 </tr>160 </thead>161 <tbody>162 {items.map((l) => (163 <tr key={l.id} className="hover:bg-sunken">164 <td className={cn(td, 'max-w-[360px]')}>165 {showAsset && l.assetSlug ? (166 <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} />167 ) : (168 <span className="flex items-center gap-2">169 <Thumb src={l.imageUrls[0]} alt="" size={28} />170 <span className="truncate text-muted" title={l.rawTitle}>171 {l.rawTitle}172 </span>173 </span>174 )}175 </td>176 <td className={td}>177 <GradeBadge grader={l.grader} grade={l.grade} />178 {l.condition ? <span className="ml-1 text-[11px] text-muted">{humanize(l.condition)}</span> : null}179 </td>180 <td className={tdNum}>181 <PriceCell usd={l.priceUsd} native={l.price} currency={l.currency} />182 {l.shippingCost ? <span className="block text-[10px] text-subtle">+{fmtMoney(l.shippingCost, l.currency ?? 'USD')} ship</span> : null}183 </td>184 <td className={tdNum}>185 {l.discountToRiv === null ? (186 <span className="text-subtle" title="No valuation to compare against">—</span>187 ) : (188 <span className="inline-flex flex-col items-end leading-tight">189 <Delta value={-l.discountToRiv} />190 {l.assetRivUsd !== undefined && l.assetRivUsd !== null ? <span className="text-[10px] text-subtle">RIV {fmtMoney(l.assetRivUsd)}</span> : null}191 </span>192 )}193 </td>194 <td className={cn(td, 'text-muted')}>195 {humanize(l.listingType)}196 {l.bidCount !== null ? <span className="block text-[10px] text-subtle">{l.bidCount} bids</span> : null}197 {l.endsAt ? <span className="block text-[10px] text-subtle">ends {fmtRelative(l.endsAt)}</span> : null}198 </td>199 <td className={cn(td, 'text-muted')}>200 {l.seller ?? '—'}201 {l.location ? <span className="block text-[10px] text-subtle">{l.location}</span> : null}202 </td>203 <td className={td}>204 <SourceLink name={l.sourceName} url={l.sourceUrl} />205 </td>206 <td className={cn(td, 'text-muted')}>{fmtRelative(l.lastSeenAt)}</td>207 </tr>208 ))}209 </tbody>210 </Table>211 );212}213214export function LotsTable({ items, className, emptyDescription = 'Auction lots appear once auction-house connectors run.' }: { items: LotRow[]; className?: string; emptyDescription?: string }) {215 if (!items.length) return <EmptyState title="No auction lots" description={emptyDescription} />;216 return (217 <Table className={className}>218 <thead>219 <tr>220 <th className={th}>Lot</th>221 <th className={th}>Auction</th>222 <th className={cn(th, 'text-right')}>Estimate</th>223 <th className={cn(th, 'text-right')}>Bid / hammer</th>224 <th className={cn(th, 'text-right')}>RIV</th>225 <th className={th}>Ends</th>226 <th className={th}>Status</th>227 </tr>228 </thead>229 <tbody>230 {items.map((l) => (231 <tr key={l.id} className="hover:bg-sunken">232 <td className={cn(td, 'max-w-[360px]')}>233 <span className="flex items-center gap-2">234 <Thumb src={l.imageUrls[0]} alt="" size={32} />235 <span className="min-w-0">236 <a href={l.url} target="_blank" rel="noopener nofollow" className="block truncate font-medium text-fg hover:underline" title={l.title}>237 {l.lotNumber ? `Lot ${l.lotNumber} · ` : ''}238 {l.title}239 </a>240 {l.assetSlug ? (241 <a href={`/asset/${l.assetSlug}`} className="block truncate text-[11px] text-muted hover:text-fg">242 {l.assetTitle}243 </a>244 ) : (245 <span className="block text-[11px] text-subtle">Not yet matched to a canonical asset</span>246 )}247 </span>248 </span>249 </td>250 <td className={cn(td, 'text-muted')}>251 {l.auctionHouse}252 {l.auctionName ? <span className="block max-w-[200px] truncate text-[10px] text-subtle">{l.auctionName}</span> : null}253 </td>254 <td className={tdNum}>{l.estimateLow !== null || l.estimateHigh !== null ? `${fmtMoney(l.estimateLow, l.currency ?? 'USD')} – ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</td>255 <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>256 <td className={tdNum}>{fmtMoney(l.rivUsd)}</td>257 <td className={cn(td, 'text-muted')}>{l.endsAt ? `${fmtDate(l.endsAt, { time: true })} (${fmtRelative(l.endsAt)})` : '—'}</td>258 <td className={td}>259 <Badge tone={l.status === 'live' ? 'gain' : l.status === 'upcoming' ? 'index' : 'neutral'}>{l.status}</Badge>260 </td>261 </tr>262 ))}263 </tbody>264 </Table>265 );266}267268export function pctLabel(v: number | null): string {269 return fmtPct(v);270}271