Refactor i18n structure and update language files

- Removed old validation and common language files for English and Indonesian.
- Created new language files for English and Indonesian under the updated directory structure.
- Updated setup to reflect new language file paths and changed default language to English.
- Modified storage keys from 'locale' to 'language' for consistency.
- Added new language files for various modules including bookmarks, history, navigation, and system settings.
- Introduced new booking module language files for both English and Indonesian.
- Implemented a login page component with basic structure.
This commit is contained in:
Firman Ramdhani
2026-07-16 10:35:08 +07:00
parent 03eb4a803b
commit 9b09938a50
44 changed files with 147 additions and 99 deletions
+1 -1
View File
@@ -4,5 +4,5 @@
"mode": "auto" "mode": "auto"
} }
], ],
"cSpell.words": ["mantine", "Menlo", "mgmt", "Millis", "Pandang", "Segoe", "Ujung", "WITA"] "cSpell.words": ["mantine", "Menlo", "mgmt", "Millis", "Pandang", "Segoe", "Ujung", "VITE", "WITA"]
} }
+20 -18
View File
@@ -7,6 +7,7 @@
A highly decoupled, type-safe internationalization engine for the monorepo. A highly decoupled, type-safe internationalization engine for the monorepo.
It uses a **Hybrid Namespace Strategy**: It uses a **Hybrid Namespace Strategy**:
1. **Centralized Engine**: Setup, local persistence orchestration, and global words (`common`). 1. **Centralized Engine**: Setup, local persistence orchestration, and global words (`common`).
2. **Decentralized Dictionaries**: Feature-specific translations (`booking`, `billing`) live inside the application modules and are lazy-loaded. 2. **Decentralized Dictionaries**: Feature-specific translations (`booking`, `billing`) live inside the application modules and are lazy-loaded.
@@ -68,7 +69,7 @@ graph TD
## 1. App-Level Setup (Bootstrap) ## 1. App-Level Setup (Bootstrap)
Initialize the engine *before* your React application mounts to prevent UI flashing. Provide an `I18nStorageAdapter` using Dependency Injection so the core engine can persist the user's language without being tightly coupled to a specific storage implementation. Initialize the engine _before_ your React application mounts to prevent UI flashing. Provide an `I18nStorageAdapter` using Dependency Injection so the core engine can persist the user's language without being tightly coupled to a specific storage implementation.
```tsx ```tsx
// apps/web/src/main.tsx // apps/web/src/main.tsx
@@ -83,17 +84,19 @@ async function bootstrap() {
await setupI18n({ await setupI18n({
storageAdapter: { storageAdapter: {
getLanguage: async () => { getLanguage: async () => {
const stored = await secureStorage.getItem(AppStorageKey.LOCALE); const stored = await secureStorage.getItem(AppStorageKey.LANGUAGE);
return typeof stored === 'string' ? stored : null; return typeof stored === 'string' ? stored : null;
}, },
setLanguage: async (lng: string) => { setLanguage: async (lng: string) => {
await secureStorage.setItem(AppStorageKey.LOCALE, lng); await secureStorage.setItem(AppStorageKey.LANGUAGE, lng);
}, },
}, },
}); });
createRoot(document.getElementById('app')!).render( createRoot(document.getElementById('app')!).render(
<StrictMode><App /></StrictMode>, <StrictMode>
<App />
</StrictMode>,
); );
} }
bootstrap(); bootstrap();
@@ -106,6 +109,7 @@ bootstrap();
Dictionaries live right next to the UI components that use them. Dictionaries live right next to the UI components that use them.
### Folder Structure ### Folder Structure
```text ```text
apps/web/src/apps/modules/booking/ apps/web/src/apps/modules/booking/
├── presentation/BookingTable.tsx ├── presentation/BookingTable.tsx
@@ -138,11 +142,7 @@ registerModuleNamespace(BookingModuleConfig.translationNamespace, {
}); });
export default function BookingModule() { export default function BookingModule() {
return ( return <EnterpriseModuleProvider config={BookingModuleConfig}>{/* Routes and Pages */}</EnterpriseModuleProvider>;
<EnterpriseModuleProvider config={BookingModuleConfig}>
{/* Routes and Pages */}
</EnterpriseModuleProvider>
);
} }
``` ```
@@ -159,7 +159,7 @@ import bookingEn from '../apps/modules/booking/locales/en/booking.json';
declare module 'react-i18next' { declare module 'react-i18next' {
interface CustomTypeOptions { interface CustomTypeOptions {
defaultNS: 'common'; defaultNS: 'common';
resources: typeof coreResources['en'] & { booking: typeof bookingEn }; resources: (typeof coreResources)['en'] & { booking: typeof bookingEn };
} }
} }
``` ```
@@ -168,8 +168,7 @@ declare module 'react-i18next' {
Inside your pages and components, use `useEnterpriseModuleTranslationContext()` instead of the raw `useTranslation` hook. The context automatically scopes the `t` function to your module's namespace and the global `common` namespace. Inside your pages and components, use `useEnterpriseModuleTranslationContext()` instead of the raw `useTranslation` hook. The context automatically scopes the `t` function to your module's namespace and the global `common` namespace.
> [!WARNING] > [!WARNING] > **Best Practice:** Do not hardcode the module namespace prefix (e.g., `t('booking:header.title')`) when using `useEnterpriseModuleTranslationContext`. The provider already scopes it. Simply use `t('header.title')`. You can still access global keys using the common prefix: `t('common:edit')`.
> **Best Practice:** Do not hardcode the module namespace prefix (e.g., `t('booking:header.title')`) when using `useEnterpriseModuleTranslationContext`. The provider already scopes it. Simply use `t('header.title')`. You can still access global keys using the common prefix: `t('common:edit')`.
```tsx ```tsx
// apps/web/src/apps/modules/booking/presentation/pages/booking.page.index.tsx // apps/web/src/apps/modules/booking/presentation/pages/booking.page.index.tsx
@@ -189,6 +188,7 @@ export default function BookingPageIndex() {
``` ```
**4. Dynamic Variables (Interpolation):** **4. Dynamic Variables (Interpolation):**
```json ```json
// booking.json // booking.json
{ {
@@ -197,6 +197,7 @@ export default function BookingPageIndex() {
} }
} }
``` ```
```tsx ```tsx
// Inside component // Inside component
<h1>{t('messages.welcome', { name: 'Firman', count: 5 })}</h1> <h1>{t('messages.welcome', { name: 'Firman', count: 5 })}</h1>
@@ -242,12 +243,9 @@ export function AuthProvider({ children }) {
// 2. Inject into the engine. // 2. Inject into the engine.
// `deep: true` ensures only provided keys are overridden. // `deep: true` ensures only provided keys are overridden.
applyTenantOverrides( applyTenantOverrides(response.data.namespace, response.data.overrides);
response.data.namespace,
response.data.overrides
);
} catch (err) { } catch (err) {
console.error("Failed to fetch tenant configuration", err); console.error('Failed to fetch tenant configuration', err);
} }
} }
@@ -279,6 +277,7 @@ const handleSwitch = async (newLng: string) => {
} }
}; };
``` ```
> [!NOTE] > [!NOTE]
> For public pages (like `apps/landing`), simply call `changeLanguage('en')` without the callback function. It will update the UI and local storage instantly without hitting the network. > For public pages (like `apps/landing`), simply call `changeLanguage('en')` without the callback function. It will update the UI and local storage instantly without hitting the network.
@@ -289,18 +288,21 @@ const handleSwitch = async (newLng: string) => {
To support Dynamic Tenant Overrides, the backend must expose an endpoint (e.g., `GET /v1/tenant/i18n-config`). To support Dynamic Tenant Overrides, the backend must expose an endpoint (e.g., `GET /v1/tenant/i18n-config`).
### Identification ### Identification
The backend **MUST identify the tenant via the `Authorization` (JWT) header**. The frontend will not send `tenantId` in the query payload to prevent spoofing. The backend **MUST identify the tenant via the `Authorization` (JWT) header**. The frontend will not send `tenantId` in the query payload to prevent spoofing.
### Expected JSON Response Format ### Expected JSON Response Format
The response must match the structural shape of the frontend dictionary. Because the frontend uses a **Deep Merge** strategy, the backend **only needs to return the specific keys the tenant wishes to override**. The response must match the structural shape of the frontend dictionary. Because the frontend uses a **Deep Merge** strategy, the backend **only needs to return the specific keys the tenant wishes to override**.
If the frontend dictionary has `header.title` and `header.subtitle`, and the backend only sends `header.title`, the `subtitle` will safely remain intact. If the frontend dictionary has `header.title` and `header.subtitle`, and the backend only sends `header.title`, the `subtitle` will safely remain intact.
**Example Request:** **Example Request:**
`GET /v1/tenant/i18n-config` `GET /v1/tenant/i18n-config`
*(Authorization: Bearer eyJhbG...)* _(Authorization: Bearer eyJhbG...)_
**Expected Response (200 OK):** **Expected Response (200 OK):**
```json ```json
{ {
"data": { "data": {
+3 -5
View File
@@ -1,15 +1,13 @@
import { createLocalStorage } from '@repo/core-storage'; import { createLocalStorage } from '@repo/core-storage';
export const AppStorageKey = { export const AppStorageKey = {
LOCALE: 'app_locale', LANGUAGE: 'app_language',
} as const; } as const;
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey]; export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
export const PLAIN_KEYS = new Set<AppStorageKeyValue>([ export const PLAIN_KEYS = new Set<AppStorageKeyValue>([AppStorageKey.LANGUAGE]);
AppStorageKey.LOCALE,
]);
export const secureStorage = createLocalStorage<AppStorageKeyValue>({ export const secureStorage = createLocalStorage<AppStorageKeyValue>({
plainTextKeys: PLAIN_KEYS plainTextKeys: PLAIN_KEYS,
}); });
+2 -2
View File
@@ -22,8 +22,8 @@ import App from './app';
async function bootstrap() { async function bootstrap() {
await setupI18n({ await setupI18n({
storageAdapter: { storageAdapter: {
getLanguage: async () => await secureStorage.getItem<string>(AppStorageKey.LOCALE), getLanguage: async () => await secureStorage.getItem<string>(AppStorageKey.LANGUAGE),
setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LOCALE, lng), setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LANGUAGE, lng),
}, },
}); });
+9 -1
View File
@@ -1,5 +1,13 @@
VITE_API_BASE_URL=http://localhost:8000/api
VITE_APP_ENV=development VITE_APP_ENV=development
VITE_APP_NAME=development_fe-monorepo-web
VITE_APP_VERSION=0.0.1
VITE_API_BASE_URL=http://localhost:8000/api
VITE_COUCHDB_BASE_URL=http://202.146.229.134:7700 VITE_COUCHDB_BASE_URL=http://202.146.229.134:7700
VITE_FARO_URL=https://telemetry.eigen.co.id/collect
VITE_OTLP_TRACE_URL=https://telemetry.eigen.co.id/v1/traces
VITE_COUCHDB_USERNAME=root VITE_COUCHDB_USERNAME=root
VITE_COUCHDB_PASSWORD=password VITE_COUCHDB_PASSWORD=password
+12 -1
View File
@@ -1,3 +1,14 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
const LoginPage = lazy(() => import('./login'));
export default function AuthModule() { export default function AuthModule() {
return <div>auth module</div>; return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<Navigate to="/auth/login" replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
);
} }
+3
View File
@@ -0,0 +1,3 @@
export default function LoginPage() {
return <div>Login Page</div>;
}
@@ -6,8 +6,8 @@ import { FullPageModuleConfig } from '../../domain/constants';
import { fullPageDataService } from '../../domain/factories'; import { fullPageDataService } from '../../domain/factories';
import { FullPageEntity } from '../../domain/entities'; import { FullPageEntity } from '../../domain/entities';
import fullPageId from '../locales/id/full-page.json'; import fullPageId from '../languages/id/full-page.json';
import fullPageEn from '../locales/en/full-page.json'; import fullPageEn from '../languages/en/full-page.json';
const IndexPage = lazy(() => import('../pages/full-page.page.index')); const IndexPage = lazy(() => import('../pages/full-page.page.index'));
const FormPage = lazy(() => import('../pages/full-page.page.form')); const FormPage = lazy(() => import('../pages/full-page.page.form'));
@@ -4,6 +4,7 @@ import { Sparkles } from 'lucide-react';
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { FullPageModuleConfig } from '../../domain/constants'; import { FullPageModuleConfig } from '../../domain/constants';
import { useState } from 'react'; import { useState } from 'react';
import { FullPageEntity } from '../../domain/entities';
const cancelSchema = z.object({ const cancelSchema = z.object({
reason: z.string().min(1, 'Reason is required'), reason: z.string().min(1, 'Reason is required'),
@@ -11,11 +12,13 @@ const cancelSchema = z.object({
export default function FullPagePageDetail() { export default function FullPagePageDetail() {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const [detailData, setDetailData] = useState(); const [detailData, setDetailData] = useState<FullPageEntity>();
console.log({ detailData }); console.log({ detailData });
return ( return (
<EnterpriseDetailPageProvider <EnterpriseDetailPageProvider
onDetailLoaded={detailData} onDetailLoaded={setDetailData}
cancelModalConfig={{ cancelModalConfig={{
title: t('common:confirmDialog.cancel.title'), title: t('common:confirmDialog.cancel.title'),
schema: cancelSchema, schema: cancelSchema,
@@ -6,8 +6,8 @@ import { SinglePageModuleConfig } from '../../domain/constants';
import { singlePageDataService } from '../../domain/factories'; import { singlePageDataService } from '../../domain/factories';
import { SinglePageEntity } from '../../domain/entities'; import { SinglePageEntity } from '../../domain/entities';
import singlePageId from '../locales/id/single-page.json'; import singlePageId from '../languages/id/single-page.json';
import singlePageEn from '../locales/en/single-page.json'; import singlePageEn from '../languages/en/single-page.json';
const IndexPage = lazy(() => import('../pages/single-page.page.index')); const IndexPage = lazy(() => import('../pages/single-page.page.index'));
@@ -131,7 +131,7 @@ export default function HeaderLayout() {
onChange={async (val) => { onChange={async (val) => {
if (!val) return; if (!val) return;
await i18n.changeLanguage(val); await i18n.changeLanguage(val);
await secureStorage.setItem(AppStorageKey.LOCALE, val); await secureStorage.setItem(AppStorageKey.LANGUAGE, val);
}} }}
styles={{ styles={{
input: { input: {
@@ -7,12 +7,12 @@ import { BookmarkDrawer } from './components/bookmark';
import { useHistoryTracker } from './hooks/useHistoryTracker'; import { useHistoryTracker } from './hooks/useHistoryTracker';
import { MENU_ITEMS } from './data/menu.data'; import { MENU_ITEMS } from './data/menu.data';
import navEn from './locales/en/nav.json'; import navEn from './languages/en/nav.json';
import navId from './locales/id/nav.json'; import navId from './languages/id/nav.json';
import historyEn from './locales/en/history.json'; import historyEn from './languages/en/history.json';
import historyId from './locales/id/history.json'; import historyId from './languages/id/history.json';
import bookmarkEn from './locales/en/bookmark.json'; import bookmarkEn from './languages/en/bookmark.json';
import bookmarkId from './locales/id/bookmark.json'; import bookmarkId from './languages/id/bookmark.json';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Namespace Registration (Module Scope) // Namespace Registration (Module Scope)
@@ -6,8 +6,8 @@ import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
import { Shortcut } from './components/shortcut'; import { Shortcut } from './components/shortcut';
import { System } from './components/system'; import { System } from './components/system';
import informationId from './locales/id/information.json'; import informationId from './languages/id/information.json';
import informationEn from './locales/en/information.json'; import informationEn from './languages/en/information.json';
import { useEffect } from 'react'; import { useEffect } from 'react';
registerModuleNamespace('information', { registerModuleNamespace('information', {
@@ -7,8 +7,8 @@ import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
import { HistorySetting } from './components/history-setting'; import { HistorySetting } from './components/history-setting';
import { NotificationSetting } from './components/notification-setting'; import { NotificationSetting } from './components/notification-setting';
import settingId from './locales/id/setting.json'; import settingId from './languages/id/setting.json';
import settingEn from './locales/en/setting.json'; import settingEn from './languages/en/setting.json';
registerModuleNamespace('setting', { registerModuleNamespace('setting', {
id: settingId, id: settingId,
@@ -75,7 +75,7 @@ export class BookingTransformer extends BaseDataTransformer<BookingEntity, Booki
*/ */
override transformToDTO(entity: BookingEntity): BookingDTO { override transformToDTO(entity: BookingEntity): BookingDTO {
return { return {
id: entity.id, id: entity.id as string,
booking_code: entity.bookingCode, booking_code: entity.bookingCode,
customer_name: entity.customerName, customer_name: entity.customerName,
check_in_date: entity.checkInDate, check_in_date: entity.checkInDate,
@@ -2,9 +2,9 @@ import { useState, useEffect, useCallback } from 'react';
import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n'; import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n';
import { secureIndexedDB, AppStorageKey } from '../../../../../../core/storage/local'; import { secureIndexedDB, AppStorageKey } from '../../../../../../core/storage/local';
// Decentralized locale imports // Decentralized languages imports
import bookingId from '../locales/id/booking.json'; import bookingId from '../languages/id/booking.json';
import bookingEn from '../locales/en/booking.json'; import bookingEn from '../languages/en/booking.json';
// ─── Shared Styles ────────────────────────────────────────────── // ─── Shared Styles ──────────────────────────────────────────────
@@ -4,7 +4,7 @@ export const LoadingScreen = () => {
return ( return (
<div className="fixed inset-0 z-50 flex h-screen w-screen items-center justify-center bg-white"> <div className="fixed inset-0 z-50 flex h-screen w-screen items-center justify-center bg-white">
<Stack align="center" gap="sm"> <Stack align="center" gap="sm">
<Loader color="blue" size="lg" type="dots" /> <Loader color="brand" size="lg" type="dots" />
</Stack> </Stack>
</div> </div>
); );
+12 -2
View File
@@ -3,9 +3,19 @@
* DO NOT use `import.meta.env` directly in components. Import this `ENV` object instead. * DO NOT use `import.meta.env` directly in components. Import this `ENV` object instead.
*/ */
export const ENV = { export const ENV = {
API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api', DEFAULT_LANGUAGE: 'en',
APP_ENV: (import.meta.env.VITE_APP_ENV || 'development') as 'development' | 'staging' | 'production',
IS_PROD: import.meta.env.VITE_APP_ENV === 'production', IS_PROD: import.meta.env.VITE_APP_ENV === 'production',
APP_ENV: (import.meta.env.VITE_APP_ENV || 'development') as 'development' | 'staging' | 'production',
APP_NAME: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web',
APP_VERSION: import.meta.env.VITE_APP_VERSION || '0.0.0',
API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api',
// Observability
FARO_URL: import.meta.env.VITE_FARO_URL || 'https://telemetry.eigen.co.id/collect',
OTLP_TRACE_URL: import.meta.env.VITE_OTLP_TRACE_URL || 'https://telemetry.eigen.co.id/v1/traces',
// CouchDB Connection // CouchDB Connection
COUCHDB_BASE_URL: import.meta.env.VITE_COUCHDB_BASE_URL || 'http://localhost:5984', COUCHDB_BASE_URL: import.meta.env.VITE_COUCHDB_BASE_URL || 'http://localhost:5984',
+13 -4
View File
@@ -1,5 +1,7 @@
import { createHttpClient } from '@repo/core-api/http-client'; import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability'; import { faroAdapter } from '@repo/core-api/observability';
import { ENV } from '../environment';
import { AppStorageKey, secureStorage } from '../storage/local';
/** /**
* Enterprise HTTP client for `apps/web`. * Enterprise HTTP client for `apps/web`.
@@ -15,17 +17,24 @@ import { faroAdapter } from '@repo/core-api/observability';
*/ */
export const apiClient = createHttpClient( export const apiClient = createHttpClient(
{ {
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1', baseURL: ENV.API_BASE_URL,
timeout: 15000, timeout: 15000,
observability: faroAdapter, observability: faroAdapter,
}, },
{ {
// ── Auth Interceptor ────────────────────────────────────────── // ── Auth Interceptor ──────────────────────────────────────────
onRequest: async (config) => { onRequest: async (config) => {
config.headers['ex-app-name'] = ENV.APP_NAME;
config.headers['ex-app-version'] = ENV.APP_VERSION;
config.headers['ex-timezone-offset-minutes'] = new Date().getTimezoneOffset();
config.headers['ex-timezone-offset-hours'] = Math.floor(new Date().getTimezoneOffset() / 60);
const language = await secureStorage.getItem<string>(AppStorageKey.LANGUAGE);
config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE;
const token = localStorage.getItem('access_token'); const token = localStorage.getItem('access_token');
if (token) { if (token) config.headers.Authorization = `Bearer ${token}`;
config.headers.Authorization = `Bearer ${token}`;
}
return config; return config;
}, },
+2 -2
View File
@@ -2,7 +2,7 @@ import { createLocalStorage, createIndexedDB } from '@repo/core-storage';
export const AppStorageKey = { export const AppStorageKey = {
USER_PROFILE: 'user_profile', USER_PROFILE: 'user_profile',
LOCALE: 'app_locale', LANGUAGE: 'app_language',
THEME: 'app_theme', THEME: 'app_theme',
ACCESS_TOKEN: 'access_token', ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token', REFRESH_TOKEN: 'refresh_token',
@@ -22,7 +22,7 @@ export const ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
]); ]);
export const PLAIN_KEYS = new Set<AppStorageKeyValue>([ export const PLAIN_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.LOCALE, AppStorageKey.LANGUAGE,
AppStorageKey.THEME, AppStorageKey.THEME,
AppStorageKey.MOCK_DB_COMPANY_A, AppStorageKey.MOCK_DB_COMPANY_A,
AppStorageKey.OFFLINE_DRAFT, AppStorageKey.OFFLINE_DRAFT,
+13 -9
View File
@@ -4,11 +4,11 @@
import { initTelemetry } from '@repo/core-api/observability/setup'; import { initTelemetry } from '@repo/core-api/observability/setup';
initTelemetry({ initTelemetry({
appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web', appName: ENV.APP_NAME,
appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0', appVersion: ENV.APP_VERSION,
telemetryUrl: import.meta.env.VITE_FARO_URL || 'https://telemetry.eigen.co.id/collect', telemetryUrl: ENV.FARO_URL,
otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || 'https://telemetry.eigen.co.id/v1/traces', otlpTraceUrl: ENV.OTLP_TRACE_URL,
environment: import.meta.env.VITE_ENV || 'development', environment: ENV.APP_ENV,
}); });
// ─── Application Bootstrap ────────────────────────────────────── // ─── Application Bootstrap ──────────────────────────────────────
@@ -17,21 +17,25 @@ import { lazy, StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n'; import { setupI18n } from '@repo/core-i18n';
import { secureStorage, AppStorageKey } from './core/storage/local'; import { secureStorage, AppStorageKey } from './core/storage/local';
import { ENV } from './core/environment';
const App = lazy(() => import('./apps')); const App = lazy(() => import('./apps'));
async function bootstrap() { async function bootstrap() {
// Initialize i18next and load language from secureStorage // Initialize i18next and load language from secureStorage
await setupI18n({ await setupI18n(
{
storageAdapter: { storageAdapter: {
getLanguage: async () => { getLanguage: async () => {
return secureStorage.getItem<string>(AppStorageKey.LOCALE); return secureStorage.getItem<string>(AppStorageKey.LANGUAGE);
}, },
setLanguage: async (lng: string) => { setLanguage: async (lng: string) => {
await secureStorage.setItem(AppStorageKey.LOCALE, lng); await secureStorage.setItem(AppStorageKey.LANGUAGE, lng);
}, },
}, },
}); },
ENV.DEFAULT_LANGUAGE,
);
createRoot(document.getElementById('app')!).render( createRoot(document.getElementById('app')!).render(
<StrictMode> <StrictMode>
@@ -294,7 +294,7 @@ class TestTransformer extends BaseDataTransformer<TestEntity2, TestDTO> {
transformToDTO(entity: TestEntity2): TestDTO { transformToDTO(entity: TestEntity2): TestDTO {
return { return {
id: entity.id, id: entity.id as string,
booking_code: entity.bookingCode, booking_code: entity.bookingCode,
customer_name: entity.customerName, customer_name: entity.customerName,
}; };
+7 -7
View File
@@ -1,11 +1,11 @@
import i18n from 'i18next'; import i18n from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import commonEn from './locales/en/common.json'; import commonEn from './languages/en/common.json';
import commonId from './locales/id/common.json'; import commonId from './languages/id/common.json';
import validationEn from './locales/en/validation.json'; import validationEn from './languages/en/validation.json';
import validationId from './locales/id/validation.json'; import validationId from './languages/id/validation.json';
const DEFAULT_LANGUAGE = 'id'; const DEFAULT_LANGUAGE = 'en';
const SUPPORTED_LANGUAGES = ['en', 'id'] as const; const SUPPORTED_LANGUAGES = ['en', 'id'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
@@ -32,10 +32,10 @@ export let globalStorageAdapter: I18nStorageAdapter | undefined;
* *
* It accepts an optional storage adapter to read the initial language. * It accepts an optional storage adapter to read the initial language.
*/ */
export async function setupI18n(config: I18nConfig = {}): Promise<void> { export async function setupI18n(config: I18nConfig = {}, defaultLanguage?: string): Promise<void> {
globalStorageAdapter = config.storageAdapter; globalStorageAdapter = config.storageAdapter;
let initialLng = DEFAULT_LANGUAGE; let initialLng = defaultLanguage ? defaultLanguage : DEFAULT_LANGUAGE;
try { try {
if (globalStorageAdapter) { if (globalStorageAdapter) {
const storedLng = await globalStorageAdapter.getLanguage(); const storedLng = await globalStorageAdapter.getLanguage();
@@ -48,7 +48,7 @@ Object.defineProperty(globalThis, 'localStorage', {
const TestStorageKey = { const TestStorageKey = {
THEME: 'theme', THEME: 'theme',
LOCALE: 'locale', LANGUAGE: 'language',
ACCESS_TOKEN: 'access_token', ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token', REFRESH_TOKEN: 'refresh_token',
USER_PROFILE: 'user_profile', USER_PROFILE: 'user_profile',
@@ -62,7 +62,7 @@ const ENCRYPTED_KEYS = new Set<TestStorageKeyValue>([
TestStorageKey.USER_PROFILE, TestStorageKey.USER_PROFILE,
]); ]);
const PLAIN_KEYS = new Set<TestStorageKeyValue>([TestStorageKey.THEME, TestStorageKey.LOCALE]); const PLAIN_KEYS = new Set<TestStorageKeyValue>([TestStorageKey.THEME, TestStorageKey.LANGUAGE]);
interface TestUser { interface TestUser {
id: number; id: number;
@@ -108,10 +108,10 @@ describe('LocalStorageService', () => {
}); });
it('stores plain JSON without encryption for non-sensitive keys', async () => { it('stores plain JSON without encryption for non-sensitive keys', async () => {
await storage.setItem(TestStorageKey.LOCALE, 'en-US'); await storage.setItem(TestStorageKey.LANGUAGE, 'en-US');
expect(mockEncrypt).not.toHaveBeenCalled(); expect(mockEncrypt).not.toHaveBeenCalled();
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(TestStorageKey.LOCALE, '"en-US"'); expect(mockLocalStorage.setItem).toHaveBeenCalledWith(TestStorageKey.LANGUAGE, '"en-US"');
}); });
it('encrypts sensitive keys (ACCESS_TOKEN)', async () => { it('encrypts sensitive keys (ACCESS_TOKEN)', async () => {
@@ -194,7 +194,7 @@ describe('LocalStorageService', () => {
describe('clear', () => { describe('clear', () => {
it('clears all keys from storage', async () => { it('clears all keys from storage', async () => {
await storage.setItem(TestStorageKey.THEME, 'dark'); await storage.setItem(TestStorageKey.THEME, 'dark');
await storage.setItem(TestStorageKey.LOCALE, 'en'); await storage.setItem(TestStorageKey.LANGUAGE, 'en');
await storage.clear(); await storage.clear();
expect(mockLocalStorage.clear).toHaveBeenCalled(); expect(mockLocalStorage.clear).toHaveBeenCalled();
@@ -220,11 +220,11 @@ describe('LocalStorageService', () => {
describe('keys', () => { describe('keys', () => {
it('returns all stored keys', async () => { it('returns all stored keys', async () => {
await storage.setItem(TestStorageKey.THEME, 'dark'); await storage.setItem(TestStorageKey.THEME, 'dark');
await storage.setItem(TestStorageKey.LOCALE, 'en'); await storage.setItem(TestStorageKey.LANGUAGE, 'en');
const allKeys = await storage.keys(); const allKeys = await storage.keys();
expect(allKeys).toContain(TestStorageKey.THEME); expect(allKeys).toContain(TestStorageKey.THEME);
expect(allKeys).toContain(TestStorageKey.LOCALE); expect(allKeys).toContain(TestStorageKey.LANGUAGE);
expect(allKeys).toHaveLength(2); expect(allKeys).toHaveLength(2);
}); });
}); });
@@ -248,8 +248,8 @@ describe('LocalStorageService', () => {
expect(ENCRYPTED_KEYS.has(TestStorageKey.THEME)).toBe(false); expect(ENCRYPTED_KEYS.has(TestStorageKey.THEME)).toBe(false);
}); });
it('LOCALE is NOT in ENCRYPTED_KEYS', () => { it('LANGUAGE is NOT in ENCRYPTED_KEYS', () => {
expect(ENCRYPTED_KEYS.has(TestStorageKey.LOCALE)).toBe(false); expect(ENCRYPTED_KEYS.has(TestStorageKey.LANGUAGE)).toBe(false);
}); });
}); });
}); });