mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
chore: move i18n to shared package (#1066)
* chore: move i18n to shared package * chore: move server translations to shared package and apply linter and prettier on entire shared package
This commit is contained in:
+22
-20
@@ -2,31 +2,33 @@ import axios, { AxiosInstance } from 'axios'
|
||||
import type { WeatherResult } from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
import en from '../i18n/translations/en'
|
||||
import br from '../i18n/translations/br'
|
||||
import de from '../i18n/translations/de'
|
||||
import es from '../i18n/translations/es'
|
||||
import fr from '../i18n/translations/fr'
|
||||
import it from '../i18n/translations/it'
|
||||
import nl from '../i18n/translations/nl'
|
||||
import pl from '../i18n/translations/pl'
|
||||
import cs from '../i18n/translations/cs'
|
||||
import hu from '../i18n/translations/hu'
|
||||
import ru from '../i18n/translations/ru'
|
||||
import zh from '../i18n/translations/zh'
|
||||
import zhTw from '../i18n/translations/zhTw'
|
||||
import ar from '../i18n/translations/ar'
|
||||
|
||||
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
|
||||
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
|
||||
const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
en: 'Too many attempts. Please try again later.',
|
||||
de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
||||
es: 'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
||||
fr: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||
hu: 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
|
||||
nl: 'Te veel pogingen. Probeer het later opnieuw.',
|
||||
br: 'Muitas tentativas. Tente novamente mais tarde.',
|
||||
cs: 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
|
||||
pl: 'Zbyt wiele prób. Spróbuj ponownie później.',
|
||||
ru: 'Слишком много попыток. Попробуйте позже.',
|
||||
zh: '尝试次数过多,请稍后再试。',
|
||||
'zh-TW': '嘗試次數過多,請稍後再試。',
|
||||
it: 'Troppi tentativi. Riprova più tardi.',
|
||||
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
|
||||
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||
}
|
||||
|
||||
function translateRateLimit(): string {
|
||||
const fallback = 'Too many attempts. Please try again later.'
|
||||
const fallback = RATE_LIMIT_MESSAGES['en']!
|
||||
try {
|
||||
const lang = localStorage.getItem('app_language') || 'en'
|
||||
const table = rateLimitTranslations[lang] || rateLimitTranslations.en
|
||||
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
|
||||
return RATE_LIMIT_MESSAGES[lang] ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -102,19 +102,19 @@ describe('BottomNav', () => {
|
||||
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', () => {
|
||||
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Mes voyages')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Mes voyages')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', () => {
|
||||
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Profil')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Profil')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', () => {
|
||||
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
seedStore(useAddonStore, {
|
||||
addons: [
|
||||
@@ -124,9 +124,9 @@ describe('BottomNav', () => {
|
||||
],
|
||||
});
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Vacances')).toBeInTheDocument();
|
||||
expect(screen.getByText('Atlas')).toBeInTheDocument();
|
||||
expect(screen.getByText('Journal de voyage')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Vacances')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Atlas')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Journal de voyage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
|
||||
|
||||
@@ -1,56 +1,47 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, ReactNode } from 'react'
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import de from './translations/de'
|
||||
import en from './translations/en'
|
||||
import es from './translations/es'
|
||||
import fr from './translations/fr'
|
||||
import hu from './translations/hu'
|
||||
import it from './translations/it'
|
||||
import tr from './translations/tr'
|
||||
import ru from './translations/ru'
|
||||
import zh from './translations/zh'
|
||||
import zhTw from './translations/zhTw'
|
||||
import nl from './translations/nl'
|
||||
import id from './translations/id'
|
||||
import ar from './translations/ar'
|
||||
import br from './translations/br'
|
||||
import cs from './translations/cs'
|
||||
import pl from './translations/pl'
|
||||
import ja from './translations/ja'
|
||||
import ko from './translations/ko'
|
||||
import uk from './translations/uk'
|
||||
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
||||
import en from '@trek/shared/i18n/en'
|
||||
import type { SupportedLanguageCode } from '@trek/shared'
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
getLocaleForLanguage,
|
||||
getIntlLanguage,
|
||||
isRtlLanguage,
|
||||
} from '@trek/shared'
|
||||
import type { TranslationStrings } from '@trek/shared/i18n'
|
||||
|
||||
export { SUPPORTED_LANGUAGES }
|
||||
|
||||
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
|
||||
|
||||
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
|
||||
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
|
||||
de, en, es, fr, hu, it, tr, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja, ko, uk,
|
||||
// One explicit dynamic import per locale — Vite code-splits a separate chunk per locale.
|
||||
// Only the active locale is fetched; en is always available synchronously as the fallback.
|
||||
const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: TranslationStrings }>> = {
|
||||
en: () => Promise.resolve({ default: en }),
|
||||
de: () => import('@trek/shared/i18n/de'),
|
||||
es: () => import('@trek/shared/i18n/es'),
|
||||
fr: () => import('@trek/shared/i18n/fr'),
|
||||
hu: () => import('@trek/shared/i18n/hu'),
|
||||
it: () => import('@trek/shared/i18n/it'),
|
||||
tr: () => import('@trek/shared/i18n/tr'),
|
||||
ru: () => import('@trek/shared/i18n/ru'),
|
||||
zh: () => import('@trek/shared/i18n/zh'),
|
||||
'zh-TW': () => import('@trek/shared/i18n/zh-TW'),
|
||||
nl: () => import('@trek/shared/i18n/nl'),
|
||||
id: () => import('@trek/shared/i18n/id'),
|
||||
ar: () => import('@trek/shared/i18n/ar'),
|
||||
br: () => import('@trek/shared/i18n/br'),
|
||||
cs: () => import('@trek/shared/i18n/cs'),
|
||||
pl: () => import('@trek/shared/i18n/pl'),
|
||||
ja: () => import('@trek/shared/i18n/ja'),
|
||||
ko: () => import('@trek/shared/i18n/ko'),
|
||||
uk: () => import('@trek/shared/i18n/uk'),
|
||||
}
|
||||
|
||||
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
||||
const LOCALES: Record<string, string> = Object.fromEntries(
|
||||
SUPPORTED_LANGUAGES.map(l => [l.value, l.locale])
|
||||
)
|
||||
const RTL_LANGUAGES = new Set(['ar'])
|
||||
// Re-export pure helpers that live in shared so downstream consumers can import them
|
||||
// through this module without changing their import path.
|
||||
export { getLocaleForLanguage, getIntlLanguage, isRtlLanguage }
|
||||
|
||||
export function getLocaleForLanguage(language: string): string {
|
||||
return LOCALES[language] || LOCALES.en
|
||||
}
|
||||
|
||||
export function getIntlLanguage(language: string): string {
|
||||
if (language === 'br') return 'pt-BR'
|
||||
return ['de', 'es', 'fr', 'hu', 'it', 'tr', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja', 'ko', 'uk'].includes(language) ? language : 'en'
|
||||
}
|
||||
|
||||
export function isRtlLanguage(language: string): boolean {
|
||||
return RTL_LANGUAGES.has(language)
|
||||
}
|
||||
|
||||
// Detects the user's preferred language from the browser/OS settings and maps
|
||||
// it to one of the supported language codes. Returns null if no match is found.
|
||||
// Detects the user's preferred language from browser/OS settings.
|
||||
// Returns null if no supported language matches.
|
||||
export function detectBrowserLanguage(): string | null {
|
||||
if (typeof navigator === 'undefined') return null
|
||||
const browserLangs = navigator.languages?.length
|
||||
@@ -59,17 +50,14 @@ export function detectBrowserLanguage(): string | null {
|
||||
const supported = SUPPORTED_LANGUAGES.map(l => l.value)
|
||||
|
||||
for (const lang of browserLangs) {
|
||||
// Exact match (e.g. 'de', 'zh-TW') — case-insensitive
|
||||
const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase())
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
// pt-BR has no exact match (our code is 'br', not 'pt-BR'), so map it explicitly.
|
||||
// pt-PT and bare 'pt' are NOT mapped — they fall through to null and let the
|
||||
// server default or 'en' fallback apply instead.
|
||||
// pt-BR has no exact match (our code is 'br'), so map it explicitly.
|
||||
// pt-PT and bare 'pt' are NOT mapped — they fall through to null.
|
||||
if (lang.toLowerCase() === 'pt-br') return 'br'
|
||||
|
||||
// Prefix match (e.g. 'de-AT' → 'de', 'zh-CN' → 'zh') — case-insensitive
|
||||
const prefix = lang.split('-')[0].toLowerCase()
|
||||
const prefix = lang.split('-')[0]?.toLowerCase()
|
||||
const prefixMatch = supported.find(s => s.toLowerCase() === prefix)
|
||||
if (prefixMatch) return prefixMatch
|
||||
}
|
||||
@@ -91,18 +79,27 @@ interface TranslationProviderProps {
|
||||
|
||||
export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
const language = useSettingsStore((s) => s.settings.language) || 'en'
|
||||
const [strings, setStrings] = useState<TranslationStrings>(en)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
|
||||
}, [language])
|
||||
|
||||
const value = useMemo((): TranslationContextValue => {
|
||||
const strings = translations[language] || translations.en
|
||||
const fallback = translations.en
|
||||
useEffect(() => {
|
||||
const loader = localeLoaders[language as SupportedLanguageCode]
|
||||
if (!loader) return
|
||||
|
||||
let cancelled = false
|
||||
loader().then(mod => {
|
||||
if (!cancelled) setStrings(mod.default)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [language])
|
||||
|
||||
const value = useMemo((): TranslationContextValue => {
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
let val: string = (strings[key] ?? fallback[key] ?? key) as string
|
||||
let val: string = (strings[key] ?? en[key] ?? key) as string
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
||||
@@ -112,7 +109,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
}
|
||||
|
||||
return { t, language, locale: getLocaleForLanguage(language) }
|
||||
}, [language])
|
||||
}, [strings, language])
|
||||
|
||||
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
|
||||
}
|
||||
|
||||
@@ -1,25 +1,4 @@
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{ value: 'de', label: 'Deutsch', locale: 'de-DE' },
|
||||
{ value: 'en', label: 'English', locale: 'en-US' },
|
||||
{ value: 'es', label: 'Español', locale: 'es-ES' },
|
||||
{ value: 'fr', label: 'Français', locale: 'fr-FR' },
|
||||
{ value: 'hu', label: 'Magyar', locale: 'hu-HU' },
|
||||
{ value: 'nl', label: 'Nederlands', locale: 'nl-NL' },
|
||||
{ value: 'br', label: 'Português (Brasil)', locale: 'pt-BR' },
|
||||
{ value: 'cs', label: 'Česky', locale: 'cs-CZ' },
|
||||
{ value: 'pl', label: 'Polski', locale: 'pl-PL' },
|
||||
{ value: 'ru', label: 'Русский', locale: 'ru-RU' },
|
||||
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
|
||||
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
|
||||
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
|
||||
{ value: 'tr', label: 'Türkçe', locale: 'tr-TR' },
|
||||
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
||||
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
|
||||
{ value: 'ja', label: '日本語', locale: 'ja-JP' },
|
||||
{ value: 'ko', label: '한국어', locale: 'ko-KR' },
|
||||
{ value: 'uk', label: 'Українська', locale: 'uk-UA' },
|
||||
] as const
|
||||
|
||||
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
||||
|
||||
export const SUPPORTED_LANGUAGE_CODES: string[] = SUPPORTED_LANGUAGES.map(l => l.value)
|
||||
// Canonical language registry now lives in @trek/shared. Re-exported here so
|
||||
// existing imports of './supportedLanguages' continue to work unchanged.
|
||||
export { SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES } from '@trek/shared'
|
||||
export type { SupportedLanguageCode } from '@trek/shared'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user