diff --git a/backend/src/db/migration_002.sql b/backend/src/db/migration_002.sql new file mode 100644 index 0000000..7ad9a72 --- /dev/null +++ b/backend/src/db/migration_002.sql @@ -0,0 +1,21 @@ +-- Migration 002: Add team field to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS team TEXT; + +-- Refresh leaderboard view to include team +DROP MATERIALIZED VIEW IF EXISTS leaderboard; + +CREATE MATERIALIZED VIEW leaderboard AS +SELECT + u.id AS user_id, + u.full_name, + u.team, + RANK() OVER (ORDER BY COALESCE(SUM(t.points), 0) DESC) AS rank, + COALESCE(SUM(t.points), 0) AS total_points, + COUNT(t.id) FILTER (WHERE t.points IS NOT NULL) AS tips_count, + COUNT(t.id) FILTER (WHERE t.points = 3) AS exact_count, + COUNT(t.id) FILTER (WHERE t.points = 1) AS tendency_count +FROM users u +LEFT JOIN tips t ON t.user_id = u.id +GROUP BY u.id, u.full_name, u.team; + +CREATE UNIQUE INDEX IF NOT EXISTS leaderboard_user_id_idx ON leaderboard (user_id); diff --git a/backend/src/index.ts b/backend/src/index.ts index 56e15b0..ff099ad 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -13,6 +13,7 @@ import matchesRouter from './routes/matches'; import tipsRouter from './routes/tips'; import leaderboardRouter from './routes/leaderboard'; import adminRouter from './routes/admin'; +import profileRouter from './routes/profile'; const app = express(); const PORT = parseInt(process.env.PORT ?? '3001'); @@ -116,6 +117,7 @@ app.use('/api/matches', matchesRouter); app.use('/api/tips', tipsRouter); app.use('/api/leaderboard', leaderboardRouter); app.use('/api/admin', adminRouter); +app.use('/api/profile', profileRouter); // ============================================================ // Frontend (React Build) – statisches Serving diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index dbae623..b3f842b 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -47,6 +47,21 @@ router.post('/evaluate', async (_req: Request, res: Response): Promise => } }); +/** + * POST /api/admin/refresh-leaderboard + * Materialized View manuell aktualisieren + */ +router.post('/refresh-leaderboard', async (_req: Request, res: Response): Promise => { + try { + await query('REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard'); + logger.info('Leaderboard refreshed manually'); + res.json({ success: true, message: 'Leaderboard refreshed' }); + } catch (error) { + logger.error('Failed to refresh leaderboard', { error }); + res.status(500).json({ success: false, error: (error as Error).message }); + } +}); + /** * GET /api/admin/stats * Allgemeine Statistiken (für Admin-Dashboard) diff --git a/backend/src/routes/leaderboard.ts b/backend/src/routes/leaderboard.ts index 0546131..1af2de3 100644 --- a/backend/src/routes/leaderboard.ts +++ b/backend/src/routes/leaderboard.ts @@ -18,13 +18,14 @@ router.get('/', async (req: Request, res: Response): Promise => { query<{ user_id: string; full_name: string; + team: string | null; total_points: number; tips_count: number; exact_count: number; tendency_count: number; rank: number; }>( - `SELECT user_id, full_name, total_points, tips_count, + `SELECT user_id, full_name, team, total_points, tips_count, exact_count, tendency_count, rank FROM leaderboard ORDER BY rank ASC @@ -51,6 +52,7 @@ router.get('/', async (req: Request, res: Response): Promise => { const response: LeaderboardResponse = { entries, currentUserRank, + currentUserId: userId, totalParticipants: parseInt(metaRows[0]?.count ?? '0'), lastUpdated: new Date().toISOString(), }; @@ -73,13 +75,14 @@ router.get('/me', async (req: Request, res: Response): Promise => { const [leaderboardRows, tipsRows] = await Promise.all([ query<{ full_name: string; + team: string | null; total_points: number; tips_count: number; exact_count: number; tendency_count: number; rank: number | null; }>( - `SELECT full_name, total_points, tips_count, exact_count, tendency_count, rank + `SELECT full_name, team, total_points, tips_count, exact_count, tendency_count, rank FROM leaderboard WHERE user_id = $1`, [userId] ), @@ -110,6 +113,7 @@ router.get('/me', async (req: Request, res: Response): Promise => { const response: UserStatsResponse = { userId, fullName: lb?.full_name ?? req.staffbaseUser!.name ?? 'Unbekannt', + team: lb?.team ?? null, totalPoints: lb?.total_points ?? 0, rank: lb?.rank ?? null, tipsCount: lb?.tips_count ?? 0, diff --git a/backend/src/routes/matches.ts b/backend/src/routes/matches.ts index 6b4c246..c67d20a 100644 --- a/backend/src/routes/matches.ts +++ b/backend/src/routes/matches.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express'; import { query } from '../db/client'; import { MatchResponse } from '../types'; import { logger } from '../services/logger'; +import { upsertUser } from '../services/userService'; const router = Router(); @@ -17,6 +18,9 @@ router.get('/', async (req: Request, res: Response): Promise => { const userId = req.staffbaseUser!.sub; const { stage, group } = req.query; + // User beim ersten App-Aufruf automatisch anlegen / aktualisieren + await upsertUser(req.staffbaseUser!); + try { let whereClause = ''; const params: unknown[] = [userId]; diff --git a/backend/src/routes/profile.ts b/backend/src/routes/profile.ts new file mode 100644 index 0000000..c2e4d4a --- /dev/null +++ b/backend/src/routes/profile.ts @@ -0,0 +1,37 @@ +import { Router, Request, Response } from 'express'; +import { query } from '../db/client'; +import { logger } from '../services/logger'; + +const router = Router(); + +/** + * PATCH /api/profile/team + * Nutzer setzt sein Team manuell + */ +router.patch('/team', async (req: Request, res: Response): Promise => { + const userId = req.staffbaseUser!.sub; + const { team } = req.body as { team: string }; + + if (typeof team !== 'string' || team.trim().length === 0) { + res.status(400).json({ error: 'team darf nicht leer sein' }); + return; + } + + if (team.trim().length > 80) { + res.status(400).json({ error: 'team darf maximal 80 Zeichen haben' }); + return; + } + + try { + await query( + `UPDATE users SET team = $1, updated_at = NOW() WHERE id = $2`, + [team.trim(), userId] + ); + res.json({ success: true, team: team.trim() }); + } catch (error) { + logger.error('Failed to update team', { error, userId }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/backend/src/routes/tips.ts b/backend/src/routes/tips.ts index 4506c75..af2f68a 100644 --- a/backend/src/routes/tips.ts +++ b/backend/src/routes/tips.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express'; import { query, withTransaction } from '../db/client'; import { TipSubmitRequest, TipSubmitResponse } from '../types'; import { logger } from '../services/logger'; +import { upsertUser } from '../services/userService'; const router = Router(); @@ -72,22 +73,8 @@ router.post('/', async (req: Request, res: Response): Promise => { return; } - // Sicherstellen, dass der User in der Datenbank existiert - await client.query( - `INSERT INTO users (id, full_name, locale, branch_id, role) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (id) DO UPDATE SET - full_name = EXCLUDED.full_name, - locale = EXCLUDED.locale, - updated_at = NOW()`, - [ - userId, - req.staffbaseUser!.name ?? 'Unbekannt', - req.staffbaseUser!.locale ?? 'de_DE', - req.staffbaseUser!.branch_id ?? null, - req.staffbaseUser!.role ?? 'viewer', - ] - ); + // Sicherstellen, dass der User in der Datenbank existiert (Fallback) + await upsertUser(req.staffbaseUser!); // Tipp anlegen oder aktualisieren (UPSERT) const result = await client.query<{ diff --git a/backend/src/services/userService.ts b/backend/src/services/userService.ts new file mode 100644 index 0000000..c4ded40 --- /dev/null +++ b/backend/src/services/userService.ts @@ -0,0 +1,45 @@ +import { query } from '../db/client'; +import { StaffbaseTokenData } from '../types'; +import { logger } from './logger'; + +/** + * Legt einen User an oder aktualisiert seinen Namen/Locale beim Login. + * Das Team wird über die department_team_mapping Tabelle ermittelt, + * falls noch kein manuelles Team gesetzt wurde. + */ +export async function upsertUser(user: StaffbaseTokenData): Promise { + try { + await query( + `INSERT INTO users (id, full_name, locale, branch_id, role) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE SET + full_name = EXCLUDED.full_name, + locale = EXCLUDED.locale, + branch_id = EXCLUDED.branch_id, + updated_at = NOW()`, + [ + user.sub, + user.name ?? 'Unbekannt', + user.locale ?? 'de_DE', + user.branchId ?? null, + user.role ?? 'viewer', + ] + ); + + // Team über Matching-Tabelle setzen, falls noch kein manuelles Team gesetzt + if (user.branchId) { + await query( + `UPDATE users u + SET team = m.team_name + FROM department_team_mapping m + WHERE u.id = $1 + AND u.team IS NULL + AND m.department_key = $2`, + [user.sub, user.branchId] + ); + } + } catch (err) { + // Fehler beim Upsert soll die eigentliche Anfrage nicht blockieren + logger.error('Failed to upsert user', { error: err, userId: user.sub }); + } +} diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 74bc7df..1ed21bf 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -71,6 +71,7 @@ export interface DbTip { export interface DbLeaderboardEntry { user_id: string; full_name: string; + team: string | null; total_points: number; tips_count: number; exact_count: number; @@ -141,6 +142,7 @@ export interface TipSubmitResponse { export interface LeaderboardResponse { entries: DbLeaderboardEntry[]; currentUserRank: number | null; + currentUserId: string | null; totalParticipants: number; lastUpdated: string; } @@ -148,6 +150,7 @@ export interface LeaderboardResponse { export interface UserStatsResponse { userId: string; fullName: string; + team: string | null; totalPoints: number; rank: number | null; tipsCount: number; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 923dd70..96104a9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -39,6 +39,13 @@ export const api = { getMyStats: () => request('/leaderboard/me'), + // Profile + updateTeam: (team: string) => + request<{ success: boolean; team: string }>('/profile/team', { + method: 'PATCH', + body: JSON.stringify({ team }), + }), + // Admin syncMatches: () => request<{ success: boolean; total: number; created: number; updated: number }>( @@ -88,6 +95,7 @@ export interface MyTip { export interface LeaderboardEntry { user_id: string; full_name: string; + team: string | null; total_points: number; tips_count: number; exact_count: number; @@ -98,6 +106,7 @@ export interface LeaderboardEntry { export interface LeaderboardResponse { entries: LeaderboardEntry[]; currentUserRank: number | null; + currentUserId: string | null; totalParticipants: number; lastUpdated: string; } @@ -105,6 +114,7 @@ export interface LeaderboardResponse { export interface UserStats { userId: string; fullName: string; + team: string | null; totalPoints: number; rank: number | null; tipsCount: number; diff --git a/frontend/src/components/MatchCard.module.css b/frontend/src/components/MatchCard.module.css index 3ac373f..4996d55 100644 --- a/frontend/src/components/MatchCard.module.css +++ b/frontend/src/components/MatchCard.module.css @@ -1,61 +1,153 @@ -.card { padding: 16px 20px; transition: box-shadow 0.2s; } -.card:hover { box-shadow: 0 12px 30px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.09); } -.live { box-shadow: 0 0 0 1px rgba(248,113,113,0.3), 0 10px 25px rgba(0,0,0,0.25) !important; } +/* MatchCard — Stadium Elite Style */ +.card { + padding: 20px 24px; + transition: box-shadow 0.2s, transform 0.15s; + cursor: default; +} + +.card:hover { + transform: translateY(-1px); + box-shadow: + 0 16px 36px rgba(0,0,0,0.35), + inset 0 1px 0 rgba(255,255,255,0.10), + inset 1px 0 0 rgba(255,255,255,0.05); +} + +.live { + box-shadow: + 0 0 0 1px rgba(248,113,113,0.25), + 0 10px 30px rgba(248,113,113,0.08), + inset 0 1px 0 rgba(255,255,255,0.07) !important; +} + +/* Top row */ .topRow { display: flex; align-items: center; - gap: 10px; - margin-bottom: 14px; - flex-wrap: wrap; + gap: 8px; + margin-bottom: 18px; +} + +.status { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.statusLive { + color: var(--error); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } } -.status { font-size: 12px; color: var(--text-muted); } -.statusLive { color: var(--error); font-weight: 600; } -.kickoff { font-size: 13px; color: var(--text-secondary); margin-left: auto; } .badge, .badgeUrgent { font-size: 11px; - padding: 3px 8px; - border-radius: 10px; - font-weight: 600; + font-weight: 700; + padding: 3px 9px; + border-radius: 20px; +} + +.badge { + background: var(--surface-high); + color: var(--text-secondary); +} + +.badgeUrgent { + background: rgba(254,174,50,0.12); + color: var(--gold); + border: 1px solid rgba(254,174,50,0.2); } -.badge { background: var(--surface-high); color: var(--text-secondary); } -.badgeUrgent { background: rgba(254,174,50,0.15); color: var(--gold); } .group { font-size: 11px; + font-weight: 600; color: var(--primary); background: var(--primary-dim); - padding: 2px 8px; - border-radius: 10px; + padding: 3px 9px; + border-radius: 20px; + border: 1px solid rgba(75,183,248,0.15); + margin-left: auto; } +/* Match row — Teams + Score */ .matchRow { display: grid; - grid-template-columns: 1fr auto 1fr; + grid-template-columns: 1fr 100px 1fr; align-items: center; - gap: 16px; - margin-bottom: 16px; + gap: 12px; + margin-bottom: 18px; } -.teamHome { display: flex; align-items: center; gap: 10px; justify-content: flex-end; } -.teamAway { display: flex; align-items: center; gap: 10px; } -.crest { width: 28px; height: 28px; object-fit: contain; } +/* Teams */ +.teamHome { + display: flex; + align-items: center; + gap: 12px; + justify-content: flex-end; +} + +.teamAway { + display: flex; + align-items: center; + gap: 12px; +} + +/* Flag box — glossy square */ +.flagBox { + width: 56px; + height: 56px; + border-radius: 13px; + background: var(--surface-high); + box-shadow: + 0 4px 12px rgba(0,0,0,0.3), + inset 0 1px 0 rgba(255,255,255,0.12); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + position: relative; +} + +.flagBox::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 50%; + background: linear-gradient(180deg, rgba(255,255,255,0.08) 0%, transparent 100%); + pointer-events: none; + z-index: 1; +} + +.crest { + width: 38px; + height: 38px; + object-fit: contain; + position: relative; + z-index: 0; +} .teamName { font-family: 'Plus Jakarta Sans', sans-serif; - font-weight: 600; + font-weight: 700; font-size: 15px; color: var(--text-primary); + line-height: 1.2; } +/* Score / VS center */ .scoreBox { - min-width: 80px; - text-align: center; - background: var(--surface-high); - border-radius: var(--radius-sm); - padding: 8px 12px; + display: flex; + align-items: center; + justify-content: center; } .score { @@ -63,63 +155,95 @@ font-size: 22px; font-weight: 800; color: var(--text-primary); - letter-spacing: 2px; + letter-spacing: 4px; } -.vs { - font-size: 14px; - font-weight: 600; - color: var(--text-muted); +.kickoffCenter { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; } -/* Tipp */ +.kickoffCenterTime { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + font-weight: 800; + color: var(--primary); + white-space: nowrap; +} + +/* Tipp area */ .tipRow { - border-top: 1px solid rgba(255,255,255,0.06); - padding-top: 12px; + border-top: 1px solid rgba(255,255,255,0.05); + padding-top: 14px; display: flex; align-items: center; justify-content: center; + min-height: 44px; } -.tipBtn { width: 100%; max-width: 240px; } +.tipBtn { + width: 100%; + max-width: 260px; + padding: 11px 20px; + font-size: 14px; + letter-spacing: 0.02em; +} +/* Existing tip display */ .tipDisplay { display: flex; align-items: center; - gap: 12px; + gap: 10px; flex-wrap: wrap; justify-content: center; } -.tipLabel { font-size: 13px; color: var(--text-secondary); } +.tipLabel { + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} .tipScore { font-family: 'Plus Jakarta Sans', sans-serif; - font-weight: 700; - font-size: 16px; + font-weight: 800; + font-size: 17px; color: var(--primary); + background: var(--primary-dim); + padding: 3px 12px; + border-radius: 8px; + border: 1px solid rgba(75,183,248,0.15); } .points { - font-size: 13px; - font-weight: 600; - padding: 3px 10px; - border-radius: 10px; + font-size: 12px; + font-weight: 700; + padding: 4px 10px; + border-radius: 20px; } -.exact { background: rgba(52,211,153,0.15); color: var(--success); } -.tendency { background: rgba(75,183,248,0.15); color: var(--primary); } -.wrong { background: rgba(248,113,113,0.12); color: var(--error); } + +.exact { background: rgba(52,211,153,0.12); color: var(--success); border: 1px solid rgba(52,211,153,0.2); } +.tendency { background: rgba(75,183,248,0.12); color: var(--primary); border: 1px solid rgba(75,183,248,0.2); } +.wrong { background: rgba(248,113,113,0.10); color: var(--error); border: 1px solid rgba(248,113,113,0.15); } .editBtn { background: transparent; - border: 1px solid rgba(255,255,255,0.15); - color: var(--text-secondary); + border: 1px solid rgba(255,255,255,0.1); + color: var(--text-muted); padding: 4px 12px; - border-radius: 6px; + border-radius: 20px; font-size: 12px; cursor: pointer; transition: all 0.15s; } .editBtn:hover { border-color: var(--primary); color: var(--primary); } -.noTip { font-size: 13px; color: var(--text-muted); } +.noTip { + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} diff --git a/frontend/src/components/MatchCard.tsx b/frontend/src/components/MatchCard.tsx index d5a8a8a..1bd85d3 100644 --- a/frontend/src/components/MatchCard.tsx +++ b/frontend/src/components/MatchCard.tsx @@ -9,7 +9,7 @@ interface Props { const STATUS_LABELS: Record = { SCHEDULED: 'Geplant', TIMED: 'Terminiert', - IN_PLAY: '🔴 Live', + IN_PLAY: 'Live', PAUSED: 'Pause', FINISHED: 'Beendet', POSTPONED: 'Verschoben', @@ -18,20 +18,31 @@ const STATUS_LABELS: Record = { function formatKickoff(utcDate: string): string { return new Date(utcDate).toLocaleString('de-DE', { - hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' + hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin', }) + ' Uhr'; } function CountdownBadge({ minutes }: { minutes: number }) { if (minutes <= 0) return null; - if (minutes < 60) return in {minutes} Min.; + if (minutes < 60) return ⚡ in {minutes} Min.; const h = Math.floor(minutes / 60); const m = minutes % 60; - if (h < 24) return in {h}h {m > 0 ? `${m}m` : ''}; + if (h < 24) return in {h}h{m > 0 ? ` ${m}m` : ''}; const d = Math.floor(h / 24); return in {d} Tag{d > 1 ? 'en' : ''}; } +function FlagBox({ crest, name }: { crest: string | null; name: string }) { + return ( +
+ {crest + ? {name} + : 🏳️ + } +
+ ); +} + export default function MatchCard({ match, onTip }: Props) { const isFinished = match.status === 'FINISHED'; const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED'; @@ -39,51 +50,53 @@ export default function MatchCard({ match, onTip }: Props) { return (
- {/* Status + Kickoff */} + + {/* Top row: Status / Kickoff / Badges */}
- {STATUS_LABELS[match.status] ?? match.status} + {isLive && '● '}{STATUS_LABELS[match.status] ?? match.status} - {formatKickoff(match.utcDate)} + {match.group && ( + + {match.group.replace('GROUP_', 'Gruppe ')} + + )} {match.tippable && } - {match.group && {match.group.replace('GROUP_', 'Gruppe ')}}
{/* Teams + Score */}
- {/* Home Team */} + {/* Home */}
- {match.homeTeam.crest && ( - - )} {match.homeTeam.name} +
- {/* Score / VS */} + {/* Score / Kickoff time */}
{isFinished || isLive ? ( - {match.score.home ?? '–'} : {match.score.away ?? '–'} + {match.score.home ?? '–'} : {match.score.away ?? '–'} ) : ( - vs +
+ {formatKickoff(match.utcDate)} +
)}
- {/* Away Team */} + {/* Away */}
+ {match.awayTeam.name} - {match.awayTeam.crest && ( - - )}
- {/* Tipp-Bereich */} + {/* Tipp area */}
{hasTip ? (
- Dein Tipp: + Dein Tipp {match.userTip!.home} : {match.userTip!.away} @@ -93,7 +106,7 @@ export default function MatchCard({ match, onTip }: Props) { match.userTip!.points === 1 ? styles.tendency : styles.wrong }`}> {match.userTip!.points === 3 ? '🎯 3 Punkte' : - match.userTip!.points === 1 ? '✓ 1 Punkt' : '✗ 0 Punkte'} + match.userTip!.points === 1 ? '✓ 1 Punkt' : '✗ 0 Punkte'} )} {match.tippable && ( @@ -102,7 +115,7 @@ export default function MatchCard({ match, onTip }: Props) {
) : match.tippable ? ( ) : ( Kein Tipp abgegeben diff --git a/frontend/src/components/TipModal.module.css b/frontend/src/components/TipModal.module.css index e5b40c6..bba6116 100644 --- a/frontend/src/components/TipModal.module.css +++ b/frontend/src/components/TipModal.module.css @@ -1,118 +1,321 @@ +/* TipModal — Stadium Elite / Stitch Style */ + .overlay { - position: fixed; inset: 0; z-index: 200; - background: rgba(0,0,0,0.7); - backdrop-filter: blur(8px); - display: flex; align-items: center; justify-content: center; - padding: 20px; + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(12px); + display: flex; + align-items: flex-end; + justify-content: center; + padding-bottom: 0; + animation: fadeIn 0.2s ease; } -.modal { +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Bottom sheet */ +.sheet { background: var(--surface-mid); - border-radius: var(--radius-xl); - padding: 28px; - width: 100%; max-width: 440px; - box-shadow: 0 24px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(75,183,248,0.1); + width: 100%; + max-width: 540px; + border-radius: 28px 28px 0 0; + padding: 12px 28px 36px; + box-shadow: + 0 -8px 40px rgba(0,0,0,0.5), + 0 0 0 1px rgba(75,183,248,0.08), + inset 0 1px 0 rgba(255,255,255,0.08); + animation: slideUp 0.3s cubic-bezier(0.32, 0.72, 0, 1); position: relative; } -.header { - display: flex; align-items: center; justify-content: space-between; +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +/* Glossy sheen on top of sheet */ +.sheet::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; height: 40%; + background: linear-gradient(180deg, rgba(255,255,255,0.04) 0%, transparent 100%); + border-radius: 28px 28px 0 0; + pointer-events: none; +} + +/* Drag handle */ +.handle { + width: 40px; + height: 4px; + background: rgba(255,255,255,0.15); + border-radius: 2px; + margin: 0 auto 20px; +} + +/* Match header */ +.matchHeader { + display: flex; + align-items: center; + justify-content: center; margin-bottom: 24px; } -.title { - font-family: 'Plus Jakarta Sans', sans-serif; - font-size: 20px; font-weight: 800; +.groupBadge { + font-size: 11px; + font-weight: 700; + color: var(--primary); + background: var(--primary-dim); + padding: 3px 10px; + border-radius: 20px; + border: 1px solid rgba(75,183,248,0.2); + text-transform: uppercase; + letter-spacing: 0.05em; } -.closeBtn { - background: var(--surface-high); - border: none; color: var(--text-secondary); - width: 32px; height: 32px; border-radius: 50%; - cursor: pointer; font-size: 14px; - display: flex; align-items: center; justify-content: center; - transition: all 0.15s; -} -.closeBtn:hover { background: var(--surface-high); color: var(--text-primary); } +/* Teams row */ .teamsRow { - display: flex; align-items: center; justify-content: space-between; - gap: 12px; margin-bottom: 28px; + display: grid; + grid-template-columns: 1fr 40px 1fr; + gap: 8px; + margin-bottom: 32px; + align-items: start; } -.team { display: flex; align-items: center; gap: 8px; flex: 1; } -.teamRight { flex-direction: row-reverse; } +.teamBlock { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} -.crest { width: 32px; height: 32px; object-fit: contain; } +/* Large flag box */ +.flagLarge { + width: 72px; + height: 72px; + border-radius: 18px; + background: var(--surface-high); + box-shadow: + 0 8px 24px rgba(0,0,0,0.35), + inset 0 1px 0 rgba(255,255,255,0.12), + inset 1px 0 0 rgba(255,255,255,0.06); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +.flagLarge::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 50%; + background: linear-gradient(180deg, rgba(255,255,255,0.08) 0%, transparent 100%); + pointer-events: none; + z-index: 1; +} + +.flagImg { + width: 48px; + height: 48px; + object-fit: contain; + position: relative; + z-index: 0; +} + +.flagEmoji { font-size: 36px; } .teamName { font-family: 'Plus Jakarta Sans', sans-serif; - font-weight: 600; font-size: 14px; + font-weight: 700; + font-size: 14px; color: var(--text-primary); + text-align: center; + line-height: 1.2; } -.vs { font-size: 13px; color: var(--text-muted); font-weight: 600; } +.teamShort { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.vsBlock { + display: flex; + align-items: center; + justify-content: center; + height: 72px; +} + +.kickoffBlock { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.kickoffDate { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 11px; + font-weight: 700; + color: var(--text-secondary); + text-align: center; + white-space: nowrap; +} + +.kickoffTime { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 12px; + font-weight: 800; + color: var(--primary); + text-align: center; + white-space: nowrap; +} + +/* Picker section */ +.pickerSection { + margin-bottom: 20px; +} + +.pickerLabel { + text-align: center; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-bottom: 16px; +} .pickerRow { - display: flex; align-items: center; justify-content: center; - gap: 20px; margin-bottom: 20px; + display: flex; + align-items: center; + justify-content: center; + gap: 16px; } -.colon { +.pickerColon { font-family: 'Plus Jakarta Sans', sans-serif; - font-size: 40px; font-weight: 800; + font-size: 48px; + font-weight: 800; color: var(--text-secondary); line-height: 1; + padding-bottom: 12px; } +/* Individual picker */ .picker { - display: flex; flex-direction: column; - align-items: center; gap: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; } .pickerBtn { - width: 48px; height: 48px; + width: 52px; + height: 52px; background: var(--surface-high); - border: 1px solid rgba(255,255,255,0.1); - border-radius: var(--radius-md); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 14px; color: var(--text-primary); - font-size: 22px; font-weight: 300; + font-size: 24px; + font-weight: 300; cursor: pointer; - display: flex; align-items: center; justify-content: center; + display: flex; + align-items: center; + justify-content: center; transition: all 0.15s; + box-shadow: + 0 4px 10px rgba(0,0,0,0.2), + inset 0 1px 0 rgba(255,255,255,0.08); +} + +.pickerBtn:hover { + background: var(--primary-dim); + border-color: rgba(75,183,248,0.3); + color: var(--primary); + box-shadow: + 0 4px 16px rgba(75,183,248,0.15), + inset 0 1px 0 rgba(75,183,248,0.1); +} + +.pickerBtn:active { + transform: scale(0.94); } -.pickerBtn:hover { background: var(--primary-dim); border-color: var(--primary); color: var(--primary); } .pickerValue { font-family: 'Plus Jakarta Sans', sans-serif; - font-size: 52px; font-weight: 800; + font-size: 58px; + font-weight: 800; color: var(--text-primary); - min-width: 60px; text-align: center; + min-width: 70px; + text-align: center; line-height: 1; } -.tendencyRow { - display: flex; align-items: center; justify-content: center; - gap: 8px; margin-bottom: 24px; - padding: 10px; +/* Tendenz bar */ +.tendencyBar { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 16px; background: var(--surface-high); - border-radius: var(--radius-sm); + border-radius: 12px; + margin-bottom: 20px; + border: 1px solid rgba(255,255,255,0.06); } -.tendencyLabel { font-size: 13px; color: var(--text-secondary); } -.tendencyValue { font-size: 14px; font-weight: 700; color: var(--primary); } +.tendencyIcon { font-size: 18px; } +.tendencyText { + font-size: 14px; + color: var(--text-secondary); +} + +.tendencyText strong { + color: var(--tendency-color, var(--primary)); + font-weight: 700; +} + +/* Error */ .error { color: var(--error); font-size: 13px; text-align: center; + padding: 10px 14px; + background: rgba(248,113,113,0.08); + border-radius: 10px; margin-bottom: 16px; - padding: 10px; - background: rgba(248,113,113,0.1); - border-radius: var(--radius-sm); + border: 1px solid rgba(248,113,113,0.15); } -.actions { - display: flex; gap: 12px; justify-content: flex-end; +/* CTA Buttons */ +.saveBtn { + width: 100%; + padding: 15px; + font-size: 15px; + border-radius: 14px; + margin-bottom: 10px; + box-shadow: 0 6px 20px rgba(75,183,248,0.25); } -.actions .btn-primary { flex: 1; } + +.cancelBtn { + width: 100%; + padding: 12px; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 14px; + cursor: pointer; + transition: color 0.15s; +} + +.cancelBtn:hover { color: var(--text-secondary); } diff --git a/frontend/src/components/TipModal.tsx b/frontend/src/components/TipModal.tsx index 282199b..26ac352 100644 --- a/frontend/src/components/TipModal.tsx +++ b/frontend/src/components/TipModal.tsx @@ -8,6 +8,14 @@ interface Props { onSaved: (matchId: number, home: number, away: number) => void; } +type Tendency = 'home' | 'draw' | 'away'; + +function getTendency(home: number, away: number): Tendency { + if (home > away) return 'home'; + if (away > home) return 'away'; + return 'draw'; +} + export default function TipModal({ match, onClose, onSaved }: Props) { const existing = match.userTip; const [home, setHome] = useState(existing?.home ?? 0); @@ -15,9 +23,15 @@ export default function TipModal({ match, onClose, onSaved }: Props) { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const adjust = (setter: React.Dispatch>, val: number, delta: number) => { - setter(Math.max(0, Math.min(20, val + delta))); - }; + const tendency = getTendency(home, away); + const tendencyLabel = + tendency === 'home' ? match.homeTeam.shortName || match.homeTeam.name : + tendency === 'away' ? match.awayTeam.shortName || match.awayTeam.name : + 'Unentschieden'; + const tendencyColor = + tendency === 'home' ? 'var(--primary)' : + tendency === 'away' ? 'var(--cyan)' : + 'var(--gold)'; const handleSave = async () => { setSaving(true); @@ -31,54 +45,97 @@ export default function TipModal({ match, onClose, onSaved }: Props) { } }; - // Tendenz-Anzeige - const tendency = home > away ? match.homeTeam.shortName : - away > home ? match.awayTeam.shortName : 'Unentschieden'; - return (
-
e.stopPropagation()}> - {/* Header */} -
-

Tipp abgeben

- +
e.stopPropagation()}> + + {/* Drag handle */} +
+ + {/* Match info header */} +
+ {match.group && ( + + {match.group.replace('GROUP_', 'Gruppe ')} + + )}
- {/* Teams */} + {/* Teams mit Flaggen */}
-
- {match.homeTeam.crest && } +
+
+ {match.homeTeam.crest + ? {match.homeTeam.name} + : 🏳️ + } +
{match.homeTeam.name} + {match.homeTeam.shortName}
- vs -
+ +
+
+ + {new Date(match.utcDate).toLocaleString('de-DE', { + weekday: 'short', day: 'numeric', month: 'short', + timeZone: 'Europe/Berlin' + })} + + + {new Date(match.utcDate).toLocaleString('de-DE', { + hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' + })} Uhr + +
+
+ +
+
+ {match.awayTeam.crest + ? {match.awayTeam.name} + : 🏳️ + } +
{match.awayTeam.name} - {match.awayTeam.crest && } + {match.awayTeam.shortName}
{/* Score Picker */} -
- setHome(v)} /> -
:
- setAway(v)} /> +
+

Dein Tipp

+
+ +
:
+ +
{/* Tendenz */} -
- Tendenz: - {tendency} +
+ + {tendency === 'draw' ? '🤝' : tendency === 'home' ? '🏠' : '✈️'} + + + Tendenz: {tendencyLabel} +
{error &&
{error}
} - {/* Buttons */} -
- - -
+ {/* CTA */} + + +
); @@ -87,9 +144,21 @@ export default function TipModal({ match, onClose, onSaved }: Props) { function ScorePicker({ value, onChange }: { value: number; onChange: (v: number) => void }) { return (
- + {value} - +
); } diff --git a/frontend/src/pages/AdminPage.module.css b/frontend/src/pages/AdminPage.module.css index 8ea6de8..b8b33b1 100644 --- a/frontend/src/pages/AdminPage.module.css +++ b/frontend/src/pages/AdminPage.module.css @@ -1,11 +1,181 @@ -.page { display: flex; flex-direction: column; gap: 24px; max-width: 800px; } +.page { + display: flex; + flex-direction: column; + gap: 20px; + max-width: 760px; +} + +/* Header */ +.pageHeader { + display: flex; + align-items: center; + gap: 12px; +} + .title { font-size: 28px; font-weight: 800; } -.hint { font-size: 13px; color: var(--text-secondary); padding: 12px 16px; background: var(--surface-mid); border-radius: var(--radius-sm); border-left: 3px solid var(--primary); } -.cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } -.actionCard { padding: 28px; display: flex; flex-direction: column; gap: 14px; } -.cardIcon { font-size: 32px; } -.cardTitle { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 18px; font-weight: 700; } -.cardDesc { font-size: 13px; color: var(--text-secondary); line-height: 1.6; } -.status { font-size: 13px; padding: 10px 14px; border-radius: var(--radius-sm); } -.success { background: rgba(52,211,153,0.1); color: var(--success); } -.error { background: rgba(248,113,113,0.1); color: var(--error); } + +.roleBadge { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--gold); + background: rgba(254,174,50,0.1); + border: 1px solid rgba(254,174,50,0.2); + padding: 3px 10px; + border-radius: 20px; +} + +.hint { + font-size: 13px; + color: var(--text-secondary); + padding: 12px 16px; + background: var(--surface-mid); + border-radius: var(--radius-sm); + border-left: 3px solid var(--primary); + line-height: 1.5; +} + +/* Cards grid */ +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; +} + +.actionCard { + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + transition: box-shadow 0.2s, transform 0.15s; +} + +.actionCard:hover { + transform: translateY(-1px); + box-shadow: + 0 16px 36px rgba(0,0,0,0.35), + inset 0 1px 0 rgba(255,255,255,0.10); +} + +/* Card top row: icon + text */ +.cardTop { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.cardIcon { + width: 44px; + height: 44px; + border-radius: 12px; + background: var(--primary-dim); + border: 1px solid rgba(75,183,248,0.15); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + color: var(--primary); + flex-shrink: 0; + font-family: monospace; +} + +.cardTitle { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 4px; +} + +.cardDesc { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Result bar */ +.resultBar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: var(--radius-sm); + font-size: 12px; +} + +.resultSuccess { + background: rgba(52,211,153,0.08); + border: 1px solid rgba(52,211,153,0.15); + color: var(--success); +} + +.resultError { + background: rgba(248,113,113,0.08); + border: 1px solid rgba(248,113,113,0.15); + color: var(--error); +} + +.resultDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.resultMsg { flex: 1; } + +.resultTime { + font-size: 11px; + opacity: 0.6; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +/* Action button */ +.actionBtn { + width: 100%; + padding: 11px 20px; + border-radius: var(--radius-sm); + background: var(--primary); + color: #fff; + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.03em; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.15s; + box-shadow: 0 4px 14px rgba(75,183,248,0.25); +} + +.actionBtn:hover:not(:disabled) { + background: #6bc4fa; + box-shadow: 0 6px 20px rgba(75,183,248,0.35); +} + +.actionBtn:disabled { + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; +} + +.actionBtnLoading { opacity: 0.75; } + +/* Spinner inline */ +.spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index b7f8413..55dc7b8 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -2,20 +2,28 @@ import { useState } from 'react'; import { api } from '../api/client'; import styles from './AdminPage.module.css'; +interface ActionResult { + message: string; + success: boolean; + timestamp: Date; +} + export default function AdminPage() { - const [syncStatus, setSyncStatus] = useState(null); - const [evalStatus, setEvalStatus] = useState(null); - const [syncing, setSyncing] = useState(false); - const [evaluating, setEvaluating] = useState(false); + const [syncResult, setSyncResult] = useState(null); + const [evalResult, setEvalResult] = useState(null); + const [refreshResult, setRefreshResult] = useState(null); + const [syncing, setSyncing] = useState(false); + const [evaluating, setEvaluating] = useState(false); + const [refreshing, setRefreshing] = useState(false); const handleSync = async () => { setSyncing(true); - setSyncStatus(null); + setSyncResult(null); try { const res = await api.syncMatches(); - setSyncStatus(`✓ ${res.total} Spiele geladen — ${res.created} neu, ${res.updated} aktualisiert`); + setSyncResult({ success: true, timestamp: new Date(), message: `${res.total} Spiele geladen — ${res.created} neu, ${res.updated} aktualisiert` }); } catch (e) { - setSyncStatus(`⚠️ Fehler: ${(e as Error).message}`); + setSyncResult({ success: false, timestamp: new Date(), message: (e as Error).message }); } finally { setSyncing(false); } @@ -23,51 +31,134 @@ export default function AdminPage() { const handleEvaluate = async () => { setEvaluating(true); - setEvalStatus(null); + setEvalResult(null); try { const res = await api.evaluateTips(); - setEvalStatus(`✓ ${res.matchesEvaluated} Spiele ausgewertet — ${res.tipsUpdated} Tipps bewertet`); + setEvalResult({ success: true, timestamp: new Date(), message: `${res.matchesEvaluated} Spiele ausgewertet — ${res.tipsUpdated} Tipps bewertet` }); } catch (e) { - setEvalStatus(`⚠️ Fehler: ${(e as Error).message}`); + setEvalResult({ success: false, timestamp: new Date(), message: (e as Error).message }); } finally { setEvaluating(false); } }; + const handleRefreshLeaderboard = async () => { + setRefreshing(true); + setRefreshResult(null); + try { + await fetch('/api/admin/refresh-leaderboard', { method: 'POST' }); + setRefreshResult({ success: true, timestamp: new Date(), message: 'Materialized View aktualisiert' }); + } catch (e) { + setRefreshResult({ success: false, timestamp: new Date(), message: (e as Error).message }); + } finally { + setRefreshing(false); + } + }; + + function formatTime(d: Date) { + return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } + return (
-

⚙️ Administration

-

Diese Seite ist nur für Editoren. Nach der Staffbase-Integration wird sie durch Rollenprüfung geschützt.

+ {/* Header */} +
+

Administration

+
Editor
+
+

+ Nur für Editoren sichtbar. Nach Staffbase-Freischaltung wird diese Seite durch Rollenprüfung geschützt. +

+ + {/* Action Cards */}
-
-
🔄
-

Spiele synchronisieren

-

Lädt alle WM 2026-Spiele von football-data.org und speichert sie in der Datenbank. Täglich ausführen oder nach Spielplan-Änderungen.

- {syncStatus && ( -
- {syncStatus} -
- )} - -
-
-
🧮
-

Tipps auswerten

-

Berechnet Punkte für alle abgeschlossenen Spiele und aktualisiert die Rangliste. Nach jedem Spieltag ausführen.

- {evalStatus && ( -
- {evalStatus} -
- )} - -
+ {/* Sync */} + + + {/* Evaluate */} + + + {/* Refresh Leaderboard */} + +
); } + +/* ── Sub-component ── */ +function ActionCard({ + icon, title, desc, result, loading, loadingLabel, actionLabel, onAction, formatTime, +}: { + icon: string; + title: string; + desc: string; + result: ActionResult | null; + loading: boolean; + loadingLabel: string; + actionLabel: string; + onAction: () => void; + formatTime: (d: Date) => string; +}) { + return ( +
+
+
{icon}
+
+
{title}
+
{desc}
+
+
+ + {result && ( +
+ + {result.message} + {formatTime(result.timestamp)} +
+ )} + + +
+ ); +} diff --git a/frontend/src/pages/LeaderboardPage.module.css b/frontend/src/pages/LeaderboardPage.module.css index 559fccc..2f72175 100644 --- a/frontend/src/pages/LeaderboardPage.module.css +++ b/frontend/src/pages/LeaderboardPage.module.css @@ -1,38 +1,232 @@ -.page { display: flex; flex-direction: column; gap: 24px; } -.pageHeader { display: flex; align-items: baseline; gap: 16px; } +.page { display: flex; flex-direction: column; gap: 16px; } + +.pageHeader { display: flex; align-items: baseline; gap: 16px; margin-bottom: 4px; } .title { font-size: 28px; font-weight: 800; } .meta { font-size: 13px; color: var(--text-secondary); } + .loading { display: flex; justify-content: center; padding: 60px; } .spinner { width: 32px; height: 32px; border: 3px solid var(--surface-high); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } + .empty { text-align: center; color: var(--text-secondary); padding: 60px; } +/* ── Color tokens ── */ +.gold { color: var(--gold); } +.silver { color: #C0C0C0; } +.bronze { color: #CD7F32; } + +/* ── Podium ── */ +.podiumWrap { + display: flex; + align-items: flex-end; + justify-content: center; + gap: 8px; + padding: 8px 0 0; +} + +.podiumCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + flex: 1; + max-width: 130px; +} + +.podiumFirst { flex: 1.2; max-width: 150px; } + +.podiumMedal { font-size: 24px; line-height: 1; } + +.podiumAvatar { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--surface-high); + box-shadow: 0 4px 14px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.12); + display: flex; + align-items: center; + justify-content: center; + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 16px; + font-weight: 800; + color: var(--text-primary); + transition: box-shadow 0.2s; +} + +.podiumAvatarLarge { + width: 64px; + height: 64px; + font-size: 22px; +} + +.podiumAvatarMe { + background: rgba(75,183,248,0.15); + box-shadow: 0 0 0 2px var(--primary), 0 4px 14px rgba(75,183,248,0.25); +} + +/* Gold/silver/bronze ring on avatar */ +.podiumAvatar.gold { box-shadow: 0 0 0 2px var(--gold), 0 4px 16px rgba(254,174,50,0.3); } +.podiumAvatar.silver { box-shadow: 0 0 0 2px #C0C0C0, 0 4px 14px rgba(192,192,192,0.2); } +.podiumAvatar.bronze { box-shadow: 0 0 0 2px #CD7F32, 0 4px 14px rgba(205,127,50,0.2); } + +.podiumName { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 12px; + font-weight: 700; + color: var(--text-primary); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.podiumPoints { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 15px; + font-weight: 800; + text-align: center; +} +.podiumPtLabel { font-size: 10px; font-weight: 500; opacity: 0.7; } + +.podiumBar { + width: 100%; + border-radius: 10px 10px 0 0; + margin-top: 6px; +} +.podiumBar.goldBar { background: linear-gradient(180deg, rgba(254,174,50,0.3) 0%, rgba(254,174,50,0.08) 100%); box-shadow: inset 0 2px 0 rgba(254,174,50,0.35); } +.podiumBar.silverBar { background: linear-gradient(180deg, rgba(192,192,192,0.22) 0%, rgba(192,192,192,0.06) 100%); box-shadow: inset 0 2px 0 rgba(192,192,192,0.28); } +.podiumBar.bronzeBar { background: linear-gradient(180deg, rgba(205,127,50,0.22) 0%, rgba(205,127,50,0.06) 100%); box-shadow: inset 0 2px 0 rgba(205,127,50,0.28); } + +/* ── List header ── */ +.listHeader { + display: grid; + grid-template-columns: 40px 1fr 40px 64px; + gap: 8px; + padding: 0 20px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--text-muted); + text-transform: uppercase; +} +.listHeader span:last-child { text-align: right; } + +/* ── Rest list ── */ .list { display: flex; flex-direction: column; gap: 8px; } .row { padding: 14px 20px; display: grid; - grid-template-columns: 44px 1fr auto auto; + grid-template-columns: 40px 36px 1fr 40px 64px; align-items: center; - gap: 16px; + gap: 8px; transition: all 0.15s; } .row:hover { box-shadow: 0 8px 24px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.09); } -.topThree { box-shadow: 0 10px 25px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.1), 0 0 0 1px rgba(254,174,50,0.1); } +.rowMe { + box-shadow: 0 0 0 1px var(--primary), 0 8px 24px rgba(75,183,248,0.12), inset 0 1px 0 rgba(75,183,248,0.08) !important; +} -.rank { font-size: 22px; text-align: center; } -.rankNum { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 16px; font-weight: 700; color: var(--text-secondary); } +.rankCol { text-align: center; } +.rankNum { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 15px; + font-weight: 700; + color: var(--text-secondary); +} -.name { font-family: 'Plus Jakarta Sans', sans-serif; font-weight: 600; font-size: 15px; } +.avatarSmall { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--surface-high); + display: flex; + align-items: center; + justify-content: center; + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 12px; + font-weight: 800; + color: var(--text-primary); + flex-shrink: 0; +} -.stats { display: flex; gap: 16px; } -.stat { display: flex; align-items: center; gap: 4px; } -.statVal { font-weight: 700; font-size: 14px; } -.statLbl { font-size: 12px; color: var(--text-muted); } +.avatarSmallMe { + background: rgba(75,183,248,0.15); + box-shadow: 0 0 0 2px var(--primary); + color: var(--primary); +} -.points { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 20px; font-weight: 800; min-width: 70px; text-align: right; } -.ptLabel { font-size: 12px; font-weight: 500; color: var(--text-secondary); } -.gold { color: var(--gold); } -.silver { color: #C0C0C0; } -.bronze { color: #CD7F32; } +.nameCol { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.nameRow { display: flex; align-items: center; gap: 6px; } + +.rowName { + font-family: 'Plus Jakarta Sans', sans-serif; + font-weight: 700; + font-size: 14px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rowTeam { + font-size: 11px; + color: var(--text-muted); +} + +.nameMe { color: var(--primary); } + +.aufholjagd { + font-size: 10px; + font-weight: 800; + color: var(--primary); + letter-spacing: 0.06em; +} + +.trendCol { display: flex; justify-content: center; font-size: 18px; } +.trendUp { color: #34D399; } +.trendDown { color: #F87171; } +.trendNeutral { color: var(--text-muted); } + +.pointsCol { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 16px; + font-weight: 800; + color: var(--primary); + text-align: right; +} + +/* ── CTA Card ── */ +.ctaCard { + display: flex; + align-items: center; + gap: 16px; + padding: 20px 24px; + margin-top: 4px; +} + +.ctaText { flex: 1; } + +.ctaTitle { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 16px; + font-weight: 800; + color: var(--text-primary); + margin-bottom: 4px; +} + +.ctaBody { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; +} + +.ctaBtn { + flex-shrink: 0; + padding: 10px 20px; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.06em; +} diff --git a/frontend/src/pages/LeaderboardPage.tsx b/frontend/src/pages/LeaderboardPage.tsx index 9895f74..3b9d1ba 100644 --- a/frontend/src/pages/LeaderboardPage.tsx +++ b/frontend/src/pages/LeaderboardPage.tsx @@ -1,20 +1,32 @@ import { useState, useEffect } from 'react'; -import { api, LeaderboardEntry } from '../api/client'; +import { useNavigate } from 'react-router-dom'; +import { api, LeaderboardEntry, LeaderboardResponse } from '../api/client'; import styles from './LeaderboardPage.module.css'; -const MEDALS = ['🥇', '🥈', '🥉']; +function initials(name: string) { + return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); +} + +function TrendIcon({ entry, prev }: { entry: LeaderboardEntry; prev: LeaderboardEntry | undefined }) { + if (!prev) return ; + if (entry.total_points > prev.total_points) return ; + if (entry.total_points < prev.total_points) return ; + return ; +} export default function LeaderboardPage() { - const [entries, setEntries] = useState([]); - const [myRank, setMyRank] = useState(null); - const [total, setTotal] = useState(0); + const [data, setData] = useState(null); + const [tippableCount, setTippableCount] = useState(0); const [loading, setLoading] = useState(true); + const navigate = useNavigate(); useEffect(() => { - api.getLeaderboard().then(res => { - setEntries(res.entries); - setMyRank(res.currentUserRank); - setTotal(res.totalParticipants); + Promise.all([ + api.getLeaderboard(), + api.getMatches(), + ]).then(([lb, matches]) => { + setData(lb); + setTippableCount(matches.matches.filter(m => m.tippable && !m.userTip).length); }).finally(() => setLoading(false)); }, []); @@ -22,12 +34,25 @@ export default function LeaderboardPage() {
); + if (!data) return null; + + const { entries, currentUserId, currentUserRank, totalParticipants } = data; + const top3 = entries.slice(0, 3); + const rest = entries.slice(3); + + // Podium order: 2nd left, 1st center, 3rd right + type PodiumSlot = { entry: LeaderboardEntry; rank: 1 | 2 | 3; medal: string; colorClass: string; barHeight: string }; + const podiumSlots: PodiumSlot[] = []; + if (top3[1]) podiumSlots.push({ entry: top3[1], rank: 2, medal: '🥈', colorClass: styles.silver, barHeight: '64px' }); + if (top3[0]) podiumSlots.push({ entry: top3[0], rank: 1, medal: '🥇', colorClass: styles.gold, barHeight: '96px' }); + if (top3[2]) podiumSlots.push({ entry: top3[2], rank: 3, medal: '🥉', colorClass: styles.bronze, barHeight: '48px' }); + return (
-

🏆 Rangliste

+

Rangliste

- {total} Teilnehmer{myRank ? ` · Du: Platz ${myRank}` : ''} + {totalParticipants} Teilnehmer{currentUserRank ? ` · Du: Platz ${currentUserRank}` : ''}
@@ -36,33 +61,95 @@ export default function LeaderboardPage() { Noch keine Punkte vergeben. Spiele müssen erst abgeschlossen sein.
) : ( -
- {entries.map((entry, i) => ( -
-
- {i < 3 ? MEDALS[i] : {entry.rank}} -
-
{entry.full_name}
-
- - {entry.exact_count} - 🎯 - - - {entry.tendency_count} - - - - {entry.tips_count} - Tipps - -
-
- {entry.total_points} Pkt -
+ <> + {/* ── Podium ── */} + {top3.length > 0 && ( +
+ {podiumSlots.map(({ entry, rank, medal, colorClass, barHeight }) => { + const isMe = entry.user_id === currentUserId; + const isFirst = rank === 1; + return ( +
+
{medal}
+
+ {initials(entry.full_name)} +
+
+ {entry.full_name.split(' ')[0]}{isMe ? ' (Ich)' : ''} +
+
+ {entry.total_points.toLocaleString('de-DE')} + Pkt +
+
+
+ ); + })}
- ))} -
+ )} + + {/* ── List header ── */} + {rest.length > 0 && ( + <> +
+ POS + SPIELER + TREND + PUNKTE +
+ +
+ {rest.map((entry, i) => { + const isMe = entry.user_id === currentUserId; + const prev = rest[i - 1]; + return ( +
+
+ {entry.rank} +
+
+ {initials(entry.full_name)} +
+
+
+ + {entry.full_name}{isMe ? ' (Ich)' : ''} + +
+ {entry.team &&
{entry.team}
} + {isMe &&
AUFHOLJAGD!
} +
+
+ +
+
+ {entry.total_points.toLocaleString('de-DE')} +
+
+ ); + })} +
+ + )} + + {/* ── CTA Card ── */} + {tippableCount > 0 && ( +
+
+
Punkte sichern!
+
+ {tippableCount} Spiel{tippableCount !== 1 ? 'e' : ''} noch ohne Tipp — kletter nach oben. +
+
+ +
+ )} + )}
); diff --git a/frontend/src/pages/ProfilePage.module.css b/frontend/src/pages/ProfilePage.module.css index eee26e3..a077bef 100644 --- a/frontend/src/pages/ProfilePage.module.css +++ b/frontend/src/pages/ProfilePage.module.css @@ -1,14 +1,90 @@ .page { display: flex; flex-direction: column; gap: 20px; max-width: 640px; } .loading { display: flex; justify-content: center; padding: 60px; } .spinner { width: 32px; height: 32px; border: 3px solid var(--surface-high); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; } +.spinnerSm { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.7s linear infinite; display: inline-block; } @keyframes spin { to { transform: rotate(360deg); } } .empty { color: var(--text-secondary); padding: 40px; text-align: center; } -.heroCard { padding: 28px; display: flex; align-items: center; gap: 20px; } -.avatar { width: 60px; height: 60px; border-radius: 50%; background: var(--primary-dim); border: 2px solid rgba(75,183,248,0.3); display: flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', sans-serif; font-size: 24px; font-weight: 800; color: var(--primary); flex-shrink: 0; } -.heroInfo { flex: 1; } +.heroCard { padding: 28px; display: flex; align-items: flex-start; gap: 20px; } +.avatar { width: 60px; height: 60px; border-radius: 50%; background: var(--primary-dim); border: 2px solid rgba(75,183,248,0.3); display: flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', sans-serif; font-size: 22px; font-weight: 800; color: var(--primary); flex-shrink: 0; margin-top: 2px; } +.heroInfo { flex: 1; min-width: 0; } .name { font-size: 22px; font-weight: 800; } .rankBadge { font-size: 13px; color: var(--gold); margin-top: 4px; font-weight: 600; } + +/* Team-Feld */ +.teamRow { margin-top: 8px; } + +.teamBtn { + background: none; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; +} + +.teamName { + font-size: 13px; + color: var(--text-secondary); + font-weight: 600; +} + +.teamEditHint { + font-size: 11px; + color: var(--text-muted); + opacity: 0; + transition: opacity 0.15s; +} +.teamBtn:hover .teamEditHint { opacity: 1; } + +.teamPlaceholder { + font-size: 12px; + color: var(--primary); + opacity: 0.7; + transition: opacity 0.15s; +} +.teamBtn:hover .teamPlaceholder { opacity: 1; } + +.teamEditRow { + display: flex; + align-items: center; + gap: 6px; +} + +.teamInput { + background: var(--surface-high); + border: 1px solid rgba(75,183,248,0.3); + border-radius: 8px; + padding: 5px 10px; + font-size: 13px; + color: var(--text-primary); + font-family: inherit; + outline: none; + width: 180px; + transition: border-color 0.15s; +} +.teamInput:focus { border-color: var(--primary); } + +.teamSaveBtn, .teamCancelBtn { + width: 28px; height: 28px; + border-radius: 8px; + border: none; + cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 13px; + font-weight: 700; + transition: all 0.15s; +} +.teamSaveBtn { background: var(--primary); color: #fff; } +.teamSaveBtn:hover:not(:disabled) { background: #6bc4fa; } +.teamSaveBtn:disabled { opacity: 0.5; cursor: not-allowed; } +.teamCancelBtn { background: var(--surface-high); color: var(--text-muted); } +.teamCancelBtn:hover { color: var(--text-primary); } + +.teamMsg { font-size: 12px; margin-top: 4px; } +.teamMsgOk { color: var(--success); } +.teamMsgErr { color: var(--error); } .heroPoints { text-align: right; } .pointsVal { font-size: 40px; font-weight: 800; color: var(--primary); line-height: 1; display: block; } .pointsLbl { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; } diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 66f6e76..5e21db0 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -2,27 +2,88 @@ import { useState, useEffect } from 'react'; import { api, UserStats } from '../api/client'; import styles from './ProfilePage.module.css'; +function initials(name: string) { + return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); +} + export default function ProfilePage() { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [teamEdit, setTeamEdit] = useState(false); + const [teamValue, setTeamValue] = useState(''); + const [teamSaving, setTeamSaving] = useState(false); + const [teamMsg, setTeamMsg] = useState<{ ok: boolean; text: string } | null>(null); useEffect(() => { - api.getMyStats().then(setStats).finally(() => setLoading(false)); + api.getMyStats().then(s => { + setStats(s); + setTeamValue(s.team ?? ''); + }).finally(() => setLoading(false)); }, []); + const saveTeam = async () => { + if (!teamValue.trim()) return; + setTeamSaving(true); + setTeamMsg(null); + try { + const res = await api.updateTeam(teamValue); + setStats(prev => prev ? { ...prev, team: res.team } : prev); + setTeamValue(res.team); + setTeamEdit(false); + setTeamMsg({ ok: true, text: 'Team gespeichert' }); + } catch (e) { + setTeamMsg({ ok: false, text: (e as Error).message }); + } finally { + setTeamSaving(false); + } + }; + if (loading) return
; - if (!stats) return
Profil nicht verfügbar.
; + if (!stats) return
Profil nicht verfügbar.
; const evaluated = stats.exactCount + stats.tendencyCount + stats.wrongCount; return (
+ + {/* Hero card */}
-
{stats.fullName.charAt(0).toUpperCase()}
+
{initials(stats.fullName)}

{stats.fullName}

- {stats.rank && ( -
🏆 Platz {stats.rank}
+ {stats.rank &&
🏆 Platz {stats.rank}
} + + {/* Team-Feld */} +
+ {teamEdit ? ( +
+ setTeamValue(e.target.value)} + placeholder="z. B. Vertrieb Süd" + maxLength={80} + autoFocus + onKeyDown={e => { if (e.key === 'Enter') saveTeam(); if (e.key === 'Escape') setTeamEdit(false); }} + /> + + +
+ ) : ( + + )} +
+ {teamMsg && ( +
+ {teamMsg.text} +
)}
@@ -31,6 +92,7 @@ export default function ProfilePage() {
+ {/* Stats grid */}
{stats.exactCount} @@ -50,6 +112,7 @@ export default function ProfilePage() {
+ {/* Accuracy bar */} {evaluated > 0 && (