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
- 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
378 lines
11 KiB
Markdown
378 lines
11 KiB
Markdown
# InvestPlay — Localization Guide
|
||
|
||
## Overview
|
||
|
||
InvestPlay supports multiple languages through a layered i18n architecture:
|
||
|
||
1. **UI strings** — `i18next` with namespaced JSON files (runtime-loaded, no build step)
|
||
2. **Educational content** — Payload CMS localized collections (per-locale content variants)
|
||
3. **Financial formatting** — `Intl.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:
|
||
|
||
```env
|
||
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:
|
||
|
||
```typescript
|
||
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
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```json
|
||
{
|
||
"welcome": "Welcome, {name}!",
|
||
"login": "Sign in to continue"
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
t("welcome", { name: "Alex" });
|
||
// -> "Welcome, Alex!"
|
||
```
|
||
|
||
### Pluralization
|
||
|
||
```json
|
||
{
|
||
"xpEarned": "You earned {count} XP",
|
||
"xpEarned_plural": "You earned {count} XP",
|
||
"lessonsCompleted": "{count} lesson completed",
|
||
"lessonsCompleted_plural": "{count} lessons completed"
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
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):
|
||
|
||
```json
|
||
{
|
||
"streak": "{count, plural, one {# day streak} other {# day streak}}",
|
||
"students": "{count, plural, one {# student} other {# students}}"
|
||
}
|
||
```
|
||
|
||
### Context and Gender
|
||
|
||
```json
|
||
{
|
||
"notification": "{name} {context, select, trade {bought} quiz {scored} badge {unlocked} other {completed}} {target}"
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
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
|
||
|
||
```json
|
||
{
|
||
"terms": "By continuing, you agree to our <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>"
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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
|
||
|
||
```typescript
|
||
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
|
||
|
||
```typescript
|
||
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
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
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`:
|
||
|
||
```typescript
|
||
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
|
||
|
||
---
|
||
|
||
## Related Documentation
|
||
|
||
- [CONTEXT.md](./CONTEXT.md) — Project context and tech stack
|
||
- [API.md](./API.md) — API endpoints and authentication
|
||
- [ARCHITECTURE.md](./ARCHITECTURE.md) — System architecture
|