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
432 changes: 432 additions & 0 deletions src/app/reports/[category]/[slug]/page.tsx

Large diffs are not rendered by default.

119 changes: 119 additions & 0 deletions src/app/reports/[category]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { SectionRule } from "@/components/section-rule";
import { ReportCard } from "@/components/reports/report-card";
import {
getAllReportCategories,
getReportsByCategory,
REPORT_CATEGORY_META,
} from "@/lib/reports/loader";
import { pageMetadata } from "@/lib/page-metadata";
import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld";
import { SITE } from "@/data/site";

type Props = { params: Promise<{ category: string }> };

export async function generateStaticParams() {
return getAllReportCategories().map((category) => ({ category }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { category } = await params;
const meta = REPORT_CATEGORY_META[category];
if (!meta) return {};
return pageMetadata({
path: `/reports/${category}`,
title: `${meta.label} Reports`,
description: meta.description,
});
}

export default async function CategoryPage({ params }: Props) {
const { category } = await params;
const meta = REPORT_CATEGORY_META[category];
if (!meta) notFound();

const reports = getReportsByCategory(category);
const pageUrl = `${SITE.url}/reports/${category}`;

const breadcrumbLd = {
"@context": "https://schema.org",
...buildBreadcrumbJsonLd([
{ name: "Home", item: SITE.url },
{ name: "Reports", item: `${SITE.url}/reports` },
{ name: `${meta.label} Reports`, item: pageUrl },
]),
};

const collectionLd = {
"@context": "https://schema.org",
"@type": "CollectionPage",
"@id": `${pageUrl}#collection`,
name: `${meta.label} Reports`,
description: meta.description,
url: pageUrl,
numberOfItems: reports.length,
hasPart: reports.map((r) => ({
"@type": "Article",
name: r.title,
url: `${SITE.url}/reports/${category}/${r.slug}`,
datePublished: r.publishedAt,
description: r.summary,
})),
};

return (
<div className="mx-auto max-w-[1400px] px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: serialized via safeJsonLd
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbLd) }}
/>
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: serialized via safeJsonLd
dangerouslySetInnerHTML={{ __html: safeJsonLd(collectionLd) }}
/>
<nav className="mb-6 flex items-center gap-2 label-mono text-[11px] text-ink-muted">
<Link href="/reports" className="hover:text-ink transition-colors">
Reports
</Link>
<span>/</span>
<span className="text-ink">{meta.label}</span>
</nav>

<header className="border-b-2 border-ink pb-6 max-w-3xl">
<h1 className="display text-3xl sm:text-4xl tracking-tight">
{meta.label} Reports
</h1>
<p className="mt-3 text-base sm:text-lg text-ink-soft leading-snug max-w-2xl">
{meta.description}
</p>
<div className="mt-4">
<a
href={`/reports/${category}/rss.xml`}
className="label-mono text-[11px] border border-rule px-2 py-1 text-ink-muted hover:text-ink hover:border-ink transition-colors"
>
RSS feed
</a>
</div>
</header>

{reports.length === 0 ? (
<div className="mt-14 text-ink-muted text-base">
No {meta.label} reports published yet. Check back soon.
</div>
) : (
<>
<SectionRule label={`${meta.label} reports`} number="i" />
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{reports.map((r) => (
<ReportCard key={r.slug} report={r} />
))}
</div>
</>
)}
</div>
);
}
71 changes: 71 additions & 0 deletions src/app/reports/[category]/rss.xml/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { getReportsByCategory, REPORT_CATEGORY_META } from "@/lib/reports/loader";
import { SITE } from "@/data/site";

export const revalidate = 3600;

function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

function toRfc822(d: Date): string {
return d.toUTCString().replace("GMT", "+0000");
}

