# Adding a language

> Add a new language to the SPA, where translation namespaces live, and how strings are looked up.

## Where i18n lives

Everything for translations sits under `frontend/src/shared/i18n/`:

```
frontend/src/shared/i18n/
  i18n.ts               // i18next init, SUPPORTED_LOCALES, LOCALE_LABELS
  index.ts              // public re-exports
  LanguageSwitcher.tsx  // the header dropdown
  errorCodeMap.ts       // FluentValidation code -> i18n key
  locales.test.ts       // keeps locales in sync
  locales/
    en.json             // the source of truth
    nl.json
```

`i18n.ts` initialises i18next with every locale bundled at build time (no async loading). The detected
locale is persisted in `localStorage` under `slicekit.locale`, falling back to the browser's `navigator`
language, then to `en`:

```ts

  en: 'English',
  nl: 'Nederlands',
};
```

`` is mounted in both the public layout and the authenticated header. It maps over
`SUPPORTED_LOCALES`, labels each entry with `LOCALE_LABELS`, and calls `i18n.changeLanguage(locale)`.
It renders nothing when only one locale is configured, so the dropdown appears the moment you add a
second language.

## How strings are looked up

There are **no hard-coded UI strings.** Every user-visible string goes through `t('namespace.key')`
from `useTranslation()`:

```tsx
const { t } = useTranslation();
return {t('common.save_changes')};
```

Keys are namespaced by surface, so each feature owns its strings: `common.*` for reusable actions
(`cancel`, `save_changes`, `copy`), `nav.*` for navigation, `auth.<page>.*` for the auth screens,
`account.*` for settings, `validation.*` for form messages, and `errors.*` for `AppError` details keyed
by camelCase `errorCode`. Reuse `common.*` before inventing a key: adding `account.cancel_changes` when
`common.cancel` exists creates drift that translators have to keep in sync by hand.

`en.json` is the source of truth. Every other locale file mirrors its key structure exactly.

## Adding a language

Suppose you are adding German (`de`).

### 1. Add the locale file

Copy `en.json` as a starting point so every key is present, then translate the values:

```sh
cp frontend/src/shared/i18n/locales/en.json frontend/src/shared/i18n/locales/de.json
```

Keep the keys identical to `en.json`. The sync test (below) fails if a key is missing.

### 2. Register the locale in `i18n.ts`

Import the file, add it to the i18next `resources`, append the code to `SUPPORTED_LOCALES`, and give it
a label:

```ts

  en: 'English',
  nl: 'Nederlands',
  de: 'Deutsch', // new
};

void i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: {
      en: { translation: en },
      nl: { translation: nl },
      de: { translation: de }, // new
    },
    fallbackLng: 'en',
    supportedLngs: SUPPORTED_LOCALES,
    // ...
  });
```

`Locale` is derived from `SUPPORTED_LOCALES`, so `LOCALE_LABELS` and the switcher stay type-checked
against the new code automatically.

### 3. The language switcher wires itself up

No change needed. `LanguageSwitcher.tsx` reads `SUPPORTED_LOCALES` and `LOCALE_LABELS`, so the new
entry shows up in the dropdown as soon as it is registered.

### 4. Keep the locale in sync

`locales.test.ts` walks every key in `en.json` and asserts each other locale contains it. Wire the new
locale into the completeness check next to `nl`:

```ts

describe('de.json completeness', () => {
  const enKeys = collectKeys(en);
  const deKeys = new Set(collectKeys(de));

  it.each(enKeys)('de.json has key "%s"', (key) => {
    expect(deKeys.has(key)).toBe(true);
  });
});
```

The same file also checks that every i18n key referenced by `VALIDATION_CODE_MAP` (the FluentValidation
code to key map in `errorCodeMap.ts`) resolves in each locale, so server-driven validation messages
cannot ship half-translated. Run it with `pnpm test`.

## Interpolation and markup

Pass variables as the second argument; i18next substitutes `{{name}}` placeholders:

```ts
t('dashboard.welcome', { name });
// en.json: "welcome": "Welcome, {{name}}"
// de.json: "welcome": "Willkommen, {{name}}"
```

For strings that contain markup, use `` rather than concatenating JSX, so translators can
reorder fragments freely:

```tsx
 }}
/>
```

## Error message fallbacks

`AppError` falls back to `t('errors.request_failed', { status })` when the server returns no
`ProblemDetails` body (a network failure, or a 5xx with no payload). That key must exist in every
locale. For known error codes the lookup is `errors.{camelCase(errorCode)}`; how the typed client
resolves them is covered in [the typed API client](/docs/api-client).

## Checklist

- [ ] Copied `en.json` to `locales/<code>.json` and translated the values, keeping keys identical.
- [ ] Imported the file in `i18n.ts` and added it to `resources`.
- [ ] Appended the code to `SUPPORTED_LOCALES` and added a `LOCALE_LABELS` entry.
- [ ] Added a `<code>.json` completeness block to `locales.test.ts`.
- [ ] `pnpm test` passes (no missing keys, validation keys resolve).
- [ ] `pnpm dev`, then toggle the language switcher: every visible string changes, including a forced
      400 error toast.

See [the frontend overview](/docs/frontend-overview) for how features and shared modules fit together.
