From 98543979db7355b4b2f45bfa4b5bc7cbb052a1e8 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 17 Jun 2026 21:54:02 +0200 Subject: [PATCH] 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 --- src/api/directus.js | 8 ++++++++ src/components/MilestoneOverlay.jsx | 10 ++++++---- src/pages/Feed.jsx | 5 +++++ src/pages/Profil.css | 22 ++++++++++++++++++++++ src/pages/Profil.jsx | 20 +++++++++++++++++++- 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/api/directus.js b/src/api/directus.js index b1488c0..3d0c291 100644 --- a/src/api/directus.js +++ b/src/api/directus.js @@ -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`, { diff --git a/src/components/MilestoneOverlay.jsx b/src/components/MilestoneOverlay.jsx index 9912825..0388b4d 100644 --- a/src/components/MilestoneOverlay.jsx +++ b/src/components/MilestoneOverlay.jsx @@ -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: '' } } diff --git a/src/pages/Feed.jsx b/src/pages/Feed.jsx index bbeb4e4..d754233 100644 --- a/src/pages/Feed.jsx +++ b/src/pages/Feed.jsx @@ -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() } }) diff --git a/src/pages/Profil.css b/src/pages/Profil.css index aeda97c..29c6e8c 100644 --- a/src/pages/Profil.css +++ b/src/pages/Profil.css @@ -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); } diff --git a/src/pages/Profil.jsx b/src/pages/Profil.jsx index 155502e..633761e 100644 --- a/src/pages/Profil.jsx +++ b/src/pages/Profil.jsx @@ -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() { )} + {/* ── Erfolge ── */} + {achievements.length > 0 && ( +
+

ERFOLGE · {achievements.filter(a => a.unlocked).length}/{achievements.length}

+
+ {achievements.map(a => ( +
+ {a.unlocked ? a.icon : '🔒'} + {a.label} +
+ ))} +
+
+ )} + {/* ── Streak-Kalender ── */} {stats && (