fix(i18n): initialize locale from browser/localStorage and add fallback chain
- Initialize useState with getInitialLocale() instead of hardcoded 'en', so the correct locale is used from the very first render - Default to 'pt-BR' instead of 'en' for SSR and fallback - Add fallback chain in t(): tries current locale -> pt-BR -> en - Extract resolveKey() helper for cleaner key resolution - Cast dictionaries as Record<string, unknown> to avoid type issues - Wrap localStorage access in try-catch for blocked storage scenarios Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8fb358a984
commit
55705a0fbb
1 changed files with 52 additions and 36 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, startTransition } from 'react';
|
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
||||||
import en from '@/i18n/en.json';
|
import en from '@/i18n/en.json';
|
||||||
import es from '@/i18n/es.json';
|
import es from '@/i18n/es.json';
|
||||||
import ptBR from '@/i18n/pt-BR.json';
|
import ptBR from '@/i18n/pt-BR.json';
|
||||||
|
|
@ -13,12 +13,14 @@ interface I18nContextType {
|
||||||
t: (key: string, params?: Record<string, string | number>) => string;
|
t: (key: string, params?: Record<string, string | number>) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dictionaries: Record<Locale, typeof en> = {
|
const dictionaries: Record<Locale, Record<string, unknown>> = {
|
||||||
en,
|
en: en as Record<string, unknown>,
|
||||||
es,
|
es: es as Record<string, unknown>,
|
||||||
'pt-BR': ptBR,
|
'pt-BR': ptBR as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VALID_LOCALES: Locale[] = ['en', 'es', 'pt-BR'];
|
||||||
|
|
||||||
const I18nContext = createContext<I18nContextType | null>(null);
|
const I18nContext = createContext<I18nContextType | null>(null);
|
||||||
|
|
||||||
const localeStorageKey = 'locale';
|
const localeStorageKey = 'locale';
|
||||||
|
|
@ -30,63 +32,77 @@ const normalizeLocale = (language: string): Locale => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getInitialLocale = (): Locale => {
|
const getInitialLocale = (): Locale => {
|
||||||
if (typeof window === 'undefined') return 'en';
|
if (typeof window === 'undefined') return 'pt-BR';
|
||||||
const storedLocale = localStorage.getItem(localeStorageKey);
|
try {
|
||||||
if (storedLocale && Object.keys(dictionaries).includes(storedLocale)) {
|
const storedLocale = localStorage.getItem(localeStorageKey);
|
||||||
return storedLocale as Locale;
|
if (storedLocale && VALID_LOCALES.includes(storedLocale as Locale)) {
|
||||||
|
return storedLocale as Locale;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage might be blocked
|
||||||
}
|
}
|
||||||
if (typeof navigator !== 'undefined' && navigator.language) {
|
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||||
return normalizeLocale(navigator.language);
|
return normalizeLocale(navigator.language);
|
||||||
}
|
}
|
||||||
return 'en';
|
return 'pt-BR';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolveKey(dict: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const keys = key.split('.');
|
||||||
|
let value: unknown = dict;
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
if (value && typeof value === 'object' && value !== null && k in value) {
|
||||||
|
value = (value as Record<string, unknown>)[k];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||||
const [locale, setLocaleState] = useState<Locale>('en');
|
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
|
||||||
|
|
||||||
const setLocale = (newLocale: Locale) => {
|
const setLocale = (newLocale: Locale) => {
|
||||||
setLocaleState(newLocale);
|
setLocaleState(newLocale);
|
||||||
};
|
};
|
||||||
|
|
||||||
const t = useCallback((key: string, params?: Record<string, string | number>) => {
|
const t = useCallback((key: string, params?: Record<string, string | number>) => {
|
||||||
const keys = key.split('.');
|
// Try current locale first, then fallback to pt-BR, then en
|
||||||
let value: unknown = dictionaries[locale];
|
const dict = dictionaries[locale] || dictionaries['pt-BR'];
|
||||||
|
let result = resolveKey(dict, key);
|
||||||
|
|
||||||
for (const k of keys) {
|
// Fallback chain: try pt-BR if not found, then en
|
||||||
if (value && typeof value === 'object' && value !== null && k in value) {
|
if (result === undefined && locale !== 'pt-BR') {
|
||||||
value = (value as Record<string, unknown>)[k];
|
result = resolveKey(dictionaries['pt-BR'], key);
|
||||||
} else {
|
}
|
||||||
return key;
|
if (result === undefined && locale !== 'en') {
|
||||||
}
|
result = resolveKey(dictionaries['en'], key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value !== 'string') return key;
|
if (result === undefined) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
return Object.entries(params).reduce(
|
return Object.entries(params).reduce(
|
||||||
(str, [paramKey, paramValue]) => str.replace(`{${paramKey}}`, String(paramValue)),
|
(str, [paramKey, paramValue]) => str.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)),
|
||||||
value
|
result
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return result;
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
// Restore locale from localStorage after hydration completes.
|
|
||||||
// Using startTransition to mark this as non-urgent so React
|
|
||||||
// finishes hydration before re-rendering with the stored locale.
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = getInitialLocale();
|
|
||||||
if (stored !== 'en') {
|
|
||||||
startTransition(() => {
|
|
||||||
setLocaleState(stored);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
localStorage.setItem(localeStorageKey, locale);
|
try {
|
||||||
|
localStorage.setItem(localeStorageKey, locale);
|
||||||
|
} catch {
|
||||||
|
// localStorage might be blocked
|
||||||
|
}
|
||||||
document.documentElement.lang = locale;
|
document.documentElement.lang = locale;
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue