# Internationalization (i18n) This document describes the i18n scaffolding added to the customer-facing surfaces of the app: the Telegram bot (`bot/`), the Flask-side customer notifications (`bober_bbq/utils/notify_customer.py`, `bober_bbq/utils/telegram_notify.py`), and the React/Vite Mini App (`webapp/`). **The admin panel (`bober_bbq/templates/admin/*`, `bober_bbq/admin/*.py`) is explicitly out of scope and stays hardcoded in Ukrainian** — it's an internal tool used only by Ukrainian-speaking cafe staff. This started as scaffolding proven on one slice (cart/checkout) and has since been extended to cover every customer-facing screen and message: the whole Mini App (menu, cart, checkout, orders, contacts, closed/announcement banners, navigation), every reachable bot message, and — via `Category.name_en` / `Product.name_en` / `Product.description_en` — the menu content itself. See "What's translated today" below for the precise boundary and the handful of things still deliberately left in Ukrainian. ## Why not a framework? The codebase has no existing i18n dependency, and the rest of it favors small, dependency-free patterns already (e.g. `Setting.get(key, default)` in `bober_bbq/models.py`). Pulling in Flask-Babel for the backend or `react-i18next` for the Mini App would mean a new config surface, message-catalog build step, and a heavier mental model for a project this size. Instead: - **Backend**: a plain Python dict of dicts (`TRANSLATIONS`) plus a `t(key, locale, **kwargs)` lookup function, in the same spirit as `Setting.get`. - **Webapp**: a plain TypeScript dict of dicts plus a ~30-line React context + hook (`useI18n()`). Neither needs a build step, a message extractor, or a new dependency. ## Backend: `bober_bbq/utils/i18n.py` ```python from bober_bbq.utils.i18n import t text = t("order.status.confirmed", locale, number=order.display_number()) ``` - `TRANSLATIONS` is a `dict[locale][key] -> template string` (`{placeholder}` syntax, filled in with `str.format(**kwargs)`). - `SUPPORTED_LOCALES = ("uk", "en")`; `DEFAULT_LOCALE = "uk"`. - `normalize_locale(code)` maps an arbitrary code (e.g. Telegram's `language_code`, which can be `"en-US"`, `"uk"`, `"ru"`, etc.) to a supported locale, defaulting to `"uk"` for anything unrecognized — including a locale we simply haven't added yet. - `t()` **never raises and never shows a customer a crash**: a missing key for a supported locale falls back to `uk`; a missing key everywhere returns the raw key (ugly, but visible and non-fatal) — both cases log a `warning` so the gap gets noticed and fixed. ### Locale storage: `TelegramUser.locale` - New nullable column, default `"uk"` (see `bober_bbq/models.py` and the migration in `bober_bbq/migrate.py`, added via `_add_column_if_missing` exactly like every other column in that file — safe to run repeatedly, including against the existing production SQLite database). - Populated **once, on first contact only** (never overwritten on later visits, so a customer's own future language choice — once there's a settings screen for it — won't be silently reset by their Telegram client language): - **Bot**: `bot/services/cart_service.py`'s `get_or_create_user()` takes an optional `language_code` argument, sourced from aiogram's `message.from_user.language_code` in `bot/handlers/start.py`. - **Mini App / Flask API**: `bober_bbq/api/auth.py`'s `_upsert_user()` reads `language_code` out of the Telegram WebApp `initData` payload (Telegram includes it in the `user` JSON automatically). ### Menu content: `Category.name_en` / `Product.name_en` / `Product.description_en` / `Product.weight_en` Product/category names, descriptions, and the weight/volume field (`"300 г"` → `"300 g"`) are admin-entered business data, not UI chrome — they live in the database, not in `TRANSLATIONS`. Each gets an optional nullable `..._en` column (`bober_bbq/models.py`, migrated via the usual `_add_column_if_missing` in `bober_bbq/migrate.py`), filled in from the product/category edit forms in the admin panel (`Назва EN`, `Опис EN`, `Вага/обʼєм EN` — labels are Ukrainian since the admin panel itself stays Ukrainian-only, the *values* typed into them are English). `Category.to_dict(locale)` / `Product.to_dict(locale)` return the `_en` field when `locale == "en"` **and** it's actually been filled in, falling back to the Ukrainian value otherwise — an untranslated product just shows its Ukrainian name/weight in the English UI rather than an empty field. `bober_bbq/api/menu.py`'s `/categories` and `/products` endpoints take an optional `?locale=` query param for this (they have no authenticated user/session to read a stored preference from). The 14 EU-mandated allergen labels (`ALLERGENS` in `bober_bbq/models.py`) are a small, fixed, well-known set, so their English labels live directly in the same dict (`(uk_label, emoji, en_label)`) rather than needing a database column — `Product.allergen_details(locale)` picks the right one. ### What's translated today (backend) - `bober_bbq/utils/notify_customer.py` — every order-status-change message sent to a customer, the cancellation-reason suffix, the pickup-ready address line, and the post-order rating prompt. - `bot/handlers/start.py` — the `/start` welcome message. - `bot/handlers/menu.py` — the `/menu` command's "open the Mini App" hint. - `bot/handlers/contacts.py` — the `/contacts` command and its reply-keyboard button (`bot/keyboards.py`'s `main_menu_keyboard(locale)` — the button label itself is locale-dependent, so the message filter that recognizes a tap on it matches against every locale's variant, not one fixed string). - `bot/handlers/rating.py` — the "already rated" callback answer and all 5 score-specific thank-you messages. - `bober_bbq/api/orders.py` — the customer's own order list's `status_label` (a separate translation namespace, `order.status_label.*`, from the Ukrainian-only `models.ORDER_STATUS_LABELS` the admin panel uses), and the cancel/pay error messages. - `bober_bbq/api/checkout.py` — every customer-facing validation/business error (empty cart, cafe closed, minimum order amount, invalid payment method, invalid cash-change/time input, the two card-payment-failed Telegram messages, the order-received confirmation) and `_contact_field_error`'s name/phone validation messages. - `bober_bbq/utils/workhours.py`'s `closed_message(locale)` and `bober_bbq/utils/promo.py`'s `validate_promo_code(..., locale)` — shared helpers called from checkout, so both the delivery/pickup/dine-in flows and the promo-preview endpoint get the same localized text. All of the above resolve locale from `TelegramUser.locale` — via `g.telegram_ user.locale` in authenticated Flask routes, or a plain `db.session.get (TelegramUser, telegram_id)` lookup in bot handlers that don't already have the user loaded. The two unauthenticated menu-browsing endpoints (`/api/categories`, `/api/products`) are the only exception — they take `?locale=` directly since there's no session to read a stored preference from. **What's still Ukrainian-only by design**: admin-authored free text is never translated automatically — a custom `force_closed_message`, the cafe's phone/address/name in `/contacts` — same reasoning as menu content: it's business data the admin typed, not UI chrome, shown as written regardless of the reader's locale. Order-item names inside order history are historical snapshots (`OrderItem.product_name` at the time of purchase) and are also left as originally captured. ## Webapp: `webapp/src/i18n/` - `translations.ts` — `Record>`, `Locale = "uk" | "en"`. - `index.tsx` — `` (wraps `` in `main.tsx`) + `useI18n()` hook returning `{ locale, setLocale, t }`. - `t(key, vars?)` does `{placeholder}` substitution via a simple regex replace — no ICU plural rules, no framework. - Missing key: warns to the console and falls back to `uk`, then to the raw key — same never-crash philosophy as the backend. - **Initial locale detection** (`detectInitialLocale()`), in priority order: 1. A previously saved manual choice in `localStorage` (`bober_locale`). 2. Telegram's own client language, `Telegram.WebApp.initDataUnsafe.user. language_code` (only present inside the real Telegram Mini App). 3. The browser's `navigator.language`. 4. `"uk"` if nothing else matched or is supported. - A **UA / EN switcher** in the app header (`App.tsx`'s `LocaleSwitch`, `.locale-switch` in `styles.css`) lets a customer override auto-detection at any time. Choosing a locale does two things: saves it to `localStorage` for that browser (so it's stuck to the browser, works offline, and doesn't depend on the API call below succeeding), and fires a fire-and-forget `PATCH /api/me/locale` (`bober_bbq/api/me.py`) that persists the choice to `TelegramUser.locale` server-side — so an explicit in-app choice also applies outside the Mini App session, e.g. the bot's own order-status push notifications, which have no other way to learn about it. A failure to reach that endpoint is silently ignored; the local choice still applies for this browser regardless. ### What's translated today (webapp) Every screen: `MenuPage` + `ProductCard` (empty states, quantity stepper, "Add"/currency), `CartPage` + `CartItemRow`, `CheckoutPage` (all three order types end to end), `OrdersPage` (empty state, payment method/status labels, pay/repeat/cancel buttons, the repeat-order "some items unavailable" message, cancel confirmation dialog), `ContactsPage` (all labels — the phone/address/hours *values* themselves are admin-entered business data, see below), `ClosedScreen` (title + the built-in default message), `BottomNav` tab labels, and `AnnouncementPopup` (dismiss button + aria-label). App-level loading-error text and the open/closed header status line are covered too. Currency is shown as "грн" (uk) / "UAH" (en) via a `common.currency` key. Menu content (product/category names+descriptions) is fetched per-locale from the backend (see above) — `api.categories(locale)` / `api.products (locale)` in `api.ts` re-fetch whenever the switcher changes, they're not translated client-side. ## How to add a new translatable string **Backend**: add the key to `TRANSLATIONS["uk"]` in `bober_bbq/utils/i18n.py` first (it's the source of truth), then to every other locale dict. Call it as `t("your.new.key", locale, **kwargs)`. **Webapp**: add the key to `translations.uk` in `webapp/src/i18n/translations.ts` first, then to every other locale. Call it as `t("your.new.key", { yourVar: value })` from a component that has `const { t } = useI18n();`. In both cases, a key missing from a non-`uk` locale doesn't break anything — it just falls back to Ukrainian and logs a warning, so translations can be filled in incrementally. ## How to add a new language **Backend** (`bober_bbq/utils/i18n.py`): 1. Add the code to `SUPPORTED_LOCALES`. 2. Add a new top-level dict to `TRANSLATIONS` with every key `uk` has. 3. If it should auto-populate from Telegram, no extra work is needed — `normalize_locale()` already accepts any code Telegram sends once it's in `SUPPORTED_LOCALES`. **Webapp** (`webapp/src/i18n/translations.ts`): 1. Add the code to the `Locale` union and `SUPPORTED_LOCALES`. 2. Add a new top-level object to `translations` with every key `uk` has. 3. Add a button for it to the `LocaleSwitch` component in `App.tsx` if you want it manually selectable (auto-detection from Telegram/browser works without this). ## What's explicitly still Ukrainian-only by design - **The entire admin panel** (`bober_bbq/templates/admin/*`, `bober_bbq/admin/*.py`) — internal tool for Ukrainian staff, not part of this effort at all, now or later (per explicit scope decision). - **Admin-authored free text**, always, regardless of locale: cafe name/phone, a custom `force_closed_message`, an announcement banner message. These are business content the admin typed, not UI strings — same reasoning as menu content, and the same fix (an optional `_en` field) would apply if a client ever wants them bilingual too. **Address is the one exception**: `Setting["address_en"]` (optional, filled in from Адмінка → Налаштування → Заклад) is used in place of `address` when `locale == "en"` and it's non-blank — same opt-in fallback as `Product.name_en`. Covers every place the cafe's own address is shown to a customer: `/api/settings` (Mini App contacts screen + the closed screen), the bot's `/contacts` command, and the pickup-ready notification's address line. - **Historical order-item names** in order history — `OrderItem.product_name` is a snapshot of the product's name *at the time of purchase*, shown as originally captured rather than re-translated after the fact. - **Product/category translations are opt-in per item** — an admin who hasn't filled in `name_en`/`description_en` for a given product just sees it fall back to Ukrainian in the English UI; nothing forces translating the whole menu before English can be used at all. ## Open decisions for a human - **Which languages to prioritize beyond English** — Russian is the other obvious candidate given the customer base, but was deliberately not added here to avoid making a language-priority call that's a business decision, not a technical one. - **Whether cafe name/phone/custom messages should ever be bilingual** — would need the same `_en`-field treatment as address/menu content; not done here since it wasn't asked for and these rarely need translation in practice. - **Machine-translating existing menu items in bulk** — right now filling in `name_en`/`description_en` is a manual per-product admin task. Auto- translating via an API (DeepL/Google Translate) was considered and deliberately not built: mistranslating a dish's name or, worse, an allergen-relevant detail in its description is a real safety concern for food, not just a cosmetic one — manual review stays the safer default.