Skip to content

Commit 04f5c5a

Browse files
lucksptFrancisca105
authored andcommitted
Improve pick the winner animation
1 parent 86886df commit 04f5c5a

File tree

3 files changed

+469
-8
lines changed

3 files changed

+469
-8
lines changed
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
"use client";
2+
3+
import Image from "next/image";
4+
import { useEffect, useState } from "react";
5+
6+
interface PrizeDrawAnimationProps {
7+
participants: User[];
8+
winner: User;
9+
prize: Prize;
10+
onAnimationComplete?: () => void;
11+
}
12+
13+
type ParticipantPosition = {
14+
user: User;
15+
startX: number;
16+
delay: number;
17+
inHat: boolean;
18+
};
19+
20+
export function PrizeDrawAnimation({
21+
participants,
22+
winner,
23+
prize,
24+
onAnimationComplete,
25+
}: PrizeDrawAnimationProps) {
26+
const [participantPositions, setParticipantPositions] = useState<
27+
ParticipantPosition[]
28+
>([]);
29+
const [isShaking, setIsShaking] = useState(false);
30+
const [showWinner, setShowWinner] = useState(false);
31+
const [showPrize, setShowPrize] = useState(false);
32+
const [animationDuration, setAnimationDuration] = useState(1000);
33+
34+
useEffect(() => {
35+
// Calculate dynamic timing based on participant count
36+
// For few participants (<=10): keep original timing (150ms stagger, 1000ms animation)
37+
// For many participants (>10): scale down to fit in ~5 seconds total
38+
const participantCount = participants.length;
39+
let calculatedStagger = 150;
40+
let calculatedDuration = 1000;
41+
42+
if (participantCount > 10) {
43+
// Target total time of 5 seconds for the dropping phase
44+
const targetTotalTime = 5000;
45+
// Total time = (last participant delay) + animation duration
46+
// targetTotalTime = (participantCount - 1) * stagger + duration
47+
// We want to keep the animation smooth, so minimum duration is 600ms
48+
calculatedDuration = Math.max(600, targetTotalTime * 0.3);
49+
calculatedStagger = (targetTotalTime - calculatedDuration) / Math.max(1, participantCount - 1);
50+
// Ensure minimum stagger for visual effect
51+
calculatedStagger = Math.max(20, calculatedStagger);
52+
}
53+
54+
setAnimationDuration(calculatedDuration);
55+
56+
// Initialize participant positions with random horizontal positions and delays
57+
const positions = participants.map((user, index) => ({
58+
user,
59+
startX: Math.random() * 80 + 10, // Random position between 10% and 90%
60+
delay: index * calculatedStagger, // Stagger the drops with dynamic timing
61+
inHat: false,
62+
}));
63+
setParticipantPositions(positions);
64+
65+
// Set up individual timers to mark each participant as "in hat" after their animation completes
66+
const timers: NodeJS.Timeout[] = [];
67+
positions.forEach((position, index) => {
68+
const dropCompleteTime = position.delay + calculatedDuration; // delay + animation duration
69+
const timer = setTimeout(() => {
70+
setParticipantPositions((prev) => {
71+
const updated = [...prev];
72+
if (updated[index]) {
73+
updated[index] = { ...updated[index], inHat: true };
74+
}
75+
return updated;
76+
});
77+
}, dropCompleteTime);
78+
timers.push(timer);
79+
});
80+
81+
// Calculate total drop time
82+
const totalDropTime = (positions.length - 1) * calculatedStagger + calculatedDuration;
83+
84+
// Start shaking after all participants have dropped
85+
const shakeTimer = setTimeout(() => {
86+
setIsShaking(true);
87+
}, totalDropTime);
88+
89+
// Show winner after shaking
90+
const winnerTimer = setTimeout(() => {
91+
setIsShaking(false);
92+
setShowWinner(true);
93+
}, totalDropTime + 2000); // Shake for 2 seconds
94+
95+
// Show prize after winner is displayed
96+
const prizeTimer = setTimeout(() => {
97+
setShowPrize(true);
98+
onAnimationComplete?.();
99+
}, totalDropTime + 3000); // 1 second after winner appears
100+
101+
return () => {
102+
timers.forEach(timer => clearTimeout(timer));
103+
clearTimeout(shakeTimer);
104+
clearTimeout(winnerTimer);
105+
clearTimeout(prizeTimer);
106+
};
107+
}, [participants, onAnimationComplete]);
108+
109+
return (
110+
<div className="relative w-full h-full flex items-center justify-center overflow-hidden">
111+
{/* Falling participants - they start from above the viewport */}
112+
{!showWinner && (
113+
<div className="absolute inset-0 pointer-events-none">
114+
{participantPositions.map((participant, index) => (
115+
<div
116+
key={participant.user.id}
117+
className="absolute animate-fall"
118+
style={{
119+
left: `${participant.startX}%`,
120+
top: "-100px",
121+
animationDelay: `${participant.delay}ms`,
122+
animationDuration: `${animationDuration}ms`,
123+
animationFillMode: "forwards",
124+
}}
125+
>
126+
<Image
127+
className="size-12 rounded-full object-cover border-2 border-white shadow-lg"
128+
width={48}
129+
height={48}
130+
src={participant.user.img}
131+
alt={participant.user.name}
132+
/>
133+
</div>
134+
))}
135+
</div>
136+
)}
137+
138+
{/* Drawing hat */}
139+
<div
140+
className={`relative transition-transform duration-200 ${
141+
isShaking ? "animate-shake" : ""
142+
} ${showWinner ? "opacity-0 scale-0" : "opacity-100 scale-100"}`}
143+
>
144+
{/* Hat SVG - Simple top hat illustration */}
145+
<svg
146+
width="200"
147+
height="200"
148+
viewBox="0 0 200 200"
149+
fill="none"
150+
xmlns="http://www.w3.org/2000/svg"
151+
className="drop-shadow-xl"
152+
>
153+
{/* Hat brim */}
154+
<ellipse
155+
cx="100"
156+
cy="140"
157+
rx="80"
158+
ry="15"
159+
fill="#2D3748"
160+
stroke="#1A202C"
161+
strokeWidth="2"
162+
/>
163+
{/* Hat body */}
164+
<path
165+
d="M 40 140 L 50 80 Q 50 70 60 70 L 140 70 Q 150 70 150 80 L 160 140 Z"
166+
fill="#4A5568"
167+
stroke="#2D3748"
168+
strokeWidth="2"
169+
/>
170+
{/* Hat top opening (darker to show depth) */}
171+
<ellipse cx="100" cy="75" rx="40" ry="8" fill="#1A202C" />
172+
{/* Hat band */}
173+
<rect
174+
x="55"
175+
y="130"
176+
width="90"
177+
height="12"
178+
rx="2"
179+
fill="#E53E3E"
180+
/>
181+
</svg>
182+
183+
{/* Participant count badge */}
184+
{!showWinner && (
185+
<div className="absolute -top-4 -right-4 bg-sinfo-primary text-white rounded-full size-12 flex items-center justify-center font-bold text-sm shadow-lg">
186+
{participantPositions.filter((p) => p.inHat).length}
187+
</div>
188+
)}
189+
</div>
190+
191+
{/* Winner reveal */}
192+
{showWinner && (
193+
<div className="absolute inset-0 flex items-center justify-center animate-winner-reveal">
194+
<div className="relative flex flex-col items-center gap-6">
195+
<div className="relative">
196+
<Image
197+
className="size-40 rounded-full object-cover shadow-2xl border-4 border-sinfo-primary animate-pulse-slow"
198+
width={160}
199+
height={160}
200+
src={winner.img}
201+
alt={winner.name}
202+
/>
203+
{/* Winner crown/badge */}
204+
<div className="absolute -top-2 -right-2 bg-yellow-400 text-yellow-900 rounded-full size-12 flex items-center justify-center shadow-lg">
205+
<svg
206+
width="24"
207+
height="24"
208+
viewBox="0 0 24 24"
209+
fill="currentColor"
210+
>
211+
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" />
212+
</svg>
213+
</div>
214+
</div>
215+
<span className="text-2xl font-bold text-gray-800 animate-fade-in">
216+
{winner.name}
217+
</span>
218+
219+
{/* Prize display - fades in after winner with absolute positioning to prevent layout shift */}
220+
<div className="relative h-64 w-full flex items-center justify-center">
221+
{showPrize && (
222+
<div className="absolute flex flex-col items-center gap-2 animate-prize-fade-in">
223+
<Image
224+
className="w-48 h-48 object-contain"
225+
width={192}
226+
height={192}
227+
src={prize.img}
228+
alt={prize.name}
229+
/>
230+
<span className="text-xl font-medium text-gray-700">
231+
{prize.name}
232+
</span>
233+
</div>
234+
)}
235+
</div>
236+
</div>
237+
</div>
238+
)}
239+
240+
<style jsx>{`
241+
@keyframes fall {
242+
0% {
243+
transform: translateY(0) rotate(0deg);
244+
opacity: 0;
245+
}
246+
10% {
247+
opacity: 1;
248+
}
249+
80% {
250+
opacity: 1;
251+
}
252+
100% {
253+
transform: translateY(calc(50vh + 100px)) rotate(360deg);
254+
opacity: 0;
255+
}
256+
}
257+
258+
@keyframes shake {
259+
0%,
260+
100% {
261+
transform: translateX(0) rotate(0deg);
262+
}
263+
10%,
264+
30%,
265+
50%,
266+
70%,
267+
90% {
268+
transform: translateX(-10px) rotate(-5deg);
269+
}
270+
20%,
271+
40%,
272+
60%,
273+
80% {
274+
transform: translateX(10px) rotate(5deg);
275+
}
276+
}
277+
278+
@keyframes winnerReveal {
279+
0% {
280+
transform: translateY(100px) scale(0.5);
281+
opacity: 0;
282+
}
283+
50% {
284+
transform: translateY(-20px) scale(1.1);
285+
}
286+
100% {
287+
transform: translateY(0) scale(1);
288+
opacity: 1;
289+
}
290+
}
291+
292+
@keyframes fadeIn {
293+
from {
294+
opacity: 0;
295+
transform: translateY(10px);
296+
}
297+
to {
298+
opacity: 1;
299+
transform: translateY(0);
300+
}
301+
}
302+
303+
@keyframes pulseSlow {
304+
0%,
305+
100% {
306+
transform: scale(1);
307+
}
308+
50% {
309+
transform: scale(1.05);
310+
}
311+
}
312+
313+
.animate-fall {
314+
animation: fall 1s ease-in forwards;
315+
}
316+
317+
.animate-shake {
318+
animation: shake 0.5s ease-in-out infinite;
319+
}
320+
321+
.animate-winner-reveal {
322+
animation: winnerReveal 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)
323+
forwards;
324+
}
325+
326+
.animate-fade-in {
327+
animation: fadeIn 0.6s ease-out 0.3s forwards;
328+
opacity: 0;
329+
}
330+
331+
.animate-pulse-slow {
332+
animation: pulseSlow 2s ease-in-out infinite;
333+
}
334+
335+
.animate-prize-fade-in {
336+
animation: fadeIn 0.8s ease-out forwards;
337+
opacity: 0;
338+
}
339+
`}</style>
340+
</div>
341+
);
342+
}

0 commit comments

Comments
 (0)