Skip to content

Commit d8f1159

Browse files
committed
✨(frontend) adds customization for translations
Part of customization PoC
1 parent 3d5adad commit d8f1159

File tree

5 files changed

+182
-30
lines changed

5 files changed

+182
-30
lines changed

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { PropsWithChildren, useEffect } from 'react';
44

55
import { Box } from '@/components';
66
import { useCunninghamTheme } from '@/cunningham';
7-
import { useLanguageSynchronizer } from '@/features/language/';
7+
import {
8+
useLanguageSynchronizer,
9+
useTranslationsCustomizer,
10+
} from '@/features/language/';
811
import { useAnalytics } from '@/libs';
912
import { CrispProvider, PostHogAnalytic } from '@/services';
1013
import { useSentryStore } from '@/stores/useSentryStore';
@@ -17,6 +20,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
1720
const { setTheme } = useCunninghamTheme();
1821
const { AnalyticsProvider } = useAnalytics();
1922
const { synchronizeLanguage } = useLanguageSynchronizer();
23+
const { customizeTranslations } = useTranslationsCustomizer();
2024

2125
useEffect(() => {
2226
if (!conf?.SENTRY_DSN) {
@@ -35,8 +39,10 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
3539
}, [conf?.FRONTEND_THEME, setTheme]);
3640

3741
useEffect(() => {
38-
void synchronizeLanguage();
39-
}, [synchronizeLanguage]);
42+
void synchronizeLanguage().finally(() => {
43+
void customizeTranslations();
44+
});
45+
}, [synchronizeLanguage, customizeTranslations]);
4046

