|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|
}
|