From f6ab2c719dbc0450134337fdb02e2f85bc924703 Mon Sep 17 00:00:00 2001 From: Ronny Date: Sat, 11 Apr 2026 19:10:22 +0200 Subject: [PATCH] feat: Punkte-Reveal with confetti animation Shows animated reveal overlay for unseen match results. Exact match (3pts) triggers confetti explosion. Each reveal shown only once (localStorage tracking). Co-Authored-By: Claude Sonnet 4.6 --- frontend/package-lock.json | 19 ++++ frontend/package.json | 2 + .../src/components/ConfettiReveal.module.css | 91 +++++++++++++++++++ frontend/src/components/ConfettiReveal.tsx | 43 +++++++++ frontend/src/hooks/useRevealQueue.ts | 36 ++++++++ frontend/src/pages/MatchesPage.tsx | 8 ++ 6 files changed, 199 insertions(+) create mode 100644 frontend/src/components/ConfettiReveal.module.css create mode 100644 frontend/src/components/ConfettiReveal.tsx create mode 100644 frontend/src/hooks/useRevealQueue.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 560e157..1a9570e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,14 @@ "name": "wm2026-tippspiel-frontend", "version": "1.0.0", "dependencies": { + "canvas-confetti": "^1.9.4", "lucide-react": "^1.7.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0" }, "devDependencies": { + "@types/canvas-confetti": "^1.9.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", @@ -1155,6 +1157,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1279,6 +1288,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 521c4ac..9460d50 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,12 +8,14 @@ "preview": "vite preview" }, "dependencies": { + "canvas-confetti": "^1.9.4", "lucide-react": "^1.7.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0" }, "devDependencies": { + "@types/canvas-confetti": "^1.9.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/frontend/src/components/ConfettiReveal.module.css b/frontend/src/components/ConfettiReveal.module.css new file mode 100644 index 0000000..c5902aa --- /dev/null +++ b/frontend/src/components/ConfettiReveal.module.css @@ -0,0 +1,91 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + animation: fadeIn 0.3s ease; +} + +.card { + background: var(--surface-mid); + border-radius: var(--radius-lg); + padding: 32px 24px; + text-align: center; + max-width: 320px; + width: 90%; + animation: scaleIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.result { + font-size: 1.3rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.tipLine { + font-size: 0.95rem; + color: var(--text-secondary); + margin-bottom: 16px; +} + +.badge { + display: inline-block; + padding: 8px 20px; + border-radius: 20px; + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 8px; +} + +.badgeExact { + background: linear-gradient(135deg, var(--gold), #FFD700); + color: #1a1a1a; + animation: shimmer 2s ease-in-out; +} + +.badgeTendency { + background: var(--success); + color: #1a1a1a; +} + +.badgeWrong { + background: var(--text-muted); + color: var(--bg-deep); +} + +.label { + font-size: 1rem; + color: var(--text-primary); + margin-bottom: 20px; +} + +.dismissBtn { + background: var(--primary); + color: white; + border: none; + border-radius: var(--radius-sm); + padding: 10px 32px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes scaleIn { + from { transform: scale(0.8); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +@keyframes shimmer { + 0% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); } + 50% { box-shadow: 0 0 20px rgba(254, 174, 50, 0.6); } + 100% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); } +} diff --git a/frontend/src/components/ConfettiReveal.tsx b/frontend/src/components/ConfettiReveal.tsx new file mode 100644 index 0000000..b6f270c --- /dev/null +++ b/frontend/src/components/ConfettiReveal.tsx @@ -0,0 +1,43 @@ +import { useEffect, useRef } from 'react'; +import confetti from 'canvas-confetti'; +import { Match } from '../api/client'; +import styles from './ConfettiReveal.module.css'; + +interface Props { + match: Match; + onDismiss: () => void; +} + +export default function ConfettiReveal({ match, onDismiss }: Props) { + const didFire = useRef(false); + const tip = match.userTip!; + const points = tip.points!; + + useEffect(() => { + if (points === 3 && !didFire.current) { + didFire.current = true; + confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } }); + } + }, [points]); + + const resultLabel = points === 3 ? 'EXAKT! 🎉' : points === 1 ? 'Richtige Tendenz! 👏' : 'Knapp daneben... 😅'; + const badgeClass = points === 3 ? styles.badgeExact : points === 1 ? styles.badgeTendency : styles.badgeWrong; + + return ( +
+
e.stopPropagation()}> +
+ {match.homeTeam.shortName} {match.score.home}:{match.score.away} {match.awayTeam.shortName} +
+
+ Dein Tipp: {tip.home}:{tip.away} +
+
+ {points} {points === 1 ? 'Punkt' : 'Punkte'} +
+
{resultLabel}
+ +
+
+ ); +} diff --git a/frontend/src/hooks/useRevealQueue.ts b/frontend/src/hooks/useRevealQueue.ts new file mode 100644 index 0000000..896101f --- /dev/null +++ b/frontend/src/hooks/useRevealQueue.ts @@ -0,0 +1,36 @@ +import { useState, useEffect } from 'react'; +import { Match } from '../api/client'; + +const SEEN_KEY = 'tippspiel_seen_results'; + +function getSeenIds(): Set { + try { + return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) || '[]')); + } catch { return new Set(); } +} + +function markSeen(matchId: number) { + const seen = getSeenIds(); + seen.add(matchId); + localStorage.setItem(SEEN_KEY, JSON.stringify([...seen])); +} + +export function useRevealQueue(matches: Match[]) { + const [queue, setQueue] = useState([]); + + useEffect(() => { + const seen = getSeenIds(); + const unseen = matches.filter( + m => m.status === 'FINISHED' && m.userTip && m.userTip.points !== null && !seen.has(m.id) + ); + setQueue(unseen); + }, [matches]); + + function dismissCurrent() { + if (queue.length === 0) return; + markSeen(queue[0].id); + setQueue(q => q.slice(1)); + } + + return { current: queue[0] || null, remaining: queue.length, dismissCurrent }; +} diff --git a/frontend/src/pages/MatchesPage.tsx b/frontend/src/pages/MatchesPage.tsx index 31a8e9e..5721ece 100644 --- a/frontend/src/pages/MatchesPage.tsx +++ b/frontend/src/pages/MatchesPage.tsx @@ -2,6 +2,8 @@ import { useState, useEffect, useCallback } from 'react'; import { api, Match } from '../api/client'; import MatchCard from '../components/MatchCard'; import TipModal from '../components/TipModal'; +import { useRevealQueue } from '../hooks/useRevealQueue'; +import ConfettiReveal from '../components/ConfettiReveal'; import styles from './MatchesPage.module.css'; type Section = { @@ -101,6 +103,8 @@ export default function MatchesPage() { }); } + const { current: revealMatch, dismissCurrent } = useRevealQueue(allMatches); + const filteredMatches = allMatches.filter(m => !stageFilter || m.stage === stageFilter); const sections = groupIntoSections(filteredMatches); @@ -110,6 +114,10 @@ export default function MatchesPage() { return (
+ {revealMatch && ( + + )} + {/* Header Stats */}