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.
257 lines
14 KiB
Markdown
257 lines
14 KiB
Markdown
# 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.
|