Multilanguage Translation Process
This document collects the code-integration steps for adding a new language across AdGem's player-facing services. Each service has its own stack and process, so pick the section for the service you're touching. A language is only fully covered once it is added in every service the player interacts with — the Offerwall localizes the UI the player browses, and ServiceHub localizes what the player receives after opening a support case. Both share the same locale codes and the same useLocalization feature-flag gating.
Authors: Maria Cornejo (Offerwall), Quintin Soto (ServiceHub)
Locale codes (shared convention)
A locale is identified by a short code that matches the value the Offerwall resolves from the browser (navigator.language, a BCP 47 language tag). Use the 2-letter ISO 639-1 code (id, th, vi, ko) for languages where the region doesn't matter, or the full lang-REGION tag (zh-TW, zh-HK, pt-BR) when it does. ServiceHub stores and reuses whatever code the Offerwall sent, so the two services must use identical codes.
Both resolvers (Localization.resolveLocale() in the Offerwall, Language::resolve() in ServiceHub) match the full tag first and then the bare base language. A region-tagged code only matches itself: with pt-BR registered, a browser reporting pt-BR / pt_br resolves to it, but pt or pt-PT still fall back to en. Register the bare code instead when every region should share the translation.
Offerwall — Static UI Translations
Author: Maria Cornejo
This section describes the code-integration steps for adding a new language (static UI strings) to the Offerwall UI codebase. All file paths below (e.g. src/i18n.js) are relative to the Offerwall UI repository, not this architecture-docs repository.
Scope: This covers static translations only — the fixed UI strings.
Overview of changes
| File | Change |
|---|---|
src/locales/<code>.json | New — full translation of en.json (same keys) |
src/i18n.js | Import the JSON and register it in the messages map |
src/static-data/localization.js | Add <code> to supportedLocales |
src/locales/__tests__/locales.spec.js | Add a describe(...) block for the new locale |
Step 1 — Create the translation file
Copy src/locales/en.json to src/locales/<code>.json and translate the values, leaving the keys untouched.
Notes from the existing files:
en.jsonis the source of truth: flat dot-notation (e.g."mainOfferwall.allOffers"), not nested objects. Keep this structure exactly — the test suite asserts key parity againsten.json, so match whatever keys it currently holds rather than a fixed count (it grows over time).- Every key must be present and every value must be a non-empty string (the test suite enforces both — see Step 4).
- Follow the project's Prettier rules: no trailing commas, 2-space indentation.
- Preserve the
{placeholder}tokens verbatim —{name},{count},{id},{size},{status}. They are substituted at render time, so do not translate or rename them; only the surrounding text is translated. Reposition them freely if the target language needs a different word order. Note the supported syntax is narrow: the production bundle resolvesvue-i18nto its runtime-only build (no message compiler), sosrc/i18n.jsregisters a small custommessageCompilerthat handles simple named placeholders only — no plural, linked, or positional message syntax. - Pay extra attention to the long-form strings, which were called out for native review in the PRs:
requireEvidenceModal.*errorPages.subtitle.appUpdateTroubleshootingModal.*
- Named
{placeholders}({name},{id},{count},{size},{status}) must be kept verbatim — a cross-locale test inlocales.spec.jsasserts every locale uses the same placeholders asen.jsonfor every key. - For languages with grammatical particles or concatenation quirks (e.g. Korean
이(가)/은(는)), the convention was to keep parenthesized forms inline so sentence-concatenation patterns still read correctly. Example (ko.json):
{
"mainOfferwall.allOffers": "전체 오퍼",
"mainOfferwall.bottomNav.newTrending": "신규 + 인기",
"mainOfferwall.myOffers": "내 오퍼"
}
Step 2 — Register the locale in src/i18n.js
Add the import and a messages entry. Both must be added — importing without registering does nothing. For region-tagged codes, use the quoted-key form: 'zh-TW': zh_tw.
// add with the other locale imports
import ko from './locales/ko.json'
const i18n = createI18n({
legacy: false,
locale: locale,
fallbackLocale: 'en',
messages: {
en,
es,
de,
fr,
ko, // <-- add here
th,
id,
it,
vi,
'zh-TW': zh_tw,
'zh-HK': zh_hk,
'pt-BR': pt_br
},
globalInjection: true
})
Step 3 — Add the code to supportedLocales
src/static-data/localization.js is the single list the app uses to decide whether a browser locale is supported. Add the new code:
export const Localization = {
supportedLocales: ['en', 'es', 'de', 'fr', 'th', 'ko', 'id', 'it', 'vi', 'zh-TW', 'zh-HK', 'pt-BR']
}
This list drives runtime locale resolution (see How the locale is selected at runtime). If a code is registered in i18n.js but missing here, the app will never switch to it from the browser language.
Step 4 — Add the locale test
src/locales/__tests__/locales.spec.js has one describe block per locale. Remember to add the corresponding import <code> from '@/locales/<code>.json' at the top of the file alongside the others (pick a different identifier when the code collides with a Vitest global — it.json is imported as itLocale). Copy an existing block and swap the locale code. Each block asserts four things:
import <code> from '@/locales/<code>.json'
describe('<Language> (<code>) locale', () => {
it('is listed in supportedLocales', () => {
expect(Localization.supportedLocales).toContain('<code>')
})
it('is registered in the i18n instance', () => {
expect(i18n.global.getLocaleMessage('<code>')).toEqual(<code>)
})
it('has the same keys as en.json (no missing or extra translations)', () => {
const enKeys = Object.keys(en).sort()
const <code>Keys = Object.keys(<code>).sort()
expect(<code>Keys).toEqual(enKeys)
})
it('has non-empty string values for every key', () => {
for (const [key, value] of Object.entries(<code>)) {
expect(typeof value, `<code>.${key} should be a string`).toBe('string')
expect(value.length, `<code>.${key} should not be empty`).toBeGreaterThan(0)
}
})
})
Step 5 — Validate locally
npm run test:unit # locale parity + non-empty value tests must pass
npm run lint:check # ESLint
npm run format:check # Prettier (no semicolons, single quotes, no trailing commas)
The has the same keys as en.json test is the most common failure: it catches keys that were dropped, renamed, or added during translation. If it fails, diff your file's keys against en.json.
Also grep the specs for the new code used as an example of an unsupported locale (localization.spec.js, ConfigStore.spec.js). Adding fr broke tests that used fr-FR that way, and adding pt-BR broke the ones that had been retargeted to pt-BR; retarget them to a code that is still unsupported.
Locale JSON changes ship in their own PR
The steps above cover adding a new language. The other kind of locale change — a feature that needs a new key in src/locales/*.json, or that edits existing copy — follows a separate rule, recorded in the Offerwall UI repo's CLAUDE.md (Git Workflow):
The locale JSON edits do not go in the feature PR. Every key has to be added to all 10 locale files, which buries the code diff and pulls in a different set of reviewers (product/localization).
-
Open the locale PR first and merge it, then the feature PR consumes the key with
$t('...'). Never the other way around — a component referencing a key that isn't merged yet renders the raw key in production. -
Renaming or deleting a key is a three-step sequence, never one PR:
- A copy PR adds the new key to all locale files, leaving the old one in place.
- The feature PR switches the components over.
- A follow-up copy PR deletes the orphaned key.
Merging a rename or delete before step 2 breaks
main, not just the feature branch.
Adding a new language is already a standalone locale PR by this rule — it adds no keys and touches no feature code — so the steps above need no extra sequencing.
How the locale is selected at runtime
Adding a locale makes it available, but the app decides when to use it:
- The default in
src/i18n.jsis hard-coded to'en'(const locale = 'en'). - Locale switching happens in
ConfigStore.fetchLocalization(). It passesnavigator.languagetoLocalization.resolveLocale()(insrc/static-data/localization.js), then setsi18n.global.locale.valueand reports the result to RUM as theui_localeglobal context.resolveLocale()matches case-insensitively and separator-agnostically — it normalizes_to-and lowercases — trying the full tag first, then the 2-letter base, and returning the canonical supported casing. Sozh_TW→zh-TW,ko-KR→ko, andes-419→es; anything unmatched returns'en'. fetchLocalization()is gated behind theuseLocalizationfeature flag —MainOfferwall.vueonly calls it when the flag turns on (watch(() => featureFlags.useLocalization, ...)).fallbackLocaleis'en', so any missing key renders the English string rather than the raw key.
So a newly added locale only renders for users whose browser language matches and who have the useLocalization flag enabled.
Checklist
-
src/locales/<code>.jsoncreated fromen.json, all keys translated, no keys added/removed -
{placeholder}tokens preserved verbatim in every translated value - Import +
messagesentry added insrc/i18n.js -
<code>added tosupportedLocalesinsrc/static-data/localization.js -
describeblock + import added insrc/locales/__tests__/locales.spec.js - Tests that used
<code>as an unsupported-locale example retargeted -
npm run test:unit,lint:check, andformat:checkall pass - Native speaker reviewed long-form strings (
requireEvidenceModal.*,errorPages.subtitle.appUpdate,TroubleshootingModal.*)
ServiceHub — Player Communications
Author: Quintin Soto
This section describes the code-integration steps for adding a new language to ServiceHub, the Laravel application that powers player support cases. All file paths below (e.g. lang/en/mail.php) are relative to the ServiceHub repository.
Scope: Everything ServiceHub renders to a player — the support emails and the automated case messages. It does not cover the internal support-agent UI (the Vue/Inertia admin under resources/js), which is English-only and has no i18n layer.
Two translation layers
ServiceHub localizes player-facing text through two independent mechanisms, and adding a language touches both.
| Layer | What it covers | Source of truth | Mechanism |
|---|---|---|---|
| Laravel lang files | Support emails + automated case messages (fixed strings) | lang/en/ | Laravel __() / trans() |
| Spatie translatable | Per-record DB content: campaign / goal / canned-message text | the campaigns, goals, canned_messages rows | spatie/laravel-translatable |
Currently supported locales (both layers): en, zh-TW, de, es, fr, th, ko, id, vi, zh-HK, pt-BR, it. en is the default and the fallback. The canonical list lives in config/localization.php (locales) — everything else (spatie defaultLocales, the Nova rules map) derives from it; see Step 2.
Overview of changes (ServiceHub)
| File | Change |
|---|---|
lang/<code>/mail.php | New — full translation of lang/en/mail.php (same keys) |
lang/<code>/messages.php | New — full translation of lang/en/messages.php (same keys) |
config/localization.php | Add <code> to the locales array — the single source of truth (drives Translatable::defaultLocales() and the Nova rules map) |
app/Enums/Language.php | Add a case for <code> so the machine translator can target it |
DB content (campaigns, goals, canned_messages) | Backfill <code> translations for the translatable attributes |
Step 1 — Create the lang files
These are the fixed, player-facing strings. Copy each English file and translate the values, leaving the keys untouched.
cp lang/en/mail.php lang/<code>/mail.php
cp lang/en/messages.php lang/<code>/messages.php
lang/en/mail.php— every key is consumed by an email Blade view inresources/views/email/support_case/*.blade.phpvia__('mail.<key>'). Keep the nestedsubjects.*group and every key.lang/en/messages.php—automated.welcome,automated.approved, andautomated.closedare the automated case messages written into the chat byMessageFactory::makeAutomatedMessage().
Rules for the translated values:
- Keep every key, including the nested
subjects.*entries inmail.php. A missing key falls back to English silently (fallback_localeis'en'), and there is currently no automated key-parity test, so diff your file againstlang/en/by hand. - Preserve
:placeholdertokens verbatim —:id,:goal,:offer,:publisher. Laravel substitutes these; do not translate or rename them. - Preserve the inline HTML in
mail.php(<b>,<a href="...">, …). Several keys are rendered with{!! ... !!}(unescaped) in the Blade views, so the markup is part of the string. Do not translate URLs. - Preserve the
\nline breaks inmessages.php— the automated messages rely on them for paragraph spacing in the chat UI. - Translate the email subjects (
mail.subjects.*) too; keep the(#:id)suffix.
The other files in
lang/en/—auth.php,validation.php,passwords.php,pagination.php— are framework/agent-facing and are not part of the player experience. Leave the locale directory with justmail.phpandmessages.phpand let those fall back to English unless there's a specific reason to translate them.
Step 2 — Register the locale
The locale set is centralized in config/localization.php — the single source of truth. Add the code to the locales array:
// config/localization.php
'locales' => ['en', 'zh-TW', 'de', 'es', 'fr', 'th', 'ko', 'id', 'vi', 'zh-HK', 'pt-BR', 'it', '<code>'], // <-- add here
app/Providers/AppServiceProvider::boot() feeds this list to spatie — Translatable::defaultLocales(config('localization.locales', ['en'])) — so spatie/laravel-translatable reads/writes the locale on the translatable attributes:
App\Models\Campaign—offer_name,basic_requirements,offer_instructions,offer_descriptionApp\Models\Goal—descriptionApp\Models\CannedMessage—message
There is no hard-coded defaultLocales([...]) array to edit anymore, and the Nova canned-message rules map derives from this same config (see Step 3). If a locale is missing here, the models will not surface it even when the JSON column already holds a value for it.
You must also add a matching case to the App\Enums\Language enum — the machine translator targets locales through it (same pattern as new_dashboard). Add the case, its str() label, and a support-oriented prompt() (copy an existing case and adapt the language):
// app/Enums/Language.php — value must match the config/localization.php code
case Spanish = 'es';
Without a matching enum case, the translator records an "unsupported locale" error for that code and skips it. Also list the case in nonEnglish(), and check tests/Unit/Enums/LanguageTest.php: its resolve() fixture uses a code the app does not support as the fall-back-to-English example, so retarget that row if it is the code you are adding.
Step 3 — Nova canned-message editor
Canned messages are authored by the support team in Nova. No manual edit is needed here — the per-locale validation map in app/Nova/CannedMessage.php is now derived from config/localization.php, so the new locale gets an input automatically once it's in the locales array (Step 2):
Translatable::make([
Textarea::make('Message')->rules('required', 'max:5000'),
])->rules([
// source locale (config('localization.source')) is required; every other locale is nullable
'message' => collect(config('localization.locales', ['en']))
->mapWithKeys(fn ($locale) => [
$locale => $locale === config('localization.source', 'en') ? 'required' : 'nullable',
])
->all(),
]),
The source locale (en) is the only required entry; every other locale stays nullable so the fallback to English still applies when a translation hasn't been authored yet. There is no longer a hand-maintained rules map to keep in sync.
Step 4 — Backfill DB content translations
Step 2 only makes the locale available; the campaign, goal, and canned-message rows still need translated values for it. Two sources:
- Canned messages are translated in Nova (Step 3), per message.
- Campaign / goal text (
offer_instructions,offer_description,description, …) is synced from AdGem upstream. Adding a locale in ServiceHub does not retroactively translate existing rows — coordinate with the data source so the new locale arrives in the synced payload, otherwise these attributes fall back to English viagetTranslation().
Campaign and Goal override getTranslation() to fall back to the raw column / English when the requested locale is empty, so untranslated content degrades gracefully rather than rendering blank.
Step 5 — Validate locally
composer cs # Laravel Pint (code style)
composer analyze # PHPStan static analysis
php artisan test # PHPUnit suite (phpunit.xml) — there is no `composer test` script
There is no key-parity test for the lang files today, so the most useful manual check is to preview the rendered emails in the new locale (e.g. via Mailpit / the local mail driver) and confirm:
- every section renders translated text — English leaking through means a key is present in
enbut missing in your file; a raw key likemail.created_bodymeans it's missing entirely, :placeholdersubstitutions resolved correctly,- the HTML / links are intact.
How the locale is selected at runtime (ServiceHub)
ServiceHub never reads navigator.language. The locale travels with the player:
- Capture. When a support case is created, the locale is taken from the request payload and stored on the case applicant:
CaseApplicant::firstOrCreate(['…', 'locale' => $input->get('locale', 'en')], …)(SupportCaseService::createSupportCase()). This is the locale the Offerwall resolved for that player — hence the shared-codes requirement. - Gating. Every player-facing render is gated behind the DevCycle
use-localizationfeature flag, evaluated per app/publisher viaDevCycleService::useLocalization($appId, $pubId). When the flag is off, ServiceHub forces'en'. - Emails. Mailables are sent with
->locale($useLocalization ? $applicant->locale : 'en')(e.g.SupportCaseService,SendSupportCasesExpirationReminder). Laravel resolves__('mail.*')againstlang/<locale>/mail.php. - Automated messages.
MessageFactory::makeAutomatedMessage()passes the locale as the third argument to__():__("messages.automated.$key", [...], $useLocalization ? $applicant->locale : 'en'). - DB content. Callers read translated columns via
$model->getTranslation($key, $locale), with the sameuseLocalization ? $applicant->locale : 'en'choice. - Fallback.
config/app.phpsets bothlocaleandfallback_localeto'en', so any missing lang key or empty translation renders English rather than a raw key.
So a newly added locale only reaches a player whose stored caseApplicant->locale matches it and whose app/publisher has the use-localization flag enabled.
Checklist (ServiceHub)
-
lang/<code>/mail.phpcreated fromlang/en/mail.php, all keys present,:placeholdersand HTML preserved -
lang/<code>/messages.phpcreated fromlang/en/messages.php,\nformatting preserved -
<code>added to thelocalesarray inconfig/localization.php(spatiedefaultLocalesand the Nova rules map derive from it — no manual edits there) -
casefor<code>added toapp/Enums/Language.php(withstr()label,prompt(), and listed innonEnglish()) -
LanguageTestunsupported-locale fixture retargeted if it used<code> - DB content backfilled: canned messages authored in Nova; campaign/goal sync includes the new locale
- Locale code matches what the Offerwall sends as
locale(see the Offerwall section) - Emails previewed in the new locale; placeholders and links verified
-
use-localizationflag plan confirmed for the target app(s)/publisher(s)