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.
14 KiB
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 at(key, locale, **kwargs)lookup function, in the same spirit asSetting.get. - Webapp: a plain TypeScript dict of dicts plus a ~30-line React context
- hook (
useI18n()).
- hook (
Neither needs a build step, a message extractor, or a new dependency.
Backend: bober_bbq/utils/i18n.py
from bober_bbq.utils.i18n import t
text = t("order.status.confirmed", locale, number=order.display_number())
TRANSLATIONSis adict[locale][key] -> template string({placeholder}syntax, filled in withstr.format(**kwargs)).SUPPORTED_LOCALES = ("uk", "en");DEFAULT_LOCALE = "uk".normalize_locale(code)maps an arbitrary code (e.g. Telegram'slanguage_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 touk; a missing key everywhere returns the raw key (ugly, but visible and non-fatal) — both cases log awarningso the gap gets noticed and fixed.
Locale storage: TelegramUser.locale
- New nullable column, default
"uk"(seebober_bbq/models.pyand the migration inbober_bbq/migrate.py, added via_add_column_if_missingexactly 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'sget_or_create_user()takes an optionallanguage_codeargument, sourced from aiogram'smessage.from_user.language_codeinbot/handlers/start.py. - Mini App / Flask API:
bober_bbq/api/auth.py's_upsert_user()readslanguage_codeout of the Telegram WebAppinitDatapayload (Telegram includes it in theuserJSON automatically).
- Bot:
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/startwelcome message.bot/handlers/menu.py— the/menucommand's "open the Mini App" hint.bot/handlers/contacts.py— the/contactscommand and its reply-keyboard button (bot/keyboards.py'smain_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'sstatus_label(a separate translation namespace,order.status_label.*, from the Ukrainian-onlymodels.ORDER_STATUS_LABELSthe 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'sclosed_message(locale)andbober_bbq/utils/promo.py'svalidate_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 />inmain.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:- A previously saved manual choice in
localStorage(bober_locale). - Telegram's own client language,
Telegram.WebApp.initDataUnsafe.user. language_code(only present inside the real Telegram Mini App). - The browser's
navigator.language. "uk"if nothing else matched or is supported.
- A previously saved manual choice in
- A UA / EN switcher in the app header (
App.tsx'sLocaleSwitch,.locale-switchinstyles.css) lets a customer override auto-detection at any time. Choosing a locale does two things: saves it tolocalStoragefor 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-forgetPATCH /api/me/locale(bober_bbq/api/me.py) that persists the choice toTelegramUser.localeserver-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):
- Add the code to
SUPPORTED_LOCALES. - Add a new top-level dict to
TRANSLATIONSwith every keyukhas. - If it should auto-populate from Telegram, no extra work is needed —
normalize_locale()already accepts any code Telegram sends once it's inSUPPORTED_LOCALES.
Webapp (webapp/src/i18n/translations.ts):
- Add the code to the
Localeunion andSUPPORTED_LOCALES. - Add a new top-level object to
translationswith every keyukhas. - Add a button for it to the
LocaleSwitchcomponent inApp.tsxif 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_enfield) 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 ofaddresswhenlocale == "en"and it's non-blank — same opt-in fallback asProduct.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/contactscommand, and the pickup-ready notification's address line. - Historical order-item names in order history —
OrderItem.product_nameis 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_enfor 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_enis 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.