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%
13.5 KB · 292 lines tsx
Raw Blame History
1import Link from 'next/link';2import type { AssetCard as AssetCardData, SaleRow } from '@/lib/queries/assets';3import type { RadarRow, LotRow } from '@/lib/queries/market-lists';4import type { LatestSale } from '@/lib/queries/site';5import type { MarketRow } from '@/lib/queries/markets';6import { fmtMoney, fmtRelative, fmtNum, cn, fmtDate } from '@/lib/format';7import { catName, humanize } from '@/lib/taxonomy';8import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge, VsRiv } from '@/components/ui/primitives';9import { GradeBadge, SourceLink, Thumb } from '@/components/market/bits';10import { AssetTile, Rail, RailHeader, type TileMetric } from '@/components/market/asset-tile';11import { heatStyle } from '@/components/charts/heat';1213const EMPTY = 'Populates as the pipeline ingests data.';1415/** Editorial rail of poster tiles (snap-scroll on mobile, grid from lg). */16export function AssetRail({ title, subtitle, href, items, metric, empty = EMPTY, priority = false }: { title: string; subtitle?: string; href: string; items: AssetCardData[]; metric: TileMetric; empty?: string; priority?: boolean }) {17  return (18    <section>19      <RailHeader title={title} subtitle={subtitle} href={href} />20      {items.length ? (21        <Rail>22          {items.slice(0, 8).map((a, i) => (23            <AssetTile key={a.id} a={a} metric={metric} priority={priority && i < 4} />24          ))}25        </Rail>26      ) : (27        <Card>28          <EmptyState title="Nothing to show yet" description={empty} compact />29        </Card>30      )}31    </section>32  );33}3435function SaleTile({ s }: { s: SaleRow }) {36  return (37    <Link href={s.assetSlug ? `/asset/${s.assetSlug}` : '/sales'} className="card card-hover group flex w-[220px] flex-col gap-2 p-2 sm:w-[240px]">38      <span className="flex items-start gap-2.5">39        <Thumb src={s.heroImageUrl ?? s.imageUrls[0]} alt="" size={56} rounded="rounded-[5px]" />40        <span className="min-w-0 flex-1">41          <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg" title={s.assetTitle ?? s.rawTitle}>42            {s.assetTitle ?? s.rawTitle}43          </span>44          <span className="mt-0.5 block truncate text-[11px] text-muted">{catName(s.categorySlug)}</span>45        </span>46      </span>47      <span className="flex items-end justify-between gap-2 border-t border-border pt-2">48        <span className="flex flex-col leading-tight">49          <span className="num text-[15px] font-semibold text-fg">{fmtMoney(s.priceUsd)}</span>50          <span className="t-caption">51            {fmtDate(s.saleDate)} · {s.sourceName}52          </span>53        </span>54        <GradeBadge grader={s.grader} grade={s.grade} />55      </span>56    </Link>57  );58}5960export function SalesRail({ title, subtitle, href, items, hideWhenEmpty = false }: { title: string; subtitle?: string; href: string; items: SaleRow[]; hideWhenEmpty?: boolean }) {61  if (hideWhenEmpty && !items.length) return null;62  return (63    <section>64      <RailHeader title={title} subtitle={subtitle} href={href} />65      {items.length ? (66        <Rail cols="lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6">67          {items.slice(0, 8).map((s) => (68            <SaleTile key={s.id} s={s} />69          ))}70        </Rail>71      ) : (72        <Card>73          <EmptyState title="No verified sales yet" description="Record sales are drawn from verified transactions only (§154)." compact />74        </Card>75      )}76    </section>77  );78}7980export function RadarRail({ items, hideWhenEmpty = false }: { items: RadarRow[]; hideWhenEmpty?: boolean }) {81  if (hideWhenEmpty && !items.length) return null;82  return (83    <section>84      <RailHeader title="Rare Finds" subtitle="Rare Radar: unusual appearances and discrepancies" href="/radar" hrefLabel="Open radar" />85      {items.length ? (86        <Rail cols="lg:grid-cols-3 xl:grid-cols-4">87          {items.slice(0, 8).map((r) => (88            <Link key={r.id} href={`/asset/${r.assetSlug}`} className="card card-hover flex w-[240px] gap-2.5 p-2.5 sm:w-[260px]">89              <Thumb src={r.heroImageUrl} alt="" size={52} rounded="rounded-[5px]" />90              <span className="min-w-0 flex-1">91                <Badge tone={r.kind === 'record_sale' ? 'gold' : r.kind === 'price_discrepancy' ? 'alert' : 'rarity'}>{humanize(r.kind)}</Badge>92                <span className="mt-1 line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{r.assetTitle}</span>93                <span className="num block text-[11px] text-muted">94                  {r.rivUsd === null ? 'RIV —' : `RIV ${fmtMoney(r.rivUsd)}`} · signal {r.score.toFixed(0)} · {fmtRelative(r.detectedAt)}95                </span>96              </span>97            </Link>98          ))}99        </Rail>100      ) : (101        <Card>102          <EmptyState title="Radar is quiet" description="Findings appear when the pipeline detects first listings in years, ultra-low populations or price discrepancies." compact />103        </Card>104      )}105    </section>106  );107}108109export function LotsRail({ items, hideWhenEmpty = false }: { items: LotRow[]; hideWhenEmpty?: boolean }) {110  if (hideWhenEmpty && !items.length) return null;111  return (112    <section>113      <RailHeader title="Auctions Ending Soon" subtitle="Next 72 hours · all-in adds the buyer’s premium; bid vs RIV where assessed" href="/auctions?tab=ending" hrefLabel="All auctions" />114      {items.length ? (115        <Rail cols="lg:grid-cols-3 xl:grid-cols-4">116          {items.slice(0, 8).map((l) => (117            <a key={l.id} href={l.url} target="_blank" rel="noopener nofollow" className="card card-hover flex w-[240px] gap-2.5 p-2.5 sm:w-[260px]">118              <Thumb src={l.imageUrls[0]} alt="" size={52} rounded="rounded-[5px]" />119              <span className="min-w-0 flex-1">120                <span className="line-clamp-2 text-[13px] font-medium leading-[1.3] text-fg">{l.title}</span>121                <span className="block truncate text-[11px] text-muted">122                  {l.auctionHouse} · ends {fmtRelative(l.endsAt)}123                </span>124                <span className="num mt-1 flex items-baseline justify-between gap-2 text-[13px] font-semibold text-fg">125                  <span>{l.allInBidUsd !== null || l.allInEstimateLowUsd !== null ? `${l.feeBasis === 'added_approximate' || l.feeBasis === 'added_default' ? '≈ ' : ''}${fmtMoney(l.allInBidUsd ?? l.allInEstimateLowUsd)}` : l.currentBid !== null ? fmtMoney(l.currentBid, l.currency ?? 'USD') : l.estimateHigh !== null ? `est. ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</span>126                  {l.bidVsRiv !== null || l.estimateVsRiv !== null ? <VsRiv discount={l.bidVsRiv ?? l.estimateVsRiv} className="text-[11px]" showLabel={false} /> : <span className="text-[10px] font-normal text-subtle">{l.allInBidUsd !== null || l.allInEstimateLowUsd !== null ? 'all-in' : ''}</span>}127                </span>128              </span>129            </a>130          ))}131        </Rail>132      ) : (133        <Card>134          <EmptyState title="No lots ending soon" description="Auction lots appear once auction-house connectors are live." compact />135        </Card>136      )}137    </section>138  );139}140141export function MarketInsights({ rows }: { rows: MarketRow[] }) {142  const withData = rows.filter((r) => r.counts.assets > 0).sort((a, b) => b.counts.sales - a.counts.sales || b.counts.assets - a.counts.assets);143  return (144    <Card>145      <CardHeader title="Market Insights" subtitle="Category activity · 30-day change of the category index when published" action={<Link href="/markets" className="text-muted hover:text-fg">Markets →</Link>} />146      {withData.length ? (147        <Table sticky={false}>148          <thead>149            <tr>150              <th className={th}>Category</th>151              <th className={cn(th, 'text-right')}>Assets</th>152              <th className={cn(th, 'text-right')}>Sales</th>153              <th className={cn(th, 'text-right')}>Listings</th>154              <th className={cn(th, 'text-right')}>30D</th>155            </tr>156          </thead>157          <tbody>158            {withData.slice(0, 10).map((r) => (159              <tr key={r.node.slug} className="hover:bg-sunken">160                <td className={td}>161                  <Link href={`/markets/${r.node.slug}`} className="font-medium text-fg hover:underline">162                    {r.node.name}163                  </Link>164                </td>165                <td className={tdNum}>{fmtNum(r.counts.assets)}</td>166                <td className={tdNum}>{fmtNum(r.counts.sales)}</td>167                <td className={tdNum}>{fmtNum(r.counts.listings)}</td>168                <td className={tdNum} style={heatStyle(r.snapshot?.change30d)}>169                  <Delta value={r.snapshot?.change30d} />170                </td>171              </tr>172            ))}173          </tbody>174        </Table>175      ) : (176        <EmptyState title="No category data yet" description={EMPTY} compact />177      )}178    </Card>179  );180}181182export interface LatestObservation {183  id: string;184  assetSlug: string;185  assetTitle: string;186  categorySlug: string;187  heroImageUrl: string | null;188  priceUsd: number;189  priceKind: string;190  observationDate: string;191  sourceName: string;192}193194/** Latest transactions as a dense, attributed table; falls back to guide-price observations when no sale exists yet. */195export function LatestTransactions({ items, observations = [] }: { items: LatestSale[]; observations?: LatestObservation[] }) {196  if (!items.length && observations.length) {197    return (198      <Card>199        <CardHeader title="Latest guide price observations" subtitle="No verified sales indexed yet — price-guide observations (not transactions), with source and date" action={<Link href="/explore?has=observations" className="text-muted hover:text-fg">Browse →</Link>} />200        <Table>201          <thead>202            <tr>203              <th className={th}>Asset</th>204              <th className={th}>Kind</th>205              <th className={th}>Source</th>206              <th className={th}>Date</th>207              <th className={cn(th, 'text-right')}>Price</th>208            </tr>209          </thead>210          <tbody>211            {observations.map((o) => (212              <tr key={o.id} className="hover:bg-sunken">213                <td className={cn(td, 'max-w-[320px]')}>214                  <span className="flex items-center gap-2.5">215                    <Thumb src={o.heroImageUrl} alt="" size={32} />216                    <span className="min-w-0">217                      <Link href={`/asset/${o.assetSlug}`} className="block truncate font-medium text-fg hover:underline">218                        {o.assetTitle}219                      </Link>220                      <span className="block truncate text-[11px] text-muted">{catName(o.categorySlug)}</span>221                    </span>222                  </span>223                </td>224                <td className={cn(td, 'text-muted')}>{humanize(o.priceKind)}</td>225                <td className={cn(td, 'text-muted')}>{o.sourceName}</td>226                <td className={cn(td, 'text-muted')}>{fmtDate(o.observationDate)}</td>227                <td className={cn(tdNum, 'font-semibold text-muted')}>{fmtMoney(o.priceUsd)}</td>228              </tr>229            ))}230          </tbody>231        </Table>232      </Card>233    );234  }235  return (236    <Card>237      <CardHeader title="Latest transactions" subtitle="Most recent verified sales across sources · USD at the sale-date exchange rate" action={<Link href="/sales" className="text-muted hover:text-fg">All sales →</Link>} />238      {items.length ? (239        <Table>240          <thead>241            <tr>242              <th className={th}>Asset</th>243              <th className={th}>Grade</th>244              <th className={th}>Type</th>245              <th className={th}>Source</th>246              <th className={th}>Date</th>247              <th className={cn(th, 'text-right')}>Price</th>248            </tr>249          </thead>250          <tbody>251            {items.map((s) => (252              <tr key={s.id} className="hover:bg-sunken">253                <td className={cn(td, 'max-w-[320px]')}>254                  <span className="flex items-center gap-2.5">255                    <Thumb src={s.heroImageUrl} alt="" size={32} />256                    <span className="min-w-0">257                      <Link href={`/asset/${s.assetSlug}`} className="block truncate font-medium text-fg hover:underline">258                        {s.assetTitle}259                      </Link>260                      <span className="block truncate text-[11px] text-muted">{catName(s.categorySlug)}</span>261                    </span>262                  </span>263                </td>264                <td className={td}>265                  <GradeBadge grader={s.grader} grade={s.grade} />266                </td>267                <td className={cn(td, 'text-muted')}>{humanize(s.saleType)}</td>268                <td className={td}>269                  <SourceLink name={s.sourceName} url={s.sourceUrl} />270                </td>271                <td className={cn(td, 'text-muted')}>{fmtDate(s.saleDate)}</td>272                <td className={tdNum}>273                  <span className="flex flex-col items-end leading-tight">274                    <span className="font-semibold text-fg">{fmtMoney(s.priceUsd)}</span>275                    {s.currency !== 'USD' ? <span className="text-[10px] text-subtle">{fmtMoney(s.price, s.currency)}</span> : null}276                  </span>277                </td>278              </tr>279            ))}280          </tbody>281        </Table>282      ) : (283        <EmptyState title="No transactions yet" description="Verified sales stream in here as connectors run." compact />284      )}285    </Card>286  );287}288289export function cls(...c: Array<string | false | undefined>): string {290  return cn(...c);291}292