feat: Stadium Elite Design, Rangliste, Profil-Team, User-Upsert & n8n Cronjob

- MatchCard + TipModal: Uhrzeit statt VS zwischen Flaggen, Gruppe zentriert
- LeaderboardPage: Podium (2./1./3.), DU-Badge, Trend-Pfeile, Team-Zeile, CTA-Card
- AdminPage: Stadium Elite Redesign mit Result-Bar und Inline-Spinner
- ProfilePage: Team-Feld inline editierbar (PATCH /api/profile/team)
- User-Upsert beim ersten App-Aufruf (Matches-Route) statt erst beim Tipp
- DB Migration 002: team-Spalte in users, Leaderboard View aktualisiert
- Leaderboard-Refresh automatisch nach Tipps-Auswertung
- n8n Workflow angelegt: stündlicher Sync + Auswertung (ID: t3SDspIGDXwkfOt3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ronny Mueller
2026-04-03 23:37:38 +02:00
parent e967f36f6c
commit e27a62a37b
20 changed files with 1515 additions and 297 deletions
+180 -10
View File
@@ -1,11 +1,181 @@
.page { display: flex; flex-direction: column; gap: 24px; max-width: 800px; }
.page {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 760px;
}
/* Header */
.pageHeader {
display: flex;
align-items: center;
gap: 12px;
}
.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); }
.roleBadge {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--gold);
background: rgba(254,174,50,0.1);
border: 1px solid rgba(254,174,50,0.2);
padding: 3px 10px;
border-radius: 20px;
}
.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);
line-height: 1.5;
}
/* Cards grid */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
}
.actionCard {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
transition: box-shadow 0.2s, transform 0.15s;
}
.actionCard:hover {
transform: translateY(-1px);
box-shadow:
0 16px 36px rgba(0,0,0,0.35),
inset 0 1px 0 rgba(255,255,255,0.10);
}
/* Card top row: icon + text */
.cardTop {
display: flex;
gap: 16px;
align-items: flex-start;
}
.cardIcon {
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--primary-dim);
border: 1px solid rgba(75,183,248,0.15);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--primary);
flex-shrink: 0;
font-family: monospace;
}
.cardTitle {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.cardDesc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
/* Result bar */
.resultBar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--radius-sm);
font-size: 12px;
}
.resultSuccess {
background: rgba(52,211,153,0.08);
border: 1px solid rgba(52,211,153,0.15);
color: var(--success);
}
.resultError {
background: rgba(248,113,113,0.08);
border: 1px solid rgba(248,113,113,0.15);
color: var(--error);
}
.resultDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.resultMsg { flex: 1; }
.resultTime {
font-size: 11px;
opacity: 0.6;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Action button */
.actionBtn {
width: 100%;
padding: 11px 20px;
border-radius: var(--radius-sm);
background: var(--primary);
color: #fff;
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.03em;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.15s;
box-shadow: 0 4px 14px rgba(75,183,248,0.25);
}
.actionBtn:hover:not(:disabled) {
background: #6bc4fa;
box-shadow: 0 6px 20px rgba(75,183,248,0.35);
}
.actionBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}
.actionBtnLoading { opacity: 0.75; }
/* Spinner inline */
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
+129 -38
View File
@@ -2,20 +2,28 @@ import { useState } from 'react';
import { api } from '../api/client';
import styles from './AdminPage.module.css';
interface ActionResult {
message: string;
success: boolean;
timestamp: Date;
}
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 [syncResult, setSyncResult] = useState<ActionResult | null>(null);
const [evalResult, setEvalResult] = useState<ActionResult | null>(null);
const [refreshResult, setRefreshResult] = useState<ActionResult | null>(null);
const [syncing, setSyncing] = useState(false);
const [evaluating, setEvaluating] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const handleSync = async () => {
setSyncing(true);
setSyncStatus(null);
setSyncResult(null);
try {
const res = await api.syncMatches();
setSyncStatus(`${res.total} Spiele geladen — ${res.created} neu, ${res.updated} aktualisiert`);
setSyncResult({ success: true, timestamp: new Date(), message: `${res.total} Spiele geladen — ${res.created} neu, ${res.updated} aktualisiert` });
} catch (e) {
setSyncStatus(`⚠️ Fehler: ${(e as Error).message}`);
setSyncResult({ success: false, timestamp: new Date(), message: (e as Error).message });
} finally {
setSyncing(false);
}
@@ -23,51 +31,134 @@ export default function AdminPage() {
const handleEvaluate = async () => {
setEvaluating(true);
setEvalStatus(null);
setEvalResult(null);
try {
const res = await api.evaluateTips();
setEvalStatus(`${res.matchesEvaluated} Spiele ausgewertet — ${res.tipsUpdated} Tipps bewertet`);
setEvalResult({ success: true, timestamp: new Date(), message: `${res.matchesEvaluated} Spiele ausgewertet — ${res.tipsUpdated} Tipps bewertet` });
} catch (e) {
setEvalStatus(`⚠️ Fehler: ${(e as Error).message}`);
setEvalResult({ success: false, timestamp: new Date(), message: (e as Error).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 as Error).message });
} finally {
setRefreshing(false);
}
};
function formatTime(d: Date) {
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
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>
{/* Header */}
<div className={styles.pageHeader}>
<h1 className={`font-display ${styles.title}`}>Administration</h1>
<div className={styles.roleBadge}>Editor</div>
</div>
<p className={styles.hint}>
Nur für Editoren sichtbar. Nach Staffbase-Freischaltung wird diese Seite durch Rollenprüfung geschützt.
</p>
{/* Action Cards */}
<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>
{/* Sync */}
<ActionCard
icon="↻"
title="Spiele synchronisieren"
desc="Lädt alle WM 2026-Spiele von football-data.org und aktualisiert die Datenbank."
result={syncResult}
loading={syncing}
loadingLabel="Wird synchronisiert…"
actionLabel="Jetzt synchronisieren"
onAction={handleSync}
formatTime={formatTime}
/>
{/* Evaluate */}
<ActionCard
icon="◈"
title="Tipps auswerten"
desc="Berechnet Punkte für alle abgeschlossenen Spiele und aktualisiert die Rangliste."
result={evalResult}
loading={evaluating}
loadingLabel="Wird ausgewertet…"
actionLabel="Tipps auswerten"
onAction={handleEvaluate}
formatTime={formatTime}
/>
{/* Refresh Leaderboard */}
<ActionCard
icon="⟳"
title="Rangliste aktualisieren"
desc="Aktualisiert die Materialized View manuell — normalerweise automatisch nach Auswertung."
result={refreshResult}
loading={refreshing}
loadingLabel="Wird aktualisiert…"
actionLabel="Rangliste neu berechnen"
onAction={handleRefreshLeaderboard}
formatTime={formatTime}
/>
</div>
</div>
);
}
/* ── Sub-component ── */
function ActionCard({
icon, title, desc, result, loading, loadingLabel, actionLabel, onAction, formatTime,
}: {
icon: string;
title: string;
desc: string;
result: ActionResult | null;
loading: boolean;
loadingLabel: string;
actionLabel: string;
onAction: () => void;
formatTime: (d: Date) => string;
}) {
return (
<div className={`card ${styles.actionCard}`}>
<div className={styles.cardTop}>
<div className={styles.cardIcon}>{icon}</div>
<div>
<div className={styles.cardTitle}>{title}</div>
<div className={styles.cardDesc}>{desc}</div>
</div>
</div>
{result && (
<div className={`${styles.resultBar} ${result.success ? styles.resultSuccess : styles.resultError}`}>
<span className={styles.resultDot} />
<span className={styles.resultMsg}>{result.message}</span>
<span className={styles.resultTime}>{formatTime(result.timestamp)}</span>
</div>
)}
<button
className={`${styles.actionBtn} ${loading ? styles.actionBtnLoading : ''}`}
onClick={onAction}
disabled={loading}
>
{loading ? (
<><span className={styles.spinner} />{loadingLabel}</>
) : actionLabel}
</button>
</div>
);
}
+211 -17
View File
@@ -1,38 +1,232 @@
.page { display: flex; flex-direction: column; gap: 24px; }
.pageHeader { display: flex; align-items: baseline; gap: 16px; }
.page { display: flex; flex-direction: column; gap: 16px; }
.pageHeader { display: flex; align-items: baseline; gap: 16px; margin-bottom: 4px; }
.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; }
/* ── Color tokens ── */
.gold { color: var(--gold); }
.silver { color: #C0C0C0; }
.bronze { color: #CD7F32; }
/* ── Podium ── */
.podiumWrap {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 8px;
padding: 8px 0 0;
}
.podiumCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
flex: 1;
max-width: 130px;
}
.podiumFirst { flex: 1.2; max-width: 150px; }
.podiumMedal { font-size: 24px; line-height: 1; }
.podiumAvatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--surface-high);
box-shadow: 0 4px 14px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.12);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 16px;
font-weight: 800;
color: var(--text-primary);
transition: box-shadow 0.2s;
}
.podiumAvatarLarge {
width: 64px;
height: 64px;
font-size: 22px;
}
.podiumAvatarMe {
background: rgba(75,183,248,0.15);
box-shadow: 0 0 0 2px var(--primary), 0 4px 14px rgba(75,183,248,0.25);
}
/* Gold/silver/bronze ring on avatar */
.podiumAvatar.gold { box-shadow: 0 0 0 2px var(--gold), 0 4px 16px rgba(254,174,50,0.3); }
.podiumAvatar.silver { box-shadow: 0 0 0 2px #C0C0C0, 0 4px 14px rgba(192,192,192,0.2); }
.podiumAvatar.bronze { box-shadow: 0 0 0 2px #CD7F32, 0 4px 14px rgba(205,127,50,0.2); }
.podiumName {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.podiumPoints {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 15px;
font-weight: 800;
text-align: center;
}
.podiumPtLabel { font-size: 10px; font-weight: 500; opacity: 0.7; }
.podiumBar {
width: 100%;
border-radius: 10px 10px 0 0;
margin-top: 6px;
}
.podiumBar.goldBar { background: linear-gradient(180deg, rgba(254,174,50,0.3) 0%, rgba(254,174,50,0.08) 100%); box-shadow: inset 0 2px 0 rgba(254,174,50,0.35); }
.podiumBar.silverBar { background: linear-gradient(180deg, rgba(192,192,192,0.22) 0%, rgba(192,192,192,0.06) 100%); box-shadow: inset 0 2px 0 rgba(192,192,192,0.28); }
.podiumBar.bronzeBar { background: linear-gradient(180deg, rgba(205,127,50,0.22) 0%, rgba(205,127,50,0.06) 100%); box-shadow: inset 0 2px 0 rgba(205,127,50,0.28); }
/* ── List header ── */
.listHeader {
display: grid;
grid-template-columns: 40px 1fr 40px 64px;
gap: 8px;
padding: 0 20px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--text-muted);
text-transform: uppercase;
}
.listHeader span:last-child { text-align: right; }
/* ── Rest list ── */
.list { display: flex; flex-direction: column; gap: 8px; }
.row {
padding: 14px 20px;
display: grid;
grid-template-columns: 44px 1fr auto auto;
grid-template-columns: 40px 36px 1fr 40px 64px;
align-items: center;
gap: 16px;
gap: 8px;
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); }
.rowMe {
box-shadow: 0 0 0 1px var(--primary), 0 8px 24px rgba(75,183,248,0.12), inset 0 1px 0 rgba(75,183,248,0.08) !important;
}
.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); }
.rankCol { text-align: center; }
.rankNum {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 15px;
font-weight: 700;
color: var(--text-secondary);
}
.name { font-family: 'Plus Jakarta Sans', sans-serif; font-weight: 600; font-size: 15px; }
.avatarSmall {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--surface-high);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 12px;
font-weight: 800;
color: var(--text-primary);
flex-shrink: 0;
}
.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); }
.avatarSmallMe {
background: rgba(75,183,248,0.15);
box-shadow: 0 0 0 2px var(--primary);
color: var(--primary);
}
.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; }
.nameCol { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.nameRow { display: flex; align-items: center; gap: 6px; }
.rowName {
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 700;
font-size: 14px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rowTeam {
font-size: 11px;
color: var(--text-muted);
}
.nameMe { color: var(--primary); }
.aufholjagd {
font-size: 10px;
font-weight: 800;
color: var(--primary);
letter-spacing: 0.06em;
}
.trendCol { display: flex; justify-content: center; font-size: 18px; }
.trendUp { color: #34D399; }
.trendDown { color: #F87171; }
.trendNeutral { color: var(--text-muted); }
.pointsCol {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 16px;
font-weight: 800;
color: var(--primary);
text-align: right;
}
/* ── CTA Card ── */
.ctaCard {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
margin-top: 4px;
}
.ctaText { flex: 1; }
.ctaTitle {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 16px;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 4px;
}
.ctaBody {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
.ctaBtn {
flex-shrink: 0;
padding: 10px 20px;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.06em;
}
+124 -37
View File
@@ -1,20 +1,32 @@
import { useState, useEffect } from 'react';
import { api, LeaderboardEntry } from '../api/client';
import { useNavigate } from 'react-router-dom';
import { api, LeaderboardEntry, LeaderboardResponse } from '../api/client';
import styles from './LeaderboardPage.module.css';
const MEDALS = ['🥇', '🥈', '🥉'];
function initials(name: string) {
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
}
function TrendIcon({ entry, prev }: { entry: LeaderboardEntry; prev: LeaderboardEntry | undefined }) {
if (!prev) return <span className={styles.trendNeutral}></span>;
if (entry.total_points > prev.total_points) return <span className={styles.trendUp}></span>;
if (entry.total_points < prev.total_points) return <span className={styles.trendDown}></span>;
return <span className={styles.trendNeutral}></span>;
}
export default function LeaderboardPage() {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [myRank, setMyRank] = useState<number | null>(null);
const [total, setTotal] = useState(0);
const [data, setData] = useState<LeaderboardResponse | null>(null);
const [tippableCount, setTippableCount] = useState(0);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
api.getLeaderboard().then(res => {
setEntries(res.entries);
setMyRank(res.currentUserRank);
setTotal(res.totalParticipants);
Promise.all([
api.getLeaderboard(),
api.getMatches(),
]).then(([lb, matches]) => {
setData(lb);
setTippableCount(matches.matches.filter(m => m.tippable && !m.userTip).length);
}).finally(() => setLoading(false));
}, []);
@@ -22,12 +34,25 @@ export default function LeaderboardPage() {
<div className={styles.loading}><div className={styles.spinner} /></div>
);
if (!data) return null;
const { entries, currentUserId, currentUserRank, totalParticipants } = data;
const top3 = entries.slice(0, 3);
const rest = entries.slice(3);
// Podium order: 2nd left, 1st center, 3rd right
type PodiumSlot = { entry: LeaderboardEntry; rank: 1 | 2 | 3; medal: string; colorClass: string; barHeight: string };
const podiumSlots: PodiumSlot[] = [];
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 (
<div className={styles.page}>
<div className={styles.pageHeader}>
<h1 className={`font-display ${styles.title}`}>🏆 Rangliste</h1>
<h1 className={`font-display ${styles.title}`}>Rangliste</h1>
<div className={styles.meta}>
{total} Teilnehmer{myRank ? ` · Du: Platz ${myRank}` : ''}
{totalParticipants} Teilnehmer{currentUserRank ? ` · Du: Platz ${currentUserRank}` : ''}
</div>
</div>
@@ -36,33 +61,95 @@ export default function LeaderboardPage() {
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>
<>
{/* ── Podium ── */}
{top3.length > 0 && (
<div className={styles.podiumWrap}>
{podiumSlots.map(({ entry, rank, medal, colorClass, barHeight }) => {
const isMe = entry.user_id === currentUserId;
const isFirst = rank === 1;
return (
<div key={entry.user_id} className={`${styles.podiumCard} ${isFirst ? styles.podiumFirst : ''}`}>
<div className={styles.podiumMedal}>{medal}</div>
<div className={`${styles.podiumAvatar} ${colorClass} ${isFirst ? styles.podiumAvatarLarge : ''} ${isMe ? styles.podiumAvatarMe : ''}`}>
{initials(entry.full_name)}
</div>
<div className={`${styles.podiumName} ${isMe ? styles.nameMe : ''}`}>
{entry.full_name.split(' ')[0]}{isMe ? ' (Ich)' : ''}
</div>
<div className={`${styles.podiumPoints} ${colorClass}`}>
{entry.total_points.toLocaleString('de-DE')}
<span className={styles.podiumPtLabel}> Pkt</span>
</div>
<div className={`${styles.podiumBar} ${colorClass}Bar`} style={{ height: barHeight }} />
</div>
);
})}
</div>
))}
</div>
)}
{/* ── List header ── */}
{rest.length > 0 && (
<>
<div className={styles.listHeader}>
<span>POS</span>
<span>SPIELER</span>
<span>TREND</span>
<span>PUNKTE</span>
</div>
<div className={styles.list}>
{rest.map((entry, i) => {
const isMe = entry.user_id === currentUserId;
const prev = rest[i - 1];
return (
<div key={entry.user_id} className={`card ${styles.row} ${isMe ? styles.rowMe : ''}`}>
<div className={styles.rankCol}>
<span className={styles.rankNum}>{entry.rank}</span>
</div>
<div className={`${styles.avatarSmall} ${isMe ? styles.avatarSmallMe : ''}`}>
{initials(entry.full_name)}
</div>
<div className={styles.nameCol}>
<div className={styles.nameRow}>
<span className={`${styles.rowName} ${isMe ? styles.nameMe : ''}`}>
{entry.full_name}{isMe ? ' (Ich)' : ''}
</span>
</div>
{entry.team && <div className={styles.rowTeam}>{entry.team}</div>}
{isMe && <div className={styles.aufholjagd}>AUFHOLJAGD!</div>}
</div>
<div className={styles.trendCol}>
<TrendIcon entry={entry} prev={prev} />
</div>
<div className={styles.pointsCol}>
{entry.total_points.toLocaleString('de-DE')}
</div>
</div>
);
})}
</div>
</>
)}
{/* ── CTA Card ── */}
{tippableCount > 0 && (
<div className={`card ${styles.ctaCard}`}>
<div className={styles.ctaText}>
<div className={styles.ctaTitle}>Punkte sichern!</div>
<div className={styles.ctaBody}>
{tippableCount} Spiel{tippableCount !== 1 ? 'e' : ''} noch ohne Tipp kletter nach oben.
</div>
</div>
<button
className={`btn-primary ${styles.ctaBtn}`}
onClick={() => navigate('/')}
>
TIPPEN
</button>
</div>
)}
</>
)}
</div>
);
+79 -3
View File
@@ -1,14 +1,90 @@
.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; }
.spinnerSm { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.7s linear infinite; display: inline-block; }
@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; }
.heroCard { padding: 28px; display: flex; align-items: flex-start; 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: 22px; font-weight: 800; color: var(--primary); flex-shrink: 0; margin-top: 2px; }
.heroInfo { flex: 1; min-width: 0; }
.name { font-size: 22px; font-weight: 800; }
.rankBadge { font-size: 13px; color: var(--gold); margin-top: 4px; font-weight: 600; }
/* Team-Feld */
.teamRow { margin-top: 8px; }
.teamBtn {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.teamName {
font-size: 13px;
color: var(--text-secondary);
font-weight: 600;
}
.teamEditHint {
font-size: 11px;
color: var(--text-muted);
opacity: 0;
transition: opacity 0.15s;
}
.teamBtn:hover .teamEditHint { opacity: 1; }
.teamPlaceholder {
font-size: 12px;
color: var(--primary);
opacity: 0.7;
transition: opacity 0.15s;
}
.teamBtn:hover .teamPlaceholder { opacity: 1; }
.teamEditRow {
display: flex;
align-items: center;
gap: 6px;
}
.teamInput {
background: var(--surface-high);
border: 1px solid rgba(75,183,248,0.3);
border-radius: 8px;
padding: 5px 10px;
font-size: 13px;
color: var(--text-primary);
font-family: inherit;
outline: none;
width: 180px;
transition: border-color 0.15s;
}
.teamInput:focus { border-color: var(--primary); }
.teamSaveBtn, .teamCancelBtn {
width: 28px; height: 28px;
border-radius: 8px;
border: none;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 13px;
font-weight: 700;
transition: all 0.15s;
}
.teamSaveBtn { background: var(--primary); color: #fff; }
.teamSaveBtn:hover:not(:disabled) { background: #6bc4fa; }
.teamSaveBtn:disabled { opacity: 0.5; cursor: not-allowed; }
.teamCancelBtn { background: var(--surface-high); color: var(--text-muted); }
.teamCancelBtn:hover { color: var(--text-primary); }
.teamMsg { font-size: 12px; margin-top: 4px; }
.teamMsgOk { color: var(--success); }
.teamMsgErr { color: var(--error); }
.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; }
+70 -7
View File
@@ -2,27 +2,88 @@ import { useState, useEffect } from 'react';
import { api, UserStats } from '../api/client';
import styles from './ProfilePage.module.css';
function initials(name: string) {
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
}
export default function ProfilePage() {
const [stats, setStats] = useState<UserStats | null>(null);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<UserStats | null>(null);
const [loading, setLoading] = useState(true);
const [teamEdit, setTeamEdit] = useState(false);
const [teamValue, setTeamValue] = useState('');
const [teamSaving, setTeamSaving] = useState(false);
const [teamMsg, setTeamMsg] = useState<{ ok: boolean; text: string } | null>(null);
useEffect(() => {
api.getMyStats().then(setStats).finally(() => setLoading(false));
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 as Error).message });
} finally {
setTeamSaving(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>;
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}>
{/* Hero card */}
<div className={`card ${styles.heroCard}`}>
<div className={styles.avatar}>{stats.fullName.charAt(0).toUpperCase()}</div>
<div className={styles.avatar}>{initials(stats.fullName)}</div>
<div className={styles.heroInfo}>
<h1 className={`font-display ${styles.name}`}>{stats.fullName}</h1>
{stats.rank && (
<div className={styles.rankBadge}>🏆 Platz {stats.rank}</div>
{stats.rank && <div className={styles.rankBadge}>🏆 Platz {stats.rank}</div>}
{/* Team-Feld */}
<div className={styles.teamRow}>
{teamEdit ? (
<div className={styles.teamEditRow}>
<input
className={styles.teamInput}
value={teamValue}
onChange={e => setTeamValue(e.target.value)}
placeholder="z. B. Vertrieb Süd"
maxLength={80}
autoFocus
onKeyDown={e => { if (e.key === 'Enter') saveTeam(); if (e.key === 'Escape') setTeamEdit(false); }}
/>
<button className={styles.teamSaveBtn} onClick={saveTeam} disabled={teamSaving}>
{teamSaving ? <span className={styles.spinnerSm} /> : '✓'}
</button>
<button className={styles.teamCancelBtn} onClick={() => { setTeamEdit(false); setTeamValue(stats.team ?? ''); }}></button>
</div>
) : (
<button className={styles.teamBtn} onClick={() => setTeamEdit(true)}>
{stats.team
? <><span className={styles.teamName}>{stats.team}</span><span className={styles.teamEditHint}>bearbeiten</span></>
: <span className={styles.teamPlaceholder}>+ Team hinzufügen</span>
}
</button>
)}
</div>
{teamMsg && (
<div className={`${styles.teamMsg} ${teamMsg.ok ? styles.teamMsgOk : styles.teamMsgErr}`}>
{teamMsg.text}
</div>
)}
</div>
<div className={styles.heroPoints}>
@@ -31,6 +92,7 @@ export default function ProfilePage() {
</div>
</div>
{/* Stats grid */}
<div className={styles.statsGrid}>
<div className={`card ${styles.statCard}`}>
<span className={`font-display ${styles.statVal} text-gold`}>{stats.exactCount}</span>
@@ -50,6 +112,7 @@ export default function ProfilePage() {
</div>
</div>
{/* Accuracy bar */}
{evaluated > 0 && (
<div className={`card ${styles.accuracyCard}`}>
<div className={styles.accuracyHeader}>