feat: WM2026 Tippspiel - Initial Backend + Frontend
This commit is contained in:
@@ -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