diff --git a/packages/commonwealth/client/scripts/state/index.ts b/packages/commonwealth/client/scripts/state/index.ts index 57fc19b20cf..c9f6875af9a 100644 --- a/packages/commonwealth/client/scripts/state/index.ts +++ b/packages/commonwealth/client/scripts/state/index.ts @@ -12,6 +12,7 @@ import { Configuration, fetchCustomDomainQuery } from 'state/api/configuration'; import { fetchNodesQuery } from 'state/api/nodes'; import { errorStore } from 'state/ui/error'; import { EXCEPTION_CASE_VANILLA_getCommunityById } from './api/communities/getCommuityById'; +import { languageStore } from './ui/language/language'; import { userStore } from './ui/user'; export enum ApiStatus { @@ -66,6 +67,9 @@ export async function initAppState( updateSelectedCommunity = true, ): Promise { try { + // Initialize language settings first since it doesn't depend on API + languageStore.getState().initializeLanguage(); + const [{ data: statusRes }] = await Promise.all([ axios.get(`${SERVER_URL}/status`), ]); diff --git a/packages/commonwealth/client/scripts/state/ui/language/__tests__/language.spec.ts b/packages/commonwealth/client/scripts/state/ui/language/__tests__/language.spec.ts new file mode 100644 index 00000000000..c2895f2b9c5 --- /dev/null +++ b/packages/commonwealth/client/scripts/state/ui/language/__tests__/language.spec.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { languageStore } from '../language'; + +describe('Language Store', () => { + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + // Reset language store to default state + languageStore.setState({ currentLanguage: 'en' }); + }); + + describe('getBrowserLanguage', () => { + it('should return browser language if supported', () => { + // Mock navigator.language + const originalNavigator = window.navigator; + Object.defineProperty(window, 'navigator', { + value: { ...originalNavigator, language: 'de' }, + configurable: true, + }); + + const browserLang = languageStore.getState().getBrowserLanguage(); + expect(browserLang).toBe('de'); + + // Restore original navigator + Object.defineProperty(window, 'navigator', { + value: originalNavigator, + configurable: true, + }); + }); + + it('should return "en" if browser language is not supported', () => { + // Mock unsupported language + const originalNavigator = window.navigator; + Object.defineProperty(window, 'navigator', { + value: { ...originalNavigator, language: 'fr' }, + configurable: true, + }); + + const browserLang = languageStore.getState().getBrowserLanguage(); + expect(browserLang).toBe('en'); + + // Restore original navigator + Object.defineProperty(window, 'navigator', { + value: originalNavigator, + configurable: true, + }); + }); + }); + + describe('initializeLanguage', () => { + it('should set language from browser if no stored preference', () => { + // Mock German browser language + const originalNavigator = window.navigator; + Object.defineProperty(window, 'navigator', { + value: { ...originalNavigator, language: 'de' }, + configurable: true, + }); + + languageStore.getState().initializeLanguage(); + expect(languageStore.getState().currentLanguage).toBe('de'); + + // Restore original navigator + Object.defineProperty(window, 'navigator', { + value: originalNavigator, + configurable: true, + }); + }); + + it('should keep stored language preference if available', () => { + // Set stored preference to Turkish + languageStore.setState({ currentLanguage: 'tr' }); + + // Mock different browser language + const originalNavigator = window.navigator; + Object.defineProperty(window, 'navigator', { + value: { ...originalNavigator, language: 'de' }, + configurable: true, + }); + + languageStore.getState().initializeLanguage(); + expect(languageStore.getState().currentLanguage).toBe('tr'); + + // Restore original navigator + Object.defineProperty(window, 'navigator', { + value: originalNavigator, + configurable: true, + }); + }); + }); +}); diff --git a/packages/commonwealth/client/scripts/state/ui/language/constants.ts b/packages/commonwealth/client/scripts/state/ui/language/constants.ts new file mode 100644 index 00000000000..9958fe0f06d --- /dev/null +++ b/packages/commonwealth/client/scripts/state/ui/language/constants.ts @@ -0,0 +1,9 @@ +export const SUPPORTED_LANGUAGES = { + en: { name: 'English', flag: '🇺🇸' }, + ru: { name: 'Russian', flag: '🇷🇺' }, + uk: { name: 'Ukrainian', flag: '🇺🇦' }, + zh: { name: 'Chinese (Traditional)', flag: '🇨🇳' }, + hi: { name: 'Hindi', flag: '🇮🇳' }, + de: { name: 'German', flag: '🇩🇪' }, + tr: { name: 'Turkish', flag: '🇹🇷' }, +} as const; diff --git a/packages/commonwealth/client/scripts/state/ui/language/language.ts b/packages/commonwealth/client/scripts/state/ui/language/language.ts new file mode 100644 index 00000000000..df773e94ed2 --- /dev/null +++ b/packages/commonwealth/client/scripts/state/ui/language/language.ts @@ -0,0 +1,44 @@ +import { devtools, persist } from 'zustand/middleware'; +import { createStore } from 'zustand/vanilla'; +import { createBoundedUseStore } from '../utils'; +import { SUPPORTED_LANGUAGES } from './constants'; + +interface LanguageStore { + currentLanguage: keyof typeof SUPPORTED_LANGUAGES; + setLanguage: (lang: keyof typeof SUPPORTED_LANGUAGES) => void; + getBrowserLanguage: () => keyof typeof SUPPORTED_LANGUAGES; + initializeLanguage: () => void; +} + +export const languageStore = createStore()( + devtools( + persist( + (set) => ({ + currentLanguage: 'en', + setLanguage: (lang) => set({ currentLanguage: lang }), + getBrowserLanguage: () => { + // Get browser language and remove region code if present (e.g., 'en-US' -> 'en') + const browserLang = navigator.language.split('-')[0].toLowerCase(); + + // Check if browser language is supported, return 'en' if not + return browserLang in SUPPORTED_LANGUAGES + ? (browserLang as keyof typeof SUPPORTED_LANGUAGES) + : 'en'; + }, + initializeLanguage: () => { + const state = languageStore.getState(); + // Only set language from browser if no stored preference exists + if (!localStorage.getItem('language-store')) { + state.setLanguage(state.getBrowserLanguage()); + } + }, + }), + { + name: 'language-store', + }, + ), + ), +); + +const useLanguageStore = createBoundedUseStore(languageStore); +export default useLanguageStore; diff --git a/packages/commonwealth/test/unit/state/ui/language/language.spec.ts b/packages/commonwealth/test/unit/state/ui/language/language.spec.ts new file mode 100644 index 00000000000..f16f3ecb0a1 --- /dev/null +++ b/packages/commonwealth/test/unit/state/ui/language/language.spec.ts @@ -0,0 +1,97 @@ +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { languageStore } from '../../../../../client/scripts/state/ui/language/language'; + +// Mock localStorage +const localStorageMock = (() => { + let store: { [key: string]: string } = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString(); + }, + clear: () => { + store = {}; + }, + }; +})(); + +// Mock window.navigator +const navigatorMock = { + language: 'en', +}; + +describe('Language Store', () => { + beforeAll(() => { + // Setup global mocks + Object.defineProperty(global, 'localStorage', { + value: localStorageMock, + writable: true, + }); + Object.defineProperty(global, 'navigator', { + value: navigatorMock, + writable: true, + }); + }); + + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + // Reset language store to default state + languageStore.setState({ currentLanguage: 'en' }); + }); + + describe('getBrowserLanguage', () => { + it('should return browser language if supported', () => { + // Set navigator language to German + (global.navigator as any).language = 'de'; + + const browserLang = languageStore.getState().getBrowserLanguage(); + expect(browserLang).toBe('de'); + + // Reset navigator language + (global.navigator as any).language = 'en'; + }); + + it('should return "en" if browser language is not supported', () => { + // Set navigator language to unsupported language + (global.navigator as any).language = 'fr'; + + const browserLang = languageStore.getState().getBrowserLanguage(); + expect(browserLang).toBe('en'); + + // Reset navigator language + (global.navigator as any).language = 'en'; + }); + }); + + describe('initializeLanguage', () => { + it('should set language from browser if no stored preference', () => { + // Set navigator language to German + (global.navigator as any).language = 'de'; + + languageStore.getState().initializeLanguage(); + expect(languageStore.getState().currentLanguage).toBe('de'); + + // Reset navigator language + (global.navigator as any).language = 'en'; + }); + + it('should keep stored language preference if available', () => { + // Set stored preference to Turkish + languageStore.setState({ currentLanguage: 'tr' }); + localStorage.setItem( + 'language-store', + JSON.stringify({ currentLanguage: 'tr' }), + ); + + // Set navigator language to German + (global.navigator as any).language = 'de'; + + languageStore.getState().initializeLanguage(); + expect(languageStore.getState().currentLanguage).toBe('tr'); + + // Reset navigator language + (global.navigator as any).language = 'en'; + }); + }); +});