diff --git a/frontend/src/entries/subpage.tsx b/frontend/src/entries/subpage.tsx index baaa0e89a..1c7be6b9f 100644 --- a/frontend/src/entries/subpage.tsx +++ b/frontend/src/entries/subpage.tsx @@ -12,7 +12,7 @@ if (messageContainer) { message.config({ getContainer: () => messageContainer }); } -readyI18n().then(() => { +readyI18n('subscription').then(() => { const root = document.getElementById('app'); if (root) { createRoot(root).render( diff --git a/frontend/src/i18n/react.ts b/frontend/src/i18n/react.ts index e8934081d..01a0254ac 100644 --- a/frontend/src/i18n/react.ts +++ b/frontend/src/i18n/react.ts @@ -2,6 +2,7 @@ import i18next from 'i18next'; import { initReactI18next } from 'react-i18next'; import { LanguageManager } from '@/utils'; +import type { LanguageScope } from '@/utils'; import enUS from '../../../internal/web/translation/en-US.json'; const FALLBACK = 'en-US'; @@ -15,15 +16,15 @@ function moduleKeyFor(code: string): string { return `../../../internal/web/translation/${code}.json`; } -let active: string = LanguageManager.getLanguage(); -if ( - active !== FALLBACK && - !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active)) -) { - active = FALLBACK; -} +export async function readyI18n(scope: LanguageScope = 'panel') { + let active = LanguageManager.getLanguage(scope); + if ( + active !== FALLBACK && + !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active)) + ) { + active = FALLBACK; + } -export async function readyI18n() { await i18next.use(initReactI18next).init({ lng: active, fallbackLng: FALLBACK, diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx index d31c12aa3..d0c3c0a54 100644 --- a/frontend/src/pages/sub/SubPage.tsx +++ b/frontend/src/pages/sub/SubPage.tsx @@ -93,11 +93,11 @@ export default function SubPage() { setMessageInstance(messageApi); }, [messageApi]); const { isMobile } = useMediaQuery(576); - const [lang, setLang] = useState(() => LanguageManager.getLanguage()); + const [lang, setLang] = useState(() => LanguageManager.getLanguage('subscription')); const onLangChange = useCallback((next: string) => { setLang(next); - LanguageManager.setLanguage(next); + LanguageManager.setLanguage(next, 'subscription'); }, []); const cycleTheme = useCallback(() => { @@ -186,16 +186,18 @@ export default function SubPage() { items.push({ key: 'lastOnline', label: t('lastOnline'), - children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker) : '-', + children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker, lang) : '-', }); items.push({ key: 'expiry', label: t('subscription.expiry'), children: - expireMs === 0 ? t('subscription.noExpiry') : IntlUtil.formatDate(expireMs, datepicker), + expireMs === 0 + ? t('subscription.noExpiry') + : IntlUtil.formatDate(expireMs, datepicker, lang), }); return items; - }, [t]); + }, [t, lang]); const androidMenuItems = useMemo( () => [ diff --git a/frontend/src/test/language-scope.test.ts b/frontend/src/test/language-scope.test.ts new file mode 100644 index 000000000..50e5f7726 --- /dev/null +++ b/frontend/src/test/language-scope.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('subscription language scope', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('initializes lazily and changes the subscription language without changing the panel', async () => { + vi.resetModules(); + const utils = await import('@/utils'); + const cookies = new Map([['lang', 'en-US']]); + vi.spyOn(utils.CookieManager, 'getCookie').mockImplementation( + (name) => cookies.get(name) ?? '', + ); + vi.spyOn(utils.CookieManager, 'setCookie').mockImplementation((name, value) => { + cookies.set(name, value); + }); + const getLanguage = vi.spyOn(utils.LanguageManager, 'getLanguage'); + const reload = vi.fn(); + vi.stubGlobal('window', { navigator: { language: 'en-US' }, location: { reload } }); + + const { readyI18n } = await import('@/i18n/react'); + expect(getLanguage).not.toHaveBeenCalled(); + + await readyI18n('subscription'); + expect(cookies.get('subLang')).toBe('en-US'); + + utils.LanguageManager.setLanguage('fa-IR', 'subscription'); + expect(cookies.get('lang')).toBe('en-US'); + expect(cookies.get('subLang')).toBe('fa-IR'); + expect(reload).toHaveBeenCalledOnce(); + + const dateTimeFormat = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(function ( + locale?: Intl.LocalesArgument, + ) { + return { format: () => String(locale) } as Intl.DateTimeFormat; + } as typeof Intl.DateTimeFormat); + expect(utils.IntlUtil.formatDate(0, 'gregorian', 'fa-IR')).toBe('fa-IR'); + expect(dateTimeFormat).toHaveBeenLastCalledWith('fa-IR', expect.any(Object)); + }); + + it('does not resolve the language for empty or invalid dates', async () => { + const utils = await import('@/utils'); + const getLanguage = vi.spyOn(utils.LanguageManager, 'getLanguage').mockReturnValue('en-US'); + + expect(utils.IntlUtil.formatDate(null)).toBe(''); + expect(utils.IntlUtil.formatDate(undefined)).toBe(''); + expect(utils.IntlUtil.formatDate('not-a-date')).toBe(''); + expect(getLanguage).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index ea991ee83..fc12ffd03 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -879,6 +879,13 @@ export interface SupportedLanguage { icon: string; } +export type LanguageScope = 'panel' | 'subscription'; + +const languageCookieNames: Record = { + panel: 'lang', + subscription: 'subLang', +}; + export class LanguageManager { static readonly supportedLanguages: readonly SupportedLanguage[] = [ { name: 'العربية', value: 'ar-EG', icon: '🇪🇬' }, @@ -896,10 +903,19 @@ export class LanguageManager { { name: 'Português', value: 'pt-BR', icon: '🇧🇷' }, ]; - static getLanguage(): string { - let lang = CookieManager.getCookie('lang'); + static getLanguage(scope: LanguageScope = 'panel'): string { + const cookieName = languageCookieNames[scope]; + let lang = CookieManager.getCookie(cookieName); if (lang) return lang; + if (scope === 'subscription') { + const legacyLang = CookieManager.getCookie(languageCookieNames.panel); + if (LanguageManager.isSupportLanguage(legacyLang)) { + CookieManager.setCookie(cookieName, legacyLang, 365); + return legacyLang; + } + } + if (window.navigator) { const nav = window.navigator as Navigator & { userLanguage?: string }; lang = nav.language || nav.userLanguage || ''; @@ -924,24 +940,24 @@ export class LanguageManager { }); if (LanguageManager.isSupportLanguage(lang)) { - CookieManager.setCookie('lang', lang, 365); + CookieManager.setCookie(cookieName, lang, 365); } else { - CookieManager.setCookie('lang', 'en-US', 365); + CookieManager.setCookie(cookieName, 'en-US', 365); window.location.reload(); } } else { - CookieManager.setCookie('lang', 'en-US', 365); + CookieManager.setCookie(cookieName, 'en-US', 365); window.location.reload(); } return lang; } - static setLanguage(language: string): void { + static setLanguage(language: string, scope: LanguageScope = 'panel'): void { if (!LanguageManager.isSupportLanguage(language)) { language = 'en-US'; } - CookieManager.setCookie('lang', language, 365); + CookieManager.setCookie(languageCookieNames[scope], language, 365); window.location.reload(); } @@ -977,12 +993,13 @@ export class IntlUtil { static formatDate( date: string | number | Date | null | undefined, calendar: CalendarKind = 'gregorian', + language?: string, ): string { if (date == null) return ''; const d = new Date(date); if (!isFinite(d.getTime())) return ''; - const language = LanguageManager.getLanguage(); - const locale = calendar === 'jalalian' ? 'fa-IR' : language; + const resolvedLanguage = language ?? LanguageManager.getLanguage(); + const locale = calendar === 'jalalian' ? 'fa-IR' : resolvedLanguage; const intlOptions: Intl.DateTimeFormatOptions = { year: 'numeric',