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); }); }); });