/**
* 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 */}
{/* 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 */}
{/* Status section */}
{isSpinning ? (
) : isCountdown ? (
) : (
)}
{/* 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();