feat(i18n): add Japanese dashboard locale

This commit is contained in:
Tam Nhu Tran
2026-03-10 16:13:29 +07:00
parent 447033cb03
commit 8b5158bec5
3 changed files with 1248 additions and 34 deletions
+1200
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
export const LOCALE_STORAGE_KEY = 'ccs-ui-locale';
export const SUPPORTED_LOCALES = ['en', 'zh-CN', 'vi'] as const;
export const SUPPORTED_LOCALES = ['en', 'zh-CN', 'vi', 'ja'] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
@@ -10,6 +10,7 @@ export function isSupportedLocale(locale: string): locale is AppLocale {
export function normalizeLocale(locale: string | null | undefined): AppLocale {
if (!locale) return 'en';
if (locale.toLowerCase().startsWith('ja')) return 'ja';
if (locale.toLowerCase().startsWith('zh')) return 'zh-CN';
if (locale.toLowerCase().startsWith('vi')) return 'vi';
return 'en';
@@ -8,6 +8,7 @@ import {
LOCALE_STORAGE_KEY,
normalizeLocale,
persistLocale,
SUPPORTED_LOCALES,
} from '@/lib/locales';
function flattenKeys(node: unknown, prefix = '', out = new Set<string>()): Set<string> {
@@ -91,22 +92,28 @@ describe('Dashboard i18n', () => {
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'zh-CN');
}, 10000);
it('supports Vietnamese locale in switcher and persistence', async () => {
render(<LanguageSwitcher />);
it.each([
{ locale: 'vi', label: 'Vietnamese', browserLocale: 'vi-VN' },
{ locale: 'ja', label: 'Japanese', browserLocale: 'ja-JP' },
])(
'supports $locale locale in switcher and persistence',
async ({ locale, label, browserLocale }) => {
render(<LanguageSwitcher />);
await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(await screen.findByText('Vietnamese'));
await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(await screen.findByText(label));
await waitFor(() => {
expect(i18n.language).toBe('vi');
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'vi');
expect(normalizeLocale('vi-VN')).toBe('vi');
});
await waitFor(() => {
expect(i18n.language).toBe(locale);
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, locale);
expect(normalizeLocale(browserLocale)).toBe(locale);
}
);
it('restores locale from persisted storage', () => {
persistLocale('vi');
expect(getInitialLocale()).toBe('vi');
persistLocale('ja');
expect(getInitialLocale()).toBe('ja');
});
it('shows Chinese labels on translated settings tabs', async () => {
@@ -119,32 +126,38 @@ describe('Dashboard i18n', () => {
expect(screen.getByText('认证')).toBeInTheDocument();
});
it('keeps vi translation keys in parity with en and preserves placeholders', () => {
const resources = i18n.options.resources as
| Record<string, { translation: Record<string, unknown> }>
| undefined;
it.each(SUPPORTED_LOCALES.filter((locale) => locale !== 'en'))(
'keeps %s translation keys in parity with en and preserves placeholders',
(locale) => {
const resources = i18n.options.resources as
| Record<string, { translation: Record<string, unknown> }>
| undefined;
const enTranslation = resources?.en?.translation;
const viTranslation = resources?.vi?.translation;
const enTranslation = resources?.en?.translation;
const localeTranslation = resources?.[locale]?.translation;
expect(enTranslation).toBeDefined();
expect(viTranslation).toBeDefined();
expect(enTranslation).toBeDefined();
expect(localeTranslation).toBeDefined();
const enKeys = flattenKeys(enTranslation);
const viKeys = flattenKeys(viTranslation);
const enKeys = flattenKeys(enTranslation);
const localeKeys = flattenKeys(localeTranslation);
expect([...viKeys].sort()).toEqual([...enKeys].sort());
expect([...localeKeys].sort()).toEqual([...enKeys].sort());
for (const key of enKeys) {
const enValue = key
.split('.')
.reduce<unknown>((acc, part) => (acc as Record<string, unknown>)?.[part], enTranslation);
const viValue = key
.split('.')
.reduce<unknown>((acc, part) => (acc as Record<string, unknown>)?.[part], viTranslation);
for (const key of enKeys) {
const enValue = key
.split('.')
.reduce<unknown>((acc, part) => (acc as Record<string, unknown>)?.[part], enTranslation);
const localeValue = key
.split('.')
.reduce<unknown>(
(acc, part) => (acc as Record<string, unknown>)?.[part],
localeTranslation
);
if (typeof enValue !== 'string' || typeof viValue !== 'string') continue;
expect(collectPlaceholders(viValue)).toEqual(collectPlaceholders(enValue));
if (typeof enValue !== 'string' || typeof localeValue !== 'string') continue;
expect(collectPlaceholders(localeValue)).toEqual(collectPlaceholders(enValue));
}
}
});
);
});