From c9032e478b3983d2f8014a387783e8a1ee58d48d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:57:58 -0300 Subject: [PATCH] feat(i18n): auto-detect browser language on first visit (#5979) * feat(i18n): auto-detect browser language on first visit Adds a pure detectBrowserLocale() matcher (exact match, zh-HK/zh-MO folded to zh-TW, language-prefix match, else null) plus a client-only LocaleAutoDetect component mounted once in the root layout. On first visit (no locale cookie set), it reads navigator.languages, computes a match against the supported locales, and persists it via the same cookie/localStorage writer LanguageSelector already used for manual selection (now extracted to shared/lib/persistLocale.ts) before refreshing the router. Co-authored-by: anmingwei Inspired-by: https://github.com/decolua/9router/pull/1324 * chore(changelog): restore release entries + add browser-lang-detect bullet --------- Co-authored-by: anmingwei --- CHANGELOG.md | 1 + src/app/layout.tsx | 2 + src/i18n/detectBrowserLocale.ts | 52 +++++++++++++++++++ src/shared/components/LanguageSelector.tsx | 13 +---- src/shared/components/LocaleAutoDetect.tsx | 39 ++++++++++++++ src/shared/lib/persistLocale.ts | 19 +++++++ tests/unit/i18n-detect-browser-locale.test.ts | 43 +++++++++++++++ 7 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 src/i18n/detectBrowserLocale.ts create mode 100644 src/shared/components/LocaleAutoDetect.tsx create mode 100644 src/shared/lib/persistLocale.ts create mode 100644 tests/unit/i18n-detect-browser-locale.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b8145f59..3349f3590e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - **feat(providers):** add SumoPod and X5Lab as OpenAI-compatible (API-key) providers. (thanks @rigelra15) - **feat(server):** support reverse-proxy subpath deployment via OMNIROUTE_BASE_PATH (basePath-aware auth redirects). (thanks @SillyHippy) - **feat(cli-tools):** add CodeWhale CLI tool (successor to DeepSeek TUI). (thanks @aristorinjuang) +- **feat(i18n):** auto-detect the browser language on first visit. (thanks @ayanmw) ### 🔧 Bug Fixes diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1e1d6483f8..8388309570 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import { normalizeComplianceEventTypes } from "@/i18n/request"; import { getSettings } from "@/lib/db/settings"; import type { Viewport } from "next"; import { PwaRegister } from "@/shared/components/PwaRegister"; +import { LocaleAutoDetect } from "@/shared/components/LocaleAutoDetect"; const inter = Inter({ subsets: ["latin"], @@ -109,6 +110,7 @@ export default async function RootLayout({ children }) { + {children} diff --git a/src/i18n/detectBrowserLocale.ts b/src/i18n/detectBrowserLocale.ts new file mode 100644 index 0000000000..00f8128750 --- /dev/null +++ b/src/i18n/detectBrowserLocale.ts @@ -0,0 +1,52 @@ +/** + * Pure browser-language detector used to pick an initial locale on first + * visit, before the user has made an explicit selection (no cookie set). + * + * Matching order: + * 1. Exact match against `navigator.languages` entries (case-insensitive). + * 2. `zh-HK` / `zh-MO` are treated as `zh-TW` (Traditional Chinese) since + * OmniRoute does not ship a dedicated Hong-Kong/Macau locale. + * 3. Language-prefix match — e.g. `en-US` matches a supported `en` locale. + * 4. No match → `null` (caller should keep the existing default). + * + * Kept dependency-free (no DOM/`navigator` access) so it is trivially unit + * testable and reusable from both client components and future server code. + */ +export function detectBrowserLocale( + languages: readonly string[], + locales: readonly string[] +): string | null { + if (!languages || languages.length === 0 || !locales || locales.length === 0) { + return null; + } + + const normalizedLocales = locales.map((locale) => locale.toLowerCase()); + + for (const rawLanguage of languages) { + if (!rawLanguage) continue; + const language = rawLanguage.toLowerCase(); + + // 1. Exact match. + const exactIndex = normalizedLocales.indexOf(language); + if (exactIndex !== -1) { + return locales[exactIndex]; + } + + // 2. zh-HK / zh-MO fold to zh-TW when zh-TW is supported. + if (language === "zh-hk" || language === "zh-mo") { + const zhTwIndex = normalizedLocales.indexOf("zh-tw"); + if (zhTwIndex !== -1) { + return locales[zhTwIndex]; + } + } + + // 3. Language-prefix match (e.g. "en-US" -> "en"). + const prefix = language.split("-")[0]; + const prefixIndex = normalizedLocales.indexOf(prefix); + if (prefixIndex !== -1) { + return locales[prefixIndex]; + } + } + + return null; +} diff --git a/src/shared/components/LanguageSelector.tsx b/src/shared/components/LanguageSelector.tsx index 94d6f70b0c..c136bf7bd7 100644 --- a/src/shared/components/LanguageSelector.tsx +++ b/src/shared/components/LanguageSelector.tsx @@ -2,19 +2,10 @@ import { useState, useRef, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { LANGUAGES, LOCALE_COOKIE } from "@/i18n/config"; +import { LANGUAGES } from "@/i18n/config"; import type { Locale } from "@/i18n/config"; import { useLocale } from "next-intl"; - -/** Persist locale preference in cookie + localStorage (outside component scope for ESLint) */ -function persistLocale(code: Locale) { - document.cookie = `${LOCALE_COOKIE}=${code};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`; - try { - localStorage.setItem(LOCALE_COOKIE, code); - } catch { - // Ignore - } -} +import { persistLocale } from "@/shared/lib/persistLocale"; function CountryFlag({ emoji, alt }: { emoji: string; alt: string }) { const [error, setError] = useState(false); diff --git a/src/shared/components/LocaleAutoDetect.tsx b/src/shared/components/LocaleAutoDetect.tsx new file mode 100644 index 0000000000..e84c75bc7a --- /dev/null +++ b/src/shared/components/LocaleAutoDetect.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { LOCALES, LOCALE_COOKIE } from "@/i18n/config"; +import type { Locale } from "@/i18n/config"; +import { detectBrowserLocale } from "@/i18n/detectBrowserLocale"; +import { persistLocale } from "@/shared/lib/persistLocale"; + +function hasLocaleCookie(): boolean { + return document.cookie + .split(";") + .some((entry) => entry.trim().startsWith(`${LOCALE_COOKIE}=`)); +} + +/** + * Auto-detects the browser language on first visit (no locale cookie set + * yet) and persists it via the same writer `LanguageSelector` uses for a + * manual selection, then refreshes the router so the server re-renders with + * the detected locale. Mounted once in the root layout; renders nothing. + */ +export function LocaleAutoDetect() { + const router = useRouter(); + + useEffect(() => { + if (typeof navigator === "undefined" || hasLocaleCookie()) return; + + const detected = detectBrowserLocale(navigator.languages ?? [navigator.language], LOCALES); + if (!detected) return; + + persistLocale(detected as Locale); + router.refresh(); + // Run once on mount only — this is a first-visit detection, not a + // reactive effect that should re-run on router identity changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +} diff --git a/src/shared/lib/persistLocale.ts b/src/shared/lib/persistLocale.ts new file mode 100644 index 0000000000..51ce4b245d --- /dev/null +++ b/src/shared/lib/persistLocale.ts @@ -0,0 +1,19 @@ +import { LOCALE_COOKIE } from "@/i18n/config"; +import type { Locale } from "@/i18n/config"; + +/** + * Persist the locale preference in the cookie `src/i18n/request.ts` reads on + * the server, plus localStorage as a client-side convenience mirror. + * + * Shared by every client-side locale writer (manual selection in + * `LanguageSelector`, first-visit auto-detection in `LocaleAutoDetect`) so + * there is a single source of truth for the cookie name/format. + */ +export function persistLocale(code: Locale): void { + document.cookie = `${LOCALE_COOKIE}=${code};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`; + try { + localStorage.setItem(LOCALE_COOKIE, code); + } catch { + // Ignore (e.g. storage disabled/full) + } +} diff --git a/tests/unit/i18n-detect-browser-locale.test.ts b/tests/unit/i18n-detect-browser-locale.test.ts new file mode 100644 index 0000000000..d55748fb96 --- /dev/null +++ b/tests/unit/i18n-detect-browser-locale.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { detectBrowserLocale } from "../../src/i18n/detectBrowserLocale"; + +const SUPPORTED_LOCALES = ["en", "pt-BR", "es", "zh-TW", "fr", "de"] as const; + +describe("detectBrowserLocale", () => { + it("returns the exact match when a browser language equals a supported locale", () => { + assert.equal(detectBrowserLocale(["pt-BR"], SUPPORTED_LOCALES), "pt-BR"); + }); + + it("folds zh-HK to zh-TW when zh-TW is supported", () => { + assert.equal(detectBrowserLocale(["zh-HK"], SUPPORTED_LOCALES), "zh-TW"); + }); + + it("folds zh-MO to zh-TW when zh-TW is supported", () => { + assert.equal(detectBrowserLocale(["zh-MO"], SUPPORTED_LOCALES), "zh-TW"); + }); + + it("falls back to a language-prefix match when no exact match exists", () => { + assert.equal(detectBrowserLocale(["en-US"], SUPPORTED_LOCALES), "en"); + }); + + it("returns null when nothing matches", () => { + assert.equal(detectBrowserLocale(["ja-JP"], SUPPORTED_LOCALES), null); + }); + + it("returns null for an empty languages list", () => { + assert.equal(detectBrowserLocale([], SUPPORTED_LOCALES), null); + }); + + it("returns null for an empty locales list", () => { + assert.equal(detectBrowserLocale(["en-US"], []), null); + }); + + it("tries each browser language in order until one matches", () => { + assert.equal(detectBrowserLocale(["ja-JP", "fr-CA"], SUPPORTED_LOCALES), "fr"); + }); + + it("is case-insensitive", () => { + assert.equal(detectBrowserLocale(["PT-br"], SUPPORTED_LOCALES), "pt-BR"); + }); +});