From 4655362ff450e47f78bd5b58b3aa98cb91f1edf6 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Fri, 22 May 2026 23:30:41 +0700 Subject: [PATCH] feat: implement core-i18n package with decentralized namespace support and integrate into landing and web apps --- README.md | 26 +- apps/landing/package.json | 3 + apps/landing/src/app.tsx | 2 + apps/landing/src/locales/en/home.json | 4 + apps/landing/src/locales/id/home.json | 4 + apps/landing/src/main.tsx | 17 +- .../src/presentation/I18nLandingSample.tsx | 50 +++ apps/landing/src/types/i18next.d.ts | 14 + apps/web/package.json | 3 + .../src/apps/modules/example/example.page.tsx | 4 + .../features/i18n/locales/en/booking.json | 8 + .../features/i18n/locales/id/booking.json | 8 + .../features/i18n/presentation/I18nSample.tsx | 308 ++++++++++++++++++ apps/web/src/main.tsx | 19 +- apps/web/src/types/i18next.d.ts | 14 + packages/core-i18n/README.md | 183 +++++++++++ packages/core-i18n/package.json | 26 ++ packages/core-i18n/src/index.ts | 4 + packages/core-i18n/src/locales/en/common.json | 12 + packages/core-i18n/src/locales/id/common.json | 12 + packages/core-i18n/src/manager.ts | 56 ++++ packages/core-i18n/src/react-i18next.d.ts | 9 + packages/core-i18n/src/setup.ts | 57 ++++ packages/core-i18n/tsconfig.json | 9 + pnpm-lock.yaml | 93 ++++++ 25 files changed, 932 insertions(+), 13 deletions(-) create mode 100644 apps/landing/src/locales/en/home.json create mode 100644 apps/landing/src/locales/id/home.json create mode 100644 apps/landing/src/presentation/I18nLandingSample.tsx create mode 100644 apps/landing/src/types/i18next.d.ts create mode 100644 apps/web/src/apps/modules/example/features/i18n/locales/en/booking.json create mode 100644 apps/web/src/apps/modules/example/features/i18n/locales/id/booking.json create mode 100644 apps/web/src/apps/modules/example/features/i18n/presentation/I18nSample.tsx create mode 100644 apps/web/src/types/i18next.d.ts create mode 100644 packages/core-i18n/README.md create mode 100644 packages/core-i18n/package.json create mode 100644 packages/core-i18n/src/index.ts create mode 100644 packages/core-i18n/src/locales/en/common.json create mode 100644 packages/core-i18n/src/locales/id/common.json create mode 100644 packages/core-i18n/src/manager.ts create mode 100644 packages/core-i18n/src/react-i18next.d.ts create mode 100644 packages/core-i18n/src/setup.ts create mode 100644 packages/core-i18n/tsconfig.json diff --git a/README.md b/README.md index b533b4a..e86f1fc 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ The monorepo is organized into **Apps** (deployable applications) and **Packages ├── packages/ │ ├── core-api/ # Shared HTTP Client, Observability & Data Services Engine │ ├── core-storage/ # Enterprise Storage Engine (IndexedDB/localStorage + Encryption) +│ ├── core-i18n/ # Enterprise Internationalization Architecture │ ├── ui/ # Shared UI Component Library │ ├── utils/ # Shared Utilities (Date, Encryption, Core Logic, etc) │ └── configs/ # Shared Tooling Configurations @@ -249,7 +250,26 @@ Provides a unified, Promise-based interface for interacting with browser storage --- -### 7. `packages/utils` +### 7. `packages/core-i18n` + +The **Enterprise Internationalization Architecture** for the monorepo. + +Provides a Hybrid Namespace Architecture combining a centralized i18n engine with decentralized, lazy-loaded feature dictionaries. Features strict TypeScript typings (including nested keys), optional backend synchronization with automatic error rollbacks, and a deep-merge mechanism for dynamic tenant-specific vocabulary overrides. + +**Key Capabilities**: + +| Feature | Description | +|---|---| +| 🌐 Hybrid Namespaces | Centralized `common` corpus + lazy-loaded feature dictionaries. | +| 🛡️ Strict Typings | Native TS autocomplete for nested paths (e.g., `header.title`) via module augmentation. | +| 🔄 Safe Backend Sync | `changeLanguage` accepts a `syncCallback` with built-in rollback if the API fails. | +| 🏢 Tenant Overrides | `applyTenantOverrides` performs a partial deep-merge to selectively override terminology. | + +**Documentation**: [README.md](packages/core-i18n/README.md) + +--- + +### 8. `packages/utils` Shared business logic and reusable utility modules that can be consumed across multiple applications. Fully tested using Vitest. @@ -257,7 +277,7 @@ This package is intended to hold non-UI, cross-cutting logic such as date/time h --- -### 8. `packages/ui` +### 9. `packages/ui` Shared UI component library (Buttons, Inputs, Cards, Layouts). @@ -266,7 +286,7 @@ Shared UI component library (Buttons, Inputs, Cards, Layouts). --- -### 9. `packages/configs` +### 10. `packages/configs` Single source of truth for tooling configuration. diff --git a/apps/landing/package.json b/apps/landing/package.json index 45cc029..1d78f4c 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -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": { diff --git a/apps/landing/src/app.tsx b/apps/landing/src/app.tsx index 9e88335..6abf839 100644 --- a/apps/landing/src/app.tsx +++ b/apps/landing/src/app.tsx @@ -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() {

