From 8d37d8c2ab2db3dc54c179e8ca2f16dde487e54e Mon Sep 17 00:00:00 2001 From: Ronny Date: Sun, 12 Apr 2026 18:09:25 +0200 Subject: [PATCH] feat: premium achievement badges with Material Symbols icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - New /api/achievements endpoint calculating 6 badges: Scharfschütze, Serien-Tipper, Tabellenführer, Frühtipper, Globetrotter, Diamant - Each with progress tracking (current/target) Frontend: - AchievementBadge component with Stitch-inspired design - Material Symbols Outlined font (filled icons) - Unlocked: colored icon with glow + drop-shadow, rank label - Locked: grayscale, lock overlay, progress bar - ProfilePage: real badges replacing emoji placeholders - Progress bar showing X/6 collected - Mobile: 2-col grid, Desktop: 6-col grid Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/index.ts | 2 + backend/src/routes/achievements.ts | 169 ++++++++++++++++++ frontend/src/api/client.ts | 22 +++ .../components/AchievementBadge.module.css | 167 +++++++++++++++++ frontend/src/components/AchievementBadge.tsx | 70 ++++++++ frontend/src/index.css | 3 + frontend/src/pages/ProfilePage.module.css | 47 ++++- frontend/src/pages/ProfilePage.tsx | 42 +++-- 8 files changed, 500 insertions(+), 22 deletions(-) create mode 100644 backend/src/routes/achievements.ts create mode 100644 frontend/src/components/AchievementBadge.module.css create mode 100644 frontend/src/components/AchievementBadge.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index 71eb8fd..12e0959 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,6 +15,7 @@ import adminRouter from './routes/admin'; import profileRouter from './routes/profile'; import devRouter from './routes/dev'; import dashboardRouter from './routes/dashboard'; +import achievementsRouter from './routes/achievements'; const app = express(); const PORT = parseInt(process.env.PORT ?? '3001'); @@ -128,6 +129,7 @@ app.use('/api/leaderboard', leaderboardRouter); app.use('/api/admin', adminRouter); app.use('/api/profile', profileRouter); app.use('/api/dashboard', dashboardRouter); +app.use('/api/achievements', achievementsRouter); if (process.env.NODE_ENV === 'development') { app.use('/api/dev', devRouter); } diff --git a/backend/src/routes/achievements.ts b/backend/src/routes/achievements.ts new file mode 100644 index 0000000..4fce49f --- /dev/null +++ b/backend/src/routes/achievements.ts @@ -0,0 +1,169 @@ +import { Router, Request, Response } from 'express'; +import { query } from '../db/client'; +import { logger } from '../services/logger'; + +const router = Router(); + +export interface Achievement { + id: string; + name: string; + description: string; + icon: string; // Material Symbol name + color: string; // CSS color for the glow + rankLabel: string; // e.g. "Gold-Rang", "On Fire" + unlocked: boolean; + progress: number; // 0-100 percentage + current: number; // current value + target: number; // target value +} + +router.get('/', async (req: Request, res: Response): Promise => { + const userId = req.staffbaseUser?.sub; + if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; } + + try { + // Get user stats + const statsResult = await query( + `SELECT total_points, tips_count, exact_count, tendency_count, rank + FROM leaderboard WHERE user_id = $1`, + [userId] + ); + const stats = statsResult[0]; + const exactCount = parseInt(String(stats?.exact_count ?? '0')); + const tipsCount = parseInt(String(stats?.tips_count ?? '0')); + const rank = stats?.rank ? parseInt(String(stats.rank)) : null; + + // Calculate streak (same logic as dashboard) + const pastMatches = await query<{ has_tip: boolean }>( + `SELECT CASE WHEN t.id IS NOT NULL THEN true ELSE false END AS has_tip + FROM matches m + LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1 + WHERE m.status IN ('FINISHED', 'IN_PLAY') + ORDER BY m.utc_date DESC`, + [userId] + ); + let maxStreak = 0; + let currentStreak = 0; + for (const m of pastMatches) { + if (m.has_tip === true || (m.has_tip as unknown) === 't') { + currentStreak++; + if (currentStreak > maxStreak) maxStreak = currentStreak; + } else { + currentStreak = 0; + } + } + + // Check early tipper: tips submitted >24h before kickoff + const earlyTips = await query<{ count: string }>( + `SELECT COUNT(*) AS count FROM tips t + JOIN matches m ON m.id = t.match_id + WHERE t.user_id = $1 + AND m.status = 'FINISHED' + AND t.created_at < m.utc_date - interval '24 hours'`, + [userId] + ); + const earlyCount = parseInt(earlyTips[0]?.count || '0'); + const finishedWithTips = await query<{ count: string }>( + `SELECT COUNT(*) AS count FROM tips t + JOIN matches m ON m.id = t.match_id + WHERE t.user_id = $1 AND m.status = 'FINISHED'`, + [userId] + ); + const finishedTipCount = parseInt(finishedWithTips[0]?.count || '0'); + + // Check globetrotter: tips in all groups + const groupsTipped = await query<{ count: string }>( + `SELECT COUNT(DISTINCT m.group_name) AS count + FROM tips t JOIN matches m ON m.id = t.match_id + WHERE t.user_id = $1 AND m.stage = 'GROUP_STAGE' AND m.group_name IS NOT NULL`, + [userId] + ); + const groupCount = parseInt(groupsTipped[0]?.count || '0'); + const totalGroups = 12; // WM 2026 has 12 groups (A-L) + + // Build achievements + const achievements: Achievement[] = [ + { + id: 'sharpshooter', + name: 'Scharfschütze', + description: '5 exakte Tipps', + icon: 'target', + color: '#f5ce53', + rankLabel: 'Gold-Rang', + unlocked: exactCount >= 5, + progress: Math.min(100, (exactCount / 5) * 100), + current: exactCount, + target: 5, + }, + { + id: 'streak_master', + name: 'Serien-Tipper', + description: '10er Tipp-Serie', + icon: 'local_fire_department', + color: '#ff716c', + rankLabel: 'On Fire', + unlocked: maxStreak >= 10, + progress: Math.min(100, (maxStreak / 10) * 100), + current: maxStreak, + target: 10, + }, + { + id: 'league_leader', + name: 'Tabellenführer', + description: 'Platz 1 erreicht', + icon: 'crown', + color: '#e6c047', + rankLabel: 'Prestige', + unlocked: rank === 1, + progress: rank === 1 ? 100 : 0, + current: rank === 1 ? 1 : 0, + target: 1, + }, + { + id: 'early_bird', + name: 'Frühtipper', + description: 'Tipps 24h vor Anpfiff', + icon: 'alarm', + color: '#4BB7F8', + rankLabel: 'Speedster', + unlocked: finishedTipCount > 0 && earlyCount === finishedTipCount, + progress: finishedTipCount > 0 ? Math.min(100, (earlyCount / finishedTipCount) * 100) : 0, + current: earlyCount, + target: finishedTipCount || 1, + }, + { + id: 'globetrotter', + name: 'Globetrotter', + description: 'Alle Gruppenspiele', + icon: 'public', + color: '#4BB7F8', + rankLabel: 'Reisender', + unlocked: groupCount >= totalGroups, + progress: Math.min(100, (groupCount / totalGroups) * 100), + current: groupCount, + target: totalGroups, + }, + { + id: 'diamond', + name: 'Diamant', + description: '20 exakte Tipps', + icon: 'diamond', + color: '#a066ff', + rankLabel: 'Legendär', + unlocked: exactCount >= 20, + progress: Math.min(100, (exactCount / 20) * 100), + current: exactCount, + target: 20, + }, + ]; + + const unlockedCount = achievements.filter(a => a.unlocked).length; + + res.json({ achievements, unlockedCount, total: achievements.length }); + } catch (error) { + logger.error('Achievements failed', { error }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b0a1ddb..0102260 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -56,6 +56,9 @@ export const api = { // Dashboard getDashboard: () => request('/dashboard'), + // Achievements + getAchievements: () => request('/achievements'), + // Admin syncMatches: () => request<{ success: boolean; total: number; created: number; updated: number }>( @@ -151,3 +154,22 @@ export interface UserStats { wrongCount: number; accuracy: number; } + +export interface Achievement { + id: string; + name: string; + description: string; + icon: string; + color: string; + rankLabel: string; + unlocked: boolean; + progress: number; + current: number; + target: number; +} + +export interface AchievementsData { + achievements: Achievement[]; + unlockedCount: number; + total: number; +} diff --git a/frontend/src/components/AchievementBadge.module.css b/frontend/src/components/AchievementBadge.module.css new file mode 100644 index 0000000..d58c204 --- /dev/null +++ b/frontend/src/components/AchievementBadge.module.css @@ -0,0 +1,167 @@ +/* ═══ Achievement Badge — Premium Gaming Style ═══ */ + +.badge { + position: relative; + background: var(--surface-mid); + border-radius: var(--radius-md); + padding: 20px 12px 16px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); + transition: transform 0.2s; +} + +.badge:active { + transform: scale(0.97); +} + +/* ── Unlocked ── */ +.unlocked { + border-color: rgba(255, 255, 255, 0.08); +} + +.glow { + position: absolute; + inset: -4px; + border-radius: inherit; + filter: blur(20px); + opacity: 0.5; + pointer-events: none; + z-index: 0; +} + +/* ── Locked ── */ +.locked { + opacity: 0.45; + filter: grayscale(0.7); +} + +.lockOverlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.lockCircle { + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.7); +} + +/* ── Icon ── */ +.iconWrap { + position: relative; + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 10px; + z-index: 1; +} + +.iconGlow { + position: absolute; + inset: -8px; + border-radius: 50%; + filter: blur(16px); + pointer-events: none; +} + +.icon { + font-size: 42px; + position: relative; + z-index: 1; +} + +/* ── Text ── */ +.name { + font-size: 0.7rem; + font-weight: 700; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 2px; + position: relative; + z-index: 1; +} + +.desc { + font-size: 0.6rem; + color: var(--text-muted); + margin-bottom: 8px; + position: relative; + z-index: 1; +} + +/* ── Rank Badge (unlocked) ── */ +.rankBadge { + font-size: 0.55rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 3px 10px; + border-radius: 20px; + border: 1px solid; + position: relative; + z-index: 1; +} + +/* ── Progress (locked) ── */ +.progress { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + position: relative; + z-index: 1; +} + +.progressBar { + flex: 1; + height: 4px; + background: var(--surface-high); + border-radius: 2px; + overflow: hidden; +} + +.progressFill { + height: 100%; + border-radius: 2px; + transition: width 0.5s ease; +} + +.progressText { + font-size: 0.55rem; + color: var(--text-muted); + font-weight: 600; + white-space: nowrap; +} + +/* ── Light Mode ── */ +:global([data-theme="light"]) .badge { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +:global([data-theme="light"]) .locked { + opacity: 0.35; +} + +:global([data-theme="light"]) .lockCircle { + background: rgba(255, 255, 255, 0.8); + color: rgba(0, 0, 0, 0.4); +} diff --git a/frontend/src/components/AchievementBadge.tsx b/frontend/src/components/AchievementBadge.tsx new file mode 100644 index 0000000..247a7ad --- /dev/null +++ b/frontend/src/components/AchievementBadge.tsx @@ -0,0 +1,70 @@ +import { Achievement } from '../api/client'; +import styles from './AchievementBadge.module.css'; + +interface Props { + achievement: Achievement; +} + +export default function AchievementBadge({ achievement }: Props) { + const { name, description, icon, color, rankLabel, unlocked, current, target } = achievement; + + return ( +
+ {/* Glow background for unlocked */} + {unlocked && ( +
+ )} + + {/* Lock overlay for locked */} + {!unlocked && ( +
+
+ lock +
+
+ )} + + {/* Icon */} +
+ {unlocked && ( +
+ )} + + {icon} + +
+ + {/* Name + Description */} +

{name}

+

{description}

+ + {/* Progress or Rank label */} + {unlocked ? ( +
+ {rankLabel} +
+ ) : ( +
+
+
+
+ {current}/{target} +
+ )} +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 953fb9a..825a1bf 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,6 +2,9 @@ WM 2026 Tippspiel — Stadium Elite Design System ============================================================ */ +/* Material Symbols — filled icons for badges */ +@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@400,1&display=swap'); + /* Stadium LED Segment Display Font */ @font-face { font-family: 'DSEG7'; diff --git a/frontend/src/pages/ProfilePage.module.css b/frontend/src/pages/ProfilePage.module.css index 9431461..a010046 100644 --- a/frontend/src/pages/ProfilePage.module.css +++ b/frontend/src/pages/ProfilePage.module.css @@ -246,10 +246,47 @@ margin-top: 8px; } +.achievementsHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.achievementsCount { + font-size: 1.1rem; + font-weight: 800; + color: var(--text-primary); +} + +.achievementsProgress { + margin-bottom: 14px; +} + +.achievementsBar { + height: 6px; + background: var(--surface-high); + border-radius: 3px; + overflow: hidden; +} + +.achievementsFill { + height: 100%; + background: linear-gradient(90deg, var(--primary), var(--gold)); + border-radius: 3px; + transition: width 0.5s ease; +} + .badgeGrid { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + +@media (min-width: 500px) { + .badgeGrid { + grid-template-columns: repeat(3, 1fr); + } } @media (min-width: 768px) { @@ -258,9 +295,9 @@ } } -.badge { - display: flex; - flex-direction: column; +/* Remove old placeholder styles — now in AchievementBadge component */ +.badge_placeholder_removed { + display: none; align-items: center; gap: 4px; padding: 14px 8px; diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 62bdad1..5348eb2 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; -import { api, UserStats, MyTip } from '../api/client'; +import { api, UserStats, MyTip, Achievement } from '../api/client'; import StatsRing from '../components/StatsRing'; +import AchievementBadge from '../components/AchievementBadge'; import styles from './ProfilePage.module.css'; function initials(name: string) { @@ -30,6 +31,8 @@ function homeWinPct(tips: MyTip[]): number { export default function ProfilePage() { const [stats, setStats] = useState(null); const [tips, setTips] = useState([]); + const [achievements, setAchievements] = useState([]); + const [unlockedCount, setUnlockedCount] = useState(0); const [loading, setLoading] = useState(true); const [teamEdit, setTeamEdit] = useState(false); const [teamValue, setTeamValue] = useState(''); @@ -40,10 +43,13 @@ export default function ProfilePage() { Promise.all([ api.getMyStats(), api.getMyTips(), - ]).then(([s, t]) => { + api.getAchievements(), + ]).then(([s, t, a]) => { setStats(s); setTeamValue(s.team ?? ''); setTips(t.tips); + setAchievements(a.achievements); + setUnlockedCount(a.unlockedCount); }).finally(() => setLoading(false)); }, []); @@ -203,23 +209,25 @@ export default function ProfilePage() {
)} - {/* Achievements (Phase 2 placeholder) */} + {/* Achievements */}
-

Erfolge

-
- {[ - { icon: '🎯', label: 'Scharfschütze', desc: '5 exakte Treffer' }, - { icon: '🔥', label: 'Serien-Tipper', desc: '10er Streak' }, - { icon: '🏆', label: 'Tabellenführer', desc: 'Platz 1 erreichen' }, - { icon: '⚡', label: 'Frühtipper', desc: 'Alle Tipps 24h vorher' }, - { icon: '🌍', label: 'Globetrotter', desc: 'Alle Gruppen getippt' }, - { icon: '💎', label: 'Diamant', desc: '20 exakte Treffer' }, - ].map((badge, i) => ( -
- {badge.icon} - {badge.label} - {badge.desc} +
+

Erfolge

+ {unlockedCount}/{achievements.length} +
+ {achievements.length > 0 && ( +
+
+
+
+ )} +
+ {achievements.map(a => ( + ))}