From 52f1f1a7e9fe1789fd87925a79fa0d7e6458e67c Mon Sep 17 00:00:00 2001 From: Filipe Lopes Date: Thu, 22 Oct 2026 11:36:40 -0300 Subject: [PATCH 1/2] feat(i18n): Implement language resolution logic * Introduce `src/i18n/language.js` to handle language detection and preference. * Refactor `src/i18n/config.js` to use a structured language resolution process (storage > browser > default) instead of simple checks. * Apply the determined language to the document element for better accessibility and display consistency. --- package.json | 1 + src/i18n/config.js | 23 ++++++++++++++------ src/i18n/language.js | 51 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 src/i18n/language.js diff --git a/package.json b/package.json index bf3ba3f1b..5bb53686c 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "scripts": { "start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js --progress --host 0.0.0.0 --port ${WEB_PORT} --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env ANALYTICS_API=${ANALYTICS_API} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --mode ${NODE_ENV} --hot", "build": "node --max-old-space-size=1536 ./node_modules/webpack/bin/webpack.js --progress --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env ANALYTICS_API=${ANALYTICS_API} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --mode ${NODE_ENV}", + "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test 'src/**/__tests__/**/*.test.js'", "eslint": "./node_modules/.bin/eslint ./src --ext .jsx,.js" }, "devDependencies": { diff --git a/src/i18n/config.js b/src/i18n/config.js index 6fc2b425c..73d678fd4 100644 --- a/src/i18n/config.js +++ b/src/i18n/config.js @@ -5,21 +5,23 @@ import es from './locales/es/translations.json' import zh from './locales/zh/translations.json' import ptBR from './locales/pt-BR/translations.json' +import { getStoredLanguage, getBrowserLanguage, resolveLanguage, DEFAULT_LANGUAGE } from './language' + const LANGUAGE_STORAGE_KEY = 'ocl.language' const SUPPORTED_LANGUAGES = ['en', 'es', 'zh', 'pt-BR'] +// Priority: manual choice (localStorage) > browser preference > default. const getInitialLanguage = () => { if(typeof window === 'undefined') - return 'en' + return DEFAULT_LANGUAGE - try { - const storedLanguage = window.localStorage.getItem(LANGUAGE_STORAGE_KEY) - return SUPPORTED_LANGUAGES.includes(storedLanguage) ? storedLanguage : 'en' - } catch(_) { - return 'en' - } + return resolveLanguage([ + () => getStoredLanguage(window.localStorage, LANGUAGE_STORAGE_KEY, SUPPORTED_LANGUAGES), + () => getBrowserLanguage(window.navigator, SUPPORTED_LANGUAGES) + ]) } +// Registered after init, so only user-triggered changes are persisted, not the detected initial language. const persistLanguage = language => { try { window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language) @@ -28,6 +30,11 @@ const persistLanguage = language => { } } +const applyDocumentLanguage = language => { + if(typeof document !== 'undefined') + document.documentElement.lang = language +} + i18n.use(initReactI18next).init({ fallbackLng: 'en', lng: getInitialLanguage(), @@ -50,6 +57,8 @@ i18n.use(initReactI18next).init({ }); i18n.languages = SUPPORTED_LANGUAGES; +applyDocumentLanguage(i18n.language) i18n.on('languageChanged', persistLanguage) +i18n.on('languageChanged', applyDocumentLanguage) export default i18n; diff --git a/src/i18n/language.js b/src/i18n/language.js new file mode 100644 index 000000000..6bb048246 --- /dev/null +++ b/src/i18n/language.js @@ -0,0 +1,51 @@ +export const DEFAULT_LANGUAGE = 'en' + +const baseOf = tag => tag.split('-')[0] + +/* + * Maps a BCP 47 tag (e.g. "pt-PT", "es_MX", "en-US") to one of the supported locales. + * An exact match wins; otherwise the primary language code is matched, so any regional + * variant of a supported language (pt-PT, es-AR, zh-TW, ...) resolves to it. + */ +export const matchLanguage = (tag, supported) => { + if(typeof tag !== 'string' || !tag) + return undefined + + const normalized = tag.trim().replace('_', '-').toLowerCase() + return supported.find(locale => locale.toLowerCase() === normalized) || + supported.find(locale => baseOf(locale.toLowerCase()) === baseOf(normalized)) +} + +export const getStoredLanguage = (storage, key, supported) => { + try { + const stored = storage.getItem(key) + return supported.includes(stored) ? stored : undefined + } catch(_) { + return undefined // storage unavailable (e.g. blocked or private mode) + } +} + +// navigator.languages is ordered by preference; the first tag that maps to a supported locale wins. +export const getBrowserLanguage = (nav, supported) => { + const tags = nav?.languages?.length ? nav.languages : [nav?.language] + for(const tag of tags) { + const match = matchLanguage(tag, supported) + if(match) + return match + } + return undefined +} + +/* + * Runs the sources in priority order and returns the first locale found. + * Each source is a function returning a supported locale or undefined, so a new + * signal is added by appending one entry to the list. + */ +export const resolveLanguage = (sources, fallback = DEFAULT_LANGUAGE) => { + for(const source of sources) { + const language = source() + if(language) + return language + } + return fallback +} From cdf8a40f0f6baf7cde4e6d0c0f5185ab8e56bdb7 Mon Sep 17 00:00:00 2001 From: Filipe Lopes <34348097+filiperochalopes@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:26:02 -0300 Subject: [PATCH 2/2] chore: sync package.json with main branch --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 473a59cbc..b728f956f 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,8 @@ "scripts": { "start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js --progress --host 0.0.0.0 --port ${WEB_PORT} --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env ANALYTICS_API=${ANALYTICS_API} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --mode ${NODE_ENV} --hot", "build": "node --max-old-space-size=1536 ./node_modules/webpack/bin/webpack.js --progress --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env ANALYTICS_API=${ANALYTICS_API} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --mode ${NODE_ENV}", - "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test 'src/**/__tests__/**/*.test.js'", - "eslint": "./node_modules/.bin/eslint ./src --ext .jsx,.js" + "eslint": "./node_modules/.bin/eslint ./src --ext .jsx,.js", + "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test 'src/**/__tests__/**/*.test.js'" }, "devDependencies": { "@babel/core": "^7.29.7",