Enterprise Web App

+ ); diff --git a/apps/landing/src/locales/en/home.json b/apps/landing/src/locales/en/home.json new file mode 100644 index 0000000..ffdd0d9 --- /dev/null +++ b/apps/landing/src/locales/en/home.json @@ -0,0 +1,4 @@ +{ + "welcome": "Welcome to Our Product", + "cta": "Get Started Now" +} diff --git a/apps/landing/src/locales/id/home.json b/apps/landing/src/locales/id/home.json new file mode 100644 index 0000000..70de94c --- /dev/null +++ b/apps/landing/src/locales/id/home.json @@ -0,0 +1,4 @@ +{ + "welcome": "Selamat Datang di Produk Kami", + "cta": "Mulai Sekarang" +} diff --git a/apps/landing/src/main.tsx b/apps/landing/src/main.tsx index 12ade2e..2bfdd13 100644 --- a/apps/landing/src/main.tsx +++ b/apps/landing/src/main.tsx @@ -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( - - - , -); +async function bootstrap() { + await setupI18n(); + + createRoot(document.getElementById('app')!).render( + + + , + ); +} + +bootstrap(); diff --git a/apps/landing/src/presentation/I18nLandingSample.tsx b/apps/landing/src/presentation/I18nLandingSample.tsx new file mode 100644 index 0000000..27a1840 --- /dev/null +++ b/apps/landing/src/presentation/I18nLandingSample.tsx @@ -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 ( +
+

+ {t('home:welcome')} +

+ +
+ + +
+ + +
+ ); +} diff --git a/apps/landing/src/types/i18next.d.ts b/apps/landing/src/types/i18next.d.ts new file mode 100644 index 0000000..fd7316b --- /dev/null +++ b/apps/landing/src/types/i18next.d.ts @@ -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; + }; + } +} diff --git a/apps/web/package.json b/apps/web/package.json index eac6aec..945b931 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" }, diff --git a/apps/web/src/apps/modules/example/example.page.tsx b/apps/web/src/apps/modules/example/example.page.tsx index 2711f00..f77b202 100644 --- a/apps/web/src/apps/modules/example/example.page.tsx +++ b/apps/web/src/apps/modules/example/example.page.tsx @@ -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
example @@ -11,5 +12,8 @@ export default function ExamplePage() {

Enterprise Web App

+
+ +
; } diff --git a/apps/web/src/apps/modules/example/features/i18n/locales/en/booking.json b/apps/web/src/apps/modules/example/features/i18n/locales/en/booking.json new file mode 100644 index 0000000..8e8835d --- /dev/null +++ b/apps/web/src/apps/modules/example/features/i18n/locales/en/booking.json @@ -0,0 +1,8 @@ +{ + "module_name": "Purchasing", + "select_date": "Select Date", + "header": { + "title": "Transaction List", + "subtitle": "Manage all your transactions here" + } +} diff --git a/apps/web/src/apps/modules/example/features/i18n/locales/id/booking.json b/apps/web/src/apps/modules/example/features/i18n/locales/id/booking.json new file mode 100644 index 0000000..bf22e68 --- /dev/null +++ b/apps/web/src/apps/modules/example/features/i18n/locales/id/booking.json @@ -0,0 +1,8 @@ +{ + "module_name": "Pembelanjaan", + "select_date": "Pilih Tanggal", + "header": { + "title": "Daftar Transaksi", + "subtitle": "Kelola semua transaksi Anda di sini" + } +} diff --git a/apps/web/src/apps/modules/example/features/i18n/presentation/I18nSample.tsx b/apps/web/src/apps/modules/example/features/i18n/presentation/I18nSample.tsx new file mode 100644 index 0000000..cd68b5e --- /dev/null +++ b/apps/web/src/apps/modules/example/features/i18n/presentation/I18nSample.tsx @@ -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(''); + const [activeTenant, setActiveTenant] = useState('default'); + const [isFetchingConfig, setIsFetchingConfig] = useState(false); + + // ─── Admin Panel State ────────────────────────────────────────── + const [adminModuleName, setAdminModuleName] = useState('PENGELUARAN'); + const [adminHeaderTitle, setAdminHeaderTitle] = useState('Daftar Pengeluaran'); + const [dbPayloadStr, setDbPayloadStr] = useState('No data in DB'); + + const MOCK_DB_KEY = 'mock_db_company_a'; + + const loadDbPayload = useCallback(async () => { + try { + const data = await demoIndexedDB.getItem(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 => { + if (companyId === 'company-a') { + const data = await demoIndexedDB.getItem(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 ( +
+

