Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
332 lines
14 KiB
Markdown
332 lines
14 KiB
Markdown
# 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.
|