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%
14.3 KB · 284 lines tsx
Raw Blame History
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