diff --git a/frontend/src/App.js b/frontend/src/App.js index 748c259..d9a5ead 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -8,6 +8,8 @@ import LeaderboardPage from './pages/LeaderboardPage'; import ProfilePage from './pages/ProfilePage'; import AdminPage from './pages/AdminPage'; import BottomNav from './components/BottomNav'; +import Toast from './components/Toast'; +import { useRankChange } from './hooks/useRankChange'; 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 @@ -25,6 +27,7 @@ function getInitialTheme() { } export default function App() { const [theme, setTheme] = useState(getInitialTheme); + const { message: rankMsg, dismiss: dismissRank } = useRankChange(); const [devUser, setDevUser] = useState(1); const [devMatches, setDevMatches] = useState([]); const [refreshKey, setRefreshKey] = useState(0); @@ -65,5 +68,5 @@ export default function App() { 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 }))] })); + 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, {}, refreshKey) }), _jsx(Route, { path: "/spiele", element: _jsx(MatchesPage, {}, refreshKey) }), _jsx(Route, { path: "/rangliste", element: _jsx(LeaderboardPage, {}, refreshKey) }), _jsx(Route, { path: "/profil", element: _jsx(ProfilePage, {}, refreshKey) }), _jsx(Route, { path: "/admin", element: _jsx(AdminPage, {}) })] }) }), rankMsg && _jsx(Toast, { message: rankMsg, onDismiss: dismissRank }), _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/App.tsx b/frontend/src/App.tsx index ca1580b..cfb7329 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -110,8 +110,8 @@ export default function App() {
- } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/ConfettiReveal.js b/frontend/src/components/ConfettiReveal.js new file mode 100644 index 0000000..2e7fdb8 --- /dev/null +++ b/frontend/src/components/ConfettiReveal.js @@ -0,0 +1,18 @@ +import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime"; +import { useEffect, useRef } from 'react'; +import confetti from 'canvas-confetti'; +import styles from './ConfettiReveal.module.css'; +export default function ConfettiReveal({ match, onDismiss }) { + const didFire = useRef(false); + const tip = match.userTip; + const points = tip.points; + useEffect(() => { + if (points === 3 && !didFire.current) { + didFire.current = true; + confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } }); + } + }, [points]); + const resultLabel = points === 3 ? 'EXAKT! πŸŽ‰' : points === 1 ? 'Richtige Tendenz! πŸ‘' : 'Knapp daneben... πŸ˜…'; + const badgeClass = points === 3 ? styles.badgeExact : points === 1 ? styles.badgeTendency : styles.badgeWrong; + return (_jsx("div", { className: styles.overlay, onClick: onDismiss, children: _jsxs("div", { className: styles.card, onClick: e => e.stopPropagation(), children: [_jsxs("div", { className: styles.result, children: [match.homeTeam.shortName, " ", match.score.home, ":", match.score.away, " ", match.awayTeam.shortName] }), _jsxs("div", { className: styles.tipLine, children: ["Dein Tipp: ", tip.home, ":", tip.away] }), _jsxs("div", { className: `${styles.badge} ${badgeClass}`, children: [points, " ", points === 1 ? 'Punkt' : 'Punkte'] }), _jsx("div", { className: styles.label, children: resultLabel }), _jsx("button", { className: styles.dismissBtn, onClick: onDismiss, children: "Weiter" })] }) })); +} diff --git a/frontend/src/components/DevPanel.tsx b/frontend/src/components/DevPanel.tsx index 79ec93d..35732ce 100644 --- a/frontend/src/components/DevPanel.tsx +++ b/frontend/src/components/DevPanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { api, Match } from '../api/client'; +import { Match } from '../api/client'; import styles from './DevPanel.module.css'; const DEV_USERS = [ diff --git a/frontend/src/components/StatsRing.js b/frontend/src/components/StatsRing.js new file mode 100644 index 0000000..368a8a6 --- /dev/null +++ b/frontend/src/components/StatsRing.js @@ -0,0 +1,21 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import styles from './StatsRing.module.css'; +export default function StatsRing({ exact, tendency, wrong, total }) { + const radius = 55; + const circumference = 2 * Math.PI * radius; + const all = exact + tendency + wrong || 1; + const segments = [ + { value: exact / all, color: 'var(--gold)', label: 'Exakt' }, + { value: tendency / all, color: 'var(--success)', label: 'Tendenz' }, + { value: wrong / all, color: 'var(--error)', label: 'Falsch' }, + ]; + let offset = 0; + return (_jsxs("div", { className: styles.ring, children: [_jsxs("svg", { viewBox: "0 0 140 140", className: styles.svg, children: [_jsx("circle", { cx: "70", cy: "70", r: radius, fill: "none", stroke: "var(--surface-high)", strokeWidth: "12" }), segments.map((seg, i) => { + if (seg.value === 0) + return null; + const dashArray = `${seg.value * circumference} ${circumference}`; + const rotation = offset * 360 - 90; + offset += seg.value; + return (_jsx("circle", { cx: "70", cy: "70", r: radius, fill: "none", stroke: seg.color, strokeWidth: "12", strokeDasharray: dashArray, transform: `rotate(${rotation} 70 70)`, strokeLinecap: "round" }, i)); + }), _jsx("text", { x: "70", y: "65", textAnchor: "middle", dominantBaseline: "central", fill: "var(--text-primary)", fontSize: "28", fontWeight: "700", children: total }), _jsx("text", { x: "70", y: "85", textAnchor: "middle", fill: "var(--text-secondary)", fontSize: "11", children: "Punkte" })] }), _jsx("div", { className: styles.legend, children: segments.map((seg, i) => (_jsxs("span", { className: styles.legendItem, children: [_jsx("span", { className: styles.dot, style: { background: seg.color } }), seg.label, ": ", Math.round(seg.value * all)] }, i))) })] })); +} diff --git a/frontend/src/components/Toast.js b/frontend/src/components/Toast.js new file mode 100644 index 0000000..262edcb --- /dev/null +++ b/frontend/src/components/Toast.js @@ -0,0 +1,10 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +import { useEffect } from 'react'; +import styles from './Toast.module.css'; +export default function Toast({ message, onDismiss, duration = 5000 }) { + useEffect(() => { + const timer = setTimeout(onDismiss, duration); + return () => clearTimeout(timer); + }, [onDismiss, duration]); + return (_jsx("div", { className: styles.toast, onClick: onDismiss, children: message })); +} diff --git a/frontend/src/hooks/useRankChange.js b/frontend/src/hooks/useRankChange.js new file mode 100644 index 0000000..6ebbd38 --- /dev/null +++ b/frontend/src/hooks/useRankChange.js @@ -0,0 +1,24 @@ +import { useState, useEffect } from 'react'; +import { api } from '../api/client'; +const RANK_KEY = 'tippspiel_last_rank'; +export function useRankChange() { + const [message, setMessage] = useState(null); + useEffect(() => { + api.getMyStats().then(stats => { + if (!stats.rank) + return; + const lastRank = parseInt(localStorage.getItem(RANK_KEY) || '0'); + if (lastRank > 0 && lastRank !== stats.rank) { + if (stats.rank < lastRank) { + setMessage(`⬆️ Du bist auf Platz ${stats.rank} aufgestiegen!`); + } + else { + setMessage(`⬇️ Du bist auf Platz ${stats.rank} gerutscht β€” hol dir die Punkte zurΓΌck!`); + } + } + localStorage.setItem(RANK_KEY, String(stats.rank)); + }).catch(() => { }); + }, []); + function dismiss() { setMessage(null); } + return { message, dismiss }; +} diff --git a/frontend/src/hooks/useRevealQueue.js b/frontend/src/hooks/useRevealQueue.js new file mode 100644 index 0000000..b4215e4 --- /dev/null +++ b/frontend/src/hooks/useRevealQueue.js @@ -0,0 +1,30 @@ +import { useState, useEffect } from 'react'; +const SEEN_KEY = 'tippspiel_seen_results'; +function getSeenIds() { + try { + return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) || '[]')); + } + catch { + return new Set(); + } +} +function markSeen(matchId) { + const seen = getSeenIds(); + seen.add(matchId); + localStorage.setItem(SEEN_KEY, JSON.stringify([...seen])); +} +export function useRevealQueue(matches) { + const [queue, setQueue] = useState([]); + useEffect(() => { + const seen = getSeenIds(); + const unseen = matches.filter(m => m.status === 'FINISHED' && m.userTip && m.userTip.points !== null && !seen.has(m.id)); + setQueue(unseen); + }, [matches]); + function dismissCurrent() { + if (queue.length === 0) + return; + markSeen(queue[0].id); + setQueue(q => q.slice(1)); + } + return { current: queue[0] || null, remaining: queue.length, dismissCurrent }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index e2cc7fb..9ed1226 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -27,6 +27,9 @@ --shadow-card: 0 10px 25px rgba(0,0,0,0.25); --card-shine: rgba(255,255,255,0.04); --scrollbar-bg: var(--surface-high); + --primary-rgb: 75, 183, 248; + --transition-fast: 0.15s ease; + --transition-normal: 0.3s ease; } /* --- Light Mode --- */ @@ -50,6 +53,7 @@ --shadow-card: 0 4px 16px rgba(0,0,0,0.10); --card-shine: rgba(255,255,255,0.7); --scrollbar-bg: var(--surface-high); + --primary-rgb: 26, 143, 227; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } diff --git a/frontend/src/pages/DashboardPage.js b/frontend/src/pages/DashboardPage.js index eff6864..66a3eb6 100644 --- a/frontend/src/pages/DashboardPage.js +++ b/frontend/src/pages/DashboardPage.js @@ -3,6 +3,17 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '../api/client'; import styles from './DashboardPage.module.css'; +function formatStreak(streak) { + if (streak >= 20) + return `⚑${streak}`; + if (streak >= 10) + return `πŸ”₯πŸ”₯${streak}`; + if (streak >= 3) + return `πŸ”₯${streak}`; + if (streak > 0) + return String(streak); + return '0'; +} function formatCountdown(minutes) { if (minutes < 60) return `in ${minutes} Min`; @@ -27,7 +38,7 @@ export default function DashboardPage(_props) { if (error || !data) return _jsx("div", { className: styles.error, children: "Dashboard konnte nicht geladen werden." }); const { hero, stats, nudges } = data; - return (_jsxs("div", { className: styles.dashboard, children: [_jsxs("div", { className: styles.hero, onClick: () => navigate('/spiele'), children: [_jsxs("div", { className: styles.heroLabel, children: [_jsx("span", { children: "N\u00E4chstes Spiel" }), hero && (_jsx("span", { className: styles.heroCountdown, children: formatCountdown(hero.match.minutesUntilKickoff) }))] }), hero ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.heroTeams, children: [_jsxs("div", { className: styles.heroTeam, children: [hero.match.homeTeam.crest ? (_jsx("img", { src: hero.match.homeTeam.crest, alt: hero.match.homeTeam.name, className: styles.heroCrest })) : (_jsx("div", { className: styles.heroCrest })), _jsx("span", { children: hero.match.homeTeam.shortName })] }), _jsx("span", { className: styles.heroVs, children: "vs" }), _jsxs("div", { className: styles.heroTeam, children: [hero.match.awayTeam.crest ? (_jsx("img", { src: hero.match.awayTeam.crest, alt: hero.match.awayTeam.name, className: styles.heroCrest })) : (_jsx("div", { className: styles.heroCrest })), _jsx("span", { children: hero.match.awayTeam.shortName })] })] }), hero.userTip ? (_jsxs("div", { className: styles.heroTip, children: ["Dein Tipp: ", hero.userTip.home, ":", hero.userTip.away, " \u2713"] })) : hero.tippable ? (_jsx("button", { className: styles.heroTipBtn, onClick: e => { e.stopPropagation(); navigate('/spiele'); }, children: "Jetzt tippen" })) : null] })) : (_jsx("p", { style: { textAlign: 'center', color: 'var(--text-muted)', margin: '16px 0' }, children: "Keine anstehenden Spiele" }))] }), _jsxs("div", { className: styles.statsRow, children: [_jsxs("div", { className: styles.statTile, children: [_jsx("span", { className: styles.statValue, children: stats.rank !== null ? stats.rank : 'β€”' }), _jsx("span", { className: styles.statLabel, children: "Dein Rang" })] }), _jsxs("div", { className: styles.statTile, children: [_jsx("span", { className: styles.statValue, children: stats.totalPoints }), _jsx("span", { className: styles.statLabel, children: "Punkte" })] }), _jsxs("div", { className: styles.statTile, children: [_jsx("span", { className: styles.statValue, children: stats.streak > 0 ? `${stats.streak} πŸ”₯` : stats.streak }), _jsx("span", { className: styles.statLabel, children: "Streak" })] })] }), nudges.length > 0 && (_jsx("div", { className: styles.nudges, children: nudges.map((nudge, i) => (_jsx("div", { className: styles.nudge, onClick: () => { + return (_jsxs("div", { className: styles.dashboard, children: [_jsxs("div", { className: styles.hero, onClick: () => navigate('/spiele'), children: [_jsxs("div", { className: styles.heroLabel, children: [_jsx("span", { children: "N\u00E4chstes Spiel" }), hero && (_jsx("span", { className: styles.heroCountdown, children: formatCountdown(hero.match.minutesUntilKickoff) }))] }), hero ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.heroTeams, children: [_jsxs("div", { className: styles.heroTeam, children: [hero.match.homeTeam.crest ? (_jsx("img", { src: hero.match.homeTeam.crest, alt: hero.match.homeTeam.name, className: styles.heroCrest })) : (_jsx("div", { className: styles.heroCrest })), _jsx("span", { children: hero.match.homeTeam.shortName })] }), _jsx("span", { className: styles.heroVs, children: "vs" }), _jsxs("div", { className: styles.heroTeam, children: [hero.match.awayTeam.crest ? (_jsx("img", { src: hero.match.awayTeam.crest, alt: hero.match.awayTeam.name, className: styles.heroCrest })) : (_jsx("div", { className: styles.heroCrest })), _jsx("span", { children: hero.match.awayTeam.shortName })] })] }), hero.userTip ? (_jsxs("div", { className: styles.heroTip, children: ["Dein Tipp: ", hero.userTip.home, ":", hero.userTip.away, " \u2713"] })) : hero.tippable ? (_jsx("button", { className: styles.heroTipBtn, onClick: e => { e.stopPropagation(); navigate('/spiele'); }, children: "Jetzt tippen" })) : null] })) : (_jsx("p", { style: { textAlign: 'center', color: 'var(--text-muted)', margin: '16px 0' }, children: "Keine anstehenden Spiele" }))] }), _jsxs("div", { className: styles.statsRow, children: [_jsxs("div", { className: styles.statTile, children: [_jsx("span", { className: styles.statValue, children: stats.rank !== null ? stats.rank : 'β€”' }), _jsx("span", { className: styles.statLabel, children: "Dein Rang" })] }), _jsxs("div", { className: styles.statTile, children: [_jsx("span", { className: styles.statValue, children: stats.totalPoints }), _jsx("span", { className: styles.statLabel, children: "Punkte" })] }), _jsxs("div", { className: styles.statTile, children: [_jsx("span", { className: styles.statValue, children: formatStreak(stats.streak) }), _jsx("span", { className: styles.statLabel, children: "Streak" })] })] }), nudges.length > 0 && (_jsx("div", { className: styles.nudges, children: nudges.map((nudge, i) => (_jsx("div", { className: styles.nudge, onClick: () => { if (nudge.type === 'untipped') navigate('/spiele'); else if (nudge.type === 'leader') diff --git a/frontend/src/pages/MatchesPage.js b/frontend/src/pages/MatchesPage.js index 8a1ed6e..583d386 100644 --- a/frontend/src/pages/MatchesPage.js +++ b/frontend/src/pages/MatchesPage.js @@ -3,6 +3,8 @@ import { useState, useEffect, useCallback } from 'react'; import { api } from '../api/client'; import MatchCard from '../components/MatchCard'; import TipModal from '../components/TipModal'; +import { useRevealQueue } from '../hooks/useRevealQueue'; +import ConfettiReveal from '../components/ConfettiReveal'; import styles from './MatchesPage.module.css'; function groupIntoSections(matches) { const now = new Date(); @@ -88,10 +90,11 @@ export default function MatchesPage() { return next; }); } + const { current: revealMatch, dismissCurrent } = useRevealQueue(allMatches); const filteredMatches = allMatches.filter(m => !stageFilter || m.stage === stageFilter); const sections = groupIntoSections(filteredMatches); // Stats always over all matches (unfiltered) const tipped = allMatches.filter(m => m.userTip).length; const tippable = allMatches.filter(m => m.tippable && !m.userTip).length; - return (_jsxs("div", { className: styles.page, children: [_jsxs("div", { className: styles.statsRow, children: [_jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: styles.statValue, children: allMatches.length }), _jsx("span", { className: styles.statLabel, children: "Spiele gesamt" })] }), _jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: `${styles.statValue} text-primary`, children: tipped }), _jsx("span", { className: styles.statLabel, children: "Tipps abgegeben" })] }), _jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: `${styles.statValue} text-gold`, children: tippable }), _jsx("span", { className: styles.statLabel, children: "Noch tippbar" })] })] }), _jsxs("select", { className: styles.stageFilter, value: stageFilter, onChange: e => setStageFilter(e.target.value), children: [_jsx("option", { value: "", children: "Alle Phasen" }), _jsx("option", { value: "GROUP_STAGE", children: "Gruppenphase" }), _jsx("option", { value: "ROUND_OF_32", children: "Runde der 32" }), _jsx("option", { value: "LAST_16", children: "Achtelfinale" }), _jsx("option", { value: "QUARTER_FINALS", children: "Viertelfinale" }), _jsx("option", { value: "SEMI_FINALS", children: "Halbfinale" }), _jsx("option", { value: "THIRD_PLACE", children: "Platz 3" }), _jsx("option", { value: "FINAL", children: "Finale" })] }), loading && (_jsxs("div", { className: styles.loadingState, children: [_jsx("div", { className: styles.spinner }), _jsx("span", { children: "Spiele werden geladen\u2026" })] })), error && (_jsxs("div", { className: styles.errorState, children: [_jsxs("span", { children: ["\u26A0\uFE0F ", error] }), _jsx("button", { className: "btn-ghost", onClick: loadMatches, children: "Erneut versuchen" })] })), !loading && !error && filteredMatches.length === 0 && (_jsxs("div", { className: styles.emptyState, children: [_jsx("span", { className: styles.emptyIcon, children: "\u26BD" }), _jsx("p", { children: "Noch keine Spiele vorhanden." }), _jsx("p", { className: styles.emptyHint, children: "Geh auf die Admin-Seite und klicke \"Spiele synchronisieren\"." })] })), !loading && !error && sections.map(section => (_jsxs("div", { className: `${styles.section} ${section.highlight ? styles.sectionHighlight : ''}`, children: [_jsxs("button", { className: styles.sectionHeader, onClick: () => toggleSection(section.key), children: [_jsx("span", { className: styles.sectionLabel, children: section.label }), _jsxs("span", { className: styles.sectionCount, children: [section.matches.length, " Spiele"] }), _jsx("span", { className: styles.sectionChevron, children: openSections.has(section.key) ? 'β–Ύ' : 'β–Έ' })] }), openSections.has(section.key) && (_jsx("div", { className: styles.sectionContent, children: section.matches.map(match => (_jsx(MatchCard, { match: match, onTip: () => setSelectedMatch(match) }, match.id))) }))] }, section.key))), selectedMatch && (_jsx(TipModal, { match: selectedMatch, onClose: () => setSelectedMatch(null), onSaved: handleTipSaved }))] })); + return (_jsxs("div", { className: styles.page, children: [revealMatch && (_jsx(ConfettiReveal, { match: revealMatch, onDismiss: dismissCurrent })), _jsxs("div", { className: styles.statsRow, children: [_jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: styles.statValue, children: allMatches.length }), _jsx("span", { className: styles.statLabel, children: "Spiele gesamt" })] }), _jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: `${styles.statValue} text-primary`, children: tipped }), _jsx("span", { className: styles.statLabel, children: "Tipps abgegeben" })] }), _jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: `${styles.statValue} text-gold`, children: tippable }), _jsx("span", { className: styles.statLabel, children: "Noch tippbar" })] })] }), _jsxs("select", { className: styles.stageFilter, value: stageFilter, onChange: e => setStageFilter(e.target.value), children: [_jsx("option", { value: "", children: "Alle Phasen" }), _jsx("option", { value: "GROUP_STAGE", children: "Gruppenphase" }), _jsx("option", { value: "ROUND_OF_32", children: "Runde der 32" }), _jsx("option", { value: "LAST_16", children: "Achtelfinale" }), _jsx("option", { value: "QUARTER_FINALS", children: "Viertelfinale" }), _jsx("option", { value: "SEMI_FINALS", children: "Halbfinale" }), _jsx("option", { value: "THIRD_PLACE", children: "Platz 3" }), _jsx("option", { value: "FINAL", children: "Finale" })] }), loading && (_jsxs("div", { className: styles.loadingState, children: [_jsx("div", { className: styles.spinner }), _jsx("span", { children: "Spiele werden geladen\u2026" })] })), error && (_jsxs("div", { className: styles.errorState, children: [_jsxs("span", { children: ["\u26A0\uFE0F ", error] }), _jsx("button", { className: "btn-ghost", onClick: loadMatches, children: "Erneut versuchen" })] })), !loading && !error && filteredMatches.length === 0 && (_jsxs("div", { className: styles.emptyState, children: [_jsx("span", { className: styles.emptyIcon, children: "\u26BD" }), _jsx("p", { children: "Noch keine Spiele vorhanden." }), _jsx("p", { className: styles.emptyHint, children: "Geh auf die Admin-Seite und klicke \"Spiele synchronisieren\"." })] })), !loading && !error && sections.map(section => (_jsxs("div", { className: `${styles.section} ${section.highlight ? styles.sectionHighlight : ''}`, children: [_jsxs("button", { className: styles.sectionHeader, onClick: () => toggleSection(section.key), children: [_jsx("span", { className: styles.sectionLabel, children: section.label }), _jsxs("span", { className: styles.sectionCount, children: [section.matches.length, " Spiele"] }), _jsx("span", { className: styles.sectionChevron, children: openSections.has(section.key) ? 'β–Ύ' : 'β–Έ' })] }), openSections.has(section.key) && (_jsx("div", { className: styles.sectionContent, children: section.matches.map(match => (_jsx(MatchCard, { match: match, onTip: () => setSelectedMatch(match) }, match.id))) }))] }, section.key))), selectedMatch && (_jsx(TipModal, { match: selectedMatch, onClose: () => setSelectedMatch(null), onSaved: handleTipSaved }))] })); } diff --git a/frontend/src/pages/ProfilePage.js b/frontend/src/pages/ProfilePage.js index f6319e5..02a9946 100644 --- a/frontend/src/pages/ProfilePage.js +++ b/frontend/src/pages/ProfilePage.js @@ -1,21 +1,49 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { useState, useEffect } from 'react'; import { api } from '../api/client'; +import StatsRing from '../components/StatsRing'; import styles from './ProfilePage.module.css'; function initials(name) { return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); } +function mostCommonTip(tips) { + const counts = {}; + for (const t of tips) { + const key = `${t.tip_home}:${t.tip_away}`; + counts[key] = (counts[key] ?? 0) + 1; + } + let best = ''; + let max = 0; + for (const [key, count] of Object.entries(counts)) { + if (count > max) { + max = count; + best = key; + } + } + return best ? `${best} (${max}x getippt)` : 'β€”'; +} +function homeWinPct(tips) { + if (!tips.length) + return 0; + const homeWins = tips.filter(t => t.tip_home > t.tip_away).length; + return Math.round((homeWins / tips.length) * 100); +} export default function ProfilePage() { const [stats, setStats] = useState(null); + const [tips, setTips] = useState([]); const [loading, setLoading] = useState(true); const [teamEdit, setTeamEdit] = useState(false); const [teamValue, setTeamValue] = useState(''); const [teamSaving, setTeamSaving] = useState(false); const [teamMsg, setTeamMsg] = useState(null); useEffect(() => { - api.getMyStats().then(s => { + Promise.all([ + api.getMyStats(), + api.getMyTips(), + ]).then(([s, t]) => { setStats(s); setTeamValue(s.team ?? ''); + setTips(t.tips); }).finally(() => setLoading(false)); }, []); const saveTeam = async () => { @@ -41,10 +69,34 @@ export default function ProfilePage() { return _jsx("div", { className: styles.loading, children: _jsx("div", { className: styles.spinner }) }); if (!stats) return _jsx("div", { className: styles.empty, children: "Profil nicht verf\u00FCgbar." }); - const evaluated = stats.exactCount + stats.tendencyCount + stats.wrongCount; - return (_jsxs("div", { className: styles.page, children: [_jsxs("div", { className: `card ${styles.heroCard}`, children: [_jsx("div", { className: styles.avatar, children: initials(stats.fullName) }), _jsxs("div", { className: styles.heroInfo, children: [_jsx("h1", { className: `font-display ${styles.name}`, children: stats.fullName }), stats.rank && _jsxs("div", { className: styles.rankBadge, children: ["\uD83C\uDFC6 Platz ", stats.rank] }), _jsx("div", { className: styles.teamRow, children: teamEdit ? (_jsxs("div", { className: styles.teamEditRow, children: [_jsx("input", { className: styles.teamInput, value: teamValue, onChange: e => setTeamValue(e.target.value), placeholder: "z. B. Vertrieb S\u00FCd", maxLength: 80, autoFocus: true, onKeyDown: e => { if (e.key === 'Enter') - saveTeam(); if (e.key === 'Escape') - setTeamEdit(false); } }), _jsx("button", { className: styles.teamSaveBtn, onClick: saveTeam, disabled: teamSaving, children: teamSaving ? _jsx("span", { className: styles.spinnerSm }) : 'βœ“' }), _jsx("button", { className: styles.teamCancelBtn, onClick: () => { setTeamEdit(false); setTeamValue(stats.team ?? ''); }, children: "\u2715" })] })) : (_jsx("button", { className: styles.teamBtn, onClick: () => setTeamEdit(true), children: stats.team + const evaluatedTips = tips.filter(t => t.points !== null); + const recentTips = evaluatedTips.slice(0, 10); + const favTip = mostCommonTip(tips); + const homePct = homeWinPct(tips); + function pointBadgeClass(points) { + if (points === null) + return ''; + if (points >= 3) + return styles.badgeExact; + if (points >= 1) + return styles.badgeTendency; + return styles.badgeWrong; + } + function pointLabel(points) { + if (points === null) + return ''; + if (points >= 3) + return `${points} Pkt βœ“βœ“`; + if (points >= 1) + return `${points} Pkt βœ“`; + return `${points} Pkt βœ—`; + } + return (_jsxs("div", { className: styles.page, children: [_jsxs("div", { className: `card ${styles.heroCard}`, children: [_jsx("div", { className: styles.avatar, children: initials(stats.fullName) }), _jsxs("div", { className: styles.heroInfo, children: [_jsx("h1", { className: `font-display ${styles.name}`, children: stats.fullName }), stats.rank && _jsxs("div", { className: styles.rankBadge, children: ["\uD83C\uDFC6 Platz ", stats.rank] }), _jsx("div", { className: styles.teamRow, children: teamEdit ? (_jsxs("div", { className: styles.teamEditRow, children: [_jsx("input", { className: styles.teamInput, value: teamValue, onChange: e => setTeamValue(e.target.value), placeholder: "z. B. Vertrieb S\u00FCd", maxLength: 80, autoFocus: true, onKeyDown: e => { + if (e.key === 'Enter') + saveTeam(); + if (e.key === 'Escape') + setTeamEdit(false); + } }), _jsx("button", { className: styles.teamSaveBtn, onClick: saveTeam, disabled: teamSaving, children: teamSaving ? _jsx("span", { className: styles.spinnerSm }) : 'βœ“' }), _jsx("button", { className: styles.teamCancelBtn, onClick: () => { setTeamEdit(false); setTeamValue(stats.team ?? ''); }, children: "\u2715" })] })) : (_jsx("button", { className: styles.teamBtn, onClick: () => setTeamEdit(true), children: stats.team ? _jsxs(_Fragment, { children: [_jsx("span", { className: styles.teamName, children: stats.team }), _jsx("span", { className: styles.teamEditHint, children: "bearbeiten" })] }) - : _jsx("span", { className: styles.teamPlaceholder, children: "+ Team hinzuf\u00FCgen" }) })) }), teamMsg && (_jsx("div", { className: `${styles.teamMsg} ${teamMsg.ok ? styles.teamMsgOk : styles.teamMsgErr}`, children: teamMsg.text }))] }), _jsxs("div", { className: styles.heroPoints, children: [_jsx("span", { className: `font-display ${styles.pointsVal}`, children: stats.totalPoints }), _jsx("span", { className: styles.pointsLbl, children: "Punkte" })] })] }), _jsxs("div", { className: styles.statsGrid, children: [_jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: `font-display ${styles.statVal} text-gold`, children: stats.exactCount }), _jsx("span", { className: styles.statLbl, children: "\uD83C\uDFAF Exakt" })] }), _jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: `font-display ${styles.statVal} text-primary`, children: stats.tendencyCount }), _jsx("span", { className: styles.statLbl, children: "\u2713 Tendenz" })] }), _jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: `font-display ${styles.statVal}`, style: { color: 'var(--error)' }, children: stats.wrongCount }), _jsx("span", { className: styles.statLbl, children: "\u2717 Falsch" })] }), _jsxs("div", { className: `card ${styles.statCard}`, children: [_jsx("span", { className: `font-display ${styles.statVal}`, children: stats.tipsCount }), _jsx("span", { className: styles.statLbl, children: "Tipps gesamt" })] })] }), evaluated > 0 && (_jsxs("div", { className: `card ${styles.accuracyCard}`, children: [_jsxs("div", { className: styles.accuracyHeader, children: [_jsx("span", { className: styles.accuracyLabel, children: "Trefferquote" }), _jsxs("span", { className: `font-display ${styles.accuracyVal}`, children: [stats.accuracy, "%"] })] }), _jsxs("div", { className: styles.bar, children: [_jsx("div", { className: `${styles.barFill} ${styles.exact}`, style: { width: `${(stats.exactCount / evaluated) * 100}%` } }), _jsx("div", { className: `${styles.barFill} ${styles.tendency}`, style: { width: `${(stats.tendencyCount / evaluated) * 100}%` } })] }), _jsxs("div", { className: styles.barLegend, children: [_jsxs("span", { children: [_jsx("span", { className: styles.dot, style: { background: 'var(--gold)' } }), " Exakt"] }), _jsxs("span", { children: [_jsx("span", { className: styles.dot, style: { background: 'var(--primary)' } }), " Tendenz"] }), _jsxs("span", { children: [_jsx("span", { className: styles.dot, style: { background: 'var(--surface-high)' } }), " Falsch"] })] })] }))] })); + : _jsx("span", { className: styles.teamPlaceholder, children: "+ Team hinzuf\u00FCgen" }) })) }), teamMsg && (_jsx("div", { className: `${styles.teamMsg} ${teamMsg.ok ? styles.teamMsgOk : styles.teamMsgErr}`, children: teamMsg.text }))] })] }), _jsxs("div", { className: `card ${styles.ringCard}`, children: [_jsx("h2", { className: styles.sectionTitle, children: "Tipp-Statistik" }), _jsx(StatsRing, { exact: stats.exactCount, tendency: stats.tendencyCount, wrong: stats.wrongCount, total: stats.totalPoints }), stats.accuracy > 0 && (_jsxs("div", { className: styles.accuracyRow, children: [_jsx("span", { className: styles.accuracyLabel, children: "Trefferquote" }), _jsxs("span", { className: `font-display ${styles.accuracyVal}`, children: [stats.accuracy, "%"] })] }))] }), recentTips.length > 0 && (_jsxs("div", { className: `card ${styles.historyCard}`, children: [_jsx("h2", { className: styles.sectionTitle, children: "Letzte Tipps" }), _jsx("ul", { className: styles.tipList, children: recentTips.map((tip, i) => (_jsxs("li", { className: `${styles.tipRow} ${i % 2 === 1 ? styles.tipRowAlt : ''}`, children: [_jsxs("span", { className: styles.tipMatch, children: [tip.home_team_short, " vs ", tip.away_team_short] }), _jsxs("span", { className: styles.tipScore, children: ["Tipp: ", tip.tip_home, ":", tip.tip_away] }), _jsx("span", { className: `${styles.pointBadge} ${pointBadgeClass(tip.points)}`, children: pointLabel(tip.points) })] }, tip.match_id))) })] })), tips.length > 0 && (_jsxs("div", { className: styles.funStats, children: [_jsx("h2", { className: styles.sectionTitle, children: "Fun Facts" }), _jsxs("div", { className: styles.funGrid, children: [_jsxs("div", { className: `card ${styles.funCard}`, children: [_jsx("span", { className: styles.funIcon, children: "\uD83C\uDFAF" }), _jsx("span", { className: styles.funLabel, children: "Lieblings-Tipp" }), _jsx("span", { className: styles.funValue, children: favTip })] }), _jsxs("div", { className: `card ${styles.funCard}`, children: [_jsx("span", { className: styles.funIcon, children: "\uD83C\uDFE0" }), _jsx("span", { className: styles.funLabel, children: "Heimsiege getippt" }), _jsxs("span", { className: styles.funValue, children: [homePct, "%"] })] }), _jsxs("div", { className: `card ${styles.funCard}`, children: [_jsx("span", { className: styles.funIcon, children: "\uD83D\uDCCA" }), _jsx("span", { className: styles.funLabel, children: "Tipps abgegeben" }), _jsx("span", { className: styles.funValue, children: stats.tipsCount })] })] })] }))] })); }