feat: Erfolge im Feed-Overlay + Profil-Sektion

- getAchievements() im API-Client
- unlocked_achievements aus saveProgress als Overlay-Typ 'achievement'
- ERFOLGE-Sektion im Profil (freigeschaltet/gesperrt), degradiert lautlos ohne Endpoint

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 21:54:02 +02:00
parent 039d2cbbf4
commit 98543979db
5 changed files with 60 additions and 5 deletions

View File

@@ -120,6 +120,14 @@ export async function getStats(userToken) {
return data
}
// Alle Erfolge mit Freischalt-Status: [{ key, label, icon, unlocked, unlocked_at }].
export async function getAchievements(userToken) {
const res = await fetch(`${BASE}/auth/achievements`, { headers: auth(userToken) })
const data = await res.json().catch(() => [])
if (!res.ok) throw new Error('Erfolge konnten nicht geladen werden.')
return data
}
// Tagesziel (EP/Tag) setzen. Gibt { daily_goal_ep } zurück.
export async function setDailyGoal(dailyGoalEp, userToken) {
const res = await fetch(`${BASE}/auth/goal`, {

View File

@@ -12,10 +12,12 @@ function celebrate() {
}
// Texte je Milestone-Art. value = Level-Nummer / Streak-Tage / Tagesziel-EP.
function content({ kind, value }) {
if (kind === 'level') return { cls: '', icon: '🏆', title: `Level ${value} erreicht!`, sub: 'Du wächst spürbar — weiter so.' }
if (kind === 'streak') return { cls: 'streak', icon: '🔥', title: `${value} Tage am Stück!`, sub: 'Dranbleiben zahlt sich aus.' }
if (kind === 'goal') return { cls: 'goal', icon: '🎯', title: 'Tagesziel erreicht!', sub: 'Stark — heute hast du dein Pensum geschafft.' }
function content(m) {
const { kind, value } = m
if (kind === 'level') return { cls: '', icon: '🏆', title: `Level ${value} erreicht!`, sub: 'Du wächst spürbar — weiter so.' }
if (kind === 'streak') return { cls: 'streak', icon: '🔥', title: `${value} Tage am Stück!`, sub: 'Dranbleiben zahlt sich aus.' }
if (kind === 'goal') return { cls: 'goal', icon: '🎯', title: 'Tagesziel erreicht!', sub: 'Stark — heute hast du dein Pensum geschafft.' }
if (kind === 'achievement') return { cls: 'streak', icon: m.icon || '🎖️', title: m.label || 'Erfolg!', sub: 'Erfolg freigeschaltet! 🎉' }
return { cls: '', icon: '🎉', title: 'Geschafft!', sub: '' }
}

View File

@@ -169,6 +169,11 @@ export default function Feed() {
queued.push({ kind: 'goal', value: res?.daily_goal_ep ?? goalEp })
}
// Neu freigeschaltete Erfolge zuletzt feiern
for (const a of (res?.unlocked_achievements || [])) {
queued.push({ kind: 'achievement', key: a.key, label: a.label, icon: a.icon })
}
progress.current = { level: newLevel, streak: newStreak }
if (queued.length) { setMilestones(q => [...q, ...queued]); playMilestone() }
})

View File

@@ -185,6 +185,28 @@
.cat-label { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); }
.cat-tier { font-size: 11px; font-weight: 600; color: var(--text-soft); }
.cat-points { font-size: 12px; font-weight: 800; color: var(--accent); }
/* ── Erfolge ── */
.ach-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--sp-2);
}
.ach-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: var(--sp-3) var(--sp-2);
border-radius: var(--r-sm);
text-align: center;
}
.ach-tile.on { background: var(--gold-soft); }
.ach-tile.off { background: var(--surface-2); opacity: 0.7; }
.ach-icon { font-size: 24px; line-height: 1; }
.ach-tile.off .ach-icon { filter: grayscale(1); opacity: 0.6; }
.ach-label { font-size: 11px; font-weight: 700; color: var(--text); line-height: 1.25; }
.ach-tile.off .ach-label { color: var(--text-muted); }
.cat-bar { height: 6px; width: 100%; background: var(--surface-2); border-radius: var(--r-pill); overflow: hidden; }
.cat-bar-fill { height: 100%; border-radius: var(--r-pill); transition: width 0.6s var(--ease); }

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useMemo } from 'react'
import './Profil.css'
import { useAuth } from '../context/AuthContext'
import { getProfilData, getStats, getLanguageOptions, langById } from '../api/directus'
import { getProfilData, getStats, getLanguageOptions, langById, getAchievements } from '../api/directus'
import ProgressRing from '../components/ProgressRing'
import { levelInfo } from '../utils/leveling'
import { categoryTier, capabilitySentence } from '../utils/praise'
@@ -146,6 +146,7 @@ export default function Profil() {
const [profil, setProfil] = useState(null)
const [stats, setStats] = useState(null)
const [langs, setLangs] = useState([])
const [achievements, setAchievements] = useState([])
useEffect(() => {
const t = setTimeout(() => setRadarReady(true), 120)
@@ -160,6 +161,8 @@ export default function Profil() {
} catch { /* Fallback unten */ }
// Stats getrennt degradiert lautlos, falls /auth/stats noch nicht deployed ist
try { setStats(await getStats(token)) } catch { /* kein Tracking verfügbar */ }
// Erfolge degradiert lautlos, falls /auth/achievements noch nicht deployed ist
try { setAchievements(await getAchievements(token)) } catch { /* keine Erfolge verfügbar */ }
}
load()
}, [token])
@@ -315,6 +318,21 @@ export default function Profil() {
</div>
)}
{/* ── Erfolge ── */}
{achievements.length > 0 && (
<div className="card">
<p className="card-title">ERFOLGE · {achievements.filter(a => a.unlocked).length}/{achievements.length}</p>
<div className="ach-grid">
{achievements.map(a => (
<div key={a.key} className={`ach-tile ${a.unlocked ? 'on' : 'off'}`} title={a.label}>
<span className="ach-icon">{a.unlocked ? a.icon : '🔒'}</span>
<span className="ach-label">{a.label}</span>
</div>
))}
</div>
</div>
)}
{/* ── Streak-Kalender ── */}
{stats && (
<div className="card">