Authenticating...
Skip to main content

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​

FileChange
src/locales/<code>.jsonNew — full translation of en.json (same keys)
src/i18n.jsImport the JSON and register it in the messages map
src/static-data/localization.jsAdd <code> to supportedLocales
src/locales/__tests__/locales.spec.jsAdd 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.json is the source of truth: flat dot-notation (e.g. "mainOfferwall.allOffers"), not nested objects. Keep this structure exactly — the test suite asserts key parity against en.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 resolves vue-i18n to its runtime-only build (no message compiler), so src/i18n.js registers a small custom messageCompiler that 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.appUpdate
    • TroubleshootingModal.*
  • Named {placeholders} ({name}, {id}, {count}, {size}, {status}) must be kept verbatim — a cross-locale test in locales.spec.js asserts every locale uses the same placeholders as en.json for 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:

    1. A copy PR adds the new key to all locale files, leaving the old one in place.
    2. The feature PR switches the components over.
    3. 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:

  1. The default in src/i18n.js is hard-coded to 'en' (const locale = 'en').
  2. Locale switching happens in ConfigStore.fetchLocalization(). It passes navigator.language to Localization.resolveLocale() (in src/static-data/localization.js), then sets i18n.global.locale.value and reports the result to RUM as the ui_locale global 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. So zh_TW → zh-TW, ko-KR → ko, and es-419 → es; anything unmatched returns 'en'.
  3. fetchLocalization() is gated behind the useLocalization feature flag — MainOfferwall.vue only calls it when the flag turns on (watch(() => featureFlags.useLocalization, ...)).
  4. fallbackLocale is '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>.json created from en.json, all keys translated, no keys added/removed
  • {placeholder} tokens preserved verbatim in every translated value
  • Import + messages entry added in src/i18n.js
  • <code> added to supportedLocales in src/static-data/localization.js
  • describe block + import added in src/locales/__tests__/locales.spec.js
  • Tests that used <code> as an unsupported-locale example retargeted
  • npm run test:unit, lint:check, and format:check all 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.

LayerWhat it coversSource of truthMechanism
Laravel lang filesSupport emails + automated case messages (fixed strings)lang/en/Laravel __() / trans()
Spatie translatablePer-record DB content: campaign / goal / canned-message textthe campaigns, goals, canned_messages rowsspatie/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)​

FileChange
lang/<code>/mail.phpNew — full translation of lang/en/mail.php (same keys)
lang/<code>/messages.phpNew — full translation of lang/en/messages.php (same keys)
config/localization.phpAdd <code> to the locales array — the single source of truth (drives Translatable::defaultLocales() and the Nova rules map)
app/Enums/Language.phpAdd 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 in resources/views/email/support_case/*.blade.php via __('mail.<key>'). Keep the nested subjects.* group and every key.
  • lang/en/messages.php — automated.welcome, automated.approved, and automated.closed are the automated case messages written into the chat by MessageFactory::makeAutomatedMessage().

Rules for the translated values:

  • Keep every key, including the nested subjects.* entries in mail.php. A missing key falls back to English silently (fallback_locale is 'en'), and there is currently no automated key-parity test, so diff your file against lang/en/ by hand.
  • Preserve :placeholder tokens 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 \n line breaks in messages.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 just mail.php and messages.php and 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_description
  • App\Models\Goal — description
  • App\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 via getTranslation().

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 en but missing in your file; a raw key like mail.created_body means it's missing entirely,
  • :placeholder substitutions 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:

  1. 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.
  2. Gating. Every player-facing render is gated behind the DevCycle use-localization feature flag, evaluated per app/publisher via DevCycleService::useLocalization($appId, $pubId). When the flag is off, ServiceHub forces 'en'.
  3. Emails. Mailables are sent with ->locale($useLocalization ? $applicant->locale : 'en') (e.g. SupportCaseService, SendSupportCasesExpirationReminder). Laravel resolves __('mail.*') against lang/<locale>/mail.php.
  4. Automated messages. MessageFactory::makeAutomatedMessage() passes the locale as the third argument to __(): __("messages.automated.$key", [...], $useLocalization ? $applicant->locale : 'en').
  5. DB content. Callers read translated columns via $model->getTranslation($key, $locale), with the same useLocalization ? $applicant->locale : 'en' choice.
  6. Fallback. config/app.php sets both locale and fallback_locale to '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.php created from lang/en/mail.php, all keys present, :placeholders and HTML preserved
  • lang/<code>/messages.php created from lang/en/messages.php, \n formatting preserved
  • <code> added to the locales array in config/localization.php (spatie defaultLocales and the Nova rules map derive from it — no manual edits there)
  • case for <code> added to app/Enums/Language.php (with str() label, prompt(), and listed in nonEnglish())
  • LanguageTest unsupported-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-localization flag plan confirmed for the target app(s)/publisher(s)