From 7453780e0682a70da848936f4b9a411c013f98cc Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 3 Aug 2026 09:02:19 -0500 Subject: [PATCH] Add language switcher dropdown and persist manual locale choice. Co-authored-by: Cursor --- app/about/page.tsx | 9 +++++++- app/contact/page.tsx | 9 +++++++- app/page.tsx | 7 ++++++ components/language-switcher.tsx | 39 ++++++++++++++++++++++++++++++++ components/locale-provider.tsx | 36 +++++++++++++++++++++++++---- lib/i18n.ts | 3 +++ 6 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 components/language-switcher.tsx diff --git a/app/about/page.tsx b/app/about/page.tsx index bf6fa4d..319f04e 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { LanguageSwitcher } from "~/components/language-switcher"; import { useLocale } from "~/components/locale-provider"; export default function AboutPage() { @@ -8,12 +9,18 @@ export default function AboutPage() { return (
+
+ +

{t.about.title}

diff --git a/app/contact/page.tsx b/app/contact/page.tsx index 68b6a30..55dbdd4 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { LanguageSwitcher } from "~/components/language-switcher"; import { useLocale } from "~/components/locale-provider"; export default function ContactPage() { @@ -8,12 +9,18 @@ export default function ContactPage() { return (

+
+ +

{t.contact.title}

diff --git a/app/page.tsx b/app/page.tsx index de96e07..ce34601 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,7 @@ "use client"; import { Hero } from "~/components/sections/hero"; +import { LanguageSwitcher } from "~/components/language-switcher"; import { useLocale } from "~/components/locale-provider"; export default function HomePage() { @@ -12,6 +13,12 @@ export default function HomePage() { className="flex min-h-dvh flex-col" style={{ minHeight: "100dvh" }} > +

+ +
diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx new file mode 100644 index 0000000..17201f9 --- /dev/null +++ b/components/language-switcher.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useLocale } from "~/components/locale-provider"; +import type { Locale } from "~/lib/i18n"; + +const OPTIONS: { value: Locale; label: string }[] = [ + { value: "zh", label: "中文" }, + { value: "en", label: "English" }, +]; + +export function LanguageSwitcher({ className = "" }: { className?: string }) { + const { locale, setLocale, t } = useLocale(); + + return ( + + ); +} diff --git a/components/locale-provider.tsx b/components/locale-provider.tsx index 15347e0..2d19189 100644 --- a/components/locale-provider.tsx +++ b/components/locale-provider.tsx @@ -2,6 +2,7 @@ import { createContext, + useCallback, useContext, useEffect, useMemo, @@ -11,32 +12,47 @@ import { import { isChinaUser } from "is-china-user"; import { dictionaries, type Dictionary, type Locale } from "~/lib/i18n"; +const STORAGE_KEY = "nomadro-locale"; + type LocaleContextValue = { locale: Locale; isChina: boolean; ready: boolean; t: Dictionary; + setLocale: (locale: Locale) => void; }; const LocaleContext = createContext(null); +function isLocale(value: string | null): value is Locale { + return value === "zh" || value === "en"; +} + export function LocaleProvider({ children }: { children: ReactNode }) { - // Default zh to match primary audience; switch after client detection. - const [locale, setLocale] = useState("zh"); + const [locale, setLocaleState] = useState("zh"); const [ready, setReady] = useState(false); useEffect(() => { let cancelled = false; try { + const saved = window.localStorage.getItem(STORAGE_KEY); + if (isLocale(saved)) { + if (!cancelled) { + setLocaleState(saved); + setReady(true); + } + return; + } + const china = Boolean(isChinaUser()); if (!cancelled) { - setLocale(china ? "zh" : "en"); + setLocaleState(china ? "zh" : "en"); setReady(true); } } catch { if (!cancelled) { - setLocale("zh"); + setLocaleState("zh"); setReady(true); } } @@ -51,14 +67,24 @@ export function LocaleProvider({ children }: { children: ReactNode }) { document.documentElement.lang = dictionaries[locale].htmlLang; }, [locale, ready]); + const setLocale = useCallback((next: Locale) => { + setLocaleState(next); + try { + window.localStorage.setItem(STORAGE_KEY, next); + } catch { + // ignore storage failures + } + }, []); + const value = useMemo( () => ({ locale, isChina: locale === "zh", ready, t: dictionaries[locale], + setLocale, }), - [locale, ready], + [locale, ready, setLocale], ); return ( diff --git a/lib/i18n.ts b/lib/i18n.ts index f857cfc..4c8c941 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -12,6 +12,7 @@ export type Dictionary = { heroTagline: string; heroTaglineLine2: string; copyright: string; + languageLabel: string; liveBadge: string; entryCards: Record; about: { @@ -35,6 +36,7 @@ export const dictionaries: Record = { heroTagline: "游民导航 · 地理套利 · 直播工具箱", heroTaglineLine2: "一站开启自由生活", copyright: "© {year} Nomadro", + languageLabel: "语言", liveBadge: "LIVE", entryCards: { nav: { @@ -72,6 +74,7 @@ export const dictionaries: Record = { heroTagline: "Nomad Nav · Geo Arbitrage · Live Toolkit", heroTaglineLine2: "Your freedom lifestyle, in one place", copyright: "© {year} Nomadro", + languageLabel: "Language", liveBadge: "LIVE", entryCards: { nav: {