|
| 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