feat: implement core-i18n package with decentralized namespace support and integrate into landing and web apps
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"welcome": "Welcome to Our Product",
|
||||||
|
"cta": "Get Started Now"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"welcome": "Selamat Datang di Produk Kami",
|
||||||
|
"cta": "Mulai Sekarang"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+14
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
Vendored
+14
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,9 @@
|
|||||||
|
import 'react-i18next';
|
||||||
|
import type { resources } from './setup';
|
||||||
|
|
||||||
|
declare module 'react-i18next' {
|
||||||
|
interface CustomTypeOptions {
|
||||||
|
defaultNS: 'common';
|
||||||
|
resources: typeof resources['en'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@repo/typescript-config/library.json",
|
||||||
|
"include": ["src"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+93
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user