From be4a87885e2135709d39dd8edbe48569b8e802cb Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:57:46 -0700 Subject: [PATCH] Add locale-specific internationalization features --- src/components/I18nProvider.jsx | 96 +++- .../preferences/UserPreferences.jsx | 61 ++- src/hooks/useTranslation.js | 30 +- src/i18n/index.js | 18 +- src/lib/__tests__/localeFeatures.test.ts | 72 +++ src/lib/localeFeatures.ts | 425 ++++++++++++++++++ 6 files changed, 669 insertions(+), 33 deletions(-) create mode 100644 src/lib/__tests__/localeFeatures.test.ts create mode 100644 src/lib/localeFeatures.ts diff --git a/src/components/I18nProvider.jsx b/src/components/I18nProvider.jsx index 916c4200..05cbba34 100644 --- a/src/components/I18nProvider.jsx +++ b/src/components/I18nProvider.jsx @@ -1,9 +1,40 @@ import React, { createContext, useContext, useCallback, useEffect, useState } from 'react'; import { I18nextProvider } from 'react-i18next'; import i18n, { SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY, RTL_LANGUAGES } from '../i18n/index.js'; +import { + detectBestLocale, + formatLocaleCurrency, + formatLocaleDateTime, + formatLocaleNumber, + getCulturalAdaptations, + getLocaleProfile, + getRegionalContent, + isLocaleRTL, + loadPersistedLocale, + persistLocalePreference, + validateLocaleFormatting, +} from '../lib/localeFeatures'; const I18nContext = createContext(null); +function getInitialLocaleProfile() { + return detectBestLocale({ + storedLocale: loadPersistedLocale(), + navigatorLanguage: typeof navigator !== 'undefined' ? navigator.language : null, + htmlLang: typeof document !== 'undefined' ? document.documentElement.getAttribute('lang') : null, + }); +} + +function applyLocaleDocumentAttributes(profile) { + if (typeof document === 'undefined') return; + const root = document.documentElement; + root.setAttribute('lang', profile.code); + root.setAttribute('dir', profile.textDirection); + root.dataset.locale = profile.code; + root.dataset.region = profile.region; + root.dataset.currency = profile.currency; + root.dataset.layoutDensity = profile.layoutDensity; +} /** * I18nProvider @@ -18,47 +49,88 @@ const I18nContext = createContext(null); * */ export function I18nProvider({ children }) { + const initialProfile = getInitialLocaleProfile(); const [currentLanguage, setCurrentLanguage] = useState( - () => i18n.language?.slice(0, 2) || 'en' + () => i18n.language?.slice(0, 2) || initialProfile.languageCode + ); + const [currentLocale, setCurrentLocale] = useState( + () => initialProfile.code ); // Keep local state in sync when i18next changes language externally useEffect(() => { - const onLangChange = (lng) => setCurrentLanguage(lng.slice(0, 2)); + const onLangChange = (lng) => { + const profile = getLocaleProfile(lng); + setCurrentLanguage(profile.languageCode); + setCurrentLocale(profile.code); + applyLocaleDocumentAttributes(profile); + }; i18n.on('languageChanged', onLangChange); return () => i18n.off('languageChanged', onLangChange); }, []); + useEffect(() => { + applyLocaleDocumentAttributes(getLocaleProfile(currentLocale)); + }, [currentLocale]); + /** * Switch the active language. * @param {string} langCode - BCP-47 language code, e.g. 'en' | 'es' */ const changeLanguage = useCallback(async (langCode) => { - const supported = SUPPORTED_LANGUAGES.find((l) => l.code === langCode); + const requestedProfile = getLocaleProfile(langCode); + const supported = SUPPORTED_LANGUAGES.find((l) => { + return l.code === langCode || l.locale === langCode || l.code === requestedProfile.languageCode; + }); if (!supported) { console.warn(`[i18n] Unsupported language: "${langCode}". Falling back to "en".`); - langCode = 'en'; } - await i18n.changeLanguage(langCode); + const profile = getLocaleProfile(supported?.locale || 'en-US'); + await i18n.changeLanguage(profile.languageCode); try { - localStorage.setItem(LANGUAGE_STORAGE_KEY, langCode); + localStorage.setItem(LANGUAGE_STORAGE_KEY, profile.languageCode); } catch { // localStorage may be unavailable (private browsing, etc.) } + persistLocalePreference(profile.code); - // Update for accessibility & SEO - document.documentElement.setAttribute('lang', langCode); - // Update for RTL language support (#184) - document.documentElement.setAttribute('dir', RTL_LANGUAGES.has(langCode) ? 'rtl' : 'ltr'); + setCurrentLanguage(profile.languageCode); + setCurrentLocale(profile.code); + applyLocaleDocumentAttributes(profile); }, []); - const isRTL = RTL_LANGUAGES.has(currentLanguage); + const localeProfile = getLocaleProfile(currentLocale); + const isRTL = RTL_LANGUAGES.has(currentLanguage) || isLocaleRTL(currentLocale); + const formatDateTime = useCallback( + (date, options) => formatLocaleDateTime(date, currentLocale, options), + [currentLocale] + ); + const formatNumber = useCallback( + (value, options) => formatLocaleNumber(value, currentLocale, options), + [currentLocale] + ); + const formatCurrency = useCallback( + (value, currency) => formatLocaleCurrency(value, currentLocale, currency), + [currentLocale] + ); + const validateLocale = useCallback( + () => validateLocaleFormatting(currentLocale), + [currentLocale] + ); const value = { currentLanguage, + currentLocale, changeLanguage, supportedLanguages: SUPPORTED_LANGUAGES, + localeProfile, + culturalAdaptations: getCulturalAdaptations(currentLocale), + regionalContent: getRegionalContent(currentLocale), + formatDateTime, + formatNumber, + formatCurrency, + validateLocale, isRTL, }; @@ -79,4 +151,4 @@ export function useI18nContext() { return ctx; } -export default I18nProvider; \ No newline at end of file +export default I18nProvider; diff --git a/src/components/preferences/UserPreferences.jsx b/src/components/preferences/UserPreferences.jsx index 0277a097..102d5cf4 100644 --- a/src/components/preferences/UserPreferences.jsx +++ b/src/components/preferences/UserPreferences.jsx @@ -5,7 +5,8 @@ import ThemeSettings from './ThemeSettings' import AccessibilitySettings from './AccessibilitySettings' import NotificationPreferences from '../notifications/NotificationPreferences' import { showTestNotification } from '../../utils/offline' -import { Bell } from 'lucide-react' +import { Bell, Globe2 } from 'lucide-react' +import { useI18nContext } from '../I18nProvider.jsx' const TABS = [ { id: 'general', label: 'General' }, @@ -17,6 +18,18 @@ const TABS = [ export default function UserPreferences({ onClose }) { const { preferences, update, reset, loading } = usePreferences() + const { + changeLanguage, + currentLanguage, + currentLocale, + supportedLanguages, + localeProfile, + regionalContent, + formatDateTime, + formatNumber, + formatCurrency, + isRTL, + } = useI18nContext() const [activeTab, setActiveTab] = useState('general') const [saved, setSaved] = useState(false) @@ -26,6 +39,11 @@ export default function UserPreferences({ onClose }) { setTimeout(() => setSaved(false), 1500) } + const handleLanguageChange = async (languageCode) => { + await changeLanguage(languageCode) + await handleChange('language', languageCode) + } + if (loading) { return (
@@ -120,16 +138,38 @@ export default function UserPreferences({ onClose }) { +
+
+ + {localeProfile.label} +
+ + + + + +
+ + {label} + {value} +
+ ) +} + function Toggle({ checked, onChange }) { return (