Admin UI Languages
Preferencja języka per użytkownik dla Admin UI plus pipeline tłumaczeń scoped per moduł, który pozwala każdemu modułowi backendu dostarczać własny bundle przetłumaczonych stringów. Polski i angielski są dostarczane na start; angielski to platform-wide fallback.
Sam subsystem to pakiet workspace @endora-commerce/mod-i18n, w
packages/modules/_i18n/ (platform-internal — leading underscore, który npm name
pomija). Jego bundle tłumaczeń siedzą w root pakietu,
packages/modules/_i18n/i18n/, bo platforma kotwiczy bundlesDir modułu do
własnego katalogu modułu, a własnym katalogiem pakietu jest miejsce jego
package.json. Runtime admin SPA żyje w admin/src/i18n/.
Jak użytkownik zmienia język
- Zaloguj się do Admin UI.
- Otwórz stronę Profile (avatar prawy górny róg albo badge
EN/PLw topbarze). - Wybierz English lub Polski w sekcji Language, potem kliknij Save language.
- Cały Admin UI re-renderuje się w wybranym języku. Wylogowanie nie jest wymagane.
Wybór jest zapisywany na rekordzie użytkownika i podąża za użytkownikiem między urządzeniami: logowanie z innej przeglądarki lub maszyny daje ten sam język Admin UI co ostatni zapisany wybór.
Nowi użytkownicy (i ci, którzy nigdy nie dokonali wyboru) domyślnie widzą angielski.
Publiczne API
| Verb + Path | Purpose |
|---|---|
GET /api/v1/admin/i18n/bundles?language=<en|pl> | Zwraca scalony bundle core + per-moduł dla żądanego języka plus monotonicznie-nierosnący wektor version, którego SPA używa do wykrywania odświeżeń. Uprawnienie: dowolny uwierzytelniony admin. |
PATCH /api/v1/admin/me/preferred-language | Ustawia preferowany język wołającego użytkownika. Body: { "preferredLanguage": "en" | "pl" | null }. Idempotentne; null wraca do „brak zapisanej preferencji” → fallback angielski. Uprawnienie: dowolny uwierzytelniony admin. |
Odpowiedź session-bootstrap (GET /api/v1/admin/me) niesie preferredLanguage w
obiekcie adminUser, żeby SPA mogło zasiać <TranslationProvider> bez dodatkowego
round-trip.
Jak moduł dostarcza tłumaczenia
-
Zadeklaruj katalog bundle w manifeście:
// packages/modules/<my_module>/src/manifest.tsimport { defineModuleManifest } from '@endora-commerce/contracts';export const manifest = defineModuleManifest({id: 'my_module',name: 'My module',version: '1.0.0',dependencies: [],i18n: { bundlesDir: 'i18n' }, // ← add this}); -
Autoruj pliki JSON per język w
packages/modules/<my_module>/i18n/<lang>.json— root pakietu, nie podsrc/, bobundlesDirrozwiązuje się względem katalogu trzymającegopackage.jsonmodułu. Kształt to płaskiRecord<string, string>— klucze są kropkowane ("actions.save"), wartości to stringi. Placeholdery w stylu{name}są interpolowane w runtime.// packages/modules/my_module/i18n/en.json{"actions.save": "Save","validation.required": "This field is required.","audit.userCreated": "Created user \"{email}\"."} -
Zamień inline stringi w kodzie admin na wywołania
useTranslation(scope):import { useTranslation } from '@/i18n/useTranslation';export function MyForm() {const t = useTranslation('my_module');return <button>{t('actions.save')}</button>;} -
Zrestartuj (lub re-install) backend. Boot-time bundle reconciler przechodzi każdy moduł deklarujący
manifest.i18ni odświeża wierszetranslation_bundlesz plików JSON na dysku. Błędy są logowane, ale nie przerywają bootu.
Reguły i ograniczenia
- Obsługiwany zestaw to obecnie
['en', 'pl'](zamknięty enum w@endora-commerce/contracts/src/admin-i18n.ts). - Pliki dla nieobsługiwanych języków są odrzucane przy install.
- Moduł, który dostarcza jakikolwiek bundle, MUSI dostarczyć
en.json(angielski to platform-wide fallback). Polski bundle jest zachęcany, ale opcjonalny — dostarcz go w tym samym PR co plik angielski. - Dwa moduły MOGĄ używać tego samego klucza (np.
actions.save); każdy bundle jest scoped do modułu właściciela, więc stringi się nie kolidują. - Treść autorowana przez użytkownika (strony CMS, nazwy produktów, posty bloga, wartości settingów) jest poza zakresem tej funkcji — renderuje się tak, jak napisana, bez ponownego tłumaczenia.
Jak resolver wybiera string
Każde lookup przechodzi trzyetapowy łańcuch fallback:
- Wpis preferowanego języka użytkownika pod scope modułu.
- Wpis angielski pod scope modułu.
- Literalny placeholder
${scope}.${key}(zawsze niepusty, żeby UI nigdy nie było puste).
Zarówno backend resolver (I18nService.translate(...)) jak i admin SPA resolver
(admin/src/i18n/resolver.ts) dzielą ten sam łańcuch.
Diagnozowanie brakujących tłumaczeń
Gdy resolver robi fallback, backend zapisuje strukturalną linię logu przez istniejący logger platformy:
{ "event": "i18n.fallback", "moduleId": "settings", "languageCode": "pl", "key": "actions.save", "fellBackTo": "en" }
Operatorzy mogą odpowiedzieć „czego brakuje w polskim bundle?” jednym grepem:
grep '"event":"i18n.fallback"' /var/log/b2b-backend.log | jq -r '"\(.languageCode) \(.moduleId).\(.key) → \(.fellBackTo)"' | sort -u
Admin SPA emituje tę samą lukę jako console.warn w development (import.meta.env.DEV)
i milczy w produkcji.
Baza danych
Jedna migracja MikroORM (040_admin_i18n_init.ts) wprowadza:
- Sekwencję
translation_bundles_version_seq(wektor invalidacji cache). - Tabelę
translation_bundles— jeden wiersz per(module_id, language_code), JSONBentries,versiondomyślnienextval(translation_bundles_version_seq), więc każdy UPSERT posuwa sekwencję. - Kolumnę
admin_users.preferred_language(varchar(12), nullable). NULL oznacza „brak zapisanej preferencji” → resolver traktuje jak angielski.
Brak FK na translation_bundles.module_id — moduły są filesystem-driven, a
hard-uninstall hook to mechanizm cleanup, nie ON DELETE CASCADE.
Dodanie trzeciego języka
Dodanie de (lub innego kodu BCP-47) traktuje się jako osobną funkcję, bo koszt to głównie
praca tłumaczeniowa, nie inżynieria. Zmiana schematu to jedna linia w
@endora-commerce/contracts/src/admin-i18n.ts:
export const SupportedAdminLanguageSchema = z.enum(['en', 'pl', 'de']);
Potem każdy moduł emitujący stringi widoczne w adminie musi dostarczyć de.json (albo
zaakceptować fallback EN). Selektor na stronie Profile podchwytuje nowy kod automatycznie.
Testy
Testy jednostkowe siedzą obok implementacji:
backend/test/unit/_i18n/bundle-loader.unit.test.ts— każda ścieżka powoduBundleLoadError(parse-failed, invalid-shape, unsupported-language-file, missing-fallback-bundle) plus happy path i akceptacja EN-only.backend/test/unit/_i18n/missing-key-logger.unit.test.ts— kształt strukturalnej linii logu.backend/test/unit/_i18n/i18n-service.unit.test.ts— trzyetapowy łańcuch fallback plus interpolacja.admin/test/i18n/resolver.test.ts— ten sam łańcuch po stronie SPA.admin/test/i18n/interpolate.test.ts— regex placeholderów.