diff --git a/src/app/badges/page.tsx b/src/app/badges/page.tsx new file mode 100644 index 00000000..0318756e --- /dev/null +++ b/src/app/badges/page.tsx @@ -0,0 +1,226 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { getBenchmarksSafe } from "@/data/benchmarks"; +import { BadgesCatalog, type BadgePair } from "@/components/badges-catalog"; +import { pageMetadata } from "@/lib/page-metadata"; +import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld"; +import { SITE } from "@/data/site"; + +/** + * Public discovery page for the per-(benchmark, provider) badge endpoints. + * + * The /api/badge// route has been live for months and + * powers per-row embed buttons on bench pages and the /partners docs, + * but nothing on the site lets a visitor browse what is available. + * This page is the shields.io-style catalog: search, filter, preview, + * copy. Also doubles as a backlink generator: every embedded snippet + * is one more link back to a bench page. + * + * Loads from `getBenchmarksSafe` so a Prom blackout still renders the + * shell with whatever the carry-forward snapshot holds. Pairs that + * have no live rank are filtered out so the page never advertises a + * "rank N/A" badge. + */ + +export const revalidate = 600; + +const DESCRIPTION = + "Browse every live ranking badge published by OpenChainBench. Search by provider or category, preview the SVG, then copy a Markdown, HTML, URL or JSON snippet for your README, docs or marketing page. Free, CC-BY-4.0."; + +export const metadata: Metadata = pageMetadata({ + path: "/badges", + title: "Live ranking badges", + description: DESCRIPTION, +}); + +/** Hand-picked example pairs surfaced in the hero preview row. Each one + * is a recognisable leader the page can safely showcase. Pairs missing + * from the live snapshot (provider absent, bench in draft) are silently + * skipped so a transient outage doesn't break the layout. */ +const PREVIEW_PAIRS: Array<{ bench: string; provider: string }> = [ + { bench: "bridge-fee", provider: "mobula" }, + { bench: "perp-fees", provider: "lighter" }, + { bench: "rpc-capabilities", provider: "drpc" }, + { bench: "nft-collection-metadata", provider: "moralis" }, + { bench: "pm-api-latency", provider: "polymarket" }, +]; + +export default async function BadgesPage() { + const benchmarks = await getBenchmarksSafe(); + + const live = benchmarks.filter((b) => b.editorialStatus === "live"); + + // Flat list of (bench, provider) pairs the catalog can browse. Each + // entry carries the ranked position so the card can flash "#1/12" + // without an extra fetch. Sort order: best ranks first inside each + // bench, benches grouped alphabetically by category then title. + const pairs: BadgePair[] = []; + for (const b of live) { + const livePr = b.results.filter((r) => r.ms.p50 > 0); + if (livePr.length === 0) continue; + const sorted = [...livePr].sort((a, c) => + b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, + ); + sorted.forEach((r, idx) => { + pairs.push({ + benchSlug: b.slug, + benchTitle: b.title, + benchCategory: b.category, + providerSlug: r.slug, + providerName: r.name, + rank: idx + 1, + total: sorted.length, + dimensions: { + chain: b.dimensions?.chain, + region: b.dimensions?.region, + }, + }); + }); + } + pairs.sort((a, b) => { + if (a.benchCategory !== b.benchCategory) + return a.benchCategory.localeCompare(b.benchCategory); + if (a.benchTitle !== b.benchTitle) + return a.benchTitle.localeCompare(b.benchTitle); + return a.rank - b.rank; + }); + + // Lookup table so the hero preview row can resolve the canonical + // provider name + alt text without a second pass over the snapshot. + const pairKey = (bench: string, provider: string) => `${bench}|${provider}`; + const pairIndex = new Map( + pairs.map((p) => [pairKey(p.benchSlug, p.providerSlug), p]), + ); + const previewItems = PREVIEW_PAIRS.map((p) => + pairIndex.get(pairKey(p.bench, p.provider)), + ).filter((p): p is BadgePair => Boolean(p)); + + const collectionLd = { + "@context": "https://schema.org", + "@type": "CollectionPage", + name: "OpenChainBench live ranking badges", + description: DESCRIPTION, + url: `${SITE.url}/badges`, + isPartOf: { + "@type": "WebSite", + name: SITE.name, + url: SITE.url, + }, + mainEntity: { + "@type": "ItemList", + name: "OpenChainBench badges", + numberOfItems: pairs.length, + itemListElement: pairs.slice(0, 100).map((p, i) => ({ + "@type": "ListItem", + position: i + 1, + name: `${p.providerName} on ${p.benchTitle}`, + url: `${SITE.url}/api/badge/${p.benchSlug}/${p.providerSlug}`, + })), + }, + }; + + const breadcrumbLd = { + "@context": "https://schema.org", + ...buildBreadcrumbJsonLd([ + { name: "Home", item: SITE.url }, + { name: "Badges", item: `${SITE.url}/badges` }, + ]), + }; + + return ( +
+