Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 98 additions & 55 deletions src/app/products/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -539,59 +562,84 @@ export default async function ProviderPage({
);
})()}

{hlStats && <HlBuilderDashboard stats={hlStats} name={p.name} />}

{(() => {
// 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" ? (
<PmVenueSection
slug={pmContext.slug}
name={pmContext.name}
chainLabel={pmContext.chainLabel}
externalUrl={pmContext.externalUrl}
venueType={pmContext.venueType}
benchRows={pmContext.benchRows}
/>
) : null;
const perpVenueSection = perpContext ? (
<PerpVenueSection
slug={perpContext.slug}
cohortSlug={perpContext.cohortSlug}
name={perpContext.name}
chainLabel={perpContext.chainLabel}
externalUrl={perpContext.externalUrl}
benchRows={perpContext.benchRows}
/>
) : null;
if (pmVenueSection && perpVenueSection) {
return (
<VenueKpiToggle
sections={[
{
id: "pm",
label: "Prediction markets",
content: pmVenueSection,
},
{ id: "perp", label: "Perpetuals", content: perpVenueSection },
]}
/>
);
// 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: (
<PerpVenueSection
slug={perpContext.slug}
cohortSlug={perpContext.cohortSlug}
name={perpContext.name}
chainLabel={perpContext.chainLabel}
externalUrl={perpContext.externalUrl}
benchRows={perpContext.benchRows}
/>
),
});
}
if (pmContext?.kind === "venue") {
sections.push({
id: "pm",
label: "Prediction markets",
content: (
<PmVenueSection
slug={pmContext.slug}
name={pmContext.name}
chainLabel={pmContext.chainLabel}
externalUrl={pmContext.externalUrl}
venueType={pmContext.venueType}
benchRows={pmContext.benchRows}
/>
),
});
}
if (pmContext?.kind === "feed" && pmFeedHasData) {
sections.push({
id: "pm-feed",
label: "Data feeds",
content: (
<PmDataFeedSection
slug={pmContext.slug}
name={pmContext.name}
logoSrc={pmContext.logoSrc}
externalUrl={pmContext.externalUrl}
benchRows={pmContext.benchRows}
/>
),
});
}
return pmVenueSection ?? perpVenueSection;
if (hlStats) {
sections.push({
id: "hl",
label: "Hyperliquid",
content: <HlBuilderDashboard stats={hlStats} name={p.name} />,
});
}
if (hasRpcData) {
sections.push({
id: "rpc",
label: "RPC",
content: (
<RpcProviderChainsSection
providerSlug={p.slug}
providerName={p.name}
/>
),
});
}
return <VenueKpiToggle sections={sections} />;
})()}
{pmContext?.kind === "feed" && (
<PmDataFeedSection
slug={pmContext.slug}
name={pmContext.name}
logoSrc={pmContext.logoSrc}
externalUrl={pmContext.externalUrl}
benchRows={pmContext.benchRows}
/>
)}

{reg && (
<section className="mt-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-8">
Expand Down Expand Up @@ -749,11 +797,6 @@ export default async function ProviderPage({
</ol>
</section>

{/* 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). */}
<RpcProviderChainsSection providerSlug={p.slug} providerName={p.name} />

<RelatedProvidersSection providerSlug={p.slug} providerName={p.name} />

{badgeCards.length > 0 && (
Expand Down
15 changes: 8 additions & 7 deletions src/components/venue-kpi-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
import { useState } from "react";

/**
* Pill toggle that swaps between pre-rendered venue sections on
* /products/<slug> without navigation. The sections are server
* Pill toggle that swaps between pre-rendered KPI domain sections on
* /products/<slug> 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.
*/
Expand All @@ -37,7 +38,7 @@ export function VenueKpiToggle({
<div
className="inline-flex rounded-lg border border-ink/15 p-1 bg-paper-soft/40"
role="tablist"
aria-label="Venue cohorts"
aria-label="KPI domains"
>
{sections.map((s) => (
<button
Expand Down
18 changes: 18 additions & 0 deletions src/lib/rpc-hub-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,3 +443,21 @@ const fetchRpcHubCached = unstable_cache(
export async function fetchRpcHub(): Promise<RpcHubSnapshot | null> {
return fetchRpcHubCached();
}

/**
* True when the provider would produce at least one row in
* RpcProviderChainsSection: present in any chain's live providers[] or
* its unresponsive[] rows. Reads the same cached snapshot the section
* itself reads (fetchRpcHub is unstable_cache'd), so calling this from
* a page that also renders the section costs no extra roundtrip. Used
* to decide upfront whether the RPC domain gets a KPI pill.
*/
export async function hasRpcProviderData(slug: string): Promise<boolean> {
const snapshot = await fetchRpcHub();
if (!snapshot) return false;
return snapshot.chains.some(
(c) =>
c.providers.some((p) => p.provider === slug) ||
c.unresponsive?.some((u) => u.provider === slug),
);
}
Loading