diff --git a/backend/src/index.ts b/backend/src/index.ts index ff099ad..9aa595d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -14,6 +14,7 @@ import tipsRouter from './routes/tips'; import leaderboardRouter from './routes/leaderboard'; import adminRouter from './routes/admin'; import profileRouter from './routes/profile'; +import devRouter from './routes/dev'; const app = express(); const PORT = parseInt(process.env.PORT ?? '3001'); @@ -118,6 +119,9 @@ app.use('/api/tips', tipsRouter); app.use('/api/leaderboard', leaderboardRouter); app.use('/api/admin', adminRouter); app.use('/api/profile', profileRouter); +if (process.env.NODE_ENV === 'development') { + app.use('/api/dev', devRouter); +} // ============================================================ // Frontend (React Build) – statisches Serving diff --git a/backend/src/routes/dev.ts b/backend/src/routes/dev.ts new file mode 100644 index 0000000..51d301c --- /dev/null +++ b/backend/src/routes/dev.ts @@ -0,0 +1,143 @@ +import { Router, Request, Response } from 'express'; +import { query } from '../db/client'; +import { logger } from '../services/logger'; + +/** + * Dev-only Routes — werden in Production blockiert + * Erlaubt Daten-Manipulation für Testzwecke + */ +const router = Router(); + +// Sicherheitscheck: nur in Development verfügbar +router.use((_req: Request, res: Response, next: Function) => { + if (process.env.NODE_ENV === 'production') { + res.status(404).json({ error: 'Not found' }); + return; + } + next(); +}); + +/** + * POST /api/dev/match/:id/set-time + * Setzt das Datum eines Spiels auf einen bestimmten Zeitpunkt + * Body: { minutesFromNow: number } (negativ = Vergangenheit) + */ +router.post('/match/:id/set-time', async (req: Request, res: Response): Promise => { + const matchId = parseInt(req.params.id); + const { minutesFromNow } = req.body as { minutesFromNow: number }; + + if (isNaN(matchId) || typeof minutesFromNow !== 'number') { + res.status(400).json({ error: 'matchId und minutesFromNow erforderlich' }); + return; + } + + const newDate = new Date(Date.now() + minutesFromNow * 60 * 1000); + + try { + // Original-Datum beim ersten set-time sichern (nur wenn noch nicht gesetzt) + await query( + `UPDATE matches + SET original_utc_date = COALESCE(original_utc_date, utc_date), + utc_date = $1 + WHERE id = $2`, + [newDate.toISOString(), matchId] + ); + logger.info(`[DEV] Match ${matchId} utc_date gesetzt auf ${newDate.toISOString()}`); + res.json({ success: true, matchId, newDate: newDate.toISOString(), minutesFromNow }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/dev/match/:id/set-status + * Setzt den Status eines Spiels (z.B. FINISHED, IN_PLAY, TIMED) + * Body: { status: string, scoreHome?: number, scoreAway?: number } + */ +router.post('/match/:id/set-status', async (req: Request, res: Response): Promise => { + const matchId = parseInt(req.params.id); + const { status, scoreHome, scoreAway } = req.body as { + status: string; + scoreHome?: number; + scoreAway?: number; + }; + + const VALID_STATUSES = ['TIMED', 'SCHEDULED', 'IN_PLAY', 'PAUSED', 'FINISHED']; + if (!VALID_STATUSES.includes(status)) { + res.status(400).json({ error: `Status muss einer von: ${VALID_STATUSES.join(', ')} sein` }); + return; + } + + try { + await query( + `UPDATE matches SET status = $1, score_home = $2, score_away = $3 WHERE id = $4`, + [status, scoreHome ?? null, scoreAway ?? null, matchId] + ); + logger.info(`[DEV] Match ${matchId} Status → ${status}`); + res.json({ success: true, matchId, status, scoreHome, scoreAway }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/dev/reset-match + * Setzt ein einzelnes Spiel zurück: Status → TIMED, Score → null, null + * Body: { matchId?: number } — ohne matchId werden ALLE Spiele zurückgesetzt + */ +router.post('/reset-match', async (req: Request, res: Response): Promise => { + const { matchId } = req.body as { matchId?: number }; + try { + if (matchId) { + await query( + `UPDATE matches + SET status = 'TIMED', + score_home = NULL, + score_away = NULL, + utc_date = COALESCE(original_utc_date, utc_date), + original_utc_date = NULL + WHERE id = $1`, + [matchId] + ); + logger.info(`[DEV] Match ${matchId} zurückgesetzt auf TIMED + Original-Datum`); + res.json({ success: true, reset: 'single', matchId }); + } else { + const result = await query<{ id: number }>( + `UPDATE matches + SET status = 'TIMED', + score_home = NULL, + score_away = NULL, + utc_date = COALESCE(original_utc_date, utc_date), + original_utc_date = NULL + WHERE status IN ('IN_PLAY','PAUSED','FINISHED') + OR original_utc_date IS NOT NULL + RETURNING id` + ); + logger.info(`[DEV] ${result.length} Spiele zurückgesetzt`); + res.json({ success: true, reset: 'all', count: result.length }); + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/dev/reset + * Setzt alle manipulierten Spiele zurück (löscht Dev-Tipps, setzt Status zurück) + * Nur Spiele die durch Dev-User angelegt wurden + */ +router.post('/reset-tips', async (req: Request, res: Response): Promise => { + const { userId } = req.body as { userId?: string }; + try { + const result = await query<{ count: string }>( + `DELETE FROM tips WHERE user_id = $1 RETURNING id`, + [userId ?? 'dev-user-001'] + ); + await query('REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard'); + res.json({ success: true, deletedTips: result.length }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dbc2313..560e157 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "wm2026-tippspiel-frontend", "version": "1.0.0", "dependencies": { + "lucide-react": "^1.7.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0" @@ -1445,6 +1446,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 26ac53a..521c4ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "preview": "vite preview" }, "dependencies": { + "lucide-react": "^1.7.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0" diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css index eb7e37a..3b7c6c7 100644 --- a/frontend/src/App.module.css +++ b/frontend/src/App.module.css @@ -39,6 +39,17 @@ letter-spacing: -0.3px; } +.devBadge { + font-size: 10px; + font-weight: 700; + color: var(--gold); + background: rgba(254,174,50,0.1); + border: 1px solid rgba(254,174,50,0.25); + padding: 2px 8px; + border-radius: 10px; + letter-spacing: 0.05em; +} + .nav { display: flex; align-items: center; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 624ffbd..38bbeac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react'; import { Routes, Route, NavLink } from 'react-router-dom'; import MatchesPage from './pages/MatchesPage'; import LeaderboardPage from './pages/LeaderboardPage'; @@ -5,7 +6,47 @@ import ProfilePage from './pages/ProfilePage'; import AdminPage from './pages/AdminPage'; import styles from './App.module.css'; +const IS_DEV = import.meta.env.DEV; + +// Lazy-load DevPanel nur in Development +let DevPanel: React.ComponentType | null = null; +if (IS_DEV) { + // Dynamic import — kein Bundle-Impact in Production + import('./components/DevPanel').then(m => { DevPanel = m.default; }); +} + export default function App() { + const [devUser, setDevUser] = useState(1); + const [devMatches, setDevMatches] = useState([]); + const [refreshKey, setRefreshKey] = useState(0); + + // DevUser als Query-Parameter im API-Fetch setzen + useEffect(() => { + if (!IS_DEV) return; + // Patch fetch für Dev-Mode: devUser Query-Param anhängen + const origFetch = window.fetch; + window._devUser = devUser; + window.fetch = (input, init) => { + if (typeof input === 'string' && input.startsWith('/api')) { + const url = new URL(input, window.location.origin); + url.searchParams.set('devUser', String(window._devUser ?? 1)); + return origFetch(url.toString(), init); + } + return origFetch(input, init); + }; + return () => { window.fetch = origFetch; }; + }, [devUser]); + + // Matches für DevPanel laden + useEffect(() => { + if (!IS_DEV) return; + fetch('/api/matches').then(r => r.json()).then(d => setDevMatches(d.matches ?? [])).catch(() => {}); + }, [refreshKey, devUser]); + + function handleDevRefresh() { + setRefreshKey(k => k + 1); + } + return (
@@ -13,6 +54,9 @@ export default function App() {
🏆 WM 2026 Tippspiel + {IS_DEV && ( + DEV · User {devUser} + )}
); } diff --git a/frontend/src/components/DevPanel.module.css b/frontend/src/components/DevPanel.module.css new file mode 100644 index 0000000..f043d6c --- /dev/null +++ b/frontend/src/components/DevPanel.module.css @@ -0,0 +1,211 @@ +/* Dev Panel — floating bottom right */ +.wrap { + position: fixed; + bottom: 80px; /* über der Nav-Bar */ + right: 16px; + z-index: 9999; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.toggleBtn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 14px; + border-radius: 24px; + background: rgba(254,174,50,0.15); + border: 1px solid rgba(254,174,50,0.3); + color: var(--gold); + font-size: 14px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 4px 16px rgba(0,0,0,0.3); + transition: all 0.15s; +} +.toggleBtn:hover { background: rgba(254,174,50,0.25); } +.toggleLabel { font-size: 12px; } + +/* Panel */ +.panel { + width: 300px; + background: #111827; + border: 1px solid rgba(254,174,50,0.2); + border-radius: 16px; + overflow: hidden; + box-shadow: 0 20px 50px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + max-height: 80vh; + overflow-y: auto; +} + +.panelHeader { + padding: 12px 16px; + background: rgba(254,174,50,0.08); + border-bottom: 1px solid rgba(254,174,50,0.15); +} + +.panelTitle { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + font-weight: 700; + color: var(--gold); + letter-spacing: 0.03em; +} + +/* Sections */ +.section { + padding: 12px 16px; + border-bottom: 1px solid rgba(255,255,255,0.05); + display: flex; + flex-direction: column; + gap: 8px; +} + +.section:last-child { border-bottom: none; } + +.sectionDisabled { opacity: 0.4; pointer-events: none; } + +.sectionLabel { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +/* User buttons */ +.userButtons { display: flex; flex-direction: column; gap: 6px; } + +.userBtn { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 10px; + background: var(--surface-high); + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s; + text-align: left; +} +.userBtn:hover { border-color: rgba(75,183,248,0.3); } +.userBtnActive { + background: rgba(75,183,248,0.12); + border-color: rgba(75,183,248,0.4); +} + +.userInitial { + width: 28px; height: 28px; + border-radius: 50%; + background: var(--primary-dim); + color: var(--primary); + font-weight: 800; + font-size: 12px; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; +} +.userBtnActive .userInitial { background: rgba(75,183,248,0.2); } + +.userName { font-size: 13px; font-weight: 600; color: var(--text-primary); flex: 1; } +.userRole { font-size: 10px; color: var(--text-muted); } + +/* Select */ +.select { + width: 100%; + background: var(--surface-high); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 8px; + padding: 8px 10px; + font-size: 12px; + color: var(--text-primary); + font-family: inherit; + cursor: pointer; + outline: none; +} +.select:focus { border-color: var(--primary); } + +/* Preset grid */ +.presetGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.presetBtn { + padding: 7px 8px; + border-radius: 8px; + background: var(--surface-high); + border: 1px solid rgba(255,255,255,0.08); + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + text-align: center; +} +.presetBtn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); } +.presetBtn:disabled { opacity: 0.4; cursor: not-allowed; } +.presetBtnLive { color: var(--error) !important; border-color: rgba(248,113,113,0.2) !important; } +.presetBtnDanger { color: #34D399 !important; border-color: rgba(52,211,153,0.2) !important; } + +/* Reset */ +.resetGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +/* Tipps-löschen nimmt volle Breite (dritte Zeile) */ +.resetBtnTips { + grid-column: 1 / -1; +} + +.resetBtn { + padding: 8px 6px; + border-radius: 8px; + background: rgba(75,183,248,0.08); + border: 1px solid rgba(75,183,248,0.18); + color: var(--primary); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.resetBtn:hover:not(:disabled) { background: rgba(75,183,248,0.16); } +.resetBtn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* "Alle Spiele" — etwas warnender */ +.resetBtnAll { + background: rgba(254,174,50,0.08); + border-color: rgba(254,174,50,0.2); + color: var(--gold); +} +.resetBtnAll:hover:not(:disabled) { background: rgba(254,174,50,0.16); } + +/* Tipps löschen — rot */ +.resetBtnTips { + background: rgba(248,113,113,0.08); + border-color: rgba(248,113,113,0.18); + color: var(--error); +} +.resetBtnTips:hover:not(:disabled) { background: rgba(248,113,113,0.18); } + +/* Log */ +.log { + padding: 10px 16px; + background: rgba(0,0,0,0.2); + border-top: 1px solid rgba(255,255,255,0.05); +} +.logLine { + font-size: 10px; + color: var(--text-muted); + font-family: monospace; + line-height: 1.6; +} diff --git a/frontend/src/components/DevPanel.tsx b/frontend/src/components/DevPanel.tsx new file mode 100644 index 0000000..79ec93d --- /dev/null +++ b/frontend/src/components/DevPanel.tsx @@ -0,0 +1,248 @@ +import { useState } from 'react'; +import { api, Match } from '../api/client'; +import styles from './DevPanel.module.css'; + +const DEV_USERS = [ + { id: 1, name: 'Ronny M.', role: 'Editor' }, + { id: 2, name: 'Max M.', role: 'Viewer' }, + { id: 3, name: 'Anna S.', role: 'Viewer' }, +]; + +const TIME_PRESETS = [ + { label: 'In 2 Std.', minutes: 120 }, + { label: 'In 10 Min.', minutes: 10 }, + { label: 'Jetzt +1 Min.', minutes: 1 }, + { label: 'Läuft (−30)', minutes: -30 }, + { label: 'Beendet (−120)', minutes: -120 }, +]; + +const STATUS_PRESETS = [ + { label: 'TIMED', status: 'TIMED', scoreHome: null, scoreAway: null }, + { label: 'LIVE', status: 'IN_PLAY', scoreHome: 0, scoreAway: 0 }, + { label: 'Pause', status: 'PAUSED', scoreHome: 1, scoreAway: 0 }, + { label: '2:1 Fertig', status: 'FINISHED', scoreHome: 2, scoreAway: 1 }, + { label: '0:0 Fertig', status: 'FINISHED', scoreHome: 0, scoreAway: 0 }, +]; + +interface Props { + currentUser: number; + onUserChange: (userId: number) => void; + matches: Match[]; + onRefresh: () => void; +} + +export default function DevPanel({ currentUser, onUserChange, matches, onRefresh }: Props) { + const [open, setOpen] = useState(false); + const [selectedMatch, setSelectedMatch] = useState(''); + const [busy, setBusy] = useState(false); + const [log, setLog] = useState([]); + + function addLog(msg: string) { + setLog(prev => [`${new Date().toLocaleTimeString('de-DE')} ${msg}`, ...prev].slice(0, 8)); + } + + async function applyTime(minutes: number) { + if (!selectedMatch) return; + setBusy(true); + try { + await fetch(`/api/dev/match/${selectedMatch}/set-time`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ minutesFromNow: minutes }), + }); + addLog(`✓ Spiel #${selectedMatch}: Zeit → ${minutes > 0 ? `+${minutes}` : minutes} Min.`); + onRefresh(); + } catch (e) { + addLog(`✗ Fehler: ${(e as Error).message}`); + } finally { + setBusy(false); + } + } + + async function applyStatus(status: string, scoreHome: number | null, scoreAway: number | null) { + if (!selectedMatch) return; + setBusy(true); + try { + await fetch(`/api/dev/match/${selectedMatch}/set-status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status, scoreHome, scoreAway }), + }); + addLog(`✓ Spiel #${selectedMatch}: Status → ${status}`); + onRefresh(); + } catch (e) { + addLog(`✗ Fehler: ${(e as Error).message}`); + } finally { + setBusy(false); + } + } + + async function resetTips() { + setBusy(true); + try { + await fetch('/api/dev/reset-tips', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: `dev-user-00${currentUser}` }), + }); + addLog(`✓ Tipps von User ${currentUser} gelöscht`); + onRefresh(); + } catch (e) { + addLog(`✗ Fehler: ${(e as Error).message}`); + } finally { + setBusy(false); + } + } + + async function resetMatch(all: boolean) { + setBusy(true); + try { + const body = all ? {} : { matchId: selectedMatch }; + const res = await fetch('/api/dev/reset-match', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json() as { count?: number; matchId?: number }; + if (all) { + addLog(`✓ ${data.count ?? 0} Spiele zurückgesetzt (TIMED)`); + } else { + addLog(`✓ Spiel #${selectedMatch} zurückgesetzt (TIMED)`); + } + onRefresh(); + } catch (e) { + addLog(`✗ Fehler: ${(e as Error).message}`); + } finally { + setBusy(false); + } + } + + // Nur erste 20 Spiele zur Auswahl anbieten + const selectableMatches = matches.slice(0, 20); + + return ( +
+ {/* Toggle Button */} + + + {open && ( +
+
+ 🧪 Simulations-Modus +
+ + {/* User Switcher */} +
+
Aktiver User
+
+ {DEV_USERS.map(u => ( + + ))} +
+
+ + {/* Match Selector */} +
+
Spiel auswählen
+ +
+ + {/* Zeit-Manipulation */} +
+
Anstoßzeit setzen
+
+ {TIME_PRESETS.map(p => ( + + ))} +
+
+ + {/* Status-Manipulation */} +
+
Status setzen
+
+ {STATUS_PRESETS.map(p => ( + + ))} +
+
+ + {/* Reset */} +
+
Zurücksetzen
+
+ + + +
+
+ + {/* Log */} + {log.length > 0 && ( +
+ {log.map((l, i) =>
{l}
)} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/MatchCard.module.css b/frontend/src/components/MatchCard.module.css index 4996d55..6ae05a4 100644 --- a/frontend/src/components/MatchCard.module.css +++ b/frontend/src/components/MatchCard.module.css @@ -183,6 +183,13 @@ min-height: 44px; } +/* Wenn tipRow zum Banner wird */ +.tipRow.resultBanner { + border-top: none; + padding-top: 0; + min-height: unset; +} + .tipBtn { width: 100%; max-width: 260px; @@ -191,20 +198,36 @@ letter-spacing: 0.02em; } -/* Existing tip display */ +/* Existing tip display — 3-column grid so center stays centered */ .tipDisplay { - display: flex; + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - gap: 10px; - flex-wrap: wrap; - justify-content: center; + width: 100%; + gap: 8px; +} + +.tipLeft { display: flex; justify-content: flex-start; align-items: center; } +.tipRight { display: flex; justify-content: flex-end; align-items: center; } + +/* Banner-Variante: Icon + Label als Zeile */ +.bannerLeft { + gap: 6px; + flex-direction: row; +} + +.tipCenter { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; } .tipLabel { - font-size: 12px; + font-size: 10px; color: var(--text-muted); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.08em; } .tipScore { @@ -213,21 +236,90 @@ font-size: 17px; color: var(--primary); background: var(--primary-dim); - padding: 3px 12px; + padding: 3px 14px; border-radius: 8px; border: 1px solid rgba(75,183,248,0.15); } -.points { - font-size: 12px; - font-weight: 700; - padding: 4px 10px; - border-radius: 20px; +/* ── Ergebnis-Banner ──────────────────────────────────────────── */ +.resultBanner { + margin: 0 -24px -20px !important; + padding: 10px 24px !important; + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; } -.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); } +.resultIcon { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.resultLabel { + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 12px; + font-weight: 700; +} + +/* tipScore-Variante im Banner: dunkler Hintergrund statt Primary-Blau */ +.tipScoreBanner { + font-family: 'Plus Jakarta Sans', sans-serif; + font-weight: 800; + font-size: 17px; + padding: 3px 14px; + border-radius: 8px; + background: rgba(0,0,0,0.18); + border: 1px solid rgba(255,255,255,0.08); +} + +.resultPoints { + font-size: 14px; + font-weight: 800; + letter-spacing: 0.01em; + opacity: 0.95; +} + +/* Farb-Varianten Banner */ +.exact { + background: linear-gradient(90deg, rgba(52,211,153,0.18) 0%, rgba(52,211,153,0.08) 100%); + color: #4ade80; + border-top: 1px solid rgba(52,211,153,0.20); +} + +.tendency { + background: linear-gradient(90deg, rgba(75,183,248,0.18) 0%, rgba(75,183,248,0.08) 100%); + color: var(--primary); + border-top: 1px solid rgba(75,183,248,0.20); +} + +.wrong { + background: linear-gradient(90deg, rgba(248,113,113,0.15) 0%, rgba(248,113,113,0.06) 100%); + color: var(--error); + border-top: 1px solid rgba(248,113,113,0.18); +} + +/* Card-Glow je Ergebnis */ +.glowExact { + box-shadow: + 0 0 0 1px rgba(52,211,153,0.18), + 0 10px 30px rgba(52,211,153,0.07), + inset 0 1px 0 rgba(255,255,255,0.07) !important; +} + +.glowTendency { + box-shadow: + 0 0 0 1px rgba(75,183,248,0.18), + 0 10px 30px rgba(75,183,248,0.07), + inset 0 1px 0 rgba(255,255,255,0.07) !important; +} + +.glowWrong { + box-shadow: + 0 0 0 1px rgba(248,113,113,0.15), + 0 10px 30px rgba(248,113,113,0.05), + inset 0 1px 0 rgba(255,255,255,0.07) !important; +} .editBtn { background: transparent; diff --git a/frontend/src/components/MatchCard.tsx b/frontend/src/components/MatchCard.tsx index 1bd85d3..393eae0 100644 --- a/frontend/src/components/MatchCard.tsx +++ b/frontend/src/components/MatchCard.tsx @@ -1,3 +1,4 @@ +import { Check, TrendingUp, X } from 'lucide-react'; import { Match } from '../api/client'; import styles from './MatchCard.module.css'; @@ -47,9 +48,19 @@ export default function MatchCard({ match, onTip }: Props) { const isFinished = match.status === 'FINISHED'; const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED'; const hasTip = !!match.userTip; + const points = match.userTip?.points ?? null; + const resultClass = + points === 3 ? styles.exact : + points === 1 ? styles.tendency : + (points === 0 && isFinished) ? styles.wrong : ''; + + const glowClass = + isFinished && points === 3 ? styles.glowExact : + isFinished && points === 1 ? styles.glowTendency : + isFinished && points === 0 ? styles.glowWrong : ''; return ( -
+
{/* Top row: Status / Kickoff / Badges */}
@@ -92,27 +103,56 @@ export default function MatchCard({ match, onTip }: Props) {
- {/* Tipp area */} -
+ {/* Tipp area — wird zum farbigen Banner wenn Punkte ausgewertet */} +
{hasTip ? ( -
- Dein Tipp - - {match.userTip!.home} : {match.userTip!.away} - - {match.userTip!.points !== null && ( - - {match.userTip!.points === 3 ? '🎯 3 Punkte' : - match.userTip!.points === 1 ? '✓ 1 Punkt' : '✗ 0 Punkte'} - - )} - {match.tippable && ( - - )} -
+ points !== null ? ( + /* ── Auswertungs-Banner ── */ +
+ {/* Links: Icon + Ergebnis-Label nebeneinander, zentriert zur Tippbox */} +
+ + {points === 3 ? : + points === 1 ? : + } + + + {points === 3 ? 'Exakt' : points === 1 ? 'Tendenz' : 'Falsch'} + +
+ + {/* Mitte: nur Score, kein Label */} +
+ + {match.userTip!.home} : {match.userTip!.away} + +
+ + {/* Rechts: Punkte */} +
+ + {points === 0 ? '0 Pkt.' : `+${points} Pkt.`} + +
+
+ ) : ( + /* ── Tipp vorhanden, noch nicht ausgewertet ── */ +
+
+ {match.tippable && ( + + )} +
+
+ {/* Label nur zeigen wenn kein Ändern-Button da ist, sonst fluchtet der Button nicht */} + {!match.tippable && DEIN TIPP} + + {match.userTip!.home} : {match.userTip!.away} + +
+
+
+ ) ) : match.tippable ? ( ))}
+ {/* Gruppen-Filter (nur bei Gruppenphase) */} + {availableGroups.length > 0 && ( +
+ + {availableGroups.map(g => ( + + ))} +
+ )} + {/* Content */} {loading && (
@@ -114,20 +173,56 @@ export default function MatchesPage() {
)} - {!loading && !error && Object.entries(grouped).map(([day, dayMatches]) => ( -
-

{day}

-
- {dayMatches.map(match => ( - setSelectedMatch(match)} - /> - ))} -
-
- ))} + {!loading && !error && (() => { + const entries = Object.entries(grouped); + if (stage === 'GROUP_STAGE' && !group) { + entries.sort(([a], [b]) => a.localeCompare(b)); + } + + const todayLabel = new Date().toLocaleDateString('de-DE', { + weekday: 'long', day: 'numeric', month: 'long' + }); + + // "Nächster bevorstehender Tag" als Fallback wenn kein heutiger Spieltag + let scrollTarget: string | null = null; + if (!stage) { + if (entries.find(([l]) => l === todayLabel)) { + scrollTarget = todayLabel; + } else { + const now = Date.now(); + const future = entries.find(([, ms]) => new Date(ms[0].utcDate).getTime() > now); + scrollTarget = future?.[0] ?? null; + } + } + + return entries.map(([label, labelMatches]) => { + const isScrollTarget = label === scrollTarget; + return ( +
+

+ {label === todayLabel ? ( + <>Heute — {label} + ) : label} +

+
+ {labelMatches + .sort((a, b) => new Date(a.utcDate).getTime() - new Date(b.utcDate).getTime()) + .map(match => ( + setSelectedMatch(match)} + /> + ))} +
+
+ ); + }); + })()} {selectedMatch && (