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
+4
View File
@@ -0,0 +1,4 @@
export { setupI18n, type SupportedLanguage } from './setup';
export { changeLanguage, applyTenantOverrides } from './manager';
export { useTranslation, Trans } from 'react-i18next';
export { default as i18n } from 'i18next';
@@ -0,0 +1,12 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"success": "Success",
"error": "Error",
"settings": "Settings",
"loading": "Loading...",
"delete": "Delete",
"edit": "Edit"
}
}
@@ -0,0 +1,12 @@
{
"common": {
"save": "Simpan",
"cancel": "Batal",
"success": "Sukses",
"error": "Galat",
"settings": "Pengaturan",
"loading": "Memuat...",
"delete": "Hapus",
"edit": "Ubah"
}
}
+56
View File
@@ -0,0 +1,56 @@
import i18n from 'i18next';
import { demoSecureStorage, StorageKey } from '@repo/core-storage';
/**
* Changes the active language, saves the preference locally, and optionally syncs with the backend.
*
* @param newLng The new language code (e.g., 'en', 'id')
* @param syncCallback An optional callback to sync the preference to the backend. It receives the new language and the previous language.
*/
export async function changeLanguage(
newLng: string,
syncCallback?: (newLng: string, prevLng: string) => Promise<void>
): Promise<void> {
const prevLng = i18n.language;
if (prevLng === newLng) return;
// 1. Update local storage & i18next optimistically
await demoSecureStorage.setItem(StorageKey.LOCALE, newLng);
await i18n.changeLanguage(newLng);
// 2. Trigger optional backend sync
if (syncCallback) {
try {
await syncCallback(newLng, prevLng);
} catch (error) {
console.error('[i18n] Backend sync failed, rolling back language', error);
// Rollback on failure
await demoSecureStorage.setItem(StorageKey.LOCALE, prevLng);
await i18n.changeLanguage(prevLng);
throw error; // Rethrow so the caller can show an error toast
}
}
}
/**
* Injects tenant-specific vocabulary overrides dynamically at runtime.
*
* Uses a deep-merge strategy. Overrides are applied to the currently active language,
* or across all loaded languages if needed.
*
* @param namespace The i18n namespace to override (e.g., 'common', 'booking')
* @param overrides A deeply nested object containing the overridden string keys and values.
* @param lng Specific language to override. Defaults to currently active language.
*/
export function applyTenantOverrides(
namespace: string,
overrides: Record<string, unknown>,
lng?: string
): void {
const targetLng = lng || i18n.language;
// deep: true -> merges with existing keys rather than replacing the whole namespace
// overwrite: true -> allows replacing existing specific keys
i18n.addResourceBundle(targetLng, namespace, overrides, true, true);
}
+9
View File
@@ -0,0 +1,9 @@
import 'react-i18next';
import type { resources } from './setup';
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: typeof resources['en'];
}
}
+57
View File
@@ -0,0 +1,57 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { demoSecureStorage, StorageKey } from '@repo/core-storage';
import commonEn from './locales/en/common.json';
import commonId from './locales/id/common.json';
const DEFAULT_LANGUAGE = 'id';
const SUPPORTED_LANGUAGES = ['en', 'id'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export const resources = {
en: { common: commonEn.common },
id: { common: commonId.common },
} as const;
/**
* Bootstraps the central i18n engine.
*
* This reads the preferred locale from secureStorage and initializes
* i18next synchronously before React renders.
*/
export async function setupI18n(): Promise<void> {
let initialLng = DEFAULT_LANGUAGE;
try {
const storedLng = await demoSecureStorage.getItem<string>(StorageKey.LOCALE);
if (storedLng && SUPPORTED_LANGUAGES.includes(storedLng as SupportedLanguage)) {
initialLng = storedLng;
}
} catch (err) {
console.warn('[i18n] Failed to read locale from storage', err);
}
await i18n
.use(initReactI18next)
.init({
resources,
lng: initialLng,
fallbackLng: DEFAULT_LANGUAGE,
defaultNS: 'common',
interpolation: {
escapeValue: false, // React already escapes values
},
});
// Apply initial language to the DOM for SEO/Accessibility
if (typeof document !== 'undefined') {
document.documentElement.lang = i18n.language;
}
// Ensure DOM updates whenever the language changes later
i18n.on('languageChanged', (lng) => {
if (typeof document !== 'undefined') {
document.documentElement.lang = lng;
}
});
}