# 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/<feature>` |

### 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 (
    <div>
      <h1>{t('page.title')}</h1>
      <button>{t('actions.save')}</button>
      <p>{t('messages.success.created', { name: itemName })}</p>
    </div>
  );
}
```

### 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 (
    <tr>
      <td>{formatDate(invoice.date, { month: 'short', day: 'numeric' })}</td>
      <td>{formatCurrency(invoice.amount, 'USD')}</td>
      <td>{formatNumber(invoice.tax, { style: 'percent' })}</td>
      <td>{formatRelativeTime(invoice.created_at)}</td>
    </tr>
  );
}
```

**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<string, string> = {
      '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 `<CustomSelect>`, 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/<feature>.json` or `features/<feature>.json` under `enums.<enumName>.*` |
| One domain, many features | `features/<domain>.json` under `enums.<enumName>.*` |
| 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/<namespace>.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/<namespace>.json`
- `server/public/locales/es/<namespace>.json`
- `server/public/locales/de/<namespace>.json`
- `server/public/locales/nl/<namespace>.json`
- `server/public/locales/it/<namespace>.json`
- `server/public/locales/pl/<namespace>.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: <GeneralTab /> },
  { id: 'billing', label: t('tabs.billing'), content: <BillingTab /> },
];
```

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/<code>/` 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.
