From b4054b88aa11f7fffe930d45b968e84393531ea7 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Wed, 26 Aug 2026 14:06:48 +0800 Subject: [PATCH] added seesion and button for the en/chinese locale change --- src/components/AppBar/AppBar.tsx | 2 + src/components/AppBar/LanguageSwitcher.tsx | 60 ++++++++++++++++++++++ src/config/authConfig.ts | 18 ++++++- src/i18n/en/common.json | 1 + src/i18n/index.tsx | 20 +++++--- src/i18n/locale.ts | 23 +++++++++ src/i18n/zh/common.json | 1 + 7 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 src/components/AppBar/LanguageSwitcher.tsx create mode 100644 src/i18n/locale.ts diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 8fa9d955..4da56d55 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -1,6 +1,7 @@ import MUIAppBar from "@mui/material/AppBar"; import Toolbar from "@mui/material/Toolbar"; import React from "react"; +import LanguageSwitcher from "./LanguageSwitcher"; import Profile from "./Profile"; import Box from "@mui/material/Box"; import NavigationToggle from "./NavigationToggle"; @@ -35,6 +36,7 @@ const AppBar: React.FC = ({ avatarImageSrc, profileName }) => { gap: 1, }} > + { + const { i18n, t } = useTranslation("common"); + const { update } = useSession(); + const router = useRouter(); + const inFlightRef = useRef(false); + + const current: AppLanguage = isAppLanguage(i18n.language) ? i18n.language : "zh"; + + const onChange = async (_: React.MouseEvent, next: AppLanguage | null) => { + if (!next || next === current) return; + if (inFlightRef.current) return; + inFlightRef.current = true; + try { + setLanguageCookie(next); + await update({ locale: next }); + router.refresh(); + } finally { + inFlightRef.current = false; + } + }; + + return ( + + + EN + + ); +}; + +export default LanguageSwitcher; diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts index cb8b2e3e..d55433c5 100644 --- a/src/config/authConfig.ts +++ b/src/config/authConfig.ts @@ -12,6 +12,7 @@ declare module "next-auth" { id?: string; /** JWT expiry (seconds since epoch); used to avoid redirecting to dashboard when token is expired */ exp?: number; + locale?: string; } interface User { @@ -19,6 +20,7 @@ declare module "next-auth" { accessToken: string | null; refreshToken?: string; abilities: string[]; + locale?: string; } } @@ -28,6 +30,7 @@ declare module "next-auth/jwt" { accessToken: string | null; refreshToken?: string; abilities: string[]; + locale?: string; } } @@ -70,13 +73,24 @@ export const authOptions: AuthOptions = { }, callbacks: { // Persist custom fields into the JWT token - async jwt({ token, user }) { + async jwt({ token, user, trigger, session }) { // First sign-in: `user` is available if (user) { token.id = user.id ?? token.sub; // fallback to sub if no id token.accessToken = user.accessToken; token.refreshToken = user.refreshToken; token.abilities = user.abilities ?? []; + const loginLocale = (user as { locale?: string }).locale; + if (loginLocale) { + token.locale = loginLocale; + } + } + + if (trigger === "update" && session && typeof session === "object" && "locale" in session) { + const next = (session as { locale?: string }).locale; + if (next === "zh" || next === "en") { + token.locale = next; + } } // On subsequent calls (token refresh, session access), user is not present @@ -91,6 +105,7 @@ export const authOptions: AuthOptions = { session.refreshToken = token.refreshToken as string | undefined; session.abilities = token.abilities as string[]; session.exp = token.exp as number | undefined; + session.locale = token.locale as string | undefined; // Also add abilities to session.user for easier client-side access if (session.user) { @@ -107,5 +122,6 @@ export type SessionWithTokens = Session & { abilities: string[]; /** Backend / JWT subject — often numeric string or number */ id?: string | number; + locale?: string; }; export default authOptions; \ No newline at end of file diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index da54bee9..61a464d3 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -87,6 +87,7 @@ "Select Date": "選擇日期", "Session expired or unauthorized.": "工作階段已過期或未經授權。", "Sign out": "Sign out", + "Language": "Language", "Status": "狀態", "Stock Qty": "庫存數量", "Supporting Document": "證明文件", diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 39c92419..a93475bd 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -2,12 +2,14 @@ import { cookies, headers } from "next/headers"; import { createInstance, i18n, LanguageDetectorAsyncModule } from "i18next"; import resourcesToBackend from "i18next-resources-to-backend"; import { getServerSession } from "next-auth"; -import { authOptions } from "@/config/authConfig"; +import { authOptions, SessionWithTokens } from "@/config/authConfig"; import I18nClientProvider from "./I18nClientProvider"; import universalLanguageDetect from "@unly/universal-language-detector"; - -const FALLBACK_LANG = "zh"; -const SUPPORTED_LANGUAGES = ["zh"]; +import { + FALLBACK_LANG, + SUPPORTED_LANGUAGES, + normalizeAppLanguage, +} from "./locale"; export const detectLanguage = async (): Promise => { // Logic to get language preference from cookies/headers/session @@ -21,11 +23,13 @@ export const detectLanguage = async (): Promise => { const headersList = headers(); //console.time("[i18n] detectLanguage total"); //console.time("[i18n] getServerSession"); - const session = await getServerSession(authOptions); - //console.timeEnd("[i18n] getServerSession"); - //console.time("[i18n] universalLanguageDetect"); + const session = (await getServerSession(authOptions)) as SessionWithTokens | null; + const fromSession = normalizeAppLanguage(session?.locale); + if (fromSession) { + return fromSession; + } const lang = universalLanguageDetect({ - supportedLanguages: SUPPORTED_LANGUAGES, + supportedLanguages: [...SUPPORTED_LANGUAGES], fallbackLanguage: FALLBACK_LANG, acceptLanguageHeader: headersList.get("accept-language") || undefined, serverCookies: cookiesObj, diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts new file mode 100644 index 00000000..cd846963 --- /dev/null +++ b/src/i18n/locale.ts @@ -0,0 +1,23 @@ +export const FALLBACK_LANG = "zh"; +export const SUPPORTED_LANGUAGES = ["zh", "en"] as const; +export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]; + +/** Cookie name read by `@unly/universal-language-detector`. */ +export const I18N_COOKIE_NAME = "i18next"; + +export function isAppLanguage(value: unknown): value is AppLanguage { + return value === "zh" || value === "en"; +} + +export function normalizeAppLanguage(value: unknown): AppLanguage | null { + if (typeof value !== "string") return null; + const lower = value.trim().toLowerCase(); + if (lower === "zh" || lower.startsWith("zh-")) return "zh"; + if (lower === "en" || lower.startsWith("en-")) return "en"; + return null; +} + +export function setLanguageCookie(lang: AppLanguage) { + if (typeof document === "undefined") return; + document.cookie = `${I18N_COOKIE_NAME}=${lang}; Path=/; SameSite=Lax; Max-Age=31536000`; +} diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index a9da7006..6753daf4 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -90,6 +90,7 @@ "Select Date": "選擇日期", "Session expired or unauthorized.": "工作階段已過期或未經授權。", "Sign out": "登出", + "Language": "語言", "Status": "狀態", "Stock Qty": "庫存數量", "Supporting Document": "證明文件",