feat(i18n): Complete localization of marketing frontend with react-i18next, prefixed URLs, JSON migrations, and automation

This commit is contained in:
Codex Agent
2025-10-03 13:05:13 +02:00
parent 1845d83583
commit 60f8de9162
46 changed files with 3454 additions and 590 deletions

View File

@@ -97,39 +97,36 @@ use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
// Event types (global)
Schema::create('event_types', function (Blueprint $table) {
$table->id();
$table->json('name');
$table->json('name'); // Translatable: { "de": "Hochzeit", "en": "Wedding" }
$table->string('slug', 100)->unique();
$table->string('icon', 64)->nullable();
$table->json('settings')->nullable();
$table->timestamps();
});
// Events (tenant-scoped)
Schema::create('events', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->json('name');
$table->json('name'); // Translatable: { "de": "Event Name", "en": "Event Name" }
$table->date('date');
$table->string('slug');
$table->json('description')->nullable();
$table->json('description')->nullable(); // Translatable
$table->json('settings')->nullable();
$table->foreignId('event_type_id')->constrained('event_types');
$table->boolean('is_active')->default(true);
$table->string('default_locale', 5)->default('de');
$table->string('default_locale', 5)->default('de'); // For event-specific i18n fallback
$table->timestamps();
$table->unique(['tenant_id', 'slug']);
});
// Emotions (global library)
Schema::create('emotions', function (Blueprint $table) {
$table->id();
$table->json('name');
$table->json('name'); // Translatable: { "de": "Freude", "en": "Joy" }
$table->string('icon', 50);
$table->string('color', 7);
$table->json('description')->nullable();
$table->json('description')->nullable(); // Translatable
$table->integer('sort_order')->default(0);
$table->boolean('is_active')->default(true);
});
@@ -141,22 +138,21 @@ Schema::create('emotion_event_type', function (Blueprint $table) {
$table->primary(['emotion_id', 'event_type_id']);
});
// Tasks (with optional tenant/event scoping)
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('emotion_id')->constrained('emotions');
$table->foreignId('event_type_id')->nullable()->constrained('event_types')->nullOnDelete();
$table->json('title');
$table->json('description');
$table->json('title'); // Translatable
$table->json('description'); // Translatable
$table->string('difficulty', 16)->default('easy'); // app enum
$table->json('example_text')->nullable();
$table->json('example_text')->nullable(); // Translatable
$table->integer('sort_order')->default(0);
$table->boolean('is_active')->default(true);
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
$table->string('scope', 16)->default('global'); // global|tenant|event
$table->foreignId('event_id')->nullable()->constrained('events')->nullOnDelete();
$table->timestamps();
$table->unique(['tenant_id', 'emotion_id', 'title']);
$table->unique(['tenant_id', 'emotion_id', 'title->de']); // Example for de fallback; adjust for multi-locale
});
// Photos
@@ -196,8 +192,8 @@ Schema::create('legal_pages', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
$table->string('slug', 32); // imprint|privacy|terms|custom
$table->json('title');
$table->json('body_markdown');
$table->json('title'); // Translatable
$table->json('body_markdown'); // Translatable Markdown content per locale
$table->string('locale_fallback', 5)->default('de');
$table->unsignedInteger('version')->default(1);
$table->timestamp('effective_from')->nullable();
@@ -211,3 +207,4 @@ Schema::create('legal_pages', function (Blueprint $table) {
- Prefer app-level enums (string columns + validation) over DB `ENUM`.
- Use `cascadeOnDelete()` only where child data must be removed with parent; otherwise `nullOnDelete()`.
- Every tenant-owned table should include `tenant_id` and appropriate composite indexes.
- **i18n Integration**: JSON fields (e.g., `name`, `description`) store locale-specific values as `{ "de": "Text", "en": "Text" }`. Use Laravel's `json` cast or spatie/laravel-translatable for access. Fallback to `default_locale` or global fallback ('de'). Update via Filament resources with locale selectors. Ensure indexes on JSON paths if querying (e.g., `->de` for German titles).

View File

@@ -81,7 +81,8 @@ apps/guest-pwa/
usePollStats.ts // polls /events/:slug/stats every 10s
usePollGalleryDelta.ts // polls /events/:slug/photos?since=...
i18n/
de.json
config.ts // i18next init with react-i18next, backend loadPath '/lang/{{lng}}/guest.json'
de.json // Namespace: guest (e.g., { "gallery": { "title": "Galerie" } })
en.json
main.tsx
App.tsx
@@ -145,6 +146,7 @@ State & Data
- TanStack Query for server data (events, photos); optimistic updates for likes.
- Zustand store for local-only state (profile, queue, banners).
- IndexedDB for upload queue; CacheStorage for shell/assets.
- i18n: react-i18next; load 'guest' namespace JSON from /lang/{locale}/guest.json; path-based detection for /de/e/:slug, /en/e/:slug; useTranslation('guest') in components.
- Polling: focus-aware intervals (10s stats, 30s gallery); use document visibility to pause; backoff on failures.
Accessibility & Performance

View File

@@ -82,7 +82,7 @@ Technical Notes
- Storage: IndexedDB for queue + cache; `CacheStorage` for shell/assets.
- Background Sync: use Background Sync API when available; fallback to retry on app open.
- Accessibility: large tap targets, high contrast, keyboard support, reduced motion.
- i18n: default `de`, fallback `en`; all strings in locale files; RTL not in MVP.
- i18n: react-i18next with JSON files (`public/lang/{locale}/guest.json`); default `de`, fallback `en`; path-based detection (/de/, /en/); RTL not in MVP. Strings for UI (e.g., gallery, upload, tasks) extracted via i18next-scanner; integrate with prefixed routing and middleware.
- Media types: Photos only (no videos) — decision locked for MVP and v1.
- Realtime model: periodic polling (no WebSockets). Home counters every 10s; gallery delta every 30s with exponential backoff when tab hidden or offline.

View File

@@ -1,6 +1,78 @@
# 12 — Internationalization
# 12 — Internationalization (i18n)
- Default locale `de`, fallback `en`.
- Translatable fields stored as JSON (`name`, `description`, `title`, `body_markdown`).
- PWA copy managed via i18n files; glossary maintained centrally.
- Date/number formatting per locale; right-to-left not in MVP.
## Overview
- **Default Locale**: `de` (Deutsch), fallback `en` (English).
- **Supported Locales**: MVP focuses on `de` and `en`; expandable via config/app.php locales array.
- **Strategy**: Hybrid approach Laravel PHP for backend/Blade views, react-i18next for React/Vite PWAs (Marketing, Tenant Admin, Guest).
- **Translatable Fields**: JSON columns in DB for dynamic content (e.g., `name`, `description`, `title`, `body_markdown` in models like Package, EventType, LegalPage).
- **Namespaces**: Organized by feature (e.g., `marketing`, `auth`, `guest`, `admin`) to avoid key collisions; glossary terms centralized in a shared namespace if needed.
- **Date/Number Formatting**: Locale-aware via Laravel's Carbon/Intl; PWAs use Intl API or date-fns with locale bundles.
- **RTL Support**: Not in MVP; future via CSS classes and i18next RTL plugin.
- **SEO**: Multilingual URLs with prefixes (/de/, /en/), hreflang tags, canonical links, translated meta (title, description, og:).
## Backend (Laravel/PHP)
- **Config**: `config/app.php` `locale => 'de'`, `fallback_locale => 'en'`, `available_locales => ['de', 'en']`.
- **Translation Files**:
- PHP arrays: `resources/lang/{locale}/{group}.php` (e.g., `marketing.php`, `auth.php`, `legal.php`) for Blade and API responses.
- JSON for PWAs: `public/lang/{locale}/{namespace}.json` (e.g., `public/lang/de/marketing.json`) migrated from PHP where possible; loaded via dedicated route.
- **Routing**:
- Prefixed groups: `Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('SetLocale')` in `routes/web.php`.
- Fallbacks: Non-prefixed routes redirect to `/de/{path}` (e.g., `/login``/de/login`).
- Auth routes (login, register, logout): Prefixed and named (e.g., `Route::get('/login', ...)->name('login')`).
- API routes: Locale from header/session; no URL prefix for `/api/v1`.
- **Middleware**: `SetLocale` Extracts locale from URL segment(1), sets `App::setLocale()`, stores in session; defaults to 'de'.
- **JSON Loader Route**: `Route::get('/lang/{locale}/{namespace}.json', ...)` Serves from `public_path('lang/{locale}/{namespace}.json')`; Vite proxy forwards requests.
- **DB Translations**: Use JSON fields with spatie/laravel-translatable or native casts; admin UI (Filament) for editing per locale.
- **Legal Pages**: Dynamic via LegalPage model; rendered with `__($key)` or JSON for PWAs.
## Frontend (React/Vite PWAs)
- **Library**: react-i18next with i18next-http-backend for async JSON loads.
- **Setup** (`resources/js/i18n.js`):
- Init: `i18n.use(Backend).use(LanguageDetector).init({ lng: 'de', fallbackLng: 'en', ns: ['marketing', 'auth'], backend: { loadPath: '/lang/{{lng}}/{{ns}}.json' } })`.
- Detection: Path-based (`order: ['path']`, `lookupFromPathIndex: 0`) for prefixed URLs; cookie/session fallback.
- Provider: Wrap `<App>` in `<I18nextProvider i18n={i18n}>` in `app.tsx`.
- **Usage**:
- Hook: `const { t } = useTranslation('namespace');` in components (e.g., `t('marketing.home.title')`).
- Interpolation: Placeholders `{count}`; pluralization via i18next rules.
- Dynamic Keys: Avoid; use namespaces for organization.
- **Inertia Integration**:
- Page Resolver: `resolvePageComponent(`./Pages/${name}.tsx`, import.meta.glob('./Pages/**/*.tsx'))` Matches capital 'Pages' directory.
- Props: Pass `locale` from middleware to pages.
- Links: `<Link href={`/${locale}/path`}>` for prefixed navigation (e.g., Header.tsx).
- **Marketing Frontend**:
- Namespaces: `marketing` (Home, Packages, Blog, Features), `auth` (Login, Register).
- Components: All hard-coded strings replaced (e.g., Home.tsx: `t('marketing.hero.title')`); SEO meta via `Head` with `t()`.
- Header: Locale selector; dynamic links (e.g., `/${locale}/login` with `t('auth.header.login')`).
- **Guest/Tenant PWAs**:
- Similar setup; load JSON on app init.
- Guest: Anonymous, locale from URL or default 'de'; strings for UI (e.g., gallery, upload).
- Tenant Admin: User-preferred locale from profile; sync with backend.
- **Automation**:
- Extraction: i18next-scanner (npm script: `i18next-scanner --config i18next-scanner.config.js`) to scan TSX for `t('key')` and update JSON.
- Validation: Missing keys log warnings; dev mode strict.
## SEO & Accessibility
- **Multilingual URLs**: `/de/home`, `/en/home`; 301 redirects for non-prefixed.
- **Hreflang**: `<link rel="alternate" hreflang="de" href="/de/home">` in `<Head>`.
- **Canonical**: `<link rel="canonical" href={currentUrl}>` based on detected locale.
- **Meta**: Translated via `t('seo.title')`; og:locale='de_DE'.
- **Sitemap**: Generate with `de/` and `en/` variants; update `public/sitemap.xml`.
- **Robots.txt**: Allow both locales; noindex for dev.
- **Accessibility**: ARIA labels with `t()`; screen reader support for language switches.
## Migration from PHP to JSON
- Extract keys from `resources/lang/{locale}/marketing.php` to `public/lang/{locale}/marketing.json`.
- Consolidate: Remove duplicates; use nested objects (e.g., `{ "header": { "login": "Anmelden" } }`).
- Fallback: PHP arrays remain for backend; JSON for PWAs.
## Testing & Maintenance
- **Tests**: PHPUnit for backend (`__('key')`); Vitest/Jest for frontend (`t('key')` renders correctly).
- **Linting**: ESLint rule for missing translations; i18next-scanner in pre-commit.
- **Deployment**: JSON files in public; cache-bust via Vite hashes.
- **Expansion**: Add locales via config; migrate more namespaces (e.g., `guest`, `admin`).
## Decisions & Trade-offs
- Path-based detection over query params for SEO/clean URLs.
- JSON over PHP for PWAs: Faster async loads, no server roundtrips.
- No auto-redirect on locale mismatch in MVP; user chooses via selector.
- ADR: Use react-i18next over next-intl (Inertia compatibility).

View File

@@ -51,7 +51,7 @@ Die App ist API-first und interagiert ausschließlich über den Backend-API-Endp
### Error Handling & UX
- **Rate Limits**: 429-Responses handhaben mit Retry-Logic und User-Feedback ("Zu viele Anfragen, versuche es später").
- **Offline Mode**: Degradiertes UI (Read-Only); Sync-Status-Indikator.
- **i18n**: Unterstützung für de/en; Locale aus User-Profile.
- **i18n**: react-i18next mit JSON (`public/lang/{locale}/admin.json`); de/en; Locale aus User-Profile oder URL-Prefix (/de/, /en/); Detection via LanguageDetector; RTL nicht in MVP.
## API-Integration
Die App konsumiert den API-Contract aus docs/prp/03-api.md. Schlüssel-Endpunkte:

View File

@@ -10,7 +10,7 @@
- **Navigation**: Tabbar unten (Dashboard, Events, Photos, Settings); Side-Menu für Profile/Logout.
- **Offline-Indikator**: Banner oben ("Offline-Modus: Änderungen werden synchronisiert").
- **Loading**: Spinner für API-Calls; Skeleton-Screens für Listen.
- **i18n**: Rechts-nach-Links für de/en; Icons von Framework7-Icons (Material).
- **i18n**: LTR für de/en (react-i18next); alle Strings via `t('admin.key')`; Icons von Lucide React (aktuell, nicht Framework7).
## Benötigte Seiten und Komponenten

View File

@@ -8,7 +8,7 @@
Diese Settings werden lokal in der App gespeichert (via Capacitor Preferences oder IndexedDB) und beeinflussen das Verhalten der App:
### Core App Settings
- **language**: String (default: 'de') UI-Sprache; Sync mit User-Locale.
- **language**: String (default: 'de') UI-Sprache; Sync mit User-Profile und i18next (react-i18next); Fallback 'en'; URL-Prefix (/de/, /en/) für persistente Wechsel.
- **themeMode**: String ('system' | 'light' | 'dark') Dark Mode-Präferenz; Framework7-Theming.
- **offlineMode**: Boolean (default: true) Aktiviert Offline-Caching und Background-Sync.
- **pushNotifications**: Boolean (default: true) Erlaubt Push-Registrierung.