🌐 Enterprise i18n Demo

+

+ Current Active Language: {i18n.language} +

+ + {/* ─── Admin Panel ──────────────────────────────────────────── */} +
+

Admin Panel (Company A Config)

+

+ Simulate a backend CMS. Save the vocabulary overrides to IndexedDB. +

+ +
+ + +
+ + + +
+
Raw JSON in DB:
+
+            {dbPayloadStr}
+          
+
+
+ + {/* ─── Section A ────────────────────────────────────────────── */} +
+

A. Language Switcher & Backend Sync

+

+ Change the language. The callback simulates a 1-second backend API request. +

+ +
+ + + +
+ + {syncStatus && ( +
+ {syncStatus} +
+ )} +
+ + {/* ─── Section B ────────────────────────────────────────────── */} +
+

B. Dynamic Tenant Overrides (End-to-End)

+

+ Simulates a user logging in. It fetches the config directly from IndexedDB (mock database) and applies the deep-merge override. +

+ +
+ + + +
+ + {isFetchingConfig && ( +
+ ⏳ Fetching tenant config... +
+ )} + + {/* Display the localized strings */} +
+

UI Result:

+ + + + + + + {/* Type-safe keys from the common and booking namespaces */} + + + + + + + + + + + + + + + + + + + + + +
KeyValue
booking:module_name{t('booking:module_name')}
booking:header.title{t('booking:header.title')}
booking:header.subtitle{t('booking:header.subtitle')}
booking:select_date{t('booking:select_date')}
common:save{t('common:save')}
+
+
+
+ ); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8414dc5..40f43fc 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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( - - - , -); +async function bootstrap() { + // Initialize i18next and load language from secureStorage + await setupI18n(); + + createRoot(document.getElementById('app')!).render( + + + , + ); +} + +bootstrap(); diff --git a/apps/web/src/types/i18next.d.ts b/apps/web/src/types/i18next.d.ts new file mode 100644 index 0000000..9714310 --- /dev/null +++ b/apps/web/src/types/i18next.d.ts @@ -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; + }; + } +} diff --git a/packages/core-i18n/README.md b/packages/core-i18n/README.md new file mode 100644 index 0000000..c34d7ba --- /dev/null +++ b/packages/core-i18n/README.md @@ -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( + , + ); +} +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

{t('booking:header.title')}

; // 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" + } + } + } +} +``` diff --git a/packages/core-i18n/package.json b/packages/core-i18n/package.json new file mode 100644 index 0000000..a7a83cd --- /dev/null +++ b/packages/core-i18n/package.json @@ -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" + } +} diff --git a/packages/core-i18n/src/index.ts b/packages/core-i18n/src/index.ts new file mode 100644 index 0000000..5bcc610 --- /dev/null +++ b/packages/core-i18n/src/index.ts @@ -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'; diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/locales/en/common.json new file mode 100644 index 0000000..e0c848b --- /dev/null +++ b/packages/core-i18n/src/locales/en/common.json @@ -0,0 +1,12 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "success": "Success", + "error": "Error", + "settings": "Settings", + "loading": "Loading...", + "delete": "Delete", + "edit": "Edit" + } +} diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/locales/id/common.json new file mode 100644 index 0000000..cf07cb6 --- /dev/null +++ b/packages/core-i18n/src/locales/id/common.json @@ -0,0 +1,12 @@ +{ + "common": { + "save": "Simpan", + "cancel": "Batal", + "success": "Sukses", + "error": "Galat", + "settings": "Pengaturan", + "loading": "Memuat...", + "delete": "Hapus", + "edit": "Ubah" + } +} diff --git a/packages/core-i18n/src/manager.ts b/packages/core-i18n/src/manager.ts new file mode 100644 index 0000000..d25db0d --- /dev/null +++ b/packages/core-i18n/src/manager.ts @@ -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 +): Promise { + 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, + 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); +} diff --git a/packages/core-i18n/src/react-i18next.d.ts b/packages/core-i18n/src/react-i18next.d.ts new file mode 100644 index 0000000..762d244 --- /dev/null +++ b/packages/core-i18n/src/react-i18next.d.ts @@ -0,0 +1,9 @@ +import 'react-i18next'; +import type { resources } from './setup'; + +declare module 'react-i18next' { + interface CustomTypeOptions { + defaultNS: 'common'; + resources: typeof resources['en']; + } +} diff --git a/packages/core-i18n/src/setup.ts b/packages/core-i18n/src/setup.ts new file mode 100644 index 0000000..5ff0136 --- /dev/null +++ b/packages/core-i18n/src/setup.ts @@ -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 { + let initialLng = DEFAULT_LANGUAGE; + try { + const storedLng = await demoSecureStorage.getItem(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; + } + }); +} diff --git a/packages/core-i18n/tsconfig.json b/packages/core-i18n/tsconfig.json new file mode 100644 index 0000000..65866cf --- /dev/null +++ b/packages/core-i18n/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@repo/typescript-config/library.json", + "include": ["src"], + "compilerOptions": { + "strict": true, + "declaration": true, + "declarationMap": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4222c32..bd8343b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: '@repo/core-api': specifier: workspace:* version: link:../../packages/core-api + '@repo/core-i18n': + specifier: workspace:* + version: link:../../packages/core-i18n '@repo/ui': specifier: workspace:* version: link:../../packages/ui @@ -112,12 +115,18 @@ importers: '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@5.4.17) + i18next: + specifier: ^24.2.2 + version: 24.2.3(typescript@5.5.4) react: specifier: ^19.2.3 version: 19.2.3 react-dom: specifier: ^19.2.3 version: 19.2.3(react@19.2.3) + react-i18next: + specifier: ^15.4.0 + version: 15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -152,6 +161,9 @@ importers: '@repo/core-api': specifier: workspace:* version: link:../../packages/core-api + '@repo/core-i18n': + specifier: workspace:* + version: link:../../packages/core-i18n '@repo/core-storage': specifier: workspace:* version: link:../../packages/core-storage @@ -167,12 +179,18 @@ importers: dayjs: specifier: ^1.11.19 version: 1.11.19 + i18next: + specifier: ^24.2.2 + version: 24.2.3(typescript@5.5.4) react: specifier: ^19.2.3 version: 19.2.3 react-dom: specifier: ^19.2.3 version: 19.2.3(react@19.2.3) + react-i18next: + specifier: ^15.4.0 + version: 15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4) react-router-dom: specifier: ^7.11.0 version: 7.11.0(react-dom@19.2.3)(react@19.2.3) @@ -285,6 +303,34 @@ importers: specifier: ^4.0.17 version: 4.0.17(@opentelemetry/api@1.9.1) + packages/core-i18n: + dependencies: + '@repo/core-storage': + specifier: workspace:* + version: link:../core-storage + '@repo/utils': + specifier: workspace:* + version: link:../utils + i18next: + specifier: ^24.2.2 + version: 24.2.3(typescript@5.5.4) + react-i18next: + specifier: ^15.4.0 + version: 15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4) + devDependencies: + '@repo/eslint-config': + specifier: workspace:* + version: link:../configs/eslint + '@repo/typescript-config': + specifier: workspace:* + version: link:../configs/typescript + '@types/react': + specifier: ^19.0.8 + version: 19.2.7 + typescript: + specifier: 5.5.4 + version: 5.5.4 + packages/core-storage: dependencies: '@repo/utils': @@ -6218,6 +6264,12 @@ packages: lru-cache: 10.4.3 dev: false + /html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + dependencies: + void-elements: 3.1.0 + dev: false + /http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} dev: true @@ -6281,6 +6333,18 @@ packages: ms: 2.1.3 dev: true + /i18next@24.2.3(typescript@5.5.4): + resolution: {integrity: sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@babel/runtime': 7.28.4 + typescript: 5.5.4 + dev: false + /iconv-corefoundation@1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -8296,6 +8360,30 @@ packages: react: 19.2.3 scheduler: 0.27.0 + /react-i18next@15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4): + resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==} + peerDependencies: + i18next: '>= 23.4.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + dependencies: + '@babel/runtime': 7.28.4 + html-parse-stringify: 3.0.1 + i18next: 24.2.3(typescript@5.5.4) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + typescript: 5.5.4 + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: false @@ -10145,6 +10233,11 @@ packages: - yaml dev: true + /void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + dev: false + /walk-up-path@3.0.1: resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} dev: false