diff --git a/frontend/package.json b/frontend/package.json index 7c16f33..b05b867 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ }, "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "canvas-confetti": "^1.9.3" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fd762f8..84b88dd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,6 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import WinnerCelebration from "./components/WinnerCelebration"; import { api } from "./api/client"; import { cycleTag } from "./utils/cycleTag"; @@ -7,6 +9,7 @@ import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage"; import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles"; import { styles } from "./styles/styles"; import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes"; +import { stylesTokens } from "./styles/theme"; import AdminPanel from "./components/AdminPanel"; import LoginPage from "./components/LoginPage"; @@ -69,6 +72,38 @@ export default function App() { const [statsLoading, setStatsLoading] = useState(false); const [statsError, setStatsError] = useState(""); + // ===== Join Snack (bottom toast) ===== + const [snack, setSnack] = useState(""); + const snackTimerRef = useRef(null); + + // track members to detect joins + const lastMemberIdsRef = useRef(new Set()); + const membersBaselineRef = useRef(false); + + // ===== Winner Celebration ===== + const [celebrateOpen, setCelebrateOpen] = useState(false); + const [celebrateName, setCelebrateName] = useState(""); + + // baseline per game: beim ersten Meta-Load NICHT feiern + const winnerBaselineRef = useRef(false); + const lastWinnerIdRef = useRef(null); + + const showSnack = (msg) => { + setSnack(msg); + if (snackTimerRef.current) clearTimeout(snackTimerRef.current); + snackTimerRef.current = setTimeout(() => setSnack(""), 1800); + }; + + const vibrate = (pattern) => { + try { + if (typeof navigator !== "undefined" && "vibrate" in navigator) { + navigator.vibrate(pattern); + } + } catch { + // ignore + } + }; + const load = async () => { const m = await api("/auth/me"); setMe(m); @@ -91,12 +126,46 @@ export default function App() { const loadGameMeta = async () => { if (!gameId) return; + const meta = await api(`/games/${gameId}`); setGameMeta(meta); setWinnerUserId(meta?.winner_user_id || ""); const mem = await api(`/games/${gameId}/members`); setMembers(mem); + + // ✅ detect new members (join notifications for everyone) + try { + const prev = lastMemberIdsRef.current; + const nowIds = new Set((mem || []).map((m) => String(m.id))); + + if (!membersBaselineRef.current) { + // first load for this game -> set baseline, no notification + membersBaselineRef.current = true; + lastMemberIdsRef.current = nowIds; + return; + } + + const added = (mem || []).filter((m) => !prev.has(String(m.id))); + + if (added.length > 0) { + const names = added + .map((m) => ((m.display_name || "").trim() || (m.email || "").trim() || "Jemand")) + .slice(0, 3); + + const msg = + added.length === 1 + ? `✨ ${names[0]} ist beigetreten` + : `✨ ${names.join(", ")} ${added.length > 3 ? `(+${added.length - 3}) ` : ""}sind beigetreten`; + + showSnack(msg); + vibrate(25); // dezent & kurz + } + + lastMemberIdsRef.current = nowIds; + } catch { + // ignore snack errors + } }; // Dropdown outside click @@ -121,6 +190,16 @@ export default function App() { // on game change useEffect(() => { + // reset join detection baseline when switching games + membersBaselineRef.current = false; + lastMemberIdsRef.current = new Set(); + + // reset winner celebration baseline when switching games + winnerBaselineRef.current = false; + lastWinnerIdRef.current = null; + setCelebrateOpen(false); + setCelebrateName(""); + (async () => { if (!gameId) return; try { @@ -161,6 +240,35 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [me?.id, gameId]); + useEffect(() => { + // wid kann auch "" sein (kein Sieger) + const wid = gameMeta?.winner_user_id ? String(gameMeta.winner_user_id) : ""; + + // Baseline beim ersten Meta-Load setzen – egal ob Winner existiert oder nicht + if (!winnerBaselineRef.current) { + winnerBaselineRef.current = true; + lastWinnerIdRef.current = wid; // kann "" sein + return; + } + + // Nur reagieren, wenn sich wid ändert + if (lastWinnerIdRef.current !== wid) { + lastWinnerIdRef.current = wid; + + // wenn wid leer wird (reset), nicht feiern + if (!wid) return; + + const name = + (gameMeta?.winner_display_name || "").trim() || + (gameMeta?.winner_email || "").trim() || + "Jemand"; + + setCelebrateName(name); + setCelebrateOpen(true); + } + }, [gameMeta?.winner_user_id, gameMeta?.winner_display_name, gameMeta?.winner_email]); + + // ===== Auth actions ===== const doLogin = async () => { await api("/auth/login", { @@ -179,6 +287,12 @@ export default function App() { setGameMeta(null); setMembers([]); setWinnerUserId(""); + + // reset winner celebration on logout + winnerBaselineRef.current = false; + lastWinnerIdRef.current = null; + setCelebrateOpen(false); + setCelebrateName(""); }; // ===== Password ===== @@ -263,15 +377,23 @@ export default function App() { // ===== New game flow ===== const createGame = async () => { - // ✅ wichtig: alten Game-State weg, damit nix "hängen" bleibt + // ✅ alten Game-State komplett loswerden, damit nix am alten Spiel "hängen bleibt" setSheet(null); setGameMeta(null); setMembers([]); setWinnerUserId(""); setPulseId(null); + + // auch Chip-Modal-State resetten setChipOpen(false); setChipEntry(null); + // reset winner celebration baseline for the new game + winnerBaselineRef.current = false; + lastWinnerIdRef.current = null; + setCelebrateOpen(false); + setCelebrateName(""); + const g = await api("/games", { method: "POST", body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }), @@ -280,7 +402,7 @@ export default function App() { const gs = await api("/games"); setGames(gs); - // ✅ auf neues Spiel wechseln (triggered dann reloadSheet/loadGameMeta via effect) + // ✅ auf neues Game wechseln (triggert reloadSheet/loadGameMeta via effect) setGameId(g.id); return g; // includes code @@ -425,6 +547,13 @@ export default function App() { return (