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

378 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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