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 <div className="flex flex-col gap-10 px-4 pt-8 sm:px-6">30 <Suspense fallback={<Skeleton className="h-40" />}>31 <Categories />32 </Suspense>33 <Suspense fallback={<RailFallback />}>34 <Discovery />35 </Suspense>36 <Suspense fallback={<Skeleton className="h-72 w-full" />}>37 <FlagshipPanel />38 </Suspense>39 <Suspense fallback={<TableSkeleton rows={10} />}>40 <Latest />41 </Suspense>42 </div>43 </div>44 );45}4647function RailFallback() {48 return (49 <div className="flex flex-col gap-10">50 {[0, 1].map((k) => (51 <div key={k}>52 <Skeleton className="mb-3 h-5 w-40" />53 <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">54 {Array.from({ length: 8 }).map((_, i) => (55 <TileSkeleton key={i} />56 ))}57 </div>58 </div>59 ))}60 </div>61 );62}6364async function Categories() {65 const markets = await getMarketRows();66 const families = markets.filter((r) => r.counts.assets > 0);67 const [thumbs, sparklines] = await Promise.all([getCategoryThumbnails(markets.map((r) => r.node.slug)), getCategorySparklines(markets.map((r) => r.node.slug), 90)]);68 return (69 <section>70 <div className="mb-3 flex items-end justify-between gap-3">71 <div>72 <h2 className="t-title text-fg">Browse by category</h2>73 <p className="mt-0.5 text-xs text-muted">Live counts from the canonical tables · 90-day category index where published</p>74 </div>75 <Link href="/categories" className="num shrink-0 text-xs font-medium text-muted hover:text-fg">76 {fmtNum(families.length)} of {fmtNum(markets.length)} families with data →77 </Link>78 </div>79 <CategoryGrid rows={families} thumbs={thumbs} sparklines={sparklines} limit={12} mobileLimit={6} />80 </section>81 );82}8384async function FlagshipPanel() {85 const indices = await listIndices();86 const rare = indices.find((i) => i.isFlagship);87 const subs = indices.filter((i) => !i.isFlagship);88 const published = subs.filter((i) => i.latest);89 const building = subs.filter((i) => !i.latest).sort((a, b) => b.pricedAssets / b.minConstituents - a.pricedAssets / a.minConstituents);90 const series = rare ? await getIndexSeries(rare.id, 365) : [];91 return (92 <section className="grid gap-4 lg:grid-cols-[3fr_2fr]">93 <Card className="p-4">94 <div className="flex flex-wrap items-baseline justify-between gap-3">95 <div>96 <p className="t-label">RARE · Global Collectibles Index</p>97 {rare?.latest ? (98 <p className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">99 <span className="num text-2xl font-semibold text-fg">{rare.latest.value.toFixed(2)}</span>100 <Delta value={rare.change1d} arrow className="text-sm" />101 <span className="text-[11px] text-subtle">1D</span>102 <Delta value={rare.change30d} className="text-sm" />103 <span className="text-[11px] text-subtle">30D</span>104 </p>105 ) : (106 <p className="mt-1 text-sm text-muted">Chain-linked, equal-weighted across subindices · base 1,000 on 2024-01-01</p>107 )}108 </div>109 <Link href="/rareindex/RARE" className="text-xs font-medium text-muted hover:text-fg">110 Index detail →111 </Link>112 </div>113 <div className="mt-3">114 <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'} />115 </div>116 </Card>117 <Card className="flex flex-col p-4">118 <div className="flex items-baseline justify-between">119 <p className="t-label">Subindices</p>120 <Link href="/rareindex" className="text-xs font-medium text-muted hover:text-fg">121 All indices →122 </Link>123 </div>124 {published.length ? (125 <ul className="mt-2 divide-y divide-border">126 {published.slice(0, 8).map((i) => (127 <li key={i.ticker}>128 <Link href={`/rareindex/${i.ticker}`} className="flex min-h-[40px] items-center justify-between gap-3 text-[12px] hover:bg-sunken">129 <span className="flex min-w-0 items-center gap-2">130 <span className="inline-block h-2 w-2 shrink-0 rounded-[2px]" style={{ background: i.color ?? 'var(--ri-index)' }} />131 <span className="mono-num font-semibold text-fg">{i.ticker}</span>132 <span className="truncate text-muted">{i.name.replace('RareIndex ', '')}</span>133 </span>134 <span className="num flex shrink-0 items-center gap-3">135 <span className="text-muted">{i.latest!.value.toFixed(2)}</span>136 <Delta value={i.change1d} arrow />137 </span>138 </Link>139 </li>140 ))}141 </ul>142 ) : null}143 {building.length ? (144 <div className="mt-3 border-t border-border pt-3">145 <p className="t-caption mb-1.5">Building · priced constituents vs. minimum</p>146 <ul className="grid grid-cols-2 gap-x-4 gap-y-1.5">147 {building.slice(0, 8).map((i) => {148 const pct = Math.min(100, Math.round((i.pricedAssets / Math.max(1, i.minConstituents)) * 100));149 return (150 <li key={i.ticker} className="text-[11px]">151 <span className="flex items-center justify-between">152 <span className="mono-num text-muted">{i.ticker.replace('RARE-', '')}</span>153 <span className="num text-subtle">154 {i.pricedAssets}/{i.minConstituents}155 </span>156 </span>157 <span className="mt-0.5 block h-1 w-full overflow-hidden rounded-full bg-inset">158 <span className="block h-full rounded-full bg-index" style={{ width: `${pct}%` }} />159 </span>160 </li>161 );162 })}163 </ul>164 </div>165 ) : null}166 </Card>167 </section>168 );169}170171async function Discovery() {172 const [trending, movers, losers, records, radar, lots, watched, newest, markets, opportunities, newestPriced, documented, withSales] = await Promise.all([173 rankedAssets('trending', { limit: 8 }),174 rankedAssets('gainers', { limit: 8, window: '7d' }),175 rankedAssets('losers', { limit: 8, window: '7d' }),176 getTopSales(8),177 listRadar({ limit: 8 }),178 listLots({ endingWithinHours: 72, status: null, limit: 8 }),179 rankedAssets('watched', { limit: 8 }),180 rankedAssets('newest', { limit: 8 }),181 getMarketRows(),182 rankedAssets('opportunity', { limit: 8 }),183 rankedAssets('newest_priced', { limit: 8 }),184 rankedAssets('documented', { limit: 8 }),185 rankedAssets('with_sales', { limit: 8 }),186 ]);187 await Promise.all([attachGuidePrices(newest), attachGuidePrices(documented), attachGuidePrices(trending), attachGuidePrices(newestPriced)]);188 // Honest fallbacks while valuations are still being computed: each rail states what it shows.189 const trendingRail = trending.length190 ? { title: 'Trending Now', subtitle: 'Price × volume × listing momentum', items: trending, metric: 'trending' as const, href: '/trending' }191 : newestPriced.length192 ? { 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' }193 : { title: 'Best-documented assets', subtitle: 'Images plus sales or guide prices', items: documented, metric: 'guide' as const, href: '/explore?has=observations' };194 const moversItems = movers.length ? movers : losers;195 const moversRail = moversItems.length196 ? { title: 'Biggest Movers', subtitle: '7-day change in RareIndex Valuation', items: moversItems, metric: 'change7d' as const, href: '/explore?sort=change7d' }197 : withSales.length198 ? { 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' }199 : null;200 return (201 <>202 <AssetRail title={trendingRail.title} subtitle={trendingRail.subtitle} href={trendingRail.href} items={trendingRail.items} metric={trendingRail.metric} priority />203 {moversRail ? <AssetRail title={moversRail.title} subtitle={moversRail.subtitle} href={moversRail.href} items={moversRail.items} metric={moversRail.metric} /> : null}204 <SalesRail title="Record Sales" subtitle="Highest verified transactions" href="/records" items={records} hideWhenEmpty />205 <RadarRail items={radar} hideWhenEmpty />206 <LotsRail items={lots} hideWhenEmpty />207 <AssetRail title="Newly Added" subtitle="Latest canonical assets — guide prices labelled, RIV when available" href="/explore?sort=newest" items={newest} metric="guide" />208 {opportunities.length ? <AssetRail title="Value Opportunities" subtitle="Listings materially below RIV — analytical data, not advice" href="/listings?sort=discount" items={opportunities} metric="opportunity" /> : null}209 {watched.length ? <AssetRail title="Most Watched" subtitle="Assets on the most watchlists" href="/explore?sort=trending" items={watched} metric="watchers" /> : null}210 <MarketInsights rows={markets} />211 </>212 );213}214215async function Latest() {216 const items = await getLatestSales(12);217 const observations = items.length ? [] : await getLatestObservationsFeed(12);218 return <LatestTransactions items={items} observations={observations} />;219}220