feat: WM2026 Tippspiel - Initial Backend + Frontend
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(10,14,26,0.92);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba(75,183,248,0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.headerInner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logoFlag { font-size: 22px; }
|
||||
|
||||
.logoText {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navLink, .navLinkActive {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.navLink:hover { color: var(--text-primary); background: var(--surface-mid); }
|
||||
|
||||
.navLinkActive {
|
||||
color: var(--primary);
|
||||
background: var(--primary-dim);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Routes, Route, NavLink } from 'react-router-dom';
|
||||
import MatchesPage from './pages/MatchesPage';
|
||||
import LeaderboardPage from './pages/LeaderboardPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
import styles from './App.module.css';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerInner}>
|
||||
<div className={styles.logo}>
|
||||
<span className={styles.logoFlag}>🏆</span>
|
||||
<span className={styles.logoText}>WM 2026 Tippspiel</span>
|
||||
</div>
|
||||
<nav className={styles.nav}>
|
||||
<NavLink to="/" end className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Spielplan
|
||||
</NavLink>
|
||||
<NavLink to="/rangliste" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Rangliste
|
||||
</NavLink>
|
||||
<NavLink to="/profil" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Mein Profil
|
||||
</NavLink>
|
||||
<NavLink to="/admin" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Admin
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles.main}>
|
||||
<Routes>
|
||||
<Route path="/" element={<MatchesPage />} />
|
||||
<Route path="/rangliste" element={<LeaderboardPage />} />
|
||||
<Route path="/profil" element={<ProfilePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
const BASE = '/api';
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${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?: { stage?: string; group?: string }) => {
|
||||
const q = new URLSearchParams(params as Record<string, string>).toString();
|
||||
return request<{ matches: Match[]; count: number }>(`/matches${q ? '?' + q : ''}`);
|
||||
},
|
||||
|
||||
// Tips
|
||||
submitTip: (matchId: number, tipHome: number, tipAway: number) =>
|
||||
request<{ success: boolean; tip: TipInfo; message: string }>('/tips', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ matchId, tipHome, tipAway }),
|
||||
}),
|
||||
|
||||
getMyTips: () =>
|
||||
request<{ tips: MyTip[]; count: number }>('/tips'),
|
||||
|
||||
// Leaderboard
|
||||
getLeaderboard: () =>
|
||||
request<LeaderboardResponse>('/leaderboard'),
|
||||
|
||||
getMyStats: () =>
|
||||
request<UserStats>('/leaderboard/me'),
|
||||
|
||||
// Admin
|
||||
syncMatches: () =>
|
||||
request<{ success: boolean; total: number; created: number; updated: number }>(
|
||||
'/admin/sync',
|
||||
{ method: 'POST' }
|
||||
),
|
||||
evaluateTips: () =>
|
||||
request<{ success: boolean; matchesEvaluated: number; tipsUpdated: number }>(
|
||||
'/admin/evaluate',
|
||||
{ method: 'POST' }
|
||||
),
|
||||
};
|
||||
|
||||
// Types (gespiegelt vom Backend)
|
||||
export interface Match {
|
||||
id: number;
|
||||
externalId: number;
|
||||
utcDate: string;
|
||||
status: string;
|
||||
stage: string;
|
||||
group: string | null;
|
||||
homeTeam: { name: string; shortName: string; crest: string | null };
|
||||
awayTeam: { name: string; shortName: string; crest: string | null };
|
||||
score: { home: number | null; away: number | null };
|
||||
userTip: TipInfo | null;
|
||||
minutesUntilKickoff: number;
|
||||
tippable: boolean;
|
||||
}
|
||||
|
||||
export interface TipInfo {
|
||||
home: number;
|
||||
away: number;
|
||||
points: number | null;
|
||||
}
|
||||
|
||||
export interface MyTip {
|
||||
match_id: number;
|
||||
tip_home: number;
|
||||
tip_away: number;
|
||||
points: number | null;
|
||||
utc_date: string;
|
||||
home_team_short: string;
|
||||
away_team_short: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
user_id: string;
|
||||
full_name: string;
|
||||
total_points: number;
|
||||
tips_count: number;
|
||||
exact_count: number;
|
||||
tendency_count: number;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface LeaderboardResponse {
|
||||
entries: LeaderboardEntry[];
|
||||
currentUserRank: number | null;
|
||||
totalParticipants: number;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
userId: string;
|
||||
fullName: string;
|
||||
totalPoints: number;
|
||||
rank: number | null;
|
||||
tipsCount: number;
|
||||
exactCount: number;
|
||||
tendencyCount: number;
|
||||
wrongCount: number;
|
||||
accuracy: number;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
.card { padding: 16px 20px; transition: box-shadow 0.2s; }
|
||||
.card:hover { box-shadow: 0 12px 30px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.09); }
|
||||
.live { box-shadow: 0 0 0 1px rgba(248,113,113,0.3), 0 10px 25px rgba(0,0,0,0.25) !important; }
|
||||
|
||||
.topRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status { font-size: 12px; color: var(--text-muted); }
|
||||
.statusLive { color: var(--error); font-weight: 600; }
|
||||
.kickoff { font-size: 13px; color: var(--text-secondary); margin-left: auto; }
|
||||
|
||||
.badge, .badgeUrgent {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge { background: var(--surface-high); color: var(--text-secondary); }
|
||||
.badgeUrgent { background: rgba(254,174,50,0.15); color: var(--gold); }
|
||||
|
||||
.group {
|
||||
font-size: 11px;
|
||||
color: var(--primary);
|
||||
background: var(--primary-dim);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.matchRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.teamHome { display: flex; align-items: center; gap: 10px; justify-content: flex-end; }
|
||||
.teamAway { display: flex; align-items: center; gap: 10px; }
|
||||
.crest { width: 28px; height: 28px; object-fit: contain; }
|
||||
|
||||
.teamName {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.scoreBox {
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
background: var(--surface-high);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.vs {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Tipp */
|
||||
.tipRow {
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tipBtn { width: 100%; max-width: 240px; }
|
||||
|
||||
.tipDisplay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tipLabel { font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
.tipScore {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.points {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.exact { background: rgba(52,211,153,0.15); color: var(--success); }
|
||||
.tendency { background: rgba(75,183,248,0.15); color: var(--primary); }
|
||||
.wrong { background: rgba(248,113,113,0.12); color: var(--error); }
|
||||
|
||||
.editBtn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.editBtn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
.noTip { font-size: 13px; color: var(--text-muted); }
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Match } from '../api/client';
|
||||
import styles from './MatchCard.module.css';
|
||||
|
||||
interface Props {
|
||||
match: Match;
|
||||
onTip: () => void;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
SCHEDULED: 'Geplant',
|
||||
TIMED: 'Terminiert',
|
||||
IN_PLAY: '🔴 Live',
|
||||
PAUSED: 'Pause',
|
||||
FINISHED: 'Beendet',
|
||||
POSTPONED: 'Verschoben',
|
||||
CANCELLED: 'Abgesagt',
|
||||
};
|
||||
|
||||
function formatKickoff(utcDate: string): string {
|
||||
return new Date(utcDate).toLocaleString('de-DE', {
|
||||
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin'
|
||||
}) + ' Uhr';
|
||||
}
|
||||
|
||||
function CountdownBadge({ minutes }: { minutes: number }) {
|
||||
if (minutes <= 0) return null;
|
||||
if (minutes < 60) return <span className={styles.badgeUrgent}>in {minutes} Min.</span>;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (h < 24) return <span className={styles.badge}>in {h}h {m > 0 ? `${m}m` : ''}</span>;
|
||||
const d = Math.floor(h / 24);
|
||||
return <span className={styles.badge}>in {d} Tag{d > 1 ? 'en' : ''}</span>;
|
||||
}
|
||||
|
||||
export default function MatchCard({ match, onTip }: Props) {
|
||||
const isFinished = match.status === 'FINISHED';
|
||||
const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED';
|
||||
const hasTip = !!match.userTip;
|
||||
|
||||
return (
|
||||
<div className={`card ${styles.card} ${isLive ? styles.live : ''}`}>
|
||||
{/* Status + Kickoff */}
|
||||
<div className={styles.topRow}>
|
||||
<span className={`${styles.status} ${isLive ? styles.statusLive : ''}`}>
|
||||
{STATUS_LABELS[match.status] ?? match.status}
|
||||
</span>
|
||||
<span className={styles.kickoff}>{formatKickoff(match.utcDate)}</span>
|
||||
{match.tippable && <CountdownBadge minutes={match.minutesUntilKickoff} />}
|
||||
{match.group && <span className={styles.group}>{match.group.replace('GROUP_', 'Gruppe ')}</span>}
|
||||
</div>
|
||||
|
||||
{/* Teams + Score */}
|
||||
<div className={styles.matchRow}>
|
||||
{/* Home Team */}
|
||||
<div className={styles.teamHome}>
|
||||
{match.homeTeam.crest && (
|
||||
<img className={styles.crest} src={match.homeTeam.crest} alt="" />
|
||||
)}
|
||||
<span className={styles.teamName}>{match.homeTeam.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Score / VS */}
|
||||
<div className={styles.scoreBox}>
|
||||
{isFinished || isLive ? (
|
||||
<span className={styles.score}>
|
||||
{match.score.home ?? '–'} : {match.score.away ?? '–'}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.vs}>vs</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Away Team */}
|
||||
<div className={styles.teamAway}>
|
||||
<span className={styles.teamName}>{match.awayTeam.name}</span>
|
||||
{match.awayTeam.crest && (
|
||||
<img className={styles.crest} src={match.awayTeam.crest} alt="" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tipp-Bereich */}
|
||||
<div className={styles.tipRow}>
|
||||
{hasTip ? (
|
||||
<div className={styles.tipDisplay}>
|
||||
<span className={styles.tipLabel}>Dein Tipp:</span>
|
||||
<span className={styles.tipScore}>
|
||||
{match.userTip!.home} : {match.userTip!.away}
|
||||
</span>
|
||||
{match.userTip!.points !== null && (
|
||||
<span className={`${styles.points} ${
|
||||
match.userTip!.points === 3 ? styles.exact :
|
||||
match.userTip!.points === 1 ? styles.tendency : styles.wrong
|
||||
}`}>
|
||||
{match.userTip!.points === 3 ? '🎯 3 Punkte' :
|
||||
match.userTip!.points === 1 ? '✓ 1 Punkt' : '✗ 0 Punkte'}
|
||||
</span>
|
||||
)}
|
||||
{match.tippable && (
|
||||
<button className={styles.editBtn} onClick={onTip}>Ändern</button>
|
||||
)}
|
||||
</div>
|
||||
) : match.tippable ? (
|
||||
<button className={`btn-primary ${styles.tipBtn}`} onClick={onTip}>
|
||||
⚡ Tipp abgeben
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.noTip}>Kein Tipp abgegeben</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
.overlay {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
background: rgba(0,0,0,0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface-mid);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 28px;
|
||||
width: 100%; max-width: 440px;
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(75,183,248,0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 20px; font-weight: 800;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
background: var(--surface-high);
|
||||
border: none; color: var(--text-secondary);
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
cursor: pointer; font-size: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.closeBtn:hover { background: var(--surface-high); color: var(--text-primary); }
|
||||
|
||||
.teamsRow {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.team { display: flex; align-items: center; gap: 8px; flex: 1; }
|
||||
.teamRight { flex-direction: row-reverse; }
|
||||
|
||||
.crest { width: 32px; height: 32px; object-fit: contain; }
|
||||
|
||||
.teamName {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-weight: 600; font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vs { font-size: 13px; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
.pickerRow {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 20px; margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.colon {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 40px; font-weight: 800;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; gap: 12px;
|
||||
}
|
||||
|
||||
.pickerBtn {
|
||||
width: 48px; height: 48px;
|
||||
background: var(--surface-high);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 22px; font-weight: 300;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.pickerBtn:hover { background: var(--primary-dim); border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
.pickerValue {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 52px; font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
min-width: 60px; text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tendencyRow {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 8px; margin-bottom: 24px;
|
||||
padding: 10px;
|
||||
background: var(--surface-high);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.tendencyLabel { font-size: 13px; color: var(--text-secondary); }
|
||||
.tendencyValue { font-size: 14px; font-weight: 700; color: var(--primary); }
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px;
|
||||
background: rgba(248,113,113,0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex; gap: 12px; justify-content: flex-end;
|
||||
}
|
||||
.actions .btn-primary { flex: 1; }
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { Match, api } from '../api/client';
|
||||
import styles from './TipModal.module.css';
|
||||
|
||||
interface Props {
|
||||
match: Match;
|
||||
onClose: () => void;
|
||||
onSaved: (matchId: number, home: number, away: number) => void;
|
||||
}
|
||||
|
||||
export default function TipModal({ match, onClose, onSaved }: Props) {
|
||||
const existing = match.userTip;
|
||||
const [home, setHome] = useState(existing?.home ?? 0);
|
||||
const [away, setAway] = useState(existing?.away ?? 0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const adjust = (setter: React.Dispatch<React.SetStateAction<number>>, val: number, delta: number) => {
|
||||
setter(Math.max(0, Math.min(20, val + delta)));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.submitTip(match.id, home, away);
|
||||
onSaved(match.id, home, away);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Tendenz-Anzeige
|
||||
const tendency = home > away ? match.homeTeam.shortName :
|
||||
away > home ? match.awayTeam.shortName : 'Unentschieden';
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onClose}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>Tipp abgeben</h2>
|
||||
<button className={styles.closeBtn} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Teams */}
|
||||
<div className={styles.teamsRow}>
|
||||
<div className={styles.team}>
|
||||
{match.homeTeam.crest && <img className={styles.crest} src={match.homeTeam.crest} alt="" />}
|
||||
<span className={styles.teamName}>{match.homeTeam.name}</span>
|
||||
</div>
|
||||
<span className={styles.vs}>vs</span>
|
||||
<div className={`${styles.team} ${styles.teamRight}`}>
|
||||
<span className={styles.teamName}>{match.awayTeam.name}</span>
|
||||
{match.awayTeam.crest && <img className={styles.crest} src={match.awayTeam.crest} alt="" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score Picker */}
|
||||
<div className={styles.pickerRow}>
|
||||
<ScorePicker value={home} onChange={v => setHome(v)} />
|
||||
<div className={styles.colon}>:</div>
|
||||
<ScorePicker value={away} onChange={v => setAway(v)} />
|
||||
</div>
|
||||
|
||||
{/* Tendenz */}
|
||||
<div className={styles.tendencyRow}>
|
||||
<span className={styles.tendencyLabel}>Tendenz:</span>
|
||||
<span className={styles.tendencyValue}>{tendency}</span>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className={styles.actions}>
|
||||
<button className="btn-ghost" onClick={onClose}>Abbrechen</button>
|
||||
<button className="btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Wird gespeichert…' : '✓ Tipp speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScorePicker({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||
return (
|
||||
<div className={styles.picker}>
|
||||
<button className={styles.pickerBtn} onClick={() => onChange(Math.min(20, value + 1))}>+</button>
|
||||
<span className={styles.pickerValue}>{value}</span>
|
||||
<button className={styles.pickerBtn} onClick={() => onChange(Math.max(0, value - 1))}>–</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* ============================================================
|
||||
WM 2026 Tippspiel — Stadium Elite Design System
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--bg-deep: #0A0E1A;
|
||||
--bg-mid: #0F1628;
|
||||
--surface-low: #111827;
|
||||
--surface-mid: #151D30;
|
||||
--surface-high: #1C2640;
|
||||
--primary: #4BB7F8;
|
||||
--primary-dim: rgba(75,183,248,0.12);
|
||||
--gold: #FEAE32;
|
||||
--gold-glow: rgba(254,174,50,0.4);
|
||||
--cyan: #69DAFF;
|
||||
--text-primary: #F0F4FF;
|
||||
--text-secondary: rgba(240,244,255,0.55);
|
||||
--text-muted: rgba(240,244,255,0.3);
|
||||
--success: #34D399;
|
||||
--error: #F87171;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
--radius-xl: 28px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--surface-high); border-radius: 3px; }
|
||||
|
||||
/* Utility */
|
||||
.font-display { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
.text-primary { color: var(--primary); }
|
||||
.text-gold { color: var(--gold); }
|
||||
.text-muted { color: var(--text-secondary); }
|
||||
|
||||
/* Glass Card */
|
||||
.card {
|
||||
background: var(--surface-mid);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow:
|
||||
0 10px 25px rgba(0,0,0,0.25),
|
||||
inset 0 1px 0 rgba(255,255,255,0.07),
|
||||
inset 1px 0 0 rgba(255,255,255,0.04);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 50%;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.04) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Button primary */
|
||||
button.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 20px;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
box-shadow: 0 4px 15px rgba(75,183,248,0.3);
|
||||
}
|
||||
button.btn-primary:hover { opacity: 0.9; }
|
||||
button.btn-primary:active { transform: scale(0.97); }
|
||||
button.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* Button ghost */
|
||||
button.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
button.btn-ghost:hover { border-color: var(--primary); color: var(--primary); }
|
||||
@@ -0,0 +1,13 @@
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
.page { display: flex; flex-direction: column; gap: 24px; max-width: 800px; }
|
||||
.title { font-size: 28px; font-weight: 800; }
|
||||
.hint { font-size: 13px; color: var(--text-secondary); padding: 12px 16px; background: var(--surface-mid); border-radius: var(--radius-sm); border-left: 3px solid var(--primary); }
|
||||
.cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
.actionCard { padding: 28px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.cardIcon { font-size: 32px; }
|
||||
.cardTitle { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 18px; font-weight: 700; }
|
||||
.cardDesc { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
|
||||
.status { font-size: 13px; padding: 10px 14px; border-radius: var(--radius-sm); }
|
||||
.success { background: rgba(52,211,153,0.1); color: var(--success); }
|
||||
.error { background: rgba(248,113,113,0.1); color: var(--error); }
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import styles from './AdminPage.module.css';
|
||||
|
||||
export default function AdminPage() {
|
||||
const [syncStatus, setSyncStatus] = useState<string | null>(null);
|
||||
const [evalStatus, setEvalStatus] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [evaluating, setEvaluating] = useState(false);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setSyncStatus(null);
|
||||
try {
|
||||
const res = await api.syncMatches();
|
||||
setSyncStatus(`✓ ${res.total} Spiele geladen — ${res.created} neu, ${res.updated} aktualisiert`);
|
||||
} catch (e) {
|
||||
setSyncStatus(`⚠️ Fehler: ${(e as Error).message}`);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEvaluate = async () => {
|
||||
setEvaluating(true);
|
||||
setEvalStatus(null);
|
||||
try {
|
||||
const res = await api.evaluateTips();
|
||||
setEvalStatus(`✓ ${res.matchesEvaluated} Spiele ausgewertet — ${res.tipsUpdated} Tipps bewertet`);
|
||||
} catch (e) {
|
||||
setEvalStatus(`⚠️ Fehler: ${(e as Error).message}`);
|
||||
} finally {
|
||||
setEvaluating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={`font-display ${styles.title}`}>⚙️ Administration</h1>
|
||||
<p className={styles.hint}>Diese Seite ist nur für Editoren. Nach der Staffbase-Integration wird sie durch Rollenprüfung geschützt.</p>
|
||||
|
||||
<div className={styles.cards}>
|
||||
<div className={`card ${styles.actionCard}`}>
|
||||
<div className={styles.cardIcon}>🔄</div>
|
||||
<h2 className={styles.cardTitle}>Spiele synchronisieren</h2>
|
||||
<p className={styles.cardDesc}>Lädt alle WM 2026-Spiele von football-data.org und speichert sie in der Datenbank. Täglich ausführen oder nach Spielplan-Änderungen.</p>
|
||||
{syncStatus && (
|
||||
<div className={`${styles.status} ${syncStatus.startsWith('✓') ? styles.success : styles.error}`}>
|
||||
{syncStatus}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn-primary" onClick={handleSync} disabled={syncing}>
|
||||
{syncing ? '⏳ Wird synchronisiert…' : '🔄 Jetzt synchronisieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`card ${styles.actionCard}`}>
|
||||
<div className={styles.cardIcon}>🧮</div>
|
||||
<h2 className={styles.cardTitle}>Tipps auswerten</h2>
|
||||
<p className={styles.cardDesc}>Berechnet Punkte für alle abgeschlossenen Spiele und aktualisiert die Rangliste. Nach jedem Spieltag ausführen.</p>
|
||||
{evalStatus && (
|
||||
<div className={`${styles.status} ${evalStatus.startsWith('✓') ? styles.success : styles.error}`}>
|
||||
{evalStatus}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn-primary" onClick={handleEvaluate} disabled={evaluating}>
|
||||
{evaluating ? '⏳ Wird ausgewertet…' : '🧮 Tipps auswerten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.page { display: flex; flex-direction: column; gap: 24px; }
|
||||
.pageHeader { display: flex; align-items: baseline; gap: 16px; }
|
||||
.title { font-size: 28px; font-weight: 800; }
|
||||
.meta { font-size: 13px; color: var(--text-secondary); }
|
||||
.loading { display: flex; justify-content: center; padding: 60px; }
|
||||
.spinner { width: 32px; height: 32px; border: 3px solid var(--surface-high); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.empty { text-align: center; color: var(--text-secondary); padding: 60px; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.row {
|
||||
padding: 14px 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.row:hover { box-shadow: 0 8px 24px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.09); }
|
||||
|
||||
.topThree { box-shadow: 0 10px 25px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.1), 0 0 0 1px rgba(254,174,50,0.1); }
|
||||
|
||||
.rank { font-size: 22px; text-align: center; }
|
||||
.rankNum { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 16px; font-weight: 700; color: var(--text-secondary); }
|
||||
|
||||
.name { font-family: 'Plus Jakarta Sans', sans-serif; font-weight: 600; font-size: 15px; }
|
||||
|
||||
.stats { display: flex; gap: 16px; }
|
||||
.stat { display: flex; align-items: center; gap: 4px; }
|
||||
.statVal { font-weight: 700; font-size: 14px; }
|
||||
.statLbl { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.points { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 20px; font-weight: 800; min-width: 70px; text-align: right; }
|
||||
.ptLabel { font-size: 12px; font-weight: 500; color: var(--text-secondary); }
|
||||
.gold { color: var(--gold); }
|
||||
.silver { color: #C0C0C0; }
|
||||
.bronze { color: #CD7F32; }
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, LeaderboardEntry } from '../api/client';
|
||||
import styles from './LeaderboardPage.module.css';
|
||||
|
||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
|
||||
const [myRank, setMyRank] = useState<number | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.getLeaderboard().then(res => {
|
||||
setEntries(res.entries);
|
||||
setMyRank(res.currentUserRank);
|
||||
setTotal(res.totalParticipants);
|
||||
}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return (
|
||||
<div className={styles.loading}><div className={styles.spinner} /></div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.pageHeader}>
|
||||
<h1 className={`font-display ${styles.title}`}>🏆 Rangliste</h1>
|
||||
<div className={styles.meta}>
|
||||
{total} Teilnehmer{myRank ? ` · Du: Platz ${myRank}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
Noch keine Punkte vergeben. Spiele müssen erst abgeschlossen sein.
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
{entries.map((entry, i) => (
|
||||
<div key={entry.user_id} className={`card ${styles.row} ${i < 3 ? styles.topThree : ''}`}>
|
||||
<div className={styles.rank}>
|
||||
{i < 3 ? MEDALS[i] : <span className={styles.rankNum}>{entry.rank}</span>}
|
||||
</div>
|
||||
<div className={styles.name}>{entry.full_name}</div>
|
||||
<div className={styles.stats}>
|
||||
<span className={styles.stat}>
|
||||
<span className={styles.statVal}>{entry.exact_count}</span>
|
||||
<span className={styles.statLbl}>🎯</span>
|
||||
</span>
|
||||
<span className={styles.stat}>
|
||||
<span className={styles.statVal}>{entry.tendency_count}</span>
|
||||
<span className={styles.statLbl}>✓</span>
|
||||
</span>
|
||||
<span className={styles.stat}>
|
||||
<span className={styles.statVal}>{entry.tips_count}</span>
|
||||
<span className={styles.statLbl}>Tipps</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={`${styles.points} ${i === 0 ? styles.gold : i === 1 ? styles.silver : i === 2 ? styles.bronze : ''}`}>
|
||||
{entry.total_points} <span className={styles.ptLabel}>Pkt</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
.page { display: flex; flex-direction: column; gap: 24px; }
|
||||
|
||||
.statsRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.statCard {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter, .filterActive {
|
||||
padding: 7px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.filterActive {
|
||||
background: var(--primary-dim);
|
||||
border-color: rgba(75,183,248,0.3);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.dayGroup { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.dayHeader {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.matchList { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
/* States */
|
||||
.loadingState, .errorState, .emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px; height: 32px;
|
||||
border: 3px solid var(--surface-high);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.emptyIcon { font-size: 48px; }
|
||||
.emptyHint { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
.errorState { color: var(--error); }
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api, Match } from '../api/client';
|
||||
import MatchCard from '../components/MatchCard';
|
||||
import TipModal from '../components/TipModal';
|
||||
import styles from './MatchesPage.module.css';
|
||||
|
||||
const STAGES: { key: string; label: string }[] = [
|
||||
{ key: '', label: 'Alle' },
|
||||
{ key: 'GROUP_STAGE', label: 'Gruppenphase' },
|
||||
{ key: 'ROUND_OF_32', label: 'Runde der 32' },
|
||||
{ key: 'ROUND_OF_16', label: 'Achtelfinale' },
|
||||
{ key: 'QUARTER_FINALS', label: 'Viertelfinale' },
|
||||
{ key: 'SEMI_FINALS', label: 'Halbfinale' },
|
||||
{ key: 'FINAL', label: 'Finale' },
|
||||
];
|
||||
|
||||
export default function MatchesPage() {
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState('');
|
||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||
|
||||
const loadMatches = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.getMatches(stage ? { stage } : undefined);
|
||||
setMatches(res.matches);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stage]);
|
||||
|
||||
useEffect(() => { loadMatches(); }, [loadMatches]);
|
||||
|
||||
const handleTipSaved = (matchId: number, tipHome: number, tipAway: number) => {
|
||||
setMatches(prev => prev.map(m =>
|
||||
m.id === matchId
|
||||
? { ...m, userTip: { home: tipHome, away: tipAway, points: null } }
|
||||
: m
|
||||
));
|
||||
setSelectedMatch(null);
|
||||
};
|
||||
|
||||
// Spiele nach Datum gruppieren
|
||||
const grouped = matches.reduce<Record<string, Match[]>>((acc, m) => {
|
||||
const day = new Date(m.utcDate).toLocaleDateString('de-DE', {
|
||||
weekday: 'long', day: 'numeric', month: 'long'
|
||||
});
|
||||
if (!acc[day]) acc[day] = [];
|
||||
acc[day].push(m);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const tipped = matches.filter(m => m.userTip).length;
|
||||
const tippable = matches.filter(m => m.tippable).length;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{/* Header Stats */}
|
||||
<div className={styles.statsRow}>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={styles.statValue}>{matches.length}</span>
|
||||
<span className={styles.statLabel}>Spiele gesamt</span>
|
||||
</div>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={`${styles.statValue} text-primary`}>{tipped}</span>
|
||||
<span className={styles.statLabel}>Tipps abgegeben</span>
|
||||
</div>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={`${styles.statValue} text-gold`}>{tippable}</span>
|
||||
<span className={styles.statLabel}>Noch tippbar</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Filter */}
|
||||
<div className={styles.filters}>
|
||||
{STAGES.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
className={stage === s.key ? styles.filterActive : styles.filter}
|
||||
onClick={() => setStage(s.key)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && (
|
||||
<div className={styles.loadingState}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Spiele werden geladen…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className={styles.errorState}>
|
||||
<span>⚠️ {error}</span>
|
||||
<button className="btn-ghost" onClick={loadMatches}>Erneut versuchen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && matches.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
<span className={styles.emptyIcon}>⚽</span>
|
||||
<p>Noch keine Spiele vorhanden.</p>
|
||||
<p className={styles.emptyHint}>
|
||||
Geh auf die Admin-Seite und klicke "Spiele synchronisieren".
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && Object.entries(grouped).map(([day, dayMatches]) => (
|
||||
<div key={day} className={styles.dayGroup}>
|
||||
<h2 className={styles.dayHeader}>{day}</h2>
|
||||
<div className={styles.matchList}>
|
||||
{dayMatches.map(match => (
|
||||
<MatchCard
|
||||
key={match.id}
|
||||
match={match}
|
||||
onTip={() => setSelectedMatch(match)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedMatch && (
|
||||
<TipModal
|
||||
match={selectedMatch}
|
||||
onClose={() => setSelectedMatch(null)}
|
||||
onSaved={handleTipSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.page { display: flex; flex-direction: column; gap: 20px; max-width: 640px; }
|
||||
.loading { display: flex; justify-content: center; padding: 60px; }
|
||||
.spinner { width: 32px; height: 32px; border: 3px solid var(--surface-high); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.empty { color: var(--text-secondary); padding: 40px; text-align: center; }
|
||||
|
||||
.heroCard { padding: 28px; display: flex; align-items: center; gap: 20px; }
|
||||
.avatar { width: 60px; height: 60px; border-radius: 50%; background: var(--primary-dim); border: 2px solid rgba(75,183,248,0.3); display: flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', sans-serif; font-size: 24px; font-weight: 800; color: var(--primary); flex-shrink: 0; }
|
||||
.heroInfo { flex: 1; }
|
||||
.name { font-size: 22px; font-weight: 800; }
|
||||
.rankBadge { font-size: 13px; color: var(--gold); margin-top: 4px; font-weight: 600; }
|
||||
.heroPoints { text-align: right; }
|
||||
.pointsVal { font-size: 40px; font-weight: 800; color: var(--primary); line-height: 1; display: block; }
|
||||
.pointsLbl { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
.statsGrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.statCard { padding: 20px; text-align: center; }
|
||||
.statVal { font-size: 36px; font-weight: 800; display: block; }
|
||||
.statLbl { font-size: 13px; color: var(--text-secondary); display: block; margin-top: 4px; }
|
||||
|
||||
.accuracyCard { padding: 24px; }
|
||||
.accuracyHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
|
||||
.accuracyLabel { font-size: 14px; color: var(--text-secondary); }
|
||||
.accuracyVal { font-size: 28px; font-weight: 800; color: var(--text-primary); }
|
||||
.bar { height: 10px; background: var(--surface-high); border-radius: 5px; overflow: hidden; display: flex; margin-bottom: 12px; }
|
||||
.barFill { height: 100%; transition: width 0.5s ease; }
|
||||
.exact { background: var(--gold); }
|
||||
.tendency { background: var(--primary); }
|
||||
.barLegend { display: flex; gap: 16px; font-size: 12px; color: var(--text-secondary); }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, UserStats } from '../api/client';
|
||||
import styles from './ProfilePage.module.css';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [stats, setStats] = useState<UserStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.getMyStats().then(setStats).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className={styles.loading}><div className={styles.spinner} /></div>;
|
||||
if (!stats) return <div className={styles.empty}>Profil nicht verfügbar.</div>;
|
||||
|
||||
const evaluated = stats.exactCount + stats.tendencyCount + stats.wrongCount;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={`card ${styles.heroCard}`}>
|
||||
<div className={styles.avatar}>{stats.fullName.charAt(0).toUpperCase()}</div>
|
||||
<div className={styles.heroInfo}>
|
||||
<h1 className={`font-display ${styles.name}`}>{stats.fullName}</h1>
|
||||
{stats.rank && (
|
||||
<div className={styles.rankBadge}>🏆 Platz {stats.rank}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.heroPoints}>
|
||||
<span className={`font-display ${styles.pointsVal}`}>{stats.totalPoints}</span>
|
||||
<span className={styles.pointsLbl}>Punkte</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={`font-display ${styles.statVal} text-gold`}>{stats.exactCount}</span>
|
||||
<span className={styles.statLbl}>🎯 Exakt</span>
|
||||
</div>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={`font-display ${styles.statVal} text-primary`}>{stats.tendencyCount}</span>
|
||||
<span className={styles.statLbl}>✓ Tendenz</span>
|
||||
</div>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={`font-display ${styles.statVal}`} style={{ color: 'var(--error)' }}>{stats.wrongCount}</span>
|
||||
<span className={styles.statLbl}>✗ Falsch</span>
|
||||
</div>
|
||||
<div className={`card ${styles.statCard}`}>
|
||||
<span className={`font-display ${styles.statVal}`}>{stats.tipsCount}</span>
|
||||
<span className={styles.statLbl}>Tipps gesamt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{evaluated > 0 && (
|
||||
<div className={`card ${styles.accuracyCard}`}>
|
||||
<div className={styles.accuracyHeader}>
|
||||
<span className={styles.accuracyLabel}>Trefferquote</span>
|
||||
<span className={`font-display ${styles.accuracyVal}`}>{stats.accuracy}%</span>
|
||||
</div>
|
||||
<div className={styles.bar}>
|
||||
<div className={`${styles.barFill} ${styles.exact}`}
|
||||
style={{ width: `${(stats.exactCount / evaluated) * 100}%` }} />
|
||||
<div className={`${styles.barFill} ${styles.tendency}`}
|
||||
style={{ width: `${(stats.tendencyCount / evaluated) * 100}%` }} />
|
||||
</div>
|
||||
<div className={styles.barLegend}>
|
||||
<span><span className={styles.dot} style={{ background: 'var(--gold)' }} /> Exakt</span>
|
||||
<span><span className={styles.dot} style={{ background: 'var(--primary)' }} /> Tendenz</span>
|
||||
<span><span className={styles.dot} style={{ background: 'var(--surface-high)' }} /> Falsch</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user