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 { AssetGallery, type GalleryImage } from './asset-gallery';9import { imageProps } from '@/lib/images';10import { glyphForFamily } from '@/lib/image-glyph';11import { AssetActions } from './asset-actions';12import { VariantTable } from './variant-table';1314/**15 * Asset page header (§126): gallery + identity on the left, the valuation panel on the right.16 * Mobile-first: gallery → title → variant chips → valuation → metrics; desktop: two columns.17 */18export function AssetHeader({ asset, variants, activeVariant, tabHref, guide = null, images, watched = false }: { asset: AssetDetail; variants: VariantRow[]; activeVariant: VariantRow | null; tabHref: (v: string | null) => string; guide?: GuidePrice | null; images: GalleryImage[]; watched?: boolean }) {19 const v = activeVariant;20 const riv = v ? v.rivUsd : asset.rivUsd;21 const low = v ? v.rivLowUsd : asset.rivLowUsd;22 const high = v ? v.rivHighUsd : asset.rivHighUsd;23 const conf = v ? v.rivConfidence : asset.rivConfidence;24 const n = v ? v.rivSampleSize : asset.rivSampleSize;25 const latest = v ? v.latestSaleUsd : asset.latestSaleUsd;26 const latestAt = v ? v.latestSaleAt : asset.latestSaleAt;27 const confLabel = confidenceLabel(conf);28 const confTone = confLabel === 'High' ? 'gain' : confLabel === 'Medium' ? 'index' : confLabel === 'Low' ? 'alert' : 'neutral';2930 const facts: Array<[string, React.ReactNode]> = (31 [32 ['Set', asset.setName ? <Link key="s" href={asset.setSlug ? `/set/${asset.setSlug}` : '#'} className="hover:underline">{asset.setName}{asset.setCode ? ` (${asset.setCode})` : ''}</Link> : null],33 ['Number', asset.number],34 ['Brand', asset.brand ? <Link key="b" href={`/brand/${encodeURIComponent(asset.brand)}`} className="hover:underline">{asset.brand}</Link> : null],35 ['Franchise', asset.franchise],36 ['Reference', asset.reference],37 ['Model', asset.model],38 ['Release', asset.releaseDate],39 ['Production', asset.productionQuantity != null ? fmtNum(asset.productionQuantity) : null],40 ['Original MSRP', asset.originalMsrp != null ? fmtMoney(asset.originalMsrp, asset.originalMsrpCurrency ?? 'USD') : null],41 ] as Array<[string, React.ReactNode]>42 ).filter(([, val]) => val);4344 return (45 <header>46 <Breadcrumbs items={[{ label: 'Markets', href: '/markets' }, ...categoryCrumbs(asset.categorySlug), ...(asset.setSlug && asset.setName ? [{ label: asset.setName, href: `/set/${asset.setSlug}` }] : []), { label: asset.name }]} />47 <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start">48 {/* identity */}49 <div className="grid gap-4 sm:grid-cols-[200px_minmax(0,1fr)] md:grid-cols-[240px_minmax(0,1fr)]">50 <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" />51 <div className="min-w-0">52 <h1 className="text-[22px] font-semibold leading-tight tracking-tight text-fg sm:text-2xl lg:text-[26px]">{asset.title}</h1>53 <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px]">54 <Link href={`/markets/${asset.categorySlug}`}>55 <Badge tone="index">{catName(asset.categorySlug)}</Badge>56 </Link>57 {asset.year ? <Badge>{asset.year}</Badge> : null}58 {asset.edition ? <Badge>{asset.edition}</Badge> : null}59 {asset.variant ? <Badge>{asset.variant}</Badge> : null}60 {asset.language && asset.language !== 'en' ? <Badge>{asset.language.toUpperCase()}</Badge> : null}61 {asset.rarity ? <Badge tone="rarity">{asset.rarity}</Badge> : null}62 {asset.verified ? <Badge tone="gain">Verified record</Badge> : null}63 </div>64 {facts.length ? (65 <dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[12px] sm:grid-cols-3">66 {facts.slice(0, 9).map(([k, val]) => (67 <div key={k} className="min-w-0">68 <dt className="text-[10px] uppercase tracking-wider text-subtle">{k}</dt>69 <dd className="truncate text-fg">{val}</dd>70 </div>71 ))}72 </dl>73 ) : null}74 {variants.length ? (75 <div className="mt-4">76 <p className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-subtle">Variant / grade</p>77 <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">78 <Chip href={tabHref(null)} active={!v}>79 All variants80 </Chip>81 {variants.slice(0, 16).map((x) => (82 <Chip key={x.id} href={tabHref(x.id)} active={v?.id === x.id} title={`${x.salesCount} sales`}>83 {x.label}84 {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}85 </Chip>86 ))}87 </div>88 </div>89 ) : null}90 <div className="mt-4 hidden md:block">91 <AssetActions assetId={asset.id} slug={asset.slug} watched={watched} />92 </div>93 </div>94 <div className="hidden sm:col-span-2 lg:block">95 <VariantTable variants={variants} href={tabHref} activeId={v?.id ?? null} />96 </div>97 </div>9899 {/* valuation panel */}100 <aside className="card p-4">101 <div className="flex items-baseline justify-between">102 <span className="text-[10px] font-semibold uppercase tracking-wider text-subtle">RareIndex Valuation{v ? ` · ${v.label}` : ''}</span>103 <Badge tone={confTone}>{confLabel === 'Insufficient' ? 'No RIV' : `${confLabel} confidence`}</Badge>104 </div>105 {riv !== null ? (106 <>107 <div className="mt-1 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(riv)}</div>108 <div className="num mt-1.5 text-[12px] text-muted">109 Range {fmtMoney(low)} – {fmtMoney(high)} · <span className="text-fg">{fmtNum(n)}</span> sales used · updated {fmtRelative(asset.updatedAt)}110 </div>111 <RangeBar low={low} high={high} value={riv} latest={latest} />112 </>113 ) : guide ? (114 <div className="mt-1">115 <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">Latest guide price</div>116 <div className="mt-0.5 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(guide.priceUsd)}</div>117 <p className="num mt-1.5 text-[11px] text-muted">118 {guide.sourceName} · {guide.priceKind.replace(/_/g, ' ')}119 {guide.currency !== 'USD' ? ` · ${fmtMoney(guide.price, guide.currency)}` : ''}120 {guide.variantLabel ? ` · ${guide.variantLabel}` : ''} · observed {fmtDate(guide.observationDate)}121 </p>122 <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>123 </div>124 ) : asset.minAskUsd !== null ? (125 <div className="mt-1">126 <div className="text-[10px] font-medium uppercase tracking-wider text-subtle">Lowest current ask</div>127 <div className="mt-0.5 text-[34px] font-semibold leading-none tracking-tight text-fg">{fmtMoney(asset.minAskUsd)}</div>128 <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>129 </div>130 ) : (131 <div className="mt-1">132 <div className="text-xl font-semibold text-subtle">Data unavailable</div>133 <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>134 </div>135 )}136137 <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">138 <Metric label="Latest sale" value={latest !== null ? fmtMoney(latest) : null} sub={latestAt ? fmtRelative(latestAt) : undefined} />139 <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>} />140 <Metric label="All-time high" value={asset.athUsd !== null ? fmtMoney(asset.athUsd) : null} sub={asset.athAt ? fmtDate(asset.athAt) : undefined} />141 <Metric label="All-time low" value={asset.atlUsd !== null ? fmtMoney(asset.atlUsd) : null} sub={asset.atlAt ? fmtDate(asset.atlAt) : undefined} />142 <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} />143 <Metric label="Sales 30D" value={fmtNum(v ? v.sales30d : asset.sales30d)} sub={`${fmtNum(v ? v.salesCount : asset.salesCount)} all time`} />144 </dl>145 <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">146 <ScoreMeter label="Liquidity" value={v ? v.liquidityScore : asset.liquidityScore} />147 <ScoreMeter label="Rarity" value={asset.rarityScore} tone="rarity" />148 <ScoreMeter label="Data quality" value={asset.dataQuality} tone="gain" />149 </div>150 <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>151 </aside>152 </div>153 <AssetActions assetId={asset.id} slug={asset.slug} watched={watched} variant="bar" />154 </header>155 );156}157158function Chip({ href, active, children, title }: { href: string; active: boolean; children: React.ReactNode; title?: string }) {159 return (160 <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}>161 {children}162 </Link>163 );164}165166function Metric({ label, value, sub }: { label: string; value: React.ReactNode | null; sub?: string }) {167 return (168 <div className="min-w-0">169 <dt className="text-[10px] font-medium uppercase tracking-wider text-subtle">{label}</dt>170 <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>171 {sub ? <dd className="truncate text-[11px] text-muted">{sub}</dd> : null}172 </div>173 );174}175176/** Low–high band with RIV and latest sale markers (a meter, not a chart: shows where the estimate sits). */177function RangeBar({ low, high, value, latest }: { low: number | null; high: number | null; value: number; latest: number | null }) {178 if (low === null || high === null || high <= low) return null;179 const pos = (x: number) => `${Math.max(2, Math.min(98, ((x - low) / (high - low)) * 100))}%`;180 return (181 <div className="mt-3" aria-hidden>182 <div className="relative h-1.5 w-full rounded-full bg-inset">183 <div className="absolute inset-y-0 rounded-full" style={{ left: '0%', right: '0%', background: 'var(--ri-index-bg)' }} />184 <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" />185 {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}186 </div>187 <div className="num mt-1 flex justify-between text-[10px] text-subtle">188 <span>low {fmtMoney(low)}</span>189 <span>high {fmtMoney(high)}</span>190 </div>191 </div>192 );193}194