bober-bbq-bot/docs/I18N.md
byrsapty 96737e4f3f Add optional English translation for product weight/volume
Product.weight_en (e.g. "300 г" -> "300 g"), same opt-in-per-item fallback
as name_en/description_en. Editable from the product admin form.
2026-08-27 17:14:36 +03:00

257 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, Record<string, string>>`, `Locale =
"uk" | "en"`.
- `index.tsx` — `<I18nProvider>` (wraps `<App />` 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.