(frontend) Adds customization for translations

Part of customization PoC

Signed-off-by: Robin Weber <weber@b1-systems.de>
This commit is contained in:
rvveber
2025-04-08 16:09:30 +02:00
parent d952815932
commit f4ad26a8fa
8 changed files with 230 additions and 70 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 =

View File

@@ -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 };

View File

@@ -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 };
};

View File

@@ -1,2 +1,3 @@
export * from './hooks/useLanguageSynchronizer';
export * from './LanguagePicker';
export * from './hooks';
export * from './components';
export * from './utils';

View File

@@ -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;

View File

@@ -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);
},
};