profit-planet-frontend/src/app/i18n/useTranslation.tsx

280 lines
8.6 KiB
TypeScript

'use client';
import { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react';
import { DEFAULT_LANGUAGE } from './config';
import { en } from './translations/en';
import { de } from './translations/de';
import { sl } from './translations/sl';
import { flattenObject } from './dynamicTranslations';
import { authFetch } from '../utils/authFetch';
import useAuthStore from '../store/authStore';
import { API_ENDPOINTS } from '../utils/api';
type LanguageEntry = {
code: string;
name: string;
};
type TranslationFilesPayload = {
languages: LanguageEntry[];
translations: Record<string, Record<string, string>>;
};
const builtInTranslations: Record<string, Record<string, unknown>> = {
en: en as unknown as Record<string, unknown>,
de: de as unknown as Record<string, unknown>,
sl: sl as unknown as Record<string, unknown>,
};
const APP_LANGUAGE_STORAGE_KEY = 'pp-selected-language';
// Flat map of English keys used as canonical key list and fallback
const enFlat = flattenObject(en as unknown as Record<string, unknown>);
function normalizeLanguageValue(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
return normalized ? normalized : null;
}
function extractPreferredLanguage(payload: unknown): string | null {
if (!payload || typeof payload !== 'object') return null;
const settings = (payload as { settings?: Record<string, unknown> }).settings;
if (!settings || typeof settings !== 'object') return null;
return normalizeLanguageValue(
settings.preferred_language
?? settings.preferredLanguage
?? settings.language
?? settings.lang,
);
}
function getNestedValue(root: Record<string, unknown>, key: string): string | null {
const segments = key.split('.');
let current: unknown = root;
for (const segment of segments) {
if (!current || typeof current !== 'object') return null;
current = (current as Record<string, unknown>)[segment];
}
return typeof current === 'string' ? current : null;
}
interface I18nContextType {
language: string;
setLanguage: (lang: string) => void;
t: (key: string) => string;
languages: LanguageEntry[];
reloadTranslations: () => Promise<void>;
}
const I18nContext = createContext<I18nContextType | undefined>(undefined);
interface I18nProviderProps {
children: ReactNode;
}
export function I18nProvider({ children }: I18nProviderProps) {
const accessToken = useAuthStore((state) => state.accessToken);
const [language, setLanguage] = useState<string>(DEFAULT_LANGUAGE);
const [hasSyncedStoredLanguage, setHasSyncedStoredLanguage] = useState(false);
const [hasResolvedPersistedLanguage, setHasResolvedPersistedLanguage] = useState(false);
const lastPersistedLanguageRef = useRef<string | null>(null);
const [translationFiles, setTranslationFiles] = useState<TranslationFilesPayload>({
languages: [
{ code: 'en', name: 'English' },
{ code: 'de', name: 'Deutsch' },
{ code: 'sl', name: 'Slovenian' },
],
translations: {
en: enFlat,
de: flattenObject(de as unknown as Record<string, unknown>),
sl: flattenObject(sl as unknown as Record<string, unknown>),
},
});
const reloadTranslations = useCallback(async () => {
try {
const response = await fetch('/api/i18n/translations', { cache: 'no-store' });
const result = await response.json();
if (!response.ok || !result?.ok) return;
const languages = Array.isArray(result.languages)
? result.languages.filter((lang: unknown): lang is LanguageEntry => {
if (!lang || typeof lang !== 'object') return false;
const entry = lang as LanguageEntry;
return typeof entry.code === 'string' && typeof entry.name === 'string';
})
: [];
const translations = result.translations && typeof result.translations === 'object'
? result.translations as Record<string, Record<string, string>>
: {};
if (languages.length > 0) {
setTranslationFiles({ languages, translations });
}
} catch {
// Keep built-in fallback translations if API is unavailable.
}
}, []);
useEffect(() => {
void reloadTranslations();
}, [reloadTranslations]);
useEffect(() => {
if (typeof window === 'undefined') return;
const stored = window.localStorage.getItem(APP_LANGUAGE_STORAGE_KEY);
if (stored && stored.trim()) {
setLanguage((currentLanguage) => currentLanguage === stored ? currentLanguage : stored);
}
setHasSyncedStoredLanguage(true);
}, []);
useEffect(() => {
lastPersistedLanguageRef.current = null;
}, [accessToken]);
useEffect(() => {
if (translationFiles.languages.length === 0) return;
if (translationFiles.languages.some((entry) => entry.code === language)) return;
const fallback = translationFiles.languages.find((entry) => entry.code === DEFAULT_LANGUAGE)?.code
?? translationFiles.languages[0]?.code
?? DEFAULT_LANGUAGE;
setLanguage(fallback);
}, [translationFiles.languages, language]);
useEffect(() => {
if (!hasSyncedStoredLanguage) return;
let cancelled = false;
async function syncPersistedLanguage() {
if (!accessToken) {
setHasResolvedPersistedLanguage(true);
return;
}
setHasResolvedPersistedLanguage(false);
try {
const response = await authFetch(API_ENDPOINTS.USER_SETTINGS, {
method: 'GET',
cache: 'no-store',
});
const result = await response.json().catch(() => null);
const preferredLanguage = response.ok ? extractPreferredLanguage(result) : null;
if (!cancelled && preferredLanguage) {
lastPersistedLanguageRef.current = preferredLanguage;
setLanguage((currentLanguage) => currentLanguage === preferredLanguage ? currentLanguage : preferredLanguage);
}
} catch {
// Ignore preference sync failures and keep local state.
} finally {
if (!cancelled) {
setHasResolvedPersistedLanguage(true);
}
}
}
void syncPersistedLanguage();
return () => {
cancelled = true;
};
}, [accessToken, hasSyncedStoredLanguage]);
useEffect(() => {
if (typeof window === 'undefined' || !hasSyncedStoredLanguage) return;
window.localStorage.setItem(APP_LANGUAGE_STORAGE_KEY, language);
document.documentElement.lang = language;
if (!accessToken || !hasResolvedPersistedLanguage || lastPersistedLanguageRef.current === language) {
return;
}
let cancelled = false;
async function persistPreferredLanguage() {
try {
const response = await authFetch(API_ENDPOINTS.USER_SETTINGS, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ preferredLanguage: language }),
});
if (!cancelled && response.ok) {
lastPersistedLanguageRef.current = language;
}
} catch {
// Ignore persistence failures; localStorage remains as the client fallback.
}
}
void persistPreferredLanguage();
return () => {
cancelled = true;
};
}, [accessToken, hasResolvedPersistedLanguage, hasSyncedStoredLanguage, language]);
const t = useCallback((key: string): string => {
// 1. Check translation loaded from translation files API.
const fileValue = translationFiles.translations[language]?.[key];
if (fileValue !== undefined && fileValue !== '') return fileValue;
// 2. Check built-in static imports (works even before API load).
const builtIn = builtInTranslations[language];
if (builtIn) {
const value = getNestedValue(builtIn, key);
if (value !== null) return value;
}
// 3. Fallback to English.
return enFlat[key] ?? key;
}, [language, translationFiles.translations]);
return (
<I18nContext.Provider
value={{
language,
setLanguage,
t,
languages: translationFiles.languages,
reloadTranslations,
}}
>
{children}
</I18nContext.Provider>
);
}
export function useTranslation() {
const context = useContext(I18nContext);
if (context === undefined) {
throw new Error('useTranslation must be used within an I18nProvider');
}
return context;
}
/** Returns all known translation keys (from English as source of truth) */
export function getAllTranslationKeys(): string[] {
return Object.keys(enFlat);
}
/** Returns the English value for a key (used as reference in admin UI) */
export function getEnglishValue(key: string): string {
return enFlat[key] ?? key;
}