TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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, VsRiv } from '@/components/ui/primitives';6import { AllInCell, AssetTitleCell, FeeBasisPill, GradeBadge, PriceCell, SourceLink, Thumb, VerificationBadge } 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">{s.allInUsd !== null && Math.abs(s.allInUsd - s.priceUsd) >= 0.5 ? `${s.feeBasis === 'added_approximate' || s.feeBasis === 'added_default' ? '≈ ' : ''}${fmtMoney(s.allInUsd)}` : fmtMoney(s.priceUsd)}</span>26 {s.allInUsd !== null && Math.abs(s.allInUsd - s.priceUsd) >= 0.5 ? <span className="text-[10px] text-subtle">{fmtMoney(s.priceUsd)} hammer</span> : 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 {s.allInUsd !== null && Math.abs(s.allInUsd - s.priceUsd) >= 0.5 ? (86 <span className="inline-flex flex-col items-end leading-tight">87 <AllInCell allInUsd={s.allInUsd} priceUsd={s.priceUsd} basis={s.feeBasis} rate={s.buyerPremiumRate} />88 <span className="text-[10px] text-subtle" title="Recorded (hammer) price">89 {fmtMoney(s.priceUsd)} hammer{s.currency !== 'USD' ? ` · ${fmtMoney(s.price, s.currency)}` : ''}90 </span>91 </span>92 ) : (93 <span className="inline-flex flex-col items-end leading-tight">94 <PriceCell usd={s.priceUsd} native={s.price} currency={s.currency} />95 {s.feeBasis && s.feeBasis !== 'none' ? <FeeBasisPill basis={s.feeBasis} rate={s.buyerPremiumRate} className="mt-0.5 text-[9px]" /> : null}96 </span>97 )}98 </td>99 <td className={cn(td, 'text-muted')}>100 {humanize(s.saleType)}101 {s.auctionHouse ? <span className="block text-[10px] text-subtle">{s.auctionHouse}</span> : null}102 </td>103 <td className={td}>104 <SourceLink name={s.sourceName} url={s.sourceUrl} />105 </td>106 <td className={td}>107 <VerificationBadge label={s.verification} reason={s.verificationReason} />108 <span className="num ml-1 text-[11px] text-muted">{Math.round(s.confidence * 100)}%</span>109 {s.flags.length ? <span className="ml-1 text-[10px] text-subtle" title={s.flags.join(', ')}>⚑</span> : null}110 </td>111 </tr>112 ))}113 </tbody>114 </Table>115 );116}117118export function ListingsCards({ items, className }: { items: ListingRow[]; className?: string }) {119 return (120 <ul className={cn('divide-y divide-border', className)}>121 {items.map((l) => (122 <li key={l.id}>123 <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">124 <Thumb src={l.imageUrls[0]} alt="" size={44} rounded="rounded-[5px]" />125 <span className="min-w-0 flex-1">126 <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{l.assetTitle ?? l.rawTitle}</span>127 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted">128 <GradeBadge grader={l.grader} grade={l.grade} />129 <span>{l.sourceName}</span>130 {l.location ? <span>· {l.location}</span> : null}131 </span>132 </span>133 <span className="num flex shrink-0 flex-col items-end leading-tight">134 <span className="text-[14px] font-semibold text-fg">{fmtMoney(l.priceUsd)}</span>135 {l.discountToRiv !== null ? <VsRiv discount={l.discountToRiv} className="text-[11px]" showLabel={false} /> : <span className="text-[10px] text-subtle">{humanize(l.listingType)}</span>}136 </span>137 </a>138 </li>139 ))}140 </ul>141 );142}143144export 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 }) {145 if (!items.length) return <EmptyState title="No active listings" description={emptyDescription} />;146 if (responsive) {147 return (148 <>149 <div className="md:hidden">150 <ListingsCards items={items} />151 </div>152 <div className="hidden md:block">153 <ListingsTable items={items} showAsset={showAsset} className={className} responsive={false} />154 </div>155 </>156 );157 }158 return (159 <Table className={className}>160 <thead>161 <tr>162 {showAsset ? <th className={th}>Asset</th> : <th className={th}>Listing</th>}163 <th className={th}>Grade</th>164 <th className={cn(th, 'text-right')}>Ask</th>165 <th className={cn(th, 'text-right')} title="Discount of the asking price to the RareIndex Valuation. Analytical data, not advice.">166 vs RIV167 </th>168 <th className={th}>Type</th>169 <th className={th}>Seller · location</th>170 <th className={th}>Source</th>171 <th className={th}>Seen</th>172 </tr>173 </thead>174 <tbody>175 {items.map((l) => (176 <tr key={l.id} className="hover:bg-sunken">177 <td className={cn(td, 'max-w-[360px]')}>178 {showAsset && l.assetSlug ? (179 <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} />180 ) : (181 <span className="flex items-center gap-2">182 <Thumb src={l.imageUrls[0]} alt="" size={28} />183 <span className="truncate text-muted" title={l.rawTitle}>184 {l.rawTitle}185 </span>186 </span>187 )}188 </td>189 <td className={td}>190 <GradeBadge grader={l.grader} grade={l.grade} />191 {l.condition ? <span className="ml-1 text-[11px] text-muted">{humanize(l.condition)}</span> : null}192 </td>193 <td className={tdNum}>194 <PriceCell usd={l.priceUsd} native={l.price} currency={l.currency} />195 {l.shippingCost ? <span className="block text-[10px] text-subtle">+{fmtMoney(l.shippingCost, l.currency ?? 'USD')} ship</span> : null}196 </td>197 <td className={tdNum}>198 {l.discountToRiv === null ? (199 <span className="text-subtle" title="No valuation to compare against">—</span>200 ) : (201 <span className="inline-flex flex-col items-end leading-tight">202 <VsRiv discount={l.discountToRiv} showLabel={false} />203 {l.assetRivUsd !== undefined && l.assetRivUsd !== null ? <span className="text-[10px] text-subtle">RIV {fmtMoney(l.assetRivUsd)}</span> : null}204 </span>205 )}206 </td>207 <td className={cn(td, 'text-muted')}>208 {humanize(l.listingType)}209 {l.bidCount !== null ? <span className="block text-[10px] text-subtle">{l.bidCount} bids</span> : null}210 {l.endsAt ? <span className="block text-[10px] text-subtle">ends {fmtRelative(l.endsAt)}</span> : null}211 </td>212 <td className={cn(td, 'text-muted')}>213 {l.seller ?? '—'}214 {l.location ? <span className="block text-[10px] text-subtle">{l.location}</span> : null}215 </td>216 <td className={td}>217 <SourceLink name={l.sourceName} url={l.sourceUrl} />218 </td>219 <td className={cn(td, 'text-muted')}>{fmtRelative(l.lastSeenAt)}</td>220 </tr>221 ))}222 </tbody>223 </Table>224 );225}226227export function LotsTable({ items, className, emptyDescription = 'Auction lots appear once auction-house connectors run.' }: { items: LotRow[]; className?: string; emptyDescription?: string }) {228 if (!items.length) return <EmptyState title="No auction lots" description={emptyDescription} />;229 return (230 <Table className={className}>231 <thead>232 <tr>233 <th className={th}>Lot</th>234 <th className={th}>Auction</th>235 <th className={cn(th, 'text-right')}>Estimate</th>236 <th className={cn(th, 'text-right')}>Bid / hammer</th>237 <th className={cn(th, 'text-right')}>RIV</th>238 <th className={th}>Ends</th>239 <th className={th}>Status</th>240 </tr>241 </thead>242 <tbody>243 {items.map((l) => (244 <tr key={l.id} className="hover:bg-sunken">245 <td className={cn(td, 'max-w-[360px]')}>246 <span className="flex items-center gap-2">247 <Thumb src={l.imageUrls[0]} alt="" size={32} />248 <span className="min-w-0">249 <a href={l.url} target="_blank" rel="noopener nofollow" className="block truncate font-medium text-fg hover:underline" title={l.title}>250 {l.lotNumber ? `Lot ${l.lotNumber} · ` : ''}251 {l.title}252 </a>253 {l.assetSlug ? (254 <a href={`/asset/${l.assetSlug}`} className="block truncate text-[11px] text-muted hover:text-fg">255 {l.assetTitle}256 </a>257 ) : (258 <span className="block text-[11px] text-subtle">Not yet matched to a canonical asset</span>259 )}260 </span>261 </span>262 </td>263 <td className={cn(td, 'text-muted')}>264 {l.auctionHouse}265 {l.auctionName ? <span className="block max-w-[200px] truncate text-[10px] text-subtle">{l.auctionName}</span> : null}266 </td>267 <td className={tdNum}>{l.estimateLow !== null || l.estimateHigh !== null ? `${fmtMoney(l.estimateLow, l.currency ?? 'USD')} – ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</td>268 <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>269 <td className={tdNum}>{fmtMoney(l.rivUsd)}</td>270 <td className={cn(td, 'text-muted')}>{l.endsAt ? `${fmtDate(l.endsAt, { time: true })} (${fmtRelative(l.endsAt)})` : '—'}</td>271 <td className={td}>272 <Badge tone={l.status === 'live' ? 'gain' : l.status === 'upcoming' ? 'index' : 'neutral'}>{l.status}</Badge>273 </td>274 </tr>275 ))}276 </tbody>277 </Table>278 );279}280281export function pctLabel(v: number | null): string {282 return fmtPct(v);283}284