4147
useEffect(() => {
4248
if (!conf?.POSTHOG_KEY) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface ConfigResponse {
1212
CRISP_WEBSITE_ID?: string;
1313
FRONTEND_THEME?: Theme;
1414
FRONTEND_CSS_URL?: string;
15+
FRONTEND_CUSTOM_TRANSLATIONS_URL?: string;
1516
MEDIA_BASE_URL?: string;
1617
POSTHOG_KEY?: PostHogConf;
1718
SENTRY_DSN?: string;
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import i18next, { Resource } from 'i18next';
2+
import { useCallback, useState } from 'react';
3+
import { useTranslation } from 'react-i18next';
4+
5+
import { useConfig } from '@/core';
6+
7+
const CACHE_KEY = 'docs_custom_translations';
8+
const CACHE_URL_KEY = 'docs_custom_translations_url';
9+
10+
const safeLocalStorage = {
11+
getItem: (key: string): string | null => {
12+
if (typeof window === 'undefined') {
13+
return null;
14+
}
15+
return localStorage.getItem(key);
16+
},
17+
setItem: (key: string, value: string): void => {
18+
if (typeof window === 'undefined') {
19+
return;
20+
}
21+
localStorage.setItem(key, value);
22+
},
23+
};
24+
25+
export const useTranslationsCustomizer = () => {
26+
const { data: conf } = useConfig();
27+
const [isCustomized, setIsCustomized] = useState<boolean | null>(null);
28+
const { i18n } = useTranslation();
29+
30+
// Apply custom translations to i18next
31+
const applyCustomTranslations = useCallback(
32+
(translations: Resource) => {
33+
if (!translations) {
34+
setIsCustomized(false);
35+
return;
36+
}
37+
38+
// Add each language's custom translations with proper namespace handling
39+
Object.entries(translations).forEach(([lng, namespaces]) => {
40+
Object.entries(namespaces).forEach(([ns, value]) => {
41+
i18next.addResourceBundle(lng, ns, value, true, true);
42+
});
43+
});
44+
45+
const currentLanguage = i18n.language;
46+
void i18next.changeLanguage(currentLanguage);
47+
48+
// Emit added event to make sure components re-render
49+
i18next.emit('added', currentLanguage, 'translation');
50+
51+
setIsCustomized(true);
52+
},
53+
[i18n],
54+
);
55+
56+
const fetchAndApplyCustomTranslations = useCallback(
57+
async (url: string) => {
58+
try {
59+
const response = await fetch(url);
60+
if (!response.ok) {
61+
throw new Error('Failed to fetch custom translations');
62+
}
63+
64+
const translations = (await response.json()) as Resource;
65+
66+
// Cache for future use
67+
safeLocalStorage.setItem(CACHE_KEY, JSON.stringify(translations));
68+
safeLocalStorage.setItem(CACHE_URL_KEY, url);
69+
70+
// Apply the translations
71+
applyCustomTranslations(translations);
72+
73+
return true;
74+
} catch (e) {
75+
console.error('Error fetching custom translations:', e);
76+
safeLocalStorage.setItem(CACHE_KEY, 'false');
77+
safeLocalStorage.setItem(CACHE_URL_KEY, url || '');
78+
setIsCustomized(false);
79+
80+
return false;
81+
}
82+
},
83+
[applyCustomTranslations],
84+
);
85+
86+
// Main function to customize translations
87+
const customizeTranslations = useCallback(async () => {
88+
// Skip if already processed or no config
89+
if (isCustomized !== null || !conf) {
90+
return;
91+
}
92+
93+
const customUrl = conf.FRONTEND_CUSTOM_TRANSLATIONS_URL;
94+
const cachedUrl = safeLocalStorage.getItem(CACHE_URL_KEY);
95+
96+
// Fast path: If we have cached translations for the same URL
97+
if (cachedUrl === customUrl) {
98+
const cached = safeLocalStorage.getItem(CACHE_KEY);
99+
100+
if (cached) {
101+
try {
102+
// Apply cached translations immediately
103+
if (cached !== 'false') {
104+
const parsedTranslations = JSON.parse(cached) as Resource;
105+
applyCustomTranslations(parsedTranslations);
106+
} else {
107+
setIsCustomized(false);
108+
}
109+
110+
// Update cache in background if URL is provided
111+
if (customUrl) {
112+
void fetchAndApplyCustomTranslations(customUrl);
113+
}
114+
115+
return;
116+
} catch (e) {
117+
console.error('Error parsing cached translations:', e);
118+
}
119+
}
120+
}
121+
122+
// No valid cache, fetch new translations or mark as not customized
123+
if (customUrl) {
124+
await fetchAndApplyCustomTranslations(customUrl);
125+
} else {
126+
safeLocalStorage.setItem(CACHE_KEY, 'false');
127+
safeLocalStorage.setItem(CACHE_URL_KEY, '');
128+
setIsCustomized(false);
129+
}
130+
}, [
131+
conf,
132+
isCustomized,
133+
applyCustomTranslations,
134+
fetchAndApplyCustomTranslations,
135+
]);
136+
137+
return { customizeTranslations, isCustomized };
138+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './hooks/useLanguageSynchronizer';
2+
export * from './hooks/useTranslationsCustomizer';
23
export * from './LanguagePicker';

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

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,39 @@ import resources from './translations.json';
77
export const availableFrontendLanguages: readonly string[] =
88
Object.keys(resources);
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+
// Add an initialization guard
11+
let isInitialized = false;
12+
13+
// Initialize i18next with the base translations only once
14+
if (!isInitialized && !i18next.isInitialized) {
15+
isInitialized = true;
16+
17+
i18next
18+
.use(LanguageDetector)
19+
.use(initReactI18next)
20+
.init({
21+
resources,
22+
fallbackLng: 'en',
23+
debug: false,
24+
detection: {
25+
order: ['cookie', 'navigator'],
26+
caches: ['cookie'],
27+
lookupCookie: 'docs_language',
28+
cookieMinutes: 525600,
29+
cookieOptions: {
30+
path: '/',
31+
sameSite: 'lax',
32+
},
33+
},
34+
interpolation: {
35+
escapeValue: false,
2536
},
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-
});
37+
preload: availableFrontendLanguages,
38+
lowerCaseLng: true,
39+
nsSeparator: false,
40+
keySeparator: false,
41+
})
42+
.catch((e) => console.error('i18n initialization failed:', e));
43+
}
3844

3945
export default i18next;

0 commit comments

Comments
 (0)