Skip to content
Open
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
4 changes: 3 additions & 1 deletion app/[username]/vs/[opponent]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getRepoStars } from "@/lib/github/stars";
import { pickFlag } from "@/lib/flagPriority";
import { recordScout } from "@/lib/analytics";
import { computeDuel } from "@/lib/duel";
import { resolveServerLocale } from "@/lib/i18n/server";
import type { Card } from "@/lib/scoring/types";

export const dynamic = "force-dynamic"; // per-user, token-gated, always fresh
Expand Down Expand Up @@ -120,7 +121,8 @@ export default async function Page({ params }: Params) {

after(() => Promise.all([recordScout(), recordScout()])); // a duel is two scouts

const duel = computeDuel(withFlag(a.card), withFlag(b.card));
const locale = await resolveServerLocale();
const duel = computeDuel(withFlag(a.card), withFlag(b.card), locale);
return (
<div className="relative min-h-screen overflow-x-hidden text-ink">
<Background />
Expand Down
20 changes: 15 additions & 5 deletions app/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { useEffect } from "react";
import Link from "next/link";
import Background from "@/components/Background";
import { useLocale } from "@/lib/i18n/useLocale";
import { useUIDict } from "@/lib/i18n/hooks";

// Route-level error boundary. Catches an unexpected throw in the route subtree —
// a bad render, or an upstream failure that isn't a clean GithubError the page
Expand All @@ -16,27 +18,35 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri
console.error("[gitfut] route error:", error);
}, [error]);

const locale = useLocale();
const ui = useUIDict().errors;
const errKicker = ui.errorKicker;
const errTitle = ui.errorTitle;
const errBody = ui.errorBody;
const tryAgain = locale === "ko" ? "다시 시도" : "TRY AGAIN";
const home = locale === "ko" ? "홈" : "HOME";

