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%
12.3 KB · 240 lines tsx
Raw Blame History
1import { Suspense } from 'react';2import Link from 'next/link';3import { Hero } from '@/components/home/hero';4import { IndexTape } from '@/components/market/index-strip';5import { AssetRail, LatestTransactions, LotsRail, MarketInsights, RadarRail, SalesRail } from '@/components/home/sections';6import { CategoryGrid } from '@/components/market/category-grid';7import { TileSkeleton } from '@/components/market/asset-tile';8import { attachGuidePrices, getCategoryThumbnails, getLatestObservationsFeed, rankedAssets } from '@/lib/queries/assets';9import { fmtNum } from '@/lib/format';10import { Card, Delta, Skeleton, TableSkeleton } from '@/components/ui/primitives';11import { getLatestSales, getSiteStats } from '@/lib/queries/site';12import { getIndexSeries, listIndices } from '@/lib/queries/indices';13import { getCategorySparklines, getMarketRows } from '@/lib/queries/markets';14import { getTopSales, listLots, listRadar } from '@/lib/queries/market-lists';15import { LineChart } from '@/components/charts/line-chart';1617export const revalidate = 120;1819export default async function HomePage() {20  const [stats, indices] = await Promise.all([getSiteStats(), listIndices()]);21  return (22    <div className="-mx-4 sm:-mx-6">23      <div className="px-4 sm:px-6">24        <Hero stats={stats} />25      </div>26      <div className="mt-8">27        <IndexTape indices={indices} />28      </div>29      {/* §195 hierarchy: hero + search → live stats (in hero) → RARE tape → global index → market snapshot →30          trending → record sales → Rare Radar → value opportunities → ending auctions → latest sales → categories → long tail */}31      <div className="flex flex-col gap-10 px-4 pt-8 sm:px-6">32        <Suspense fallback={<Skeleton className="h-72 w-full" />}>33          <FlagshipPanel />34        </Suspense>35        <Suspense fallback={<TableSkeleton rows={8} />}>36          <Snapshot />37        </Suspense>38        <Suspense fallback={<RailFallback />}>39          <Discovery />40        </Suspense>41        <Suspense fallback={<TableSkeleton rows={10} />}>42          <Latest />43        </Suspense>44        <Suspense fallback={<Skeleton className="h-40" />}>45          <Categories />46        </Suspense>47        <Suspense fallback={<RailFallback />}>48          <LongTail />49        </Suspense>50      </div>51    </div>52  );53}5455function RailFallback() {56  return (57    <div className="flex flex-col gap-10">58      {[0, 1].map((k) => (59        <div key={k}>60          <Skeleton className="mb-3 h-5 w-40" />61          <div className="rail lg:mx-0 lg:grid lg:grid-cols-4 lg:overflow-visible lg:px-0 xl:grid-cols-6 2xl:grid-cols-8 lg:[&>*]:w-auto">62            {Array.from({ length: 8 }).map((_, i) => (63              <TileSkeleton key={i} />64            ))}65          </div>66        </div>67      ))}68    </div>69  );70}7172async function Categories() {73  const markets = await getMarketRows();74  const families = markets.filter((r) => r.counts.assets > 0);75  const [thumbs, sparklines] = await Promise.all([getCategoryThumbnails(markets.map((r) => r.node.slug)), getCategorySparklines(markets.map((r) => r.node.slug), 90)]);76  return (77    <section>78      <div className="mb-3 flex items-end justify-between gap-3">79        <div>80          <h2 className="t-title text-fg">Browse by category</h2>81          <p className="mt-0.5 text-xs text-muted">Live counts from the canonical tables · 90-day category index where published</p>82        </div>83        <Link href="/categories" className="num shrink-0 text-xs font-medium text-muted hover:text-fg">84          {fmtNum(families.length)} of {fmtNum(markets.length)} families with data →85        </Link>86      </div>87      <CategoryGrid rows={families} thumbs={thumbs} sparklines={sparklines} limit={12} mobileLimit={6} />88    </section>89  );90}9192async function FlagshipPanel() {93  const indices = await listIndices();94  const rare = indices.find((i) => i.isFlagship);95  const subs = indices.filter((i) => !i.isFlagship);96  const published = subs.filter((i) => i.latest);97  const building = subs.filter((i) => !i.latest).sort((a, b) => b.pricedAssets / b.minConstituents - a.pricedAssets / a.minConstituents);98  const series = rare ? await getIndexSeries(rare.id, 365) : [];99  return (100    <section className="grid gap-4 lg:grid-cols-[3fr_2fr]">101      <Card className="p-4">102        <div className="flex flex-wrap items-baseline justify-between gap-3">103          <div>104            <p className="t-label">RARE · Global Collectibles Index</p>105            {rare?.latest ? (106              <p className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">107                <span className="num text-2xl font-semibold text-fg">{rare.latest.value.toFixed(2)}</span>108                <Delta value={rare.change1d} arrow className="text-sm" />109                <span className="text-[11px] text-subtle">1D</span>110                <Delta value={rare.change30d} className="text-sm" />111                <span className="text-[11px] text-subtle">30D</span>112              </p>113            ) : (114              <p className="mt-1 text-sm text-muted">Transaction-weighted across published subindices · base 1,000 on 2024-01-01 · <Link href="/methodology#indices" className="underline-offset-4 hover:text-fg hover:underline">methodology</Link></p>115            )}116          </div>117          <Link href="/rareindex/RARE" className="text-xs font-medium text-muted hover:text-fg">118            Index detail →119          </Link>120        </div>121        <div className="mt-3">122          <LineChart ariaLabel="RARE index over the last year" height={220} series={[{ id: 'rare', label: 'RARE', kind: 'area', points: series.map((p) => ({ x: p.date, y: p.value })) }]} emptyLabel={rare ? `Index building — publishes once ${rare.minConstituents} priced constituents exist (${rare.pricedAssets} today)` : 'Index not configured'} />123        </div>124      </Card>125      <Card className="flex flex-col p-4">126        <div className="flex items-baseline justify-between">127          <p className="t-label">Subindices</p>128          <Link href="/rareindex" className="text-xs font-medium text-muted hover:text-fg">129            All indices →130          </Link>131        </div>132        {published.length ? (133          <ul className="mt-2 divide-y divide-border">134            {published.slice(0, 8).map((i) => (135              <li key={i.ticker}>136                <Link href={`/rareindex/${i.ticker}`} className="flex min-h-[40px] items-center justify-between gap-3 text-[12px] hover:bg-sunken">137                  <span className="flex min-w-0 items-center gap-2">138                    <span className="inline-block h-2 w-2 shrink-0 rounded-[2px]" style={{ background: i.color ?? 'var(--ri-index)' }} />139                    <span className="mono-num font-semibold text-fg">{i.ticker}</span>140                    <span className="truncate text-muted">{i.name.replace('RareIndex ', '')}</span>141                  </span>142                  <span className="num flex shrink-0 items-center gap-3">143                    <span className="text-muted">{i.latest!.value.toFixed(2)}</span>144                    <Delta value={i.change1d} arrow />145                  </span>146                </Link>147              </li>148            ))}149          </ul>150        ) : null}151        {building.length ? (152          <div className="mt-3 border-t border-border pt-3">153            <p className="t-caption mb-1.5 flex items-baseline justify-between"><span>In development · priced constituents vs. minimum</span><Link href="/methodology#indices" className="text-subtle hover:text-fg">Why →</Link></p>154            <ul className="grid grid-cols-2 gap-x-4 gap-y-1.5">155              {building.slice(0, 8).map((i) => {156                const pct = Math.min(100, Math.round((i.pricedAssets / Math.max(1, i.minConstituents)) * 100));157                return (158                  <li key={i.ticker} className="text-[11px]">159                    <span className="flex items-center justify-between">160                      <span className="mono-num text-muted">{i.ticker.replace('RARE-', '')}</span>161                      <span className="num text-subtle">162                        {i.pricedAssets}/{i.minConstituents}163                      </span>164                    </span>165                    <span className="mt-0.5 block h-1 w-full overflow-hidden rounded-full bg-inset">166                      <span className="block h-full rounded-full bg-index" style={{ width: `${pct}%` }} />167                    </span>168                  </li>169                );170              })}171            </ul>172          </div>173        ) : null}174      </Card>175    </section>176  );177}178179/** Market snapshot (§195): the category table right under the global index. */180async function Snapshot() {181  const markets = await getMarketRows();182  return <MarketInsights rows={markets} />;183}184185/** Long tail below the fold: catalogue additions and community signals. */186async function LongTail() {187  const [newest, watched] = await Promise.all([rankedAssets('newest', { limit: 8 }), rankedAssets('watched', { limit: 8 })]);188  await attachGuidePrices(newest);189  return (190    <>191      <AssetRail title="Newly Added" subtitle="Latest canonical assets — guide prices labelled, RIV when available" href="/explore?sort=newest" items={newest} metric="guide" />192      {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null}193    </>194  );195}196197async function Discovery() {198  const [trending, movers, losers, records, radar, lots, opportunities, newestPriced, documented, withSales] = await Promise.all([199    rankedAssets('trending', { limit: 8 }),200    rankedAssets('gainers', { limit: 8, window: '7d' }),201    rankedAssets('losers', { limit: 8, window: '7d' }),202    getTopSales(8),203    listRadar({ limit: 8 }),204    listLots({ endingWithinHours: 72, status: null, limit: 8 }),205    rankedAssets('opportunity', { limit: 8 }),206    rankedAssets('newest_priced', { limit: 8 }),207    rankedAssets('documented', { limit: 8 }),208    rankedAssets('with_sales', { limit: 8 }),209  ]);210  await Promise.all([attachGuidePrices(documented), attachGuidePrices(trending), attachGuidePrices(newestPriced)]);211  // Honest fallbacks while valuations are still being computed: each rail states what it shows.212  const trendingRail = trending.length213    ? { title: 'Trending Now', subtitle: 'Price × volume × listing momentum', items: trending, metric: 'trending' as const, href: '/trending' }214    : newestPriced.length215      ? { title: 'Recently valued', subtitle: 'Newest RareIndex Valuations — trending publishes once momentum history exists', items: newestPriced, metric: 'riv' as const, href: '/explore?has=valuation&sort=riv' }216      : { title: 'Best-documented assets', subtitle: 'Images plus sales or guide prices', items: documented, metric: 'guide' as const, href: '/explore?has=observations' };217  const moversItems = movers.length ? movers : losers;218  const moversRail = moversItems.length219    ? { title: 'Biggest Movers', subtitle: '7-day change in RareIndex Valuation', items: moversItems, metric: 'change7d' as const, href: '/explore?sort=change7d' }220    : withSales.length221      ? { title: 'Latest sold assets', subtitle: 'Most recent verified sale per asset — movers need a 7-day valuation history', items: withSales, metric: 'latestSale' as const, href: '/sales' }222      : null;223  return (224    <>225      <AssetRail title={trendingRail.title} subtitle={trendingRail.subtitle} href={trendingRail.href} items={trendingRail.items} metric={trendingRail.metric} priority />226      {moversRail ? <AssetRail title={moversRail.title} subtitle={moversRail.subtitle} href={moversRail.href} items={moversRail.items} metric={moversRail.metric} /> : null}227      <SalesRail title="Record Sales" subtitle="Highest verified transactions" href="/records" items={records} hideWhenEmpty />228      <RadarRail items={radar} hideWhenEmpty />229      {opportunities.length ? <AssetRail title="Value Opportunities" subtitle="Asks ≥ 10 % below a transaction-based RIV of the same variant (≥ 5 sales, medium+ confidence). Analytical data, not advice; implausible asks are held back as data anomalies." href="/listings?sort=discount&disc=10" items={opportunities} metric="opportunity" /> : null}230      <LotsRail items={lots} hideWhenEmpty />231    </>232  );233}234235async function Latest() {236  const items = await getLatestSales(12);237  const observations = items.length ? [] : await getLatestObservationsFeed(12);238  return <LatestTransactions items={items} observations={observations} />;239}240