TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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