return (
<div className="relative min-h-screen overflow-x-hidden text-ink">
<Background />
<main className="relative z-[2] mx-auto flex min-h-screen max-w-[560px] flex-col items-center justify-center px-6 text-center">
<div className="font-display text-[12px] font-bold tracking-[.3em] text-brand">SCOUT REPORT</div>
<h1 className="font-display mt-3 text-[clamp(30px,6vw,48px)] font-black leading-[.95]">The scout hit a snag</h1>
<div className="font-display text-[12px] font-bold tracking-[.3em] text-brand">{errKicker}</div>
<h1 className="font-display mt-3 text-[clamp(30px,6vw,48px)] font-black leading-[.95]">{errTitle}</h1>
<p className="mt-3 text-[15.5px] leading-[1.5] text-ink-soft">
Something broke mid-scout. Try again — if it keeps happening, the pitch may be down for a moment.
{errBody}
</p>
<div className="mt-7 flex items-center gap-3">
<button
onClick={reset}
className="font-display inline-flex h-[46px] items-center rounded-xl bg-brand px-6 text-[16px] tracking-[.06em] text-[#04130a] transition hover:bg-brand-hi"
>
TRY AGAIN
{tryAgain}
</button>
<Link
href="/"
className="font-display inline-flex h-[46px] items-center rounded-xl border border-line px-6 text-[16px] tracking-[.06em] text-ink-soft transition hover:border-ink-soft hover:text-ink"
>
HOME
{home}
</Link>
</div>
{error.digest && <p className="mt-6 font-mono text-[11px] tracking-wide text-ink-mute">ref: {error.digest}</p>}
Expand Down
17 changes: 13 additions & 4 deletions app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import { useEffect } from "react";
import { useLocale } from "@/lib/i18n/useLocale";
import { useUIDict } from "@/lib/i18n/hooks";

// Last-resort boundary: a throw in the root layout itself escapes app/error.tsx,
// so this replaces the entire document (it must render its own <html>/<body>).
Expand All @@ -18,8 +20,15 @@ export default function GlobalError({
console.error("[gitfut] global error:", error);
}, [error]);

// GlobalError renders before the LocaleProvider mounts, so useLocale() may
// not have a value. Fall back to ENGLISH silently — global errors are rare
// and the user can still resolve the message.
const locale = useLocale();
const ui = useUIDict().errors;
void locale;

return (
<html lang="en">
<html lang={locale}>
<body
style={{
margin: 0,
Expand All @@ -36,9 +45,9 @@ export default function GlobalError({
>
<div style={{ maxWidth: 460 }}>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: "0.3em", color: "#39d353" }}>GITFUT</div>
<h1 style={{ margin: "14px 0 0", fontSize: 34, fontWeight: 800, lineHeight: 1.05 }}>Match abandoned</h1>
<h1 style={{ margin: "14px 0 0", fontSize: 34, fontWeight: 800, lineHeight: 1.05 }}>{ui.globalTitle}</h1>
<p style={{ margin: "14px 0 0", fontSize: 15.5, lineHeight: 1.5, color: "#a8b3bd" }}>
Something went badly wrong. Reload to get back to the pitch.
{ui.globalBody}
</p>
<button
onClick={reset}
Expand All @@ -56,7 +65,7 @@ export default function GlobalError({
cursor: "pointer",
}}
>
RELOAD
{locale === "ko" ? "새로고침" : "RELOAD"}
</button>
{error.digest && (
<p
Expand Down
82 changes: 49 additions & 33 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { Bebas_Neue, Inter, JetBrains_Mono } from "next/font/google";
import localFont from "next/font/local";
import Script from "next/script";
import "./globals.css";
import { cookies } from "next/headers";
import { DEFAULT_LOCALE, LOCALE_COOKIE_NAME, localeFromCookie } from "@/lib/i18n";
import { getDict } from "@/lib/i18n/dictionaries";
import { LocaleProvider } from "@/lib/i18n/LocaleProvider";

// Display — ultra-condensed all-caps for the WC26 "tournament" impact.
const display = Bebas_Neue({
Expand Down Expand Up @@ -30,51 +34,63 @@ const dinCond = localFont({ src: "./fonts/DINPro-Cond.otf", variable: "--font-di
const dinBold = localFont({ src: "./fonts/DINPro-CondBold.otf", variable: "--font-din-bold", display: "swap" });
const dinMedium = localFont({ src: "./fonts/DINPro-CondMedium.otf", variable: "--font-din-medium", display: "swap" });

const TITLE = "GitFut — your GitHub, rated out of 99";
const DESCRIPTION =
"Rate any GitHub profile out of 99 as a FIFA-Ultimate-Team-style player card, scored from real commits, stars and contributions. Get scouted and share your card.";

export const metadata: Metadata = {
metadataBase: new URL("https://gitfut.com"),
title: TITLE,
description: DESCRIPTION,
keywords: [
"GitHub profile card",
"rate my GitHub",
"GitHub stats",
"developer trading card",
"FUT card",
"GitHub rating",
"World Cup",
"GitFut",
],
alternates: { canonical: "/" },
openGraph: {
title: TITLE,
description: DESCRIPTION,
url: "https://gitfut.com",
siteName: "GitFut",
type: "website",
},
twitter: {
card: "summary_large_image",
export async function generateMetadata(): Promise<Metadata> {
// Title/description are pinned to EN for SEO crawlers — search engines
// index the EN copy and ignore the user's locale. Switching to per-locale
// metadata would require <Link hreflang="..."> for the homepage which is
// not part of this change.
const en = getDict(DEFAULT_LOCALE);
const TITLE = `${en.meta.brand} — ${en.meta.tagline}`;
const DESCRIPTION =
"Rate any GitHub profile out of 99 as a FIFA-Ultimate-Team-style player card, scored from real commits, stars and contributions. Get scouted and share your card.";
return {
metadataBase: new URL("https://gitfut.com"),
title: TITLE,
description: DESCRIPTION,
},
};
keywords: [
"GitHub profile card",
"rate my GitHub",
"GitHub stats",
"developer trading card",
"FUT card",
"GitHub rating",
"World Cup",
"GitFut",
],
alternates: { canonical: "/" },
openGraph: {
title: TITLE,
description: DESCRIPTION,
url: "https://gitfut.com",
siteName: "GitFut",
type: "website",
},
twitter: {
card: "summary_large_image",
title: TITLE,
description: DESCRIPTION,
},
};
}

export const viewport: Viewport = {
themeColor: "#02001e",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// Resolve locale on the server (cookie > Accept-Language > default) so the
// initial markup matches the user's locale — prevents the visible "EN briefly
// then KO" flash on first paint.
const cookieStore = await cookies();
const locale = localeFromCookie(cookieStore.get(LOCALE_COOKIE_NAME)?.value);

return (
<html
lang="en"
lang={locale}
className={`${display.variable} ${sans.variable} ${mono.variable} ${dinCond.variable} ${dinBold.variable} ${dinMedium.variable} antialiased`}
>
<body>
{children}
<LocaleProvider initialLocale={locale}>{children}</LocaleProvider>
<Script
defer
src="https://umami.gitfut.com/script.js"
Expand Down
28 changes: 21 additions & 7 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import Background from "@/components/Background";
import Mascot from "@/components/Mascot";
import { LOCALE_COOKIE_NAME, DEFAULT_LOCALE, localeFromCookie } from "@/lib/i18n";
import { getDict } from "@/lib/i18n/dictionaries";
import { resolveServerLocale } from "@/lib/i18n/server";

// not-found — for static-asset routes / random slugs, metadata is rendered
// without request context so we serve the EN title for crawlers and let the
// page body localize via in-component lookups.
export const metadata: Metadata = {
title: "404 · Offside — GitFut",
robots: { index: false },
Expand Down Expand Up @@ -45,7 +52,14 @@ function OffsideFlag() {
);
}

export default function NotFound() {
export default async function NotFound() {
const cookieStore = await cookies();
const cookieValue = cookieStore.get(LOCALE_COOKIE_NAME)?.value;
const locale = localeFromCookie(cookieValue) ?? (await resolveServerLocale());
const ui = getDict(locale).ui.errors;
const notFoundTitle = ui.notFoundTitle;
const notFoundKicker = ui.notFoundKicker;
const notFoundBody = ui.notFoundBody;
return (
<div className="relative min-h-screen overflow-x-hidden text-ink">
<Background />
Expand All @@ -61,33 +75,33 @@ export default function NotFound() {
<Mascot size={68} className="mb-1 opacity-95" />
</div>

<p className="font-display text-[12px] font-bold tracking-[.3em] text-brand">FLAG ON THE PLAY</p>
<p className="font-display text-[12px] font-bold tracking-[.3em] text-brand">{notFoundKicker}</p>

<h1 className="font-display mt-2 text-[clamp(64px,16vw,132px)] font-black leading-[.84]">
<span aria-hidden>OFFSIDE</span>
<span className="sr-only">Offside404, page not found</span>
<span aria-hidden>{notFoundTitle}</span>
<span className="sr-only">{`404${notFoundTitle}`}</span>
</h1>

<p className="font-mono mt-2 text-[13px] font-medium tracking-[.55em] text-ink-faint">
4 · 0 · 4
</p>

<p className="mt-5 max-w-[430px] text-[15.5px] leading-[1.55] text-ink-soft">
This page strayed past the last defender — there&rsquo;s no route here on the pitch.
{notFoundBody}
</p>

<div className="mt-8 flex flex-col items-center gap-[14px]">
<Link
href="/"
className="font-display inline-flex h-[46px] items-center rounded-xl bg-brand px-7 text-[16px] tracking-[.06em] text-[#04130a] transition hover:bg-brand-hi focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
>
BACK TO THE PITCH
{locale === "ko" ? "피치로 돌아가기" : "BACK TO THE PITCH"}
</Link>
<Link
href="/"
className="rounded text-[13.5px] font-medium text-ink-faint underline-offset-4 transition hover:text-brand hover:underline focus-visible:text-brand focus-visible:underline focus-visible:outline-none"
>
Scout a developer &rarr;
{locale === "ko" ? "개발자 스카우트 →" : "Scout a developer →"}
</Link>
</div>
</main>
Expand Down
4 changes: 3 additions & 1 deletion components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import FooterCredit from "@/components/FooterCredit";
import BuyMeACoffee from "@/components/BuyMeACoffee";
import SupportProductHunt from "@/components/SupportProductHunt";
import GithubStar from "@/components/GithubStar";
import LocaleToggle from "@/components/LocaleToggle";
import { SAMPLE_CARDS } from "@/lib/github/samples";

const HowItWorksModal = dynamic(() => import("@/components/HowItWorksModal"), {
Expand Down Expand Up @@ -57,7 +58,8 @@ export default function AppShell({
<main className="relative z-[2] flex min-h-screen flex-col">
{/* Overlaid in the corner (not a flow header) so it never pushes the
vertically-centered hero down. */}
<div className="absolute right-[clamp(20px,5vw,52px)] top-[clamp(16px,3vh,26px)] z-[3]">
<div className="absolute right-[clamp(20px,5vw,52px)] top-[clamp(16px,3vh,26px)] z-[3] flex items-center gap-3">
<LocaleToggle />
<GithubStar stars={stars} />
</div>
<div className="mx-auto flex w-full max-w-[1180px] flex-1 items-center gap-[clamp(24px,5vw,72px)] px-[clamp(22px,5vw,56px)] max-[860px]:flex-col max-[860px]:gap-[34px] max-[860px]:pb-6 max-[860px]:pt-[clamp(40px,6vh,56px)] max-[860px]:text-center">
Expand Down
Loading