SPB Git

spb/rareindex

Public
12.9 KB · 289 lines tsx
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 } 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 · estimates and bids are the house’s own figures" href="/auctions" 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 block text-[13px] font-semibold text-fg">{l.currentBid !== null ? fmtMoney(l.currentBid, l.currency ?? 'USD') : l.estimateHigh !== null ? `est. ${fmtMoney(l.estimateHigh, l.currency ?? 'USD')}` : '—'}</span>125              </span>126            </a>127          ))}128        </Rail>129      ) : (130        <Card>131          <EmptyState title="No lots ending soon" description="Auction lots appear once auction-house connectors are live." compact />132        </Card>133      )}134    </section>135  );136}137138export function MarketInsights({ rows }: { rows: MarketRow[] }) {139  const withData = rows.filter((r) => r.counts.assets > 0).sort((a, b) => b.counts.sales - a.counts.sales || b.counts.assets - a.counts.assets);140  return (141    <Card>142      <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>} />143      {withData.length ? (144        <Table sticky={false}>145          <thead>146            <tr>147              <th className={th}>Category</th>148              <th className={cn(th, 'text-right')}>Assets</th>149              <th className={cn(th, 'text-right')}>Sales</th>150              <th className={cn(th, 'text-right')}>Listings</th>151              <th className={cn(th, 'text-right')}>30D</th>152            </tr>153          </thead>154          <tbody>155            {withData.slice(0, 10).map((r) => (156              <tr key={r.node.slug} className="hover:bg-sunken">157                <td className={td}>158                  <Link href={`/markets/${r.node.slug}`} className="font-medium text-fg hover:underline">159                    {r.node.name}160                  </Link>161                </td>162                <td className={tdNum}>{fmtNum(r.counts.assets)}</td>163                <td className={tdNum}>{fmtNum(r.counts.sales)}</td>164                <td className={tdNum}>{fmtNum(r.counts.listings)}</td>165                <td className={tdNum} style={heatStyle(r.snapshot?.change30d)}>166                  <Delta value={r.snapshot?.change30d} />167                </td>168              </tr>169            ))}170          </tbody>171        </Table>172      ) : (173        <EmptyState title="No category data yet" description={EMPTY} compact />174      )}175    </Card>176  );177}178179export interface LatestObservation {180  id: string;181  assetSlug: string;182  assetTitle: string;183  categorySlug: string;184  heroImageUrl: string | null;185  priceUsd: number;186  priceKind: string;187  observationDate: string;188  sourceName: string;189}190191/** Latest transactions as a dense, attributed table; falls back to guide-price observations when no sale exists yet. */192export function LatestTransactions({ items, observations = [] }: { items: LatestSale[]; observations?: LatestObservation[] }) {193  if (!items.length && observations.length) {194    return (195      <Card>196        <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>} />197        <Table>198          <thead>199            <tr>200              <th className={th}>Asset</th>201              <th className={th}>Kind</th>202              <th className={th}>Source</th>203              <th className={th}>Date</th>204              <th className={cn(th, 'text-right')}>Price</th>205            </tr>206          </thead>207          <tbody>208            {observations.map((o) => (209              <tr key={o.id} className="hover:bg-sunken">210                <td className={cn(td, 'max-w-[320px]')}>211                  <span className="flex items-center gap-2.5">212                    <Thumb src={o.heroImageUrl} alt="" size={32} />213                    <span className="min-w-0">214                      <Link href={`/asset/${o.assetSlug}`} className="block truncate font-medium text-fg hover:underline">215                        {o.assetTitle}216                      </Link>217                      <span className="block truncate text-[11px] text-muted">{catName(o.categorySlug)}</span>218                    </span>219                  </span>220                </td>221                <td className={cn(td, 'text-muted')}>{humanize(o.priceKind)}</td>222                <td className={cn(td, 'text-muted')}>{o.sourceName}</td>223                <td className={cn(td, 'text-muted')}>{fmtDate(o.observationDate)}</td>224                <td className={cn(tdNum, 'font-semibold text-muted')}>{fmtMoney(o.priceUsd)}</td>225              </tr>226            ))}227          </tbody>228        </Table>229      </Card>230    );231  }232  return (233    <Card>234      <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>} />235      {items.length ? (236        <Table>237          <thead>238            <tr>239              <th className={th}>Asset</th>240              <th className={th}>Grade</th>241              <th className={th}>Type</th>242              <th className={th}>Source</th>243              <th className={th}>Date</th>244              <th className={cn(th, 'text-right')}>Price</th>245            </tr>246          </thead>247          <tbody>248            {items.map((s) => (249              <tr key={s.id} className="hover:bg-sunken">250                <td className={cn(td, 'max-w-[320px]')}>251                  <span className="flex items-center gap-2.5">252                    <Thumb src={s.heroImageUrl} alt="" size={32} />253                    <span className="min-w-0">254                      <Link href={`/asset/${s.assetSlug}`} className="block truncate font-medium text-fg hover:underline">255                        {s.assetTitle}256                      </Link>257                      <span className="block truncate text-[11px] text-muted">{catName(s.categorySlug)}</span>258                    </span>259                  </span>260                </td>261                <td className={td}>262                  <GradeBadge grader={s.grader} grade={s.grade} />263                </td>264                <td className={cn(td, 'text-muted')}>{humanize(s.saleType)}</td>265                <td className={td}>266                  <SourceLink name={s.sourceName} url={s.sourceUrl} />267                </td>268                <td className={cn(td, 'text-muted')}>{fmtDate(s.saleDate)}</td>269                <td className={tdNum}>270                  <span className="flex flex-col items-end leading-tight">271                    <span className="font-semibold text-fg">{fmtMoney(s.priceUsd)}</span>272                    {s.currency !== 'USD' ? <span className="text-[10px] text-subtle">{fmtMoney(s.price, s.currency)}</span> : null}273                  </span>274                </td>275              </tr>276            ))}277          </tbody>278        </Table>279      ) : (280        <EmptyState title="No transactions yet" description="Verified sales stream in here as connectors run." compact />281      )}282    </Card>283  );284}285286export function cls(...c: Array<string | false | undefined>): string {287  return cn(...c);288}289
Files · f5091c0a3f32b0f99bc705c36f16b636342fbdb4
Menu

Theme