feat: premium achievement badges with Material Symbols icons
Build & Deploy Tippspiel / build (push) Successful in 50s
Build & Deploy Tippspiel / build (push) Successful in 50s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import adminRouter from './routes/admin';
|
|||||||
import profileRouter from './routes/profile';
|
import profileRouter from './routes/profile';
|
||||||
import devRouter from './routes/dev';
|
import devRouter from './routes/dev';
|
||||||
import dashboardRouter from './routes/dashboard';
|
import dashboardRouter from './routes/dashboard';
|
||||||
|
import achievementsRouter from './routes/achievements';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = parseInt(process.env.PORT ?? '3001');
|
const PORT = parseInt(process.env.PORT ?? '3001');
|
||||||
@@ -128,6 +129,7 @@ app.use('/api/leaderboard', leaderboardRouter);
|
|||||||
app.use('/api/admin', adminRouter);
|
app.use('/api/admin', adminRouter);
|
||||||
app.use('/api/profile', profileRouter);
|
app.use('/api/profile', profileRouter);
|
||||||
app.use('/api/dashboard', dashboardRouter);
|
app.use('/api/dashboard', dashboardRouter);
|
||||||
|
app.use('/api/achievements', achievementsRouter);
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
app.use('/api/dev', devRouter);
|
app.use('/api/dev', devRouter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> => {
|
||||||
|
const userId = req.staffbaseUser?.sub;
|
||||||
|
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get user stats
|
||||||
|
const statsResult = await query<any>(
|
||||||
|
`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;
|
||||||
@@ -56,6 +56,9 @@ export const api = {
|
|||||||
// Dashboard
|
// Dashboard
|
||||||
getDashboard: () => request<DashboardData>('/dashboard'),
|
getDashboard: () => request<DashboardData>('/dashboard'),
|
||||||
|
|
||||||
|
// Achievements
|
||||||
|
getAchievements: () => request<AchievementsData>('/achievements'),
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
syncMatches: () =>
|
syncMatches: () =>
|
||||||
request<{ success: boolean; total: number; created: number; updated: number }>(
|
request<{ success: boolean; total: number; created: number; updated: number }>(
|
||||||
@@ -151,3 +154,22 @@ export interface UserStats {
|
|||||||
wrongCount: number;
|
wrongCount: number;
|
||||||
accuracy: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={`${styles.badge} ${unlocked ? styles.unlocked : styles.locked}`}>
|
||||||
|
{/* Glow background for unlocked */}
|
||||||
|
{unlocked && (
|
||||||
|
<div className={styles.glow} style={{ background: `${color}20` }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lock overlay for locked */}
|
||||||
|
{!unlocked && (
|
||||||
|
<div className={styles.lockOverlay}>
|
||||||
|
<div className={styles.lockCircle}>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>lock</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={styles.iconWrap}>
|
||||||
|
{unlocked && (
|
||||||
|
<div className={styles.iconGlow} style={{ background: `${color}30` }} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined ${styles.icon}`}
|
||||||
|
style={{
|
||||||
|
color: unlocked ? color : 'var(--text-muted)',
|
||||||
|
fontVariationSettings: "'FILL' 1",
|
||||||
|
filter: unlocked ? `drop-shadow(0 0 10px ${color}cc)` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + Description */}
|
||||||
|
<h3 className={styles.name}>{name}</h3>
|
||||||
|
<p className={styles.desc}>{description}</p>
|
||||||
|
|
||||||
|
{/* Progress or Rank label */}
|
||||||
|
{unlocked ? (
|
||||||
|
<div className={styles.rankBadge} style={{
|
||||||
|
background: `${color}15`,
|
||||||
|
borderColor: `${color}40`,
|
||||||
|
color: color,
|
||||||
|
}}>
|
||||||
|
{rankLabel}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.progress}>
|
||||||
|
<div className={styles.progressBar}>
|
||||||
|
<div
|
||||||
|
className={styles.progressFill}
|
||||||
|
style={{ width: `${achievement.progress}%`, background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={styles.progressText}>{current}/{target}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
WM 2026 Tippspiel — Stadium Elite Design System
|
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 */
|
/* Stadium LED Segment Display Font */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'DSEG7';
|
font-family: 'DSEG7';
|
||||||
|
|||||||
@@ -246,10 +246,47 @@
|
|||||||
margin-top: 8px;
|
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 {
|
.badgeGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.badgeGrid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -258,9 +295,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
/* Remove old placeholder styles — now in AchievementBadge component */
|
||||||
display: flex;
|
.badge_placeholder_removed {
|
||||||
flex-direction: column;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 14px 8px;
|
padding: 14px 8px;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 StatsRing from '../components/StatsRing';
|
||||||
|
import AchievementBadge from '../components/AchievementBadge';
|
||||||
import styles from './ProfilePage.module.css';
|
import styles from './ProfilePage.module.css';
|
||||||
|
|
||||||
function initials(name: string) {
|
function initials(name: string) {
|
||||||
@@ -30,6 +31,8 @@ function homeWinPct(tips: MyTip[]): number {
|
|||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const [stats, setStats] = useState<UserStats | null>(null);
|
const [stats, setStats] = useState<UserStats | null>(null);
|
||||||
const [tips, setTips] = useState<MyTip[]>([]);
|
const [tips, setTips] = useState<MyTip[]>([]);
|
||||||
|
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
||||||
|
const [unlockedCount, setUnlockedCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [teamEdit, setTeamEdit] = useState(false);
|
const [teamEdit, setTeamEdit] = useState(false);
|
||||||
const [teamValue, setTeamValue] = useState('');
|
const [teamValue, setTeamValue] = useState('');
|
||||||
@@ -40,10 +43,13 @@ export default function ProfilePage() {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
api.getMyStats(),
|
api.getMyStats(),
|
||||||
api.getMyTips(),
|
api.getMyTips(),
|
||||||
]).then(([s, t]) => {
|
api.getAchievements(),
|
||||||
|
]).then(([s, t, a]) => {
|
||||||
setStats(s);
|
setStats(s);
|
||||||
setTeamValue(s.team ?? '');
|
setTeamValue(s.team ?? '');
|
||||||
setTips(t.tips);
|
setTips(t.tips);
|
||||||
|
setAchievements(a.achievements);
|
||||||
|
setUnlockedCount(a.unlockedCount);
|
||||||
}).finally(() => setLoading(false));
|
}).finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -203,23 +209,25 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Achievements (Phase 2 placeholder) */}
|
{/* Achievements */}
|
||||||
<div className={styles.achievementsSection}>
|
<div className={styles.achievementsSection}>
|
||||||
<h3 className={styles.sectionTitle}>Erfolge</h3>
|
<div className={styles.achievementsHeader}>
|
||||||
<div className={styles.badgeGrid}>
|
<h3 className={styles.sectionTitle}>Erfolge</h3>
|
||||||
{[
|
<span className={styles.achievementsCount}>{unlockedCount}/{achievements.length}</span>
|
||||||
{ icon: '🎯', label: 'Scharfschütze', desc: '5 exakte Treffer' },
|
</div>
|
||||||
{ icon: '🔥', label: 'Serien-Tipper', desc: '10er Streak' },
|
{achievements.length > 0 && (
|
||||||
{ icon: '🏆', label: 'Tabellenführer', desc: 'Platz 1 erreichen' },
|
<div className={styles.achievementsProgress}>
|
||||||
{ icon: '⚡', label: 'Frühtipper', desc: 'Alle Tipps 24h vorher' },
|
<div className={styles.achievementsBar}>
|
||||||
{ icon: '🌍', label: 'Globetrotter', desc: 'Alle Gruppen getippt' },
|
<div
|
||||||
{ icon: '💎', label: 'Diamant', desc: '20 exakte Treffer' },
|
className={styles.achievementsFill}
|
||||||
].map((badge, i) => (
|
style={{ width: `${(unlockedCount / achievements.length) * 100}%` }}
|
||||||
<div key={i} className={styles.badge}>
|
/>
|
||||||
<span className={styles.badgeIcon}>{badge.icon}</span>
|
|
||||||
<span className={styles.badgeName}>{badge.label}</span>
|
|
||||||
<span className={styles.badgeDesc}>{badge.desc}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.badgeGrid}>
|
||||||
|
{achievements.map(a => (
|
||||||
|
<AchievementBadge key={a.id} achievement={a} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user