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

14 KiB
Raw Permalink Blame History

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

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.tsRecord<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.