- 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
11 KiB
InvestPlay — Localization Guide
Overview
InvestPlay supports multiple languages through a layered i18n architecture:
- UI strings —
i18nextwith namespaced JSON files (runtime-loaded, no build step) - Educational content — Payload CMS localized collections (per-locale content variants)
- Financial formatting —
Intl.NumberFormatandIntl.DateTimeFormat(browser-native) - 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
- Navigate to Payload Admin -> Settings -> Localization
- Add the new locale code
- Existing content collections will now show locale switcher tabs
- 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
- Author creates a lesson in the default locale (English)
- CMS marks the lesson as "Translation Needed" for other locales
- Translator opens the lesson and sees all localized fields
- Translator fills in the locale-specific content
- CMS automatically uses the locale variant when the API is queried with the matching
Accept-Languageheader
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-startinstead ofmargin-left) - Flexbox and Grid layouts use
gaprather than left/right margins
HTML
- The
<html>element will havedir="rtl"set via the locale configuration - React components should avoid hardcoded
left/rightstyle values
Icons
- Lucide icons support
RTLvia themirrorInRtlproperty on directional icons (arrows, chevrons) - Example:
<ChevronRight className="lucide-rtl-mirror" />
Future Implementation Checklist
When adding RTL support:
- Update
packages/i18n/src/index.tsto expose aisRTL(locale: string): booleanfunction - Create a
RTLProvidercomponent that setsdiron the document element - Audit all third-party libraries for RTL compatibility
- Test with long RTL strings for layout overflow
- Verify input fields, date pickers, and number inputs handle RTL correctly
Related Documentation
- CONTEXT.md — Project context and tech stack
- API.md — API endpoints and authentication
- ARCHITECTURE.md — System architecture