SPB Git

spb/rareindex

Public
12.9 KB · 194 lines tsx
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
Files · f5091c0a3f32b0f99bc705c36f16b636342fbdb4
Menu

Theme