B Blengi docs

Run your workspace

Languages & i18n

Pitchbar ships translations for 130+ languages out of the box, with English, Spanish, French, and Turkish covered end-to-end and the long tail (German, Hindi, Bengali, Arabic, Hebrew, Chinese, Japanese, Korean, Vietnamese, every popular European/Asian/African language, plus RTL scripts) covered for UI chrome — buttons, navigation, forms, status pills. Every key not yet translated for a given locale falls back to the English source automatically, so the UI never breaks while individual translators contribute the long tail.

What localises on the public marketing site

The marketing surface a visitor sees first is fully translatable: the top nav menu (Home, Pricing, How it works, Integrations, Documentation), the home page, the pricing page (including the operator-editable plan cards), and the integrations page. The server runs every one of these payloads through MarketingTranslator at render time, looking each string up in the visitor's locale dictionary (shipped lang/{locale}.json plus admin overrides from the Translation Manager). A string with no entry falls back to English, so a partially-translated locale never breaks — it just shows English for the untranslated bits until someone fills them in (by hand or via the DeepL bulk-translate button).

Because these strings are content, not hard-coded t() keys, they are surfaced into the Translation Manager via MarketingCopy so they can be edited per language alongside the regular UI keys.

Adding a new language is just dropping a file

The LocaleResolver::supported() service auto-discovers locales by scanning lang/*.json at request time. To add a new language, drop a lang/<code>.json file (e.g. lang/sv.json for Swedish) — no code change, no migration, no service restart. The next page load picks it up, the picker modal lists it, the SetLocale middleware accepts ?locale=sv, and it shows up in every API response that enumerates supported locales.

The picker pulls metadata (native name, English name, flag emoji, RTL flag) from App\Services\I18n\LocaleCatalog::ENTRIES — a curated list of 130+ popular languages. If you ship a JSON file for a code that isn't in the catalog, it still works — the picker falls back to the code itself, a 🌐 globe emoji, and LTR direction. Contributing the catalog metadata just upgrades the visual.

Right-to-left languages

The catalog flags Arabic, Hebrew, Persian, Urdu, Pashto, Sindhi, Dhivehi, Yiddish, and Uyghur as RTL. The rendering pipeline mirrors accordingly:

  • The Blade root templates (resources/views/app.blade.php for the admin SPA, resources/views/marketing/_layout.blade.php for the marketing site, resources/views/emails/leads/captured.blade.php for the lead-captured email) emit dir="rtl" on <html> when the active locale's catalog entry has rtl => true.
  • Tailwind v4 logical properties carry the layout: every ms-/me-/ps-/pe-/start-/end-/text-start/text-end utility class flips automatically based on the document direction. The codebase uses logical properties exclusively; physical ml-/mr-/pl-/pr-/left-/right- classes were swept out by an earlier codemod.
  • A Radix <DirectionProvider> wraps the React app at resources/js/app.tsx, so every Radix primitive (DropdownMenu, Popover, Tooltip, Select, Sheet, ContextMenu) gets correct alignment + animation direction without per-component code.
  • The useIsRtl() / useDirection() hooks in resources/js/lib/direction.ts read the current locale's RTL flag from the shared i18n catalog. Use them when a component needs explicit JS direction logic.
  • Directional icons (back, forward, chevrons) wrap with <DirArrow direction="forward|back" /> from resources/js/components/dir-icon.tsx so the icon itself flips. Icons whose direction is decorative (paper-plane send, undo) use the .flip-rtl CSS utility instead of swapping glyphs.
  • The visitor widget reads init.agent.locale on boot and sets dir="rtl" on its shadow root if the locale is RTL — every Tailwind logical-property class inside the widget then flips like the admin SPA.
  • The <Sidebar> component defaults its side prop to the visual start edge based on direction (side="left" in LTR, side="right" in RTL), so a vanilla <Sidebar /> always pins to the visual start.

The tests/Feature/I18n/RtlDirTest.php regression asserts that every RTL locale produces dir="rtl" on the admin Inertia root, the marketing layout, and the lead-captured email; LTR locales conversely produce dir="ltr".

Locale picker modal

Both the admin shell and the marketing site open a searchable Dialog when you click the language pill. The list shows native name + English name + flag for every locale, filterable by code or name. Same component on both surfaces. With 130+ entries, the old dropdown was unscrollable — the modal scales to thousands of languages.

Resolution order

The SetLocale middleware runs after the session middleware on every web request and walks this priority list:

  1. A locale path prefix (/nl/pricing) — the most explicit signal, resolved in the global middleware so it's active before Inertia shares props / the root view renders.
  2. Explicit ?locale=<slug> query (allow-listed).
  3. The authenticated user's users.locale column.
  4. pb_locale cookie (set by a manual switch or the first geo/host resolution; persists across sessions, per-domain).
  5. Geo — Cloudflare's CF-IPCountry mapped through COUNTRY_TO_LOCALE. Physical location outranks the browser's language list.
  6. The browser's Accept-Language header (highest q-value wins).
  7. The application default from config/app.php.

Geo auto-switch

On a visitor's first request the country (CF-IPCountry) auto-selects the language — NL/BE → nl, DE/AT/CH → de, the Spanish/French/Turkish families as mapped in COUNTRY_TO_LOCALE. The first resolution is written to pb_locale so it sticks and resolves once; a manual switch overwrites the same cookie, so an explicit choice always wins afterwards. For a mapped country the legacy suggestion banner self-suppresses (active locale already matches) — auto-apply replaces suggest.

Country domains (blengi.nl → blengi.com/nl)

An operator can point per-language country domains at this app and each is 301-redirected to the primary domain's locale path: blengi.nl/pricingblengi.com/nl/pricing, blengi.deblengi.com/de, blengi.beblengi.com/nl (Belgium → Dutch). The destination already renders translated via the /nl path-prefix routing below.

RedirectCountryDomainToLocalePath (global web middleware) does this: when the request host differs from APP_URL's host and the host's ccTLD maps to a locale (LocaleResolver::localeFromHost — the ccTLD equals the ISO country code, resolved through COUNTRY_TO_LOCALE), it 301s to {APP_URL}/{locale}{path}?{query}. Path and query survive; api / widget / billing/webhook / up prefixes are skipped so cross-origin embeds and webhooks on a country host pass through. Loop-safe — the target is always the primary host, which is never rewritten. Generic: no operator domains are hardcoded, and a gTLD (.com/.io) maps to nothing, so the primary domain just serves.

Deployment (aapanel / nginx). Bind every country domain to the same app, then remove any web-server redirect on them — the app owns the redirect now (a panel rule sending the domain to ?app_locale=… is not a Pitchbar feature and conflicts). Keep APP_URL on the primary domain (it's the redirect target + email / widget origin), issue SSL for every domain, and leave SESSION_DOMAIN unset.

Locale path prefix (/nl, /de)

The public marketing surface is served both bare (/pricing) and under an optional locale prefix (/nl/pricing) — every marketing route is registered twice, the localised copy carrying a .l name suffix so the bare routes stay the canonical route() targets. Both return 200, so deleting the /nl keeps working and the session language stays put. SetLocaleFromPath persists the segment and pins URL::defaults(['locale']) so links stay prefixed inside a localised URL. The admin app is intentionally NOT prefixed (Wayfinder/SSR churn, zero indexing benefit) — it still picks the language from geo via the session.

SEO: marketing pages emit a locale-correct <link rel="canonical"> plus hreflang alternates across the marketed locales (the default + every COUNTRY_TO_LOCALE target, narrowed to the admin’s enabled set via supported() so we never advertise a language we don’t serve) and an x-default, built by LocaleResolver::hreflangFor. The same alternates appear as xhtml:link entries per URL in /sitemap.xml. Admin pages emit neither.

Geo-suggested locale banner

When Cloudflare's CF-IPCountry header maps to a locale we ship and the visitor's current locale is something else, a slim banner appears asking "Switch to Español?" The banner never auto-switches — surprise = bad UX. Two actions:

  • Switch to <language> — PATCHes /locale/switch. Sets users.locale when signed in and the pb_locale cookie always (1-year lifetime), then reloads in the new language.
  • — POSTs /locale/dismiss-suggestion, sets pb_locale_dismiss=1 cookie (180-day lifetime). The banner never appears again on that device.

Suppression rules (server-side, in LocaleResolver::suggestionFor):

  1. Visitor already dismissed → null.
  2. Current locale already matches the suggestion → null.
  3. No CF-IPCountry header (local dev, non-CF deploys) or value is XX/T1 → null.
  4. Country isn't in COUNTRY_TO_LOCALE → null.

Country → locale map covers Spanish-speaking Latin America + Spain (es), French Europe + Quebec (fr), Turkey (tr). Other countries get no suggestion.

Banner mounts on the admin SPA (app-sidebar-layout), on every Inertia marketing page (marketing-shell), and via Blade {{ __('Switch to :language?') }} in resources/views/marketing/_layout.blade.php for any future Blade-rendered marketing pages.

The same LocaleResolver service powers the widget. The widget pass adds one extra step at the top: the agent's language_default — admins can pin a vertical-specific language even if the visitor's browser disagrees. The picker on the agent form currently offers English, Dutch, Spanish, French, German, Portuguese, Japanese, Arabic, and Chinese; the LLM answers in the pinned language and the widget chrome loads the matching lang/{locale}.json copy.

Marketing site copy

Marketing pages are operator-editable content (the JSON in Settings → System → Marketing content), not i18n keys — so they're localised at render time by MarketingTranslator: every string in the resolved payload is looked up in lang/{locale}.json and rendered translated when an entry exists. The shipped English defaults are present in every supported locale's dictionary (all 131 non-English languages carry the full marketing copy set; a regression test enforces Dutch parity for all of them), so the marketing site renders natively in whichever language the visitor picks. Any copy you customise in the editor simply has no dictionary entry and renders verbatim in every locale. To localise custom copy, add it as a key/translation pair to the locale's JSON file. The admin content editor always shows the canonical English source.

Where the strings live

  • lang/{locale}.json — the JSON dictionary used by the admin React SPA, the widget, the marketing Blade pages, and the mail templates. English source strings act as the keys.
  • lang/{locale}/auth.php, validation.php, passwords.php, pagination.php — Laravel's namespaced PHP files for built-in framework messages.
  • lang/_glossary.md — terminology lock so future strings translate consistently with prior runs.

Where to add translations

Whenever you add a user-facing string in code:

  • Backend Blade: wrap with English source.
  • Admin React: import the hook and call const { t } = useT();, then t('English source').
  • Widget: import t from core/i18n.ts and call t('English source'). Add the key to WidgetCopy::KEYS so the server materialises it into the /init payload.

Translated keys are added to lang/<locale>.json. Missing keys silently fall back to the English source — the UI never breaks. The tests/Feature/I18nTest::every supported locale ships a parseable JSON dictionary regression asserts every shipped locale file is valid JSON with non-empty string values; a sister test prevents typo-introduced keys (any key in a locale file that is absent from en.json fails CI).

Filling a language fast with DeepL

You don't have to translate by hand. The Translation manager (/admin/translations) has an Auto-translate button that machine-translates a language with DeepL, then lets you review the results in place. Add a DeepL API key under Settings → System → DeepL translation (or the DEEPL_API_KEY env var) and the button appears for every DeepL-supported language. By default it only fills the strings still showing English, placeholders are protected, and machine results land badged “unreviewed” until you approve them — see the Translation manager page for the full workflow.

Adding a new locale

  1. Drop a lang/<slug>.json file. That alone makes the locale appear in the picker and accept ?locale=<slug> via the SetLocale middleware. LocaleResolver::supported() auto-discovers the file on the next request.
  2. (Optional, but recommended) Add an entry in app/Services/I18n/LocaleCatalog::ENTRIES with the locale's native name, english name, flag emoji, and rtl flag. Without an entry, the picker still works — it just shows the locale code as the label and a 🌐 globe.
  3. (Optional) Copy lang/en/{auth,validation,passwords,pagination}.php into lang/<slug>/ if you want Fortify / validation messages translated. Without these files, Laravel falls through to English.
  4. Run php artisan test --filter=I18nTest and --filter=LocaleResolverTest to confirm the new locale doesn't introduce phantom keys.
  5. (Optional) Update lang/_glossary.md with a column so future translations stay coherent.

There is no code change required to enable a locale. The previous LocaleResolver::SUPPORTED constant has been removed; the picker, validation rules, and middleware all read from LocaleResolver::supported() which returns the auto-discovered set.

Per-user vs per-visitor locale

Admins and operators set their preferred language at /settings/locale. The choice is persisted to users.locale and applies on every subsequent request, including emails sent on their behalf.

Visitors see the agent's language_default by default. If the agent has no language pinned, the widget falls back to the visitor's browser locale, then to English. There is no in-widget locale switcher — that's a deliberate decision so the visitor experience matches what the admin configured.

Coverage

Every customer-facing surface — visitor widget, marketing site (home, pricing, how-it-works, integrations, changelog, privacy, terms), the admin SPA (every customer-admin and platform-admin page including agent customize, sources, curated answers, knowledge, playground, behavior, CTAs, leads, conversations, experiments, billing, integrations, analytics, workflows, every settings tab), the auth + onboarding flows, transactional emails, and validation messages — is wired through useT() or __(). The English source (lang/en.json) is the source of truth and currently holds about 2,300 keys.

Per-locale coverage varies. en, es, fr, and tr are fully translated end-to-end (every key in en.json has a localised value). The other 130-ish auto-discovered locale files start with the most-common UI chrome translated (~130 keys: buttons, navigation, forms, status pills) and expand from there as translators contribute. Keys not yet translated for a given locale fall back to the English source via Laravel's standard JSON-key behaviour, so the UI never breaks; users see partial translation while the long tail fills in.

To check coverage for a locale at any time:

node -e 'const fs=require("fs"); const en=JSON.parse(fs.readFileSync("lang/en.json")); const m=JSON.parse(fs.readFileSync("lang/<code>.json")); const total=Object.keys(en).length; const translated=Object.keys(en).filter(k=>k in m && m[k]!==en[k]).length; console.log(`${translated}/${total} = ${Math.round(translated/total*100)}% covered`);'

The documentation pages under resources/views/documentation/pages/ stay in English by policy — translating dense technical writeups is a copywriting project, not engineering. Track follow-up on the Kanban board if a specific deal needs translated docs.

Browser SEO + accessibility

  • The <html lang> attribute on the marketing layout, the admin Inertia root, and transactional emails reflects the resolved locale on every render.
  • Validation messages, password-reset emails, and Fortify auth messages all flow through Laravel's translator — they pick up the user's locale automatically.
  • The widget's first paint already speaks the right language — the server materialises agent.copy at /init time, so there is never a moment of English flash before the translated copy hydrates.

Choosing which languages are available

130+ languages is a lot to show every visitor. A super_admin can curate the offered set under Settings → System → Languages: a searchable list of every installed language with a toggle each, plus Enable all / Disable all. Disabled languages disappear everywhere the picker is read — the settings language switcher, the geo-suggested-locale banner, locale validation, and the widget's language resolution.

  • English is always on — it's the fallback contract and can't be disabled (its toggle is locked).
  • Enable all stores no restriction, so any language you drop in later is automatically available too.
  • Restricting the set stores the explicit list; a user whose saved locale is later disabled simply falls back to English on their next request.

Mechanically: the choice is saved on app_settings.enabled_locales (null = unrestricted) and read into config('app.enabled_locales') at boot. LocaleResolver::allInstalled() is the raw on-disk scan; LocaleResolver::supported() is that set narrowed to the enabled locales — and everything else reads through supported().