| @@ -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<AppBarProps> = ({ avatarImageSrc, profileName }) => { | |||
| gap: 1, | |||
| }} | |||
| > | |||
| <LanguageSwitcher /> | |||
| <Profile | |||
| avatarImageSrc={avatarImageSrc} | |||
| profileName={profileName} | |||
| @@ -0,0 +1,60 @@ | |||
| "use client"; | |||
| import React, { useRef } from "react"; | |||
| import ToggleButton from "@mui/material/ToggleButton"; | |||
| import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| type AppLanguage, | |||
| isAppLanguage, | |||
| setLanguageCookie, | |||
| } from "@/i18n/locale"; | |||
| const LanguageSwitcher: React.FC = () => { | |||
| 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<HTMLElement>, 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 ( | |||
| <ToggleButtonGroup | |||
| exclusive | |||
| size="small" | |||
| value={current} | |||
| onChange={onChange} | |||
| aria-label={t("Language")} | |||
| sx={{ | |||
| mr: 0.5, | |||
| "& .MuiToggleButton-root": { | |||
| px: 1, | |||
| py: 0.25, | |||
| fontSize: "0.75rem", | |||
| lineHeight: 1.4, | |||
| textTransform: "none", | |||
| }, | |||
| }} | |||
| > | |||
| <ToggleButton value="zh">中</ToggleButton> | |||
| <ToggleButton value="en">EN</ToggleButton> | |||
| </ToggleButtonGroup> | |||
| ); | |||
| }; | |||
| export default LanguageSwitcher; | |||
| @@ -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; | |||
| @@ -87,6 +87,7 @@ | |||
| "Select Date": "選擇日期", | |||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | |||
| "Sign out": "Sign out", | |||
| "Language": "Language", | |||
| "Status": "狀態", | |||
| "Stock Qty": "庫存數量", | |||
| "Supporting Document": "證明文件", | |||
| @@ -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<string> => { | |||
| // Logic to get language preference from cookies/headers/session | |||
| @@ -21,11 +23,13 @@ export const detectLanguage = async (): Promise<string> => { | |||
| 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, | |||
| @@ -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`; | |||
| } | |||
| @@ -90,6 +90,7 @@ | |||
| "Select Date": "選擇日期", | |||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | |||
| "Sign out": "登出", | |||
| "Language": "語言", | |||
| "Status": "狀態", | |||
| "Stock Qty": "庫存數量", | |||
| "Supporting Document": "證明文件", | |||