Files
LightningLotto/front_end/src/components/DrawAnimation.tsx
Michilis dd6b26c524 feat: Mobile optimization and UI improvements
- Add mobile hamburger menu in TopBar with slide-in panel
- Optimize all components for mobile (responsive fonts, spacing, touch targets)
- Add proper viewport meta tags and safe area padding
- Fix /jackpot/next API to return active cycles regardless of scheduled time
- Remove BTC display from jackpot pot (show sats only)
- Add setup/ folder to .gitignore
- Improve mobile UX: 16px inputs (no iOS zoom), 44px touch targets
- Add active states for touch feedback on buttons
2025-12-08 15:51:13 +00:00

273 lines
8.5 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
interface DrawAnimationProps {
winnerName?: string;
winningTicket?: number;
potAmount?: number;
onComplete: () => void;
// If true, show "drawing" animation without winner info
isDrawing?: boolean;
}
export function DrawAnimation({
winnerName,
winningTicket,
potAmount,
onComplete,
isDrawing = false,
}: DrawAnimationProps) {
const [phase, setPhase] = useState<'spinning' | 'revealing' | 'winner' | 'no-winner' | 'done'>('spinning');
const [displayTicket, setDisplayTicket] = useState(0);
const [showConfetti, setShowConfetti] = useState(false);
const hasWinner = winnerName !== undefined && winningTicket !== undefined && potAmount !== undefined;
// Generate random ticket numbers during spin
useEffect(() => {
if (phase !== 'spinning') return;
const spinInterval = setInterval(() => {
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
}, 50);
// After 2.5 seconds, start revealing (only if we have winner info)
const revealTimeout = setTimeout(() => {
if (hasWinner) {
setPhase('revealing');
}
// If no winner, keep spinning until we get data or timeout
}, 2500);
return () => {
clearInterval(spinInterval);
clearTimeout(revealTimeout);
};
}, [phase, hasWinner]);
// When winner info becomes available during spinning, transition to revealing
useEffect(() => {
if (phase === 'spinning' && hasWinner) {
const timer = setTimeout(() => {
setPhase('revealing');
}, 500);
return () => clearTimeout(timer);
}
}, [phase, hasWinner]);
// If isDrawing becomes false and we have no winner, show no-winner phase
useEffect(() => {
if (!isDrawing && !hasWinner && phase === 'spinning') {
setPhase('no-winner');
}
}, [isDrawing, hasWinner, phase]);
// Slow down and reveal actual number
useEffect(() => {
if (phase !== 'revealing' || !winningTicket) return;
let speed = 50;
let iterations = 0;
const maxIterations = 15;
const slowDown = () => {
if (iterations >= maxIterations) {
setDisplayTicket(winningTicket);
setPhase('winner');
return;
}
speed += 30;
iterations++;
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
setTimeout(slowDown, speed);
};
slowDown();
}, [phase, winningTicket]);
// Show winner and confetti
useEffect(() => {
if (phase !== 'winner') return;
setShowConfetti(true);
// Auto-dismiss after 15 seconds (give time to load next cycle)
const dismissTimeout = setTimeout(() => {
setPhase('done');
onComplete();
}, 15000);
return () => clearTimeout(dismissTimeout);
}, [phase, onComplete]);
// Handle no-winner phase
useEffect(() => {
if (phase !== 'no-winner') return;
// Auto-dismiss after 3 seconds
const dismissTimeout = setTimeout(() => {
setPhase('done');
onComplete();
}, 3000);
return () => clearTimeout(dismissTimeout);
}, [phase, onComplete]);
const handleDismiss = useCallback(() => {
setPhase('done');
onComplete();
}, [onComplete]);
if (phase === 'done') return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
onClick={phase === 'winner' || phase === 'no-winner' ? handleDismiss : undefined}
>
{/* Confetti Effect */}
{showConfetti && (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{[...Array(50)].map((_, i) => (
<div
key={i}
className="confetti-piece"
style={{
left: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 2}s`,
backgroundColor: ['#f7931a', '#ffd700', '#ff6b6b', '#4ecdc4', '#45b7d1'][
Math.floor(Math.random() * 5)
],
}}
/>
))}
</div>
)}
<div className="text-center px-4 sm:px-6 max-w-lg w-full">
{/* Spinning Phase */}
{(phase === 'spinning' || phase === 'revealing') && (
<>
<div className="text-xl sm:text-2xl text-yellow-400 mb-3 sm:mb-4 animate-pulse">
🎰 Drawing Winner...
</div>
<div className="bg-gray-900 rounded-xl sm:rounded-2xl p-5 sm:p-8 border-2 border-yellow-500/50 shadow-2xl shadow-yellow-500/20">
<div className="text-gray-400 text-xs sm:text-sm mb-2">Ticket Number</div>
<div
className={`text-3xl sm:text-5xl md:text-6xl font-mono font-bold text-bitcoin-orange break-all ${
phase === 'spinning' ? 'animate-number-spin' : ''
}`}
>
#{displayTicket.toLocaleString()}
</div>
</div>
</>
)}
{/* Winner Phase */}
{phase === 'winner' && hasWinner && (
<div className="animate-winner-reveal">
<div className="text-3xl sm:text-4xl mb-3 sm:mb-4">🎉🏆🎉</div>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-yellow-400 mb-4 sm:mb-6">
We Have a Winner!
</div>
<div className="bg-gradient-to-br from-yellow-900/60 to-orange-900/60 rounded-xl sm:rounded-2xl p-5 sm:p-8 border-2 border-yellow-500 shadow-2xl shadow-yellow-500/30">
<div className="text-gray-300 text-xs sm:text-sm mb-1">Winner</div>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-white mb-3 sm:mb-4 break-all">
{winnerName || 'Anon'}
</div>
<div className="text-gray-300 text-xs sm:text-sm mb-1">Winning Ticket</div>
<div className="text-xl sm:text-2xl font-mono text-bitcoin-orange mb-3 sm:mb-4">
#{winningTicket!.toLocaleString()}
</div>
<div className="text-gray-300 text-xs sm:text-sm mb-1">Prize</div>
<div className="text-3xl sm:text-4xl md:text-5xl font-bold text-green-400">
{potAmount!.toLocaleString()} sats
</div>
</div>
<div className="mt-4 sm:mt-6 text-gray-400 text-xs sm:text-sm animate-pulse">
Tap anywhere to continue
</div>
</div>
)}
{/* No Winner Phase (no tickets sold) */}
{phase === 'no-winner' && (
<div className="animate-winner-reveal">
<div className="text-3xl sm:text-4xl mb-3 sm:mb-4">😔</div>
<div className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-400 mb-4 sm:mb-6">
No Tickets This Round
</div>
<div className="bg-gray-900 rounded-xl sm:rounded-2xl p-5 sm:p-8 border-2 border-gray-600 shadow-2xl">
<div className="text-gray-300 text-base sm:text-lg mb-3 sm:mb-4">
No tickets were sold for this draw.
</div>
<div className="text-bitcoin-orange text-lg sm:text-xl font-semibold">
Next draw starting soon!
</div>
</div>
<div className="mt-4 sm:mt-6 text-gray-400 text-xs sm:text-sm animate-pulse">
Tap anywhere to continue
</div>
</div>
)}
</div>
<style jsx>{`
.confetti-piece {
position: absolute;
width: 10px;
height: 10px;
top: -10px;
animation: confetti-fall 4s ease-out forwards;
}
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
.animate-number-spin {
animation: number-glow 0.1s ease-in-out infinite alternate;
}
@keyframes number-glow {
from {
text-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
}
to {
text-shadow: 0 0 20px rgba(247, 147, 26, 0.8);
}
}
.animate-winner-reveal {
animation: winner-pop 0.5s ease-out forwards;
}
@keyframes winner-pop {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
opacity: 1;
}
}
`}</style>
</div>
);
}