Skip to content

Commit d62a0ea

Browse files
authored
feat: add internationalization (i18n) support (#164) (#1765)
* feat: add internationalization (i18n) support (#164) Introduces full i18n support using next-intl with English (en) and German (de) catalogs, persistent per-user locale preference, locale-aware formatting, and a standardised server-side error code system so messages can be translated client-side. Highlights: - next-intl integration with deep-merged English fallback for missing keys - LanguageSwitcher component + setLocale GraphQL mutation - bm_web_users.locale column + persisting/loading user preference - ExposedError now carries stable code + meta; surfaced via GraphQL formatError and Koa error middleware (BAD_USER_INPUT messages pass through verbatim per the documented out-of-scope policy) - Locale-aware date formatting via dynamic date-fns locale loading with promise dedupe and eager default-locale preload - Localised react-select and @nateradebaugh/react-datetime widgets - Comprehensive message extraction across components, pages, notifications - New server/data/error-translation.js shared by REST + GraphQL helpers - Added unit tests: error-translation, formatError, locale helpers, setLocale mutation, me.locale field - Documentation: README + CONTRIBUTING updates covering localisation workflow, adding new locales, and ExposedError compatibility note * fix(i18n): preserve password length detail via INVALID_PASSWORD_LENGTH The Cypress login error test expects "Invalid password, minimum length 6 characters", but with i18n the client looks up the error code in the translation catalog. The shared INVALID_PASSWORD code mapped to the generic "Password is invalid", losing the actionable length detail. Split into two codes so the message stays specific without baking the magic number into translations: - INVALID_PASSWORD: kept generic ("Password is invalid"), used for the unreachable type-mismatch case. - INVALID_PASSWORD_LENGTH: parameterised via meta.minLength so the translated string ("Invalid password, minimum length {minLength} characters") matches the original UX. Also exercises the meta pass-through end-to-end (Koa REST + GraphQL), proving the translation pipeline works for parameterised messages.
1 parent 7fd42e1 commit d62a0ea

165 files changed

Lines changed: 3339 additions & 463 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTRIBUTING.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,77 @@ In order for us to help you please check that you've completed the following ste
2020
* Develop in a topic branch, not master (e.g. `feature/new-view`)
2121
* Write a convincing description of your PR and why we should land it
2222
* If you write JavaScript, make sure you follow https://standardjs.com/
23+
24+
## Localisation
25+
26+
The UI uses [`next-intl`](https://next-intl.dev/) for client-side translations. Translation catalogues live in [`messages/`](messages/) and the locale registry / negotiation logic lives in [`server/data/locales.js`](server/data/locales.js) (re-exported by [`utils/locale.js`](utils/locale.js) for client code).
27+
28+
### Authoring rules
29+
30+
* Always introduce new user-facing text via `useTranslations(...)` (or `t.rich(...)` when the message contains markup or interpolated React elements). Never hard-code English strings in components.
31+
* Group keys by feature area: `common.*`, `nav.*`, `forms.*`, `pages.<page>.*`, `errors.*`, `widgets.*`, `notifications.*`, `components.*`.
32+
* Use ICU MessageFormat for plurals/interpolation: `"Removed {count, plural, one {# item} other {# items}}"`.
33+
* Keep `messages/en.json` as the canonical source of truth. Every other locale must mirror its key structure exactly.
34+
35+
### Adding a string
36+
37+
1. Add the key + English copy to `messages/en.json`.
38+
2. Add a translation under the same key in every other locale file (`messages/de.json`, etc.). If a translation is unavailable at submission time, copy the English value as a placeholder so the key still resolves.
39+
3. Reference the key from a component:
40+
```js
41+
import { useTranslations } from 'next-intl'
42+
43+
const t = useTranslations('pages.login')
44+
return <h1>{t('title')}</h1>
45+
```
46+
47+
### Adding a new locale
48+
49+
1. Add the locale code to `SUPPORTED_LOCALES` in `server/data/locales.js`.
50+
2. Extend `LOCALE_CONFIG` in `utils/locale.js` with `label` (native name shown in the switcher), `htmlLang`, `openGraphLocale`, `dateFormat`, and `dateFnsLocale` (must match a [`date-fns` locale module](https://date-fns.org/v4.1.0/docs/I18n)).
51+
3. Add the locale entry to `dateFnsLocaleLoaders` in `utils/format-distance.js` so dynamic imports work.
52+
4. Create `messages/<locale>.json` with a 1:1 copy of `messages/en.json`, then translate.
53+
5. Run `npm run build` and `npm run test` to verify the locale loads and tests pass.
54+
55+
The `LanguageSwitcher` component automatically picks up new locales from `SUPPORTED_LOCALES` and renders them using the `label` from `LOCALE_CONFIG`.
56+
57+
### Server-side error messages
58+
59+
Errors thrown from GraphQL resolvers, GraphQL directives, REST routes, and webhook handlers are translated client-side via stable error codes — **not** by translating the server's English text.
60+
61+
* Always pass an error code as the second argument to `ExposedError`:
62+
```js
63+
throw new ExposedError('Server not found', 'SERVER_NOT_FOUND')
64+
```
65+
* For dynamic content (e.g. plurals, names), pass a `meta` object as the third argument:
66+
```js
67+
throw new ExposedError('This password isn\'t safe…', 'PASSWORD_COMPROMISED', { count: 5 })
68+
```
69+
* In Koa REST routes, call `ctx.throw(status, message, { code: 'STABLE_CODE', meta: {...} })` so the JSON body exposes `code` and `meta`. The `code` and `meta` properties are surfaced by the error middleware in [`server/app.js`](server/app.js).
70+
* Add a corresponding key to `messages/<locale>.json` under `errors.<CODE>` for every supported locale. Use ICU placeholders matching the `meta` keys.
71+
* The client UI (`components/ErrorMessages.js`/`utils/locale.js#translateGraphqlError` for GraphQL, `utils/locale.js#translateRestError` for REST) looks up the code and falls back to the server's message if no translation exists.
72+
* `BAD_USER_INPUT` errors (raised by `graphql-constraint-directive`) are intentionally **not** assigned an `appCode`, so the constraint message is shown verbatim. See the out-of-scope list below.
73+
74+
> **Compatibility note**: `ExposedError` now defaults `code` to `'UNKNOWN'` (and `extensions.appCode` to `'UNKNOWN'`) when no second argument is supplied. Previously this field was `undefined`. Any downstream consumer that checks `if (!err.code)` should be updated to check for the literal string `'UNKNOWN'` instead. New call-sites must always pass an explicit, stable code.
75+
76+
### Out-of-scope (explicitly NOT translated)
77+
78+
The following surfaces are intentionally left in English to keep the scope manageable:
79+
80+
* The setup/installer SPA under `server/setup/static/` (run-once flow).
81+
* CLI command output (`cli/commands/**`, `cli/utils/**`).
82+
* `graphql-constraint-directive` validation messages.
83+
* User-generated content (player names, ban reasons, appeal/report comments, custom roles, server names, document content).
84+
* Server logs and Pino output (`logger.*` calls).
85+
86+
If you want to localise any of these, please open an issue first to discuss scope.
87+
88+
### Manual verification
89+
90+
Before submitting a PR that touches localisation:
91+
92+
1. Toggle the language switcher in the top-right and confirm the page re-renders in the chosen language.
93+
2. Reload the page and confirm the choice persisted via the `bm_locale` cookie.
94+
3. If you're logged in, confirm `setLocale` ran and the preference survives a logout/login cycle.
95+
4. Check that `<html lang="…">` updates to match (DevTools → Elements panel).
96+
5. Trigger a known server error (e.g. submit invalid login) and verify the message renders in the active locale.

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,22 @@ npm run test
218218
npm run cypress
219219
```
220220

221+
## Localisation
222+
223+
The UI is localised with [`next-intl`](https://next-intl.dev/). Currently shipping languages: **English (`en`)** and **German (`de`)**.
224+
225+
For details on adding a new language, translating new strings, or wiring up server-side error codes, see [`CONTRIBUTING.md`](CONTRIBUTING.md#localisation).
226+
227+
Locale resolution order (highest priority first):
228+
229+
1. Authenticated user's stored preference (`bm_web_users.locale`)
230+
2. `bm_locale` cookie (set by the in-app language switcher)
231+
3. `Accept-Language` HTTP header
232+
4. Fallback to `en`
233+
221234
## Contributing
222235

223-
If you'd like to contribute, please fork the repository and use a feature branch. Pull requests are warmly welcome.
236+
If you'd like to contribute, please fork the repository and use a feature branch. Pull requests are warmly welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines on translations, error codes, and other development conventions.
224237

225238
## Help / Bug / Feature Request
226239

components/DateTimePicker.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { forwardRef } from 'react'
22
import Datetime from '@nateradebaugh/react-datetime'
3+
import { useLocale } from 'next-intl'
34
import clsx from 'clsx'
5+
import { LOCALE_CONFIG, DEFAULT_LOCALE } from '../utils/locale'
6+
import { useDateFnsLocale } from '../utils/format-distance'
47

58
// eslint-disable-next-line react/display-name
6-
const DateTimePicker = forwardRef(({ className = '', inputClassName = '', error = false, disabled = false, icon = null, iconPosition = 'left', ...rest }, ref) => {
9+
const DateTimePicker = forwardRef(({ className = '', inputClassName = '', error = false, disabled = false, icon = null, iconPosition = 'left', dateFormat, ...rest }, ref) => {
10+
const locale = useLocale()
11+
const dateFnsLocale = useDateFnsLocale()
12+
const localeConfig = LOCALE_CONFIG[locale] || LOCALE_CONFIG[DEFAULT_LOCALE]
13+
const resolvedDateFormat = dateFormat || localeConfig.dateFormat
714
const inputClass = clsx(`
815
flex-1
916
appearance-none
@@ -46,7 +53,7 @@ const DateTimePicker = forwardRef(({ className = '', inputClassName = '', error
4653
<span className='rounded-l-3xl inline-flex items-center px-3 bg-primary-900 text-gray-400 text-lg'>
4754
{icon}
4855
</span>}
49-
<Datetime ref={ref} dateFormat='dd/MM/yyyy' className={inputClass} {...rest} />
56+
<Datetime ref={ref} dateFormat={resolvedDateFormat} locale={dateFnsLocale || undefined} className={inputClass} {...rest} />
5057
</div>
5158
)
5259
})

components/DefaultLayout.js

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { useEffect, useMemo } from 'react'
22
import Head from 'next/head'
33
import { NextSeo } from 'next-seo'
44
import Favicon from 'react-favicon'
5+
import { useTranslations } from 'next-intl'
56
import { MdLogin } from 'react-icons/md'
67
import Footer from './Footer'
78
import Nav from './Nav'
89
import SessionNavProfile from './SessionNavProfile'
10+
import LanguageSwitcher from './LanguageSwitcher'
911
import PlayerSelector from './admin/PlayerSelector'
1012
import { useApi, useUser } from '../utils'
1113
import { useRouter, withRouter } from 'next/router'
@@ -17,20 +19,29 @@ const query = `query unreadNotificationCount {
1719
unreadNotificationCount
1820
}`
1921

20-
const LoggedOutNav = () => (
21-
<div className='hidden md:flex gap-4'>
22-
<Link href='/login' passHref>
23-
<Button className='text-sm'>
24-
<MdLogin className='mr-2' /> Login
25-
</Button>
26-
</Link>
27-
</div>
28-
)
22+
const langButtonClassName = 'bg-primary-900 !rounded-lg p-2 transform transition duration-300 hover:scale-110 hover:bg-primary-900 w-12 h-12 items-center'
23+
24+
const LoggedOutNav = () => {
25+
const t = useTranslations('nav')
26+
27+
return (
28+
<div className='hidden md:flex gap-4 items-center'>
29+
<LanguageSwitcher buttonClassName={langButtonClassName} />
30+
<Link href='/login' passHref>
31+
<Button className='text-sm'>
32+
<MdLogin className='mr-2' /> {t('login')}
33+
</Button>
34+
</Link>
35+
</div>
36+
)
37+
}
2938

3039
function DefaultLayout ({ title = 'Default Title', children, description, loading }) {
3140
const { user } = useUser()
3241
const { data } = useApi({ query: user?.id ? query : null }, { refreshInterval: 10000, refreshWhenHidden: true })
3342
const router = useRouter()
43+
const tForms = useTranslations('forms')
44+
const tNav = useTranslations('nav')
3445

3546
useEffect(() => {
3647
if (data) {
@@ -59,11 +70,11 @@ function DefaultLayout ({ title = 'Default Title', children, description, loadin
5970
<PlayerSelector
6071
multiple={false}
6172
onChange={(id) => id ? router.push(`/player/${id}`) : undefined}
62-
placeholder='Search player'
73+
placeholder={tForms('playerSelectorPlaceholder')}
6374
/>
6475
</div>]
6576
const right = useMemo(() => user?.id ? [<SessionNavProfile key='session-nav-profile' user={user} unreadNotificationCount={data?.unreadNotificationCount} />] : [<LoggedOutNav key='nav-logged-out' />], [user, data])
66-
const mobileItems = useMemo(() => !user?.id ? [{ name: 'Login', href: '/login' }, { name: 'Create Appeal', href: '/appeal' }] : [], [user])
77+
const mobileItems = useMemo(() => !user?.id ? [{ name: tNav('login'), href: '/login' }, { name: tNav('createAppeal'), href: '/appeal' }] : [], [user, tNav])
6778

6879
return (
6980
<>

components/ErrorMessages.js

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { forwardRef } from 'react'
2+
import { useTranslations } from 'next-intl'
23
import Message from './Message'
34
import { uniqBy } from 'lodash-es'
5+
import { translateGraphqlError } from '../utils/locale'
46

57
// eslint-disable-next-line react/display-name
68
const ErrorMessages = forwardRef(({
@@ -10,44 +12,46 @@ const ErrorMessages = forwardRef(({
1012
parseError,
1113
errors
1214
}, ref) => {
15+
const t = useTranslations()
16+
1317
return (
1418
<>
1519
{error && (
1620
<Message ref={ref} error>
17-
<Message.Header>Error</Message.Header>
21+
<Message.Header>{t('errors.header')}</Message.Header>
1822
<Message.List data-cy='errors'>
19-
<Message.Item>{error.message}</Message.Item>
23+
<Message.Item>{translateGraphqlError(t, error)}</Message.Item>
2024
</Message.List>
2125
</Message>
2226
)}
2327
{fetchError && (
2428
<Message ref={ref} error>
25-
<Message.Header>Fetch Error</Message.Header>
29+
<Message.Header>{t('errors.fetchHeader')}</Message.Header>
2630
<Message.List>
2731
<Message.Item>{fetchError}</Message.Item>
2832
</Message.List>
2933
</Message>
3034
)}
3135
{httpError && (
3236
<Message ref={ref} error>
33-
<Message.Header>HTTP error: {httpError.status}</Message.Header>
37+
<Message.Header>{t('errors.httpHeader', { status: httpError.status })}</Message.Header>
3438
{httpError.statusText && <Message.List><Message.Item>{httpError.statusText}</Message.Item></Message.List>}
3539
</Message>
3640
)}
3741
{parseError && (
3842
<Message ref={ref} error>
39-
<Message.Header>Parse Error</Message.Header>
43+
<Message.Header>{t('errors.header')}</Message.Header>
4044
<Message.List>
4145
<Message.Item>{parseError}</Message.Item>
4246
</Message.List>
4347
</Message>
4448
)}
4549
{errors && (
4650
<Message ref={ref} error>
47-
<Message.Header>Error</Message.Header>
51+
<Message.Header>{t('errors.header')}</Message.Header>
4852
<Message.List data-cy='errors'>
49-
{uniqBy(errors, 'message').map(({ message }, index) => (
50-
<Message.Item key={index}>{message}</Message.Item>
53+
{uniqBy(errors, 'message').map((err, index) => (
54+
<Message.Item key={index}>{translateGraphqlError(t, err)}</Message.Item>
5155
))}
5256
</Message.List>
5357
</Message>

components/Footer.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import Image from 'next/image'
2+
import { useTranslations } from 'next-intl'
23
import { useApi } from '../utils'
4+
import LanguageSwitcher from './LanguageSwitcher'
35

46
const query = `
57
query settings {
@@ -10,26 +12,32 @@ const query = `
1012
}`
1113

1214
export default function Footer () {
15+
const t = useTranslations('footer')
16+
const tCommon = useTranslations('common')
1317
const { data } = useApi({ query }, {
1418
revalidateIfStale: false,
1519
revalidateOnFocus: false,
1620
revalidateOnReconnect: false
1721
})
1822
const currentYear = new Date().getFullYear()
23+
const fallbackName = `${t('powered')} ${tCommon('siteName')}`
1924

2025
return (
2126
<footer className='text-gray-300 bg-primary-900'>
22-
<div className='container px-5 py-8 mx-auto flex items-center sm:flex-row flex-col'>
27+
<div className='container px-5 py-8 mx-auto flex items-center sm:flex-row flex-col gap-2'>
2328
<a className='flex title-font font-medium items-center md:justify-start justify-center'>
24-
<Image src={(process.env.BASE_PATH || '') + '/images/banmanager-icon.png'} alt='Logo' width='35' height='35' />
25-
<span className='ml-3 text-xl'>{data?.settings?.serverFooterName || 'Powered by BanManager'}</span>
29+
<Image src={(process.env.BASE_PATH || '') + '/images/banmanager-icon.png'} alt={tCommon('siteName')} width='35' height='35' />
30+
<span className='ml-3 text-xl'>{data?.settings?.serverFooterName || fallbackName}</span>
2631
</a>
2732
<p className='text-sm sm:ml-4 sm:pl-4 sm:border-l-2 sm:border-gray-400 sm:py-2 sm:mt-0 mt-4'>
2833
&copy; {currentYear}
2934
</p>
3035
<p className='text-sm sm:pl-4 sm:py-2 sm:mt-0 mt-4'>
3136
{data?.settings?.additionalFooterText}
3237
</p>
38+
<div className='sm:ml-auto'>
39+
<LanguageSwitcher buttonClassName='!bg-primary-800 hover:!bg-primary-700 text-sm px-3 py-2' variant='inline' />
40+
</div>
3341
</div>
3442
</footer>
3543
)

0 commit comments

Comments
 (0)