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_translationswith 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:
- Settings → Profile → Language if explicitly set.
- The browser’s
Accept-Languageheader. - 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:
- Original content is written and stored with a
source_localecolumn. - An after-insert trigger enqueues a translation job into
core.translation_dispatch, listing every locale a downstream viewer might need it in. - A pg_cron tick (every minute) wakes the
translate-workeredge function, which pulls jobs in batches. - For each
(entity, target_locale)pair, the worker:- checks
core.translation_cacheby 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_translationsAND the hash-keyed cache.
- checks
- The React layer subscribes to
core.content_translationsvia 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.
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
YourBrandrendered asYourBrand 您的品牌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
| Symptom | Likely cause | Fix |
|---|---|---|
| Bubble stays in source language for a different-locale viewer | UGC translation off for this store, or detected source matches viewer | Confirm at Settings → Translation; check the source_locale on the row. |
| Translation takes >30 seconds to appear | Cron tick is <1 minute; OpenAI latency adds a few seconds | Wait. If >5 minutes, check Settings → Translation → Usage for cap-reached banner. |
| Brand name translated incorrectly | Term not in glossary | Add it at Settings → Translation → Glossary; future translations honor it. Existing rows re-translate when the source is edited. |
| Auto-translated chip stuck on yellow | Source was edited; re-translation queued | Re-open the page; bubble updates to fresh translation. |
| “Cap reached” banner | Hit monthly cost cap | Raise the cap, wait for month rollover, or live with cached-only. |
| Garbled characters in target locale | Wrong source-locale detected | Author edits the post and picks the source language manually; pipeline re-translates. |
Related
- 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.