feat: EP-Fortschritt speichern + echtes Profil
- saveProgress/getUserProgress (POST /auth/progress, /auth/me) - Feed: onComplete bucht EP, EP-Zähler oben - Profil: echte total_ep/level/streak statt hartkodiert Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,26 @@ export async function getFeedPairs(userToken, lang = 'de', limit = 20) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fortschritt / EP ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Verbucht eine gelöste Karte: aktualisiert EP, Streak und Pair-Statistik.
|
||||||
|
// Gibt { total_ep, streak_days, level } zurück.
|
||||||
|
export async function saveProgress({ pairId, correct, points, userToken }) {
|
||||||
|
const res = await fetch(`${BASE}/auth/progress`, {
|
||||||
|
method: 'POST', headers: auth(userToken),
|
||||||
|
body: JSON.stringify({ pair_id: pairId, correct: !!correct, points: points || 0 }),
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Fortschritt konnte nicht gespeichert werden.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktueller Gesamtfortschritt des Users (EP, Streak, Level) via /auth/me.
|
||||||
|
export async function getUserProgress(userToken) {
|
||||||
|
const me = await getMe(userToken)
|
||||||
|
return { total_ep: me.total_ep || 0, streak_days: me.streak_days || 0, level: me.level || 0 }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Stubs (content-Endpunkte kommen später) ───────────────────────────────────
|
// ── Stubs (content-Endpunkte kommen später) ───────────────────────────────────
|
||||||
|
|
||||||
export async function getActiveLearningPair() { return null }
|
export async function getActiveLearningPair() { return null }
|
||||||
@@ -97,8 +117,6 @@ export async function addPointsToPair() { return false }
|
|||||||
export async function getWords() { return [] }
|
export async function getWords() { return [] }
|
||||||
export async function getQuestions() { return [] }
|
export async function getQuestions() { return [] }
|
||||||
export async function getQAPairsAtLevel() { return [] }
|
export async function getQAPairsAtLevel() { return [] }
|
||||||
export async function getUserProgress() { return [] }
|
|
||||||
export async function saveProgress() { return null }
|
|
||||||
export function assetUrl(fileId) { return fileId || null }
|
export function assetUrl(fileId) { return fileId || null }
|
||||||
export async function getProfilData(userToken) { return getMe(userToken) }
|
export async function getProfilData(userToken) { return getMe(userToken) }
|
||||||
export async function addPointsToUser() { return true }
|
export async function addPointsToUser() { return true }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import './Feed.css'
|
import './Feed.css'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getFeedPairs } from '../api/directus'
|
import { getFeedPairs, saveProgress, getUserProgress } from '../api/directus'
|
||||||
import PairSentenceCard from '../components/PairSentenceCard'
|
import PairSentenceCard from '../components/PairSentenceCard'
|
||||||
import PairYesNoCard from '../components/PairYesNoCard'
|
import PairYesNoCard from '../components/PairYesNoCard'
|
||||||
import PairWordCard from '../components/PairWordCard'
|
import PairWordCard from '../components/PairWordCard'
|
||||||
@@ -23,6 +23,7 @@ export default function Feed() {
|
|||||||
const [done, setDone] = useState(new Set())
|
const [done, setDone] = useState(new Set())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [empty, setEmpty] = useState(false)
|
const [empty, setEmpty] = useState(false)
|
||||||
|
const [totalEp, setTotalEp] = useState(null)
|
||||||
|
|
||||||
// Target language from user profile, fall back to 'de'
|
// Target language from user profile, fall back to 'de'
|
||||||
const lang = user?.language_target_short || 'de'
|
const lang = user?.language_target_short || 'de'
|
||||||
@@ -38,8 +39,23 @@ export default function Feed() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [token, lang])
|
}, [token, lang])
|
||||||
|
|
||||||
function handleComplete(item) {
|
useEffect(() => {
|
||||||
|
getUserProgress(token)
|
||||||
|
.then(p => setTotalEp(p.total_ep))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
function handleComplete(item, result) {
|
||||||
setDone(prev => new Set([...prev, item.meta.pairId]))
|
setDone(prev => new Set([...prev, item.meta.pairId]))
|
||||||
|
const correct = result === 'correct'
|
||||||
|
saveProgress({
|
||||||
|
pairId: item.meta.pairId,
|
||||||
|
correct,
|
||||||
|
points: correct ? item.meta.points : 0,
|
||||||
|
userToken: token,
|
||||||
|
})
|
||||||
|
.then(res => { if (res?.total_ep != null) setTotalEp(res.total_ep) })
|
||||||
|
.catch(err => console.error('saveProgress error', err))
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = cards.filter(c => !done.has(c.meta.pairId))
|
const visible = cards.filter(c => !done.has(c.meta.pairId))
|
||||||
@@ -68,9 +84,19 @@ export default function Feed() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed">
|
||||||
|
{totalEp != null && (
|
||||||
|
<div style={{
|
||||||
|
position: 'sticky', top: 8, zIndex: 5, alignSelf: 'center',
|
||||||
|
background: '#fff', border: '1px solid #EFE7DE', borderRadius: 999,
|
||||||
|
padding: '6px 14px', margin: '4px auto 8px', fontFamily: 'DM Sans, sans-serif',
|
||||||
|
fontWeight: 600, color: '#7A6A58', boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
||||||
|
}}>
|
||||||
|
⭐ {totalEp} EP
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{visible.map((item) => {
|
{visible.map((item) => {
|
||||||
const cardWithMeta = { ...item.card, meta: item.meta }
|
const cardWithMeta = { ...item.card, meta: item.meta }
|
||||||
const handler = () => handleComplete(item)
|
const handler = (result) => handleComplete(item, result)
|
||||||
return (
|
return (
|
||||||
<div key={item.meta.pairId} className="feed-slot">
|
<div key={item.meta.pairId} className="feed-slot">
|
||||||
{item.type === 'text' && <PairSentenceCard card={cardWithMeta} onComplete={handler} />}
|
{item.type === 'text' && <PairSentenceCard card={cardWithMeta} onComplete={handler} />}
|
||||||
|
|||||||
@@ -135,14 +135,15 @@ export default function Profil() {
|
|||||||
load()
|
load()
|
||||||
}, [token, user.username])
|
}, [token, user.username])
|
||||||
|
|
||||||
const displayName = profil?.username?.username_public || user?.username || '…'
|
const displayName = profil?.username || user?.username || '…'
|
||||||
const initials = displayName.slice(0, 2).toUpperCase()
|
const initials = displayName.slice(0, 2).toUpperCase()
|
||||||
const points = pair?.points ?? profil?.points_total ?? 0
|
const points = profil?.total_ep ?? 0
|
||||||
const level = pair?.current_level ?? 1
|
const level = profil?.level ?? Math.floor(points / 500)
|
||||||
const xpMax = level * 500
|
const epIntoLevel = points - level * 500 // EP innerhalb des aktuellen Levels
|
||||||
const xpPct = Math.min((points / xpMax) * 100, 100)
|
const epPerLevel = 500
|
||||||
const toLang = pair ? langById(pair.language_to, langs) : null
|
const xpPct = Math.min((epIntoLevel / epPerLevel) * 100, 100)
|
||||||
const langLabel = toLang ? `${toLang.flag} ${toLang.label}` : 'Zielsprache'
|
const toLang = profil?.language_target_short ? langById(profil.language_target_id, langs) : null
|
||||||
|
const langLabel = toLang ? `${toLang.flag} ${toLang.label}` : (profil?.language_target_titel || 'Zielsprache')
|
||||||
const streak = profil?.streak_days ?? 0
|
const streak = profil?.streak_days ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -193,7 +194,7 @@ export default function Profil() {
|
|||||||
|
|
||||||
<div className="xp-row">
|
<div className="xp-row">
|
||||||
<span className="lang-label">{langLabel}</span>
|
<span className="lang-label">{langLabel}</span>
|
||||||
<span className="xp-value">{points.toLocaleString('de')} / {xpMax.toLocaleString('de')} XP</span>
|
<span className="xp-value">{points.toLocaleString('de')} EP gesamt</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="xp-bar">
|
<div className="xp-bar">
|
||||||
@@ -202,7 +203,7 @@ export default function Profil() {
|
|||||||
|
|
||||||
<div className="level-row">
|
<div className="level-row">
|
||||||
<span className="level-pill">Level {level}</span>
|
<span className="level-pill">Level {level}</span>
|
||||||
<span className="level-hint">{(xpMax - points).toLocaleString('de')} XP bis Level {level + 1}</span>
|
<span className="level-hint">{(epPerLevel - epIntoLevel).toLocaleString('de')} EP bis Level {level + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user