Add migration learning platform: Onboarding, Translation, Parent Portal

Phase 1.1 — user_language_api.py: Stores native language preference
per user (TR/AR/UK/RU/PL/DE/EN). Onboarding page with flag-based
language selection for students and parents.

Phase 1.2 — translation_service.py: Batch-translates vocabulary words
into target languages via Ollama LLM. Stores in translations JSONB.
New endpoint POST /vocabulary/translate triggers translation.

Phase 2.1 — Parent Portal (/parent): Simplified UI in parent's native
language showing child's learning progress. Daily tips translated.

Phase 2.2 — Parent Quiz (/parent/quiz/[unitId]): Parents can quiz
their child on vocabulary WITHOUT speaking DE or EN. Shows word in
child's learning language + parent's native language as hint.
Answer hidden by default, revealed on tap.

All UI text translated into 7 languages (DE/EN/TR/AR/UK/RU/PL).
Arabic gets RTL layout support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 20:17:25 +02:00
parent b49ee3467e
commit d4959172a9
8 changed files with 805 additions and 0 deletions

View File

@@ -119,6 +119,10 @@ app.include_router(progress_router, prefix="/api")
from vocabulary_api import router as vocabulary_router
app.include_router(vocabulary_router, prefix="/api")
# --- 4d. User Language Preferences ---
from user_language_api import router as user_language_router
app.include_router(user_language_router, prefix="/api")
from unit_api import router as unit_router
app.include_router(unit_router) # Already has /api/units prefix

View File

@@ -0,0 +1,179 @@
"""
Translation Service — Batch-translates vocabulary words into target languages.
Uses Ollama (local LLM) to translate EN/DE word pairs into TR, AR, UK, RU, PL.
Translations are cached in vocabulary_words.translations JSONB field.
All processing happens locally — no external API calls, GDPR-compliant.
"""
import json
import logging
import os
from typing import Any, Dict, List
import httpx
logger = logging.getLogger(__name__)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
TRANSLATION_MODEL = os.getenv("TRANSLATION_MODEL", "qwen3:30b-a3b")
LANGUAGE_NAMES = {
"tr": "Turkish",
"ar": "Arabic",
"uk": "Ukrainian",
"ru": "Russian",
"pl": "Polish",
"fr": "French",
"es": "Spanish",
}
async def translate_words_batch(
words: List[Dict[str, str]],
target_language: str,
batch_size: int = 30,
) -> List[Dict[str, str]]:
"""
Translate a batch of EN/DE word pairs into a target language.
Args:
words: List of dicts with 'english' and 'german' keys
target_language: ISO 639-1 code (tr, ar, uk, ru, pl)
batch_size: Words per LLM request
Returns:
List of dicts with 'english', 'translation', 'example' keys
"""
lang_name = LANGUAGE_NAMES.get(target_language, target_language)
all_translations = []
for i in range(0, len(words), batch_size):
batch = words[i:i + batch_size]
word_list = "\n".join(
f"{j+1}. {w['english']} = {w.get('german', '')}"
for j, w in enumerate(batch)
)
prompt = f"""Translate these English/German word pairs into {lang_name}.
For each word, provide the translation and a short example sentence in {lang_name}.
Words:
{word_list}
Reply ONLY with a JSON array, no explanation:
[
{{"english": "word", "translation": "...", "example": "..."}},
...
]"""
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f"{OLLAMA_BASE_URL}/api/generate",
json={
"model": TRANSLATION_MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.2, "num_predict": 4096},
},
)
resp.raise_for_status()
response_text = resp.json().get("response", "")
# Parse JSON from response
import re
match = re.search(r'\[[\s\S]*\]', response_text)
if match:
batch_translations = json.loads(match.group())
all_translations.extend(batch_translations)
logger.info(
f"Translated batch {i//batch_size + 1}: "
f"{len(batch_translations)} words → {lang_name}"
)
else:
logger.warning(f"No JSON array in LLM response for {lang_name}")
except Exception as e:
logger.error(f"Translation batch failed ({lang_name}): {e}")
return all_translations
async def translate_and_store(
word_ids: List[str],
target_language: str,
) -> int:
"""
Translate vocabulary words and store in the database.
Fetches words from DB, translates via LLM, stores in translations JSONB.
Skips words that already have a translation for the target language.
Returns count of newly translated words.
"""
from vocabulary_db import get_pool
pool = await get_pool()
async with pool.acquire() as conn:
# Fetch words that need translation
rows = await conn.fetch(
"""
SELECT id, english, german, translations
FROM vocabulary_words
WHERE id = ANY($1::uuid[])
""",
[__import__('uuid').UUID(wid) for wid in word_ids],
)
words_to_translate = []
word_map = {}
for row in rows:
translations = row["translations"] or {}
if isinstance(translations, str):
translations = json.loads(translations)
if target_language not in translations:
words_to_translate.append({
"english": row["english"],
"german": row["german"],
})
word_map[row["english"].lower()] = str(row["id"])
if not words_to_translate:
logger.info(f"All {len(rows)} words already translated to {target_language}")
return 0
# Translate
results = await translate_words_batch(words_to_translate, target_language)
# Store results
updated = 0
async with pool.acquire() as conn:
for result in results:
en = result.get("english", "").lower()
word_id = word_map.get(en)
if not word_id:
continue
translation = result.get("translation", "")
example = result.get("example", "")
if not translation:
continue
await conn.execute(
"""
UPDATE vocabulary_words
SET translations = translations || $1::jsonb
WHERE id = $2
""",
json.dumps({target_language: {
"text": translation,
"example": example,
}}),
__import__('uuid').UUID(word_id),
)
updated += 1
logger.info(f"Stored {updated} translations for {target_language}")
return updated

