diff --git a/src/app/api/report/route.ts b/src/app/api/report/route.ts new file mode 100644 index 00000000..ab8c2a85 --- /dev/null +++ b/src/app/api/report/route.ts @@ -0,0 +1,123 @@ +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +const MAX_MESSAGE = 2000; +const MAX_CONTACT = 200; + +type Body = { + slug?: unknown; + chain?: unknown; + message?: unknown; + contact?: unknown; + page?: unknown; +}; + +export async function POST(req: Request) { + const webhook = process.env.SLACK_REPORT_WEBHOOK_URL; + if (!webhook) { + return NextResponse.json( + { error: "Reporting is not configured." }, + { status: 503 } + ); + } + + let body: Body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const slug = typeof body.slug === "string" ? body.slug.slice(0, 80) : ""; + const chain = + typeof body.chain === "string" && body.chain ? body.chain.slice(0, 40) : null; + const message = typeof body.message === "string" ? body.message.trim() : ""; + const contact = + typeof body.contact === "string" && body.contact + ? body.contact.trim().slice(0, MAX_CONTACT) + : null; + const page = typeof body.page === "string" ? body.page.slice(0, 500) : ""; + + if (!slug) { + return NextResponse.json({ error: "Missing slug." }, { status: 400 }); + } + if (message.length < 5) { + return NextResponse.json( + { error: "Message is too short. Tell us a bit more." }, + { status: 400 } + ); + } + if (message.length > MAX_MESSAGE) { + return NextResponse.json( + { error: `Message exceeds ${MAX_MESSAGE} characters.` }, + { status: 400 } + ); + } + + const ua = req.headers.get("user-agent") ?? "unknown"; + const referer = req.headers.get("referer") ?? page ?? "unknown"; + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + const titleLine = chain ? `${slug} · chain ${chain}` : slug; + const slackPayload = { + text: `New benchmark report · ${titleLine}`, + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: "🐞 New OpenChainBench report", + emoji: true, + }, + }, + { + type: "section", + fields: [ + { type: "mrkdwn", text: `*Bench*\n\`${titleLine}\`` }, + { type: "mrkdwn", text: `*Page*\n${referer}` }, + ], + }, + { + type: "section", + text: { type: "mrkdwn", text: `*Message*\n${message}` }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `*Contact:* ${contact ?? "_not provided_"} · *IP:* ${ip} · *UA:* ${ua}`, + }, + ], + }, + ], + }; + + try { + const slackRes = await fetch(webhook, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(slackPayload), + }); + if (!slackRes.ok) { + const text = await slackRes.text().catch(() => ""); + console.error("Slack webhook rejected report", slackRes.status, text); + return NextResponse.json( + { error: "Could not deliver the report. Try again in a moment." }, + { status: 502 } + ); + } + } catch (err) { + console.error("Slack webhook fetch failed", err); + return NextResponse.json( + { error: "Could not reach Slack." }, + { status: 502 } + ); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index 108c13cd..8931faad 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -10,6 +10,7 @@ import { import { Pill } from "@/components/pill"; import { BenchmarkBody } from "@/components/benchmark-body"; import { LiveIndicator } from "@/components/live-indicator"; +import { ReportSection } from "@/components/report-section"; import { SectionLabel } from "@/components/summary-stat"; import { CATEGORY_COLOR } from "@/lib/category-colors"; import { SITE } from "@/data/site"; @@ -205,15 +206,18 @@ export default async function BenchmarkPage({ - {/* Source code link. bottom of page */} + {/* Source code + report-a-problem. bottom of page */} {!isDraft && ( -

- Source code{" "} - - {benchmark.source.replace("https://github.com/", "github.com/")} - - -

+
+

+ Source code{" "} + + {benchmark.source.replace("https://github.com/", "github.com/")} + + +

+ +
)} {/* Other benchmarks */} diff --git a/src/components/report-section.tsx b/src/components/report-section.tsx new file mode 100644 index 00000000..3aaf32b4 --- /dev/null +++ b/src/components/report-section.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AlertTriangle, Loader2, X } from "lucide-react"; + +type Status = "idle" | "submitting" | "ok" | "error"; + +type Props = { + slug: string; +}; + +/** + * Discreet "Report a problem" trigger + modal. Posts to /api/report which + * forwards to a Slack webhook server-side — the URL never reaches the client. + */ +export function ReportSection({ slug }: Props) { + const [open, setOpen] = useState(false); + const [message, setMessage] = useState(""); + const [contact, setContact] = useState(""); + const [status, setStatus] = useState("idle"); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => { + document.body.style.overflow = prev; + window.removeEventListener("keydown", onKey); + }; + }, [open]); + + function close() { + setOpen(false); + if (status === "ok") { + setMessage(""); + setContact(""); + setStatus("idle"); + setErrorMsg(null); + } + } + + async function submit(e: React.SyntheticEvent) { + e.preventDefault(); + if (status === "submitting") return; + setStatus("submitting"); + setErrorMsg(null); + try { + const url = typeof window !== "undefined" ? new URL(window.location.href) : null; + const chain = url?.searchParams.get("chain") ?? null; + const res = await fetch("/api/report", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + slug, + chain, + message, + contact: contact || null, + page: url?.toString() ?? "", + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setErrorMsg( + typeof data?.error === "string" + ? data.error + : "Something went wrong. Try again." + ); + setStatus("error"); + return; + } + setStatus("ok"); + } catch { + setErrorMsg("Network error. Try again."); + setStatus("error"); + } + } + + const tooShort = message.trim().length < 5; + + return ( + <> + + + {open && ( +
+
e.stopPropagation()} + > +
+ + Report a problem + + +
+ + {status === "ok" ? ( +
+

Thanks — report received.

+

+ A maintainer will look into it. We may reach out if you left a + contact. +

+ +
+ ) : ( +
+

+ Spotted a wrong number, a missing provider, an outage, or + anything off about this benchmark? Tell us — it goes straight + to a maintainer. +

+
+ +