feat: tip confirmation animation with haptic feedback
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <html> 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 }))] }));
|
||||
}
|
||||
@@ -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' }),
|
||||
};
|
||||
@@ -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" })] })] }));
|
||||
}
|
||||
@@ -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)) }))] }))] }));
|
||||
}
|
||||
@@ -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" })) })] }));
|
||||
}
|
||||
@@ -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" })] }));
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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 && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{showSuccess && (
|
||||
<div className={styles.successOverlay}>
|
||||
<div className={styles.successCheck}>✓</div>
|
||||
<div className={styles.successText}>Dein Tipp ist drin! 🎯</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<button
|
||||
className={`btn-primary ${styles.saveBtn}`}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(_jsx(React.StrictMode, { children: _jsx(BrowserRouter, { children: _jsx(App, {}) }) }));
|
||||
@@ -0,0 +1,62 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import styles from './AdminPage.module.css';
|
||||
export default function AdminPage() {
|
||||
const [syncResult, setSyncResult] = useState(null);
|
||||
const [evalResult, setEvalResult] = useState(null);
|
||||
const [refreshResult, setRefreshResult] = useState(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [evaluating, setEvaluating] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const res = await api.syncMatches();
|
||||
setSyncResult({ success: true, timestamp: new Date(), message: `${res.total} Spiele geladen — ${res.created} neu, ${res.updated} aktualisiert` });
|
||||
}
|
||||
catch (e) {
|
||||
setSyncResult({ success: false, timestamp: new Date(), message: e.message });
|
||||
}
|
||||
finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
const handleEvaluate = async () => {
|
||||
setEvaluating(true);
|
||||
setEvalResult(null);
|
||||
try {
|
||||
const res = await api.evaluateTips();
|
||||
setEvalResult({ success: true, timestamp: new Date(), message: `${res.matchesEvaluated} Spiele ausgewertet — ${res.tipsUpdated} Tipps bewertet` });
|
||||
}
|
||||
catch (e) {
|
||||
setEvalResult({ success: false, timestamp: new Date(), message: e.message });
|
||||
}
|
||||
finally {
|
||||
setEvaluating(false);
|
||||
}
|
||||
};
|
||||
const handleRefreshLeaderboard = async () => {
|
||||
setRefreshing(true);
|
||||
setRefreshResult(null);
|
||||
try {
|
||||
await fetch('/api/admin/refresh-leaderboard', { method: 'POST' });
|
||||
setRefreshResult({ success: true, timestamp: new Date(), message: 'Materialized View aktualisiert' });
|
||||
}
|
||||
catch (e) {
|
||||
setRefreshResult({ success: false, timestamp: new Date(), message: e.message });
|
||||
}
|
||||
finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
function formatTime(d) {
|
||||
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
return (_jsxs("div", { className: styles.page, children: [_jsxs("div", { className: styles.pageHeader, children: [_jsx("h1", { className: `font-display ${styles.title}`, children: "Administration" }), _jsx("div", { className: styles.roleBadge, children: "Editor" })] }), _jsx("p", { className: styles.hint, children: "Nur f\u00FCr Editoren sichtbar. Nach Staffbase-Freischaltung wird diese Seite durch Rollenpr\u00FCfung gesch\u00FCtzt." }), _jsxs("div", { className: styles.cards, children: [_jsx(ActionCard, { icon: "\u21BB", title: "Spiele synchronisieren", desc: "L\u00E4dt alle WM 2026-Spiele von football-data.org und aktualisiert die Datenbank.", result: syncResult, loading: syncing, loadingLabel: "Wird synchronisiert\u2026", actionLabel: "Jetzt synchronisieren", onAction: handleSync, formatTime: formatTime }), _jsx(ActionCard, { icon: "\u25C8", title: "Tipps auswerten", desc: "Berechnet Punkte f\u00FCr alle abgeschlossenen Spiele und aktualisiert die Rangliste.", result: evalResult, loading: evaluating, loadingLabel: "Wird ausgewertet\u2026", actionLabel: "Tipps auswerten", onAction: handleEvaluate, formatTime: formatTime }), _jsx(ActionCard, { icon: "\u27F3", title: "Rangliste aktualisieren", desc: "Aktualisiert die Materialized View manuell \u2014 normalerweise automatisch nach Auswertung.", result: refreshResult, loading: refreshing, loadingLabel: "Wird aktualisiert\u2026", actionLabel: "Rangliste neu berechnen", onAction: handleRefreshLeaderboard, formatTime: formatTime })] })] }));
|
||||
}
|
||||
/* ── Sub-component ── */
|
||||
function ActionCard({ icon, title, desc, result, loading, loadingLabel, actionLabel, onAction, formatTime, }) {
|
||||
return (_jsxs("div", { className: `card ${styles.actionCard}`, children: [_jsxs("div", { className: styles.cardTop, children: [_jsx("div", { className: styles.cardIcon, children: icon }), _jsxs("div", { children: [_jsx("div", { className: styles.cardTitle, children: title }), _jsx("div", { className: styles.cardDesc, children: desc })] })] }), result && (_jsxs("div", { className: `${styles.resultBar} ${result.success ? styles.resultSuccess : styles.resultError}`, children: [_jsx("span", { className: styles.resultDot }), _jsx("span", { className: styles.resultMsg, children: result.message }), _jsx("span", { className: styles.resultTime, children: formatTime(result.timestamp) })] })), _jsx("button", { className: `${styles.actionBtn} ${loading ? styles.actionBtnLoading : ''}`, onClick: onAction, disabled: loading, children: loading ? (_jsxs(_Fragment, { children: [_jsx("span", { className: styles.spinner }), loadingLabel] })) : actionLabel })] }));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import styles from './DashboardPage.module.css';
|
||||
function formatCountdown(minutes) {
|
||||
if (minutes < 60)
|
||||
return `in ${minutes} Min`;
|
||||
if (minutes < 60 * 24)
|
||||
return `in ${Math.floor(minutes / 60)}h`;
|
||||
return `in ${Math.floor(minutes / (60 * 24))} Tagen`;
|
||||
}
|
||||
export default function DashboardPage(_props) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
api.getDashboard()
|
||||
.then(d => { setData(d); setLoading(false); })
|
||||
.catch(() => { setError(true); setLoading(false); });
|
||||
}, []);
|
||||
if (loading)
|
||||
return _jsx("div", { className: styles.loading, children: "Laden..." });
|
||||
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: () => {
|
||||
if (nudge.type === 'untipped')
|
||||
navigate('/spiele');
|
||||
else if (nudge.type === 'leader')
|
||||
navigate('/rangliste');
|
||||
}, children: nudge.text }, i))) }))] }));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import styles from './LeaderboardPage.module.css';
|
||||
function initials(name) {
|
||||
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
function TrendIcon({ entry, prev }) {
|
||||
if (!prev)
|
||||
return _jsx("span", { className: styles.trendNeutral, children: "\u2192" });
|
||||
if (entry.total_points > prev.total_points)
|
||||
return _jsx("span", { className: styles.trendUp, children: "\u2197" });
|
||||
if (entry.total_points < prev.total_points)
|
||||
return _jsx("span", { className: styles.trendDown, children: "\u2198" });
|
||||
return _jsx("span", { className: styles.trendNeutral, children: "\u2192" });
|
||||
}
|
||||
export default function LeaderboardPage() {
|
||||
const [data, setData] = useState(null);
|
||||
const [tippableCount, setTippableCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.getLeaderboard(),
|
||||
api.getMatches(),
|
||||
]).then(([lb, matches]) => {
|
||||
setData(lb);
|
||||
setTippableCount(matches.matches.filter(m => m.tippable && !m.userTip).length);
|
||||
}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
if (loading)
|
||||
return (_jsx("div", { className: styles.loading, children: _jsx("div", { className: styles.spinner }) }));
|
||||
if (!data)
|
||||
return null;
|
||||
const { entries, currentUserId, currentUserRank, totalParticipants } = data;
|
||||
const top3 = entries.slice(0, 3);
|
||||
const rest = entries.slice(3);
|
||||
const podiumSlots = [];
|
||||
if (top3[1])
|
||||
podiumSlots.push({ entry: top3[1], rank: 2, medal: '🥈', colorClass: styles.silver, barHeight: '64px' });
|
||||
if (top3[0])
|
||||
podiumSlots.push({ entry: top3[0], rank: 1, medal: '🥇', colorClass: styles.gold, barHeight: '96px' });
|
||||
if (top3[2])
|
||||
podiumSlots.push({ entry: top3[2], rank: 3, medal: '🥉', colorClass: styles.bronze, barHeight: '48px' });
|
||||
return (_jsxs("div", { className: styles.page, children: [_jsxs("div", { className: styles.pageHeader, children: [_jsx("h1", { className: `font-display ${styles.title}`, children: "Rangliste" }), _jsxs("div", { className: styles.meta, children: [totalParticipants, " Teilnehmer", currentUserRank ? ` · Du: Platz ${currentUserRank}` : ''] })] }), entries.length === 0 ? (_jsx("div", { className: styles.empty, children: "Noch keine Punkte vergeben. Spiele m\u00FCssen erst abgeschlossen sein." })) : (_jsxs(_Fragment, { children: [top3.length > 0 && (_jsx("div", { className: styles.podiumWrap, children: podiumSlots.map(({ entry, rank, medal, colorClass, barHeight }) => {
|
||||
const isMe = entry.user_id === currentUserId;
|
||||
const isFirst = rank === 1;
|
||||
return (_jsxs("div", { className: `${styles.podiumCard} ${isFirst ? styles.podiumFirst : ''}`, children: [_jsx("div", { className: styles.podiumMedal, children: medal }), _jsx("div", { className: `${styles.podiumAvatar} ${colorClass} ${isFirst ? styles.podiumAvatarLarge : ''} ${isMe ? styles.podiumAvatarMe : ''}`, children: initials(entry.full_name) }), _jsxs("div", { className: `${styles.podiumName} ${isMe ? styles.nameMe : ''}`, children: [entry.full_name.split(' ')[0], isMe ? ' (Ich)' : ''] }), _jsxs("div", { className: `${styles.podiumPoints} ${colorClass}`, children: [entry.total_points.toLocaleString('de-DE'), _jsx("span", { className: styles.podiumPtLabel, children: " Pkt" })] }), _jsx("div", { className: `${styles.podiumBar} ${colorClass}Bar`, style: { height: barHeight } })] }, entry.user_id));
|
||||
}) })), rest.length > 0 && (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.listHeader, children: [_jsx("span", { children: "POS" }), _jsx("span", { children: "SPIELER" }), _jsx("span", { children: "TREND" }), _jsx("span", { children: "PUNKTE" })] }), _jsx("div", { className: styles.list, children: rest.map((entry, i) => {
|
||||
const isMe = entry.user_id === currentUserId;
|
||||
const prev = rest[i - 1];
|
||||
return (_jsxs("div", { className: `card ${styles.row} ${isMe ? styles.rowMe : ''}`, children: [_jsx("div", { className: styles.rankCol, children: _jsx("span", { className: styles.rankNum, children: entry.rank }) }), _jsx("div", { className: `${styles.avatarSmall} ${isMe ? styles.avatarSmallMe : ''}`, children: initials(entry.full_name) }), _jsxs("div", { className: styles.nameCol, children: [_jsx("div", { className: styles.nameRow, children: _jsxs("span", { className: `${styles.rowName} ${isMe ? styles.nameMe : ''}`, children: [entry.full_name, isMe ? ' (Ich)' : ''] }) }), entry.team && _jsx("div", { className: styles.rowTeam, children: entry.team }), isMe && _jsx("div", { className: styles.aufholjagd, children: "AUFHOLJAGD!" })] }), _jsx("div", { className: styles.trendCol, children: _jsx(TrendIcon, { entry: entry, prev: prev }) }), _jsx("div", { className: styles.pointsCol, children: entry.total_points.toLocaleString('de-DE') })] }, entry.user_id));
|
||||
}) })] })), tippableCount > 0 && (_jsxs("div", { className: `card ${styles.ctaCard}`, children: [_jsxs("div", { className: styles.ctaText, children: [_jsx("div", { className: styles.ctaTitle, children: "Punkte sichern!" }), _jsxs("div", { className: styles.ctaBody, children: [tippableCount, " Spiel", tippableCount !== 1 ? 'e' : '', " noch ohne Tipp \u2014 kletter nach oben."] })] }), _jsx("button", { className: `btn-primary ${styles.ctaBtn}`, onClick: () => navigate('/'), children: "TIPPEN" })] }))] }))] }));
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import MatchCard from '../components/MatchCard';
|
||||
import TipModal from '../components/TipModal';
|
||||
import styles from './MatchesPage.module.css';
|
||||
function groupIntoSections(matches) {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrowStart = new Date(todayStart);
|
||||
tomorrowStart.setDate(todayStart.getDate() + 1);
|
||||
const dayAfterTomorrow = new Date(todayStart);
|
||||
dayAfterTomorrow.setDate(todayStart.getDate() + 2);
|
||||
const weekEnd = new Date(todayStart);
|
||||
weekEnd.setDate(todayStart.getDate() + 7);
|
||||
const sections = [
|
||||
{ key: 'today', label: 'Heute', matches: [], defaultOpen: true, highlight: true },
|
||||
{ key: 'tomorrow', label: 'Morgen', matches: [], defaultOpen: true, highlight: false },
|
||||
{ key: 'week', label: 'Diese Woche', matches: [], defaultOpen: false, highlight: false },
|
||||
{ key: 'later', label: 'Demnächst', matches: [], defaultOpen: false, highlight: false },
|
||||
{ key: 'past', label: 'Vergangene Spiele', matches: [], defaultOpen: false, highlight: false },
|
||||
];
|
||||
for (const match of matches) {
|
||||
const d = new Date(match.utcDate);
|
||||
if (d < todayStart) {
|
||||
sections[4].matches.push(match); // past
|
||||
}
|
||||
else if (d < tomorrowStart) {
|
||||
sections[0].matches.push(match); // today
|
||||
}
|
||||
else if (d < dayAfterTomorrow) {
|
||||
sections[1].matches.push(match); // tomorrow
|
||||
}
|
||||
else if (d < weekEnd) {
|
||||
sections[2].matches.push(match); // this week
|
||||
}
|
||||
else {
|
||||
sections[3].matches.push(match); // later
|
||||
}
|
||||
}
|
||||
// Past matches: most recent first
|
||||
sections[4].matches.reverse();
|
||||
return sections.filter(s => s.matches.length > 0);
|
||||
}
|
||||
export default function MatchesPage() {
|
||||
const [allMatches, setAllMatches] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [stageFilter, setStageFilter] = useState('');
|
||||
const [selectedMatch, setSelectedMatch] = useState(null);
|
||||
const [openSections, setOpenSections] = useState(new Set());
|
||||
const loadMatches = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.getMatches();
|
||||
setAllMatches(res.matches);
|
||||
}
|
||||
catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { loadMatches(); }, [loadMatches]);
|
||||
// Initialize open sections after initial load
|
||||
useEffect(() => {
|
||||
if (allMatches.length > 0) {
|
||||
const filteredMatches = allMatches.filter(m => !stageFilter || m.stage === stageFilter);
|
||||
const sections = groupIntoSections(filteredMatches);
|
||||
setOpenSections(new Set(sections.filter(s => s.defaultOpen).map(s => s.key)));
|
||||
}
|
||||
}, [allMatches]); // only on initial load
|
||||
const handleTipSaved = (matchId, tipHome, tipAway) => {
|
||||
setAllMatches(prev => prev.map(m => m.id === matchId
|
||||
? { ...m, userTip: { home: tipHome, away: tipAway, points: null } }
|
||||
: m));
|
||||
setSelectedMatch(null);
|
||||
};
|
||||
function toggleSection(key) {
|
||||
setOpenSections(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key))
|
||||
next.delete(key);
|
||||
else
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
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 }))] }));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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 styles from './ProfilePage.module.css';
|
||||
function initials(name) {
|
||||
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
export default function ProfilePage() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [teamEdit, setTeamEdit] = useState(false);
|
||||
const [teamValue, setTeamValue] = useState('');
|
||||
const [teamSaving, setTeamSaving] = useState(false);
|
||||
const [teamMsg, setTeamMsg] = useState(null);
|
||||
useEffect(() => {
|
||||
api.getMyStats().then(s => {
|
||||
setStats(s);
|
||||
setTeamValue(s.team ?? '');
|
||||
}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
const saveTeam = async () => {
|
||||
if (!teamValue.trim())
|
||||
return;
|
||||
setTeamSaving(true);
|
||||
setTeamMsg(null);
|
||||
try {
|
||||
const res = await api.updateTeam(teamValue);
|
||||
setStats(prev => prev ? { ...prev, team: res.team } : prev);
|
||||
setTeamValue(res.team);
|
||||
setTeamEdit(false);
|
||||
setTeamMsg({ ok: true, text: 'Team gespeichert' });
|
||||
}
|
||||
catch (e) {
|
||||
setTeamMsg({ ok: false, text: e.message });
|
||||
}
|
||||
finally {
|
||||
setTeamSaving(false);
|
||||
}
|
||||
};
|
||||
if (loading)
|
||||
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
|
||||
? _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"] })] })] }))] }));
|
||||
}
|
||||
Reference in New Issue
Block a user