diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f1a6431..530c1c559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to ## Added - 🚩 add homepage feature flag #861 - +- ✨(frontend) add customization for translations #857 ## [3.1.0] - 2025-04-07 diff --git a/docs/customization.md b/docs/customization.md new file mode 100644 index 000000000..db9ae5d5a --- /dev/null +++ b/docs/customization.md @@ -0,0 +1,79 @@ +# Application Customization 🛠️ +This document outlines the various ways you can customize our application to suit your specific needs without modifying the core codebase. +#### Available Customization Options +> 1. [Runtime Theming 🎨](#runtime-theming-🎨) +> 1. [Runtime Internationalization 🌐](#runtime-internationalization-🌐) + +
+ +# Runtime Theming 🎨 + +### How to Use + +To customize the application's appearance, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file: + +```javascript +FRONTEND_CSS_URL=http://example.com/custom-style.css +``` + +Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application. + +### Benefits + +- **Easy customization** 🔄: Customize the look and feel of our application without requiring any code changes. +- **Flexibility** 🌈: Use any CSS styles to create a custom theme that meets your needs. +- **Runtime theming** ⏱️: Change the theme of our application at runtime, without requiring a restart or recompilation. + +### Example Use Case + +Let's say you want to change the background color of our application to a custom color. Create a custom CSS file with the following contents: + +```css +body { + background-color: #3498db; +} +``` + +Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified. + +
+ +# Runtime Internationalization 🌐 + +### How to Use + +To provide custom translations, set the `FRONTEND_CUSTOM_TRANSLATIONS_URL` environment variable to the URL of your custom translations JSON file: + +```javascript +FRONTEND_CUSTOM_TRANSLATIONS_URL=http://example.com/custom-translations.json +``` + +Once you've set this variable, our application will load your custom translations and apply them to the user interface. + +### Benefits + +- **Language control** 🌐: Customize terminology to match your organization's vocabulary. +- **Context-specific language** 📝: Adapt text for your specific use case or industry. + +### Example Use Case + +Let's say you want to customize some key phrases in the application. Create a JSON file with your custom translations: + +```json +{ + "en": { + "translation": { + "Docs": "MyApp", + "Create New Document": "+" + } + }, + "de": { + "translation": { + "Docs": "MeineApp", + "Create New Document": "+" + } + } +} +``` + +Then set the `FRONTEND_CUSTOM_TRANSLATIONS_URL` environment variable to the URL of this JSON file. The application will load these translations and override the default ones where specified. diff --git a/docs/env.md b/docs/env.md index 22bc601f6..3b4c7dec5 100644 --- a/docs/env.md +++ b/docs/env.md @@ -53,6 +53,8 @@ These are the environmental variables you can set for the impress-backend contai | FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT | Cache duration of the json footer | 86400 | | FRONTEND_URL_JSON_FOOTER | Url with a json to configure the footer | | | FRONTEND_THEME | frontend theme to use | | +| FRONTEND_CSS_URL | URL to a custom CSS file for theming the application | | +| FRONTEND_CUSTOM_TRANSLATIONS_URL | URL to a JSON file containing custom translations for the application | | | POSTHOG_KEY | posthog key for analytics | | | CRISP_WEBSITE_ID | crisp website id for support | | | DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 | diff --git a/docs/theming.md b/docs/theming.md deleted file mode 100644 index 6f8c65a96..000000000 --- a/docs/theming.md +++ /dev/null @@ -1,33 +0,0 @@ -# Runtime Theming 🎨 - -### How to Use - -To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example: - -```javascript -FRONTEND_CSS_URL=http://anything/custom-style.css -``` - -Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application. - -### Benefits - -This feature provides several benefits, including: - -* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes. -* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs. -* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation. - -### Example Use Case - -Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents: - -```css -body { - background-color: #3498db; -} -``` - -Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified. - - diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 685cfb836..9c1f34c26 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1693,6 +1693,7 @@ def get(self, request): "ENVIRONMENT", "FRONTEND_CSS_URL", "FRONTEND_HOMEPAGE_FEATURE_ENABLED", + "FRONTEND_CUSTOM_TRANSLATIONS_URL", "FRONTEND_FOOTER_FEATURE_ENABLED", "FRONTEND_THEME", "MEDIA_BASE_URL", diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 8ce579e24..b06945db6 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -20,6 +20,7 @@ CRISP_WEBSITE_ID="123", FRONTEND_CSS_URL="http://testcss/", FRONTEND_HOMEPAGE_FEATURE_ENABLED=True, + FRONTEND_CUSTOM_TRANSLATIONS_URL="http://test-custom-translations/", FRONTEND_FOOTER_FEATURE_ENABLED=True, FRONTEND_THEME="test-theme", MEDIA_BASE_URL="http://testserver/", @@ -43,6 +44,7 @@ def test_api_config(is_authenticated): "ENVIRONMENT": "test", "FRONTEND_CSS_URL": "http://testcss/", "FRONTEND_HOMEPAGE_FEATURE_ENABLED": True, + "FRONTEND_CUSTOM_TRANSLATIONS_URL": "http://test-custom-translations/", "FRONTEND_FOOTER_FEATURE_ENABLED": True, "FRONTEND_THEME": "test-theme", "LANGUAGES": [ diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 55e7909b8..ed9b0dd0b 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -431,6 +431,9 @@ class Base(Configuration): FRONTEND_CSS_URL = values.Value( None, environ_name="FRONTEND_CSS_URL", environ_prefix=None ) + FRONTEND_CUSTOM_TRANSLATIONS_URL = values.Value( + None, environ_name="FRONTEND_CUSTOM_TRANSLATIONS_URL", environ_prefix=None + ) # Posthog POSTHOG_KEY = values.DictValue( diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 11ae09024..52be54c30 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -7,6 +7,7 @@ export const CONFIG = { ENVIRONMENT: 'development', FRONTEND_CSS_URL: null, FRONTEND_HOMEPAGE_FEATURE_ENABLED: true, + FRONTEND_CUSTOM_TRANSLATIONS_URL: null, FRONTEND_FOOTER_FEATURE_ENABLED: true, FRONTEND_THEME: 'default', MEDIA_BASE_URL: 'http://localhost:8083', diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index 90557aad2..44227f347 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -170,6 +170,67 @@ test.describe('Config', () => { .first(), ).toBeAttached(); }); + + test('it checks FRONTEND_CUSTOM_TRANSLATIONS_URL config', async ({ + page, + }) => { + // Create mock URL for translations + const mockTranslationsUrl = + 'http://dummyhost.example.com/translations/custom.json'; + + // Mock the config endpoint to include the custom translations URL + await page.route('**/api/v1.0/config/', async (route) => { + const request = route.request(); + if (request.method().includes('GET')) { + await route.fulfill({ + json: { + ...config, + FRONTEND_CUSTOM_TRANSLATIONS_URL: mockTranslationsUrl, + }, + }); + } else { + await route.continue(); + } + }); + + // Mock the translations endpoint to return our custom translations + await page.route(mockTranslationsUrl, async (route) => { + await route.fulfill({ + json: { + en: { + translation: { + Docs: 'CustomDocsEn', + }, + }, + fr: { + translation: { + Docs: 'CustomDocsFR', + }, + }, + }, + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + }); + + // Intercept requests to the translations URL + const translationsPromise = page.waitForRequest((req) => { + return req.url() === mockTranslationsUrl; + }); + + // Navigate to the page + await page.goto('/'); + + // Verify that the application attempted to load the translations + const translationsRequest = await translationsPromise; + expect(translationsRequest).toBeTruthy(); + + // Extra test to prove that the translations were applied + await expect(page.getByText('CustomDocsEn')).toBeAttached(); + }); }); test.describe('Config: Not loggued', () => { diff --git a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx index 371e7c356..65dc7894b 100644 --- a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx +++ b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx @@ -4,7 +4,11 @@ import { PropsWithChildren, useEffect } from 'react'; import { Box } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { useLanguageSynchronizer } from '@/features/language/'; +import { useAuthQuery } from '@/features/auth'; +import { + useLanguageSynchronizer, + useTranslationsCustomizer, +} from '@/features/language/'; import { useAnalytics } from '@/libs'; import { CrispProvider, PostHogAnalytic } from '@/services'; import { useSentryStore } from '@/stores/useSentryStore'; @@ -13,10 +17,12 @@ import { useConfig } from './api/useConfig'; export const ConfigProvider = ({ children }: PropsWithChildren) => { const { data: conf } = useConfig(); + const { data: user } = useAuthQuery(); const { setSentry } = useSentryStore(); const { setTheme } = useCunninghamTheme(); const { AnalyticsProvider } = useAnalytics(); const { synchronizeLanguage } = useLanguageSynchronizer(); + const { customizeTranslations } = useTranslationsCustomizer(); useEffect(() => { if (!conf?.SENTRY_DSN) { @@ -35,8 +41,20 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => { }, [conf?.FRONTEND_THEME, setTheme]); useEffect(() => { - void synchronizeLanguage(); - }, [synchronizeLanguage]); + if (!conf?.LANGUAGES || !user) { + return; + } + + synchronizeLanguage(conf.LANGUAGES, user); + }, [conf?.LANGUAGES, user, synchronizeLanguage]); + + useEffect(() => { + if (!conf?.FRONTEND_CUSTOM_TRANSLATIONS_URL) { + return; + } + + customizeTranslations(conf.FRONTEND_CUSTOM_TRANSLATIONS_URL); + }, [conf?.FRONTEND_CUSTOM_TRANSLATIONS_URL, customizeTranslations]); useEffect(() => { if (!conf?.POSTHOG_KEY) { diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index aa3648770..c905cabeb 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -4,7 +4,7 @@ import { APIError, errorCauses, fetchAPI } from '@/api'; import { Theme } from '@/cunningham/'; import { PostHogConf } from '@/services'; -interface ConfigResponse { +export interface ConfigResponse { AI_FEATURE_ENABLED?: boolean; COLLABORATION_WS_URL?: string; CRISP_WEBSITE_ID?: string; @@ -14,6 +14,7 @@ interface ConfigResponse { FRONTEND_THEME?: Theme; LANGUAGES: [string, string][]; LANGUAGE_CODE: string; + FRONTEND_CUSTOM_TRANSLATIONS_URL?: string; MEDIA_BASE_URL?: string; POSTHOG_KEY?: PostHogConf; SENTRY_DSN?: string; diff --git a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx index 950b4f2bb..fd60e2208 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx @@ -5,6 +5,7 @@ import { css } from 'styled-components'; import { DropdownMenu, Icon, Text } from '@/components/'; import { useConfig } from '@/core'; +import { useAuthQuery } from '@/features/auth'; import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer'; import { getMatchingLocales } from './utils/locale'; @@ -12,6 +13,7 @@ import { getMatchingLocales } from './utils/locale'; export const LanguagePicker = () => { const { t, i18n } = useTranslation(); const { data: conf } = useConfig(); + const { data: user } = useAuthQuery(); const { synchronizeLanguage } = useLanguageSynchronizer(); const language = i18n.languages[0]; Settings.defaultLocale = language; @@ -28,7 +30,9 @@ export const LanguagePicker = () => { i18n .changeLanguage(backendLocale) .then(() => { - void synchronizeLanguage('toBackend'); + if (conf?.LANGUAGES && user) { + synchronizeLanguage(conf.LANGUAGES, user, 'toBackend'); + } }) .catch((err) => { console.error('Error changing language', err); @@ -36,7 +40,7 @@ export const LanguagePicker = () => { }; return { label, isSelected, callback }; }); - }, [conf, i18n, language, synchronizeLanguage]); + }, [conf?.LANGUAGES, i18n, language, synchronizeLanguage, user]); // Extract current language label for display const currentLanguageLabel = diff --git a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts index 536e2e72d..cded24a79 100644 --- a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts +++ b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts @@ -1,37 +1,30 @@ -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { useConfig } from '@/core'; -import { useAuthQuery } from '@/features/auth/api'; +import type { ConfigResponse } from '@/core/config/api/useConfig'; +import { User } from '@/features/auth'; import { useChangeUserLanguage } from '@/features/language/api/useChangeUserLanguage'; import { getMatchingLocales } from '@/features/language/utils/locale'; import { availableFrontendLanguages } from '@/i18n/initI18n'; export const useLanguageSynchronizer = () => { - const { data: conf, isSuccess: confInitialized } = useConfig(); - const { data: user, isSuccess: userInitialized } = useAuthQuery(); const { i18n } = useTranslation(); const { mutateAsync: changeUserLanguage } = useChangeUserLanguage(); const languageSynchronizing = useRef(false); - const availableBackendLanguages = useMemo(() => { - return conf?.LANGUAGES.map(([locale]) => locale); - }, [conf]); - const synchronizeLanguage = useCallback( - async (direction?: 'toBackend' | 'toFrontend') => { - if ( - languageSynchronizing.current || - !userInitialized || - !confInitialized || - !availableBackendLanguages || - !availableFrontendLanguages - ) { + ( + languages: ConfigResponse['LANGUAGES'], + user: User, + direction?: 'toBackend' | 'toFrontend', + ) => { + if (languageSynchronizing.current || !availableFrontendLanguages) { return; } languageSynchronizing.current = true; try { + const availableBackendLanguages = languages.map(([locale]) => locale); const userPreferredLanguages = user.language ? [user.language] : []; const setOrDetectedLanguages = i18n.languages; @@ -41,25 +34,27 @@ export const useLanguageSynchronizer = () => { (userPreferredLanguages.length ? 'toFrontend' : 'toBackend'); if (direction === 'toBackend') { - // Update user's preference from frontends's language const closestBackendLanguage = getMatchingLocales( availableBackendLanguages, setOrDetectedLanguages, )[0] || availableBackendLanguages[0]; - await changeUserLanguage({ + changeUserLanguage({ userId: user.id, language: closestBackendLanguage, + }).catch((error) => { + console.error('Error changing user language', error); }); } else { - // Update frontends's language from user's preference const closestFrontendLanguage = getMatchingLocales( availableFrontendLanguages, userPreferredLanguages, )[0] || availableFrontendLanguages[0]; if (i18n.resolvedLanguage !== closestFrontendLanguage) { - await i18n.changeLanguage(closestFrontendLanguage); + i18n.changeLanguage(closestFrontendLanguage).catch((error) => { + console.error('Error changing frontend language', error); + }); } } } catch (error) { @@ -68,14 +63,7 @@ export const useLanguageSynchronizer = () => { languageSynchronizing.current = false; } }, - [ - i18n, - user, - userInitialized, - confInitialized, - availableBackendLanguages, - changeUserLanguage, - ], + [i18n, changeUserLanguage], ); return { synchronizeLanguage }; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts new file mode 100644 index 000000000..8ba3eaa7e --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts @@ -0,0 +1,85 @@ +import i18next, { Resource, i18n } from 'i18next'; +import { useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { ConfigResponse } from '@/core/config/api/useConfig'; +import { safeLocalStorage } from '@/utils/storages'; + +export const useTranslationsCustomizer = () => { + const { i18n } = useTranslation(); + const translationsCustomizing = useRef(false); + + const customizeTranslations = useCallback( + ( + customTranslationsUrl: ConfigResponse['FRONTEND_CUSTOM_TRANSLATIONS_URL'], + cacheKey: string = 'CUSTOM_TRANSLATIONS', + ) => { + if (translationsCustomizing.current) { + return; + } + translationsCustomizing.current = true; + try { + if (!customTranslationsUrl) { + safeLocalStorage.setItem(cacheKey, ''); + } else { + const previousTranslationsString = safeLocalStorage.getItem(cacheKey); + if (previousTranslationsString) { + const previousTranslations = JSON.parse( + previousTranslationsString, + ) as Resource; + try { + applyTranslations(previousTranslations, i18n); + } catch (err: unknown) { + console.error('Error parsing cached translations:', err); + safeLocalStorage.setItem(cacheKey, ''); + } + } + + // Always update in background + fetchAndCacheTranslations(customTranslationsUrl, cacheKey) + .then((updatedTranslations) => { + if ( + updatedTranslations && + JSON.stringify(updatedTranslations) !== + previousTranslationsString + ) { + applyTranslations(updatedTranslations, i18n); + } + }) + .catch((err: unknown) => { + console.error('Error fetching custom translations:', err); + }); + } + } catch (err: unknown) { + console.error('Error updating custom translations:', err); + } finally { + translationsCustomizing.current = false; + } + }, + [i18n], + ); + + const applyTranslations = (translations: Resource, i18n: i18n) => { + Object.entries(translations).forEach(([lng, namespaces]) => { + Object.entries(namespaces).forEach(([ns, value]) => { + i18next.addResourceBundle(lng, ns, value, true, true); + }); + }); + const currentLanguage = i18n.language; + void i18next.changeLanguage(currentLanguage); + }; + + const fetchAndCacheTranslations = (url: string, CACHE_KEY: string) => { + return fetch(url).then((response) => { + if (!response.ok) { + throw new Error('Failed to fetch custom translations'); + } + return response.json().then((customTranslations: Resource) => { + safeLocalStorage.setItem(CACHE_KEY, JSON.stringify(customTranslations)); + return customTranslations; + }); + }); + }; + + return { customizeTranslations }; +}; diff --git a/src/frontend/apps/impress/src/features/language/index.ts b/src/frontend/apps/impress/src/features/language/index.ts index 4b60c8bd4..d093f8f17 100644 --- a/src/frontend/apps/impress/src/features/language/index.ts +++ b/src/frontend/apps/impress/src/features/language/index.ts @@ -1,2 +1,3 @@ export * from './hooks/useLanguageSynchronizer'; +export * from './hooks/useTranslationsCustomizer'; export * from './LanguagePicker'; diff --git a/src/frontend/apps/impress/src/i18n/initI18n.ts b/src/frontend/apps/impress/src/i18n/initI18n.ts index 40700e64e..b5420d620 100644 --- a/src/frontend/apps/impress/src/i18n/initI18n.ts +++ b/src/frontend/apps/impress/src/i18n/initI18n.ts @@ -7,33 +7,39 @@ import resources from './translations.json'; export const availableFrontendLanguages: readonly string[] = Object.keys(resources); -i18next - .use(LanguageDetector) - .use(initReactI18next) - .init({ - resources, - fallbackLng: 'en', - debug: false, - detection: { - order: ['cookie', 'navigator'], // detection order - caches: ['cookie'], // Use cookies to store the language preference - lookupCookie: 'docs_language', - cookieMinutes: 525600, // Expires after one year - cookieOptions: { - path: '/', - sameSite: 'lax', +// Add an initialization guard +let isInitialized = false; + +// Initialize i18next with the base translations only once +if (!isInitialized && !i18next.isInitialized) { + isInitialized = true; + + i18next + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'en', + debug: false, + detection: { + order: ['cookie', 'navigator'], + caches: ['cookie'], + lookupCookie: 'docs_language', + cookieMinutes: 525600, + cookieOptions: { + path: '/', + sameSite: 'lax', + }, + }, + interpolation: { + escapeValue: false, }, - }, - interpolation: { - escapeValue: false, - }, - preload: availableFrontendLanguages, - lowerCaseLng: true, - nsSeparator: false, - keySeparator: false, - }) - .catch(() => { - throw new Error('i18n initialization failed'); - }); + preload: availableFrontendLanguages, + lowerCaseLng: true, + nsSeparator: false, + keySeparator: false, + }) + .catch((e) => console.error('i18n initialization failed:', e)); +} export default i18next; diff --git a/src/frontend/apps/impress/src/utils/storages.ts b/src/frontend/apps/impress/src/utils/storages.ts new file mode 100644 index 000000000..89b4d4b5f --- /dev/null +++ b/src/frontend/apps/impress/src/utils/storages.ts @@ -0,0 +1,35 @@ +/** + * @fileOverview This module provides utilities to interact with storage. + */ + +/** + * @namespace safeLocalStorage + * @description A utility for safely interacting with localStorage. + * It checks if the `window` object is defined before attempting to access localStorage, + * preventing errors in environments where `window` is not available. + */ +export const safeLocalStorage = { + /** + * Retrieves an item from localStorage. + * @param {string} key - The key of the item to retrieve. + * @returns {string | null} The item's value, or null if the item does not exist or if localStorage is not available. + */ + getItem: (key: string): string | null => { + if (typeof window === 'undefined') { + return null; + } + return localStorage.getItem(key); + }, + /** + * Sets an item in localStorage. + * @param {string} key - The key of the item to set. + * @param {string} value - The value to set for the item. + * @returns {void} + */ + setItem: (key: string, value: string): void => { + if (typeof window === 'undefined') { + return; + } + localStorage.setItem(key, value); + }, +};