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
|
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.
|
// Tagesziel (EP/Tag) setzen. Gibt { daily_goal_ep } zurück.
|
||||||
export async function setDailyGoal(dailyGoalEp, userToken) {
|
export async function setDailyGoal(dailyGoalEp, userToken) {
|
||||||
const res = await fetch(`${BASE}/auth/goal`, {
|
const res = await fetch(`${BASE}/auth/goal`, {
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ function celebrate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Texte je Milestone-Art. value = Level-Nummer / Streak-Tage / Tagesziel-EP.
|
// Texte je Milestone-Art. value = Level-Nummer / Streak-Tage / Tagesziel-EP.
|
||||||
function content({ kind, value }) {
|
function content(m) {
|
||||||
if (kind === 'level') return { cls: '', icon: '🏆', title: `Level ${value} erreicht!`, sub: 'Du wächst spürbar — weiter so.' }
|
const { kind, value } = m
|
||||||
if (kind === 'streak') return { cls: 'streak', icon: '🔥', title: `${value} Tage am Stück!`, sub: 'Dranbleiben zahlt sich aus.' }
|
if (kind === 'level') return { cls: '', icon: '🏆', title: `Level ${value} erreicht!`, sub: 'Du wächst spürbar — weiter so.' }
|
||||||
if (kind === 'goal') return { cls: 'goal', icon: '🎯', title: 'Tagesziel erreicht!', sub: 'Stark — heute hast du dein Pensum geschafft.' }
|
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: '' }
|
return { cls: '', icon: '🎉', title: 'Geschafft!', sub: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ export default function Feed() {
|
|||||||
queued.push({ kind: 'goal', value: res?.daily_goal_ep ?? goalEp })
|
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 }
|
progress.current = { level: newLevel, streak: newStreak }
|
||||||
if (queued.length) { setMilestones(q => [...q, ...queued]); playMilestone() }
|
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-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-tier { font-size: 11px; font-weight: 600; color: var(--text-soft); }
|
||||||
.cat-points { font-size: 12px; font-weight: 800; color: var(--accent); }
|
.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 { 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); }
|
.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 { useEffect, useState, useMemo } from 'react'
|
||||||
import './Profil.css'
|
import './Profil.css'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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 ProgressRing from '../components/ProgressRing'
|
||||||
import { levelInfo } from '../utils/leveling'
|
import { levelInfo } from '../utils/leveling'
|
||||||
import { categoryTier, capabilitySentence } from '../utils/praise'
|
import { categoryTier, capabilitySentence } from '../utils/praise'
|
||||||
@@ -146,6 +146,7 @@ export default function Profil() {
|
|||||||
const [profil, setProfil] = useState(null)
|
const [profil, setProfil] = useState(null)
|
||||||
const [stats, setStats] = useState(null)
|
const [stats, setStats] = useState(null)
|
||||||
const [langs, setLangs] = useState([])
|
const [langs, setLangs] = useState([])
|
||||||
|
const [achievements, setAchievements] = useState([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setRadarReady(true), 120)
|
const t = setTimeout(() => setRadarReady(true), 120)
|
||||||
@@ -160,6 +161,8 @@ export default function Profil() {
|
|||||||
} catch { /* Fallback unten */ }
|
} catch { /* Fallback unten */ }
|
||||||
// Stats getrennt – degradiert lautlos, falls /auth/stats noch nicht deployed ist
|
// Stats getrennt – degradiert lautlos, falls /auth/stats noch nicht deployed ist
|
||||||
try { setStats(await getStats(token)) } catch { /* kein Tracking verfügbar */ }
|
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()
|
load()
|
||||||
}, [token])
|
}, [token])
|
||||||
@@ -315,6 +318,21 @@ export default function Profil() {
|
|||||||
</div>
|
</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 ── */}
|
{/* ── Streak-Kalender ── */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
Reference in New Issue
Block a user