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:
@@ -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`, {
|
||||
|
||||
@@ -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: '' }
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user