From 17b86df85756872ef2741664d06007a489f278e8 Mon Sep 17 00:00:00 2001 From: Ronny Date: Sat, 11 Apr 2026 19:12:02 +0200 Subject: [PATCH] feat: rank change toast + streak milestone icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toast notification on rank change (up/down). Streak display with milestones: 🔥 at 3, 🔥🔥 at 10, ⚡ at 20. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.tsx | 4 ++++ frontend/src/components/Toast.module.css | 25 +++++++++++++++++++++++ frontend/src/components/Toast.tsx | 21 +++++++++++++++++++ frontend/src/hooks/useRankChange.ts | 26 ++++++++++++++++++++++++ frontend/src/pages/DashboardPage.tsx | 12 ++++++++--- 5 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/Toast.module.css create mode 100644 frontend/src/components/Toast.tsx create mode 100644 frontend/src/hooks/useRankChange.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b7390c..ca1580b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,8 @@ import LeaderboardPage from './pages/LeaderboardPage'; import ProfilePage from './pages/ProfilePage'; import AdminPage from './pages/AdminPage'; import BottomNav from './components/BottomNav'; +import Toast from './components/Toast'; +import { useRankChange } from './hooks/useRankChange'; import styles from './App.module.css'; 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() { const [theme, setTheme] = useState(getInitialTheme); + const { message: rankMsg, dismiss: dismissRank } = useRankChange(); const [devUser, setDevUser] = useState(1); const [devMatches, setDevMatches] = useState([]); const [refreshKey, setRefreshKey] = useState(0); @@ -115,6 +118,7 @@ export default function App() { + {rankMsg && } {IS_DEV && DevPanel && ( diff --git a/frontend/src/components/Toast.module.css b/frontend/src/components/Toast.module.css new file mode 100644 index 0000000..c9ddd3c --- /dev/null +++ b/frontend/src/components/Toast.module.css @@ -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; } +} diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..75c40b3 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -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 ( +
+ {message} +
+ ); +} diff --git a/frontend/src/hooks/useRankChange.ts b/frontend/src/hooks/useRankChange.ts new file mode 100644 index 0000000..7227ea8 --- /dev/null +++ b/frontend/src/hooks/useRankChange.ts @@ -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(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 }; +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 1089fd4..96f239b 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -7,6 +7,14 @@ 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 formatCountdown(minutes: number): string { if (minutes < 60) return `in ${minutes} Min`; if (minutes < 60 * 24) return `in ${Math.floor(minutes / 60)}h`; @@ -108,9 +116,7 @@ export default function DashboardPage(_props: Props) { Punkte
- - {stats.streak > 0 ? `${stats.streak} 🔥` : stats.streak} - + {formatStreak(stats.streak)} Streak