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

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.
  • Pay extra attention to the long-form strings, which were called out for native review in the PRs:
    • requireEvidenceModal.*
    • errorPages.subtitle.appUpdate
    • TroubleshootingModal.*
  • 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:

  1. The default in src/i18n.js is hard-coded to 'en' (const locale = 'en').
  2. Locale switching happens in ConfigStore.fetchLocalization(). It reads navigator.language, and if the full tag isn't in supportedLocales but the 2-letter prefix is, it falls back to the prefix (e.g. ko-KRko). It then sets i18n.global.locale.value.
  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
  • 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
  • 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, 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)

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.phpautomated.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', '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\Campaignoffer_name, basic_requirements, offer_instructions, offer_description
  • App\Models\Goaldescription
  • App\Models\CannedMessagemessage

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 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 and prompt())
  • 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)