This repository has been archived on 2026-05-06. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tippspiel/frontend/src/pages/DashboardPage.tsx
T
Ronny 34be6546b1
Build & Deploy Tippspiel / build (push) Successful in 51s
style: Lucide TrendingUp/Down icons for rank change arrows
2026-04-12 16:07:02 +02:00

229 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { TrendingUp, TrendingDown } from 'lucide-react';
import { api, DashboardData, Match } from '../api/client';
import TipModal from '../components/TipModal';
import styles from './DashboardPage.module.css';
interface Props {
devUser?: number;
}
function formatStreak(streak: number): string {
if (streak >= 20) return `${streak}`;
if (streak >= 10) return `🔥🔥${streak}`;
if (streak >= 3) return `🔥${streak}`;
if (streak > 0) return String(streak);
return '0';
}
function formatKickoff(utcDate: string): string {
return new Date(utcDate).toLocaleString('de-DE', {
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin',
});
}
function formatCountdown(minutes: number): string {
if (minutes < 60) return `in ${Math.round(minutes)} Min`;
if (minutes < 60 * 24) return `in ${Math.floor(minutes / 60)}h`;
const days = Math.floor(minutes / (60 * 24));
return `in ${days} ${days === 1 ? 'Tag' : 'Tagen'}`;
}
export default function DashboardPage(_props: Props) {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [tipMatch, setTipMatch] = useState<Match | null>(null);
const navigate = useNavigate();
useEffect(() => {
setLoading(true);
setError(false);
api.getDashboard()
.then(d => { setData(d); setLoading(false); })
.catch(() => { setError(true); setLoading(false); });
}, []);
// Keep stored streak up to date (hook must be before early returns)
const STREAK_KEY = 'tippspiel_last_streak';
useEffect(() => {
if (data && data.stats.streak > 0) {
localStorage.setItem(STREAK_KEY, String(data.stats.streak));
}
}, [data]);
if (loading) return <div className={styles.loading}>Laden...</div>;
if (error || !data) return <div className={styles.error}>Dashboard konnte nicht geladen werden.</div>;
const { hero, stats, nudges } = data;
const lastRank = parseInt(localStorage.getItem('tippspiel_last_rank') || '0');
const rankDiff = lastRank > 0 && stats.rank !== null ? lastRank - stats.rank : 0;
// Streak break detection via localStorage
const lastStreak = parseInt(localStorage.getItem(STREAK_KEY) || '0');
const streakBroken = lastStreak >= 3 && stats.streak === 0;
return (
<div className={styles.dashboard}>
{/* Hero Card */}
<div className={styles.hero} onClick={() => navigate('/spiele')}>
{/* Radial glow background effect */}
<div className={styles.heroGlow} />
<div className={styles.heroHeader}>
<span className={styles.heroLabel}>Nächstes Spiel</span>
{hero && (
<span className={styles.heroCountdown}>
<span className={styles.countdownDot} />
{formatCountdown(hero.match.minutesUntilKickoff).toUpperCase()}
</span>
)}
</div>
{hero ? (
<>
{/* Teams with LED time in center */}
<div className={styles.heroTeams}>
<div className={styles.heroTeam}>
<div className={styles.heroCrestBox}>
{hero.match.homeTeam.crest ? (
<img src={hero.match.homeTeam.crest} alt={hero.match.homeTeam.name} className={styles.heroCrest} />
) : (
<span className={styles.heroCrestFallback}>🏳</span>
)}
</div>
<span className={styles.heroTeamName}>{hero.match.homeTeam.shortName}</span>
</div>
{/* Center: LED time — each digit fixed-width for even spacing */}
<div className={styles.heroCenter}>
<span className={styles.heroLED}>
{formatKickoff(hero.match.utcDate).split('').map((ch, i) => (
<span key={i} className={ch === ':' ? styles.ledColon : styles.ledDigit}>{ch}</span>
))}
</span>
</div>
<div className={styles.heroTeam}>
<div className={styles.heroCrestBox}>
{hero.match.awayTeam.crest ? (
<img src={hero.match.awayTeam.crest} alt={hero.match.awayTeam.name} className={styles.heroCrest} />
) : (
<span className={styles.heroCrestFallback}>🏳</span>
)}
</div>
<span className={styles.heroTeamName}>{hero.match.awayTeam.shortName}</span>
</div>
</div>
{/* CTA or Tip Display */}
{hero.userTip ? (
<div className={styles.heroTip}>
Dein Tipp: {hero.userTip.home}:{hero.userTip.away}
</div>
) : hero.tippable ? (
<button
className={styles.heroTipBtn}
onClick={e => {
e.stopPropagation();
setTipMatch({
id: hero.match.id,
externalId: 0,
utcDate: hero.match.utcDate,
status: hero.match.status,
stage: '',
group: null,
homeTeam: hero.match.homeTeam,
awayTeam: hero.match.awayTeam,
score: { home: null, away: null },
userTip: null,
minutesUntilKickoff: hero.match.minutesUntilKickoff,
tippable: true,
});
}}
>
Jetzt tippen
</button>
) : null}
</>
) : (
<p className={styles.heroEmpty}>Keine anstehenden Spiele</p>
)}
</div>
{/* Stats Row */}
<div className={styles.statsRow}>
<div className={`card ${styles.statTile}`}>
<span className={styles.statValue}>
{stats.rank !== null ? stats.rank : '—'}
{rankDiff > 0 && <span className={styles.rankUp}><TrendingUp size={14} strokeWidth={2.5} /></span>}
{rankDiff < 0 && <span className={styles.rankDown}><TrendingDown size={14} strokeWidth={2.5} /></span>}
</span>
<span className={styles.statLabel}>Dein Rang</span>
</div>
<div className={`card ${styles.statTile}`}>
<span className={`${styles.statValue} ${styles.statGold}`}>{stats.totalPoints}</span>
<span className={styles.statLabel}>Punkte</span>
</div>
<div className={`card ${styles.statTile}`}>
<span className={styles.statValue}>{formatStreak(stats.streak)}</span>
<span className={styles.statLabel}>Streak</span>
</div>
</div>
{/* Nudges */}
{(streakBroken || nudges.length > 0) && (
<div className={styles.nudges}>
{streakBroken && (
<div
className={`card ${styles.nudge} ${styles.nudgeStreak}`}
onClick={() => {
localStorage.removeItem(STREAK_KEY);
navigate('/spiele');
}}
>
<span className={styles.nudgeIcon}>💔</span>
<span className={styles.nudgeText}>
Deine {lastStreak}er-Serie ist gerissen! Starte eine neue.
</span>
</div>
)}
{nudges.map((nudge, i) => (
<div
key={i}
className={`card ${styles.nudge}`}
onClick={() => {
if (nudge.type === 'untipped') navigate('/spiele');
else if (nudge.type === 'leader') navigate('/rangliste');
}}
>
<span className={styles.nudgeIcon}>
{nudge.type === 'untipped' ? '📅' : nudge.type === 'leader' ? '🏆' : '🎯'}
</span>
<span className={styles.nudgeText}>{nudge.text}</span>
</div>
))}
</div>
)}
{tipMatch && (
<TipModal
match={tipMatch}
onClose={() => setTipMatch(null)}
onSaved={(_matchId, tipHome, tipAway) => {
setTipMatch(null);
if (data && data.hero) {
setData({
...data,
hero: { ...data.hero, userTip: { home: tipHome, away: tipAway } },
});
}
}}
/>
)}
</div>
);
}