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:
@@ -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<Theme>(getInitialTheme);
|
||||
const { message: rankMsg, dismiss: dismissRank } = useRankChange();
|
||||
const [devUser, setDevUser] = useState(1);
|
||||
const [devMatches, setDevMatches] = useState<any[]>([]);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
@@ -115,6 +118,7 @@ export default function App() {
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
{rankMsg && <Toast message={rankMsg} onDismiss={dismissRank} />}
|
||||
<BottomNav />
|
||||
|
||||
{IS_DEV && DevPanel && (
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
<span className={styles.statLabel}>Punkte</span>
|
||||
</div>
|
||||
<div className={styles.statTile}>
|
||||
<span className={styles.statValue}>
|
||||
{stats.streak > 0 ? `${stats.streak} 🔥` : stats.streak}
|
||||
</span>
|
||||
<span className={styles.statValue}>{formatStreak(stats.streak)}</span>
|
||||
<span className={styles.statLabel}>Streak</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user