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