# Internationalization (i18n) Architecture Alga PSA uses **i18next** with **react-i18next** for internationalization. Both the MSP portal and Client Portal are fully internationalized with feature-based namespace splitting and lazy loading. ## Quick Reference | Item | Value | |------|-------| | Languages | en, fr, es, de, nl, it, pl (+ xx, yy pseudo-locales for QA) | | Total keys per language | ~9,959 | | Namespace files per language | 27 | | Translation files | `server/public/locales/{lang}/{namespace}.json` | | Central config | `packages/core/src/lib/i18n/config.ts` | | Feature flag (MSP) | `msp-i18n-enabled` | | Validation | `node scripts/validate-translations.cjs` | | Pseudo-locale generation | `node scripts/generate-pseudo-locales.cjs` | ## Key Files | Purpose | Path | |---------|------| | Core config (LOCALE_CONFIG, ROUTE_NAMESPACES, types) | `packages/core/src/lib/i18n/config.ts` | | Re-exports from core (convenience) | `packages/ui/src/lib/i18n/config.ts` | | I18nProvider, useTranslation, useFormatters | `packages/ui/src/lib/i18n/client.tsx` | | I18nWrapper (route-aware, portal-aware) | `packages/tenancy/src/components/i18n/I18nWrapper.tsx` | | Core formatters (server-side) | `packages/core/src/lib/formatters.ts` | | Email locale resolver | `packages/notifications/src/notifications/emailLocaleResolver.ts` | | MSP layout integration | `server/src/app/msp/MspLayoutClient.tsx` | | MSP server layout (flag check + locale) | `server/src/app/msp/layout.tsx` | | i18n middleware (edge-safe) | `server/src/middleware/i18n.ts` | | MSP language settings UI | `server/src/components/settings/general/MspLanguageSettings.tsx` | | Translation validation CI | `.github/workflows/validate-translations.yml` | ## Namespace Structure ``` server/public/locales/{lang}/ ├── common.json (646 keys) — shared across entire app ├── client-portal.json (365 keys) — client portal UI chrome ├── features/ — shared between both portals │ ├── tickets.json (892 keys) │ ├── projects.json (1,174 keys) │ ├── documents.json (172 keys) │ ├── appointments.json (106 keys) │ └── billing.json (82 keys) └── msp/ — MSP portal only ├── core.json (152 keys) — loads on every MSP route ├── settings.json (769 keys) ├── clients.json (953 keys) ├── assets.json (782 keys) ├── onboarding.json (462 keys) ├── contacts.json (395 keys) ├── schedule.json (293 keys) ├── email-providers.json (285 keys) ├── time-entry.json (259 keys) ├── surveys.json (259 keys) ├── knowledge-base.json (163 keys) ├── admin.json (137 keys) ├── profile.json (117 keys) ├── licensing.json (108 keys) ├── reports.json (99 keys) ├── dispatch.json (98 keys) ├── dashboard.json (89 keys) ├── jobs.json (47 keys) └── extensions.json (44 keys) ``` ### Which namespace to use | String location | Namespace | |----------------|-----------| | Generic button/label used everywhere (Save, Cancel, Delete) | `common` | | Client portal nav, auth, dashboard, profile | `client-portal` | | Ticket/project/billing/document UI used by both portals | `features/*` | | MSP nav, sidebar, header | `msp/core` | | MSP feature page (time entry, dispatch, assets, etc.) | `msp/` | ### Route-to-namespace mapping Namespaces are lazy-loaded per route. The mapping is defined in `ROUTE_NAMESPACES` in `packages/core/src/lib/i18n/config.ts`. When adding new routes, register them there so the correct namespaces are preloaded. ## How to Use Translations ### In React components ```tsx import { useTranslation } from '@alga-psa/ui/lib/i18n/client'; export function MyComponent() { const { t } = useTranslation('msp/time-entry'); return (

{t('page.title')}

