Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions frontend/i18n/app-i18n-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import * as React from "react";
import { NextIntlClientProvider } from "next-intl";

import { DEFAULT_LOCALE, LOCALE_COOKIE_NAME, normalizeAppLocale, type AppLocale } from "@/i18n/config";
import { DEFAULT_LOCALE, LOCALE_COOKIE_NAME, normalizeAppLocale, resolveBrowserLocale, type AppLocale } from "@/i18n/config";
import { DEFAULT_MESSAGES, loadLocaleMessages, type AppMessages } from "@/i18n/messages";

type AppI18nContextValue = {
Expand All @@ -13,20 +13,27 @@ type AppI18nContextValue = {

const AppI18nContext = React.createContext<AppI18nContextValue | null>(null);

function readLocaleCookie(): AppLocale {
function readLocaleCookie(): AppLocale | null {
if (typeof document === "undefined") {
return DEFAULT_LOCALE;
return null;
}
const raw = document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${LOCALE_COOKIE_NAME}=`));
if (!raw) {
return DEFAULT_LOCALE;
return null;
}
return normalizeAppLocale(decodeURIComponent(raw.slice(LOCALE_COOKIE_NAME.length + 1)));
}

function readBrowserLocale(): AppLocale {
if (typeof navigator === "undefined") {
return DEFAULT_LOCALE;
}
return resolveBrowserLocale(navigator.languages?.length ? navigator.languages : [navigator.language]);
}

function writeLocaleCookie(locale: AppLocale): void {
if (typeof document === "undefined") {
return;
Expand All @@ -46,10 +53,12 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) {
const [messages, setMessages] = React.useState<AppMessages>(DEFAULT_MESSAGES);
const localeRef = React.useRef<AppLocale>(DEFAULT_LOCALE);

const setLocale = React.useCallback(async (nextLocale: AppLocale) => {
const applyLocale = React.useCallback(async (nextLocale: AppLocale, persist: boolean) => {
const normalized = normalizeAppLocale(nextLocale);
if (normalized === localeRef.current) {
writeLocaleCookie(normalized);
if (persist) {
writeLocaleCookie(normalized);
}
applyDocumentLocale(normalized);
return;
}
Expand All @@ -58,13 +67,20 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) {
localeRef.current = normalized;
setLocaleState(normalized);
setMessages(nextMessages);
writeLocaleCookie(normalized);
if (persist) {
writeLocaleCookie(normalized);
}
applyDocumentLocale(normalized);
}, []);

const setLocale = React.useCallback(async (nextLocale: AppLocale) => {
await applyLocale(nextLocale, true);
}, [applyLocale]);

React.useEffect(() => {
void setLocale(readLocaleCookie());
}, [setLocale]);
const cookieLocale = readLocaleCookie();
void applyLocale(cookieLocale ?? readBrowserLocale(), cookieLocale !== null);
}, [applyLocale]);

const value = React.useMemo<AppI18nContextValue>(
() => ({
Expand Down
23 changes: 22 additions & 1 deletion frontend/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,26 @@ export const APP_LOCALE_LABELS: Record<AppLocale, string> = {

export function normalizeAppLocale(value: string | null | undefined): AppLocale {
const normalized = String(value ?? "").trim();
return APP_LOCALES.includes(normalized as AppLocale) ? (normalized as AppLocale) : DEFAULT_LOCALE;
const canonical = normalized.replace("_", "-");
const lower = canonical.toLowerCase();
if (lower === "zh" || lower.startsWith("zh-")) {
return "zh-CN";
}
if (lower === "en" || lower.startsWith("en-")) {
return "en-US";
}
return APP_LOCALES.includes(canonical as AppLocale) ? (canonical as AppLocale) : DEFAULT_LOCALE;
}

export function resolveBrowserLocale(languages: readonly string[] | undefined): AppLocale {
for (const language of languages ?? []) {
const normalized = String(language ?? "").trim().toLowerCase().replace("_", "-");
if (normalized === "zh" || normalized.startsWith("zh-")) {
return "zh-CN";
}
if (normalized === "en" || normalized.startsWith("en-")) {
return "en-US";
}
}
return DEFAULT_LOCALE;
}
9 changes: 7 additions & 2 deletions frontend/i18n/resolve-error-message.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import enErrors from "@/i18n/messages/en-US/errors.json";
import zhErrors from "@/i18n/messages/zh-CN/errors.json";
import { DEFAULT_LOCALE, LOCALE_COOKIE_NAME, normalizeAppLocale, type AppLocale } from "@/i18n/config";
import { DEFAULT_LOCALE, LOCALE_COOKIE_NAME, normalizeAppLocale, resolveBrowserLocale, type AppLocale } from "@/i18n/config";
import { ApiError } from "@/shared/api/http-client";

const ERROR_MESSAGES: Record<AppLocale, unknown> = {
Expand Down Expand Up @@ -182,7 +182,12 @@ function readClientLocale(): AppLocale {
.map((item) => item.trim())
.find((item) => item.startsWith(`${LOCALE_COOKIE_NAME}=`))
?.slice(LOCALE_COOKIE_NAME.length + 1);
return normalizeAppLocale(cookieValue ? decodeURIComponent(cookieValue) : undefined);
if (cookieValue) {
return normalizeAppLocale(decodeURIComponent(cookieValue));
}
return typeof navigator === "undefined"
? DEFAULT_LOCALE
: resolveBrowserLocale(navigator.languages?.length ? navigator.languages : [navigator.language]);
}

function lookupErrorMessage(locale: AppLocale, errorCode: string): string | undefined {
Expand Down
Loading