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.6 KB · 205 lines tsx
Raw Blame History
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