View File

@@ -0,0 +1,86 @@
"""
User Language Preferences API — Stores native language + learning level.
Each user (student, parent, teacher) can set their native language.
This drives: UI language, third-language display in flashcards,
parent portal language, and translation generation.
Supported languages: de, en, tr, ar, uk, ru, pl
"""
import logging
import os
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/user", tags=["user-language"])
# Supported native languages with metadata
SUPPORTED_LANGUAGES = {
"de": {"name": "Deutsch", "name_native": "Deutsch", "flag": "de", "rtl": False},
"en": {"name": "English", "name_native": "English", "flag": "gb", "rtl": False},
"tr": {"name": "Tuerkisch", "name_native": "Turkce", "flag": "tr", "rtl": False},
"ar": {"name": "Arabisch", "name_native": "\u0627\u0644\u0639\u0631\u0628\u064a\u0629", "flag": "sy", "rtl": True},
"uk": {"name": "Ukrainisch", "name_native": "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430", "flag": "ua", "rtl": False},
"ru": {"name": "Russisch", "name_native": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439", "flag": "ru", "rtl": False},
"pl": {"name": "Polnisch", "name_native": "Polski", "flag": "pl", "rtl": False},
}
# In-memory store (will be replaced with DB later)
_preferences: Dict[str, Dict[str, Any]] = {}
class LanguagePreference(BaseModel):
native_language: str # ISO 639-1 code
role: str = "student" # student, parent, teacher
learning_level: str = "A1" # A1, A2, B1, B2, C1
@router.get("/languages")
def get_supported_languages():
"""List all supported native languages with metadata."""
return {
"languages": [
{"code": code, **meta}
for code, meta in SUPPORTED_LANGUAGES.items()
]
}
@router.get("/language-preference")
def get_language_preference(user_id: str = Query("default")):
"""Get user's language preference."""
pref = _preferences.get(user_id)
if not pref:
return {"user_id": user_id, "native_language": "de", "role": "student", "learning_level": "A1", "is_default": True}
return {**pref, "is_default": False}
@router.put("/language-preference")
def set_language_preference(
pref: LanguagePreference,
user_id: str = Query("default"),
):
"""Set user's native language and learning level."""
if pref.native_language not in SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=400,
detail=f"Sprache '{pref.native_language}' nicht unterstuetzt. "
f"Verfuegbar: {', '.join(SUPPORTED_LANGUAGES.keys())}",
)
_preferences[user_id] = {
"user_id": user_id,
"native_language": pref.native_language,
"role": pref.role,
"learning_level": pref.learning_level,
}
lang_meta = SUPPORTED_LANGUAGES[pref.native_language]
logger.info(f"Language preference set: user={user_id} lang={pref.native_language} ({lang_meta['name']})")
return {**_preferences[user_id], "language_meta": lang_meta}

View File