{t('messages.success.created', { name: itemName })}

); } ``` ### Locale-aware formatting Use `useFormatters()` instead of hardcoded date/number/currency formatting: ```tsx import { useFormatters } from '@alga-psa/ui/lib/i18n/client'; export function InvoiceRow({ invoice }) { const { formatDate, formatCurrency, formatNumber, formatRelativeTime } = useFormatters(); return ( {formatDate(invoice.date, { month: 'short', day: 'numeric' })} {formatCurrency(invoice.amount, 'USD')} {formatNumber(invoice.tax, { style: 'percent' })} {formatRelativeTime(invoice.created_at)} ); } ``` **Migration from hardcoded patterns:** | Old pattern | New pattern | |------------|------------| | `new Date(x).toLocaleDateString('en-US', opts)` | `formatDate(x, opts)` | | `` `$${amount.toFixed(2)}` `` | `formatCurrency(amount, 'USD')` | | `amount.toFixed(2)` (for display) | `formatNumber(amount, { minimumFractionDigits: 2 })` | | Hardcoded `"Today"`, `"Yesterday"` | `formatRelativeTime(date)` | ### Server-side formatting ```typescript import { formatCurrency, formatDate } from '@alga-psa/core'; formatCurrency(100.50, 'fr', 'EUR'); // "100,50 EUR" formatDate(new Date(), 'de'); // "18.02.2026" ``` ### Error and toast messages Server actions return English error strings. Components map them to translation keys: ```tsx const { t } = useTranslation('msp/settings'); const handleSave = async () => { const result = await updateSettings(data); if (result.success) { toast.success(t('messages.success.updated')); } else { const errorMap: Record = { 'Permission denied': t('errors.permissionDenied'), 'Invalid configuration': t('errors.invalidConfig'), }; toast.error(errorMap[result.error!] || t('errors.saveFailed')); } }; ``` ### Enum labels from shared constants Shared enum labels (e.g. `BILLING_FREQUENCY` → Monthly / Quarterly / Annually, `CONTRACT_LINE_TYPE` → Fixed / Hourly / Usage Based, status badges, Yes/No) must not ship as hardcoded English strings inside `*_DISPLAY` / `*_OPTIONS` constant files. The labels are baked in at module load, bypass `t()`, and are invisible to both the audit test suite and `validate-translations.cjs` — only pseudo-locale visual QA catches them, and only on screens someone remembers to visit. The project uses a **localized option-hook pattern**: the constant file owns the values and the TypeScript types, a colocated hook produces the localized option list, and every consumer imports the hook instead of the constant. ```ts // Source of truth: values only export const BILLING_FREQUENCY_VALUES = ['monthly', 'quarterly', 'annually'] as const; export type BillingFrequency = typeof BILLING_FREQUENCY_VALUES[number]; // Published hook: returns localized options export function useBillingFrequencyOptions() { const { t } = useTranslation('features/billing'); return BILLING_FREQUENCY_VALUES.map((value) => ({ value, label: t(`enums.billingFrequency.${value}`), })); } // Companion formatter for table/cell renderers export function useFormatBillingFrequency() { const { t } = useTranslation('features/billing'); return (value: string) => t(`enums.billingFrequency.${value}`, { defaultValue: value }); } ``` Consumers call the hook at the top of the component body and pass the result into ``, column `render` callbacks, badges, etc. The route that renders any consumer must have the chosen namespace listed in `ROUTE_NAMESPACES` or the hook returns the raw key. **Where to put the keys:** | Blast radius | Namespace | |---|---| | Single feature area | `msp/.json` or `features/.json` under `enums..*` | | One domain, many features | `features/.json` under `enums..*` | | App-wide (Yes/No, status, priority) | `common.json` under `enums.*` | Inline hardcoded option arrays inside components (`const userTypeOptions = [{ value, label: 'Technician' }, ...]`) are the same anti-pattern with a smaller blast radius and should be migrated the same way — or deleted if they are dead code. Full recipe, reviewer checklist, audit greps, and the current migration backlog live in [`.ai/translation/enum-labels-pattern.md`](../../.ai/translation/enum-labels-pattern.md). ## How to Add Translation Keys ### 1. Add keys to the English JSON file Add your keys to `server/public/locales/en/.json` following the naming convention: ```json { "page": { "title": "...", "description": "..." }, "sections": { "sectionName": { "title": "...", "empty": "..." } }, "fields": { "fieldName": { "label": "...", "placeholder": "...", "help": "..." } }, "actions": { "create": "...", "edit": "...", "delete": "..." }, "table": { "columns": { "name": "...", "status": "..." }, "empty": "..." }, "dialogs": { "confirmDelete": { "title": "...", "message": "...", "confirm": "...", "cancel": "..." } }, "errors": { "loadFailed": "...", "saveFailed": "...", "permissionDenied": "..." }, "validation": { "nameRequired": "...", "emailInvalid": "..." }, "messages": { "success": { "created": "...", "updated": "..." }, "error": { "createFailed": "..." } } } ``` ### 2. Add translations for all other languages Add the same keys with translated values to: - `server/public/locales/fr/.json` - `server/public/locales/es/.json` - `server/public/locales/de/.json` - `server/public/locales/nl/.json` - `server/public/locales/it/.json` - `server/public/locales/pl/.json` ### 3. Regenerate pseudo-locales ```bash node scripts/generate-pseudo-locales.cjs ``` ### 4. Validate ```bash node scripts/validate-translations.cjs ``` This checks: all keys present in all languages, no extra keys, no broken `{{variables}}`, valid JSON. ### 5. Register routes (if new namespace) If you created a new namespace file, add route entries to `ROUTE_NAMESPACES` in `packages/core/src/lib/i18n/config.ts`: ```typescript export const ROUTE_NAMESPACES = { // ... '/msp/my-feature': ['common', 'msp/core', 'msp/my-feature'], }; ``` ## How Locale Resolution Works ### MSP Portal (internal users) 1. User language preference (`user_preferences` table) 2. MSP org default locale (`tenant_settings.settings.mspPortal.defaultLocale`) 3. System default (`'en'`) Gated by the `msp-i18n-enabled` feature flag. When the flag is off, the MSP portal forces English. The `I18nWrapper` always renders regardless of the flag state — it just forces `initialLocale='en'` when off. ### Client Portal 1. User language preference 2. Client default locale (`clients.properties.defaultLocale`) 3. Tenant client portal default (`tenant_settings.settings.clientPortal.defaultLocale`) 4. Cookie / browser language 5. System default (`'en'`) ### Emails Currently locale-resolved for **client portal users only**. MSP internal users receive English emails regardless of preference. Resolution hierarchy (client users): User preference > Client preference > Tenant client portal default > Tenant default > English. Falls back to English if no template exists for the resolved locale. **Translation coverage:** en, fr, es, de, nl have full coverage. Italian and Polish are partial — missing project, SLA, time entry, and billing-misc email templates. See `docs/email/email-i18n-implementation-summary.md` for details. ## Pseudo-Locale Visual QA Two pseudo-locales exist for visual testing: - `xx` — all values = `'11111'` - `yy` — all values = `'55555'` **QA process:** 1. Enable the `msp-i18n-enabled` flag locally 2. Switch to locale `xx` in the language picker 3. Navigate the feature area 4. Every user-visible string should show `11111` 5. Any string still showing English was not extracted — fix it Dates, numbers, and currency will NOT show `11111` — they come from `useFormatters()`. ## CustomTabs and Translation `CustomTabs` uses a required `id` field for matching (URL sync, state, Radix values). The `label` property is display-only and safe to translate: ```tsx const tabs = [ { id: 'general', label: t('tabs.general'), content: }, { id: 'billing', label: t('tabs.billing'), content: }, ]; ``` Always store/compare active tab state using `id`, never `label`. ## Adding a New Language 1. Add the locale code to `packages/core/src/lib/i18n/config.ts` in `supportedLocales` and `localeNames` 2. Copy `server/public/locales/en/` to `server/public/locales//` and translate all files 3. Create email template migrations (see `server/migrations/20251228123000_add_polish_email_templates.cjs` for reference) 4. Create internal notification template migrations (see `server/migrations/20251228120000_add_polish_internal_notification_templates.cjs`) 5. Add language to `languageNames` mapping in `server/src/components/settings/notifications/EmailTemplates.tsx` (3 locations) 6. Run `node scripts/generate-pseudo-locales.cjs` and `node scripts/validate-translations.cjs` 7. Test in browser with both portals ## Common Pitfalls 1. **Wrong import path** — use `@alga-psa/ui/lib/i18n/client`, not a server path 2. **Hardcoded locale in formatting** — use `useFormatters()`, not `toLocaleDateString('en-US')` 3. **Missing route in ROUTE_NAMESPACES** — if `t('key')` returns the key itself, the namespace likely isn't loaded for that route 4. **German/Dutch text overflow** — these translations are 30-50% longer than English; test layouts 5. **Forgetting toast/error messages** — `toast.success()`, `toast.error()`, `setError()` calls are easy to miss 6. **Missing aria-labels and tooltips** — accessibility text must also be translated 7. **Currency symbol placement** — use `formatCurrency()` which handles per-locale placement 8. **Enum labels from `*_DISPLAY` / `*_OPTIONS` constants** — labels imported from a constant file never reach `t()` and never appear in the audit suite. Use the [enum-labels option-hook pattern](../../.ai/translation/enum-labels-pattern.md) (`useXOptions()` / `useFormatX()`) instead. Applies to inline `const fooOptions = [{ value, label: 'Xyz' }, ...]` arrays inside components as well.