Translatable labels pattern — the standard for every form and label
Every label, button, column header, section header, empty-state message, and error string in the frontend goes through the translation system — never a bare English string literal in JSX or in code that surfaces to the user. This is the standing default; build new UI this way unless explicitly told otherwise for that specific case.
Languages are real data, not a hardcoded list
Languages come from MAS_CHOICES where ChoiceType = 'LANGUAGES' — the same
generic legacy picklist table used for any future choice-driven dropdown,
composite key (ChoiceType, ChoiceCode).
Backend: IChoiceRepository/ChoiceRepository
(Application/Common/Interfaces, Infrastructure/Persistence/Repositories/Mas)
exposes ListByTypeAsync (cached per choiceType, 1hr TTL, same lookup-table
caching rule as City/Country) and GetImageAsync (live, uncached blob read).
ChoicesController (Api/Controllers/Operations) serves both — GetImage is
deliberately [AllowAnonymous] because it's consumed via a plain <img src>
tag, which never sends the Authorization header (the same reasoning already
documented for CompaniesController.GetLogo in the territory-access pattern —
apply this to any future choice-image or logo endpoint consumed the same way).
Frontend building blocks
LanguageProvider/useLanguage()(src/i18n/LanguageContext.tsx) fetches the real language list once, holds the currentlanguageCode(persisted tolocalStorage), and is the outermost provider inApp.tsx— aboveAuthProvider— soLoginPagecan translate too, even though the real language-list fetch requires auth and silently comes back empty pre-login (falls back toEN).LanguageSelector(src/layout/LanguageSelector.tsx) renders one flag per real language row — the flag is the realChoiceImageblob viachoiceImageUrl(choiceType, choiceCode), not an emoji — falling back to the raw code text if the entity has no image or the image fails to load. Selecting one callssetLanguageCode, which immediately changes whatuseTranslationreads from.useTranslation()(src/i18n/useTranslation.ts) is the one hook every component uses:
t(key: string, fallback: string, params?: Record<string, string | number>): string
:::tip fallback is required, not optional
If a dictionary entry is missing (very plausible when a dictionary is
authored incrementally, e.g. by several parallel passes), the UI must show
the correct English text instead of a broken raw key string. That's why
fallback is a required parameter, not optional.
:::
Dynamic text uses {placeholder} tokens in the fallback plus a params
object, e.g.:
t("cities.editCity", "Edit {code}", { code: city.code })
Never string-concatenate a translated fragment with a raw value.
Per-namespace dictionary files
src/i18n/en/<namespace>.ts, one file per page/area (common.ts, shell.ts,
login.ts, plus one per master-data entity: cities.ts, countries.ts,
branches.ts, companies.ts, clients.ts, warehouses.ts,
departments.ts, equipment.ts, forumTypes.ts, queues.ts,
trCodes.ts, addresses.ts) — merged in src/i18n/en/index.ts.
One file per namespace specifically so adding a new page's dictionary is a
new file + one import line in index.ts, never an edit to a file another
page's translation work is also touching — this matters in practice because
this rollout was done with one parallel agent per entity, each only touching
its own dictionary file and its own page.
common.* carries every generic, reused-everywhere string — Code, Name,
Description, Status, Active, Inactive, Actions, Search, Save, Saving…,
Cancel, Edit, Delete, Confirm, the delete-confirm dialog's title/message, the
grid pager's "Showing X–Y of Z"/Rows/Prev/Next/Page-of strings — reuse these
instead of duplicating the same word into a per-entity file. Entity-specific
strings (page title, "New X" button, empty-state text,
failed-to-create/update/delete messages, field/column labels unique to that
entity) live in that entity's own file.
Shared components translate themselves
DataGrid.tsx (the pager status bar), ConfirmDialog.tsx (default
Cancel/Confirm labels), and the shell (TopBar.tsx, SideNav.tsx's chrome,
LoginPage.tsx, DashboardPage.tsx, BranchSwitcherModal.tsx,
ChangePasswordModal.tsx, IconPanelButton.tsx) all call useTranslation()
themselves — a page never needs to pass translated strings down as props for
these.
:::note SideNav.tsx exception
SideNav.tsx deliberately does not translate dynamic menu item text
(real MAS_MENU/MAS_APP_MENU data — the sidebar is 100% server-driven; the
client-side staticMenu.ts stand-in this used to reference no longer
exists, removed once real menu data replaced it) — only its own chrome
(search placeholder, no-results text, collapse toggle). Menu item labels are
content, not form labels, and are out of scope for this pattern until the
menu data itself grows translation support.
:::
Data fetching stays as-is
The language list fetch uses the existing axios + useEffect/useState
pattern, no new data-fetching library introduced for this.
Reference files
ChoiceDto.cs, IChoiceRepository.cs, ChoiceRepository.cs,
ChoicesController.cs, useTranslation.ts, LanguageContext.tsx,
LanguageSelector.tsx, i18n/en/common.ts, i18n/en/cities.ts,
CitiesPage.tsx (fully converted, the template every other page's
conversion followed).