TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import Link from 'next/link';2import type { AssetDetail, VariantRow, GuidePrice } from '@/lib/queries/assets';3import { fmtMoney, fmtNum, fmtRelative, fmtDate, cn, confidenceLabel } from '@/lib/format';4import { catName, categoryCrumbs } from '@/lib/taxonomy';5import { Breadcrumbs } from '@/components/ui/page-header';6import { Badge, Delta } from '@/components/ui/primitives';7import { ScoreMeter } from '@/components/ui/evidence';8import { AskVsRivExplainer, ConfidenceExplainer, LiquidityExplainer, RarityExplainer } from './score-explainers';9import { AssetGallery, type GalleryImage } from './asset-gallery';10import { imageProps } from '@/lib/images';11import { glyphForFamily } from '@/lib/image-glyph';12import { AssetActions } from './asset-actions';13import { VariantTable } from './variant-table';1415/**16 * Asset page header (§126): gallery + identity on the left, the valuation panel on the right.17 * Mobile-first: gallery → title → variant chips → valuation → metrics; desktop: two columns.18 */19export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = null, images, watched = false, population = null }: { asset: AssetDetail; variants: VariantRow[]; activeVariant: VariantRow | null; tabHref: (v: string | null) => string; guide?: GuidePrice | null; images: GalleryImage[]; watched?: boolean; /** latest published graded population total, when a report exists */ population?: number | null }) {20 const v = activeVariant;21 const riv = v ? v.rivUsd : asset.rivUsd;22 const low = v ? v.rivLowUsd : asset.rivLowUsd;23 const high = v ? v.rivHighUsd : asset.rivHighUsd;24 const conf = v ? v.rivConfidence : asset.rivConfidence;25 const n = v ? v.rivSampleSize : asset.rivSampleSize;26 const latest = v ? v.latestSaleUsd : asset.latestSaleUsd;27 const latestAt = v ? v.latestSaleAt : asset.latestSaleAt;28 const confLabel = confidenceLabel(conf);29 const confTone = confLabel === 'High' ? 'gain' : confLabel === 'Medium' ? 'index' : confLabel === 'Low' ? 'alert' : 'neutral';3031 const facts: Array<[string, React.ReactNode]> = (32 [33 ['Set', asset.setName ? <Link key="s" href={asset.setSlug ? `/set/${asset.setSlug}` : '#'} className="hover:underline">{asset.setName}{asset.setCode ? ` (${asset.setCode})` : ''}</Link> : null],34 ['Number', asset.number],35 ['Brand', asset.brand ? <Link key="b" href={`/brand/${encodeURIComponent(asset.brand)}`} className="hover:underline">{asset.brand}</Link> : null],36 ['Franchise', asset.franchise],37 ['Reference', asset.reference],38 ['Model', asset.model],39 ['Release', asset.releaseDate],40 ['Production', asset.productionQuantity != null ? fmtNum(asset.productionQuantity) : null],41 ['Original MSRP', asset.originalMsrp != null ? fmtMoney(asset.originalMsrp, asset.originalMsrpCurrency ?? 'USD') : null],42 ] as Array<[string, React.ReactNode]>43 ).filter(([, val]) => val);4445 return (46 <header>47 <Breadcrumbs items={[{ label: 'Markets', href: '/markets' }, ...categoryCrumbs(asset.categorySlug), ...(asset.setSlug && asset.setName ? [{ label: asset.setName, href: `/set/${asset.setSlug}` }] : []), { label: asset.name }]} />48 <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start">49 {/* identity */}50 <div className="grid gap-4 sm:grid-cols-[200px_minmax(0,1fr)] md:grid-cols-[240px_minmax(0,1fr)]">51 <AssetGallery images={images.map((im) => ({ ...im, ...(imageProps(im.url, { maxWidth: 768 }) ?? {}), full: imageProps(im.url, { maxWidth: 1200 })?.src ?? null }))} alt={asset.title} glyph={glyphForFamily(asset.familySlug)} className="aspect-[4/3] w-full sm:aspect-square" />52 <div className="min-w-0">53 <h1 className="text-[22px] font-semibold leading-tight tracking-tight text-fg sm:text-2xl lg:text-[26px]">{asset.title}</h1>54 <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px]">55 <Link href={`/markets/${asset.categorySlug}`}>56 <Badge tone="index">{catName(asset.categorySlug)}</Badge>57 </Link>58 {asset.year ? <Badge>{asset.year}</Badge> : null}59 {asset.edition ? <Badge>{asset.edition}</Badge> : null}60 {asset.variant ? <Badge>{asset.variant}</Badge> : null}61 {asset.language && asset.language !== 'en' ? <Badge>{asset.language.toUpperCase()}</Badge> : null}62 {asset.rarity ? <Badge tone="rarity">{asset.rarity}</Badge> : null}63 {asset.verified ? <Badge tone="gain">Verified record</Badge> : null}64 </div>65 {facts.length ? (66 <dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[12px] sm:grid-cols-3">67 {facts.slice(0, 9).map(([k, val]) => (68 <div key={k} className="min-w-0">69 <dt className="text-[10px] uppercase tracking-wider text-subtle">{k}</dt>70 <dd className="truncate text-fg">{val}</dd>71 </div>72 ))}73 </dl>74 ) : null}75 {variants.length ? (76 <div className="mt-4">77 <p className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-subtle">Variant / grade</p>78 <div className="scrollbar-none -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:px-0">79 <Chip href={tabHref(null)} active={!v}>80 All variants81 </Chip>82 {variants.slice(0, 16).map((x) => (83 <Chip key={x.id} href={tabHref(x.id)} active={v?.id === x.id} title={`${x.salesCount} sales`}>84 {x.label}85 {x.salesCount ? <span className={cn('num ml-1.5 rounded-sm px-1 text-[10px]', v?.id === x.id ? 'bg-white/20' : 'bg-inset text-subtle')}>{x.salesCount}</span> : null}86 </Chip>87 ))}88 </div>89 </div>90 ) : null}91 <div className="mt-4 hidden md:block">92 <AssetActions assetId={asset.id} slug={asset.slug} watched={watched} />93 </div>94 </div>95 <div className="hidden sm:col-span-2 lg:block">96 <VariantTable variants={variants} href={tabHref} activeId={v?.id ?? null} />97 </div>98 </div>99100 {/* valuation panel */}101 <aside className="card p-4">102 <div className="flex items-baseline justify-between">103 <span className="text-[10px] font-semibold uppercase tracking-wider text-subtle">RareIndex Valuation{v ? ` · ${v.label}` : ''}</span>104 <Badge tone={confTone}>{confLabel === 'Insufficient' ? 'No RIV' : `${confLabel} confidence`}</Badge>105 </div>106 {riv !== null ? (107 <>108 <div className="mt-1 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(riv)}</div>109 <div className="num mt-1.5 text-[12px] text-muted">110 Range {fmtMoney(low)} – {fmtMoney(high)} · <span className="text-fg">{fmtNum(n)}</span> sales used · updated {fmtRelative(asset.updatedAt)}111 </div>112 <RangeBar low={low} high={high} value={riv} latest={latest} />113 <div className="mt-2 flex flex-col gap-1">114 <ConfidenceExplainer asset={asset} variant={v} />115 <AskVsRivExplainer asset={asset} variant={v} />116 </div>117 </>118 ) : guide ? (119 <div className="mt-1">120 <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">Latest guide price</div>121 <div className="mt-0.5 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(guide.priceUsd)}</div>122 <p className="num mt-1.5 text-[11px] text-muted">123 {guide.sourceName} · {guide.priceKind.replace(/_/g, ' ')}124 {guide.currency !== 'USD' ? ` · ${fmtMoney(guide.price, guide.currency)}` : ''}125 {guide.variantLabel ? ` · ${guide.variantLabel}` : ''} · observed {fmtDate(guide.observationDate)}126 </p>127 <p className="mt-1.5 text-[11px] leading-snug text-subtle">A price-guide observation, not a transaction and not a RareIndex valuation. RIV publishes once enough verified sales exist.</p>128 </div>129 ) : asset.minAskUsd !== null ? (130 <div className="mt-1">131 <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">Lowest current ask</div>132 <div className="mt-0.5 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(asset.minAskUsd)}</div>133 <p className="mt-1.5 text-[11px] leading-snug text-subtle">An asking price across {fmtNum(asset.activeListings)} live listing{asset.activeListings === 1 ? '' : 's'} — not a confirmed transaction.</p>134 </div>135 ) : (136 <div className="mt-1">137 <div className="text-xl font-semibold text-subtle">Data unavailable</div>138 <p className="mt-1 text-[11px] leading-snug text-muted">Catalogued, but no verified sale, guide price or listing has been observed yet. Watch this asset to be notified when evidence arrives.</p>139 </div>140 )}141142 <dl className="mt-4 grid grid-cols-2 gap-x-3 gap-y-3 border-t border-border pt-3 sm:grid-cols-4 lg:grid-cols-2">143 <Metric label="Latest sale" value={latest !== null ? fmtMoney(latest) : null} sub={latestAt ? fmtRelative(latestAt) : undefined} />144 <Metric label="1D · 30D · 1Y" value={<span className="flex flex-wrap gap-x-1.5"><Delta value={asset.change1d} digits={1} /><Delta value={v ? v.change30d : asset.change30d} digits={1} /><Delta value={v ? v.change1y : asset.change1y} digits={1} /></span>} />145 <Metric label="All-time high" value={asset.athUsd !== null ? fmtMoney(asset.athUsd) : null} sub={asset.athAt ? fmtDate(asset.athAt) : undefined} />146 <Metric label="All-time low" value={asset.atlUsd !== null ? fmtMoney(asset.atlUsd) : null} sub={asset.atlAt ? fmtDate(asset.atlAt) : undefined} />147 <Metric label="Active listings" value={fmtNum(v ? v.activeListings : asset.activeListings)} sub={(v ? v.minAskUsd : asset.minAskUsd) !== null ? `low ask ${fmtMoney(v ? v.minAskUsd : asset.minAskUsd)}` : undefined} />148 <Metric label="Sales 30D" value={fmtNum(v ? v.sales30d : asset.sales30d)} sub={`${fmtNum(v ? v.salesCount : asset.salesCount)} all time`} />149 </dl>150 <div className="mt-3 grid grid-cols-1 gap-2.5 border-t border-border pt-3 sm:grid-cols-3 lg:grid-cols-1">151 <div>152 <ScoreMeter label="Liquidity" value={v ? v.liquidityScore : asset.liquidityScore} />153 <LiquidityExplainer asset={asset} variant={v} className="mt-1" />154 </div>155 <div>156 <ScoreMeter label="Rarity" value={asset.rarityScore} tone="rarity" />157 <RarityExplainer asset={asset} population={population} className="mt-1" />158 </div>159 <ScoreMeter label="Data quality" value={asset.dataQuality} tone="gain" />160 </div>161 <p className="mt-3 text-[10px] leading-relaxed text-subtle">Estimate from observed public sales, not an offer or appraisal. RareIndex does not authenticate items.</p>162 </aside>163 </div>164 <AssetActions assetId={asset.id} slug={asset.slug} watched={watched} variant="bar" />165 </header>166 );167}168169function Chip({ href, active, children, title }: { href: string; active: boolean; children: React.ReactNode; title?: string }) {170 return (171 <Link href={href} scroll={false} title={title} className={cn('inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full border px-3 text-[12px] font-medium', active ? 'border-fg bg-accent text-accent-fg' : 'border-border bg-elevated text-muted hover:border-border-strong hover:text-fg')} aria-current={active ? 'true' : undefined}>172 {children}173 </Link>174 );175}176177function Metric({ label, value, sub }: { label: string; value: React.ReactNode | null; sub?: string }) {178 return (179 <div className="min-w-0">180 <dt className="text-[10px] font-medium uppercase tracking-wider text-subtle">{label}</dt>181 <dd className="num mt-0.5 truncate text-[15px] font-semibold leading-tight text-fg">{value ?? <span className="text-subtle" title="Data unavailable">—</span>}</dd>182 {sub ? <dd className="truncate text-[11px] text-muted">{sub}</dd> : null}183 </div>184 );185}186187/** Low–high band with RIV and latest sale markers (a meter, not a chart: shows where the estimate sits). */188function RangeBar({ low, high, value, latest }: { low: number | null; high: number | null; value: number; latest: number | null }) {189 if (low === null || high === null || high <= low) return null;190 const pos = (x: number) => `${Math.max(2, Math.min(98, ((x - low) / (high - low)) * 100))}%`;191 return (192 <div className="mt-3" aria-hidden>193 <div className="relative h-1.5 w-full rounded-full bg-inset">194 <div className="absolute inset-y-0 rounded-full" style={{ left: '0%', right: '0%', background: 'var(--ri-index-bg)' }} />195 <span className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-elevated" style={{ left: pos(value), background: 'var(--ri-index)' }} title="RIV" />196 {latest !== null && latest >= low && latest <= high ? <span className="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-elevated" style={{ left: pos(latest), background: 'var(--ri-series-2, #c2410c)' }} title="Latest sale" /> : null}197 </div>198 <div className="num mt-1 flex justify-between text-[10px] text-subtle">199 <span>low {fmtMoney(low)}</span>200 <span>high {fmtMoney(high)}</span>201 </div>202 </div>203 );204}205