- New src/lib/dateFnsI18n.ts re-exports date-fns with formatDistanceToNow /
formatDistanceToNowStrict / formatDistance / format defaulting to the active
locale, plus dateLocaleCode() for native toLocale{Date,Time}String calls.
- LanguageProvider keeps the locale in sync during render, so the first paint is
already in-language.
- Codemod: 11 date-fns imports now use the shim; ~39 native date/time calls pass
the app locale code instead of undefined/[]/hardcoded 'en-US'. Number
.toLocaleString() calls are deliberately untouched.
87 lines
2.8 KiB
TypeScript
87 lines
2.8 KiB
TypeScript
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
|
import {
|
|
LanguageContext,
|
|
getNestedValue,
|
|
interpolate,
|
|
translations,
|
|
type Language,
|
|
} from './language';
|
|
import { setDateLocale } from '@/lib/dateFnsI18n';
|
|
|
|
interface LanguageProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Pick the starting language from the visitor's browser on their FIRST visit.
|
|
* A device set to Spanish (`es-ES`, `es-MX`, …) lands in Castellano; anything
|
|
* else falls back to English. Once the user taps the language toggle their
|
|
* choice is stored and always wins.
|
|
*/
|
|
function detectLanguage(): Language {
|
|
if (typeof navigator === 'undefined') return 'en';
|
|
const tags = [navigator.language, ...(navigator.languages ?? [])];
|
|
return tags.some((tag) => tag?.toLowerCase().startsWith('es')) ? 'es' : 'en';
|
|
}
|
|
|
|
/**
|
|
* Provides language state + translation. The context, types and hooks live in
|
|
* `./language` so this file exports only the component (React Fast Refresh).
|
|
*/
|
|
export function LanguageProvider({ children }: LanguageProviderProps) {
|
|
const [storedLanguage, setStoredLanguage] = useLocalStorage<Language>('app-language', detectLanguage());
|
|
const [language, setLanguageState] = useState<Language>(storedLanguage);
|
|
|
|
// Sync with localStorage
|
|
useEffect(() => {
|
|
setLanguageState(storedLanguage);
|
|
}, [storedLanguage]);
|
|
|
|
const setLanguage = useCallback((lang: Language) => {
|
|
setLanguageState(lang);
|
|
setStoredLanguage(lang);
|
|
// Update HTML lang attribute for accessibility
|
|
document.documentElement.lang = lang;
|
|
}, [setStoredLanguage]);
|
|
|
|
// Set initial HTML lang attribute
|
|
useEffect(() => {
|
|
document.documentElement.lang = language;
|
|
}, [language]);
|
|
|
|
const t = useCallback((key: string, params?: Record<string, string | number>): string => {
|
|
// Try to get translation in current language
|
|
const translation = getNestedValue(translations[language], key);
|
|
|
|
if (translation) {
|
|
return interpolate(translation, params);
|
|
}
|
|
|
|
// Fallback to English
|
|
if (language !== 'en') {
|
|
const fallback = getNestedValue(translations.en, key);
|
|
if (fallback) {
|
|
return interpolate(fallback, params);
|
|
}
|
|
}
|
|
|
|
// Return key if no translation found (helps identify missing translations)
|
|
console.warn(`Missing translation for key: ${key}`);
|
|
return key;
|
|
}, [language]);
|
|
|
|
// Currently no RTL languages supported, but ready for future
|
|
const isRTL = false;
|
|
|
|
// Keep date-fns + native toLocale* formatting in the app language. Called
|
|
// during render (before children) so the first paint is already in-language;
|
|
// it is an idempotent module assignment, not React state.
|
|
setDateLocale(language);
|
|
|
|
return (
|
|
<LanguageContext.Provider value={{ language, setLanguage, t, isRTL }}>
|
|
{children}
|
|
</LanguageContext.Provider>
|
|
);
|
|
}
|