'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>; }; const builtInTranslations: Record> = { en: en as unknown as Record, de: de as unknown as Record, sl: sl as unknown as Record, }; 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); 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 }).settings; if (!settings || typeof settings !== 'object') return null; return normalizeLanguageValue( settings.preferred_language ?? settings.preferredLanguage ?? settings.language ?? settings.lang, ); } function getNestedValue(root: Record, 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)[segment]; } return typeof current === 'string' ? current : null; } interface I18nContextType { language: string; setLanguage: (lang: string) => void; t: (key: string) => string; languages: LanguageEntry[]; reloadTranslations: () => Promise; } const I18nContext = createContext(undefined); interface I18nProviderProps { children: ReactNode; } export function I18nProvider({ children }: I18nProviderProps) { const accessToken = useAuthStore((state) => state.accessToken); const [language, setLanguage] = useState(DEFAULT_LANGUAGE); const [hasSyncedStoredLanguage, setHasSyncedStoredLanguage] = useState(false); const [hasResolvedPersistedLanguage, setHasResolvedPersistedLanguage] = useState(false); const lastPersistedLanguageRef = useRef(null); const [translationFiles, setTranslationFiles] = useState({ languages: [ { code: 'en', name: 'English' }, { code: 'de', name: 'Deutsch' }, { code: 'sl', name: 'Slovenian' }, ], translations: { en: enFlat, de: flattenObject(de as unknown as Record), sl: flattenObject(sl as unknown as Record), }, }); 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> : {}; 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 ( {children} ); } 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; }