Browse Source

fix(frontend): isolate subscription language preference (#6394)

* fix(frontend): isolate subscription language preference

* fix(frontend): defer date locale resolution
dawn 9 hours ago
parent
commit
ded2aa150c

+ 1 - 1
frontend/src/entries/subpage.tsx

@@ -12,7 +12,7 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-readyI18n().then(() => {
+readyI18n('subscription').then(() => {
   const root = document.getElementById('app');
   const root = document.getElementById('app');
   if (root) {
   if (root) {
     createRoot(root).render(
     createRoot(root).render(

+ 9 - 8
frontend/src/i18n/react.ts

@@ -2,6 +2,7 @@ import i18next from 'i18next';
 import { initReactI18next } from 'react-i18next';
 import { initReactI18next } from 'react-i18next';
 
 
 import { LanguageManager } from '@/utils';
 import { LanguageManager } from '@/utils';
+import type { LanguageScope } from '@/utils';
 import enUS from '../../../internal/web/translation/en-US.json';
 import enUS from '../../../internal/web/translation/en-US.json';
 
 
 const FALLBACK = 'en-US';
 const FALLBACK = 'en-US';
@@ -15,15 +16,15 @@ function moduleKeyFor(code: string): string {
   return `../../../internal/web/translation/${code}.json`;
   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({
   await i18next.use(initReactI18next).init({
     lng: active,
     lng: active,
     fallbackLng: FALLBACK,
     fallbackLng: FALLBACK,

+ 7 - 5
frontend/src/pages/sub/SubPage.tsx

@@ -93,11 +93,11 @@ export default function SubPage() {
     setMessageInstance(messageApi);
     setMessageInstance(messageApi);
   }, [messageApi]);
   }, [messageApi]);
   const { isMobile } = useMediaQuery(576);
   const { isMobile } = useMediaQuery(576);
-  const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
+  const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
 
 
   const onLangChange = useCallback((next: string) => {
   const onLangChange = useCallback((next: string) => {
     setLang(next);
     setLang(next);
-    LanguageManager.setLanguage(next);
+    LanguageManager.setLanguage(next, 'subscription');
   }, []);
   }, []);
 
 
   const cycleTheme = useCallback(() => {
   const cycleTheme = useCallback(() => {
@@ -186,16 +186,18 @@ export default function SubPage() {
     items.push({
     items.push({
       key: 'lastOnline',
       key: 'lastOnline',
       label: t('lastOnline'),
       label: t('lastOnline'),
-      children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker) : '-',
+      children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker, lang) : '-',
     });
     });
     items.push({
     items.push({
       key: 'expiry',
       key: 'expiry',
       label: t('subscription.expiry'),
       label: t('subscription.expiry'),
       children:
       children:
-        expireMs === 0 ? t('subscription.noExpiry') : IntlUtil.formatDate(expireMs, datepicker),
+        expireMs === 0
+          ? t('subscription.noExpiry')
+          : IntlUtil.formatDate(expireMs, datepicker, lang),
     });
     });
     return items;
     return items;
-  }, [t]);
+  }, [t, lang]);
 
 
   const androidMenuItems = useMemo(
   const androidMenuItems = useMemo(
     () => [
     () => [

+ 52 - 0
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<string, string>([['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();
+  });
+});

+ 26 - 9
frontend/src/utils/index.ts

@@ -879,6 +879,13 @@ export interface SupportedLanguage {
   icon: string;
   icon: string;
 }
 }
 
 
+export type LanguageScope = 'panel' | 'subscription';
+
+const languageCookieNames: Record<LanguageScope, string> = {
+  panel: 'lang',
+  subscription: 'subLang',
+};
+
 export class LanguageManager {
 export class LanguageManager {
   static readonly supportedLanguages: readonly SupportedLanguage[] = [
   static readonly supportedLanguages: readonly SupportedLanguage[] = [
     { name: 'العربية', value: 'ar-EG', icon: '🇪🇬' },
     { name: 'العربية', value: 'ar-EG', icon: '🇪🇬' },
@@ -896,10 +903,19 @@ export class LanguageManager {
     { name: 'Português', value: 'pt-BR', icon: '🇧🇷' },
     { 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 (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) {
     if (window.navigator) {
       const nav = window.navigator as Navigator & { userLanguage?: string };
       const nav = window.navigator as Navigator & { userLanguage?: string };
       lang = nav.language || nav.userLanguage || '';
       lang = nav.language || nav.userLanguage || '';
@@ -924,24 +940,24 @@ export class LanguageManager {
       });
       });
 
 
       if (LanguageManager.isSupportLanguage(lang)) {
       if (LanguageManager.isSupportLanguage(lang)) {
-        CookieManager.setCookie('lang', lang, 365);
+        CookieManager.setCookie(cookieName, lang, 365);
       } else {
       } else {
-        CookieManager.setCookie('lang', 'en-US', 365);
+        CookieManager.setCookie(cookieName, 'en-US', 365);
         window.location.reload();
         window.location.reload();
       }
       }
     } else {
     } else {
-      CookieManager.setCookie('lang', 'en-US', 365);
+      CookieManager.setCookie(cookieName, 'en-US', 365);
       window.location.reload();
       window.location.reload();
     }
     }
 
 
     return lang;
     return lang;
   }
   }
 
 
-  static setLanguage(language: string): void {
+  static setLanguage(language: string, scope: LanguageScope = 'panel'): void {
     if (!LanguageManager.isSupportLanguage(language)) {
     if (!LanguageManager.isSupportLanguage(language)) {
       language = 'en-US';
       language = 'en-US';
     }
     }
-    CookieManager.setCookie('lang', language, 365);
+    CookieManager.setCookie(languageCookieNames[scope], language, 365);
     window.location.reload();
     window.location.reload();
   }
   }
 
 
@@ -977,12 +993,13 @@ export class IntlUtil {
   static formatDate(
   static formatDate(
     date: string | number | Date | null | undefined,
     date: string | number | Date | null | undefined,
     calendar: CalendarKind = 'gregorian',
     calendar: CalendarKind = 'gregorian',
+    language?: string,
   ): string {
   ): string {
     if (date == null) return '';
     if (date == null) return '';
     const d = new Date(date);
     const d = new Date(date);
     if (!isFinite(d.getTime())) return '';
     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 = {
     const intlOptions: Intl.DateTimeFormatOptions = {
       year: 'numeric',
       year: 'numeric',