Overview

Translation in {{COMPANY_SHORT_NAME}} comes in two halves and they don’t share infrastructure on purpose.

  • UI chrome — buttons, labels, navigation, error messages. Pre-translated catalogs shipped with the app, refreshed by an internal tool. Picked at sign-in based on the user’s browser locale and overridable in Settings → Profile → Language.
  • User-generated content (UGC) — messages, reviews, posts, product copy, vendor bios. Translated on-the-fly via OpenAI when a viewer in a different locale opens the page. Stored in core.content_translations with a hash-keyed cache so repeat viewers don’t re-pay.

You decide whether UGC translation is on per-store, set a monthly cost cap, and define a glossary of brand names and SKUs that should never be translated.

What working correctly looks like

  • Switching Settings → Profile → Language reloads the app in that language within a second.
  • A buyer with a Spanish browser viewing a product written in English sees the description in Spanish, with an auto-translated chip.
  • Clicking View original on a translated bubble swaps to the source instantly.
  • Settings → Translation → Usage shows current-month characters translated and the dollar burn vs. the cap.
  • Glossary entries (e.g. BPC-157, your brand name) appear verbatim in every translated locale.
  • If the cap is hit, new translations queue with a cap reached banner instead of failing or spending more.

Supported locales

Both halves support the same 15 locales:

  • en — English (source language for UI chrome).
  • es — Spanish.
  • zh-CN — Chinese, Simplified.
  • zh-TW — Chinese, Traditional.
  • ja — Japanese.
  • ko — Korean.
  • vi — Vietnamese.
  • fr — French.
  • de — German.
  • pt-BR — Portuguese (Brazil).
  • ar — Arabic (rendered RTL).
  • hi — Hindi.
  • ru — Russian.
  • it — Italian.
  • th — Thai.

RTL locales (ar) flip the entire shell layout. UI components handle this via the document dir attribute; UGC bubbles are rendered with dir="auto" so a single thread can mix LTR and RTL content gracefully.

UI chrome translation

The app interface (buttons, labels, modals, error toasts) is translated using shipped JSON catalogs in packages/i18n/src/locales/<locale>/<ns>.json. Operators don’t maintain these — we do, and we re-publish whenever an English string changes.

Per-user, the active language follows this preference order:

  1. Settings → Profile → Language if explicitly set.
  2. The browser’s Accept-Language header.
  3. The first listed supported locale (defaults to en).

If a locale is missing a string, we fall through to English rather than show a key like orders.list.empty. Operators on edge browsers won’t notice.

User-generated content translation

UGC is the body of every message, review, post, product description, vendor bio. The flow:

  1. Original content is written and stored with a source_locale column.
  2. An after-insert trigger enqueues a translation job into core.translation_dispatch, listing every locale a downstream viewer might need it in.
  3. A pg_cron tick (every minute) wakes the translate-worker edge function, which pulls jobs in batches.
  4. For each (entity, target_locale) pair, the worker:
    • checks core.translation_cache by content hash + target locale — if hit, copies the row;
    • otherwise calls OpenAI with the body, the glossary, and the per-locale style guide;
    • writes the result into core.content_translations AND the hash-keyed cache.
  5. The React layer subscribes to core.content_translations via Realtime; the bubble renders the moment the row arrives.

What gets auto-translated today:

  • Messages — both directions of every conversation thread (DMs to/from clients).
  • Reviews — the body and the operator’s reply.
  • Posts — vendor and operator long-form posts in your community feed.
  • Product copy — description, bullet points, ingredient lists.
  • Vendor bios — the about-us text on each vendor profile.

Source-locale detection

The pipeline needs to know what language the source is in before it knows how to translate it. Two paths.

  • Auto-detect — default. We pass the source body through a small classifier (Unicode-script-based fast path; OpenAI fallback if the script is ambiguous, e.g. Latin script could be EN/ES/PT/IT/FR/DE/VI). The detected locale is stored in source_locale.
  • Manual override — the author can pick the language explicitly when composing. Useful if you sometimes write in your buyer’s language directly.

