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:
Vendored
+1
-1
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
A highly decoupled, type-safe internationalization engine for the monorepo.
|
A highly decoupled, type-safe internationalization engine for the monorepo.
|
||||||
|
|
||||||
It uses a **Hybrid Namespace Strategy**:
|
It uses a **Hybrid Namespace Strategy**:
|
||||||
|
|
||||||
1. **Centralized Engine**: Setup, local persistence orchestration, and global words (`common`).
|
1. **Centralized Engine**: Setup, local persistence orchestration, and global words (`common`).
|
||||||
2. **Decentralized Dictionaries**: Feature-specific translations (`booking`, `billing`) live inside the application modules and are lazy-loaded.
|
2. **Decentralized Dictionaries**: Feature-specific translations (`booking`, `billing`) live inside the application modules and are lazy-loaded.
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ graph TD
|
|||||||
|
|
||||||
## 1. App-Level Setup (Bootstrap)
|
## 1. App-Level Setup (Bootstrap)
|
||||||
|
|
||||||
Initialize the engine *before* your React application mounts to prevent UI flashing. Provide an `I18nStorageAdapter` using Dependency Injection so the core engine can persist the user's language without being tightly coupled to a specific storage implementation.
|
Initialize the engine _before_ your React application mounts to prevent UI flashing. Provide an `I18nStorageAdapter` using Dependency Injection so the core engine can persist the user's language without being tightly coupled to a specific storage implementation.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// apps/web/src/main.tsx
|
// apps/web/src/main.tsx
|
||||||
@@ -83,17 +84,19 @@ async function bootstrap() {
|
|||||||
await setupI18n({
|
await setupI18n({
|
||||||
storageAdapter: {
|
storageAdapter: {
|
||||||
getLanguage: async () => {
|
getLanguage: async () => {
|
||||||
const stored = await secureStorage.getItem(AppStorageKey.LOCALE);
|
const stored = await secureStorage.getItem(AppStorageKey.LANGUAGE);
|
||||||
return typeof stored === 'string' ? stored : null;
|
return typeof stored === 'string' ? stored : null;
|
||||||
},
|
},
|
||||||
setLanguage: async (lng: string) => {
|
setLanguage: async (lng: string) => {
|
||||||
await secureStorage.setItem(AppStorageKey.LOCALE, lng);
|
await secureStorage.setItem(AppStorageKey.LANGUAGE, lng);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById('app')!).render(
|
createRoot(document.getElementById('app')!).render(
|
||||||
<StrictMode><App /></StrictMode>,
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
@@ -106,6 +109,7 @@ bootstrap();
|
|||||||
Dictionaries live right next to the UI components that use them.
|
Dictionaries live right next to the UI components that use them.
|
||||||
|
|
||||||
### Folder Structure
|
### Folder Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
apps/web/src/apps/modules/booking/
|
apps/web/src/apps/modules/booking/
|
||||||
├── presentation/BookingTable.tsx
|
├── presentation/BookingTable.tsx
|
||||||
@@ -138,11 +142,7 @@ registerModuleNamespace(BookingModuleConfig.translationNamespace, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function BookingModule() {
|
export default function BookingModule() {
|
||||||
return (
|
return <EnterpriseModuleProvider config={BookingModuleConfig}>{/* Routes and Pages */}</EnterpriseModuleProvider>;
|
||||||
<EnterpriseModuleProvider config={BookingModuleConfig}>
|
|
||||||
{/* Routes and Pages */}
|
|
||||||
</EnterpriseModuleProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ import bookingEn from '../apps/modules/booking/locales/en/booking.json';
|
|||||||
declare module 'react-i18next' {
|
declare module 'react-i18next' {
|
||||||
interface CustomTypeOptions {
|
interface CustomTypeOptions {
|
||||||
defaultNS: 'common';
|
defaultNS: 'common';
|
||||||
resources: typeof coreResources['en'] & { booking: typeof bookingEn };
|
resources: (typeof coreResources)['en'] & { booking: typeof bookingEn };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -168,8 +168,7 @@ declare module 'react-i18next' {
|
|||||||
|
|
||||||
Inside your pages and components, use `useEnterpriseModuleTranslationContext()` instead of the raw `useTranslation` hook. The context automatically scopes the `t` function to your module's namespace and the global `common` namespace.
|
Inside your pages and components, use `useEnterpriseModuleTranslationContext()` instead of the raw `useTranslation` hook. The context automatically scopes the `t` function to your module's namespace and the global `common` namespace.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING] > **Best Practice:** Do not hardcode the module namespace prefix (e.g., `t('booking:header.title')`) when using `useEnterpriseModuleTranslationContext`. The provider already scopes it. Simply use `t('header.title')`. You can still access global keys using the common prefix: `t('common:edit')`.
|
||||||
> **Best Practice:** Do not hardcode the module namespace prefix (e.g., `t('booking:header.title')`) when using `useEnterpriseModuleTranslationContext`. The provider already scopes it. Simply use `t('header.title')`. You can still access global keys using the common prefix: `t('common:edit')`.
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// apps/web/src/apps/modules/booking/presentation/pages/booking.page.index.tsx
|
// apps/web/src/apps/modules/booking/presentation/pages/booking.page.index.tsx
|
||||||
@@ -189,6 +188,7 @@ export default function BookingPageIndex() {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**4. Dynamic Variables (Interpolation):**
|
**4. Dynamic Variables (Interpolation):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// booking.json
|
// booking.json
|
||||||
{
|
{
|
||||||
@@ -197,6 +197,7 @@ export default function BookingPageIndex() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Inside component
|
// Inside component
|
||||||
<h1>{t('messages.welcome', { name: 'Firman', count: 5 })}</h1>
|
<h1>{t('messages.welcome', { name: 'Firman', count: 5 })}</h1>
|
||||||
@@ -242,12 +243,9 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
// 2. Inject into the engine.
|
// 2. Inject into the engine.
|
||||||
// `deep: true` ensures only provided keys are overridden.
|
// `deep: true` ensures only provided keys are overridden.
|
||||||
applyTenantOverrides(
|
applyTenantOverrides(response.data.namespace, response.data.overrides);
|
||||||
response.data.namespace,
|
|
||||||
response.data.overrides
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch tenant configuration", err);
|
console.error('Failed to fetch tenant configuration', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +277,7 @@ const handleSwitch = async (newLng: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> For public pages (like `apps/landing`), simply call `changeLanguage('en')` without the callback function. It will update the UI and local storage instantly without hitting the network.
|
> For public pages (like `apps/landing`), simply call `changeLanguage('en')` without the callback function. It will update the UI and local storage instantly without hitting the network.
|
||||||
|
|
||||||
@@ -289,18 +288,21 @@ const handleSwitch = async (newLng: string) => {
|
|||||||
To support Dynamic Tenant Overrides, the backend must expose an endpoint (e.g., `GET /v1/tenant/i18n-config`).
|
To support Dynamic Tenant Overrides, the backend must expose an endpoint (e.g., `GET /v1/tenant/i18n-config`).
|
||||||
|
|
||||||
### Identification
|
### Identification
|
||||||
|
|
||||||
The backend **MUST identify the tenant via the `Authorization` (JWT) header**. The frontend will not send `tenantId` in the query payload to prevent spoofing.
|
The backend **MUST identify the tenant via the `Authorization` (JWT) header**. The frontend will not send `tenantId` in the query payload to prevent spoofing.
|
||||||
|
|
||||||
### Expected JSON Response Format
|
### Expected JSON Response Format
|
||||||
|
|
||||||
The response must match the structural shape of the frontend dictionary. Because the frontend uses a **Deep Merge** strategy, the backend **only needs to return the specific keys the tenant wishes to override**.
|
The response must match the structural shape of the frontend dictionary. Because the frontend uses a **Deep Merge** strategy, the backend **only needs to return the specific keys the tenant wishes to override**.
|
||||||
|
|
||||||
If the frontend dictionary has `header.title` and `header.subtitle`, and the backend only sends `header.title`, the `subtitle` will safely remain intact.
|
If the frontend dictionary has `header.title` and `header.subtitle`, and the backend only sends `header.title`, the `subtitle` will safely remain intact.
|
||||||
|
|
||||||
**Example Request:**
|
**Example Request:**
|
||||||
`GET /v1/tenant/i18n-config`
|
`GET /v1/tenant/i18n-config`
|
||||||
*(Authorization: Bearer eyJhbG...)*
|
_(Authorization: Bearer eyJhbG...)_
|
||||||
|
|
||||||
**Expected Response (200 OK):**
|
**Expected Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
+5
-2
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,7 +2,7 @@ import { createLocalStorage, createIndexedDB } from '@repo/core-storage';
|
|||||||
|
|
||||||
export const AppStorageKey = {
|
export const AppStorageKey = {
|
||||||
USER_PROFILE: 'user_profile',
|
USER_PROFILE: 'user_profile',
|
||||||
LOCALE: 'app_locale',
|
LANGUAGE: 'app_language',
|
||||||
THEME: 'app_theme',
|
THEME: 'app_theme',
|
||||||
ACCESS_TOKEN: 'access_token',
|
ACCESS_TOKEN: 'access_token',
|
||||||
REFRESH_TOKEN: 'refresh_token',
|
REFRESH_TOKEN: 'refresh_token',
|
||||||
@@ -22,7 +22,7 @@ export const ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export const PLAIN_KEYS = new Set<AppStorageKeyValue>([
|
export const PLAIN_KEYS = new Set<AppStorageKeyValue>([
|
||||||
AppStorageKey.LOCALE,
|
AppStorageKey.LANGUAGE,
|
||||||
AppStorageKey.THEME,
|
AppStorageKey.THEME,
|
||||||
AppStorageKey.MOCK_DB_COMPANY_A,
|
AppStorageKey.MOCK_DB_COMPANY_A,
|
||||||
AppStorageKey.OFFLINE_DRAFT,
|
AppStorageKey.OFFLINE_DRAFT,
|
||||||
|
|||||||
+13
-9
@@ -4,11 +4,11 @@
|
|||||||
import { initTelemetry } from '@repo/core-api/observability/setup';
|
import { initTelemetry } from '@repo/core-api/observability/setup';
|
||||||
|
|
||||||
initTelemetry({
|
initTelemetry({
|
||||||
appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web',
|
appName: ENV.APP_NAME,
|
||||||
appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0',
|
appVersion: ENV.APP_VERSION,
|
||||||
telemetryUrl: import.meta.env.VITE_FARO_URL || 'https://telemetry.eigen.co.id/collect',
|
telemetryUrl: ENV.FARO_URL,
|
||||||
otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || 'https://telemetry.eigen.co.id/v1/traces',
|
otlpTraceUrl: ENV.OTLP_TRACE_URL,
|
||||||
environment: import.meta.env.VITE_ENV || 'development',
|
environment: ENV.APP_ENV,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Application Bootstrap ──────────────────────────────────────
|
// ─── Application Bootstrap ──────────────────────────────────────
|
||||||
@@ -17,21 +17,25 @@ import { lazy, StrictMode } from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { setupI18n } from '@repo/core-i18n';
|
import { setupI18n } from '@repo/core-i18n';
|
||||||
import { secureStorage, AppStorageKey } from './core/storage/local';
|
import { secureStorage, AppStorageKey } from './core/storage/local';
|
||||||
|
import { ENV } from './core/environment';
|
||||||
|
|
||||||
const App = lazy(() => import('./apps'));
|
const App = lazy(() => import('./apps'));
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
// Initialize i18next and load language from secureStorage
|
// Initialize i18next and load language from secureStorage
|
||||||
await setupI18n({
|
await setupI18n(
|
||||||
|
{
|
||||||
storageAdapter: {
|
storageAdapter: {
|
||||||
getLanguage: async () => {
|
getLanguage: async () => {
|
||||||
return secureStorage.getItem<string>(AppStorageKey.LOCALE);
|
return secureStorage.getItem<string>(AppStorageKey.LANGUAGE);
|
||||||
},
|
},
|
||||||
setLanguage: async (lng: string) => {
|
setLanguage: async (lng: string) => {
|
||||||
await secureStorage.setItem(AppStorageKey.LOCALE, lng);
|
await secureStorage.setItem(AppStorageKey.LANGUAGE, lng);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
ENV.DEFAULT_LANGUAGE,
|
||||||
|
);
|
||||||
|
|
||||||
createRoot(document.getElementById('app')!).render(
|
createRoot(document.getElementById('app')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ class TestTransformer extends BaseDataTransformer<TestEntity2, TestDTO> {
|
|||||||
|
|
||||||
transformToDTO(entity: TestEntity2): TestDTO {
|
transformToDTO(entity: TestEntity2): TestDTO {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id as string,
|
||||||
booking_code: entity.bookingCode,
|
booking_code: entity.bookingCode,
|
||||||
customer_name: entity.customerName,
|
customer_name: entity.customerName,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user