@@ -324,3 +324,29 @@ async def api_bulk_import(payload: BulkImportPayload):
count = await insert_words_bulk(words)
logger.info(f"Bulk imported {count} vocabulary words")
return {"imported": count}
# ---------------------------------------------------------------------------
# Translation Generation
# ---------------------------------------------------------------------------
class TranslateRequest(BaseModel):
word_ids: List[str]
target_language: str
@router.post("/translate")
async def api_translate_words(payload: TranslateRequest):
"""Generate translations for vocabulary words into a target language.
Uses local LLM (Ollama) for translation. Results are cached in the
vocabulary_words.translations JSONB field.
"""
from translation_service import translate_and_store
if payload.target_language not in {"tr", "ar", "uk", "ru", "pl", "fr", "es"}:
raise HTTPException(status_code=400, detail=f"Sprache '{payload.target_language}' nicht unterstuetzt")
count = await translate_and_store(payload.word_ids, payload.target_language)
return {"translated": count, "target_language": payload.target_language}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-lehrer:8001'
async function proxyRequest(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
): Promise<NextResponse> {
const { path } = await params
const pathStr = path.join('/')
const searchParams = request.nextUrl.searchParams.toString()
const url = `${BACKEND_URL}/api/user/${pathStr}${searchParams ? `?${searchParams}` : ''}`
try {
const fetchOptions: RequestInit = {
method: request.method,
headers: { 'Content-Type': 'application/json' },
}
if (request.method !== 'GET' && request.method !== 'HEAD') {
fetchOptions.body = await request.text()
}
const resp = await fetch(url, fetchOptions)
const data = await resp.text()
return new NextResponse(data, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 502 })
}
}
export const GET = proxyRequest
export const POST = proxyRequest
export const PUT = proxyRequest

View File

