diff --git a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx index 371e7c35..6694f6b9 100644 --- a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx +++ b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx @@ -1,10 +1,15 @@ import { Loader } from '@openfun/cunningham-react'; import Head from 'next/head'; -import { PropsWithChildren, useEffect } from 'react'; +import { PropsWithChildren, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { Box } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { useLanguageSynchronizer } from '@/features/language/'; +import { useAuthQuery } from '@/features/auth'; +import { + useCustomTranslations, + useSynchronizedLanguage, +} from '@/features/language'; import { useAnalytics } from '@/libs'; import { CrispProvider, PostHogAnalytic } from '@/services'; import { useSentryStore } from '@/stores/useSentryStore'; @@ -13,10 +18,35 @@ import { useConfig } from './api/useConfig'; export const ConfigProvider = ({ children }: PropsWithChildren) => { const { data: conf } = useConfig(); + const { data: user } = useAuthQuery(); const { setSentry } = useSentryStore(); const { setTheme } = useCunninghamTheme(); + const { changeLanguageSynchronized } = useSynchronizedLanguage(); + const { customizeTranslations } = useCustomTranslations(); const { AnalyticsProvider } = useAnalytics(); - const { synchronizeLanguage } = useLanguageSynchronizer(); + const { i18n } = useTranslation(); + const languageSynchronized = useRef(false); + + useEffect(() => { + if (!user || languageSynchronized.current) { + return; + } + + const targetLanguage = + user?.language ?? i18n.resolvedLanguage ?? i18n.language; + + void changeLanguageSynchronized(targetLanguage, user).then(() => { + languageSynchronized.current = true; + }); + }, [user, i18n.resolvedLanguage, i18n.language, changeLanguageSynchronized]); + + useEffect(() => { + if (!conf?.theme_customization?.translations) { + return; + } + + customizeTranslations(conf.theme_customization.translations); + }, [conf?.theme_customization?.translations, customizeTranslations]); useEffect(() => { if (!conf?.SENTRY_DSN) { @@ -34,10 +64,6 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => { setTheme(conf.FRONTEND_THEME); }, [conf?.FRONTEND_THEME, setTheme]); - useEffect(() => { - void synchronizeLanguage(); - }, [synchronizeLanguage]); - useEffect(() => { if (!conf?.POSTHOG_KEY) { return; diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index 761c588c..584500de 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { Resource } from 'i18next'; import { APIError, errorCauses, fetchAPI } from '@/api'; import { Theme } from '@/cunningham/'; @@ -7,9 +8,10 @@ import { PostHogConf } from '@/services'; interface ThemeCustomization { footer?: FooterType; + translations?: Resource; } -interface ConfigResponse { +export interface ConfigResponse { AI_FEATURE_ENABLED?: boolean; COLLABORATION_WS_URL?: string; COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean; diff --git a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx index 950b4f2b..fd60e220 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx @@ -5,6 +5,7 @@ import { css } from 'styled-components'; import { DropdownMenu, Icon, Text } from '@/components/'; import { useConfig } from '@/core'; +import { useAuthQuery } from '@/features/auth'; import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer'; import { getMatchingLocales } from './utils/locale'; @@ -12,6 +13,7 @@ import { getMatchingLocales } from './utils/locale'; export const LanguagePicker = () => { const { t, i18n } = useTranslation(); const { data: conf } = useConfig(); + const { data: user } = useAuthQuery(); const { synchronizeLanguage } = useLanguageSynchronizer(); const language = i18n.languages[0]; Settings.defaultLocale = language; @@ -28,7 +30,9 @@ export const LanguagePicker = () => { i18n .changeLanguage(backendLocale) .then(() => { - void synchronizeLanguage('toBackend'); + if (conf?.LANGUAGES && user) { + synchronizeLanguage(conf.LANGUAGES, user, 'toBackend'); + } }) .catch((err) => { console.error('Error changing language', err); @@ -36,7 +40,7 @@ export const LanguagePicker = () => { }; return { label, isSelected, callback }; }); - }, [conf, i18n, language, synchronizeLanguage]); + }, [conf?.LANGUAGES, i18n, language, synchronizeLanguage, user]); // Extract current language label for display const currentLanguageLabel = diff --git a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts index e6bb23b9..cded24a7 100644 --- a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts +++ b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts @@ -1,37 +1,30 @@ -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { useConfig } from '@/core'; -import { useAuthQuery } from '@/features/auth/api'; +import type { ConfigResponse } from '@/core/config/api/useConfig'; +import { User } from '@/features/auth'; import { useChangeUserLanguage } from '@/features/language/api/useChangeUserLanguage'; import { getMatchingLocales } from '@/features/language/utils/locale'; import { availableFrontendLanguages } from '@/i18n/initI18n'; export const useLanguageSynchronizer = () => { - const { data: conf, isSuccess: confInitialized } = useConfig(); - const { data: user, isSuccess: userInitialized } = useAuthQuery(); const { i18n } = useTranslation(); const { mutateAsync: changeUserLanguage } = useChangeUserLanguage(); const languageSynchronizing = useRef(false); - const availableBackendLanguages = useMemo(() => { - return conf?.LANGUAGES.map(([locale]) => locale); - }, [conf?.LANGUAGES]); - const synchronizeLanguage = useCallback( - async (direction?: 'toBackend' | 'toFrontend') => { - if ( - languageSynchronizing.current || - !userInitialized || - !confInitialized || - !availableBackendLanguages || - !availableFrontendLanguages - ) { + ( + languages: ConfigResponse['LANGUAGES'], + user: User, + direction?: 'toBackend' | 'toFrontend', + ) => { + if (languageSynchronizing.current || !availableFrontendLanguages) { return; } languageSynchronizing.current = true; try { + const availableBackendLanguages = languages.map(([locale]) => locale); const userPreferredLanguages = user.language ? [user.language] : []; const setOrDetectedLanguages = i18n.languages; @@ -41,25 +34,27 @@ export const useLanguageSynchronizer = () => { (userPreferredLanguages.length ? 'toFrontend' : 'toBackend'); if (direction === 'toBackend') { - // Update user's preference from frontends's language const closestBackendLanguage = getMatchingLocales( availableBackendLanguages, setOrDetectedLanguages, )[0] || availableBackendLanguages[0]; - await changeUserLanguage({ + changeUserLanguage({ userId: user.id, language: closestBackendLanguage, + }).catch((error) => { + console.error('Error changing user language', error); }); } else { - // Update frontends's language from user's preference const closestFrontendLanguage = getMatchingLocales( availableFrontendLanguages, userPreferredLanguages, )[0] || availableFrontendLanguages[0]; if (i18n.resolvedLanguage !== closestFrontendLanguage) { - await i18n.changeLanguage(closestFrontendLanguage); + i18n.changeLanguage(closestFrontendLanguage).catch((error) => { + console.error('Error changing frontend language', error); + }); } } } catch (error) { @@ -68,14 +63,7 @@ export const useLanguageSynchronizer = () => { languageSynchronizing.current = false; } }, - [ - i18n, - user, - userInitialized, - confInitialized, - availableBackendLanguages, - changeUserLanguage, - ], + [i18n, changeUserLanguage], ); return { synchronizeLanguage }; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts new file mode 100644 index 00000000..8ba3eaa7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts @@ -0,0 +1,85 @@ +import i18next, { Resource, i18n } from 'i18next'; +import { useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { ConfigResponse } from '@/core/config/api/useConfig'; +import { safeLocalStorage } from '@/utils/storages'; + +export const useTranslationsCustomizer = () => { + const { i18n } = useTranslation(); + const translationsCustomizing = useRef(false); + + const customizeTranslations = useCallback( + ( + customTranslationsUrl: ConfigResponse['FRONTEND_CUSTOM_TRANSLATIONS_URL'], + cacheKey: string = 'CUSTOM_TRANSLATIONS', + ) => { + if (translationsCustomizing.current) { + return; + } + translationsCustomizing.current = true; + try { + if (!customTranslationsUrl) { + safeLocalStorage.setItem(cacheKey, ''); + } else { + const previousTranslationsString = safeLocalStorage.getItem(cacheKey); + if (previousTranslationsString) { + const previousTranslations = JSON.parse( + previousTranslationsString, + ) as Resource; + try { + applyTranslations(previousTranslations, i18n); + } catch (err: unknown) { + console.error('Error parsing cached translations:', err); + safeLocalStorage.setItem(cacheKey, ''); + } + } + + // Always update in background + fetchAndCacheTranslations(customTranslationsUrl, cacheKey) + .then((updatedTranslations) => { + if ( + updatedTranslations && + JSON.stringify(updatedTranslations) !== + previousTranslationsString + ) { + applyTranslations(updatedTranslations, i18n); + } + }) + .catch((err: unknown) => { + console.error('Error fetching custom translations:', err); + }); + } + } catch (err: unknown) { + console.error('Error updating custom translations:', err); + } finally { + translationsCustomizing.current = false; + } + }, + [i18n], + ); + + const applyTranslations = (translations: Resource, i18n: i18n) => { + Object.entries(translations).forEach(([lng, namespaces]) => { + Object.entries(namespaces).forEach(([ns, value]) => { + i18next.addResourceBundle(lng, ns, value, true, true); + }); + }); + const currentLanguage = i18n.language; + void i18next.changeLanguage(currentLanguage); + }; + + const fetchAndCacheTranslations = (url: string, CACHE_KEY: string) => { + return fetch(url).then((response) => { + if (!response.ok) { + throw new Error('Failed to fetch custom translations'); + } + return response.json().then((customTranslations: Resource) => { + safeLocalStorage.setItem(CACHE_KEY, JSON.stringify(customTranslations)); + return customTranslations; + }); + }); + }; + + return { customizeTranslations }; +}; diff --git a/src/frontend/apps/impress/src/features/language/index.ts b/src/frontend/apps/impress/src/features/language/index.ts index 4b60c8bd..d3732c1e 100644 --- a/src/frontend/apps/impress/src/features/language/index.ts +++ b/src/frontend/apps/impress/src/features/language/index.ts @@ -1,2 +1,3 @@ -export * from './hooks/useLanguageSynchronizer'; -export * from './LanguagePicker'; +export * from './hooks'; +export * from './components'; +export * from './utils'; diff --git a/src/frontend/apps/impress/src/i18n/initI18n.ts b/src/frontend/apps/impress/src/i18n/initI18n.ts index 40700e64..09022640 100644 --- a/src/frontend/apps/impress/src/i18n/initI18n.ts +++ b/src/frontend/apps/impress/src/i18n/initI18n.ts @@ -4,36 +4,38 @@ import { initReactI18next } from 'react-i18next'; import resources from './translations.json'; -export const availableFrontendLanguages: readonly string[] = - Object.keys(resources); +// Add an initialization guard +let isInitialized = false; -i18next - .use(LanguageDetector) - .use(initReactI18next) - .init({ - resources, - fallbackLng: 'en', - debug: false, - detection: { - order: ['cookie', 'navigator'], // detection order - caches: ['cookie'], // Use cookies to store the language preference - lookupCookie: 'docs_language', - cookieMinutes: 525600, // Expires after one year - cookieOptions: { - path: '/', - sameSite: 'lax', +// Initialize i18next with the base translations only once +if (!isInitialized && !i18next.isInitialized) { + isInitialized = true; + + i18next + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'en', + debug: false, + detection: { + order: ['cookie', 'navigator'], + caches: ['cookie'], + lookupCookie: 'docs_language', + cookieMinutes: 525600, + cookieOptions: { + path: '/', + sameSite: 'lax', + }, }, - }, - interpolation: { - escapeValue: false, - }, - preload: availableFrontendLanguages, - lowerCaseLng: true, - nsSeparator: false, - keySeparator: false, - }) - .catch(() => { - throw new Error('i18n initialization failed'); - }); + interpolation: { + escapeValue: false, + }, + lowerCaseLng: true, + nsSeparator: false, + keySeparator: false, + }) + .catch((e) => console.error('i18n initialization failed:', e)); +} export default i18next; diff --git a/src/frontend/apps/impress/src/utils/storages.ts b/src/frontend/apps/impress/src/utils/storages.ts new file mode 100644 index 00000000..b7f152c9 --- /dev/null +++ b/src/frontend/apps/impress/src/utils/storages.ts @@ -0,0 +1,52 @@ +/** + * @fileOverview This module provides utilities to interact with local storage safely. + */ + +interface SyncStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** + * @namespace safeLocalStorage + * @description A utility for safely interacting with localStorage. + * It checks if the `window` object is defined before attempting to access localStorage, + * preventing errors in environments where `window` is not available. + */ +export const safeLocalStorage: SyncStorage = { + /** + * Retrieves an item from localStorage. + * @param {string} key - The key of the item to retrieve. + * @returns {string | null} The item's value, or null if the item does not exist or if localStorage is not available. + */ + getItem: (key: string): string | null => { + if (typeof window === 'undefined') { + return null; + } + return localStorage.getItem(key); + }, + /** + * Sets an item in localStorage. + * @param {string} key - The key of the item to set. + * @param {string} value - The value to set for the item. + * @returns {void} + */ + setItem: (key: string, value: string): void => { + if (typeof window === 'undefined') { + return; + } + localStorage.setItem(key, value); + }, + /** + * Removes an item from localStorage. + * @param {string} key - The key of the item to remove. + * @returns {void} + */ + removeItem: (key: string): void => { + if (typeof window === 'undefined') { + return; + } + localStorage.removeItem(key); + }, +};