/** * CoinRush Jackpot - Clean Modern Version * Full featured with navbar, WebSocket, footer, wheel * v2200 - Added UX improvements: sounds, confetti, toasts */ const { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } = React; // ============================================ // CONFIG // ============================================ const CONFIG = { minBet: 0.10, maxBet: 100000, spinDuration: 8000, // 8 seconds spin revealDuration: 8000, // 8 seconds reveal hold wsReconnectDelay: 3000, enableSounds: true, enableConfetti: true }; // ============================================ // SOUND EFFECTS MANAGER // ============================================ const SoundManager = { sounds: {}, enabled: true, init() { // Pre-load sounds (using existing sound files or create simple beeps) this.sounds.tick = this.createTone(800, 0.05); this.sounds.spin = this.createTone(400, 0.1); this.sounds.win = this.createTone(600, 0.3); }, createTone(freq, duration) { return { freq, duration }; }, play(soundName) { if (!this.enabled || !CONFIG.enableSounds) return; try { const AudioContext = window.AudioContext || window.webkitAudioContext; if (!AudioContext) return; const ctx = new AudioContext(); const oscillator = ctx.createOscillator(); const gainNode = ctx.createGain(); oscillator.connect(gainNode); gainNode.connect(ctx.destination); const sound = this.sounds[soundName]; if (!sound) return; oscillator.frequency.value = sound.freq; oscillator.type = 'sine'; gainNode.gain.setValueAtTime(0.1, ctx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + sound.duration); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + sound.duration); } catch (e) { // Silently fail if audio not supported } }, toggle() { this.enabled = !this.enabled; return this.enabled; } }; SoundManager.init(); // ============================================ // CONFETTI COMPONENT // ============================================ function Confetti({ active, duration = 5000 }) { const canvasRef = useRef(null); const animationRef = useRef(null); useEffect(() => { if (!active || !CONFIG.enableConfetti) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = window.innerHeight; const particles = []; const colors = ['#22c55e', '#ffd700', '#ff4081', '#00e5ff', '#aa00ff', '#ff6d00']; // Create particles for (let i = 0; i < 150; i++) { particles.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height - canvas.height, size: Math.random() * 10 + 5, color: colors[Math.floor(Math.random() * colors.length)], speedY: Math.random() * 3 + 2, speedX: (Math.random() - 0.5) * 4, rotation: Math.random() * 360, rotationSpeed: (Math.random() - 0.5) * 10 }); } const startTime = Date.now(); const animate = () => { const elapsed = Date.now() - startTime; if (elapsed > duration) { ctx.clearRect(0, 0, canvas.width, canvas.height); return; } ctx.clearRect(0, 0, canvas.width, canvas.height); const opacity = elapsed > duration - 1000 ? (duration - elapsed) / 1000 : 1; particles.forEach(p => { p.y += p.speedY; p.x += p.speedX; p.rotation += p.rotationSpeed; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate((p.rotation * Math.PI) / 180); ctx.globalAlpha = opacity; ctx.fillStyle = p.color; ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size / 3); ctx.restore(); }); animationRef.current = requestAnimationFrame(animate); }; animate(); return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } }; }, [active, duration]); if (!active) return null; return ( ); } // ============================================ // TOAST NOTIFICATION SYSTEM // ============================================ const ToastContext = createContext(null); function ToastProvider({ children }) { const [toasts, setToasts] = useState([]); const addToast = useCallback((message, type = 'info', duration = 4000) => { const id = Date.now() + Math.random(); setToasts(prev => [...prev, { id, message, type }]); setTimeout(() => { setToasts(prev => prev.filter(t => t.id !== id)); }, duration); }, []); return ( {children}
{toasts.map(toast => (
{toast.type === 'success' && Icons.check} {toast.type === 'error' && Icons.close} {toast.type === 'info' && Icons.info} {toast.type === 'win' && Icons.crown} {toast.message}
))}
); } const useToast = () => useContext(ToastContext); // ============================================ // WINNER CELEBRATION OVERLAY - Subtle & Premium // Only shows for the actual winner // ============================================ function WinnerOverlay({ show, winner, amount, isCurrentUser, onClose }) { const [visible, setVisible] = useState(false); useEffect(() => { if (show && isCurrentUser) { SoundManager.play('win'); // Slight delay for smooth entrance setTimeout(() => setVisible(true), 100); } else { setVisible(false); } }, [show, isCurrentUser]); // Only render for the actual winner if (!show || !isCurrentUser) return null; return (
e.stopPropagation()}> {/* Close button */} {/* Trophy icon */}
{Icons.trophy}
{/* Victory text */}

You Won!

