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({