Files
InvestPlay/docs/LOCALIZATION.md
Lefteris Notas e75f913f1c
Some checks failed
CI / Lint (push) Has been cancelled
CI / TypeCheck (push) Has been cancelled
CI / Test (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/api/Dockerfile name:api]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/cms/Dockerfile name:cms]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/web/Dockerfile name:web]) (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy / Deploy (push) Has been cancelled
feat: complete Phase 1 foundation scaffold
- Monorepo: Turborepo + pnpm workspaces with 7 apps/packages
- Backend: NestJS scaffold with 9 modules (auth, tenant, curriculum, simulation, portfolio, ai-coach, analytics, classroom, gamification)
- Frontend: React 18 + Vite + TailwindCSS + shadcn/ui with 10 pages, 14 UI primitives, 3 Zustand stores
- Database: Prisma schema with 19 models, 13 enums, seed script, multi-tenant ready
- Docker: Dev, production, and Portainer Compose files with Traefik reverse proxy
- Configuration: .env.example (47 vars), Zod validation, frontend-safe env exposure
- i18n: English + Greek locale files (10 files), ICU MessageFormat, react-i18next
- Shared packages: @investplay/types, @investplay/ui, @investplay/i18n, @investplay/utils
- CI/CD: GitHub Actions (lint, typecheck, test, build, deploy, PR checks)
- Documentation: CONTEXT.md, ARCHITECTURE.md (10 Mermaid diagrams), API.md, LOCALIZATION.md, DEVELOPMENT-ROADMAP.md
- Infrastructure: Dockerfiles (dev + prod), nginx configs, backup/healthcheck scripts
- Security: JWT auth guards, role-based access, rate limiting, Helmet, CORS, PII sanitization
2026-06-12 19:32:57 +03:00

11 KiB
Raw Blame History

InvestPlay — Localization Guide

Overview

InvestPlay supports multiple languages through a layered i18n architecture:

  1. UI stringsi18next with namespaced JSON files (runtime-loaded, no build step)
  2. Educational content — Payload CMS localized collections (per-locale content variants)
  3. Financial formattingIntl.NumberFormat and Intl.DateTimeFormat (browser-native)
  4. Currency — Locale-aware currency symbols and formatting

Currently Supported Locales

Locale Code Status Notes
English en Primary Default fallback locale
Greek el Active Market entry requirement

Target Locales (Phase 8)

German (de), French (fr), Spanish (es), Italian (it), Portuguese (pt), Dutch (nl)


How to Add a New Locale

Step 1: Define the locale in configuration

Add the locale to the SUPPORTED_LOCALES environment variable:

SUPPORTED_LOCALES=en,el,de

Step 2: Create translation JSON files

Create a new directory under packages/i18n/src/locales/<locale_code>/ with the same namespace structure as existing locales:

packages/i18n/src/locales/
├── en/
│   ├── common.json
│   ├── finance.json
│   ├── gamification.json
│   ├── classroom.json
│   └── ai-coach.json
├── el/
│   ├── common.json
│   ├── finance.json
│   ├── gamification.json
│   ├── classroom.json
│   └── ai-coach.json
└── de/              # New locale
    ├── common.json
    ├── finance.json
    ├── gamification.json
    ├── classroom.json
    └── ai-coach.json

Step 3: Register in the i18n package

Edit packages/i18n/src/index.ts to import and register the new locale:

import deCommon from "./locales/de/common.json" with { type: "json" };
import deFinance from "./locales/de/finance.json" with { type: "json" };
import deGamification from "./locales/de/gamification.json" with { type: "json" };
import deClassroom from "./locales/de/classroom.json" with { type: "json" };
import deAiCoach from "./locales/de/ai-coach.json" with { type: "json" };

const resources = {
  en: { /* existing */ },
  el: { /* existing */ },
  de: {
    common: deCommon,
    finance: deGamification,
    gamification: deGamification,
    classroom: deClassroom,
    aiCoach: deAiCoach,
  },
};

Step 4: Add locale in the CMS

  1. Navigate to Payload Admin -> Settings -> Localization
  2. Add the new locale code
  3. Existing content collections will now show locale switcher tabs
  4. Content editors can create locale-specific variants for each lesson, article, or block

Step 5: Add locale-specific financial examples

If the new locale has different financial regulations, tax rules, or common investment products, create locale-specific example data in the CMS. This ensures learners see locally relevant content.

Step 6: Test the locale

# TypeScript checking ensures all namespaces are covered
pnpm typecheck

# Run tests with German locale
NODE_ENV=test SUPPORTED_LOCALES=en,el,de pnpm test

CMS Localization Workflow

Architecture

Payload CMS supports built-in localization at the collection level. When a collection has localization: true, each document stores locale-specific variants:

// Conceptual Payload collection config
const Lessons = {
  slug: "lessons",
  localization: {
    locales: ["en", "el", "de"],
    defaultLocale: "en",
    fallback: true, // Falls back to default locale if variant missing
  },
  fields: [
    { name: "title", type: "text", localized: true },
    { name: "body", type: "richText", localized: true },
    { name: "blocks", type: "blocks", localized: true },
    { name: "duration", type: "number", localized: false }, // Shared across locales
  ],
};

Content Editor Flow

  1. Author creates a lesson in the default locale (English)
  2. CMS marks the lesson as "Translation Needed" for other locales
  3. Translator opens the lesson and sees all localized fields
  4. Translator fills in the locale-specific content
  5. CMS automatically uses the locale variant when the API is queried with the matching Accept-Language header

Frontend Rendering

The frontend requests content with the user's preferred locale. The CMS API automatically resolves the correct variant:

// The CMS API respects Accept-Language header
const response = await fetch(`${CMS_API_URL}/api/lessons/${id}`, {
  headers: {
    "Accept-Language": userLocale, // "de", "el", etc.
    "Authorization": `Bearer ${cmsApiKey}`,
  },
});

If a locale variant is missing, Payload falls back to the default locale content.


ICU MessageFormat Examples

InvestPlay uses ICU MessageFormat for all translated strings via i18next. This provides robust support for plurals, gender, context, and rich text.

Basic Interpolation

{
  "welcome": "Welcome, {name}!",
  "login": "Sign in to continue"
}
t("welcome", { name: "Alex" });
// -> "Welcome, Alex!"

Pluralization

{
  "xpEarned": "You earned {count} XP",
  "xpEarned_plural": "You earned {count} XP",
  "lessonsCompleted": "{count} lesson completed",
  "lessonsCompleted_plural": "{count} lessons completed"
}
t("xpEarned", { count: 1 });  // -> "You earned 1 XP"
t("xpEarned", { count: 5 });  // -> "You earned 5 XP"
t("lessonsCompleted", { count: 1 }); // -> "1 lesson completed"
t("lessonsCompleted", { count: 3 }); // -> "3 lessons completed"

ICU native plural syntax (also supported):

{
  "streak": "{count, plural, one {# day streak} other {# day streak}}",
  "students": "{count, plural, one {# student} other {# students}}"
}

Context and Gender

{
  "notification": "{name} {context, select, trade {bought} quiz {scored} badge {unlocked} other {completed}} {target}"
}
t("notification", { name: "Alex", context: "trade", target: "AAPL" });
// -> "Alex bought AAPL"

t("notification", { name: "Maria", context: "badge", target: "Early Investor" });
// -> "Maria unlocked Early Investor"

Rich Text Formatting

{
  "terms": "By continuing, you agree to our <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>"
}
t("terms", {
  terms: (text: string) => `<a href="/terms">${text}</a>`,
  privacy: (text: string) => `<a href="/privacy">${text}</a>`,
});

Currency Formatting

Currency symbols are dynamically selected based on locale. The formatting happens in @investplay/utils:

import { formatCurrency } from "@investplay/utils";

formatCurrency(1234.56, "EUR", "en");   // -> "€1,234.56"
formatCurrency(1234.56, "EUR", "el");   // -> "1.234,56 €"
formatCurrency(1234.56, "USD", "en");   // -> "$1,234.56"

Date and Time Formatting

import { formatDate, formatRelativeTime } from "@investplay/utils";

formatDate("2026-06-12", "en");           // -> "Jun 12, 2026"
formatDate("2026-06-12", "el");           // -> "12 Ιουν 2026"
formatRelativeTime("2026-06-10", "en");   // -> "2 days ago"
formatRelativeTime("2026-06-10", "el");   // -> "πριν από 2 ημέρες"

Number Formatting

import { formatNumber, formatPercentage } from "@investplay/utils";

formatNumber(1234567.89, "en");   // -> "1,234,567.89"
formatNumber(1234567.89, "el");   // -> "1.234.567,89"
formatPercentage(0.1234, "en");   // -> "12.34%"
formatPercentage(0.1234, "el");   // -> "12,34%"

Namespace Structure

Namespace Contents Example Keys
common Navigation, buttons, errors, meta nav.home, button.save, error.notFound
finance Financial terms, currencies, instruments term.dividend, currency.eur, instrument.stock
gamification XP, badges, streaks, levels xp.earned, badge.investor, streak.days
classroom Teacher dashboard, assignments, roster classroom.create, assignment.due, roster.student
aiCoach AI chat UI strings, hints, feedback ai.greeting, ai.hint, ai.quotaExhausted

Currency/Date/Number Formatting

All formatting is handled browser-side using the Intl API via @investplay/utils. No server-side formatting is needed for display values.

Currency Formatting Configuration

import { CURRENCY_SYMBOLS } from "@investplay/utils";

CURRENCY_SYMBOLS = {
  EUR: { symbol: "€", code: "EUR" },
  USD: { symbol: "$", code: "USD" },
  GBP: { symbol: "£", code: "GBP" },
} as const;

The formatCurrency function uses Intl.NumberFormat internally:

new Intl.NumberFormat(locale, {
  style: "currency",
  currency: currencyCode,
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
});

Locale-Format Mapping

Locale Decimal Grouping Currency Position Date Format
en . , Prefix ($1,234.56) MMM dd, yyyy
el , . Suffix (1.234,56 €) dd MMM yyyy
de , . Suffix (1.234,56 €) dd.MM.yyyy
fr , Suffix (1 234,56 €) dd/MM/yyyy
es , . Suffix (1.234,56 €) dd/MM/yyyy
it , . Suffix (1.234,56 €) dd/MM/yyyy
pt , . Suffix (1.234,56 €) dd/MM/yyyy
nl , . Suffix (1.234,56 €) dd-MM-yyyy

Implementation

All formatting functions in @investplay/utils accept a locale parameter that defaults to the user's current locale from i18next.language:

import { useTranslation } from "@investplay/i18n";

function TradeRow({ trade }: { trade: Trade }) {
  const { i18n } = useTranslation();
  return (
    <tr>
      <td>{formatDate(trade.executedAt, i18n.language)}</td>
      <td>{formatCurrency(trade.amount, "EUR", i18n.language)}</td>
      <td>{formatPercentage(trade.roi, i18n.language)}</td>
    </tr>
  );
}

RTL Readiness Notes

RTL (right-to-left) support for languages like Arabic (ar) is planned for Phase 8. The following steps have been taken to ensure RTL readiness:

CSS

  • TailwindCSS has built-in RTL support via the rtl: variant
  • shadcn/ui components use logical CSS properties (margin-inline-start instead of margin-left)
  • Flexbox and Grid layouts use gap rather than left/right margins

HTML

  • The <html> element will have dir="rtl" set via the locale configuration
  • React components should avoid hardcoded left/right style values

Icons

  • Lucide icons support RTL via the mirrorInRtl property on directional icons (arrows, chevrons)
  • Example: <ChevronRight className="lucide-rtl-mirror" />

Future Implementation Checklist

When adding RTL support:

  1. Update packages/i18n/src/index.ts to expose a isRTL(locale: string): boolean function
  2. Create a RTLProvider component that sets dir on the document element
  3. Audit all third-party libraries for RTL compatibility
  4. Test with long RTL strings for layout overflow
  5. Verify input fields, date pickers, and number inputs handle RTL correctly