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.
MULTILINGUAL_ENABLED (default false). While
it's off the site is English-only: the locale picker
is hidden, ?locale / saved locale / Accept-Language
are ignored (every request resolves to the app locale), the
geo-suggestion banner never shows, and the
Translation manager is
hidden (404). Set MULTILINGUAL_ENABLED=true to turn
everything below on. The toggle is environment-only by design — flip it
per deployment, no UI switch.
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.
The catalog flags Arabic, Hebrew, Persian, Urdu, Pashto, Sindhi, Dhivehi, Yiddish, and Uyghur as RTL. The rendering pipeline mirrors accordingly:
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.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.<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.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.<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.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.<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".
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.
The SetLocale middleware runs after the session middleware
on every web request and walks this priority list:
?locale=<slug> query (allow-listed).users.locale column.pb_locale cookie (set by the geo-banner switch
path, persists across sessions for unauth visitors).Accept-Language header (highest q-value
wins).config/app.php.
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:
/locale/switch. Sets users.locale
when signed in and the pb_locale cookie always
(1-year lifetime), then reloads in the new language./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):
CF-IPCountry header (local dev, non-CF
deploys) or value is XX/T1 → null.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 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 all present in the
Dutch dictionary; 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.
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.Whenever you add a user-facing string in code: