From 950f51c61b9326ee0139c224c7e46e4a700176f6 Mon Sep 17 00:00:00 2001 From: Ronny Date: Sat, 11 Apr 2026 19:08:39 +0200 Subject: [PATCH] feat: tip confirmation animation with haptic feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Success overlay with animated checkmark and 'Dein Tipp ist drin! 🎯' message. Haptic vibration on mobile. Auto-closes after 1.2s. - Add showSuccess state to TipModal - Trigger vibration feedback on successful submit - Display success overlay with popIn animation for checkmark - Auto-close modal after success animation completes - Add CSS animations (fadeIn, popIn) to TipModal.module.css Co-Authored-By: Claude Opus 4.6 (1M context) --- .../4751-1775923699/state/server-info | 1 - .../4751-1775923699/state/server-stopped | 1 + .../4751-1775923699/state/server.log | 1 + frontend/src/App.js | 69 ++++++++++ frontend/src/api/client.js | 48 +++++++ frontend/src/components/BottomNav.js | 8 ++ frontend/src/components/DevPanel.js | 120 ++++++++++++++++++ frontend/src/components/MatchCard.js | 85 +++++++++++++ frontend/src/components/TipModal.js | 53 ++++++++ frontend/src/components/TipModal.module.css | 39 ++++++ frontend/src/components/TipModal.tsx | 16 ++- frontend/src/main.js | 7 + frontend/src/pages/AdminPage.js | 62 +++++++++ frontend/src/pages/DashboardPage.js | 36 ++++++ frontend/src/pages/LeaderboardPage.js | 55 ++++++++ frontend/src/pages/MatchesPage.js | 97 ++++++++++++++ frontend/src/pages/ProfilePage.js | 50 ++++++++ 17 files changed, 746 insertions(+), 2 deletions(-) delete mode 100644 .superpowers/brainstorm/4751-1775923699/state/server-info create mode 100644 .superpowers/brainstorm/4751-1775923699/state/server-stopped create mode 100644 frontend/src/App.js create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/components/BottomNav.js create mode 100644 frontend/src/components/DevPanel.js create mode 100644 frontend/src/components/MatchCard.js create mode 100644 frontend/src/components/TipModal.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/pages/AdminPage.js create mode 100644 frontend/src/pages/DashboardPage.js create mode 100644 frontend/src/pages/LeaderboardPage.js create mode 100644 frontend/src/pages/MatchesPage.js create mode 100644 frontend/src/pages/ProfilePage.js diff --git a/.superpowers/brainstorm/4751-1775923699/state/server-info b/.superpowers/brainstorm/4751-1775923699/state/server-info deleted file mode 100644 index fb69fe6..0000000 --- a/.superpowers/brainstorm/4751-1775923699/state/server-info +++ /dev/null @@ -1 +0,0 @@ -{"type":"server-started","port":52250,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:52250","screen_dir":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content","state_dir":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/state"} diff --git a/.superpowers/brainstorm/4751-1775923699/state/server-stopped b/.superpowers/brainstorm/4751-1775923699/state/server-stopped new file mode 100644 index 0000000..795ce5c --- /dev/null +++ b/.superpowers/brainstorm/4751-1775923699/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1775926519574} diff --git a/.superpowers/brainstorm/4751-1775923699/state/server.log b/.superpowers/brainstorm/4751-1775923699/state/server.log index 9c4d273..108608f 100644 --- a/.superpowers/brainstorm/4751-1775923699/state/server.log +++ b/.superpowers/brainstorm/4751-1775923699/state/server.log @@ -35,3 +35,4 @@ {"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"} {"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"} {"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"} +{"type":"server-stopped","reason":"idle timeout"} diff --git a/frontend/src/App.js b/frontend/src/App.js new file mode 100644 index 0000000..748c259 --- /dev/null +++ b/frontend/src/App.js @@ -0,0 +1,69 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useState, useEffect } from 'react'; +import { Routes, Route, NavLink } from 'react-router-dom'; +import { Sun, Moon, Settings } from 'lucide-react'; +import DashboardPage from './pages/DashboardPage'; +import MatchesPage from './pages/MatchesPage'; +import LeaderboardPage from './pages/LeaderboardPage'; +import ProfilePage from './pages/ProfilePage'; +import AdminPage from './pages/AdminPage'; +import BottomNav from './components/BottomNav'; +import styles from './App.module.css'; +const IS_DEV = import.meta.env.DEV || import.meta.env.VITE_TEST_MODE === 'true'; +// Lazy-load DevPanel in Development/Test-Mode +let DevPanel = null; +// VITE_TEST_MODE wird erst zur Laufzeit geprüft, daher Import immer einbinden +import('./components/DevPanel').then(m => { DevPanel = m.default; }).catch(() => { }); +function getInitialTheme() { + try { + const stored = localStorage.getItem('theme'); + if (stored === 'light' || stored === 'dark') + return stored; + } + catch { } + return 'dark'; +} +export default function App() { + const [theme, setTheme] = useState(getInitialTheme); + const [devUser, setDevUser] = useState(1); + const [devMatches, setDevMatches] = useState([]); + const [refreshKey, setRefreshKey] = useState(0); + // Theme auf setzen und in localStorage speichern + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + try { + localStorage.setItem('theme', theme); + } + catch { } + }, [theme]); + function toggleTheme() { + setTheme(t => t === 'dark' ? 'light' : 'dark'); + } + // 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 (_jsxs("div", { className: styles.app, children: [_jsx("header", { className: styles.header, children: _jsxs("div", { className: styles.headerInner, children: [_jsxs("div", { className: styles.logo, children: [_jsx("span", { className: styles.logoFlag, children: "\uD83C\uDFC6" }), _jsx("span", { className: styles.logoText, children: "WM 2026 Tippspiel" }), IS_DEV && (_jsxs("span", { className: styles.devBadge, children: ["DEV \u00B7 User ", devUser] }))] }), _jsxs("nav", { className: styles.nav, children: [_jsx(NavLink, { to: "/spiele", className: ({ isActive }) => isActive ? styles.navLinkActive : styles.navLink, children: "Spielplan" }), _jsx(NavLink, { to: "/rangliste", className: ({ isActive }) => isActive ? styles.navLinkActive : styles.navLink, children: "Rangliste" }), _jsx(NavLink, { to: "/profil", className: ({ isActive }) => isActive ? styles.navLinkActive : styles.navLink, children: "Mein Profil" }), _jsx(NavLink, { to: "/admin", className: styles.adminLink, title: "Admin", children: _jsx(Settings, { size: 16 }) }), _jsx("button", { className: styles.themeToggle, onClick: toggleTheme, title: theme === 'dark' ? 'Light Mode aktivieren' : 'Dark Mode aktivieren', "aria-label": "Theme wechseln", children: theme === 'dark' ? _jsx(Sun, { size: 16 }) : _jsx(Moon, { size: 16 }) })] })] }) }), _jsx("main", { className: styles.main, children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(DashboardPage, { devUser: devUser }, refreshKey) }), _jsx(Route, { path: "/spiele", element: _jsx(MatchesPage, { devUser: devUser }, refreshKey) }), _jsx(Route, { path: "/rangliste", element: _jsx(LeaderboardPage, {}, refreshKey) }), _jsx(Route, { path: "/profil", element: _jsx(ProfilePage, {}, refreshKey) }), _jsx(Route, { path: "/admin", element: _jsx(AdminPage, {}) })] }) }), _jsx(BottomNav, {}), IS_DEV && DevPanel && (_jsx(DevPanel, { currentUser: devUser, onUserChange: (u) => { setDevUser(u); setRefreshKey(k => k + 1); }, matches: devMatches, onRefresh: handleDevRefresh }))] })); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..c375e65 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,48 @@ +const BASE = '/api'; +function withDevUser(path) { + const devUser = new URLSearchParams(window.location.search).get('devUser'); + if (!devUser) + return path; + const sep = path.includes('?') ? '&' : '?'; + return `${path}${sep}devUser=${devUser}`; +} +async function request(path, options) { + const res = await fetch(`${BASE}${withDevUser(path)}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.message || err.error || 'Request failed'); + } + return res.json(); +} +export const api = { + // Matches + getMatches: (params) => { + const q = new URLSearchParams(params).toString(); + return request(`/matches${q ? '?' + q : ''}`); + }, + // Tips + submitTip: (matchId, tipHome, tipAway) => request('/tips', { + method: 'POST', + body: JSON.stringify({ matchId, tipHome, tipAway }), + }), + getMyTips: () => request('/tips'), + // Leaderboard + getLeaderboard: () => request('/leaderboard'), + getMyStats: () => request('/leaderboard/me'), + // Profile + updateTeam: (team) => request('/profile/team', { + method: 'PATCH', + body: JSON.stringify({ team }), + }), + // Dashboard + getDashboard: () => request('/dashboard'), + // Admin + syncMatches: () => request('/admin/sync', { method: 'POST' }), + evaluateTips: () => request('/admin/evaluate', { method: 'POST' }), +}; diff --git a/frontend/src/components/BottomNav.js b/frontend/src/components/BottomNav.js new file mode 100644 index 0000000..1606780 --- /dev/null +++ b/frontend/src/components/BottomNav.js @@ -0,0 +1,8 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { NavLink } from 'react-router-dom'; +import { Home, Trophy, User } from 'lucide-react'; +import styles from './BottomNav.module.css'; +export default function BottomNav() { + const linkClass = ({ isActive }) => isActive ? styles.tabActive : styles.tab; + return (_jsxs("nav", { className: styles.bottomNav, children: [_jsxs(NavLink, { to: "/", end: true, className: linkClass, children: [_jsx(Home, { size: 20 }), _jsx("span", { children: "Home" })] }), _jsxs(NavLink, { to: "/spiele", className: linkClass, children: [_jsx("span", { className: styles.emojiIcon, children: "\u26BD" }), _jsx("span", { children: "Spiele" })] }), _jsxs(NavLink, { to: "/rangliste", className: linkClass, children: [_jsx(Trophy, { size: 20 }), _jsx("span", { children: "Rangliste" })] }), _jsxs(NavLink, { to: "/profil", className: linkClass, children: [_jsx(User, { size: 20 }), _jsx("span", { children: "Profil" })] })] })); +} diff --git a/frontend/src/components/DevPanel.js b/frontend/src/components/DevPanel.js new file mode 100644 index 0000000..4afc5d6 --- /dev/null +++ b/frontend/src/components/DevPanel.js @@ -0,0 +1,120 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useState } from 'react'; +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 }, +]; +export default function DevPanel({ currentUser, onUserChange, matches, onRefresh }) { + const [open, setOpen] = useState(false); + const [selectedMatch, setSelectedMatch] = useState(''); + const [busy, setBusy] = useState(false); + const [log, setLog] = useState([]); + function addLog(msg) { + setLog(prev => [`${new Date().toLocaleTimeString('de-DE')} ${msg}`, ...prev].slice(0, 8)); + } + async function applyTime(minutes) { + 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.message}`); + } + finally { + setBusy(false); + } + } + async function applyStatus(status, scoreHome, scoreAway) { + 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.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.message}`); + } + finally { + setBusy(false); + } + } + async function resetMatch(all) { + 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(); + if (all) { + addLog(`✓ ${data.count ?? 0} Spiele zurückgesetzt (TIMED)`); + } + else { + addLog(`✓ Spiel #${selectedMatch} zurückgesetzt (TIMED)`); + } + onRefresh(); + } + catch (e) { + addLog(`✗ Fehler: ${e.message}`); + } + finally { + setBusy(false); + } + } + // Nur erste 20 Spiele zur Auswahl anbieten + const selectableMatches = matches.slice(0, 20); + return (_jsxs("div", { className: styles.wrap, children: [_jsxs("button", { className: styles.toggleBtn, onClick: () => setOpen(o => !o), children: [open ? '✕' : '🧪', " ", !open && _jsx("span", { className: styles.toggleLabel, children: "Dev" })] }), open && (_jsxs("div", { className: styles.panel, children: [_jsx("div", { className: styles.panelHeader, children: _jsx("span", { className: styles.panelTitle, children: "\uD83E\uDDEA Simulations-Modus" }) }), _jsxs("section", { className: styles.section, children: [_jsx("div", { className: styles.sectionLabel, children: "Aktiver User" }), _jsx("div", { className: styles.userButtons, children: DEV_USERS.map(u => (_jsxs("button", { className: `${styles.userBtn} ${currentUser === u.id ? styles.userBtnActive : ''}`, onClick: () => { + onUserChange(u.id); + addLog(`→ Wechsel zu User ${u.id}: ${u.name}`); + }, children: [_jsx("span", { className: styles.userInitial, children: u.name.charAt(0) }), _jsx("span", { className: styles.userName, children: u.name }), _jsx("span", { className: styles.userRole, children: u.role })] }, u.id))) })] }), _jsxs("section", { className: styles.section, children: [_jsx("div", { className: styles.sectionLabel, children: "Spiel ausw\u00E4hlen" }), _jsxs("select", { className: styles.select, value: selectedMatch, onChange: e => setSelectedMatch(e.target.value ? parseInt(e.target.value) : ''), children: [_jsx("option", { value: "", children: "\u2014 Spiel w\u00E4hlen \u2014" }), selectableMatches.map(m => (_jsxs("option", { value: m.id, children: ["#", m.id, " ", m.homeTeam.shortName, " vs ", m.awayTeam.shortName] }, m.id)))] })] }), _jsxs("section", { className: `${styles.section} ${!selectedMatch ? styles.sectionDisabled : ''}`, children: [_jsx("div", { className: styles.sectionLabel, children: "Ansto\u00DFzeit setzen" }), _jsx("div", { className: styles.presetGrid, children: TIME_PRESETS.map(p => (_jsx("button", { className: styles.presetBtn, onClick: () => applyTime(p.minutes), disabled: !selectedMatch || busy, children: p.label }, p.label))) })] }), _jsxs("section", { className: `${styles.section} ${!selectedMatch ? styles.sectionDisabled : ''}`, children: [_jsx("div", { className: styles.sectionLabel, children: "Status setzen" }), _jsx("div", { className: styles.presetGrid, children: STATUS_PRESETS.map(p => (_jsx("button", { className: `${styles.presetBtn} ${p.status === 'FINISHED' ? styles.presetBtnDanger : p.status === 'IN_PLAY' ? styles.presetBtnLive : ''}`, onClick: () => applyStatus(p.status, p.scoreHome, p.scoreAway), disabled: !selectedMatch || busy, children: p.label }, p.label))) })] }), _jsxs("section", { className: styles.section, children: [_jsx("div", { className: styles.sectionLabel, children: "Zur\u00FCcksetzen" }), _jsxs("div", { className: styles.resetGrid, children: [_jsx("button", { className: styles.resetBtn, onClick: () => resetMatch(false), disabled: !selectedMatch || busy, title: "Ausgew\u00E4hltes Spiel auf TIMED zur\u00FCcksetzen", children: "\u21BA Spiel zur\u00FCcksetzen" }), _jsx("button", { className: `${styles.resetBtn} ${styles.resetBtnAll}`, onClick: () => resetMatch(true), disabled: busy, title: "Alle laufenden/beendeten Spiele zur\u00FCcksetzen", children: "\u21BA Alle Spiele" }), _jsx("button", { className: `${styles.resetBtn} ${styles.resetBtnTips}`, onClick: resetTips, disabled: busy, children: "\uD83D\uDDD1 Tipps l\u00F6schen" })] })] }), log.length > 0 && (_jsx("div", { className: styles.log, children: log.map((l, i) => _jsx("div", { className: styles.logLine, children: l }, i)) }))] }))] })); +} diff --git a/frontend/src/components/MatchCard.js b/frontend/src/components/MatchCard.js new file mode 100644 index 0000000..29ea743 --- /dev/null +++ b/frontend/src/components/MatchCard.js @@ -0,0 +1,85 @@ +import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime"; +import { Check, TrendingUp, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import styles from './MatchCard.module.css'; +function getCardState(match) { + if (match.status === 'IN_PLAY' || match.status === 'PAUSED') + return 'live'; + if (match.status === 'FINISHED') { + return match.userTip ? 'finished' : 'missed'; + } + // SCHEDULED or TIMED + return match.userTip ? 'tipped' : 'open'; +} +function useCountdown(minutesUntilKickoff) { + const [remaining, setRemaining] = useState(minutesUntilKickoff); + useEffect(() => { + if (minutesUntilKickoff > 60) + return; // only active for <1h + setRemaining(minutesUntilKickoff); + const interval = setInterval(() => { + setRemaining(r => Math.max(0, r - 1 / 60)); + }, 1000); + return () => clearInterval(interval); + }, [minutesUntilKickoff]); + return remaining; +} +const STATUS_LABELS = { + SCHEDULED: 'Geplant', + TIMED: 'Terminiert', + IN_PLAY: 'Live', + PAUSED: 'Pause', + FINISHED: 'Beendet', + POSTPONED: 'Verschoben', + CANCELLED: 'Abgesagt', +}; +function formatKickoff(utcDate) { + return new Date(utcDate).toLocaleString('de-DE', { + hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin', + }) + ' Uhr'; +} +function CountdownBadge({ minutes }) { + if (minutes <= 0) + return null; + if (minutes < 60) + return _jsxs("span", { className: styles.badgeUrgent, children: ["\u26A1 in ", minutes, " Min."] }); + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h < 24) + return _jsxs("span", { className: styles.badge, children: ["in ", h, "h", m > 0 ? ` ${m}m` : ''] }); + const d = Math.floor(h / 24); + return _jsxs("span", { className: styles.badge, children: ["in ", d, " Tag", d > 1 ? 'en' : ''] }); +} +function FlagBox({ crest, name }) { + return (_jsx("div", { className: styles.flagBox, children: crest + ? _jsx("img", { className: styles.crest, src: crest, alt: name }) + : _jsx("span", { style: { fontSize: 18 }, children: "\uD83C\uDFF3\uFE0F" }) })); +} +export default function MatchCard({ match, onTip }) { + const state = getCardState(match); + const remaining = useCountdown(match.minutesUntilKickoff); + const remainingMins = Math.ceil(remaining); + const isFinished = state === 'finished' || state === 'missed'; + const isLive = state === 'live'; + 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 (_jsxs("div", { className: `card ${styles.card} ${styles[`card_${state}`]} ${isLive ? styles.live : ''} ${glowClass}`, children: [_jsxs("div", { className: styles.topRow, children: [_jsxs("span", { className: `${styles.status} ${isLive ? styles.statusLive : ''}`, children: [isLive && _jsx("span", { className: styles.liveDot }), STATUS_LABELS[match.status] ?? match.status] }), match.group && (_jsx("span", { className: styles.group, children: match.group.replace('GROUP_', 'Gruppe ') })), (state === 'open' || state === 'tipped') && match.tippable && (match.minutesUntilKickoff < 60 ? (_jsxs("span", { className: `${styles.countdown} ${remainingMins < 5 ? styles.countdownUrgent : ''}`, children: ["Noch ", remainingMins, " Min!"] })) : (_jsx(CountdownBadge, { minutes: match.minutesUntilKickoff })))] }), _jsxs("div", { className: styles.matchRow, children: [_jsxs("div", { className: styles.teamHome, children: [_jsx("span", { className: styles.teamName, children: match.homeTeam.name }), _jsx(FlagBox, { crest: match.homeTeam.crest, name: match.homeTeam.name })] }), _jsx("div", { className: styles.scoreBox, children: isFinished || isLive ? (_jsxs("div", { className: styles.scoreStack, children: [_jsxs("span", { className: styles.score, children: [match.score.home ?? '–', "\u00A0:\u00A0", match.score.away ?? '–'] }), isLive && hasTip && (_jsxs("span", { className: styles.liveTipCompare, children: ["Tipp: ", match.userTip.home, ":", match.userTip.away] }))] })) : (_jsx("div", { className: styles.kickoffCenter, children: _jsx("span", { className: styles.kickoffCenterTime, children: formatKickoff(match.utcDate) }) })) }), _jsxs("div", { className: styles.teamAway, children: [_jsx(FlagBox, { crest: match.awayTeam.crest, name: match.awayTeam.name }), _jsx("span", { className: styles.teamName, children: match.awayTeam.name })] })] }), _jsx("div", { className: `${styles.tipRow} ${hasTip && points !== null ? `${styles.resultBanner} ${resultClass}` : ''}`, children: state === 'missed' ? ( + /* ── Missed: no tip, match finished ── */ + _jsx("span", { className: styles.missedLabel, children: "Nicht getippt" })) : state === 'live' ? ( + /* ── Live: no tip input, locked ── */ + hasTip ? (_jsxs("div", { className: styles.tipDisplay, children: [_jsx("div", { className: styles.tipLeft }), _jsxs("div", { className: styles.tipCenter, children: [_jsx("span", { className: styles.tipLabel, children: "DEIN TIPP" }), _jsxs("span", { className: styles.tipScore, children: [match.userTip.home, " : ", match.userTip.away] })] }), _jsx("div", { className: styles.tipRight })] })) : (_jsx("span", { className: styles.noTip, children: "Kein Tipp abgegeben" }))) : hasTip ? (points !== null ? ( + /* ── Auswertungs-Banner ── */ + _jsxs("div", { className: styles.tipDisplay, children: [_jsxs("div", { className: `${styles.tipLeft} ${styles.bannerLeft}`, children: [_jsx("span", { className: styles.resultIcon, children: points === 3 ? _jsx(Check, { size: 14, strokeWidth: 3 }) : + points === 1 ? _jsx(TrendingUp, { size: 14, strokeWidth: 2.5 }) : + _jsx(X, { size: 14, strokeWidth: 3 }) }), _jsx("span", { className: styles.resultLabel, children: points === 3 ? 'Exakt' : points === 1 ? 'Tendenz' : 'Falsch' })] }), _jsx("div", { className: styles.tipCenter, children: _jsxs("span", { className: styles.tipScoreBanner, children: [match.userTip.home, " : ", match.userTip.away] }) }), _jsx("div", { className: styles.tipRight, children: _jsx("span", { className: `${styles.pointsBadge} ${points === 3 ? styles.pointsBadge_exact : + points === 1 ? styles.pointsBadge_tendency : + styles.pointsBadge_wrong}`, children: points === 0 ? '0 Pkt.' : `+${points} Pkt.` }) })] })) : ( + /* ── Tipp vorhanden, noch nicht ausgewertet (tipped state) ── */ + _jsxs("div", { className: styles.tipDisplay, children: [_jsx("div", { className: styles.tipLeft, children: match.tippable && (_jsx("button", { className: styles.changeBtn, onClick: onTip, children: "\u00C4ndern" })) }), _jsxs("div", { className: styles.tipCenter, children: [!match.tippable && _jsx("span", { className: styles.tipLabel, children: "DEIN TIPP" }), _jsxs("span", { className: styles.tipDisplay_score, children: [_jsx(Check, { size: 13, strokeWidth: 3, style: { color: 'var(--success)', flexShrink: 0 } }), _jsxs("span", { className: styles.tipScore, children: [match.userTip.home, " : ", match.userTip.away] })] })] }), _jsx("div", { className: styles.tipRight })] }))) : match.tippable ? (_jsx("button", { className: `btn-primary ${styles.tipBtn}`, onClick: onTip, children: "Tipp abgeben" })) : (_jsx("span", { className: styles.noTip, children: "Kein Tipp abgegeben" })) })] })); +} diff --git a/frontend/src/components/TipModal.js b/frontend/src/components/TipModal.js new file mode 100644 index 0000000..0c0ad05 --- /dev/null +++ b/frontend/src/components/TipModal.js @@ -0,0 +1,53 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useState } from 'react'; +import { api } from '../api/client'; +import styles from './TipModal.module.css'; +function getTendency(home, away) { + if (home > away) + return 'home'; + if (away > home) + return 'away'; + return 'draw'; +} +export default function TipModal({ match, onClose, onSaved }) { + const existing = match.userTip; + const [home, setHome] = useState(existing?.home ?? 0); + const [away, setAway] = useState(existing?.away ?? 0); + const [saving, setSaving] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + const [error, setError] = useState(null); + 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); + setError(null); + try { + await api.submitTip(match.id, home, away); + setShowSuccess(true); + if (navigator.vibrate) + navigator.vibrate(50); // haptic feedback + setTimeout(() => { + setShowSuccess(false); + onSaved(match.id, home, away); + onClose(); + }, 1200); + } + catch (e) { + setError(e.message); + setSaving(false); + } + }; + return (_jsx("div", { className: styles.overlay, onClick: onClose, children: _jsxs("div", { className: styles.sheet, onClick: e => e.stopPropagation(), children: [_jsx("div", { className: styles.handle }), _jsxs("div", { className: styles.teamsRow, children: [_jsxs("div", { className: styles.teamBlock, children: [_jsx("div", { className: styles.flagLarge, children: match.homeTeam.crest + ? _jsx("img", { src: match.homeTeam.crest, alt: match.homeTeam.name, className: styles.flagImg }) + : _jsx("span", { className: styles.flagEmoji, children: "\uD83C\uDFF3\uFE0F" }) }), _jsx("span", { className: styles.teamName, children: match.homeTeam.name })] }), _jsx("div", { className: styles.vsBlock }), _jsxs("div", { className: styles.teamBlock, children: [_jsx("div", { className: styles.flagLarge, children: match.awayTeam.crest + ? _jsx("img", { src: match.awayTeam.crest, alt: match.awayTeam.name, className: styles.flagImg }) + : _jsx("span", { className: styles.flagEmoji, children: "\uD83C\uDFF3\uFE0F" }) }), _jsx("span", { className: styles.teamName, children: match.awayTeam.name })] })] }), _jsxs("div", { className: styles.pickerSection, children: [_jsx("p", { className: styles.pickerLabel, children: "Dein Tipp" }), _jsxs("div", { className: styles.pickerRow, children: [_jsx(ScorePicker, { value: home, onChange: setHome }), _jsx("div", { className: styles.pickerColon, children: ":" }), _jsx(ScorePicker, { value: away, onChange: setAway })] })] }), _jsxs("div", { className: styles.tendencyBar, style: { '--tendency-color': tendencyColor }, children: [_jsx("span", { className: styles.tendencyIcon, children: tendency === 'draw' ? '🤝' : tendency === 'home' ? '🏠' : '✈️' }), _jsxs("span", { className: styles.tendencyText, children: ["Tendenz: ", _jsx("strong", { children: tendencyLabel })] })] }), error && _jsx("div", { className: styles.error, children: error }), showSuccess && (_jsxs("div", { className: styles.successOverlay, children: [_jsx("div", { className: styles.successCheck, children: "\u2713" }), _jsx("div", { className: styles.successText, children: "Dein Tipp ist drin! \uD83C\uDFAF" })] })), _jsx("button", { className: `btn-primary ${styles.saveBtn}`, onClick: handleSave, disabled: saving, children: saving ? '⏳ Wird gespeichert…' : '✓ Tipp bestätigen' }), _jsx("button", { className: styles.cancelBtn, onClick: onClose, children: "Abbrechen" })] }) })); +} +function ScorePicker({ value, onChange }) { + return (_jsxs("div", { className: styles.picker, children: [_jsx("button", { className: styles.pickerBtn, onClick: () => onChange(Math.min(20, value + 1)), "aria-label": "Erh\u00F6hen", children: "+" }), _jsx("span", { className: styles.pickerValue, children: value }), _jsx("button", { className: styles.pickerBtn, onClick: () => onChange(Math.max(0, value - 1)), "aria-label": "Verringern", children: "\u2212" })] })); +} diff --git a/frontend/src/components/TipModal.module.css b/frontend/src/components/TipModal.module.css index aeb16be..fdd1e91 100644 --- a/frontend/src/components/TipModal.module.css +++ b/frontend/src/components/TipModal.module.css @@ -300,3 +300,42 @@ } .cancelBtn:hover { color: var(--text-secondary); } + +/* Success overlay animation */ +.successOverlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg-deep) 92%, transparent); + border-radius: inherit; + animation: fadeIn 0.3s ease; + z-index: 10; +} + +.successCheck { + width: 64px; + height: 64px; + border-radius: 50%; + background: var(--success); + color: white; + font-size: 32px; + display: flex; + align-items: center; + justify-content: center; + animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.successText { + margin-top: 12px; + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); +} + +@keyframes popIn { + 0% { transform: scale(0); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} diff --git a/frontend/src/components/TipModal.tsx b/frontend/src/components/TipModal.tsx index 6ad61a7..905af70 100644 --- a/frontend/src/components/TipModal.tsx +++ b/frontend/src/components/TipModal.tsx @@ -21,6 +21,7 @@ export default function TipModal({ match, onClose, onSaved }: Props) { const [home, setHome] = useState(existing?.home ?? 0); const [away, setAway] = useState(existing?.away ?? 0); const [saving, setSaving] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); const [error, setError] = useState(null); const tendency = getTendency(home, away); @@ -38,7 +39,13 @@ export default function TipModal({ match, onClose, onSaved }: Props) { setError(null); try { await api.submitTip(match.id, home, away); - onSaved(match.id, home, away); + setShowSuccess(true); + if (navigator.vibrate) navigator.vibrate(50); // haptic feedback + setTimeout(() => { + setShowSuccess(false); + onSaved(match.id, home, away); + onClose(); + }, 1200); } catch (e) { setError((e as Error).message); setSaving(false); @@ -99,6 +106,13 @@ export default function TipModal({ match, onClose, onSaved }: Props) { {error &&
{error}
} + {showSuccess && ( +
+
+
Dein Tipp ist drin! 🎯
+
+ )} + {/* CTA */}