SPB Git

spb/rareindex

Public
13.0 KB · 271 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';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
Files · f5091c0a3f32b0f99bc705c36f16b636342fbdb4
Menu

Theme