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 315dd6af..c704cb9b 100644 --- a/src/components/preferences/UserPreferences.jsx +++ b/src/components/preferences/UserPreferences.jsx @@ -5,24 +5,8 @@ import ThemeSettings from './ThemeSettings' import AccessibilitySettings from './AccessibilitySettings' import NotificationPreferences from '../notifications/NotificationPreferences' import { showTestNotification } from '../../utils/offline' -import { - Bell, - Download, - RefreshCw, - Share2, - SlidersHorizontal, - Upload, -} from 'lucide-react' -import { - applyPreferencePreset, - createPreferencePreset, - exportPreferences, - getPreferenceSyncStatus, - importPreferences, - PREFERENCE_PRESETS, - sharePreferencePreset, - validatePreferences, -} from '../../lib/userPreferences' +import { Bell, Globe2 } from 'lucide-react' +import { useI18nContext } from '../I18nProvider.jsx' const TABS = [ { id: 'general', label: 'General' }, @@ -34,7 +18,19 @@ const TABS = [ ] export default function UserPreferences({ onClose }) { - const { preferences, update, save, reset, loading } = usePreferences() + 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) const [shareToken, setShareToken] = useState('') @@ -47,42 +43,9 @@ export default function UserPreferences({ onClose }) { setTimeout(() => setSaved(false), 1500) } - const handleSavePreferences = async (nextPreferences) => { - await save(nextPreferences) - setSaved(true) - setTimeout(() => setSaved(false), 1500) - } - - const handlePreset = async (presetId) => { - await handleSavePreferences(applyPreferencePreset(presetId, preferences)) - } - - const downloadText = (filename, content, type) => { - const blob = new Blob([content], { type }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = filename - link.click() - URL.revokeObjectURL(url) - } - - const handleExport = () => { - downloadText('stellar-preferences.json', exportPreferences(preferences), 'application/json') - } - - const handleImport = async () => { - const payload = prompt('Paste exported preferences JSON') - if (!payload) return - await handleSavePreferences(importPreferences(payload)) - } - - const handleSharePreset = () => { - const preset = createPreferencePreset('Shared preferences', preferences, { - description: 'Shared dashboard preference preset', - shared: true, - }) - setShareToken(sharePreferencePreset(preset)) + const handleLanguageChange = async (languageCode) => { + await changeLanguage(languageCode) + await handleChange('language', languageCode) } if (loading) { @@ -179,16 +142,38 @@ export default function UserPreferences({ onClose }) { +
+
+ + {localeProfile.label} +
+ + + + + +
+ + {label} + {value} + + ) +} + function Toggle({ checked, onChange }) { return (