From eb15ced5730a3c0ad067465305e5b18926d893df Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 10 Jul 2026 15:36:25 +0200 Subject: [PATCH] product pages: one KPI domain pill bar (perp, pm, feeds, hl, rpc), data-gated --- src/app/products/[slug]/page.tsx | 153 ++++++++++++++++++---------- src/components/venue-kpi-toggle.tsx | 15 +-- src/lib/rpc-hub-stats.ts | 18 ++++ 3 files changed, 124 insertions(+), 62 deletions(-) diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 06f156af..ce9957c7 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -22,6 +22,9 @@ import { import { HlBuilderDashboard } from "@/components/hl-builder-dashboard"; import { RelatedProvidersSection } from "@/components/related-providers-section"; import { getPmVenueContext } from "@/lib/pm-venue-context"; +import { fetchPmDataFeedKpis } from "@/lib/pm-venue-data"; +import { fetchPerpVenueKpis } from "@/lib/perp-venue-data"; +import { hasRpcProviderData } from "@/lib/rpc-hub-stats"; import { PmVenueSection } from "@/components/pm-venue-section"; import { getPerpVenueContext, @@ -186,6 +189,26 @@ export default async function ProviderPage({ // Perp DEX cohort dashboard. Same pattern as the PM context above. const perpContext = await getPerpVenueContext(p.slug); + // KPI-domain availability, resolved upfront so the pill bar knows + // every domain before rendering. A pill must never open onto an empty + // section, so each check mirrors the section's own hide-if-empty + // rule. The KPI fetchers are the same unstable_cache entries the + // sections read, so none of this costs an extra roundtrip: + // - perp: PerpVenueSection nulls when no KPI feed AND no measured row + // - PM feed: PmDataFeedSection, same rule + // - RPC: hasRpcProviderData reads the cached rpc-hub snapshot and + // matches RpcProviderChainsSection's own row filter + const perpHasData = perpContext + ? perpContext.benchRows.some((r) => r.value !== null && r.rank !== null) || + (await fetchPerpVenueKpis(perpContext.cohortSlug)) !== null + : false; + const pmFeedHasData = + pmContext?.kind === "feed" + ? pmContext.benchRows.some((r) => r.value !== null && r.rank !== null) || + (await fetchPmDataFeedKpis(pmContext.slug)) !== null + : false; + const hasRpcData = await hasRpcProviderData(p.slug); + const sorted = [...p.appearances].sort((a, b) => { if (a.rank !== b.rank) return a.rank - b.rank; return a.benchmark.title.localeCompare(b.benchmark.title); @@ -539,59 +562,84 @@ export default async function ProviderPage({ ); })()} - {hlStats && } - {(() => { - // Cohort sections. When a product belongs to BOTH the PM venue - // cohort and the perp cohort, wrap the two sections in a pill - // toggle so both stay reachable on the same page. With a single - // cohort the section renders directly, no toggle bar. - const pmVenueSection = - pmContext?.kind === "venue" ? ( - - ) : null; - const perpVenueSection = perpContext ? ( - - ) : null; - if (pmVenueSection && perpVenueSection) { - return ( - - ); + // KPI domain sections. A product can belong to up to five KPI + // domains (perp venue, PM venue, PM data feed, HL builder, RPC + // provider). Every domain with data joins ONE pill bar so all + // stay reachable on the same page; with a single domain the + // section renders directly, no toggle bar (VenueKpiToggle + // handles both cases). Availability is resolved before render: + // perpContext / pmContext / hlStats above, hasRpcData for the + // rpc-hub snapshot. + const sections: { id: string; label: string; content: React.ReactNode }[] = []; + if (perpContext && perpHasData) { + sections.push({ + id: "perp", + label: "Perpetuals", + content: ( + + ), + }); + } + if (pmContext?.kind === "venue") { + sections.push({ + id: "pm", + label: "Prediction markets", + content: ( + + ), + }); + } + if (pmContext?.kind === "feed" && pmFeedHasData) { + sections.push({ + id: "pm-feed", + label: "Data feeds", + content: ( + + ), + }); } - return pmVenueSection ?? perpVenueSection; + if (hlStats) { + sections.push({ + id: "hl", + label: "Hyperliquid", + content: , + }); + } + if (hasRpcData) { + sections.push({ + id: "rpc", + label: "RPC", + content: ( + + ), + }); + } + return ; })()} - {pmContext?.kind === "feed" && ( - - )} {reg && (
@@ -749,11 +797,6 @@ export default async function ProviderPage({
- {/* Per-chain RPC deep-dive from the rpc-hub cohort snapshot. - Renders nothing for providers outside the free-RPC cluster - (the section fetches the cached snapshot and self-filters). */} - - {badgeCards.length > 0 && ( diff --git a/src/components/venue-kpi-toggle.tsx b/src/components/venue-kpi-toggle.tsx index 33435515..5e8c5d4d 100644 --- a/src/components/venue-kpi-toggle.tsx +++ b/src/components/venue-kpi-toggle.tsx @@ -3,15 +3,16 @@ import { useState } from "react"; /** - * Pill toggle that swaps between pre-rendered venue sections on - * /products/ without navigation. The sections are server + * Pill toggle that swaps between pre-rendered KPI domain sections on + * /products/ without navigation (perp venue, PM venue, PM data + * feed, Hyperliquid builder, RPC provider). The sections are server * components rendered by the page and passed in as ReactNode content, * so switching tabs costs zero network round trips. * - * Callers only mount this when at least two sections exist; with a - * single cohort the page renders the section directly (a one-button - * toggle bar would be noise). Inactive sections stay in the DOM under - * `hidden` so tab switches are instant and anchors keep working. + * With a single section the content renders directly (a one-button + * toggle bar would be noise); with none, nothing. Inactive sections + * stay in the DOM under `hidden` so tab switches are instant and + * anchors keep working. * * Pill styling mirrors PmHubTabs / PerpHubTabs. */ @@ -37,7 +38,7 @@ export function VenueKpiToggle({
{sections.map((s) => (