@@ -0,0 +1,175 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
interface LangOption {
code: string
name: string
name_native: string
flag: string
rtl: boolean
}
const STORAGE_KEY = 'bp_native_language'
export default function OnboardingPage() {
const router = useRouter()
const { isDark } = useTheme()
const { setLanguage } = useLanguage()
const [languages, setLanguages] = useState<LangOption[]>([])
const [selected, setSelected] = useState<string | null>(null)
const [role, setRole] = useState<'student' | 'parent'>('student')
const [saving, setSaving] = useState(false)
// Skip if already onboarded
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) router.replace('/learn')
}, [router])
// Load supported languages
useEffect(() => {
fetch('/api/user/languages')
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.languages) setLanguages(d.languages) })
.catch(() => {
// Fallback if API unavailable
setLanguages([
{ code: 'de', name: 'Deutsch', name_native: 'Deutsch', flag: 'de', rtl: false },
{ code: 'tr', name: 'Tuerkisch', name_native: 'Turkce', flag: 'tr', rtl: false },
{ code: 'ar', name: 'Arabisch', name_native: '\u0627\u0644\u0639\u0631\u0628\u064a\u0629', flag: 'sy', rtl: true },
{ code: 'uk', name: 'Ukrainisch', name_native: '\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430', flag: 'ua', rtl: false },
{ code: 'ru', name: 'Russisch', name_native: '\u0420\u0443\u0441\u0441\u043a\u0438\u0439', flag: 'ru', rtl: false },
{ code: 'pl', name: 'Polnisch', name_native: 'Polski', flag: 'pl', rtl: false },
])
})
}, [])
const handleContinue = async () => {
if (!selected) return
setSaving(true)
// Save to backend
try {
await fetch('/api/user/language-preference?user_id=default', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ native_language: selected, role }),
})
} catch {}
// Save locally + set UI language
localStorage.setItem(STORAGE_KEY, selected)
setLanguage(selected)
// Navigate
if (role === 'parent') {
router.push('/parent')
} else {
router.push('/learn')
}
}
const greetings: Record<string, string> = {
de: 'Willkommen!',
en: 'Welcome!',
tr: 'Hos geldiniz!',
ar: '\u0645\u0631\u062d\u0628\u0627!',
uk: '\u041b\u0430\u0441\u043a\u0430\u0432\u043e \u043f\u0440\u043e\u0441\u0438\u043c\u043e!',
ru: '\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c!',
pl: 'Witamy!',
}
return (
<div className={`min-h-screen flex items-center justify-center ${
isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50'
}`}>
<div className={`w-full max-w-md mx-4 rounded-3xl p-8 ${
isDark ? 'bg-white/10 backdrop-blur-xl border border-white/20' : 'bg-white shadow-2xl border border-slate-200'
}`}>
{/* Greeting in multiple languages */}
<div className="text-center mb-8">
<div className="text-4xl mb-3">
{selected ? greetings[selected] || greetings.de : (
<span className={`${isDark ? 'text-white/80' : 'text-slate-700'}`}>
{Object.values(greetings).slice(0, 4).join(' / ')}
</span>
)}
</div>
<h1 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
BreakPilot Sprache
</h1>
</div>
{/* Role Selection */}
<div className="flex gap-2 mb-6">
{(['student', 'parent'] as const).map(r => (
<button
key={r}
onClick={() => setRole(r)}
className={`flex-1 py-3 rounded-xl text-sm font-medium transition-all ${
role === r
? 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-md'
: isDark ? 'bg-white/5 text-white/50 hover:bg-white/10' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{r === 'student' ? 'Schueler / Student' : 'Elternteil / Parent'}
</button>
))}
</div>
{/* Question */}
<p className={`text-center text-sm mb-4 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{role === 'student' ? 'Was ist deine Muttersprache?' : 'Welche Sprache sprechen Sie?'}
</p>
{/* Language Grid */}
<div className="grid grid-cols-2 gap-2 mb-6">
{languages.map(lang => (
<button
key={lang.code}
onClick={() => setSelected(lang.code)}
className={`p-3 rounded-xl text-left transition-all ${
selected === lang.code
? (isDark ? 'bg-blue-500/30 border-2 border-blue-400 ring-2 ring-blue-400/30' : 'bg-blue-50 border-2 border-blue-500 ring-2 ring-blue-500/20')
: (isDark ? 'bg-white/5 border-2 border-transparent hover:bg-white/10' : 'bg-slate-50 border-2 border-transparent hover:bg-slate-100')
}`}
>
<div className="flex items-center gap-2">
<span className="text-lg">{lang.flag === 'de' ? '\uD83C\uDDE9\uD83C\uDDEA' : lang.flag === 'tr' ? '\uD83C\uDDF9\uD83C\uDDF7' : lang.flag === 'sy' ? '\uD83C\uDDF8\uD83C\uDDFE' : lang.flag === 'ua' ? '\uD83C\uDDFA\uD83C\uDDE6' : lang.flag === 'ru' ? '\uD83C\uDDF7\uD83C\uDDFA' : lang.flag === 'pl' ? '\uD83C\uDDF5\uD83C\uDDF1' : lang.flag === 'gb' ? '\uD83C\uDDEC\uD83C\uDDE7' : '\uD83C\uDFF3\uFE0F'}</span>
<div>
<div className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{lang.name_native}</div>
<div className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{lang.name}</div>
</div>
</div>
</button>
))}
</div>
{/* Continue */}
<button
onClick={handleContinue}
disabled={!selected || saving}
className={`w-full py-4 rounded-xl font-semibold text-lg transition-all ${
selected
? 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg hover:shadow-blue-500/25'
: isDark ? 'bg-white/5 text-white/30' : 'bg-slate-100 text-slate-400'
}`}
>
{saving ? 'Wird gespeichert...' : selected ? (
selected === 'de' ? 'Weiter' :
selected === 'tr' ? 'Devam' :
selected === 'ar' ? '\u0627\u0633\u062a\u0645\u0631' :
selected === 'uk' ? '\u041f\u0440\u043e\u0434\u043e\u0432\u0436\u0438\u0442\u0438' :
selected === 'ru' ? '\u041f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c' :
selected === 'pl' ? 'Dalej' :
'Continue'
) : 'Sprache waehlen'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,125 @@
'use client'
import React, { useState, useEffect } from 'react'
import Link from 'next/link'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
interface LearningUnit {
id: string
label: string
meta: string
status: string
created_at: string
}
// Parent-specific translations (expanded from i18n)
const parentT: Record<string, Record<string, string>> = {
title: { de: 'Eltern-Portal', tr: 'Ebeveyn Portal\u0131', ar: '\u0628\u0648\u0627\u0628\u0629 \u0627\u0644\u0648\u0627\u0644\u062f\u064a\u0646', uk: '\u041f\u043e\u0440\u0442\u0430\u043b \u0431\u0430\u0442\u044c\u043a\u0456\u0432', ru: '\u041f\u043e\u0440\u0442\u0430\u043b \u0440\u043e\u0434\u0438\u0442\u0435\u043b\u0435\u0439', pl: 'Portal rodzica', en: 'Parent Portal' },
greeting: { de: 'Willkommen!', tr: 'Hos geldiniz!', ar: '\u0645\u0631\u062d\u0628\u0627!', uk: '\u041b\u0430\u0441\u043a\u0430\u0432\u043e \u043f\u0440\u043e\u0441\u0438\u043c\u043e!', ru: '\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c!', pl: 'Witamy!', en: 'Welcome!' },
child_progress: { de: 'Lernfortschritt', tr: '\u00d6\u011frenme ilerlemesi', ar: '\u062a\u0642\u062f\u0645 \u0627\u0644\u062a\u0639\u0644\u0645', uk: '\u041f\u0440\u043e\u0433\u0440\u0435\u0441 \u043d\u0430\u0432\u0447\u0430\u043d\u043d\u044f', ru: '\u041f\u0440\u043e\u0433\u0440\u0435\u0441\u0441 \u043e\u0431\u0443\u0447\u0435\u043d\u0438\u044f', pl: 'Post\u0119py w nauce', en: 'Learning Progress' },
quiz_child: { de: 'Vokabeln abfragen', tr: 'Kelime sorgula', ar: '\u0627\u062e\u062a\u0628\u0627\u0631 \u0627\u0644\u0645\u0641\u0631\u062f\u0627\u062a', uk: '\u041e\u043f\u0438\u0442\u0430\u0442\u0438 \u0441\u043b\u043e\u0432\u0430', ru: '\u041e\u043f\u0440\u043e\u0441\u0438\u0442\u044c \u0441\u043b\u043e\u0432\u0430', pl: 'Odpyta\u0107 s\u0142\u00f3wka', en: 'Quiz your child' },
no_units: { de: 'Noch keine Lerneinheiten vorhanden.', tr: 'Hen\u00fcz \u00f6\u011frenme birimi yok.', ar: '\u0644\u0627 \u062a\u0648\u062c\u062f \u0648\u062d\u062f\u0627\u062a \u062a\u0639\u0644\u064a\u0645\u064a\u0629 \u0628\u0639\u062f.', uk: '\u041d\u0430\u0432\u0447\u0430\u043b\u044c\u043d\u0438\u0445 \u043c\u043e\u0434\u0443\u043b\u0456\u0432 \u043f\u043e\u043a\u0438 \u043d\u0435\u043c\u0430\u0454.', ru: '\u0423\u0447\u0435\u0431\u043d\u044b\u0445 \u043c\u043e\u0434\u0443\u043b\u0435\u0439 \u043f\u043e\u043a\u0430 \u043d\u0435\u0442.', pl: 'Brak jednostek do nauki.', en: 'No learning units yet.' },
words: { de: 'Woerter', tr: 'kelime', ar: '\u0643\u0644\u0645\u0627\u062a', uk: '\u0441\u043b\u0456\u0432', ru: '\u0441\u043b\u043e\u0432', pl: 's\u0142\u00f3w', en: 'words' },
tip: { de: 'Tipp fuer heute', tr: 'Bug\u00fcn\u00fcn ipucu', ar: '\u0646\u0635\u064a\u062d\u0629 \u0627\u0644\u064a\u0648\u0645', uk: '\u041f\u043e\u0440\u0430\u0434\u0430 \u043d\u0430 \u0441\u044c\u043e\u0433\u043e\u0434\u043d\u0456', ru: '\u0421\u043e\u0432\u0435\u0442 \u043d\u0430 \u0441\u0435\u0433\u043e\u0434\u043d\u044f', pl: 'Wskaz\u00f3wka na dzi\u015b', en: 'Tip for today' },
}
export default function ParentPage() {
const { isDark } = useTheme()
const { language } = useLanguage()
const [units, setUnits] = useState<LearningUnit[]>([])
const [isLoading, setIsLoading] = useState(true)
const t = (key: string) => parentT[key]?.[language] || parentT[key]?.['de'] || key
const glassCard = isDark
? 'bg-white/10 backdrop-blur-xl border border-white/10'
: 'bg-white/80 backdrop-blur-xl border border-black/5'
useEffect(() => {
fetch('/api/learning-units/')
.then(r => r.ok ? r.json() : [])
.then(setUnits)
.catch(() => {})
.finally(() => setIsLoading(false))
}, [])
return (
<div className={`min-h-screen ${isDark ? 'bg-gradient-to-br from-slate-900 via-blue-900 to-indigo-900' : 'bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50'}`}
dir={language === 'ar' ? 'rtl' : 'ltr'}>
{/* Header */}
<div className={`${glassCard} border-0 border-b`}>
<div className="max-w-lg mx-auto px-6 py-5">
<div className="flex items-center justify-between">
<div>
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{t('title')}
</h1>
<p className={`text-lg ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{t('greeting')}
</p>
</div>
<Link href="/onboarding" className={`text-sm px-3 py-1.5 rounded-lg ${isDark ? 'bg-white/10 text-white/60' : 'bg-slate-100 text-slate-500'}`}>
{language.toUpperCase()}
</Link>
</div>
</div>
</div>
{/* Content */}
<div className="max-w-lg mx-auto px-6 py-6 space-y-4">
<h2 className={`text-lg font-semibold ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
{t('child_progress')}
</h2>
{isLoading && (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-4 border-blue-400 border-t-transparent rounded-full animate-spin" />
</div>
)}
{!isLoading && units.length === 0 && (
<div className={`${glassCard} rounded-2xl p-8 text-center`}>
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>{t('no_units')}</p>
</div>
)}
{units.map(unit => (
<div key={unit.id} className={`${glassCard} rounded-2xl p-5`}>
<h3 className={`text-lg font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{unit.label}
</h3>
<p className={`text-sm mb-4 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{unit.meta}
</p>
<Link
href={`/parent/quiz/${unit.id}`}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-medium hover:shadow-lg transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{t('quiz_child')}
</Link>
</div>
))}
{/* Daily Tip */}
<div className={`${glassCard} rounded-2xl p-5`}>
<h3 className={`text-sm font-medium mb-2 ${isDark ? 'text-yellow-300/80' : 'text-yellow-600'}`}>
{t('tip')}
</h3>
<p className={`text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
{language === 'tr' && '\u00c7ocugunuza her g\u00fcn 5 dakika kelime \u00e7al\u0131s\u0131n. K\u0131sa ama d\u00fczenli \u00e7al\u0131sma en etkili y\u00f6ntemdir.'}
{language === 'ar' && '\u0627\u0637\u0644\u0628 \u0645\u0646 \u0637\u0641\u0644\u0643 \u0623\u0646 \u064a\u062a\u062f\u0631\u0628 \u0639\u0644\u0649 \u0627\u0644\u0645\u0641\u0631\u062f\u0627\u062a 5 \u062f\u0642\u0627\u0626\u0642 \u0643\u0644 \u064a\u0648\u0645. \u0627\u0644\u062a\u062f\u0631\u064a\u0628 \u0627\u0644\u0642\u0635\u064a\u0631 \u0648\u0627\u0644\u0645\u0646\u062a\u0638\u0645 \u0647\u0648 \u0627\u0644\u0623\u0643\u062b\u0631 \u0641\u0639\u0627\u0644\u064a\u0629.'}
{language === 'uk' && '\u041f\u043e\u043f\u0440\u043e\u0441\u0456\u0442\u044c \u0434\u0438\u0442\u0438\u043d\u0443 \u0432\u0447\u0438\u0442\u0438 \u0441\u043b\u043e\u0432\u0430 5 \u0445\u0432\u0438\u043b\u0438\u043d \u043a\u043e\u0436\u043d\u043e\u0433\u043e \u0434\u043d\u044f. \u041a\u043e\u0440\u043e\u0442\u043a\u0456 \u0430\u043b\u0435 \u0440\u0435\u0433\u0443\u043b\u044f\u0440\u043d\u0456 \u0437\u0430\u043d\u044f\u0442\u0442\u044f \u043d\u0430\u0439\u0435\u0444\u0435\u043a\u0442\u0438\u0432\u043d\u0456\u0448\u0456.'}
{language === 'ru' && '\u041f\u043e\u043f\u0440\u043e\u0441\u0438\u0442\u0435 \u0440\u0435\u0431\u0435\u043d\u043a\u0430 \u0443\u0447\u0438\u0442\u044c \u0441\u043b\u043e\u0432\u0430 5 \u043c\u0438\u043d\u0443\u0442 \u043a\u0430\u0436\u0434\u044b\u0439 \u0434\u0435\u043d\u044c. \u041a\u043e\u0440\u043e\u0442\u043a\u0438\u0435 \u0440\u0435\u0433\u0443\u043b\u044f\u0440\u043d\u044b\u0435 \u0437\u0430\u043d\u044f\u0442\u0438\u044f \u0441\u0430\u043c\u044b\u0435 \u044d\u0444\u0444\u0435\u043a\u0442\u0438\u0432\u043d\u044b\u0435.'}
{(language === 'de' || !['tr','ar','uk','ru'].includes(language)) && 'Bitten Sie Ihr Kind jeden Tag 5 Minuten Vokabeln zu ueben. Kurze regelmaessige Uebungen sind am effektivsten.'}
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,175 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { AudioButton } from '@/components/learn/AudioButton'
interface QAItem {
id: string; question: string; answer: string
translations?: Record<string, { text?: string }>
}
const pt: Record<string, Record<string, string>> = {
ask_child: { de: 'Fragen Sie Ihr Kind:', tr: '\u00c7ocu\u011funuza sorun:', ar: '\u0627\u0633\u0623\u0644 \u0637\u0641\u0644\u0643:', uk: '\u0417\u0430\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u0434\u0438\u0442\u0438\u043d\u0443:', ru: '\u0421\u043f\u0440\u043e\u0441\u0438\u0442\u0435 \u0440\u0435\u0431\u0435\u043d\u043a\u0430:', en: 'Ask your child:' },
correct_answer: { de: 'Richtige Antwort:', tr: 'Do\u011fru cevap:', ar: '\u0627\u0644\u0625\u062c\u0627\u0628\u0629 \u0627\u0644\u0635\u062d\u064a\u062d\u0629:', uk: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u0430 \u0432\u0456\u0434\u043f\u043e\u0432\u0456\u0434\u044c:', ru: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043e\u0442\u0432\u0435\u0442:', en: 'Correct answer:' },
show_answer: { de: 'Antwort zeigen', tr: 'Cevab\u0131 g\u00f6ster', ar: '\u0625\u0638\u0647\u0627\u0631 \u0627\u0644\u0625\u062c\u0627\u0628\u0629', uk: '\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u0438 \u0432\u0456\u0434\u043f\u043e\u0432\u0456\u0434\u044c', ru: '\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043e\u0442\u0432\u0435\u0442', en: 'Show answer' },
hide_answer: { de: 'Antwort verbergen', tr: 'Cevab\u0131 gizle', ar: '\u0625\u062e\u0641\u0627\u0621 \u0627\u0644\u0625\u062c\u0627\u0628\u0629', uk: '\u0421\u0445\u043e\u0432\u0430\u0442\u0438 \u0432\u0456\u0434\u043f\u043e\u0432\u0456\u0434\u044c', ru: '\u0421\u043a\u0440\u044b\u0442\u044c \u043e\u0442\u0432\u0435\u0442', en: 'Hide answer' },
wrong: { de: 'Falsch', tr: 'Yanl\u0131\u015f', ar: '\u062e\u0637\u0623', uk: '\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', ru: '\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', en: 'Wrong' },
correct: { de: 'Richtig', tr: 'Do\u011fru', ar: '\u0635\u062d\u064a\u062d', uk: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', ru: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', en: 'Correct' },
done: { de: 'Fertig!', tr: 'Bitti!', ar: '\u0627\u0646\u062a\u0647\u0649!', uk: '\u0413\u043e\u0442\u043e\u0432\u043e!', ru: '\u0413\u043e\u0442\u043e\u0432\u043e!', en: 'Done!' },
again: { de: 'Nochmal', tr: 'Tekrar', ar: '\u0645\u0631\u0629 \u0623\u062e\u0631\u0649', uk: '\u0429\u0435 \u0440\u0430\u0437', ru: '\u0415\u0449\u0435 \u0440\u0430\u0437', en: 'Again' },
back: { de: 'Zurueck', tr: 'Geri', ar: '\u0631\u062c\u0648\u0639', uk: '\u041d\u0430\u0437\u0430\u0434', ru: '\u041d\u0430\u0437\u0430\u0434', en: 'Back' },
question: { de: 'Frage', tr: 'Soru', ar: '\u0633\u0624\u0627\u0644', uk: '\u041f\u0438\u0442\u0430\u043d\u043d\u044f', ru: '\u0412\u043e\u043f\u0440\u043e\u0441', en: 'Question' },
}
export default function ParentQuizPage() {
const { unitId } = useParams<{ unitId: string }>()
const router = useRouter()
const { isDark } = useTheme()
const { language } = useLanguage()
const [items, setItems] = useState<QAItem[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const [showAnswer, setShowAnswer] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [stats, setStats] = useState({ correct: 0, incorrect: 0 })
const [isComplete, setIsComplete] = useState(false)
const t = (key: string) => pt[key]?.[language] || pt[key]?.['de'] || key
const glassCard = isDark
? 'bg-white/10 backdrop-blur-xl border border-white/10'
: 'bg-white/80 backdrop-blur-xl border border-black/5'
useEffect(() => {
fetch(`/api/learning-units/${unitId}/qa`)
.then(r => r.ok ? r.json() : { qa_items: [] })
.then(d => setItems(d.qa_items || []))
.catch(() => {})
.finally(() => setIsLoading(false))
}, [unitId])
const handleResult = useCallback((correct: boolean) => {
setStats(prev => ({
correct: prev.correct + (correct ? 1 : 0),
incorrect: prev.incorrect + (correct ? 0 : 1),
}))
setShowAnswer(false)
if (currentIndex + 1 >= items.length) { setIsComplete(true) }
else { setCurrentIndex(i => i + 1) }
}, [currentIndex, items.length])
const currentItem = items[currentIndex]
const nativeTranslation = currentItem?.translations?.[language]?.text || ''
if (isLoading) {
return <div className={`min-h-screen flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-slate-900 via-blue-900 to-indigo-900' : 'bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50'}`}>
<div className="w-8 h-8 border-4 border-blue-400 border-t-transparent rounded-full animate-spin" />
</div>
}
return (
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-slate-900 via-blue-900 to-indigo-900' : 'bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50'}`}
dir={language === 'ar' ? 'rtl' : 'ltr'}>
{/* Header */}
<div className={`${glassCard} border-0 border-b`}>
<div className="max-w-lg mx-auto px-6 py-4 flex items-center justify-between">
<button onClick={() => router.push('/parent')} className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{t('back')}
</button>
<span className={`font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{t('question')} {currentIndex + 1}/{items.length}
</span>
<span />
</div>
</div>
{/* Progress */}
<div className="w-full h-1.5 bg-white/10">
<div className="h-full bg-gradient-to-r from-blue-500 to-cyan-500 transition-all" style={{ width: `${(currentIndex / Math.max(items.length, 1)) * 100}%` }} />
</div>
<div className="flex-1 flex items-center justify-center px-6 py-8">
{isComplete ? (
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
<div className="text-5xl mb-4">{stats.correct > stats.incorrect ? '\uD83C\uDF89' : '\uD83D\uDCAA'}</div>
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('done')}</h2>
<p className={`text-lg mb-6 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
{stats.correct}/{items.length} {t('correct').toLowerCase()}
</p>
<div className="flex gap-3">
<button onClick={() => { setCurrentIndex(0); setStats({ correct: 0, incorrect: 0 }); setIsComplete(false) }}
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-medium">{t('again')}</button>
<button onClick={() => router.push('/parent')}
className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>{t('back')}</button>
</div>
</div>
) : currentItem ? (
<div className="w-full max-w-md space-y-6">
{/* Instruction for parent */}
<p className={`text-center text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{t('ask_child')}
</p>
{/* Word to ask */}
<div className={`${glassCard} rounded-3xl p-8 text-center`}>
<span className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{currentItem.question}
</span>
{nativeTranslation && (
<p className={`text-lg mt-3 ${isDark ? 'text-blue-300/70' : 'text-blue-600'}`}>
({nativeTranslation})
</p>
)}
<div className="flex justify-center gap-3 mt-4">
<AudioButton text={currentItem.question} lang="en" isDark={isDark} size="md" />
</div>
</div>
{/* Show/Hide Answer */}
<button
onClick={() => setShowAnswer(!showAnswer)}
className={`w-full py-3 rounded-xl border text-sm font-medium transition-all ${
isDark ? 'border-white/20 text-white/70 hover:bg-white/5' : 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{showAnswer ? t('hide_answer') : t('show_answer')}
</button>
{showAnswer && (
<div className={`${glassCard} rounded-2xl p-6 text-center`}>
<p className={`text-xs mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{t('correct_answer')}</p>
<span className={`text-2xl font-bold ${isDark ? 'text-green-300' : 'text-green-700'}`}>
{currentItem.answer}
</span>
{nativeTranslation && (
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
({nativeTranslation})
</p>
)}
<div className="flex justify-center mt-3">
<AudioButton text={currentItem.answer} lang="de" isDark={isDark} size="md" />
</div>
</div>
)}
{/* Right/Wrong Buttons */}
<div className="flex gap-4">
<button onClick={() => handleResult(false)}
className="flex-1 py-4 rounded-2xl font-semibold bg-gradient-to-r from-red-500 to-rose-500 text-white hover:shadow-lg transition-all">
{t('wrong')}
</button>
<button onClick={() => handleResult(true)}
className="flex-1 py-4 rounded-2xl font-semibold bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg transition-all">
{t('correct')}
</button>
</div>
</div>
) : null}
</div>
</div>
)
}