e27a62a37b
- 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>
157 lines
6.6 KiB
TypeScript
157 lines
6.6 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { api, LeaderboardEntry, LeaderboardResponse } from '../api/client';
|
|
import styles from './LeaderboardPage.module.css';
|
|
|
|
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 [data, setData] = useState<LeaderboardResponse | null>(null);
|
|
const [tippableCount, setTippableCount] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
api.getLeaderboard(),
|
|
api.getMatches(),
|
|
]).then(([lb, matches]) => {
|
|
setData(lb);
|
|
setTippableCount(matches.matches.filter(m => m.tippable && !m.userTip).length);
|
|
}).finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
if (loading) return (
|
|
<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>
|
|
<div className={styles.meta}>
|
|
{totalParticipants} Teilnehmer{currentUserRank ? ` · Du: Platz ${currentUserRank}` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
{entries.length === 0 ? (
|
|
<div className={styles.empty}>
|
|
Noch keine Punkte vergeben. Spiele müssen erst abgeschlossen sein.
|
|
</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>
|
|
)}
|
|
|
|
{/* ── 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>
|
|
);
|
|
}
|