From dec5ea08e268ff9fdf559de410b214a55d8417a7 Mon Sep 17 00:00:00 2001 From: sigco3111 Date: Thu, 30 Jul 2026 17:12:49 +0900 Subject: [PATCH] feat(i18n): en/ko dictionary + cookie-driven locale + UI plumbing - Add lib/i18n/ (Locale union, dictionaries, translator API, server + client) - Wire LocaleProvider into app/layout.tsx, hydrate from cookie - Convert ScoutForm, LoadingScreen, HowItWorksModal, ResultView, DuelView (incl. duel stat row labels + receipts + share/draw headlines), FlagPicker (country names), 404/error pages to the i18n API - Translate the scoring engine's archetype blurbs, playstyle names, metric labels, style and workRate reasons - Add LocaleToggle in the AppShell corner (cookie write + custom event) - Pin buildCard() locale='en' for OG / JSON endpoints (locked keys: t('@...')) - Add 250-entry countries table (priority KO overrides + EN fallback) - Add tests/i18n.test.ts covering dictionary parity, fallback chains, negotiateLocale, format helpers --- app/[username]/vs/[opponent]/page.tsx | 4 +- app/error.tsx | 20 +- app/global-error.tsx | 17 +- app/layout.tsx | 82 ++-- app/not-found.tsx | 28 +- components/AppShell.tsx | 4 +- components/DuelView.tsx | 55 +-- components/FlagPicker.tsx | 5 +- components/HowItWorksModal.tsx | 101 ++--- components/LoadingScreen.tsx | 12 +- components/LocaleToggle.tsx | 70 ++++ components/ResultView.tsx | 15 +- components/ScoutForm.tsx | 31 +- lib/duel.ts | 41 +- lib/github/samples.ts | 2 +- lib/i18n/LocaleProvider.tsx | 73 ++++ lib/i18n/dict/en.ts | 548 ++++++++++++++++++++++++++ lib/i18n/dict/ko.ts | 535 +++++++++++++++++++++++++ lib/i18n/dict/types.ts | 269 +++++++++++++ lib/i18n/dictionaries.ts | 17 + lib/i18n/format.ts | 55 +++ lib/i18n/hooks.ts | 36 ++ lib/i18n/index.ts | 57 +++ lib/i18n/server.ts | 36 ++ lib/i18n/t.ts | 265 +++++++++++++ lib/i18n/useLocale.ts | 48 +++ lib/puns.ts | 28 +- lib/scoring/attributes.ts | 144 ++++--- lib/scoring/engine.ts | 44 ++- lib/scoring/playstyles.ts | 65 ++- lib/scout.ts | 5 +- tests/i18n.test.ts | 175 ++++++++ 32 files changed, 2592 insertions(+), 295 deletions(-) create mode 100644 components/LocaleToggle.tsx create mode 100644 lib/i18n/LocaleProvider.tsx create mode 100644 lib/i18n/dict/en.ts create mode 100644 lib/i18n/dict/ko.ts create mode 100644 lib/i18n/dict/types.ts create mode 100644 lib/i18n/dictionaries.ts create mode 100644 lib/i18n/format.ts create mode 100644 lib/i18n/hooks.ts create mode 100644 lib/i18n/index.ts create mode 100644 lib/i18n/server.ts create mode 100644 lib/i18n/t.ts create mode 100644 lib/i18n/useLocale.ts create mode 100644 tests/i18n.test.ts diff --git a/app/[username]/vs/[opponent]/page.tsx b/app/[username]/vs/[opponent]/page.tsx index 4f45375..da68a91 100644 --- a/app/[username]/vs/[opponent]/page.tsx +++ b/app/[username]/vs/[opponent]/page.tsx @@ -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 @@ -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 (
diff --git a/app/error.tsx b/app/error.tsx index bc2e79a..5c18ebd 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -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 @@ -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 (
-
SCOUT REPORT
-

The scout hit a snag

+
{errKicker}
+

{errTitle}

- Something broke mid-scout. Try again — if it keeps happening, the pitch may be down for a moment. + {errBody}

- HOME + {home}
{error.digest &&

ref: {error.digest}

} diff --git a/app/global-error.tsx b/app/global-error.tsx index 7f2c01d..7a456f4 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -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 /). @@ -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 ( - +
GITFUT
-

Match abandoned

+

{ui.globalTitle}

- Something went badly wrong. Reload to get back to the pitch. + {ui.globalBody}

{error.digest && (

{ + // 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 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 ( - {children} + {children}