From 3a9da788e50e67b50734f66c89af5bbc0b7e5917 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 17:05:19 +0100 Subject: [PATCH] Add winner celebration feature with confetti effects This update introduces a winner celebration overlay displayed when a game's winner is announced. It includes confetti animations along with a congratulatory message, enhancing user experience. The feature resets appropriately during game transitions and logout to maintain correct behavior. --- frontend/package.json | 3 +- frontend/src/App.jsx | 63 ++++++- frontend/src/components/WinnerCelebration.jsx | 170 ++++++++++++++++++ 3 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/WinnerCelebration.jsx 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 ba7ca9f..634bd7b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,6 @@ 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"; @@ -79,6 +80,14 @@ export default function App() { 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); @@ -185,6 +194,12 @@ export default function App() { 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 { @@ -225,6 +240,32 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [me?.id, gameId]); + // ✅ Winner Celebration Trigger: + // - beim ersten Load eines Spiels NICHT feiern + // - feiern nur, wenn winner_user_id sich danach ändert (Host speichert) + useEffect(() => { + const wid = gameMeta?.winner_user_id ? String(gameMeta.winner_user_id) : ""; + if (!wid) return; + + if (!winnerBaselineRef.current) { + winnerBaselineRef.current = true; + lastWinnerIdRef.current = wid; + return; + } + + if (lastWinnerIdRef.current !== wid) { + lastWinnerIdRef.current = wid; + + 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", { @@ -243,6 +284,12 @@ export default function App() { setGameMeta(null); setMembers([]); setWinnerUserId(""); + + // reset winner celebration on logout + winnerBaselineRef.current = false; + lastWinnerIdRef.current = null; + setCelebrateOpen(false); + setCelebrateName(""); }; // ===== Password ===== @@ -338,6 +385,12 @@ export default function App() { 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() }), @@ -491,6 +544,13 @@ export default function App() { return (
+ {/* Winner Celebration Overlay */} + setCelebrateOpen(false)} + /> + , document.body - ) - } + )}
); } diff --git a/frontend/src/components/WinnerCelebration.jsx b/frontend/src/components/WinnerCelebration.jsx new file mode 100644 index 0000000..d69aecd --- /dev/null +++ b/frontend/src/components/WinnerCelebration.jsx @@ -0,0 +1,170 @@ +import React, { useEffect } from "react"; +import { createPortal } from "react-dom"; +import confetti from "canvas-confetti"; +import { stylesTokens } from "../styles/theme"; + +export default function WinnerCelebration({ open, winnerName, onClose }) { + useEffect(() => { + if (!open) return; + + // Scroll lock + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const reduceMotion = + window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + if (!reduceMotion) { + const end = Date.now() + 4500; + + // 2 große Bursts + confetti({ + particleCount: 150, + spread: 95, + startVelocity: 38, + origin: { x: 0.12, y: 0.62 }, + zIndex: 999999, + }); + confetti({ + particleCount: 150, + spread: 95, + startVelocity: 38, + origin: { x: 0.88, y: 0.62 }, + zIndex: 999999, + }); + + // “Rain” über die Zeit + (function frame() { + confetti({ + particleCount: 6, + spread: 70, + startVelocity: 32, + origin: { x: Math.random(), y: Math.random() * 0.18 }, + scalar: 1.0, + zIndex: 999999, + }); + if (Date.now() < end) requestAnimationFrame(frame); + })(); + } + + const t = setTimeout(() => onClose?.(), 5500); + + return () => { + clearTimeout(t); + document.body.style.overflow = prevOverflow; + }; + }, [open, onClose]); + + useEffect(() => { + if (!open) return; + const onKey = (e) => e.key === "Escape" && onClose?.(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + const node = ( +
+
e.stopPropagation()} + style={{ + width: "min(560px, 92vw)", + borderRadius: 22, + padding: "16px 16px", + border: `1px solid ${stylesTokens.panelBorder}`, + background: stylesTokens.panelBg, + boxShadow: "0 22px 90px rgba(0,0,0,0.60)", + backdropFilter: "blur(10px)", + WebkitBackdropFilter: "blur(10px)", + position: "relative", + overflow: "hidden", + }} + > + {/* dezente “Gold Line” */} +
+ + {/* kleine “shine” Ecke oben rechts */} +
+ +
+
🏆
+ +
+ Spieler{" "} + + {winnerName || "Unbekannt"} + {" "} + hat die richtige Lösung! +
+ +
+ Fall gelöst. Respekt. ✨ +
+ +
+ +
+
+
+
+ ); + + return createPortal(node, document.body); +}