feat: rank change toast + streak milestone icons

Toast notification on rank change (up/down).
Streak display with milestones: 🔥 at 3, 🔥🔥 at 10,  at 20.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ronny
2026-04-11 19:12:02 +02:00
parent 6c3ad56515
commit 17b86df857
5 changed files with 85 additions and 3 deletions
+4
View File
@@ -7,6 +7,8 @@ import LeaderboardPage from './pages/LeaderboardPage';
import ProfilePage from './pages/ProfilePage'; import ProfilePage from './pages/ProfilePage';
import AdminPage from './pages/AdminPage'; import AdminPage from './pages/AdminPage';
import BottomNav from './components/BottomNav'; import BottomNav from './components/BottomNav';
import Toast from './components/Toast';
import { useRankChange } from './hooks/useRankChange';
import styles from './App.module.css'; import styles from './App.module.css';
const IS_DEV = import.meta.env.DEV || import.meta.env.VITE_TEST_MODE === 'true'; const IS_DEV = import.meta.env.DEV || import.meta.env.VITE_TEST_MODE === 'true';
@@ -28,6 +30,7 @@ function getInitialTheme(): Theme {
export default function App() { export default function App() {
const [theme, setTheme] = useState<Theme>(getInitialTheme); const [theme, setTheme] = useState<Theme>(getInitialTheme);
const { message: rankMsg, dismiss: dismissRank } = useRankChange();
const [devUser, setDevUser] = useState(1); const [devUser, setDevUser] = useState(1);
const [devMatches, setDevMatches] = useState<any[]>([]); const [devMatches, setDevMatches] = useState<any[]>([]);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
@@ -115,6 +118,7 @@ export default function App() {
</Routes> </Routes>
</main> </main>
{rankMsg && <Toast message={rankMsg} onDismiss={dismissRank} />}
<BottomNav /> <BottomNav />
{IS_DEV && DevPanel && ( {IS_DEV && DevPanel && (
+25
View File
@@ -0,0 +1,25 @@
.toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: color-mix(in srgb, var(--surface-high) 95%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--text-primary);
padding: 12px 20px;
border-radius: var(--radius-md);
font-size: 0.9rem;
font-weight: 500;
z-index: 300;
cursor: pointer;
animation: slideDown 0.3s ease;
border: 1px solid rgba(75, 183, 248, 0.15);
max-width: 90%;
text-align: center;
}
@keyframes slideDown {
from { transform: translateX(-50%) translateY(-100%); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import styles from './Toast.module.css';
interface Props {
message: string;
onDismiss: () => void;
duration?: number;
}
export default function Toast({ message, onDismiss, duration = 5000 }: Props) {
useEffect(() => {
const timer = setTimeout(onDismiss, duration);
return () => clearTimeout(timer);
}, [onDismiss, duration]);
return (
<div className={styles.toast} onClick={onDismiss}>
{message}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { useState, useEffect } from 'react';
import { api } from '../api/client';
const RANK_KEY = 'tippspiel_last_rank';
export function useRankChange() {
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
api.getMyStats().then(stats => {
if (!stats.rank) return;
const lastRank = parseInt(localStorage.getItem(RANK_KEY) || '0');
if (lastRank > 0 && lastRank !== stats.rank) {
if (stats.rank < lastRank) {
setMessage(`⬆️ Du bist auf Platz ${stats.rank} aufgestiegen!`);
} else {
setMessage(`⬇️ Du bist auf Platz ${stats.rank} gerutscht — hol dir die Punkte zurück!`);
}
}
localStorage.setItem(RANK_KEY, String(stats.rank));
}).catch(() => {});
}, []);
function dismiss() { setMessage(null); }
return { message, dismiss };
}
+9 -3
View File
@@ -7,6 +7,14 @@ interface Props {
devUser?: number; 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 formatCountdown(minutes: number): string { function formatCountdown(minutes: number): string {
if (minutes < 60) return `in ${minutes} Min`; if (minutes < 60) return `in ${minutes} Min`;
if (minutes < 60 * 24) return `in ${Math.floor(minutes / 60)}h`; if (minutes < 60 * 24) return `in ${Math.floor(minutes / 60)}h`;
@@ -108,9 +116,7 @@ export default function DashboardPage(_props: Props) {
<span className={styles.statLabel}>Punkte</span> <span className={styles.statLabel}>Punkte</span>
</div> </div>
<div className={styles.statTile}> <div className={styles.statTile}>
<span className={styles.statValue}> <span className={styles.statValue}>{formatStreak(stats.streak)}</span>
{stats.streak > 0 ? `${stats.streak} 🔥` : stats.streak}
</span>
<span className={styles.statLabel}>Streak</span> <span className={styles.statLabel}>Streak</span>
</div> </div>
</div> </div>