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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user