export async function GET(
_req: Request,
{ params }: { params: Promise<{ category: string }> },
) {
const { category } = await params;
const catMeta = REPORT_CATEGORY_META[category];
const reports = getReportsByCategory(category);
const self = `${SITE.url}/reports/${category}/rss.xml`;
const title = catMeta
? `OpenChainBench ${catMeta.label} Reports`
: `OpenChainBench ${category} Reports`;
const description = catMeta?.description ?? `OpenChainBench reports on ${category}.`;

const lines: string[] = [];
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
lines.push('<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">');
lines.push(" <channel>");
lines.push(` <title>${escapeXml(title)}</title>`);
lines.push(` <link>${SITE.url}/reports/${category}</link>`);
lines.push(` <description>${escapeXml(description)}</description>`);
lines.push(" <language>en</language>");
lines.push(` <lastBuildDate>${toRfc822(new Date())}</lastBuildDate>`);
lines.push(
` <atom:link href="${self}" rel="self" type="application/rss+xml" />`,
);

for (const r of reports) {
const link = `${SITE.url}/reports/${category}/${r.slug}`;
lines.push(" <item>");
lines.push(` <title>${escapeXml(r.title)}</title>`);
lines.push(` <link>${link}</link>`);
lines.push(` <guid isPermaLink="true">${link}</guid>`);
lines.push(
` <pubDate>${toRfc822(new Date(r.publishedAt))}</pubDate>`,
);
lines.push(
` <description>${escapeXml(r.summary)}</description>`,
);
lines.push(" </item>");
}

lines.push(" </channel>");
lines.push("</rss>");

return new NextResponse(lines.join("\n"), {
status: 200,
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
146 changes: 146 additions & 0 deletions src/app/reports/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ReportCard } from "@/components/reports/report-card";
import { getAllReports, REPORT_CATEGORY_META } from "@/lib/reports/loader";
import { pageMetadata } from "@/lib/page-metadata";
import { reportsLandingLd } from "@/lib/hub-jsonld";
import { safeJsonLd } from "@/lib/jsonld";
import { SITE } from "@/data/site";

export const metadata: Metadata = pageMetadata({
path: "/reports",
title: "Reports",
description:
"Monthly and quarterly research reports on crypto infrastructure performance: RPC reliability, bridge fees, and prediction markets. Citable, reproducible, CC BY 4.0.",
});

const SERIF: React.CSSProperties = { fontFamily: "var(--font-serif)" };

export default function ReportsPage() {
const reports = getAllReports();
const [featured, ...rest] = reports;
const categories = Object.entries(REPORT_CATEGORY_META) as Array<[string, { label: string; description: string }]>;

return (
<div className="mx-auto max-w-[1400px] px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{reportsLandingLd().map((ld, i) => (
<script
key={i}
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: serialized via safeJsonLd
dangerouslySetInnerHTML={{ __html: safeJsonLd(ld) }}
/>
))}

{/* ── Masthead ── */}
<header className="border-b-2 border-ink pb-6 mb-10">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
<div>
<h1 className="display text-3xl sm:text-4xl tracking-tight">Reports</h1>
<p
className="mt-2 text-base sm:text-lg text-ink-soft leading-relaxed max-w-xl"
style={SERIF}
>
Research on crypto infrastructure performance. Every figure is live
benchmark data, citable under CC&nbsp;BY&nbsp;4.0.
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<a
href="/reports/rss.xml"
className="label-mono text-[11px] border border-rule px-2.5 py-1.5 text-ink-muted hover:text-ink hover:border-ink transition-colors"
>
RSS
</a>
<a
href={`https://x.com/${SITE.twitter}`}
target="_blank"
rel="noopener noreferrer"
className="label-mono text-[11px] border border-rule px-2.5 py-1.5 text-ink-muted hover:text-ink hover:border-ink transition-colors"
>
{SITE.twitter}
</a>
</div>
</div>
</header>

{reports.length === 0 ? (
<p className="text-ink-muted text-base mt-14">No reports published yet. Check back soon.</p>
) : (
<div className="lg:grid lg:grid-cols-12 lg:gap-16">
{/* ── Left: Featured + rest ── */}
<div className="lg:col-span-8">
{/* Featured latest report */}
{featured && <ReportCard report={featured} featured />}

{/* Grid of secondary reports */}
{rest.length > 0 && (
<div className="grid gap-5 sm:grid-cols-2">
{rest.map((r) => (
<ReportCard key={`${r.categorySlug}/${r.slug}`} report={r} />
))}
</div>
)}
</div>

{/* ── Right: Category sidebar ── */}
<aside className="lg:col-span-4 mt-12 lg:mt-0">
<div className="sticky top-24 space-y-8">
<div>
<h2 className="label-mono text-[11px] uppercase tracking-[0.18em] text-ink-muted mb-4">
Browse by topic
</h2>
<div className="space-y-px">
{categories.map(([slug, meta]) => (
<Link
key={slug}
href={`/reports/${slug}`}
className="group flex items-start justify-between gap-4 border border-rule hover:border-ink/40 transition-colors p-4"
>
<div>
<p className="font-medium text-sm text-ink group-hover:text-ink-soft transition-colors">
{meta.label}
</p>
<p className="mt-1 text-xs text-ink-muted leading-relaxed">
{meta.description}
</p>
</div>
<span className="label-mono text-[11px] text-ink-faint group-hover:text-ink-muted transition-colors shrink-0 mt-0.5">
</span>
</Link>
))}
</div>
</div>

<div className="border-t border-rule pt-6">
<p className="label-mono text-[11px] uppercase tracking-[0.18em] text-ink-muted mb-3">
Get updates
</p>
<p className="text-sm text-ink-muted leading-relaxed mb-3">
New reports ship every 2–3 months. Subscribe via RSS or follow on X.
</p>
<div className="flex flex-col gap-2">
<a
href="/reports/rss.xml"
className="label-mono text-[11px] border border-rule px-3 py-2 text-ink-muted hover:text-ink hover:border-ink transition-colors text-center"
>
RSS feed
</a>
<a
href={`https://x.com/${SITE.twitter}`}
target="_blank"
rel="noopener noreferrer"
className="label-mono text-[11px] border border-rule px-3 py-2 text-ink-muted hover:text-ink hover:border-ink transition-colors text-center"
>
{SITE.twitter} on X
</a>
</div>
</div>
</div>
</aside>
</div>
)}
</div>
);
}
Loading
Loading