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"]
} }
+31 -29
View File
@@ -4,9 +4,10 @@
> >
> **Description:** Hybrid namespace internationalization engine built on i18next, providing centralized common vocabularies with lazy-loaded feature dictionaries, tenant overrides, and backend sync with automatic rollback. > **Description:** Hybrid namespace internationalization engine built on i18next, providing centralized common vocabularies with lazy-loaded feature dictionaries, tenant overrides, and backend sync with automatic rollback.
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.
@@ -46,10 +47,10 @@ graph TD
DICT -.->|lazy loads| I18N DICT -.->|lazy loads| I18N
COMMON --->|preloads| I18N COMMON --->|preloads| I18N
I18N <===>|reads / persists| STORE I18N <===>|reads / persists| STORE
I18N --->|changeLanguage sync| SYNC I18N --->|changeLanguage sync| SYNC
SYNC -.->|fails? rollback| I18N SYNC -.->|fails? rollback| I18N
TENANT -.->|applyTenantOverrides| I18N TENANT -.->|applyTenantOverrides| I18N
%% ─── Apply Styles ─── %% ─── Apply Styles ───
@@ -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,17 +159,16 @@ 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 };
} }
} }
``` ```
**3. Consume in Components:** **3. Consume in Components:**
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>
@@ -221,7 +222,7 @@ export const getErrorMessage = (code: string) => {
## 4. Real-World Implementation Flow ## 4. Real-World Implementation Flow
The engine supports robust flows for authenticated apps, including Tenant Vocabulary Overrides and Backend Synchronization. The engine supports robust flows for authenticated apps, including Tenant Vocabulary Overrides and Backend Synchronization.
### A. The Tenant Override Flow (After Login) ### A. The Tenant Override Flow (After Login)
@@ -239,18 +240,15 @@ export function AuthProvider({ children }) {
try { try {
// 1. Fetch tenant-specific overrides from the API // 1. Fetch tenant-specific overrides from the API
const response = await api.get('/v1/tenant/i18n-config'); const response = await api.get('/v1/tenant/i18n-config');
// 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);
} }
} }
fetchTenantConfig(); fetchTenantConfig();
}, []); }, []);
@@ -269,7 +267,7 @@ import { api } from '@/api';
const handleSwitch = async (newLng: string) => { const handleSwitch = async (newLng: string) => {
try { try {
await changeLanguage(newLng, async (lng) => { await changeLanguage(newLng, async (lng) => {
// The core engine waits for this Promise. // The core engine waits for this Promise.
// If it throws, the UI reverts to the previous language automatically. // If it throws, the UI reverts to the previous language automatically.
await api.patch('/v1/user/profile', { language: lng }); await api.patch('/v1/user/profile', { language: lng });
}); });
@@ -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": {
@@ -313,4 +315,4 @@ If the frontend dictionary has `header.title` and `header.subtitle`, and the bac
} }
} }
} }
``` ```
+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,
+17 -13
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: { {
getLanguage: async () => { storageAdapter: {
return secureStorage.getItem<string>(AppStorageKey.LOCALE); getLanguage: async () => {
}, return 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);
},
}, },
}, },
}); 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);
}); });
}); });
}); });