If the detected source matches the viewer’s locale, we skip translation entirely — no badge, no cost.

The auto-translated badge

Translated content always wears a small chip above the bubble.

  • Chip text“auto-translated from Spanish” in the viewer’s own language.
  • View original — clicking swaps the bubble in place to show the source body. Click again to flip back.
  • Translation freshness — if the source has been edited since translation, the chip turns yellow with “source updated”; the next view re-translates.
Always offer the source

The view original link is required by the platform — you can’t hide it from buyers via settings. The reasoning: machine translation is good, not perfect, and the recipient has a right to see what was actually written.

Glossary & brand terms

Brand names, SKUs, and proprietary jargon should never be translated. Configure them at Settings → Translation → Glossary:

  • One term per row (e.g. BPC-157, YourBrand™, Tirzepatide).
  • Optional per-locale override if you want a specific localized form (e.g. you’d like YourBrand rendered as YourBrand 您的品牌 in Chinese the first time it appears).
  • Case-sensitive matching; the worker passes the glossary into the OpenAI prompt as a hard rule.

The default glossary ships with common peptide names (BPC-157, TB-500, GHK-Cu, semaglutide, tirzepatide, etc.) so they aren’t mangled across locales.

Monthly cost cap

UGC translation costs real money — we pass the OpenAI bill through. To prevent surprise:

  • Settings → Translation → Cost cap sets a monthly USD ceiling.
  • The pipeline tracks token spend per call against the cap.
  • At 80% spend you get a warning email.
  • At 100% the circuit-breaker trips: new jobs queue but do not execute. They run on the 1st of the next month, or the moment you raise the cap.
  • Cached translations always serve, even past the cap.

Live spend is visible at Settings → Translation → Usage: characters translated, OpenAI tokens consumed, dollars spent.

Settings & permissions

  • Settings → Translation — UGC on/off per store, cost cap, glossary, per-locale style guides. Owner / admin.
  • Settings → Translation → Usage — live counter. Owner / admin.
  • Settings → Profile → Language — per-user UI language. Every member.
  • The OpenAI key, the daily cost cap, the circuit-breaker thresholds all live in core.translation_settings (singleton). Read-only outside the admin app.

To turn UGC translation off entirely, toggle UGC translation at Settings → Translation. Existing rows in core.content_translations are kept; new content is not translated. Buyers in other locales then see source content with no badge.

API + automation

Adding a new translatable entity (e.g. a new content type your team built) is a one-PR job — you wire an after-insert trigger to core.fn_queue_translation, add a CASE branch to the entity-resolution functions, and call useTranslatedContent in your React render. The pattern mirrors the shop.messages wiring shipped in production. See Integration API for the broader programmability story.

Troubleshooting

SymptomLikely causeFix
Bubble stays in source language for a different-locale viewerUGC translation off for this store, or detected source matches viewerConfirm at Settings → Translation; check the source_locale on the row.
Translation takes >30 seconds to appearCron tick is <1 minute; OpenAI latency adds a few secondsWait. If >5 minutes, check Settings → Translation → Usage for cap-reached banner.
Brand name translated incorrectlyTerm not in glossaryAdd it at Settings → Translation → Glossary; future translations honor it. Existing rows re-translate when the source is edited.
Auto-translated chip stuck on yellowSource was edited; re-translation queuedRe-open the page; bubble updates to fresh translation.
“Cap reached” bannerHit monthly cost capRaise the cap, wait for month rollover, or live with cached-only.
Garbled characters in target localeWrong source-locale detectedAuthor edits the post and picks the source language manually; pipeline re-translates.
  • Email pipeline — transactional email templates are translated via the UI chrome path, not the UGC path.
  • SMS pipeline — same as email.
  • Billing & quotas — UGC translation runs against a monthly character quota on Scale-tier and below.
  • Integration API — for wiring custom entities into the translation pipeline.
  • Audit log — cap changes, settings flips, and circuit-breaker trips are recorded.