Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
14 KiB
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
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:
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
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:
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.
// 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.
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:
{
"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>.jsonserver/public/locales/es/<namespace>.jsonserver/public/locales/de/<namespace>.jsonserver/public/locales/nl/<namespace>.jsonserver/public/locales/it/<namespace>.jsonserver/public/locales/pl/<namespace>.json
3. Regenerate pseudo-locales
node scripts/generate-pseudo-locales.cjs
4. Validate
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:
export const ROUTE_NAMESPACES = {
// ...
'/msp/my-feature': ['common', 'msp/core', 'msp/my-feature'],
};
How Locale Resolution Works
MSP Portal (internal users)
- User language preference (
user_preferencestable) - MSP org default locale (
tenant_settings.settings.mspPortal.defaultLocale) - 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
- User language preference
- Client default locale (
clients.properties.defaultLocale) - Tenant client portal default (
tenant_settings.settings.clientPortal.defaultLocale) - Cookie / browser language
- 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:
- Enable the
msp-i18n-enabledflag locally - Switch to locale
xxin the language picker - Navigate the feature area
- Every user-visible string should show
11111 - 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:
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
- Add the locale code to
packages/core/src/lib/i18n/config.tsinsupportedLocalesandlocaleNames - Copy
server/public/locales/en/toserver/public/locales/<code>/and translate all files - Create email template migrations (see
server/migrations/20251228123000_add_polish_email_templates.cjsfor reference) - Create internal notification template migrations (see
server/migrations/20251228120000_add_polish_internal_notification_templates.cjs) - Add language to
languageNamesmapping inserver/src/components/settings/notifications/EmailTemplates.tsx(3 locations) - Run
node scripts/generate-pseudo-locales.cjsandnode scripts/validate-translations.cjs - Test in browser with both portals
Common Pitfalls
- Wrong import path — use
@alga-psa/ui/lib/i18n/client, not a server path - Hardcoded locale in formatting — use
useFormatters(), nottoLocaleDateString('en-US') - Missing route in ROUTE_NAMESPACES — if
t('key')returns the key itself, the namespace likely isn't loaded for that route - German/Dutch text overflow — these translations are 30-50% longer than English; test layouts
- Forgetting toast/error messages —
toast.success(),toast.error(),setError()calls are easy to miss - Missing aria-labels and tooltips — accessibility text must also be translated
- Currency symbol placement — use
formatCurrency()which handles per-locale placement - Enum labels from
*_DISPLAY/*_OPTIONSconstants — labels imported from a constant file never reacht()and never appear in the audit suite. Use the enum-labels option-hook pattern (useXOptions()/useFormatX()) instead. Applies to inlineconst fooOptions = [{ value, label: 'Xyz' }, ...]arrays inside components as well.