Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 84 additions & 12 deletions src/components/I18nProvider.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,47 +49,88 @@ const I18nContext = createContext(null);
* </I18nProvider>
*/
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 <html lang=""> for accessibility & SEO
document.documentElement.setAttribute('lang', langCode);
// Update <html dir=""> 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,
};

Expand All @@ -79,4 +151,4 @@ export function useI18nContext() {
return ctx;
}

export default I18nProvider;
export default I18nProvider;
114 changes: 54 additions & 60 deletions src/components/preferences/UserPreferences.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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('')
Expand All @@ -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) {
Expand Down Expand Up @@ -179,16 +142,38 @@ export default function UserPreferences({ onClose }) {

<PreferenceRow label="Language">
<select
value={preferences.language}
onChange={(e) => handleChange('language', e.target.value)}
value={currentLanguage || preferences.language}
onChange={(e) => handleLanguageChange(e.target.value)}
style={selectStyle}
>
<option value="en">English</option>
<option value="es">Español</option>
<option value="zh">中文</option>
{supportedLanguages.map((language) => (
<option key={language.code} value={language.code}>
{language.nativeLabel} ({language.locale})
</option>
))}
</select>
</PreferenceRow>

<div style={{
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
background: 'var(--bg-elevated)',
padding: '12px',
display: 'grid',
gap: '8px',
fontSize: '12px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-primary)', fontFamily: 'var(--font-display)', fontWeight: 600 }}>
<Globe2 size={14} />
{localeProfile.label}
</div>
<LocalePreview label="Region" value={`${localeProfile.region} / ${currentLocale} / ${isRTL ? 'RTL' : 'LTR'}`} />
<LocalePreview label="Date" value={formatDateTime('2026-06-25T15:30:00Z')} />
<LocalePreview label="Number" value={formatNumber(1234567.89)} />
<LocalePreview label="Currency" value={formatCurrency(1234.56)} />
<LocalePreview label="Local note" value={regionalContent.defaultNetworkNotice} />
</div>

<PreferenceRow label="Compact Mode">
<Toggle
checked={preferences.compactMode}
Expand Down Expand Up @@ -352,6 +337,15 @@ function PreferenceRow({ label, children }) {
)
}

function LocalePreview({ label, value }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '90px minmax(0, 1fr)', gap: '8px', alignItems: 'center' }}>
<span style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>{label}</span>
<span style={{ color: 'var(--text-secondary)', overflowWrap: 'anywhere' }}>{value}</span>
</div>
)
}

function Toggle({ checked, onChange }) {
return (
<button
Expand Down
30 changes: 24 additions & 6 deletions src/hooks/useTranslation.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,20 @@ import { RTL_LANGUAGES } from "../i18n/index.js";
*/
export function useTranslation(ns = "translation") {
const { t, i18n, ready } = useI18nextTranslation(ns);
const { currentLanguage, changeLanguage, supportedLanguages, isRTL } =
useI18nContext();
const {
currentLanguage,
currentLocale,
changeLanguage,
supportedLanguages,
localeProfile,
culturalAdaptations,
regionalContent,
formatDateTime,
formatNumber: formatLocaleNumber,
formatCurrency,
validateLocale,
isRTL,
} = useI18nContext();

/**
* Safe translate — returns the key itself when a translation is missing,
Expand Down Expand Up @@ -76,12 +88,12 @@ export function useTranslation(ns = "translation") {
const formatNumber = useCallback(
(value, opts = {}) => {
try {
return new Intl.NumberFormat(currentLanguage, opts).format(value);
return formatLocaleNumber(value, opts);
} catch {
return String(value);
}
},
[currentLanguage],
[formatLocaleNumber],
);

/**
Expand All @@ -92,12 +104,12 @@ export function useTranslation(ns = "translation") {
const formatDate = useCallback(
(date, opts = { dateStyle: "medium" }) => {
try {
return new Intl.DateTimeFormat(currentLanguage, opts).format(new Date(date));
return formatDateTime(date, opts);
} catch {
return String(date);
}
},
[currentLanguage],
[formatDateTime],
);

/** true if the active language is RTL (#107) */
Expand All @@ -111,8 +123,14 @@ export function useTranslation(ns = "translation") {
i18n,
ready,
currentLanguage,
currentLocale,
changeLanguage,
supportedLanguages,
localeProfile,
culturalAdaptations,
regionalContent,
formatCurrency,
validateLocale,
isRTL: isRTLActive,
};
}
Expand Down
18 changes: 9 additions & 9 deletions src/i18n/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ import ko from "./ko.json";
import ar from "./ar.json";

export const SUPPORTED_LANGUAGES = [
{ code: "en", label: "English", nativeLabel: "English", dir: "ltr" },
{ code: "es", label: "Spanish", nativeLabel: "Español", dir: "ltr" },
{ code: "zh", label: "Chinese", nativeLabel: "中文", dir: "ltr" },
{ code: "fr", label: "French", nativeLabel: "Français", dir: "ltr" },
{ code: "de", label: "German", nativeLabel: "Deutsch", dir: "ltr" },
{ code: "pt", label: "Portuguese", nativeLabel: "Português", dir: "ltr" },
{ code: "ja", label: "Japanese", nativeLabel: "日本語", dir: "ltr" },
{ code: "ko", label: "Korean", nativeLabel: "한국어", dir: "ltr" },
{ code: "ar", label: "Arabic", nativeLabel: "العربية", dir: "rtl" },
{ code: "en", locale: "en-US", label: "English", nativeLabel: "English", dir: "ltr" },
{ code: "es", locale: "es-ES", label: "Spanish", nativeLabel: "Español", dir: "ltr" },
{ code: "zh", locale: "zh-CN", label: "Chinese", nativeLabel: "中文", dir: "ltr" },
{ code: "fr", locale: "fr-FR", label: "French", nativeLabel: "Français", dir: "ltr" },
{ code: "de", locale: "de-DE", label: "German", nativeLabel: "Deutsch", dir: "ltr" },
{ code: "pt", locale: "pt-BR", label: "Portuguese", nativeLabel: "Português", dir: "ltr" },
{ code: "ja", locale: "ja-JP", label: "Japanese", nativeLabel: "日本語", dir: "ltr" },
{ code: "ko", locale: "ko-KR", label: "Korean", nativeLabel: "한국어", dir: "ltr" },
{ code: "ar", locale: "ar-SA", label: "Arabic", nativeLabel: "العربية", dir: "rtl" },
];

/** Languages that flow right-to-left (#107). */
Expand Down
Loading
Loading