feat: implement core-i18n package with decentralized namespace support and integrate into landing and web apps

This commit is contained in:
Firman Ramdhani
2026-05-22 23:30:41 +07:00
parent 255704f867
commit 4655362ff4
25 changed files with 932 additions and 13 deletions
+23 -3
View File
@@ -35,6 +35,7 @@ The monorepo is organized into **Apps** (deployable applications) and **Packages
├── packages/
│ ├── core-api/ # Shared HTTP Client, Observability & Data Services Engine
│ ├── core-storage/ # Enterprise Storage Engine (IndexedDB/localStorage + Encryption)
│ ├── core-i18n/ # Enterprise Internationalization Architecture
│ ├── ui/ # Shared UI Component Library
│ ├── utils/ # Shared Utilities (Date, Encryption, Core Logic, etc)
│ └── configs/ # Shared Tooling Configurations
@@ -249,7 +250,26 @@ Provides a unified, Promise-based interface for interacting with browser storage
---
### 7. `packages/utils`
### 7. `packages/core-i18n`
The **Enterprise Internationalization Architecture** for the monorepo.
Provides a Hybrid Namespace Architecture combining a centralized i18n engine with decentralized, lazy-loaded feature dictionaries. Features strict TypeScript typings (including nested keys), optional backend synchronization with automatic error rollbacks, and a deep-merge mechanism for dynamic tenant-specific vocabulary overrides.
**Key Capabilities**:
| Feature | Description |
|---|---|
| 🌐 Hybrid Namespaces | Centralized `common` corpus + lazy-loaded feature dictionaries. |
| 🛡️ Strict Typings | Native TS autocomplete for nested paths (e.g., `header.title`) via module augmentation. |
| 🔄 Safe Backend Sync | `changeLanguage` accepts a `syncCallback` with built-in rollback if the API fails. |
| 🏢 Tenant Overrides | `applyTenantOverrides` performs a partial deep-merge to selectively override terminology. |
**Documentation**: [README.md](packages/core-i18n/README.md)
---
### 8. `packages/utils`
Shared business logic and reusable utility modules that can be consumed across multiple applications. Fully tested using Vitest.
@@ -257,7 +277,7 @@ This package is intended to hold non-UI, cross-cutting logic such as date/time h
---
### 8. `packages/ui`
### 9. `packages/ui`
Shared UI component library (Buttons, Inputs, Cards, Layouts).
@@ -266,7 +286,7 @@ Shared UI component library (Buttons, Inputs, Cards, Layouts).
---
### 9. `packages/configs`
### 10. `packages/configs`
Single source of truth for tooling configuration.
+3
View File
@@ -12,11 +12,14 @@
},
"dependencies": {
"@repo/core-api": "workspace:*",
"@repo/core-i18n": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
"i18next": "^24.2.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-i18next": "^15.4.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
+2
View File
@@ -1,6 +1,7 @@
import { ThemeProvider } from '@repo/ui/provider';
import { Button } from '@repo/ui/components';
import LandingSample from './features/public-content/presentation/LandingSample';
import I18nLandingSample from './presentation/I18nLandingSample';
export default function App() {
return (
@@ -36,6 +37,7 @@ export default function App() {
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
<LandingSample />
</div>
<I18nLandingSample />
</div>
</ThemeProvider>
);
+4
View File
@@ -0,0 +1,4 @@
{
"welcome": "Welcome to Our Product",
"cta": "Get Started Now"
}
+4
View File
@@ -0,0 +1,4 @@
{
"welcome": "Selamat Datang di Produk Kami",
"cta": "Mulai Sekarang"
}
+12 -5
View File
@@ -15,10 +15,17 @@ initTelemetry({
import './main.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
import App from './app';
createRoot(document.getElementById('app')!).render(
<StrictMode>
<App />
</StrictMode>,
);
async function bootstrap() {
await setupI18n();
createRoot(document.getElementById('app')!).render(
<StrictMode>
<App />
</StrictMode>,
);
}
bootstrap();
@@ -0,0 +1,50 @@
import { useEffect } from 'react';
import { useTranslation, changeLanguage, i18n } from '@repo/core-i18n';
// Decentralized locale imports
import homeId from '../locales/id/home.json';
import homeEn from '../locales/en/home.json';
export default function I18nLandingSample() {
const { t } = useTranslation(['common', 'home']);
// 1. Lazy-load the 'home' namespace when the module mounts
useEffect(() => {
i18n.addResourceBundle('id', 'home', homeId, true, false);
i18n.addResourceBundle('en', 'home', homeEn, true, false);
}, []);
// 2. Change language without syncCallback to prove decoupling
const setLanguage = (lng: string) => {
// We intentionally omit the second argument (syncCallback)
// because the landing page is public and doesn't need backend syncing.
changeLanguage(lng).catch(console.error);
};
return (
<div style={{ padding: 40, textAlign: 'center', backgroundColor: '#f8fafc', color: '#0f172a' }}>
<h1 style={{ fontSize: 36, fontWeight: 'bold', marginBottom: 16 }}>
{t('home:welcome')}
</h1>
<div style={{ marginBottom: 32 }}>
<button
onClick={() => setLanguage('id')}
style={{ padding: '8px 16px', marginRight: 8, cursor: 'pointer', borderRadius: 4, background: '#3b82f6', color: 'white', border: 'none' }}
>
Bahasa Indonesia
</button>
<button
onClick={() => setLanguage('en')}
style={{ padding: '8px 16px', cursor: 'pointer', borderRadius: 4, background: '#3b82f6', color: 'white', border: 'none' }}
>
English
</button>
</div>
<button style={{ padding: '16px 32px', fontSize: 18, fontWeight: 'bold', cursor: 'pointer', borderRadius: 8, background: '#10b981', color: 'white', border: 'none' }}>
{t('home:cta')}
</button>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import 'react-i18next';
// Import the core types so we don't break the common namespace
import type { resources as coreResources } from '@repo/core-i18n/src/setup';
import homeEn from '../locales/en/home.json';
// Combine core resources with app-specific decentralized resources
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: typeof coreResources['en'] & {
home: typeof homeEn;
};
}
}
+3
View File
@@ -14,13 +14,16 @@
},
"dependencies": {
"@repo/core-api": "workspace:*",
"@repo/core-i18n": "workspace:*",
"@repo/core-storage": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
"dayjs": "^1.11.19",
"i18next": "^24.2.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-i18next": "^15.4.0",
"react-router-dom": "^7.11.0",
"tailwindcss": "^4.1.18"
},
@@ -1,5 +1,6 @@
import BookingSample from "./features/booking/presentation/BookingSample";
import StorageSample from "./features/storage/presentation/StorageSample";
import I18nSample from "./features/i18n/presentation/I18nSample";
export default function ExamplePage() {
return <div className="bg-amber-200">example
@@ -11,5 +12,8 @@ export default function ExamplePage() {
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
<StorageSample />
</div>
<div className="p-8 bg-slate-900">
<I18nSample />
</div>
</div>;
}
@@ -0,0 +1,8 @@
{
"module_name": "Purchasing",
"select_date": "Select Date",
"header": {
"title": "Transaction List",
"subtitle": "Manage all your transactions here"
}
}
@@ -0,0 +1,8 @@
{
"module_name": "Pembelanjaan",
"select_date": "Pilih Tanggal",
"header": {
"title": "Daftar Transaksi",
"subtitle": "Kelola semua transaksi Anda di sini"
}
}
@@ -0,0 +1,308 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n';
import { demoIndexedDB } from '@repo/core-storage';
// Decentralized locale imports
import bookingId from '../locales/id/booking.json';
import bookingEn from '../locales/en/booking.json';
// ─── Shared Styles ──────────────────────────────────────────────
const sectionStyle = {
marginTop: 24,
padding: 24,
border: '1px solid #334155',
borderRadius: 8,
background: '#0f172a',
};
const btnStyle = (color: string) => ({
padding: '8px 16px',
fontSize: 14,
fontWeight: 600 as const,
cursor: 'pointer' as const,
background: color,
color: '#fff',
border: 'none',
borderRadius: 6,
marginRight: 8,
});
// ─── Component ──────────────────────────────────────────────────
export default function I18nSample() {
const { t } = useTranslation(['common', 'booking']);
const [syncStatus, setSyncStatus] = useState<string>('');
const [activeTenant, setActiveTenant] = useState<string>('default');
const [isFetchingConfig, setIsFetchingConfig] = useState(false);
// ─── Admin Panel State ──────────────────────────────────────────
const [adminModuleName, setAdminModuleName] = useState('PENGELUARAN');
const [adminHeaderTitle, setAdminHeaderTitle] = useState('Daftar Pengeluaran');
const [dbPayloadStr, setDbPayloadStr] = useState<string>('No data in DB');
const MOCK_DB_KEY = 'mock_db_company_a';
const loadDbPayload = useCallback(async () => {
try {
const data = await demoIndexedDB.getItem<any>(MOCK_DB_KEY);
setDbPayloadStr(data ? JSON.stringify(data, null, 2) : 'No data in DB');
} catch (e) {
setDbPayloadStr('Error reading DB');
}
}, []);
useEffect(() => {
loadDbPayload();
}, [loadDbPayload]);
const handleAdminSave = async () => {
const payload = {
namespace: 'booking',
overrides: {
module_name: adminModuleName,
header: { title: adminHeaderTitle }
}
};
await demoIndexedDB.setItem(MOCK_DB_KEY, payload);
setSyncStatus('✅ Saved tenant config to IndexedDB!');
await loadDbPayload();
};
// ─── Mock API ───────────────────────────────────────────────────
const mockFetchTenantConfig = async (companyId: string): Promise<any> => {
if (companyId === 'company-a') {
const data = await demoIndexedDB.getItem<any>(MOCK_DB_KEY);
if (!data) {
throw new Error('Company A config not found in DB. Please save via Admin Panel first.');
}
return data;
} else if (companyId === 'company-b') {
// Hardcoded fallback for B
return {
namespace: 'booking',
overrides: {
module_name: 'PROCUREMENT (B)',
header: { title: 'Procurement List (B)' }
}
};
}
throw new Error('Unknown company');
};
// 1. Lazy-load the 'booking' namespace when the module mounts
useEffect(() => {
// Check if it's already loaded to prevent duplicate work, but for safety:
i18n.addResourceBundle('id', 'booking', bookingId, true, false);
i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
}, []);
// ─── Section A: Language Switcher ──────────────────────────────
const handleLanguageChange = async (newLng: string, shouldFail: boolean = false) => {
setSyncStatus('Syncing with backend...');
try {
await changeLanguage(newLng, async (lng, _prevLng) => {
// Mock API Call
await new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error('Mock API 500: Failed to save preference'));
} else {
resolve(true);
}
}, 1000);
});
// If success
setSyncStatus(`✅ Successfully synced language '${lng}' to backend.`);
});
} catch (error) {
setSyncStatus(`❌ Rollback triggered: ${error instanceof Error ? error.message : String(error)}`);
}
};
// ─── Section B: Tenant Overrides (Real-World Flow) ─────────────
const handleSimulateLogin = async (companyId: string) => {
setIsFetchingConfig(true);
setActiveTenant(companyId);
try {
// 1. App successfully authenticates and fetches config
const config = await mockFetchTenantConfig(companyId);
// 2. Inject the deep-merge payload returned from the server
// In a real app, you might apply this to the current active language or all languages.
applyTenantOverrides(config.namespace, config.overrides, 'id');
applyTenantOverrides(config.namespace, config.overrides, 'en');
} catch (err) {
console.error('Failed to fetch config', err);
} finally {
setIsFetchingConfig(false);
}
};
const resetTenant = () => {
// To reset, we just reload the original bundles
i18n.addResourceBundle('id', 'booking', bookingId, true, true);
i18n.addResourceBundle('en', 'booking', bookingEn, true, true);
setActiveTenant('default');
};
return (
<div style={{ fontFamily: 'sans-serif', maxWidth: 800, color: '#f8fafc' }}>
<h2 style={{ fontSize: 24, fontWeight: 'bold' }}>🌐 Enterprise i18n Demo</h2>
<p style={{ color: '#94a3b8' }}>
Current Active Language: <strong style={{ color: '#38bdf8' }}>{i18n.language}</strong>
</p>
{/* ─── Admin Panel ──────────────────────────────────────────── */}
<div style={sectionStyle}>
<h3 style={{ fontSize: 18, marginBottom: 16, color: '#fbbf24' }}>Admin Panel (Company A Config)</h3>
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
Simulate a backend CMS. Save the vocabulary overrides to IndexedDB.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
<label style={{ fontSize: 14 }}>
<span style={{ display: 'inline-block', width: 120 }}>Module Name:</span>
<input
type="text"
value={adminModuleName}
onChange={(e) => setAdminModuleName(e.target.value)}
style={{ padding: 6, borderRadius: 4, background: '#1e293b', border: '1px solid #475569', color: '#fff', width: 250 }}
/>
</label>
<label style={{ fontSize: 14 }}>
<span style={{ display: 'inline-block', width: 120 }}>Header Title:</span>
<input
type="text"
value={adminHeaderTitle}
onChange={(e) => setAdminHeaderTitle(e.target.value)}
style={{ padding: 6, borderRadius: 4, background: '#1e293b', border: '1px solid #475569', color: '#fff', width: 250 }}
/>
</label>
</div>
<button onClick={handleAdminSave} style={btnStyle('#d97706')}>
Save to Database (IndexedDB)
</button>
<div style={{ marginTop: 16, padding: 12, background: '#1e293b', borderRadius: 6 }}>
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 4 }}>Raw JSON in DB:</div>
<pre style={{ margin: 0, fontSize: 12, color: '#a7f3d0' }}>
<code>{dbPayloadStr}</code>
</pre>
</div>
</div>
{/* ─── Section A ────────────────────────────────────────────── */}
<div style={sectionStyle}>
<h3 style={{ fontSize: 18, marginBottom: 16 }}>A. Language Switcher & Backend Sync</h3>
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
Change the language. The callback simulates a 1-second backend API request.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={() => handleLanguageChange('id')} style={btnStyle('#0284c7')}>
ID (Lokal & Sync)
</button>
<button onClick={() => handleLanguageChange('en')} style={btnStyle('#0284c7')}>
EN (Lokal & Sync)
</button>
<button onClick={() => handleLanguageChange('en', true)} style={btnStyle('#dc2626')}>
Force Error (Test Rollback)
</button>
</div>
{syncStatus && (
<div
style={{
marginTop: 16,
padding: 12,
background: '#1e293b',
borderRadius: 6,
fontSize: 14,
}}
>
{syncStatus}
</div>
)}
</div>
{/* ─── Section B ────────────────────────────────────────────── */}
<div style={sectionStyle}>
<h3 style={{ fontSize: 18, marginBottom: 16 }}>B. Dynamic Tenant Overrides (End-to-End)</h3>
<p style={{ fontSize: 14, color: '#94a3b8', marginBottom: 16 }}>
Simulates a user logging in. It fetches the config directly from IndexedDB (mock database) and applies the deep-merge override.
</p>
<div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
<button
onClick={resetTenant}
style={btnStyle(activeTenant === 'default' ? '#16a34a' : '#475569')}
>
Default Company
</button>
<button
onClick={() => handleSimulateLogin('company-a')}
style={btnStyle(activeTenant === 'company-a' ? '#16a34a' : '#475569')}
disabled={isFetchingConfig}
>
Simulate Login as Company A
</button>
<button
onClick={() => handleSimulateLogin('company-b')}
style={btnStyle(activeTenant === 'company-b' ? '#16a34a' : '#475569')}
disabled={isFetchingConfig}
>
Simulate Login as Company B
</button>
</div>
{isFetchingConfig && (
<div style={{ marginBottom: 16, color: '#fbbf24', fontSize: 14 }}>
Fetching tenant config...
</div>
)}
{/* Display the localized strings */}
<div style={{ background: '#1e293b', padding: 16, borderRadius: 8 }}>
<h4 style={{ color: '#cbd5e1', marginBottom: 12 }}>UI Result:</h4>
<table style={{ width: '100%', textAlign: 'left', borderCollapse: 'collapse' }}>
<tbody>
<tr style={{ borderBottom: '1px solid #334155' }}>
<th style={{ padding: 8, color: '#94a3b8' }}>Key</th>
<th style={{ padding: 8, color: '#94a3b8' }}>Value</th>
</tr>
{/* Type-safe keys from the common and booking namespaces */}
<tr style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: 8 }}><code>booking:module_name</code></td>
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:module_name')}</td>
</tr>
<tr style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: 8 }}><code>booking:header.title</code></td>
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.title')}</td>
</tr>
<tr style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: 8 }}><code>booking:header.subtitle</code></td>
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:header.subtitle')}</td>
</tr>
<tr style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: 8 }}><code>booking:select_date</code></td>
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('booking:select_date')}</td>
</tr>
<tr>
<td style={{ padding: 8 }}><code>common:save</code></td>
<td style={{ padding: 8, fontWeight: 'bold' }}>{t('common:save')}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
+14 -5
View File
@@ -15,10 +15,19 @@ initTelemetry({
import './main.css';
import { lazy, StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
const App = lazy(() => import('./apps'));
createRoot(document.getElementById('app')!).render(
<StrictMode>
<App />
</StrictMode>,
);
async function bootstrap() {
// Initialize i18next and load language from secureStorage
await setupI18n();
createRoot(document.getElementById('app')!).render(
<StrictMode>
<App />
</StrictMode>,
);
}
bootstrap();
+14
View File
@@ -0,0 +1,14 @@
import 'react-i18next';
// Import the core types so we don't break the common namespace
import type { resources as coreResources } from '@repo/core-i18n/src/setup';
import bookingEn from '../apps/modules/example/features/i18n/locales/en/booking.json';
// Combine core resources with app-specific decentralized resources
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: typeof coreResources['en'] & {
booking: typeof bookingEn;
};
}
}
+183
View File
@@ -0,0 +1,183 @@
# Enterprise i18n Architecture (`@repo/core-i18n`)
A highly decoupled, type-safe internationalization engine for the Eigen Monorepo.
It uses a **Hybrid Namespace Strategy**:
1. **Centralized Engine**: Setup, local persistence (`@repo/core-storage`), and global words (`common`).
2. **Decentralized Dictionaries**: Feature-specific translations (`booking`, `billing`) live inside the application modules and are lazy-loaded.
This architecture strictly adheres to **Inversion of Control (IoC)**. The core engine handles local state and performance, but leaves API and networking decisions entirely to the consuming applications.
---
## 1. App-Level Setup (Bootstrap)
Initialize the engine *before* your React application mounts to prevent UI flashing.
```tsx
// apps/web/src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
import App from './app';
async function bootstrap() {
// Synchronously reads preferred language from storage & inits i18next
await setupI18n();
createRoot(document.getElementById('app')!).render(
<StrictMode><App /></StrictMode>,
);
}
bootstrap();
```
---
## 2. Module-Level Setup (Decentralized Dictionaries)
Dictionaries live right next to the UI components that use them.
### Folder Structure
```text
apps/web/src/apps/modules/booking/
├── presentation/BookingTable.tsx
└── locales/
├── id/booking.json
└── en/booking.json
```
### Lazy Loading & Type Safety
Register the namespace when the component mounts. To get native TypeScript autocomplete for nested keys (e.g., `header.title`), augment the global `react-i18next` types.
**1. Augment Types:**
```ts
// apps/web/src/types/i18next.d.ts
import 'react-i18next';
import type { resources as coreResources } from '@repo/core-i18n/src/setup';
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 };
}
}
```
**2. Use in Component:**
```tsx
import { useEffect } from 'react';
import { i18n, useTranslation } from '@repo/core-i18n';
import bookingId from '../locales/id/booking.json';
import bookingEn from '../locales/en/booking.json';
export default function BookingFeature() {
const { t } = useTranslation(['common', 'booking']);
useEffect(() => {
i18n.addResourceBundle('id', 'booking', bookingId, true, false);
i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
}, []);
return <h1>{t('booking:header.title')}</h1>; // Autocomplete works!
}
```
---
## 3. Real-World Implementation Flow
The engine supports robust flows for authenticated apps, including Tenant Vocabulary Overrides and Backend Synchronization.
### A. The Tenant Override Flow (After Login)
If "Company A" calls "Purchasing" -> "Procurement", they shouldn't need a custom build. The backend returns an override config, and the frontend dynamically merges it using `applyTenantOverrides`.
```tsx
// Example inside an AuthProvider or Post-Login useEffect
import { useEffect } from 'react';
import { applyTenantOverrides } from '@repo/core-i18n';
import { api } from '@/api';
export function AuthProvider({ children }) {
useEffect(() => {
async function fetchTenantConfig() {
try {
// 1. Fetch tenant-specific overrides from the API
const response = await api.get('/v1/tenant/i18n-config');
// 2. Inject into the engine.
// `deep: true` ensures only provided keys are overridden.
applyTenantOverrides(
response.data.namespace,
response.data.overrides
);
} catch (err) {
console.error("Failed to fetch tenant configuration", err);
}
}
fetchTenantConfig();
}, []);
return <>{children}</>;
}
```
### B. User Preference Sync (With Rollback)
When a logged-in user changes their language, we update the UI instantly, save it locally, and sync it to the backend. If the backend fails, the engine automatically rolls back.
```tsx
import { changeLanguage } from '@repo/core-i18n';
import { api } from '@/api';
const handleSwitch = async (newLng: string) => {
try {
await changeLanguage(newLng, async (lng) => {
// 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 });
});
toast.success('Language saved!');
} catch (err) {
toast.error('Sync failed. Reverted to previous language.');
}
};
```
> [!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.
---
## 4. Backend API Contract (For Backend Engineers)
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**.
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...)*
**Expected Response (200 OK):**
```json
{
"data": {
"namespace": "booking",
"overrides": {
"module_name": "Procurement",
"header": {
"title": "Procurement List"
}
}
}
}
```
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@repo/core-i18n",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./react-i18next": "./src/react-i18next.d.ts"
},
"scripts": {
"lint": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core-storage": "workspace:*",
"@repo/utils": "workspace:*",
"i18next": "^24.2.2",
"react-i18next": "^15.4.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/react": "^19.0.8",
"typescript": "5.5.4"
}
}
+4
View File
@@ -0,0 +1,4 @@
export { setupI18n, type SupportedLanguage } from './setup';
export { changeLanguage, applyTenantOverrides } from './manager';
export { useTranslation, Trans } from 'react-i18next';
export { default as i18n } from 'i18next';
@@ -0,0 +1,12 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"success": "Success",
"error": "Error",
"settings": "Settings",
"loading": "Loading...",
"delete": "Delete",
"edit": "Edit"
}
}
@@ -0,0 +1,12 @@
{
"common": {
"save": "Simpan",
"cancel": "Batal",
"success": "Sukses",
"error": "Galat",
"settings": "Pengaturan",
"loading": "Memuat...",
"delete": "Hapus",
"edit": "Ubah"
}
}
+56
View File
@@ -0,0 +1,56 @@
import i18n from 'i18next';
import { demoSecureStorage, StorageKey } from '@repo/core-storage';
/**
* Changes the active language, saves the preference locally, and optionally syncs with the backend.
*
* @param newLng The new language code (e.g., 'en', 'id')
* @param syncCallback An optional callback to sync the preference to the backend. It receives the new language and the previous language.
*/
export async function changeLanguage(
newLng: string,
syncCallback?: (newLng: string, prevLng: string) => Promise<void>
): Promise<void> {
const prevLng = i18n.language;
if (prevLng === newLng) return;
// 1. Update local storage & i18next optimistically
await demoSecureStorage.setItem(StorageKey.LOCALE, newLng);
await i18n.changeLanguage(newLng);
// 2. Trigger optional backend sync
if (syncCallback) {
try {
await syncCallback(newLng, prevLng);
} catch (error) {
console.error('[i18n] Backend sync failed, rolling back language', error);
// Rollback on failure
await demoSecureStorage.setItem(StorageKey.LOCALE, prevLng);
await i18n.changeLanguage(prevLng);
throw error; // Rethrow so the caller can show an error toast
}
}
}
/**
* Injects tenant-specific vocabulary overrides dynamically at runtime.
*
* Uses a deep-merge strategy. Overrides are applied to the currently active language,
* or across all loaded languages if needed.
*
* @param namespace The i18n namespace to override (e.g., 'common', 'booking')
* @param overrides A deeply nested object containing the overridden string keys and values.
* @param lng Specific language to override. Defaults to currently active language.
*/
export function applyTenantOverrides(
namespace: string,
overrides: Record<string, unknown>,
lng?: string
): void {
const targetLng = lng || i18n.language;
// deep: true -> merges with existing keys rather than replacing the whole namespace
// overwrite: true -> allows replacing existing specific keys
i18n.addResourceBundle(targetLng, namespace, overrides, true, true);
}
+9
View File
@@ -0,0 +1,9 @@
import 'react-i18next';
import type { resources } from './setup';
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: typeof resources['en'];
}
}
+57
View File
@@ -0,0 +1,57 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { demoSecureStorage, StorageKey } from '@repo/core-storage';
import commonEn from './locales/en/common.json';
import commonId from './locales/id/common.json';
const DEFAULT_LANGUAGE = 'id';
const SUPPORTED_LANGUAGES = ['en', 'id'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export const resources = {
en: { common: commonEn.common },
id: { common: commonId.common },
} as const;
/**
* Bootstraps the central i18n engine.
*
* This reads the preferred locale from secureStorage and initializes
* i18next synchronously before React renders.
*/
export async function setupI18n(): Promise<void> {
let initialLng = DEFAULT_LANGUAGE;
try {
const storedLng = await demoSecureStorage.getItem<string>(StorageKey.LOCALE);
if (storedLng && SUPPORTED_LANGUAGES.includes(storedLng as SupportedLanguage)) {
initialLng = storedLng;
}
} catch (err) {
console.warn('[i18n] Failed to read locale from storage', err);
}
await i18n
.use(initReactI18next)
.init({
resources,
lng: initialLng,
fallbackLng: DEFAULT_LANGUAGE,
defaultNS: 'common',
interpolation: {
escapeValue: false, // React already escapes values
},
});
// Apply initial language to the DOM for SEO/Accessibility
if (typeof document !== 'undefined') {
document.documentElement.lang = i18n.language;
}
// Ensure DOM updates whenever the language changes later
i18n.on('languageChanged', (lng) => {
if (typeof document !== 'undefined') {
document.documentElement.lang = lng;
}
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/library.json",
"include": ["src"],
"compilerOptions": {
"strict": true,
"declaration": true,
"declarationMap": true
}
}
+93
View File
@@ -103,6 +103,9 @@ importers:
'@repo/core-api':
specifier: workspace:*
version: link:../../packages/core-api
'@repo/core-i18n':
specifier: workspace:*
version: link:../../packages/core-i18n
'@repo/ui':
specifier: workspace:*
version: link:../../packages/ui
@@ -112,12 +115,18 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@5.4.17)
i18next:
specifier: ^24.2.2
version: 24.2.3(typescript@5.5.4)
react:
specifier: ^19.2.3
version: 19.2.3
react-dom:
specifier: ^19.2.3
version: 19.2.3(react@19.2.3)
react-i18next:
specifier: ^15.4.0
version: 15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4)
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
@@ -152,6 +161,9 @@ importers:
'@repo/core-api':
specifier: workspace:*
version: link:../../packages/core-api
'@repo/core-i18n':
specifier: workspace:*
version: link:../../packages/core-i18n
'@repo/core-storage':
specifier: workspace:*
version: link:../../packages/core-storage
@@ -167,12 +179,18 @@ importers:
dayjs:
specifier: ^1.11.19
version: 1.11.19
i18next:
specifier: ^24.2.2
version: 24.2.3(typescript@5.5.4)
react:
specifier: ^19.2.3
version: 19.2.3
react-dom:
specifier: ^19.2.3
version: 19.2.3(react@19.2.3)
react-i18next:
specifier: ^15.4.0
version: 15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4)
react-router-dom:
specifier: ^7.11.0
version: 7.11.0(react-dom@19.2.3)(react@19.2.3)
@@ -285,6 +303,34 @@ importers:
specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1)
packages/core-i18n:
dependencies:
'@repo/core-storage':
specifier: workspace:*
version: link:../core-storage
'@repo/utils':
specifier: workspace:*
version: link:../utils
i18next:
specifier: ^24.2.2
version: 24.2.3(typescript@5.5.4)
react-i18next:
specifier: ^15.4.0
version: 15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4)
devDependencies:
'@repo/eslint-config':
specifier: workspace:*
version: link:../configs/eslint
'@repo/typescript-config':
specifier: workspace:*
version: link:../configs/typescript
'@types/react':
specifier: ^19.0.8
version: 19.2.7
typescript:
specifier: 5.5.4
version: 5.5.4
packages/core-storage:
dependencies:
'@repo/utils':
@@ -6218,6 +6264,12 @@ packages:
lru-cache: 10.4.3
dev: false
/html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
dependencies:
void-elements: 3.1.0
dev: false
/http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
dev: true
@@ -6281,6 +6333,18 @@ packages:
ms: 2.1.3
dev: true
/i18next@24.2.3(typescript@5.5.4):
resolution: {integrity: sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@babel/runtime': 7.28.4
typescript: 5.5.4
dev: false
/iconv-corefoundation@1.1.7:
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
engines: {node: ^8.11.2 || >=10}
@@ -8296,6 +8360,30 @@ packages:
react: 19.2.3
scheduler: 0.27.0
/react-i18next@15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4):
resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==}
peerDependencies:
i18next: '>= 23.4.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
dependencies:
'@babel/runtime': 7.28.4
html-parse-stringify: 3.0.1
i18next: 24.2.3(typescript@5.5.4)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
typescript: 5.5.4
dev: false
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false
@@ -10145,6 +10233,11 @@ packages:
- yaml
dev: true
/void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
dev: false
/walk-up-path@3.0.1:
resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==}
dev: false