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/ ├── packages/
│ ├── core-api/ # Shared HTTP Client, Observability & Data Services Engine │ ├── core-api/ # Shared HTTP Client, Observability & Data Services Engine
│ ├── core-storage/ # Enterprise Storage Engine (IndexedDB/localStorage + Encryption) │ ├── core-storage/ # Enterprise Storage Engine (IndexedDB/localStorage + Encryption)
│ ├── core-i18n/ # Enterprise Internationalization Architecture
│ ├── ui/ # Shared UI Component Library │ ├── ui/ # Shared UI Component Library
│ ├── utils/ # Shared Utilities (Date, Encryption, Core Logic, etc) │ ├── utils/ # Shared Utilities (Date, Encryption, Core Logic, etc)
│ └── configs/ # Shared Tooling Configurations │ └── 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. 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). 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. Single source of truth for tooling configuration.
+3
View File
@@ -12,11 +12,14 @@
}, },
"dependencies": { "dependencies": {
"@repo/core-api": "workspace:*", "@repo/core-api": "workspace:*",
"@repo/core-i18n": "workspace:*",
"@repo/ui": "workspace:*", "@repo/ui": "workspace:*",
"@repo/utils": "workspace:*", "@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"i18next": "^24.2.2",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-i18next": "^15.4.0",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
"devDependencies": { "devDependencies": {
+2
View File
@@ -1,6 +1,7 @@
import { ThemeProvider } from '@repo/ui/provider'; import { ThemeProvider } from '@repo/ui/provider';
import { Button } from '@repo/ui/components'; import { Button } from '@repo/ui/components';
import LandingSample from './features/public-content/presentation/LandingSample'; import LandingSample from './features/public-content/presentation/LandingSample';
import I18nLandingSample from './presentation/I18nLandingSample';
export default function App() { export default function App() {
return ( return (
@@ -36,6 +37,7 @@ export default function App() {
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1> <h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
<LandingSample /> <LandingSample />
</div> </div>
<I18nLandingSample />
</div> </div>
</ThemeProvider> </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"
}
+7
View File
@@ -15,10 +15,17 @@ initTelemetry({
import './main.css'; import './main.css';
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
import App from './app'; import App from './app';
async function bootstrap() {
await setupI18n();
createRoot(document.getElementById('app')!).render( createRoot(document.getElementById('app')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </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": { "dependencies": {
"@repo/core-api": "workspace:*", "@repo/core-api": "workspace:*",
"@repo/core-i18n": "workspace:*",
"@repo/core-storage": "workspace:*", "@repo/core-storage": "workspace:*",
"@repo/ui": "workspace:*", "@repo/ui": "workspace:*",
"@repo/utils": "workspace:*", "@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"i18next": "^24.2.2",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-i18next": "^15.4.0",
"react-router-dom": "^7.11.0", "react-router-dom": "^7.11.0",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
@@ -1,5 +1,6 @@
import BookingSample from "./features/booking/presentation/BookingSample"; import BookingSample from "./features/booking/presentation/BookingSample";
import StorageSample from "./features/storage/presentation/StorageSample"; import StorageSample from "./features/storage/presentation/StorageSample";
import I18nSample from "./features/i18n/presentation/I18nSample";
export default function ExamplePage() { export default function ExamplePage() {
return <div className="bg-amber-200">example 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> <h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
<StorageSample /> <StorageSample />
</div> </div>
<div className="p-8 bg-slate-900">
<I18nSample />
</div>
</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>
);
}
+9
View File
@@ -15,10 +15,19 @@ initTelemetry({
import './main.css'; import './main.css';
import { lazy, StrictMode } from 'react'; import { lazy, StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
const App = lazy(() => import('./apps')); const App = lazy(() => import('./apps'));
async function bootstrap() {
// Initialize i18next and load language from secureStorage
await setupI18n();
createRoot(document.getElementById('app')!).render( createRoot(document.getElementById('app')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </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': '@repo/core-api':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/core-api version: link:../../packages/core-api
'@repo/core-i18n':
specifier: workspace:*
version: link:../../packages/core-i18n
'@repo/ui': '@repo/ui':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/ui version: link:../../packages/ui
@@ -112,12 +115,18 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.18 specifier: ^4.1.18
version: 4.1.18(vite@5.4.17) version: 4.1.18(vite@5.4.17)
i18next:
specifier: ^24.2.2
version: 24.2.3(typescript@5.5.4)
react: react:
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3 version: 19.2.3
react-dom: react-dom:
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3(react@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: tailwindcss:
specifier: ^4.1.18 specifier: ^4.1.18
version: 4.1.18 version: 4.1.18
@@ -152,6 +161,9 @@ importers:
'@repo/core-api': '@repo/core-api':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/core-api version: link:../../packages/core-api
'@repo/core-i18n':
specifier: workspace:*
version: link:../../packages/core-i18n
'@repo/core-storage': '@repo/core-storage':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/core-storage version: link:../../packages/core-storage
@@ -167,12 +179,18 @@ importers:
dayjs: dayjs:
specifier: ^1.11.19 specifier: ^1.11.19
version: 1.11.19 version: 1.11.19
i18next:
specifier: ^24.2.2
version: 24.2.3(typescript@5.5.4)
react: react:
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3 version: 19.2.3
react-dom: react-dom:
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3(react@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: react-router-dom:
specifier: ^7.11.0 specifier: ^7.11.0
version: 7.11.0(react-dom@19.2.3)(react@19.2.3) version: 7.11.0(react-dom@19.2.3)(react@19.2.3)
@@ -285,6 +303,34 @@ importers:
specifier: ^4.0.17 specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1) 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: packages/core-storage:
dependencies: dependencies:
'@repo/utils': '@repo/utils':
@@ -6218,6 +6264,12 @@ packages:
lru-cache: 10.4.3 lru-cache: 10.4.3
dev: false 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: /http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
dev: true dev: true
@@ -6281,6 +6333,18 @@ packages:
ms: 2.1.3 ms: 2.1.3
dev: true 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: /iconv-corefoundation@1.1.7:
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
engines: {node: ^8.11.2 || >=10} engines: {node: ^8.11.2 || >=10}
@@ -8296,6 +8360,30 @@ packages:
react: 19.2.3 react: 19.2.3
scheduler: 0.27.0 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: /react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false dev: false
@@ -10145,6 +10233,11 @@ packages:
- yaml - yaml
dev: true 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: /walk-up-path@3.0.1:
resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==}
dev: false dev: false