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:
@@ -9,7 +9,7 @@ interface Props {
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
SCHEDULED: 'Geplant',
|
||||
TIMED: 'Terminiert',
|
||||
IN_PLAY: '🔴 Live',
|
||||
IN_PLAY: 'Live',
|
||||
PAUSED: 'Pause',
|
||||
FINISHED: 'Beendet',
|
||||
POSTPONED: 'Verschoben',
|
||||
@@ -18,20 +18,31 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
|
||||
function formatKickoff(utcDate: string): string {
|
||||
return new Date(utcDate).toLocaleString('de-DE', {
|
||||
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin'
|
||||
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>;
|
||||
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>;
|
||||
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>;
|
||||
}
|
||||
|
||||
function FlagBox({ crest, name }: { crest: string | null; name: string }) {
|
||||
return (
|
||||
<div className={styles.flagBox}>
|
||||
{crest
|
||||
? <img className={styles.crest} src={crest} alt={name} />
|
||||
: <span style={{ fontSize: 18 }}>🏳️</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MatchCard({ match, onTip }: Props) {
|
||||
const isFinished = match.status === 'FINISHED';
|
||||
const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED';
|
||||
@@ -39,51 +50,53 @@ export default function MatchCard({ match, onTip }: Props) {
|
||||
|
||||
return (
|
||||
<div className={`card ${styles.card} ${isLive ? styles.live : ''}`}>
|
||||
{/* Status + Kickoff */}
|
||||
|
||||
{/* Top row: Status / Kickoff / Badges */}
|
||||
<div className={styles.topRow}>
|
||||
<span className={`${styles.status} ${isLive ? styles.statusLive : ''}`}>
|
||||
{STATUS_LABELS[match.status] ?? match.status}
|
||||
{isLive && '● '}{STATUS_LABELS[match.status] ?? match.status}
|
||||
</span>
|
||||
<span className={styles.kickoff}>{formatKickoff(match.utcDate)}</span>
|
||||
{match.group && (
|
||||
<span className={styles.group}>
|
||||
{match.group.replace('GROUP_', 'Gruppe ')}
|
||||
</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 */}
|
||||
{/* Home */}
|
||||
<div className={styles.teamHome}>
|
||||
{match.homeTeam.crest && (
|
||||
<img className={styles.crest} src={match.homeTeam.crest} alt="" />
|
||||
)}
|
||||
<span className={styles.teamName}>{match.homeTeam.name}</span>
|
||||
<FlagBox crest={match.homeTeam.crest} name={match.homeTeam.name} />
|
||||
</div>
|
||||
|
||||
{/* Score / VS */}
|
||||
{/* Score / Kickoff time */}
|
||||
<div className={styles.scoreBox}>
|
||||
{isFinished || isLive ? (
|
||||
<span className={styles.score}>
|
||||
{match.score.home ?? '–'} : {match.score.away ?? '–'}
|
||||
{match.score.home ?? '–'} : {match.score.away ?? '–'}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.vs}>vs</span>
|
||||
<div className={styles.kickoffCenter}>
|
||||
<span className={styles.kickoffCenterTime}>{formatKickoff(match.utcDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Away Team */}
|
||||
{/* Away */}
|
||||
<div className={styles.teamAway}>
|
||||
<FlagBox crest={match.awayTeam.crest} name={match.awayTeam.name} />
|
||||
<span className={styles.teamName}>{match.awayTeam.name}</span>
|
||||
{match.awayTeam.crest && (
|
||||
<img className={styles.crest} src={match.awayTeam.crest} alt="" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tipp-Bereich */}
|
||||
{/* Tipp area */}
|
||||
<div className={styles.tipRow}>
|
||||
{hasTip ? (
|
||||
<div className={styles.tipDisplay}>
|
||||
<span className={styles.tipLabel}>Dein Tipp:</span>
|
||||
<span className={styles.tipLabel}>Dein Tipp</span>
|
||||
<span className={styles.tipScore}>
|
||||
{match.userTip!.home} : {match.userTip!.away}
|
||||
</span>
|
||||
@@ -93,7 +106,7 @@ export default function MatchCard({ match, onTip }: Props) {
|
||||
match.userTip!.points === 1 ? styles.tendency : styles.wrong
|
||||
}`}>
|
||||
{match.userTip!.points === 3 ? '🎯 3 Punkte' :
|
||||
match.userTip!.points === 1 ? '✓ 1 Punkt' : '✗ 0 Punkte'}
|
||||
match.userTip!.points === 1 ? '✓ 1 Punkt' : '✗ 0 Punkte'}
|
||||
</span>
|
||||
)}
|
||||
{match.tippable && (
|
||||
@@ -102,7 +115,7 @@ export default function MatchCard({ match, onTip }: Props) {
|
||||
</div>
|
||||
) : match.tippable ? (
|
||||
<button className={`btn-primary ${styles.tipBtn}`} onClick={onTip}>
|
||||
⚡ Tipp abgeben
|
||||
Tipp abgeben
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.noTip}>Kein Tipp abgegeben</span>
|
||||
|
||||
Reference in New Issue
Block a user