{/* Amount */}
+{fmt(amount)} coins
{/* Collect button */}
); } // Vibrant wheel colors const COLORS = [ { bg: 'linear-gradient(135deg, #FF3D3D 0%, #FF6B6B 100%)', solid: '#FF3D3D', glow: 'rgba(255, 61, 61, 0.6)' }, { bg: 'linear-gradient(135deg, #00E5FF 0%, #00B8D4 100%)', solid: '#00E5FF', glow: 'rgba(0, 229, 255, 0.6)' }, { bg: 'linear-gradient(135deg, #FFD600 0%, #FFAB00 100%)', solid: '#FFD600', glow: 'rgba(255, 214, 0, 0.6)' }, { bg: 'linear-gradient(135deg, #AA00FF 0%, #D500F9 100%)', solid: '#AA00FF', glow: 'rgba(170, 0, 255, 0.6)' }, { bg: 'linear-gradient(135deg, #00E676 0%, #1DE9B6 100%)', solid: '#00E676', glow: 'rgba(0, 230, 118, 0.6)' }, { bg: 'linear-gradient(135deg, #FF6D00 0%, #FF9100 100%)', solid: '#FF6D00', glow: 'rgba(255, 109, 0, 0.6)' }, { bg: 'linear-gradient(135deg, #2979FF 0%, #448AFF 100%)', solid: '#2979FF', glow: 'rgba(41, 121, 255, 0.6)' }, { bg: 'linear-gradient(135deg, #FF4081 0%, #F50057 100%)', solid: '#FF4081', glow: 'rgba(255, 64, 129, 0.6)' }, { bg: 'linear-gradient(135deg, #76FF03 0%, #B2FF59 100%)', solid: '#76FF03', glow: 'rgba(118, 255, 3, 0.6)' }, { bg: 'linear-gradient(135deg, #E040FB 0%, #EA80FC 100%)', solid: '#E040FB', glow: 'rgba(224, 64, 251, 0.6)' } ]; // Wheel constants const SEGMENT_WIDTH = 110; const VIEWPORT_WIDTH = 660; // Show ~6 segments const MIN_SEGMENTS = 50; // Minimum segments to ensure smooth infinite scroll // ============================================ // SVG ICONS // ============================================ const Icons = { trophy: , coin: , users: , clock: , spinner: , lock: , send: , crown: , history: , chevronDown: , info: , close: , check: , percent: , shield: , copy: , refresh: , gift: , eye: , user: , twitter: , telegram: }; // ============================================ // UTILITIES // ============================================ const fmt = (coins) => { return (coins || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }; const fmtCents = (cents) => { return fmt((cents || 0) / 100); }; // ============================================ // TOOLTIP COMPONENT // ============================================ function Tooltip({ children, text }) { return ( {children} {text} ); } // ============================================ // LIVE PLAYER BADGE COMPONENT // ============================================ function LiveBadge({ count }) { return ( {count} player{count !== 1 ? 's' : ''} ); } const parseDate = (dateStr) => { if (!dateStr) return null; try { // If no timezone specified, assume UTC by appending Z let str = dateStr; if (!str.endsWith('Z') && !str.includes('+') && !str.includes('-', 10)) { str = str + 'Z'; } return new Date(str); } catch (e) { return null; } }; // Helper to parse date as UTC const parseUTCDate = (dateStr) => { if (!dateStr) return null; try { // If no timezone specified, assume UTC by appending Z let str = dateStr; if (!str.endsWith('Z') && !str.includes('+') && !str.includes('-', 10)) { str = str + 'Z'; } return new Date(str); } catch (e) { return null; } }; const getColor = (idx) => COLORS[idx % COLORS.length]; // ============================================ // WHEEL COMPONENT WITH JS-DRIVEN ANIMATION // Uses requestAnimationFrame for reliable animation even in background tabs // ============================================ // Easing function: cubic-bezier(0.1, 0.7, 0.1, 1) approximation const easeOutQuart = (t) => 1 - Math.pow(1 - t, 4); function Wheel({ participants, pot, winner, status, secondsSinceResolved, isInitialLoad }) { const [segments, setSegments] = useState([]); const [currentOffset, setCurrentOffset] = useState(0); const [winnerIdx, setWinnerIdx] = useState(null); const [isAnimating, setIsAnimating] = useState(false); const animationRef = useRef(null); const spinStartTimeRef = useRef(null); const targetOffsetRef = useRef(0); const hasTriggeredSpinRef = useRef(false); const lastStatusRef = useRef(null); const TOTAL_SEGMENTS = 60; // Compute isSpinning from status const isSpinning = status === 'spinning'; // Cleanup animation on unmount useEffect(() => { return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } }; }, []); // JS-driven animation loop const startAnimation = useCallback((startOffset, endOffset, duration, startTime = null) => { // Cancel any existing animation if (animationRef.current) { cancelAnimationFrame(animationRef.current); } const animStartTime = startTime || performance.now(); setIsAnimating(true); const animate = (currentTime) => { const elapsed = currentTime - animStartTime; const progress = Math.min(elapsed / duration, 1); const easedProgress = easeOutQuart(progress); const newOffset = startOffset + (endOffset - startOffset) * easedProgress; setCurrentOffset(newOffset); if (progress < 1) { animationRef.current = requestAnimationFrame(animate); } else { setCurrentOffset(endOffset); setIsAnimating(false); animationRef.current = null; } }; animationRef.current = requestAnimationFrame(animate); }, []); // Build segments when participants change useEffect(() => { if (participants.length === 0) { const placeholders = Array(TOTAL_SEGMENTS).fill(null).map((_, i) => ({ key: `empty-${i}`, isEmpty: true, username: '', percentage: 0, color: { bg: 'rgba(255,255,255,0.03)', solid: '#333', glow: 'transparent' } })); setSegments(placeholders); return; } const withColors = participants.map((p, i) => ({ ...p, color: getColor(i), percentage: pot > 0 ? (p.amount / pot) * 100 : 0 })); // Fill segments by repeating players const allSegments = []; for (let i = 0; i < TOTAL_SEGMENTS; i++) { const p = withColors[i % withColors.length]; allSegments.push({ ...p, key: `seg-${i}`, segmentIndex: i }); } setSegments(allSegments); }, [participants, pot]); // Handle spinning state from server useEffect(() => { const prevStatus = lastStatusRef.current; lastStatusRef.current = status; console.log('[Wheel] Status update:', { status, prevStatus, winner, hasTriggered: hasTriggeredSpinRef.current }); // NEW ROUND: Reset everything when we go back to open/countdown if ((status === 'open' || status === 'countdown') && (prevStatus === 'spinning' || prevStatus === 'reveal' || prevStatus === 'intermission' || !prevStatus)) { console.log('[Wheel] Resetting for new round'); if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } setCurrentOffset(0); setWinnerIdx(null); setIsAnimating(false); targetOffsetRef.current = 0; spinStartTimeRef.current = null; hasTriggeredSpinRef.current = false; return; } // Skip if no winner or no segments yet if (!winner || segments.length === 0 || segments[0]?.isEmpty) { if (status === 'spinning' && !winner) { console.log('[Wheel] Spinning but no winner yet, waiting...'); } return; } // Calculate target position for winner const calculateWinnerPosition = () => { const allWinnerSegments = segments .map((s, i) => ({ ...s, idx: i })) .filter(s => s.username === winner); if (allWinnerSegments.length === 0) { console.log('[Wheel] ERROR: No winner segments found for:', winner); return null; } // Prefer target zone (60-85% of wheel) for visual effect let winnerSegments = allWinnerSegments.filter(s => s.idx > TOTAL_SEGMENTS * 0.6 && s.idx < TOTAL_SEGMENTS * 0.85); if (winnerSegments.length === 0) { winnerSegments = allWinnerSegments.filter(s => s.idx > TOTAL_SEGMENTS * 0.3); } if (winnerSegments.length === 0) { winnerSegments = allWinnerSegments; } // Use consistent target based on winner name (so all clients land same place) const hash = winner.split('').reduce((a, c) => a + c.charCodeAt(0), 0); const targetSeg = winnerSegments[hash % winnerSegments.length]; const viewportCenter = VIEWPORT_WIDTH / 2; const segmentCenter = SEGMENT_WIDTH / 2; const offset = (targetSeg.idx * SEGMENT_WIDTH) - viewportCenter + segmentCenter; return { offset, segIdx: targetSeg.idx }; }; // SPINNING: User joined mid-spin or spin just started if (status === 'spinning' && winner) { const position = calculateWinnerPosition(); if (!position) return; // Check if this is a mid-spin join (from fetchServerState, not WebSocket): const isMidSpinJoin = isInitialLoad && secondsSinceResolved && secondsSinceResolved > 0; // For mid-spin joins, always allow animation (user switched tabs) if (isMidSpinJoin) { hasTriggeredSpinRef.current = false; } // Skip if we already handled this spin (for WebSocket events) if (hasTriggeredSpinRef.current) return; console.log('[Wheel] Spinning state:', { isInitialLoad, secondsSinceResolved, isMidSpinJoin }); targetOffsetRef.current = position.offset; setWinnerIdx(position.segIdx); hasTriggeredSpinRef.current = true; if (isMidSpinJoin) { // Mid-spin join: calculate where we should be based on server time const elapsedMs = secondsSinceResolved * 1000; const remainingMs = Math.max(0, CONFIG.spinDuration - elapsedMs); console.log('[Wheel] Mid-spin join, elapsed:', elapsedMs, 'remaining:', remainingMs); if (remainingMs > 500) { // Calculate current position based on elapsed time const progress = elapsedMs / CONFIG.spinDuration; const easedProgress = easeOutQuart(progress); const currentPos = position.offset * easedProgress; console.log('[Wheel] Mid-spin: starting from', currentPos, 'to', position.offset, 'over', remainingMs, 'ms'); // Start animation from current position to final position setCurrentOffset(currentPos); startAnimation(currentPos, position.offset, remainingMs); } else { // Less than 0.5s left - just show final position console.log('[Wheel] Mid-spin: too little time, showing final position'); setCurrentOffset(position.offset); } } else { // Fresh spin from WebSocket - animate fully from start console.log('[Wheel] Fresh spin, starting full animation to:', winner); spinStartTimeRef.current = performance.now(); setCurrentOffset(0); startAnimation(0, position.offset, CONFIG.spinDuration); } } // REVEAL/INTERMISSION: Show winner at final position if ((status === 'reveal' || status === 'intermission') && winner) { const position = calculateWinnerPosition(); if (position) { console.log('[Wheel] Reveal/intermission - showing winner at position'); if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } setCurrentOffset(position.offset); setWinnerIdx(position.segIdx); setIsAnimating(false); hasTriggeredSpinRef.current = true; } } }, [status, winner, segments, secondsSinceResolved, isInitialLoad, startAnimation]); const hasPlayers = segments.length > 0 && !segments[0]?.isEmpty; const showWinnerHighlight = (status === 'reveal' || status === 'intermission') && winnerIdx !== null; return (
{/* Center pointer */}
{segments.map((seg, idx) => { const isWinnerSeg = showWinnerHighlight && idx === winnerIdx; return (
{!seg.isEmpty && (
{seg.username.slice(0, 2).toUpperCase()}
{seg.username}
{seg.percentage.toFixed(1)}%
)} {seg.isEmpty && (
?
)}
); })}
{/* Waiting overlay */} {!hasPlayers && (
Waiting for players...
)} {/* Winner flash */} {showWinnerHighlight &&
}
); } // ============================================ // TIMER COMPONENT // ============================================ function Timer({ endsAt, status }) { const [seconds, setSeconds] = useState(0); useEffect(() => { if (!endsAt || status !== 'countdown') { setSeconds(0); return; } const endDate = parseDate(endsAt); if (!endDate) return; const tick = () => { const remaining = Math.max(0, Math.ceil((endDate.getTime() - Date.now()) / 1000)); setSeconds(remaining); }; tick(); const interval = setInterval(tick, 100); return () => clearInterval(interval); }, [endsAt, status]); if (status === 'spinning') { return (
{Icons.spinner} Drawing winner...
); } if (status === 'reveal' || status === 'intermission') { return (
{Icons.crown} Winner!
); } if (status === 'open') { return (
{Icons.clock} Waiting for bets
); } if (seconds <= 0) return null; const mins = Math.floor(seconds / 60); const secs = seconds % 60; const isUrgent = seconds <= 10; return (
{Icons.clock} {mins}:{secs.toString().padStart(2, '0')}
); } // ============================================ // BET PANEL // ============================================ function BetPanel({ user, pot, onDeposit, isLocked, countdownSeconds }) { const [amount, setAmount] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [selectedPreset, setSelectedPreset] = useState(null); const presets = [1, 5, 10, 50, 100]; const handlePresetClick = (presetCoins) => { setSelectedPreset(presetCoins); setAmount(presetCoins.toString()); setError(''); }; const handleInputChange = (e) => { const val = e.target.value; setAmount(val); setSelectedPreset(null); setError(''); }; const handleDeposit = async (amountCoins) => { if (isLocked || loading) return; if (!user) { window.openAuthModal?.('login'); return; } if (amountCoins < CONFIG.minBet) { setError(`Minimum bet is ${CONFIG.minBet} coins`); return; } const balanceCoins = (user?.balance || 0) / 100; if (amountCoins > balanceCoins) { setError('Insufficient balance'); return; } setLoading(true); setError(''); try { const res = await fetch('/jackpot/deposit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ amount: amountCoins }) }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || 'Deposit failed'); setAmount(''); setSelectedPreset(null); onDeposit?.({ ...data, amount: Math.round(amountCoins * 100) }); } catch (err) { setError(err.message); } finally { setLoading(false); } }; const handleMaxBet = () => { if (!user) { window.openAuthModal?.('login'); return; } const balanceCoins = (user?.balance || 0) / 100; setAmount(balanceCoins.toFixed(2)); setSelectedPreset(null); setError(''); }; const handleLoginClick = () => { window.openAuthModal?.('login'); }; const customAmountCoins = parseFloat(amount || 0); const currentAmount = customAmountCoins > 0 ? customAmountCoins : 0; const balanceCoins = (user?.balance || 0) / 100; const chance = pot > 0 && currentAmount > 0 ? ((currentAmount / (pot + currentAmount)) * 100).toFixed(1) : null; return (
{Icons.coin} Place Bet
{user && (
{fmtCents(user.balance)} coins
)}
{isLocked && (
{Icons.lock} {countdownSeconds > 0 ? `Next round in ${countdownSeconds}s` : 'Round in progress...'}
)} {error &&
{error}
} {/* Amount Input Section */}
0 ? 'has-value' : ''}`}>
coins
{/* Preset Buttons */}
{presets.map(presetCoins => ( ))}
{/* Stats Row */}
Current Pot {fmt(pot)}
Your Chance {chance ? `${chance}%` : '—'}
{/* CTA Button */}
); } // ============================================ // PARTICIPANTS LIST // ============================================ function ParticipantsList({ participants, pot, currentUser }) { const sorted = [...participants].sort((a, b) => b.amount - a.amount); return (
{Icons.users} Players
{participants.length}
{sorted.length === 0 ? (
{Icons.users} No players yet
) : ( sorted.map((p, idx) => { const chance = pot > 0 ? (p.amount / pot) * 100 : 0; const isCurrentUser = currentUser && p.username === currentUser.username; const color = getColor(participants.findIndex(x => x.username === p.username)); return (
#{idx + 1}
{p.username.slice(0, 2).toUpperCase()}
{p.username}
{fmt(p.amount)} coins
{chance.toFixed(1)}%
); }) )}
); } // ============================================ // HISTORY PANEL - Coinflip Style // ============================================ function HistoryPanel({ history }) { return (
{Icons.history} Recent Rounds
{history.length}
{history.length === 0 ? (
{Icons.history} No rounds yet
) : (
{history.map((round, idx) => (
{Icons.crown} {round.winner}
{fmt(round.total)} {round.chance?.toFixed(1) || '?'}%
))}
)}
); } // ============================================ // JACKPOT DISPLAY // ============================================ function JackpotDisplay({ pot, participants, status, countdown, showCountdown, endsAt, winner, serverSeedHash, serverSeed, clientSeed, ticket }) { const [countdownSecs, setCountdownSecs] = useState(0); // Calculate countdown from endsAt - runs on mount and when props change useEffect(() => { console.log('[JackpotDisplay] useEffect - status:', status, 'endsAt:', endsAt, 'countdownSecs:', countdownSecs); if (!endsAt || status !== 'countdown') { console.log('[JackpotDisplay] Not in countdown mode, setting countdownSecs to 0. Reason:', !endsAt ? 'no endsAt' : 'status is ' + status); setCountdownSecs(0); return; } // Parse as UTC (backend sends dates without timezone, they are UTC) const endDate = parseUTCDate(endsAt); if (!endDate) { console.log('[JackpotDisplay] Failed to parse endsAt:', endsAt); setCountdownSecs(0); return; } console.log('[JackpotDisplay] Setting up countdown timer. endsAt:', endsAt, 'endDate:', endDate, 'now:', new Date()); const tick = () => { const remaining = Math.max(0, Math.ceil((endDate.getTime() - Date.now()) / 1000)); setCountdownSecs(remaining); }; // Run immediately tick(); // Then run every 100ms const interval = setInterval(tick, 100); return () => clearInterval(interval); }, [endsAt, status]); const isEnded = status === 'reveal' || status === 'intermission'; const showWinner = winner && isEnded; // Always show next round counter during intermission (no condition on showCountdown) const showNextRound = status === 'intermission' && countdown > 0; const isSpinning = status === 'spinning'; const isCountdown = status === 'countdown' && countdownSecs > 0; // Format time as M:SS const formatTime = (secs) => { const mins = Math.floor(secs / 60); const s = secs % 60; return `${mins}:${s.toString().padStart(2, '0')}`; }; // Calculate countdown progress (assuming max 60 seconds) const countdownProgress = Math.min(100, (countdownSecs / 60) * 100); return (
{isSpinning &&
} {isCountdown && countdownSecs <= 10 &&
}
{showWinner ? ( /* Simple inline winner display - doesn't push content */
{Icons.trophy}
Winner {winner}
+{fmt(pot)}
{showNextRound ? (
{countdown}s next
) : (
... next
)}
) : ( <> {/* Main jackpot display */}
{Icons.trophy}
Jackpot
{fmt(pot)}
{/* Status section */}
{isSpinning ? (
Drawing winner
) : isCountdown ? (
{countdownSecs}
seconds
) : (
Waiting for players
)} {/* Player count */}
{Icons.users} {participants.length} player{participants.length !== 1 ? 's' : ''}
)}
{/* Provably Fair - Compact */} {isEnded && serverSeedHash && ( )}
); } // ============================================ // FAIRNESS DISPLAY // ============================================ function FairnessDisplay({ serverSeedHash, serverSeed, clientSeed, ticket }) { const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(null); const copyToClipboard = (text, field) => { navigator.clipboard.writeText(text).then(() => { setCopied(field); setTimeout(() => setCopied(null), 2000); }); }; const truncate = (str, len = 12) => { if (!str) return 'N/A'; if (str.length <= len) return str; return str.slice(0, len) + '...'; }; const ticketFormatted = ticket !== null ? ticket.toFixed(4) : null; return (
{expanded && (
Server Hash
{truncate(serverSeedHash, 16)}
{serverSeed && (
Server Seed
{truncate(serverSeed, 16)}
)} {clientSeed && (
Client Seed
{truncate(clientSeed, 16)}
)} {ticketFormatted && (
Winning Ticket {ticketFormatted}
)}
)}
); } // ============================================ // RULES MODAL // ============================================ function RulesModal({ isOpen, onClose }) { if (!isOpen) return null; return (
e.stopPropagation()}>
{Icons.info}

Jackpot Rules

{Icons.gift} How It Works

  • Deposit coins to join the jackpot round
  • Your chance to win = Your bet ÷ Total pot
  • When countdown ends, the wheel spins to pick a winner
  • Winner takes the entire pot!

{Icons.percent} House Edge

4% House Edge

A 4% fee is deducted from the pot. Winner receives 96%.

{Icons.refresh} The Wheel

  • Each player gets a segment proportional to their bet
  • Bigger bets = larger segments = better odds
  • The pointer indicates the winner when wheel stops

{Icons.clock} Game States

Waiting Accepting bets
Countdown 30s until spin
Spinning 8 second spin
Winner Winner announced!
); } // ============================================ // FOOTER // ============================================ function Footer() { return ( ); } // ============================================ // AUTH MODAL - Use shared component from auth-react.jsx // ============================================ // The AuthModal is now loaded from /static/js/auth-react.jsx // which includes OAuth buttons (Discord, Google, X) // Access it via window.AuthModal // ============================================ // MAIN APP // ============================================ function JackpotApp() { const [user, setUser] = useState(null); const [gameState, setGameState] = useState({ status: 'open', pot: 0, participants: [], endsAt: null, winner: null, roundId: null, intermissionEndsAt: null, serverSeedHash: null, serverSeed: null, clientSeed: null, ticket: null, secondsSinceResolved: null, isInitialLoad: true // Flag to track if this is from initial fetch vs WebSocket }); const [history, setHistory] = useState([]); const [isConnected, setIsConnected] = useState(false); const [intermissionCountdown, setIntermissionCountdown] = useState(0); const [showCountdownUI, setShowCountdownUI] = useState(false); const [showRulesModal, setShowRulesModal] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false); const [authModalMode, setAuthModalMode] = useState('login'); // UX Enhancement States const [showConfetti, setShowConfetti] = useState(false); const [showWinnerOverlay, setShowWinnerOverlay] = useState(false); const [winnerData, setWinnerData] = useState(null); const [soundEnabled, setSoundEnabled] = useState(true); const wsRef = useRef(null); const userRef = useRef(null); const gameStateRef = useRef(null); const lastWinnerRef = useRef(null); // Get toast function from context const addToast = useToast(); useEffect(() => { userRef.current = user; }, [user]); useEffect(() => { gameStateRef.current = gameState; }, [gameState]); // Handle winner celebration useEffect(() => { if (gameState.status === 'reveal' && gameState.winner && gameState.winner !== lastWinnerRef.current) { lastWinnerRef.current = gameState.winner; const isCurrentUserWinner = user && gameState.winner === user.username; const winAmount = gameState.pot / 100; // Show confetti ONLY for winner if (isCurrentUserWinner) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 5000); // Show winner overlay ONLY for the actual winner setWinnerData({ winner: gameState.winner, amount: winAmount, isCurrentUser: true }); setShowWinnerOverlay(true); // Winner toast if (addToast) { addToast(`You won ${fmt(winAmount)} coins!`, 'win', 6000); } } else { // Non-winner just gets a simple toast notification if (addToast) { addToast(`${gameState.winner} won ${fmt(winAmount)} coins`, 'info', 4000); } } } // Reset winner ref on new round if (gameState.status === 'open' || gameState.status === 'countdown') { lastWinnerRef.current = null; } }, [gameState.status, gameState.winner, gameState.pot, user, addToast]); // Sound toggle const toggleSound = useCallback(() => { const newState = SoundManager.toggle(); setSoundEnabled(newState); }, []); // Expose functions to window for navbar useEffect(() => { window.showJackpotRules = () => setShowRulesModal(true); window.openAuthModal = (mode = 'login') => { setAuthModalMode(mode); setShowAuthModal(true); }; window.dailyRewardsModal = { open: () => window.dispatchEvent(new CustomEvent('openDailyRewards')), close: () => {} }; return () => { delete window.showJackpotRules; delete window.openAuthModal; delete window.dailyRewardsModal; }; }, []); // Fetch user useEffect(() => { const fetchUser = () => { fetch('/me', { credentials: 'include' }) .then(res => res.ok ? res.json() : null) .then(data => setUser(data)) .catch(() => {}); }; fetchUser(); const handleBalanceRefresh = () => fetchUser(); window.addEventListener('balanceUpdate', handleBalanceRefresh); return () => window.removeEventListener('balanceUpdate', handleBalanceRefresh); }, []); // Fetch history useEffect(() => { fetch('/jackpot/history', { credentials: 'include' }) .then(res => res.ok ? res.json() : []) .then(rounds => { const historyData = rounds.slice(0, 10).map(round => ({ id: round.id, winner: round.winner, total: round.total, chance: round.participants?.find(p => p.username === round.winner)?.share_pct || 0 })); setHistory(historyData); }) .catch(() => {}); }, []); // Fetch server state const fetchServerState = useCallback(async () => { try { const res = await fetch('/jackpot/state', { credentials: 'include' }); const data = await res.json(); console.log('[Jackpot] Raw /jackpot/state response:', data); let status = data.state || 'open'; let pot = 0, participants = [], endsAt = null, winner = null, roundId = null; let serverSeedHash = null, serverSeed = null, clientSeed = null, ticket = null; if (data.current_round) { const round = data.current_round; pot = round.total || 0; participants = (round.participants || []).map(p => ({ username: p.username, amount: p.amount || 0 })); endsAt = round.ends_at; roundId = round.id; serverSeedHash = round.server_seed_hash || null; console.log('[Jackpot] Parsed current_round:', { status, pot, endsAt, roundId }); } if (['spinning', 'reveal', 'intermission'].includes(status) && data.last_round) { const round = data.last_round; pot = round.total || 0; participants = (round.participants || []).map(p => ({ username: p.username, amount: p.amount || 0 })); winner = data.last_winner; roundId = round.id; serverSeedHash = round.server_seed_hash || null; serverSeed = round.revealed_server_seed || null; clientSeed = round.client_seed_used || null; ticket = round.ticket || null; } console.log('[Jackpot] fetchServerState result:', { status, pot, endsAt, winner, secondsSinceResolved: data.seconds_since_resolved }); const newState = { status, pot, participants, endsAt, winner, roundId, intermissionEndsAt: data.intermission_ends_at, serverSeedHash, serverSeed, clientSeed, ticket, secondsSinceResolved: data.seconds_since_resolved || null, isInitialLoad: true // This came from initial fetch, not WebSocket event }; console.log('[Jackpot] Setting gameState to:', newState); setGameState(newState); } catch (e) { console.error('Failed to fetch state:', e); } }, []); // Re-fetch state when tab becomes visible (handles switching browsers/tabs mid-spin) useEffect(() => { const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { console.log('[Jackpot] Tab became visible, re-fetching state...'); fetchServerState(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => document.removeEventListener('visibilitychange', handleVisibilityChange); }, [fetchServerState]); // WebSocket useEffect(() => { const connect = () => { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${protocol}//${location.host}/ws/lobby`); wsRef.current = ws; ws.onopen = () => { setIsConnected(true); fetchServerState(); }; ws.onclose = () => { setIsConnected(false); setTimeout(connect, CONFIG.wsReconnectDelay); }; ws.onerror = () => {}; ws.onmessage = (event) => { try { handleMessage(JSON.parse(event.data)); } catch (e) {} }; }; connect(); return () => wsRef.current?.close(); }, [fetchServerState]); // Handle WebSocket messages const handleMessage = useCallback((msg) => { switch (msg.type) { case 'jp_countdown': setGameState(prev => ({ ...prev, status: 'countdown', pot: msg.total || 0, participants: (msg.participants || []).map(p => ({ username: p.username, amount: p.amount || 0 })), endsAt: msg.ends_at, winner: null, roundId: msg.round_id || prev.roundId, isInitialLoad: false // This is from WebSocket, not initial fetch })); break; case 'jp_deposit': setGameState(prev => ({ ...prev, pot: msg.total || 0, participants: (msg.participants || []).map(p => ({ username: p.username, amount: p.amount || 0 })), isInitialLoad: false })); break; case 'jp_resolved': console.log('[Jackpot] jp_resolved received:', msg); const resolvedData = { roundId: msg.round_id, winner: msg.winner, total: msg.total, chance: msg.participants?.find(p => p.username === msg.winner)?.percentage || 0 }; setGameState(prev => ({ ...prev, status: 'spinning', winner: msg.winner, pot: msg.total || 0, participants: (msg.participants || []).map(p => ({ username: p.username, amount: p.amount || 0 })), serverSeedHash: msg.server_seed_hash || null, serverSeed: msg.server_seed || null, clientSeed: msg.client_seed || null, ticket: msg.ticket || null, isInitialLoad: false, // WebSocket event, not initial load secondsSinceResolved: 0 // Fresh spin, just started })); // After spin completes, transition to reveal state setTimeout(() => { console.log('[Jackpot] Spin complete, transitioning to reveal'); setGameState(prev => ({ ...prev, status: 'reveal' })); setHistory(prev => [{ id: resolvedData.roundId, winner: resolvedData.winner, total: resolvedData.total, chance: resolvedData.chance }, ...prev.slice(0, 9)]); // Show pending balance animation now that spin is complete if (window.showPendingBalanceAnimation) { window.showPendingBalanceAnimation(); } fetch('/me', { credentials: 'include' }) .then(res => res.ok ? res.json() : null) .then(data => { if (data) setUser(data); }) .catch(() => {}); }, CONFIG.spinDuration); break; case 'jp_reset': fetchServerState(); break; case 'balance_update': const currentUser = userRef.current; const currentGameState = gameStateRef.current; if (currentUser && msg.username === currentUser.username) { const oldBalance = currentUser.balance || 0; const newBalance = typeof msg.balance === 'number' ? msg.balance : oldBalance; const delta = newBalance - oldBalance; setUser(prev => prev ? { ...prev, balance: newBalance } : prev); // Queue animation if spinning, show immediately if revealed if (delta > 0 && window.updateCoinRushBalance) { const isSpinning = currentGameState?.status === 'spinning'; window.updateCoinRushBalance(newBalance, delta, !isSpinning); } } break; } }, [fetchServerState]); // Poll during spinning/reveal useEffect(() => { if (gameState.status === 'spinning') { const timer = setTimeout(() => fetchServerState(), CONFIG.spinDuration + 500); return () => clearTimeout(timer); } if (gameState.status === 'reveal') { const timer = setInterval(() => fetchServerState(), 2000); return () => clearInterval(timer); } }, [gameState.status, fetchServerState]); // Intermission countdown - starts immediately when winner is shown useEffect(() => { if (gameState.status !== 'intermission' || !gameState.intermissionEndsAt) { setIntermissionCountdown(0); setShowCountdownUI(false); return; } // Show countdown immediately - no delay setShowCountdownUI(true); const updateCountdown = () => { const endsAt = new Date(gameState.intermissionEndsAt); const remaining = Math.max(0, Math.ceil((endsAt - new Date()) / 1000)); setIntermissionCountdown(remaining); if (remaining <= 0) fetchServerState(); }; updateCountdown(); const timer = setInterval(updateCountdown, 1000); return () => { clearInterval(timer); }; }, [gameState.status, gameState.intermissionEndsAt, fetchServerState]); // Handlers const handleDeposit = (data) => { if (data?.amount) { const newBalance = Math.max(0, (user?.balance || 0) - data.amount); setUser(prev => prev ? { ...prev, balance: newBalance } : prev); // Show floating -X animation when betting if (window.updateCoinRushBalance) { window.updateCoinRushBalance(newBalance, -data.amount); } } }; const handleLogout = async () => { try { await fetch('/auth/logout', { method: 'POST', credentials: 'include' }); setUser(null); window.location.href = '/'; } catch (e) {} }; const isLocked = ['spinning', 'reveal', 'intermission'].includes(gameState.status); return (
{/* Floating Orbs Background */}
{/* Animated Particles */}
{[...Array(30)].map((_, i) => (
))}
{/* Navbar */} {/* Rules Modal */} setShowRulesModal(false)} /> {/* Main Content */}
{/* Participants list moved below wheel */}
{/* Footer */}
{/* Auth Modal (uses shared component from auth-react.jsx with OAuth buttons) */} {window.AuthModal && ( setShowAuthModal(false)} onSuccess={() => { setShowAuthModal(false); window.location.reload(); }} /> )} {/* UX Enhancements */} setShowWinnerOverlay(false)} /> {/* Sound Toggle Button */}
); } // ============================================ // APP WITH TOAST PROVIDER // ============================================ function JackpotAppWithProviders() { return ( ); } // ============================================ // RENDER // ============================================ const root = ReactDOM.createRoot(document.getElementById('root')); root.render();