SPB Git

spb/rareindex

Public
9.5 KB · 194 lines tsx
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
Files · d93392862678cbf30e175697fcb812bc4ff0b5a7
Menu

Theme