- 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
273 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|