Skip to content

Commit f4ad26a

Browse files
committed
✨(frontend) Adds customization for translations
Part of customization PoC Signed-off-by: Robin Weber <[email protected]>
1 parent d952815 commit f4ad26a

File tree

8 files changed

+230
-70
lines changed

8 files changed

+230
-70
lines changed

src/frontend/apps/impress/src/core/config/ConfigProvider.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { Loader } from '@openfun/cunningham-react';
22
import Head from 'next/head';
3-
import { PropsWithChildren, useEffect } from 'react';
3+
import { PropsWithChildren, useEffect, useRef } from 'react';
4+
import { useTranslation } from 'react-i18next';
45

56
import { Box } from '@/components';
67
import { useCunninghamTheme } from '@/cunningham';
7-
import { useLanguageSynchronizer } from '@/features/language/';
8+
import { useAuthQuery } from '@/features/auth';
9+
import {
10+
useCustomTranslations,
11+
useSynchronizedLanguage,
12+
} from '@/features/language';
813
import { useAnalytics } from '@/libs';
914
import { CrispProvider, PostHogAnalytic } from '@/services';
1015
import { useSentryStore } from '@/stores/useSentryStore';
@@ -13,10 +18,35 @@ import { useConfig } from './api/useConfig';
1318

1419
export const ConfigProvider = ({ children }: PropsWithChildren) => {
1520
const { data: conf } = useConfig();
21+
const { data: user } = useAuthQuery();
1622
const { setSentry } = useSentryStore();
1723
const { setTheme } = useCunninghamTheme();
24+
const { changeLanguageSynchronized } = useSynchronizedLanguage();
25+
const { customizeTranslations } = useCustomTranslations();
1826
const { AnalyticsProvider } = useAnalytics();
19-
const { synchronizeLanguage } = useLanguageSynchronizer();
27+
const { i18n } = useTranslation();
28+
const languageSynchronized = useRef(false);
29+
30+
useEffect(() => {
31+
if (!user || languageSynchronized.current) {
32+
return;
33+
}
34+
35+
const targetLanguage =
36+
user?.language ?? i18n.resolvedLanguage ?? i18n.language;
37+
38+
void changeLanguageSynchronized(targetLanguage, user).then(() => {
39+
languageSynchronized.current = true;
40+
});
41+
}, [user, i18n.resolvedLanguage, i18n.language, changeLanguageSynchronized]);
42+
43+
useEffect(() => {
44+
if (!conf?.theme_customization?.translations) {
45+
return;
46+
}
47+
48+
customizeTranslations(conf.theme_customization.translations);
49+
}, [conf?.theme_customization?.translations, customizeTranslations]);
2050

2151
useEffect(() => {
2252
if (!conf?.SENTRY_DSN) {
@@ -34,10 +64,6 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
3464
setTheme(conf.FRONTEND_THEME);
3565
}, [conf?.FRONTEND_THEME, setTheme]);
3666

37-
useEffect(() => {
38-
void synchronizeLanguage();
39-
}, [synchronizeLanguage]);
40-
4167
useEffect(() => {
4268
if (!conf?.POSTHOG_KEY) {
4369
return;

src/frontend/apps/impress/src/core/config/api/useConfig.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useQuery } from '@tanstack/react-query';
2+
import { Resource } from 'i18next';
23

34
import { APIError, errorCauses, fetchAPI } from '@/api';
45
import { Theme } from '@/cunningham/';
@@ -7,9 +8,10 @@ import { PostHogConf } from '@/services';
78

89
interface ThemeCustomization {
910
footer?: FooterType;
11+
translations?: Resource;
1012
}
1113

12-
interface ConfigResponse {
14+
export interface ConfigResponse {
1315
AI_FEATURE_ENABLED?: boolean;
1416
COLLABORATION_WS_URL?: string;
1517
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean;

src/frontend/apps/impress/src/features/language/LanguagePicker.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import { css } from 'styled-components';
55

66
import { DropdownMenu, Icon, Text } from '@/components/';
77
import { useConfig } from '@/core';
8+
import { useAuthQuery } from '@/features/auth';
89

910
import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer';
1011
import { getMatchingLocales } from './utils/locale';
1112

1213
export const LanguagePicker = () => {
1314
const { t, i18n } = useTranslation();
1415
const { data: conf } = useConfig();
16+
const { data: user } = useAuthQuery();
1517
const { synchronizeLanguage } = useLanguageSynchronizer();
1618
const language = i18n.languages[0];
1719
Settings.defaultLocale = language;
@@ -28,15 +30,17 @@ export const LanguagePicker = () => {
2830
i18n
2931
.changeLanguage(backendLocale)
3032
.then(() => {
31-
void synchronizeLanguage('toBackend');
33+
if (conf?.LANGUAGES && user) {
34+
synchronizeLanguage(conf.LANGUAGES, user, 'toBackend');
35+
}
3236
})
3337
.catch((err) => {
3438
console.error('Error changing language', err);
3539
});
3640
};
3741
return { label, isSelected, callback };
3842
});
39-
}, [conf, i18n, language, synchronizeLanguage]);
43+
}, [conf?.LANGUAGES, i18n, language, synchronizeLanguage, user]);
4044

4145
// Extract current language label for display
4246
const currentLanguageLabel =

src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts

Lines changed: 17 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,30 @@
1-
import { useCallback, useMemo, useRef } from 'react';
1+
import { useCallback, useRef } from 'react';
22
import { useTranslation } from 'react-i18next';
33

4-
import { useConfig } from '@/core';
5-
import { useAuthQuery } from '@/features/auth/api';
4+
import type { ConfigResponse } from '@/core/config/api/useConfig';
5+
import { User } from '@/features/auth';
66
import { useChangeUserLanguage } from '@/features/language/api/useChangeUserLanguage';
77
import { getMatchingLocales } from '@/features/language/utils/locale';
88
import { availableFrontendLanguages } from '@/i18n/initI18n';
99

1010
export const useLanguageSynchronizer = () => {
11-
const { data: conf, isSuccess: confInitialized } = useConfig();
12-
const { data: user, isSuccess: userInitialized } = useAuthQuery();
1311
const { i18n } = useTranslation();
1412
const { mutateAsync: changeUserLanguage } = useChangeUserLanguage();
1513
const languageSynchronizing = useRef(false);
1614

17-
const availableBackendLanguages = useMemo(() => {
18-
return conf?.LANGUAGES.map(([locale]) => locale);
19-
}, [conf?.LANGUAGES]);
20-
2115
const synchronizeLanguage = useCallback(
22-
async (direction?: 'toBackend' | 'toFrontend') => {
23-
if (
24-
languageSynchronizing.current ||
25-
!userInitialized ||
26-
!confInitialized ||
27-
!availableBackendLanguages ||
28-
!availableFrontendLanguages
29-
) {
16+
(
17+
languages: ConfigResponse['LANGUAGES'],
18+
user: User,
19+
direction?: 'toBackend' | 'toFrontend',
20+
) => {
21+
if (languageSynchronizing.current || !availableFrontendLanguages) {
3022
return;
3123
}
3224
languageSynchronizing.current = true;
3325

3426
try {
27+
const availableBackendLanguages = languages.map(([locale]) => locale);
3528
const userPreferredLanguages = user.language ? [user.language] : [];
3629
const setOrDetectedLanguages = i18n.languages;
3730

@@ -41,25 +34,27 @@ export const useLanguageSynchronizer = () => {
4134
(userPreferredLanguages.length ? 'toFrontend' : 'toBackend');
4235

4336
if (direction === 'toBackend') {
44-
// Update user's preference from frontends's language
4537
const closestBackendLanguage =
4638
getMatchingLocales(
4739
availableBackendLanguages,
4840
setOrDetectedLanguages,
4941
)[0] || availableBackendLanguages[0];
50-
await changeUserLanguage({
42+
changeUserLanguage({
5143
userId: user.id,
5244
language: closestBackendLanguage,
45+
}).catch((error) => {
46+
console.error('Error changing user language', error);
5347
});
5448
} else {
55-
// Update frontends's language from user's preference
5649
const closestFrontendLanguage =
5750
getMatchingLocales(
5851
availableFrontendLanguages,
5952
userPreferredLanguages,
6053
)[0] || availableFrontendLanguages[0];
6154
if (i18n.resolvedLanguage !== closestFrontendLanguage) {
62-
await i18n.changeLanguage(closestFrontendLanguage);
55+
i18n.changeLanguage(closestFrontendLanguage).catch((error) => {
56+
console.error('Error changing frontend language', error);
57+
});
6358
}
6459
}
6560
} catch (error) {
@@ -68,14 +63,7 @@ export const useLanguageSynchronizer = () => {
6863
languageSynchronizing.current = false;
6964
}
7065
},
71-
[
72-
i18n,
73-
user,
74-
userInitialized,
75-
confInitialized,
76-
availableBackendLanguages,
77-
changeUserLanguage,
78-
],
66+
[i18n, changeUserLanguage],
7967
);
8068

8169
return { synchronizeLanguage };
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import i18next, { Resource, i18n } from 'i18next';
2+
import { useCallback, useRef } from 'react';
3+
import { useTranslation } from 'react-i18next';
4+
5+
import type { ConfigResponse } from '@/core/config/api/useConfig';
6+
import { safeLocalStorage } from '@/utils/storages';
7+
8+
export const useTranslationsCustomizer = () => {
9+
const { i18n } = useTranslation();
10+
const translationsCustomizing = useRef(false);
11+
12+
const customizeTranslations = useCallback(
13+
(
14+
customTranslationsUrl: ConfigResponse['FRONTEND_CUSTOM_TRANSLATIONS_URL'],
15+
cacheKey: string = 'CUSTOM_TRANSLATIONS',
16+
) => {
17+
if (translationsCustomizing.current) {
18+
return;
19+
}
20+
translationsCustomizing.current = true;
21+
try {
22+
if (!customTranslationsUrl) {
23+
safeLocalStorage.setItem(cacheKey, '');
24+
} else {
25+
const previousTranslationsString = safeLocalStorage.getItem(cacheKey);
26+
if (previousTranslationsString) {
27+
const previousTranslations = JSON.parse(
28+
previousTranslationsString,
29+
) as Resource;
30+
try {
31+
applyTranslations(previousTranslations, i18n);
32+
} catch (err: unknown) {
33+
console.error('Error parsing cached translations:', err);
34+
safeLocalStorage.setItem(cacheKey, '');
35+
}
36+
}
37+
38+
// Always update in background
39+
fetchAndCacheTranslations(customTranslationsUrl, cacheKey)
40+
.then((updatedTranslations) => {
41+
if (
42+
updatedTranslations &&
43+
JSON.stringify(updatedTranslations) !==
44+
previousTranslationsString
45+
) {
46+
applyTranslations(updatedTranslations, i18n);
47+
}
48+
})
49+
.catch((err: unknown) => {
50+
console.error('Error fetching custom translations:', err);
51+
});
52+
}
53+
} catch (err: unknown) {
54+
console.error('Error updating custom translations:', err);
55+
} finally {
56+
translationsCustomizing.current = false;
57+
}
58+
},
59+
[i18n],
60+
);
61+
62+
const applyTranslations = (translations: Resource, i18n: i18n) => {
63+
Object.entries(translations).forEach(([lng, namespaces]) => {
64+
Object.entries(namespaces).forEach(([ns, value]) => {
65+
i18next.addResourceBundle(lng, ns, value, true, true);
66+
});
67+
});
68+
const currentLanguage = i18n.language;
69+
void i18next.changeLanguage(currentLanguage);
70+
};
71+
72+
const fetchAndCacheTranslations = (url: string, CACHE_KEY: string) => {
73+
return fetch(url).then((response) => {
74+
if (!response.ok) {
75+
throw new Error('Failed to fetch custom translations');
76+
}
77+
return response.json().then((customTranslations: Resource) => {
78+
safeLocalStorage.setItem(CACHE_KEY, JSON.stringify(customTranslations));
79+
return customTranslations;
80+
});
81+
});
82+
};
83+
84+
return { customizeTranslations };
85+
};
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
export * from './hooks/useLanguageSynchronizer';
2-
export * from './LanguagePicker';
1+
export * from './hooks';
2+
export * from './components';
3+
export * from './utils';

src/frontend/apps/impress/src/i18n/initI18n.ts

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,38 @@ import { initReactI18next } from 'react-i18next';
44

55
import resources from './translations.json';
66

7-
export const availableFrontendLanguages: readonly string[] =
8-
Object.keys(resources);
7+
// Add an initialization guard
8+
let isInitialized = false;
99

10-
i18next
11-
.use(LanguageDetector)
12-
.use(initReactI18next)
13-
.init({
14-
resources,
15-
fallbackLng: 'en',
16-
debug: false,
17-
detection: {
18-
order: ['cookie', 'navigator'], // detection order
19-
caches: ['cookie'], // Use cookies to store the language preference
20-
lookupCookie: 'docs_language',
21-
cookieMinutes: 525600, // Expires after one year
22-
cookieOptions: {
23-
path: '/',
24-
sameSite: 'lax',
10+
// Initialize i18next with the base translations only once
11+
if (!isInitialized && !i18next.isInitialized) {
12+
isInitialized = true;
13+
14+
i18next
15+
.use(LanguageDetector)
16+
.use(initReactI18next)
17+
.init({
18+
resources,
19+
fallbackLng: 'en',
20+
debug: false,
21+
detection: {
22+
order: ['cookie', 'navigator'],
23+
caches: ['cookie'],
24+
lookupCookie: 'docs_language',
25+
cookieMinutes: 525600,
26+
cookieOptions: {
27+
path: '/',
28+
sameSite: 'lax',
29+
},
30+
},
31+
interpolation: {
32+
escapeValue: false,
2533
},
26-
},
27-
interpolation: {
28-
escapeValue: false,
29-
},
30-
preload: availableFrontendLanguages,
31-
lowerCaseLng: true,
32-
nsSeparator: false,
33-
keySeparator: false,
34-
})
35-
.catch(() => {
36-
throw new Error('i18n initialization failed');
37-
});
34+
lowerCaseLng: true,
35+
nsSeparator: false,
36+
keySeparator: false,
37+
})
38+
.catch((e) => console.error('i18n initialization failed:', e));
39+
}
3840

3941
export default i18next;

0 commit comments

Comments
 (0)