D Diagent 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.

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. Explicit ?locale=<slug> query (allow-listed).
  2. The authenticated user's users.locale column.
  3. pb_locale cookie (set by the geo-banner switch path, persists across sessions for unauth visitors).
  4. The browser's Accept-Language header (highest q-value wins).
  5. The application default from config/app.php.

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.

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

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.