feat: complete Phase 1 foundation scaffold
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
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
This commit is contained in:
377
docs/LOCALIZATION.md
Normal file
377
docs/LOCALIZATION.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user