From 9b09938a50b3f2c000f8cc7d359c1dd818c53006 Mon Sep 17 00:00:00 2001
From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com>
Date: Thu, 16 Jul 2026 10:35:08 +0700
Subject: [PATCH] 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.
---
.vscode/settings.json | 2 +-
apps/docs-dev/src/packages/core-i18n/index.md | 60 ++++++++++---------
apps/landing/src/core/storage/index.ts | 8 +--
apps/landing/src/main.tsx | 4 +-
apps/web/.env.example | 10 +++-
apps/web/src/apps/auth/index.tsx | 13 +++-
apps/web/src/apps/auth/login/index.tsx | 3 +
.../full-page/presentation/factory/index.tsx | 4 +-
.../{locales => languages}/en/full-page.json | 0
.../{locales => languages}/id/full-page.json | 0
.../pages/full-page.page.detail.tsx | 7 ++-
.../presentation/factory/index.tsx | 4 +-
.../en/single-page.json | 0
.../id/single-page.json | 0
.../layouts/components/header.layout.tsx | 2 +-
.../{locales => languages}/en/bookmark.json | 0
.../{locales => languages}/en/history.json | 0
.../{locales => languages}/en/nav.json | 0
.../{locales => languages}/id/bookmark.json | 0
.../{locales => languages}/id/history.json | 0
.../{locales => languages}/id/nav.json | 0
.../apps/modules/layouts/module.layout.tsx | 12 ++--
.../apps/modules/system/information/index.tsx | 4 +-
.../en/information.json | 0
.../id/information.json | 0
.../src/apps/modules/system/setting/index.tsx | 4 +-
.../{locales => languages}/en/setting.json | 0
.../{locales => languages}/id/setting.json | 0
.../booking/data/booking.transformer.ts | 2 +-
.../{locales => languages}/en/booking.json | 0
.../{locales => languages}/id/booking.json | 0
.../features/i18n/presentation/I18nSample.tsx | 6 +-
.../src/core/components/loading-screen.tsx | 2 +-
apps/web/src/core/environment/index.ts | 14 ++++-
apps/web/src/core/lib/api-client.ts | 17 ++++--
apps/web/src/core/storage/local/index.ts | 4 +-
apps/web/src/main.tsx | 30 ++++++----
.../base-remote.data-services.test.ts | 2 +-
.../src/{locales => languages}/en/common.json | 0
.../{locales => languages}/en/validation.json | 0
.../src/{locales => languages}/id/common.json | 0
.../{locales => languages}/id/validation.json | 0
packages/core-i18n/src/setup.ts | 14 ++---
.../core-storage/src/tests/storage.test.ts | 18 +++---
44 files changed, 147 insertions(+), 99 deletions(-)
create mode 100644 apps/web/src/apps/auth/login/index.tsx
rename apps/web/src/apps/modules/example/full-page/presentation/{locales => languages}/en/full-page.json (100%)
rename apps/web/src/apps/modules/example/full-page/presentation/{locales => languages}/id/full-page.json (100%)
rename apps/web/src/apps/modules/example/single-page/presentation/{locales => languages}/en/single-page.json (100%)
rename apps/web/src/apps/modules/example/single-page/presentation/{locales => languages}/id/single-page.json (100%)
rename apps/web/src/apps/modules/layouts/{locales => languages}/en/bookmark.json (100%)
rename apps/web/src/apps/modules/layouts/{locales => languages}/en/history.json (100%)
rename apps/web/src/apps/modules/layouts/{locales => languages}/en/nav.json (100%)
rename apps/web/src/apps/modules/layouts/{locales => languages}/id/bookmark.json (100%)
rename apps/web/src/apps/modules/layouts/{locales => languages}/id/history.json (100%)
rename apps/web/src/apps/modules/layouts/{locales => languages}/id/nav.json (100%)
rename apps/web/src/apps/modules/system/information/{locales => languages}/en/information.json (100%)
rename apps/web/src/apps/modules/system/information/{locales => languages}/id/information.json (100%)
rename apps/web/src/apps/modules/system/setting/{locales => languages}/en/setting.json (100%)
rename apps/web/src/apps/modules/system/setting/{locales => languages}/id/setting.json (100%)
rename apps/web/src/apps/showcase/example/features/i18n/{locales => languages}/en/booking.json (100%)
rename apps/web/src/apps/showcase/example/features/i18n/{locales => languages}/id/booking.json (100%)
rename packages/core-i18n/src/{locales => languages}/en/common.json (100%)
rename packages/core-i18n/src/{locales => languages}/en/validation.json (100%)
rename packages/core-i18n/src/{locales => languages}/id/common.json (100%)
rename packages/core-i18n/src/{locales => languages}/id/validation.json (100%)
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 9f3ba47..1b53be8 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,5 +4,5 @@
"mode": "auto"
}
],
- "cSpell.words": ["mantine", "Menlo", "mgmt", "Millis", "Pandang", "Segoe", "Ujung", "WITA"]
+ "cSpell.words": ["mantine", "Menlo", "mgmt", "Millis", "Pandang", "Segoe", "Ujung", "VITE", "WITA"]
}
diff --git a/apps/docs-dev/src/packages/core-i18n/index.md b/apps/docs-dev/src/packages/core-i18n/index.md
index 23d791e..2e49973 100644
--- a/apps/docs-dev/src/packages/core-i18n/index.md
+++ b/apps/docs-dev/src/packages/core-i18n/index.md
@@ -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.
-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**:
+
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.
@@ -46,10 +47,10 @@ graph TD
DICT -.->|lazy loads| I18N
COMMON --->|preloads| I18N
I18N <===>|reads / persists| STORE
-
+
I18N --->|changeLanguage sync| SYNC
SYNC -.->|fails? rollback| I18N
-
+
TENANT -.->|applyTenantOverrides| I18N
%% ─── Apply Styles ───
@@ -68,7 +69,7 @@ graph TD
## 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
// apps/web/src/main.tsx
@@ -83,17 +84,19 @@ async function bootstrap() {
await setupI18n({
storageAdapter: {
getLanguage: async () => {
- const stored = await secureStorage.getItem(AppStorageKey.LOCALE);
+ const stored = await secureStorage.getItem(AppStorageKey.LANGUAGE);
return typeof stored === 'string' ? stored : null;
},
setLanguage: async (lng: string) => {
- await secureStorage.setItem(AppStorageKey.LOCALE, lng);
+ await secureStorage.setItem(AppStorageKey.LANGUAGE, lng);
},
},
});
createRoot(document.getElementById('app')!).render(
- ,
+
+
+ ,
);
}
bootstrap();
@@ -106,6 +109,7 @@ bootstrap();
Dictionaries live right next to the UI components that use them.
### Folder Structure
+
```text
apps/web/src/apps/modules/booking/
├── presentation/BookingTable.tsx
@@ -138,11 +142,7 @@ registerModuleNamespace(BookingModuleConfig.translationNamespace, {
});
export default function BookingModule() {
- return (
-
- {/* Routes and Pages */}
-
- );
+ return {/* Routes and Pages */};
}
```
@@ -159,17 +159,16 @@ import bookingEn from '../apps/modules/booking/locales/en/booking.json';
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
- resources: typeof coreResources['en'] & { booking: typeof bookingEn };
+ resources: (typeof coreResources)['en'] & { booking: typeof bookingEn };
}
}
```
**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]
-> **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')`.
+> [!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')`.
```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):**
+
```json
// booking.json
{
@@ -197,6 +197,7 @@ export default function BookingPageIndex() {
}
}
```
+
```tsx
// Inside component
{t('messages.welcome', { name: 'Firman', count: 5 })}
@@ -221,7 +222,7 @@ export const getErrorMessage = (code: string) => {
## 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)
@@ -239,18 +240,15 @@ export function AuthProvider({ children }) {
try {
// 1. Fetch tenant-specific overrides from the API
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.
- applyTenantOverrides(
- response.data.namespace,
- response.data.overrides
- );
+ applyTenantOverrides(response.data.namespace, response.data.overrides);
} catch (err) {
- console.error("Failed to fetch tenant configuration", err);
+ console.error('Failed to fetch tenant configuration', err);
}
}
-
+
fetchTenantConfig();
}, []);
@@ -269,7 +267,7 @@ import { api } from '@/api';
const handleSwitch = async (newLng: string) => {
try {
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.
await api.patch('/v1/user/profile', { language: lng });
});
@@ -279,6 +277,7 @@ const handleSwitch = async (newLng: string) => {
}
};
```
+
> [!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.
@@ -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`).
### 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.
### 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.
**Example Request:**
`GET /v1/tenant/i18n-config`
-*(Authorization: Bearer eyJhbG...)*
+_(Authorization: Bearer eyJhbG...)_
**Expected Response (200 OK):**
+
```json
{
"data": {
@@ -313,4 +315,4 @@ If the frontend dictionary has `header.title` and `header.subtitle`, and the bac
}
}
}
-```
\ No newline at end of file
+```
diff --git a/apps/landing/src/core/storage/index.ts b/apps/landing/src/core/storage/index.ts
index 0cae5eb..d720d0b 100644
--- a/apps/landing/src/core/storage/index.ts
+++ b/apps/landing/src/core/storage/index.ts
@@ -1,15 +1,13 @@
import { createLocalStorage } from '@repo/core-storage';
export const AppStorageKey = {
- LOCALE: 'app_locale',
+ LANGUAGE: 'app_language',
} as const;
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
-export const PLAIN_KEYS = new Set([
- AppStorageKey.LOCALE,
-]);
+export const PLAIN_KEYS = new Set([AppStorageKey.LANGUAGE]);
export const secureStorage = createLocalStorage({
- plainTextKeys: PLAIN_KEYS
+ plainTextKeys: PLAIN_KEYS,
});
diff --git a/apps/landing/src/main.tsx b/apps/landing/src/main.tsx
index 7bc17af..cae603d 100644
--- a/apps/landing/src/main.tsx
+++ b/apps/landing/src/main.tsx
@@ -22,8 +22,8 @@ import App from './app';
async function bootstrap() {
await setupI18n({
storageAdapter: {
- getLanguage: async () => await secureStorage.getItem(AppStorageKey.LOCALE),
- setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LOCALE, lng),
+ getLanguage: async () => await secureStorage.getItem(AppStorageKey.LANGUAGE),
+ setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LANGUAGE, lng),
},
});
diff --git a/apps/web/.env.example b/apps/web/.env.example
index dcd4573..ac9ac5b 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -1,5 +1,13 @@
-VITE_API_BASE_URL=http://localhost:8000/api
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_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_PASSWORD=password
diff --git a/apps/web/src/apps/auth/index.tsx b/apps/web/src/apps/auth/index.tsx
index c86aca0..4eb4320 100644
--- a/apps/web/src/apps/auth/index.tsx
+++ b/apps/web/src/apps/auth/index.tsx
@@ -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() {
- return auth module
;
+ return (
+
+ } />
+ } />
+ } />
+
+ );
}
diff --git a/apps/web/src/apps/auth/login/index.tsx b/apps/web/src/apps/auth/login/index.tsx
new file mode 100644
index 0000000..fb9b00c
--- /dev/null
+++ b/apps/web/src/apps/auth/login/index.tsx
@@ -0,0 +1,3 @@
+export default function LoginPage() {
+ return Login Page
;
+}
diff --git a/apps/web/src/apps/modules/example/full-page/presentation/factory/index.tsx b/apps/web/src/apps/modules/example/full-page/presentation/factory/index.tsx
index d3fdc07..78878c2 100644
--- a/apps/web/src/apps/modules/example/full-page/presentation/factory/index.tsx
+++ b/apps/web/src/apps/modules/example/full-page/presentation/factory/index.tsx
@@ -6,8 +6,8 @@ import { FullPageModuleConfig } from '../../domain/constants';
import { fullPageDataService } from '../../domain/factories';
import { FullPageEntity } from '../../domain/entities';
-import fullPageId from '../locales/id/full-page.json';
-import fullPageEn from '../locales/en/full-page.json';
+import fullPageId from '../languages/id/full-page.json';
+import fullPageEn from '../languages/en/full-page.json';
const IndexPage = lazy(() => import('../pages/full-page.page.index'));
const FormPage = lazy(() => import('../pages/full-page.page.form'));
diff --git a/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json b/apps/web/src/apps/modules/example/full-page/presentation/languages/en/full-page.json
similarity index 100%
rename from apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json
rename to apps/web/src/apps/modules/example/full-page/presentation/languages/en/full-page.json
diff --git a/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json b/apps/web/src/apps/modules/example/full-page/presentation/languages/id/full-page.json
similarity index 100%
rename from apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json
rename to apps/web/src/apps/modules/example/full-page/presentation/languages/id/full-page.json
diff --git a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx
index 2bad270..c7b9ced 100644
--- a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx
+++ b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx
@@ -4,6 +4,7 @@ import { Sparkles } from 'lucide-react';
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { FullPageModuleConfig } from '../../domain/constants';
import { useState } from 'react';
+import { FullPageEntity } from '../../domain/entities';
const cancelSchema = z.object({
reason: z.string().min(1, 'Reason is required'),
@@ -11,11 +12,13 @@ const cancelSchema = z.object({
export default function FullPagePageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
- const [detailData, setDetailData] = useState();
+ const [detailData, setDetailData] = useState();
+
console.log({ detailData });
+
return (
import('../pages/single-page.page.index'));
diff --git a/apps/web/src/apps/modules/example/single-page/presentation/locales/en/single-page.json b/apps/web/src/apps/modules/example/single-page/presentation/languages/en/single-page.json
similarity index 100%
rename from apps/web/src/apps/modules/example/single-page/presentation/locales/en/single-page.json
rename to apps/web/src/apps/modules/example/single-page/presentation/languages/en/single-page.json
diff --git a/apps/web/src/apps/modules/example/single-page/presentation/locales/id/single-page.json b/apps/web/src/apps/modules/example/single-page/presentation/languages/id/single-page.json
similarity index 100%
rename from apps/web/src/apps/modules/example/single-page/presentation/locales/id/single-page.json
rename to apps/web/src/apps/modules/example/single-page/presentation/languages/id/single-page.json
diff --git a/apps/web/src/apps/modules/layouts/components/header.layout.tsx b/apps/web/src/apps/modules/layouts/components/header.layout.tsx
index 27e95bf..fe0808a 100644
--- a/apps/web/src/apps/modules/layouts/components/header.layout.tsx
+++ b/apps/web/src/apps/modules/layouts/components/header.layout.tsx
@@ -131,7 +131,7 @@ export default function HeaderLayout() {
onChange={async (val) => {
if (!val) return;
await i18n.changeLanguage(val);
- await secureStorage.setItem(AppStorageKey.LOCALE, val);
+ await secureStorage.setItem(AppStorageKey.LANGUAGE, val);
}}
styles={{
input: {
diff --git a/apps/web/src/apps/modules/layouts/locales/en/bookmark.json b/apps/web/src/apps/modules/layouts/languages/en/bookmark.json
similarity index 100%
rename from apps/web/src/apps/modules/layouts/locales/en/bookmark.json
rename to apps/web/src/apps/modules/layouts/languages/en/bookmark.json
diff --git a/apps/web/src/apps/modules/layouts/locales/en/history.json b/apps/web/src/apps/modules/layouts/languages/en/history.json
similarity index 100%
rename from apps/web/src/apps/modules/layouts/locales/en/history.json
rename to apps/web/src/apps/modules/layouts/languages/en/history.json
diff --git a/apps/web/src/apps/modules/layouts/locales/en/nav.json b/apps/web/src/apps/modules/layouts/languages/en/nav.json
similarity index 100%
rename from apps/web/src/apps/modules/layouts/locales/en/nav.json
rename to apps/web/src/apps/modules/layouts/languages/en/nav.json
diff --git a/apps/web/src/apps/modules/layouts/locales/id/bookmark.json b/apps/web/src/apps/modules/layouts/languages/id/bookmark.json
similarity index 100%
rename from apps/web/src/apps/modules/layouts/locales/id/bookmark.json
rename to apps/web/src/apps/modules/layouts/languages/id/bookmark.json
diff --git a/apps/web/src/apps/modules/layouts/locales/id/history.json b/apps/web/src/apps/modules/layouts/languages/id/history.json
similarity index 100%
rename from apps/web/src/apps/modules/layouts/locales/id/history.json
rename to apps/web/src/apps/modules/layouts/languages/id/history.json
diff --git a/apps/web/src/apps/modules/layouts/locales/id/nav.json b/apps/web/src/apps/modules/layouts/languages/id/nav.json
similarity index 100%
rename from apps/web/src/apps/modules/layouts/locales/id/nav.json
rename to apps/web/src/apps/modules/layouts/languages/id/nav.json
diff --git a/apps/web/src/apps/modules/layouts/module.layout.tsx b/apps/web/src/apps/modules/layouts/module.layout.tsx
index e935d37..189715e 100644
--- a/apps/web/src/apps/modules/layouts/module.layout.tsx
+++ b/apps/web/src/apps/modules/layouts/module.layout.tsx
@@ -7,12 +7,12 @@ import { BookmarkDrawer } from './components/bookmark';
import { useHistoryTracker } from './hooks/useHistoryTracker';
import { MENU_ITEMS } from './data/menu.data';
-import navEn from './locales/en/nav.json';
-import navId from './locales/id/nav.json';
-import historyEn from './locales/en/history.json';
-import historyId from './locales/id/history.json';
-import bookmarkEn from './locales/en/bookmark.json';
-import bookmarkId from './locales/id/bookmark.json';
+import navEn from './languages/en/nav.json';
+import navId from './languages/id/nav.json';
+import historyEn from './languages/en/history.json';
+import historyId from './languages/id/history.json';
+import bookmarkEn from './languages/en/bookmark.json';
+import bookmarkId from './languages/id/bookmark.json';
// ---------------------------------------------------------------------------
// Namespace Registration (Module Scope)
diff --git a/apps/web/src/apps/modules/system/information/index.tsx b/apps/web/src/apps/modules/system/information/index.tsx
index 9a87d36..0bfff70 100644
--- a/apps/web/src/apps/modules/system/information/index.tsx
+++ b/apps/web/src/apps/modules/system/information/index.tsx
@@ -6,8 +6,8 @@ import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
import { Shortcut } from './components/shortcut';
import { System } from './components/system';
-import informationId from './locales/id/information.json';
-import informationEn from './locales/en/information.json';
+import informationId from './languages/id/information.json';
+import informationEn from './languages/en/information.json';
import { useEffect } from 'react';
registerModuleNamespace('information', {
diff --git a/apps/web/src/apps/modules/system/information/locales/en/information.json b/apps/web/src/apps/modules/system/information/languages/en/information.json
similarity index 100%
rename from apps/web/src/apps/modules/system/information/locales/en/information.json
rename to apps/web/src/apps/modules/system/information/languages/en/information.json
diff --git a/apps/web/src/apps/modules/system/information/locales/id/information.json b/apps/web/src/apps/modules/system/information/languages/id/information.json
similarity index 100%
rename from apps/web/src/apps/modules/system/information/locales/id/information.json
rename to apps/web/src/apps/modules/system/information/languages/id/information.json
diff --git a/apps/web/src/apps/modules/system/setting/index.tsx b/apps/web/src/apps/modules/system/setting/index.tsx
index 153ec85..8b5a2a6 100644
--- a/apps/web/src/apps/modules/system/setting/index.tsx
+++ b/apps/web/src/apps/modules/system/setting/index.tsx
@@ -7,8 +7,8 @@ import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
import { HistorySetting } from './components/history-setting';
import { NotificationSetting } from './components/notification-setting';
-import settingId from './locales/id/setting.json';
-import settingEn from './locales/en/setting.json';
+import settingId from './languages/id/setting.json';
+import settingEn from './languages/en/setting.json';
registerModuleNamespace('setting', {
id: settingId,
diff --git a/apps/web/src/apps/modules/system/setting/locales/en/setting.json b/apps/web/src/apps/modules/system/setting/languages/en/setting.json
similarity index 100%
rename from apps/web/src/apps/modules/system/setting/locales/en/setting.json
rename to apps/web/src/apps/modules/system/setting/languages/en/setting.json
diff --git a/apps/web/src/apps/modules/system/setting/locales/id/setting.json b/apps/web/src/apps/modules/system/setting/languages/id/setting.json
similarity index 100%
rename from apps/web/src/apps/modules/system/setting/locales/id/setting.json
rename to apps/web/src/apps/modules/system/setting/languages/id/setting.json
diff --git a/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts b/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts
index b37bc63..25fcb6e 100644
--- a/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts
+++ b/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts
@@ -75,7 +75,7 @@ export class BookingTransformer extends BaseDataTransformer {
return (
-
+
);
diff --git a/apps/web/src/core/environment/index.ts b/apps/web/src/core/environment/index.ts
index 768c715..2ca20df 100644
--- a/apps/web/src/core/environment/index.ts
+++ b/apps/web/src/core/environment/index.ts
@@ -3,9 +3,19 @@
* DO NOT use `import.meta.env` directly in components. Import this `ENV` object instead.
*/
export const ENV = {
- API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api',
- APP_ENV: (import.meta.env.VITE_APP_ENV || 'development') as 'development' | 'staging' | 'production',
+ DEFAULT_LANGUAGE: 'en',
+
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_BASE_URL: import.meta.env.VITE_COUCHDB_BASE_URL || 'http://localhost:5984',
diff --git a/apps/web/src/core/lib/api-client.ts b/apps/web/src/core/lib/api-client.ts
index 8bc1c9a..683c005 100644
--- a/apps/web/src/core/lib/api-client.ts
+++ b/apps/web/src/core/lib/api-client.ts
@@ -1,5 +1,7 @@
import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability';
+import { ENV } from '../environment';
+import { AppStorageKey, secureStorage } from '../storage/local';
/**
* Enterprise HTTP client for `apps/web`.
@@ -15,17 +17,24 @@ import { faroAdapter } from '@repo/core-api/observability';
*/
export const apiClient = createHttpClient(
{
- baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1',
+ baseURL: ENV.API_BASE_URL,
timeout: 15000,
observability: faroAdapter,
},
{
// ── Auth Interceptor ──────────────────────────────────────────
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(AppStorageKey.LANGUAGE);
+ config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE;
+
const token = localStorage.getItem('access_token');
- if (token) {
- config.headers.Authorization = `Bearer ${token}`;
- }
+ if (token) config.headers.Authorization = `Bearer ${token}`;
+
return config;
},
diff --git a/apps/web/src/core/storage/local/index.ts b/apps/web/src/core/storage/local/index.ts
index b455d43..6f38f55 100644
--- a/apps/web/src/core/storage/local/index.ts
+++ b/apps/web/src/core/storage/local/index.ts
@@ -2,7 +2,7 @@ import { createLocalStorage, createIndexedDB } from '@repo/core-storage';
export const AppStorageKey = {
USER_PROFILE: 'user_profile',
- LOCALE: 'app_locale',
+ LANGUAGE: 'app_language',
THEME: 'app_theme',
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
@@ -22,7 +22,7 @@ export const ENCRYPTED_KEYS = new Set([
]);
export const PLAIN_KEYS = new Set([
- AppStorageKey.LOCALE,
+ AppStorageKey.LANGUAGE,
AppStorageKey.THEME,
AppStorageKey.MOCK_DB_COMPANY_A,
AppStorageKey.OFFLINE_DRAFT,
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 34ec9a7..2c4e1d9 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -4,11 +4,11 @@
import { initTelemetry } from '@repo/core-api/observability/setup';
initTelemetry({
- appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web',
- appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0',
- telemetryUrl: import.meta.env.VITE_FARO_URL || 'https://telemetry.eigen.co.id/collect',
- otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || 'https://telemetry.eigen.co.id/v1/traces',
- environment: import.meta.env.VITE_ENV || 'development',
+ appName: ENV.APP_NAME,
+ appVersion: ENV.APP_VERSION,
+ telemetryUrl: ENV.FARO_URL,
+ otlpTraceUrl: ENV.OTLP_TRACE_URL,
+ environment: ENV.APP_ENV,
});
// ─── Application Bootstrap ──────────────────────────────────────
@@ -17,21 +17,25 @@ import { lazy, StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
import { secureStorage, AppStorageKey } from './core/storage/local';
+import { ENV } from './core/environment';
const App = lazy(() => import('./apps'));
async function bootstrap() {
// Initialize i18next and load language from secureStorage
- await setupI18n({
- storageAdapter: {
- getLanguage: async () => {
- return secureStorage.getItem(AppStorageKey.LOCALE);
- },
- setLanguage: async (lng: string) => {
- await secureStorage.setItem(AppStorageKey.LOCALE, lng);
+ await setupI18n(
+ {
+ storageAdapter: {
+ getLanguage: async () => {
+ return secureStorage.getItem(AppStorageKey.LANGUAGE);
+ },
+ setLanguage: async (lng: string) => {
+ await secureStorage.setItem(AppStorageKey.LANGUAGE, lng);
+ },
},
},
- });
+ ENV.DEFAULT_LANGUAGE,
+ );
createRoot(document.getElementById('app')!).render(
diff --git a/packages/core-api/src/data-services/base-remote.data-services.test.ts b/packages/core-api/src/data-services/base-remote.data-services.test.ts
index 91a15fa..0d694d2 100644
--- a/packages/core-api/src/data-services/base-remote.data-services.test.ts
+++ b/packages/core-api/src/data-services/base-remote.data-services.test.ts
@@ -294,7 +294,7 @@ class TestTransformer extends BaseDataTransformer {
transformToDTO(entity: TestEntity2): TestDTO {
return {
- id: entity.id,
+ id: entity.id as string,
booking_code: entity.bookingCode,
customer_name: entity.customerName,
};
diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/languages/en/common.json
similarity index 100%
rename from packages/core-i18n/src/locales/en/common.json
rename to packages/core-i18n/src/languages/en/common.json
diff --git a/packages/core-i18n/src/locales/en/validation.json b/packages/core-i18n/src/languages/en/validation.json
similarity index 100%
rename from packages/core-i18n/src/locales/en/validation.json
rename to packages/core-i18n/src/languages/en/validation.json
diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/languages/id/common.json
similarity index 100%
rename from packages/core-i18n/src/locales/id/common.json
rename to packages/core-i18n/src/languages/id/common.json
diff --git a/packages/core-i18n/src/locales/id/validation.json b/packages/core-i18n/src/languages/id/validation.json
similarity index 100%
rename from packages/core-i18n/src/locales/id/validation.json
rename to packages/core-i18n/src/languages/id/validation.json
diff --git a/packages/core-i18n/src/setup.ts b/packages/core-i18n/src/setup.ts
index c7bcc77..cc1a42f 100644
--- a/packages/core-i18n/src/setup.ts
+++ b/packages/core-i18n/src/setup.ts
@@ -1,11 +1,11 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
-import commonEn from './locales/en/common.json';
-import commonId from './locales/id/common.json';
-import validationEn from './locales/en/validation.json';
-import validationId from './locales/id/validation.json';
+import commonEn from './languages/en/common.json';
+import commonId from './languages/id/common.json';
+import validationEn from './languages/en/validation.json';
+import validationId from './languages/id/validation.json';
-const DEFAULT_LANGUAGE = 'id';
+const DEFAULT_LANGUAGE = 'en';
const SUPPORTED_LANGUAGES = ['en', 'id'] as const;
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.
*/
-export async function setupI18n(config: I18nConfig = {}): Promise {
+export async function setupI18n(config: I18nConfig = {}, defaultLanguage?: string): Promise {
globalStorageAdapter = config.storageAdapter;
- let initialLng = DEFAULT_LANGUAGE;
+ let initialLng = defaultLanguage ? defaultLanguage : DEFAULT_LANGUAGE;
try {
if (globalStorageAdapter) {
const storedLng = await globalStorageAdapter.getLanguage();
diff --git a/packages/core-storage/src/tests/storage.test.ts b/packages/core-storage/src/tests/storage.test.ts
index f3a34cc..25d6fc1 100644
--- a/packages/core-storage/src/tests/storage.test.ts
+++ b/packages/core-storage/src/tests/storage.test.ts
@@ -48,7 +48,7 @@ Object.defineProperty(globalThis, 'localStorage', {
const TestStorageKey = {
THEME: 'theme',
- LOCALE: 'locale',
+ LANGUAGE: 'language',
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
USER_PROFILE: 'user_profile',
@@ -62,7 +62,7 @@ const ENCRYPTED_KEYS = new Set([
TestStorageKey.USER_PROFILE,
]);
-const PLAIN_KEYS = new Set([TestStorageKey.THEME, TestStorageKey.LOCALE]);
+const PLAIN_KEYS = new Set([TestStorageKey.THEME, TestStorageKey.LANGUAGE]);
interface TestUser {
id: number;
@@ -108,10 +108,10 @@ describe('LocalStorageService', () => {
});
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(mockLocalStorage.setItem).toHaveBeenCalledWith(TestStorageKey.LOCALE, '"en-US"');
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(TestStorageKey.LANGUAGE, '"en-US"');
});
it('encrypts sensitive keys (ACCESS_TOKEN)', async () => {
@@ -194,7 +194,7 @@ describe('LocalStorageService', () => {
describe('clear', () => {
it('clears all keys from storage', async () => {
await storage.setItem(TestStorageKey.THEME, 'dark');
- await storage.setItem(TestStorageKey.LOCALE, 'en');
+ await storage.setItem(TestStorageKey.LANGUAGE, 'en');
await storage.clear();
expect(mockLocalStorage.clear).toHaveBeenCalled();
@@ -220,11 +220,11 @@ describe('LocalStorageService', () => {
describe('keys', () => {
it('returns all stored keys', async () => {
await storage.setItem(TestStorageKey.THEME, 'dark');
- await storage.setItem(TestStorageKey.LOCALE, 'en');
+ await storage.setItem(TestStorageKey.LANGUAGE, 'en');
const allKeys = await storage.keys();
expect(allKeys).toContain(TestStorageKey.THEME);
- expect(allKeys).toContain(TestStorageKey.LOCALE);
+ expect(allKeys).toContain(TestStorageKey.LANGUAGE);
expect(allKeys).toHaveLength(2);
});
});
@@ -248,8 +248,8 @@ describe('LocalStorageService', () => {
expect(ENCRYPTED_KEYS.has(TestStorageKey.THEME)).toBe(false);
});
- it('LOCALE is NOT in ENCRYPTED_KEYS', () => {
- expect(ENCRYPTED_KEYS.has(TestStorageKey.LOCALE)).toBe(false);
+ it('LANGUAGE is NOT in ENCRYPTED_KEYS', () => {
+ expect(ENCRYPTED_KEYS.has(TestStorageKey.LANGUAGE)).toBe(false);
});
});
});