Przejdź do głównej zawartości

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​

  1. Zaloguj się do Admin UI.
  2. Otwórz stronę Profile (avatar prawy górny róg albo badge EN / PL w topbarze).
  3. Wybierz English lub Polski w sekcji Language, potem kliknij Save language.
  4. 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 + PathPurpose
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-languageUstawia 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​

  1. Zadeklaruj katalog bundle w manifeście:

    // packages/modules/<my_module>/src/manifest.ts
    import { 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
    });
  2. Autoruj pliki JSON per język w packages/modules/<my_module>/i18n/<lang>.json — root pakietu, nie pod src/, bo bundlesDir rozwiązuje się względem katalogu trzymającego package.json modułu. Kształt to płaski Record<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}\"."
    }
  3. 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>;
    }
  4. Zrestartuj (lub re-install) backend. Boot-time bundle reconciler przechodzi każdy moduł deklarujący manifest.i18n i odświeża wiersze translation_bundles z 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:

  1. Wpis preferowanego języka użytkownika pod scope modułu.
  2. Wpis angielski pod scope modułu.
  3. 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), JSONB entries, version domyślnie nextval(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 powodu BundleLoadError (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.