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) when it does. ServiceHub stores and reuses whatever code the Offerwall sent, so the two services must use identical codes.
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.
- Pay extra attention to the long-form strings, which were called out for native review in the PRs:
requireEvidenceModal.*errorPages.subtitle.appUpdateTroubleshootingModal.*
- 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,
ko, // <-- add here
th,
id,
vi,
'zh-TW': zh_tw,
'zh-HK': zh_hk
},
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', 'th', 'ko', 'id', 'vi', 'zh-TW', 'zh-HK']
}
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. 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.
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 readsnavigator.language, and if the full tag isn't insupportedLocalesbut the 2-letter prefix is, it falls back to the prefix (e.g.ko-KR→ko). It then setsi18n.global.locale.value. 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 - Import +
messagesentry added insrc/i18n.js -
<code>added tosupportedLocalesinsrc/static-data/localization.js -
describeblock + import added insrc/locales/__tests__/locales.spec.js -
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, th, ko, id, vi, zh-HK. 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', 'th', 'ko', 'id', 'vi', 'zh-HK', '<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.
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 andprompt()) - 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)