SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
9.8 KB · 203 lines tsx
Raw Blame History
1import Link from 'next/link';2import type { LotRow } from '@/lib/queries/market-lists';3import { cn, confidenceLabel, fmtMoney, fmtNum, fmtRelative } from '@/lib/format';4import { Table, th, td, tdNum, EmptyState, Badge, VsRiv } from '@/components/ui/primitives';5import { FeeBasisPill, GradeBadge, Thumb } from './bits';67/**8 * Auction intelligence table (§33–§35): the house's own estimate and bid in native currency, the9 * buyer-pays all-in cost in USD (hammer + buyer's premium from the house schedule) and the gated10 * comparison with the RareIndex Valuation. Lots the worker has not assessed yet say so.11 */12export function LotsIntelTable({ items, className, showHouse = true, emptyTitle = 'No auction lots', emptyDescription = 'Lots appear once auction-house connectors publish catalogues.' }: { items: LotRow[]; className?: string; showHouse?: boolean; emptyTitle?: string; emptyDescription?: string }) {13  if (!items.length) return <EmptyState title={emptyTitle} description={emptyDescription} />;14  return (15    <Table className={className}>16      <thead>17        <tr>18          <th className={th}>Lot</th>19          {showHouse ? <th className={th}>House · auction</th> : null}20          <th className={cn(th, 'text-right')}>Estimate</th>21          <th className={cn(th, 'text-right')}>Current bid</th>22          <th className={cn(th, 'text-right')} title="Buyer-pays cost in USD: hammer + buyer's premium from the house's published or estimated schedule. VAT/duties/shipping not included.">23            All-in est.24          </th>25          <th className={cn(th, 'text-right')}>RIV</th>26          <th className={cn(th, 'text-right')} title="(all-in bid − RIV) / RIV of the lot's variant. Falls back to the low estimate (“est.”) when there is no bid. Same gates as listings.">27            Bid vs RIV28          </th>29          <th className={th}>Ends</th>30        </tr>31      </thead>32      <tbody>33        {items.map((l) => (34          <LotIntelRow key={l.id} l={l} showHouse={showHouse} />35        ))}36      </tbody>37    </Table>38  );39}4041function LotIntelRow({ l, showHouse }: { l: LotRow; showHouse: boolean }) {42  const cur = l.currency ?? 'USD';43  const hasBid = l.currentBid !== null && (l.bidCount ?? 1) > 0;44  const approx = l.feeBasis === 'added_approximate' || l.feeBasis === 'added_default';45  const allIn = l.hammerPriceUsd ?? l.allInBidUsd ?? l.allInEstimateLowUsd;46  const allInIsEstimate = l.hammerPriceUsd === null && l.allInBidUsd === null && l.allInEstimateLowUsd !== null;47  const vs = l.bidVsRiv ?? l.estimateVsRiv;48  const vsIsEstimate = l.bidVsRiv === null && l.estimateVsRiv !== null;49  return (50    <tr className="hover:bg-sunken">51      <td className={cn(td, 'max-w-[360px]')}>52        <span className="flex items-center gap-2">53          <Thumb src={l.imageUrls[0]} alt="" size={32} />54          <span className="min-w-0">55            <a href={l.url} target="_blank" rel="noopener nofollow" className="block truncate font-medium text-fg hover:underline" title={l.title}>56              {l.lotNumber ? `Lot ${l.lotNumber} · ` : ''}57              {l.title}58            </a>59            <span className="flex flex-wrap items-center gap-x-1.5 text-[11px] text-muted">60              {l.assetSlug ? (61                <Link href={`/asset/${l.assetSlug}`} className="truncate hover:text-fg">62                  {l.assetTitle}63                </Link>64              ) : (65                <span className="text-subtle">Not yet matched to a canonical asset</span>66              )}67              <GradeBadge grader={l.grader} grade={l.grade} />68            </span>69          </span>70        </span>71      </td>72      {showHouse ? (73        <td className={cn(td, 'text-muted')}>74          {l.auctionHouseSlug ? (75            <Link href={`/auctions/house/${l.auctionHouseSlug}`} className="hover:text-fg">76              {l.auctionHouse}77            </Link>78          ) : (79            l.auctionHouse80          )}81          {l.auctionName ? <span className="block max-w-[200px] truncate text-[10px] text-subtle">{l.auctionName}</span> : null}82        </td>83      ) : null}84      <td className={tdNum}>85        {l.estimateLow !== null || l.estimateHigh !== null ? (86          <span className="inline-flex flex-col items-end leading-tight">87            <span>88              {fmtMoney(l.estimateLow, cur)} – {fmtMoney(l.estimateHigh, cur)}89            </span>90            {cur !== 'USD' && (l.estimateLowUsd !== null || l.estimateHighUsd !== null) ? <span className="text-[10px] text-subtle">{fmtMoney(l.estimateLowUsd)} – {fmtMoney(l.estimateHighUsd)}</span> : null}91          </span>92        ) : (93          <span className="text-subtle">—</span>94        )}95      </td>96      <td className={tdNum}>97        {l.hammerPrice !== null ? (98          <span className="inline-flex flex-col items-end leading-tight">99            <span className="font-medium">{fmtMoney(l.hammerPrice, cur)}</span>100            <span className="text-[10px] text-subtle">hammer</span>101          </span>102        ) : hasBid ? (103          <span className="inline-flex flex-col items-end leading-tight">104            <span>{fmtMoney(l.currentBid, cur)}</span>105            <span className="text-[10px] text-subtle">{l.bidCount !== null ? `${fmtNum(l.bidCount)} bid${l.bidCount === 1 ? '' : 's'}` : 'bid'}</span>106          </span>107        ) : l.currentBid !== null ? (108          <span className="inline-flex flex-col items-end leading-tight text-muted" title="Opening price — no bid placed yet">109            <span>{fmtMoney(l.currentBid, cur)}</span>110            <span className="text-[10px] text-subtle">opening · 0 bids</span>111          </span>112        ) : (113          <span className="text-subtle">—</span>114        )}115      </td>116      <td className={tdNum}>117        {allIn !== null ? (118          <span className="inline-flex flex-col items-end leading-tight">119            <span className="font-medium text-fg">120              {approx ? '≈ ' : ''}121              {fmtMoney(allIn)}122            </span>123            <span className="flex items-center gap-1 text-[10px] text-subtle">124              {allInIsEstimate ? <span>on low est.</span> : null}125              <FeeBasisPill basis={l.feeBasis} rate={l.buyerPremiumRate} className="text-[9px]" />126            </span>127          </span>128        ) : l.assessedAt ? (129          <span className="text-subtle" title="No price to assess (no bid and no estimate)">—</span>130        ) : (131          <span className="text-[10px] text-subtle">Not assessed yet</span>132        )}133      </td>134      <td className={tdNum}>135        {l.rivUsd !== null ? (136          <span className="inline-flex flex-col items-end leading-tight">137            <span>{fmtMoney(l.rivUsd)}</span>138            <span className="text-[10px] text-subtle">139              {confidenceLabel(l.rivConfidence)} · n={l.rivSampleSize}140            </span>141          </span>142        ) : (143          <span className="text-subtle">—</span>144        )}145      </td>146      <td className={tdNum}>147        {vs !== null ? (148          <span className="inline-flex flex-col items-end leading-tight">149            <VsRiv discount={vs} showLabel={false} />150            {vsIsEstimate ? <span className="text-[10px] text-subtle">est. vs RIV</span> : null}151          </span>152        ) : l.assessedAt && l.assessmentVerdict === 'ungated' ? (153          <span className="text-subtle" title="Not compared: the valuation does not meet the gates (transaction-based RIV, ≥ 5 sales, medium+ confidence, same variant)">—</span>154        ) : l.assessedAt && l.assessmentVerdict === 'anomaly' ? (155          <Badge tone="alert">Data/identity anomaly</Badge>156        ) : l.assessedAt ? (157          <span className="text-subtle">—</span>158        ) : (159          <span className="text-[10px] text-subtle">Not assessed yet</span>160        )}161      </td>162      <td className={cn(td, 'whitespace-nowrap text-muted')}>163        {l.endsAt ? <span title={l.endsAt.toISOString()}>{fmtRelative(l.endsAt)}</span> : '—'}164        <Badge tone={l.status === 'live' ? 'gain' : l.status === 'upcoming' ? 'index' : 'neutral'} className="ml-1.5">165          {l.status}166        </Badge>167      </td>168    </tr>169  );170}171172/** Card list of lots for phones (image, title, house, all-in, bid vs RIV, ends). */173export function LotsIntelCards({ items, className }: { items: LotRow[]; className?: string }) {174  if (!items.length) return null;175  return (176    <ul className={cn('divide-y divide-border', className)}>177      {items.map((l) => {178        const allIn = l.hammerPriceUsd ?? l.allInBidUsd ?? l.allInEstimateLowUsd;179        const vs = l.bidVsRiv ?? l.estimateVsRiv;180        const approx = l.feeBasis === 'added_approximate' || l.feeBasis === 'added_default';181        return (182          <li key={l.id}>183            <a href={l.url} target="_blank" rel="noopener nofollow" className="flex min-h-[64px] items-center gap-3 px-3 py-2.5 hover:bg-sunken">184              <Thumb src={l.imageUrls[0]} alt="" size={44} rounded="rounded-[5px]" />185              <span className="min-w-0 flex-1">186                <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{l.title}</span>187                <span className="mt-0.5 block truncate text-[11px] text-muted">188                  {l.auctionHouse}189                  {l.endsAt ? ` · ends ${fmtRelative(l.endsAt)}` : ''}190                </span>191              </span>192              <span className="num flex shrink-0 flex-col items-end leading-tight">193                <span className="text-[14px] font-semibold text-fg">{allIn !== null ? `${approx ? '≈ ' : ''}${fmtMoney(allIn)}` : l.currentBid !== null ? fmtMoney(l.currentBid, l.currency ?? 'USD') : l.estimateHigh !== null ? `est. ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</span>194                {vs !== null ? <VsRiv discount={vs} className="text-[11px]" showLabel={false} /> : <span className="text-[10px] text-subtle">{allIn !== null ? 'all-in' : 'not assessed'}</span>}195              </span>196            </a>197          </li>198        );199      })}200    </ul>201  );202}203