/** * CoinRush Coinflip - React Redesign * Modern UI matching jackpot and other game modes * Clean, premium design with consistent styling */ const { useState, useEffect, useRef, useCallback, useMemo } = React; // ============================================ // CONFIGURATION // ============================================ const CONFIG = { minBet: 100, // 1.00 coins in hundredths maxBet: 10000000, flipDuration: 3000, // 3 seconds for clean animation wsReconnectDelay: 3000, battleTimeout: 300 // 5 minutes in seconds }; // ============================================ // SVG ICONS // ============================================ const Icons = { coin: ( ), swords: ( ), trophy: ( ), users: ( ), clock: ( ), plus: ( ), play: ( ), eye: ( ), crown: ( ), history: ( ), shield: ( ), bolt: ( ), close: ( ), check: ( ), info: ( ), gift: ( ), refresh: ( ), chevronDown: ( ), skull: ( ), sparkles: ( ) }; // ============================================ // UTILITIES // ============================================ const formatCoins = (cents) => { const coins = (cents || 0) / 100; return coins.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }; const parseCoinsInput = (input) => { if (typeof input === 'number') return Math.round(input * 100); const normalized = String(input).trim().replace(/\./g, '').replace(',', '.'); const parsed = parseFloat(normalized); if (!isFinite(parsed)) return 0; return Math.round(parsed * 100); }; const timeAgo = (dateStr) => { const date = new Date(dateStr); const now = new Date(); const seconds = Math.floor((now - date) / 1000); if (seconds < 60) return 'Just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; }; const formatTime = (seconds) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; const clsx = (...classes) => classes.filter(Boolean).join(' '); // ============================================ // THREE.JS 3D COIN COMPONENT - CINEMA QUALITY EDITION // Best-in-class coinflip animation with physics simulation // ============================================ function ThreeCoin({ phase, winnerSide, onAnimationComplete, size = 280 }) { const containerRef = useRef(null); const rafRef = useRef(null); const apiRef = useRef(null); useEffect(() => { if (!containerRef.current || !window.THREE || !window.gsap) return; const root = containerRef.current; const THREE = window.THREE; const gsap = window.gsap; // ===== SCENE SETUP ===== const scene = new THREE.Scene(); scene.background = null; // Camera - more overhead angle to see coin face clearly const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 100); camera.position.set(0, 6.5, 5.0); // Higher up, more overhead view camera.lookAt(0, 1.0, 0); // Look at mid-point of flip arc const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(size, size); if (THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace; if (THREE.ACESFilmicToneMapping) { renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.5; } root.appendChild(renderer.domElement); // ===== PREMIUM LIGHTING ===== scene.add(new THREE.AmbientLight(0xffffff, 0.5)); // Key light - warm gold const keyLight = new THREE.DirectionalLight(0xfff0d4, 2.2); keyLight.position.set(5, 8, 4); scene.add(keyLight); // Fill light - cool accent const fillLight = new THREE.DirectionalLight(0x5bffb2, 0.6); fillLight.position.set(-5, 3, -3); scene.add(fillLight); // Rim light - dramatic edge highlight const rimLight = new THREE.DirectionalLight(0xffffff, 1.5); rimLight.position.set(0, -4, 6); scene.add(rimLight); // Top spotlight const spotLight = new THREE.SpotLight(0xffffff, 1.2, 15, Math.PI / 6, 0.5); spotLight.position.set(0, 10, 0); scene.add(spotLight); // ===== CLEAN MODERN COIN DESIGN ===== const createCoinFace = (text, isRed) => { const size = 1024; const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d'); const cx = size / 2; const cy = size / 2; const r = size * 0.48; // Use full area // === FLAT COLOR WITH SUBTLE SHINE === const baseColor = isRed ? '#dc2626' : '#22c55e'; // === MAIN COLORED CIRCLE === ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fillStyle = baseColor; ctx.fill(); // === SUBTLE TOP SHINE === const shineGrad = ctx.createRadialGradient( cx, cy - r * 0.35, 0, cx, cy - r * 0.2, r * 0.5 ); shineGrad.addColorStop(0, 'rgba(255,255,255,0.18)'); shineGrad.addColorStop(1, 'rgba(255,255,255,0)'); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fillStyle = shineGrad; ctx.fill(); // === WHITE LETTER - centered === ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const letter = isRed ? 'R' : 'G'; const fontSize = r * 1.1; ctx.font = `700 ${fontSize}px "Inter", -apple-system, sans-serif`; ctx.fillStyle = '#ffffff'; ctx.fillText(letter, cx, cy + fontSize * 0.02); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 8; return texture; }; const createEdgeTexture = () => { const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 64; const ctx = canvas.getContext('2d'); // Simple dark edge - matches the flat style ctx.fillStyle = '#1f2937'; ctx.fillRect(0, 0, canvas.width, canvas.height); const tex = new THREE.CanvasTexture(canvas); tex.wrapS = THREE.RepeatWrapping; tex.repeat.set(20, 1); return tex; }; // ===== BUILD COIN ===== const redTex = createCoinFace('CR', true); const greenTex = createCoinFace('CR', false); const edgeTex = createEdgeTexture(); const edgeMat = new THREE.MeshStandardMaterial({ map: edgeTex, metalness: 0.85, roughness: 0.25 }); const redMat = new THREE.MeshStandardMaterial({ map: redTex, metalness: 0.4, roughness: 0.35 }); const greenMat = new THREE.MeshStandardMaterial({ map: greenTex, metalness: 0.4, roughness: 0.35 }); // Coin geometry const coinGeo = new THREE.CylinderGeometry(1, 1, 0.12, 72, 1, false); const coin = new THREE.Mesh(coinGeo, [edgeMat, redMat, greenMat]); scene.add(coin); // ===== GLOW RING ===== const glowGeo = new THREE.RingGeometry(1.1, 1.4, 64); const glowMat = new THREE.MeshBasicMaterial({ color: 0x5bffb2, transparent: true, opacity: 0, side: THREE.DoubleSide }); const glowRing = new THREE.Mesh(glowGeo, glowMat); glowRing.rotation.x = Math.PI / 2; glowRing.position.y = -0.8; scene.add(glowRing); // ===== SHADOW ===== const shadowCanvas = document.createElement('canvas'); shadowCanvas.width = shadowCanvas.height = 256; const sCtx = shadowCanvas.getContext('2d'); const sGrad = sCtx.createRadialGradient(128, 128, 0, 128, 128, 128); sGrad.addColorStop(0, 'rgba(0,0,0,0.6)'); sGrad.addColorStop(0.5, 'rgba(0,0,0,0.3)'); sGrad.addColorStop(1, 'rgba(0,0,0,0)'); sCtx.fillStyle = sGrad; sCtx.fillRect(0, 0, 256, 256); const shadowTex = new THREE.CanvasTexture(shadowCanvas); const shadow = new THREE.Sprite(new THREE.SpriteMaterial({ map: shadowTex, transparent: true, opacity: 0.7 })); shadow.scale.set(3, 3, 1); shadow.position.set(0, -0.85, 0); scene.add(shadow); // ===== AURA EFFECT ===== const auraCanvas = document.createElement('canvas'); auraCanvas.width = auraCanvas.height = 512; const aCtx = auraCanvas.getContext('2d'); const aGrad = aCtx.createRadialGradient(256, 256, 0, 256, 256, 256); aGrad.addColorStop(0, 'rgba(255,255,255,1)'); aGrad.addColorStop(0.3, 'rgba(255,255,255,0.6)'); aGrad.addColorStop(1, 'rgba(255,255,255,0)'); aCtx.fillStyle = aGrad; aCtx.fillRect(0, 0, 512, 512); const auraTex = new THREE.CanvasTexture(auraCanvas); const auraMat = new THREE.SpriteMaterial({ map: auraTex, transparent: true, opacity: 0 }); const aura = new THREE.Sprite(auraMat); aura.scale.set(4, 4, 1); aura.position.set(0, -0.3, 0); scene.add(aura); // Winner celebration - disabled (no more pulse effect) const celebrate = (side) => { // No visual effects - clean landing }; // ===== RENDER LOOP ===== let time = 0; const render = () => { rafRef.current = requestAnimationFrame(render); time += 0.016; rimLight.intensity = 1.5 + Math.sin(time * 3) * 0.3; renderer.render(scene, camera); }; render(); // ===== ANIMATION API ===== const TAU = Math.PI * 2; let finished = false; const restY = -0.3; const api = { reset() { finished = false; gsap.killTweensOf([coin.position, coin.rotation, shadow.scale, shadow.material, glowRing.material]); coin.position.set(0, restY, 0); coin.rotation.set(0, 0, 0); shadow.scale.set(3, 3, 1); shadow.material.opacity = 0.7; aura.material.opacity = 0; glowRing.material.opacity = 0; glowRing.scale.set(1, 1, 1); }, idle() { this.reset(); // Smooth floating with gentle breathing motion gsap.to(coin.position, { y: restY + 0.25, duration: 3, yoyo: true, repeat: -1, ease: 'sine.inOut' }); // Slow elegant rotation gsap.to(coin.rotation, { y: TAU, duration: 10, repeat: -1, ease: 'none' }); // Subtle tilt for depth gsap.to(coin.rotation, { x: 0.4, duration: 3.5, yoyo: true, repeat: -1, ease: 'sine.inOut' }); // Very subtle wobble gsap.to(coin.rotation, { z: 0.06, duration: 4.5, yoyo: true, repeat: -1, ease: 'sine.inOut' }); // Shadow breathes with coin gsap.to(shadow.scale, { x: 3.3, y: 3.3, duration: 3, yoyo: true, repeat: -1, ease: 'sine.inOut' }); }, // SMOOTH COINFLIP ANIMATION - Clean single arc toss(side) { gsap.killTweensOf([coin.position, coin.rotation, shadow.scale, shadow.material]); finished = false; // ROTATION LOGIC: // Cylinder: rotation.x = 0 shows RED (top), rotation.x = π shows GREEN (bottom) const landingRotation = side === 'green' ? Math.PI : 0; // Simple, clean animation parameters const peakY = 2.0; // Contained peak height const totalDuration = 1.4; // Total flip time // Spin calculations - land on correct side const fullSpins = 5; // Fixed number of spins for consistency const finalX = (fullSpins * TAU) + landingRotation; const tl = gsap.timeline(); // ─── SINGLE SMOOTH ARC - Up and down ─── // Position: smooth parabolic arc using power easing tl.to(coin.position, { y: peakY, duration: totalDuration * 0.45, ease: 'power2.out' }, 0); tl.to(coin.position, { y: restY, duration: totalDuration * 0.45, ease: 'power2.in' }, totalDuration * 0.45); // Rotation: continuous smooth spin tl.to(coin.rotation, { x: finalX, duration: totalDuration * 0.9, ease: 'power1.inOut' }, 0); // Shadow follows coin height tl.to(shadow.scale, { x: 1, y: 1, duration: totalDuration * 0.45, ease: 'power2.out' }, 0); tl.to(shadow.material, { opacity: 0.2, duration: totalDuration * 0.45 }, 0); tl.to(shadow.scale, { x: 3, y: 3, duration: totalDuration * 0.45, ease: 'power2.in' }, totalDuration * 0.45); tl.to(shadow.material, { opacity: 0.7, duration: totalDuration * 0.45 }, totalDuration * 0.45); // ─── SMALL BOUNCE ON LANDING ─── const bounceStart = totalDuration * 0.9; tl.to(coin.position, { y: restY + 0.15, duration: 0.1, ease: 'power2.out' }, bounceStart); tl.to(coin.position, { y: restY, duration: 0.1, ease: 'power2.in' }, bounceStart + 0.1); // ─── SETTLE ─── const settleTime = bounceStart + 0.25; tl.add(() => { coin.rotation.x = landingRotation; coin.rotation.y = 0; coin.rotation.z = 0; coin.position.y = restY; shadow.scale.set(3, 3, 1); shadow.material.opacity = 0.7; if (!finished && side) { finished = true; onAnimationComplete?.(); } }, settleTime); }, snapTo(side) { if (finished) return; gsap.killTweensOf([coin.position, coin.rotation]); const targetX = side === 'green' ? Math.PI : 0; gsap.to(coin.rotation, { x: targetX, y: 0, z: 0, duration: 0.3, ease: 'power2.out', onComplete: () => { if (!finished) { finished = true; onAnimationComplete?.(); } } }); coin.position.y = restY; } }; apiRef.current = api; api.idle(); // ===== CLEANUP ===== return () => { cancelAnimationFrame(rafRef.current); gsap.killTweensOf([coin.position, coin.rotation, shadow.scale, shadow.material, aura.material, aura.scale, glowRing.material, glowRing.scale]); try { root.removeChild(renderer.domElement); } catch {} coinGeo.dispose(); glowGeo.dispose(); glowMat.dispose(); edgeMat.dispose(); redMat.dispose(); greenMat.dispose(); redTex.dispose(); greenTex.dispose(); edgeTex.dispose(); renderer.dispose(); }; }, [size]); // ===== PHASE-DRIVEN ANIMATION ===== useEffect(() => { const api = apiRef.current; if (!api) return; if (phase === 'waiting' || phase === 'ready' || phase === 'countdown') { api.idle(); } else if (phase === 'flipping') { api.toss(winnerSide); } // Note: 'reveal' phase no longer calls snapTo since toss() already lands on correct side }, [phase, winnerSide]); return (
); } // API helper const api = async (path, { method = 'GET', body } = {}) => { const res = await fetch(path, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: body ? JSON.stringify(body) : undefined }); if (!res.ok) throw new Error(await res.text().catch(() => res.statusText)); const ct = res.headers.get('content-type') || ''; return ct.includes('application/json') ? res.json() : res.text(); }; // ============================================ // AVATAR COMPONENT - Uses shared auth styling with PNG borders // ============================================ function Avatar({ username, size = 'md', showBorder = true, level = 1, showLevel = false }) { // Size classes for cf-avatar base styles const sizeClasses = { xs: 'cf-avatar--xs', sm: 'cf-avatar--sm', md: 'cf-avatar--md', lg: 'cf-avatar--lg', xl: 'cf-avatar--xl' }; // Border frame sizes (approximately 1.8x the avatar size) const frameSizes = { xs: 44, sm: 72, md: 86, lg: 100, xl: 120 }; // Use colorFrom from shared-auth.js if available, fallback to hue calculation const getBackground = () => { if (typeof window.colorFrom === 'function') { return window.colorFrom(username || 'X'); } const hue = [...(username || 'X')].reduce((a, c) => a + c.charCodeAt(0), 0) % 360; return `linear-gradient(135deg, hsl(${hue} 70% 50%), hsl(${hue} 70% 35%))`; }; // Get border frame image based on level const getBorderFrame = () => { if (level >= 40) return '/static/img/borders/Challengerborder.png'; // Mythic & Legendary if (level >= 30) return '/static/img/borders/Diamondborder.png'; if (level >= 20) return '/static/img/borders/Rubyborder.png'; // Gold Elite & Platinum if (level >= 15) return '/static/img/borders/Goldborder.png'; if (level >= 10) return '/static/img/borders/Silverborder.png'; if (level >= 5) return '/static/img/borders/Bronzeborder.png'; return null; // Rookie - no frame }; // Use getAvatarBorderClass from shared-auth.js if available const getBorderClass = () => { if (typeof window.getAvatarBorderClass === 'function') { return window.getAvatarBorderClass(level); } return ''; }; const borderClass = showBorder ? getBorderClass() : ''; const frameUrl = showBorder ? getBorderFrame() : null; const frameSize = frameSizes[size] || 86; return (
{(username || 'X').slice(0, 2).toUpperCase()} {showLevel && level > 0 && ( {level} )} {frameUrl && ( )}
); } // ============================================ // SIDE BADGE COMPONENT // ============================================ function SideBadge({ side, size = 'md' }) { const isRed = side === 'red'; const sizes = { sm: 'px-2 py-0.5 text-xs', md: 'px-3 py-1 text-sm', lg: 'px-4 py-1.5 text-base' }; return ( {side} ); } // ============================================ // BATTLE CARD COMPONENT - PREMIUM REDESIGN // ============================================ function BattleCard({ battle, user, onJoin, onView, isJoining }) { const [timeLeft, setTimeLeft] = useState(CONFIG.battleTimeout); const isOwn = user && battle.creator === user.username; const canJoin = user && battle.status === 'open' && !isOwn; const isResolved = battle.status === 'resolved'; const isCountdown = battle.status === 'countdown'; const creatorSide = battle.creator_side; const joinerSide = creatorSide === 'red' ? 'green' : 'red'; useEffect(() => { if (isResolved) return; // Parse created_at - handle both ISO with 'Z' and without let createdAtStr = battle.created_at; if (createdAtStr && !createdAtStr.endsWith('Z') && !createdAtStr.includes('+')) { createdAtStr += 'Z'; // Assume UTC if no timezone specified } const createdAt = new Date(createdAtStr).getTime(); if (!Number.isFinite(createdAt)) return; const updateTimer = () => { const elapsed = (Date.now() - createdAt) / 1000; const remaining = Math.max(0, CONFIG.battleTimeout - elapsed); setTimeLeft(remaining); }; updateTimer(); const interval = setInterval(updateTimer, 1000); return () => clearInterval(interval); }, [battle.created_at, isResolved]); const isUrgent = timeLeft < 60; const isWarning = timeLeft < 120 && timeLeft >= 60; // Determine winner/loser for resolved battles const winnerIsCreator = isResolved && battle.winner === battle.creator; return (
{/* Compact row layout */}
{/* Creator */}
{battle.creator} {formatCoins(battle.amount)}
{isResolved && winnerIsCreator && {Icons.crown}}
{/* VS */}
VS
{/* Joiner or waiting */}
{battle.joiner ? ( <> {isResolved && !winnerIsCreator && {Icons.crown}}
{battle.joiner} {formatCoins(battle.join_amount || battle.amount)}
) : (
? Waiting...
)}
{/* Footer with bet amount, timer, actions */}
{formatCoins(battle.amount)}
{/* Deviation badge */} {battle.join_tolerance_pct > 0 && (
±{battle.join_tolerance_pct}%
)} {/* Status badge with timer urgency */}
{isResolved ? ( <>{battle.winner_side?.toUpperCase()} WINS ) : isCountdown ? ( <>FLIPPING ) : ( <>{Icons.clock} {formatTime(timeLeft)} )}
{canJoin && ( )}
); } // ============================================ // CREATE BATTLE PANEL // ============================================ function CreateBattlePanel({ user, onRefresh }) { const [amount, setAmount] = useState('10,00'); const [side, setSide] = useState('red'); const [tolerance, setTolerance] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const amountCents = parseCoinsInput(amount); const tolFraction = Math.max(0, tolerance) / 100; const minJoin = Math.max(100, Math.floor(amountCents * (1 - tolFraction))); const maxJoin = Math.max(minJoin, Math.ceil(amountCents * (1 + tolFraction))); const handleCreate = async (isFree = false) => { if (!user) { window.openAuthModal?.('login'); return; } if (amountCents < CONFIG.minBet) { setError('Minimum bet is 1.00 coins'); return; } if (amountCents > (user.balance || 0)) { setError('Insufficient balance'); return; } setLoading(true); setError(''); try { const endpoint = isFree ? '/coinflip/create-free' : '/coinflip/create'; await api(endpoint, { method: 'POST', body: { amount: amountCents, side, tolerance_pct: tolerance } }); onRefresh?.(); } catch (err) { setError(err.message || 'Failed to create battle'); } finally { setLoading(false); } }; const [customTolerance, setCustomTolerance] = useState(''); const presets = ['5,00', '10,00', '25,00', '50,00', '100,00', '250,00']; const handleCustomTolerance = (val) => { const num = parseInt(val, 10); setCustomTolerance(val); if (!isNaN(num) && num >= 0 && num <= 100) { setTolerance(num); } }; return (
{/* Header */}
{Icons.bolt} Create Battle
{/* Content */}
{/* Side Selection */}
VS
{/* Amount Row */}
setAmount(e.target.value)} placeholder="10,00" />
{['5', '10', '25', '50', '100', '250', '500'].map(p => ( ))} {user?.balance > 0 && ( )}
{/* Tolerance - With Custom Input */}
Deviation
{[0, 5, 10, 25].map(t => ( ))}
handleCustomTolerance(e.target.value)} className={clsx('tol-custom__input', customTolerance && 'tol-custom__input--active')} /> %
{/* Error */} {error &&
{error}
} {/* CTA */}
{user ? ( user.free_battles_available > 0 ? (
) : ( ) ) : ( )}
); } // ============================================ // HISTORY TICKER // ============================================ function HistoryTicker({ history }) { if (!history?.length) return null; return (
{history.slice(0, 12).map((h, i) => (
#{h.id} {h.winner} won {formatCoins(h.amount)} on
))}
); } // ============================================ // LEADERBOARD PANEL // ============================================ function LeaderboardPanel({ leaderboard }) { return (
{Icons.trophy} Weekly Champions
{leaderboard.length === 0 ? (
💤 No battles this week yet
) : (
{leaderboard.slice(0, 10).map((player, index) => (
{index < 3 ? ( {index === 0 ? '👑' : index === 1 ? '🥈' : '🥉'} ) : ( #{index + 1} )}
{player.username} {player.battles_played} battle{player.battles_played !== 1 ? 's' : ''}
0 ? 'cf-leaderboard-profit--positive' : 'cf-leaderboard-profit--negative' )}> {player.net_profit > 0 ? '+' : ''}{formatCoins(player.net_profit)}
))}
)}
); } // ============================================ // DUEL OVERLAY - LANDING PAGE STYLE // Premium dark theme with green/gold accents // ============================================ function DuelOverlay({ duel, reveal, phase, timeLeft, user, onClose }) { const [localPhase, setLocalPhase] = useState(() => { // Initialize based on phase prop if available if (phase === 'countdown') return 'countdown'; if (phase === 'flipping') return 'flipping'; if (phase === 'reveal') return 'landed'; return 'waiting'; }); const [showResult, setShowResult] = useState(false); const [showConfetti, setShowConfetti] = useState(false); const [coinLanded, setCoinLanded] = useState(false); const resultTimerRef = useRef(null); if (!duel) return null; // ===== COMPUTED VALUES ===== const playerRed = duel.creator_side === 'red' ? duel.creator : duel.joiner; const playerGreen = duel.creator_side === 'green' ? duel.creator : duel.joiner; const redLevel = duel.creator_side === 'red' ? (duel.creator_level || 1) : (duel.joiner_level || 1); const greenLevel = duel.creator_side === 'green' ? (duel.creator_level || 1) : (duel.joiner_level || 1); const creatorAmt = duel.amount || 0; const joinAmt = duel.join_amount || creatorAmt; const totalPot = creatorAmt + joinAmt; const redAmt = duel.creator_side === 'red' ? creatorAmt : joinAmt; const greenAmt = duel.creator_side === 'green' ? creatorAmt : joinAmt; const redPct = totalPot > 0 ? (redAmt / totalPot) * 100 : 50; const greenPct = 100 - redPct; // Winner info const winnerSide = reveal?.winner_side; const winnerName = reveal?.winner; const winnerPrize = reveal?.winner_prize || Math.floor(totalPot * 0.98); const houseFee = reveal?.house_fee || Math.floor(totalPot * 0.02); const secondsLeft = Math.max(0, Math.ceil((timeLeft || 0) / 1000)); // User state const userInGame = user && (duel.creator === user.username || duel.joiner === user.username); const normalizedUser = user?.username?.toString().trim().toLowerCase(); const normalizedWinner = winnerName?.toString().trim().toLowerCase(); const userWon = userInGame && normalizedWinner && normalizedUser === normalizedWinner; const userSide = user?.username === playerRed ? 'red' : (user?.username === playerGreen ? 'green' : null); // ===== PHASE MANAGEMENT WITH 1.5s DELAY ===== useEffect(() => { console.log('[DuelOverlay Phase Effect]', { phase, localPhase, joiner: duel.joiner, duelId: duel.id }); if (resultTimerRef.current) clearTimeout(resultTimerRef.current); if (!phase || phase === 'waiting') { setLocalPhase(duel.joiner ? 'ready' : 'waiting'); setShowResult(false); setShowConfetti(false); setCoinLanded(false); } else if (phase === 'countdown') { console.log('[DuelOverlay] Setting localPhase to countdown'); setLocalPhase('countdown'); setShowResult(false); setShowConfetti(false); setCoinLanded(false); } else if (phase === 'flipping') { setLocalPhase('flipping'); setShowResult(false); setCoinLanded(false); } else if (phase === 'reveal') { // Coin has landed, but wait 1.5 seconds before showing result setCoinLanded(true); setLocalPhase('landed'); // New phase: coin landed but result not shown yet resultTimerRef.current = setTimeout(() => { setLocalPhase('result'); setShowResult(true); if (userWon || !userInGame) setShowConfetti(true); }, 1500); // 1.5 second delay } return () => { if (resultTimerRef.current) clearTimeout(resultTimerRef.current); }; }, [phase, duel.joiner, userWon, userInGame]); // Reset state when duel changes, but respect incoming phase useEffect(() => { setShowResult(false); setShowConfetti(false); setCoinLanded(false); // Don't reset localPhase here - let the phase management effect handle it }, [duel.id]); // Floating particles (like landing page) const particles = useMemo(() => [...Array(20)].map((_, i) => ({ id: i, x: Math.random() * 100, y: Math.random() * 100, size: 2 + Math.random() * 2, duration: 4 + Math.random() * 4, delay: Math.random() * 2 })), [duel.id] ); // Confetti for winners const confetti = useMemo(() => [...Array(80)].map((_, i) => ({ id: i, x: Math.random() * 100, delay: Math.random() * 0.5, duration: 2 + Math.random() * 2, color: ['#ffd700', '#5bffb2', '#ff6b6b', '#3b82f6', '#fbbf24'][i % 5], size: 6 + Math.random() * 8 })), [duel.id] ); // ===== RENDER ===== return (
{/* Dark backdrop */}
{/* Confetti */} {showConfetti && (
{confetti.map(c => (
))}
)} {/* Modal Card */}
e.stopPropagation()}> {/* Close button */} {/* Header */}
Battle #{duel.id}
{formatCoins(winnerPrize)}
{/* Players Row */}
{/* Red Player */}
{playerRed || 'Waiting...'} {userSide === 'red' && YOU} {formatCoins(redAmt)}
{redPct.toFixed(0)}% {localPhase === 'result' && winnerSide === 'red' && (
)}
{/* VS / Coin Center */}
{/* Status indicator */} {localPhase === 'waiting' && (
Waiting...
)} {localPhase === 'ready' && (
Ready!
)} {localPhase === 'countdown' && (
{secondsLeft}
)} {localPhase === 'flipping' && (
)} {localPhase === 'result' && (
{winnerSide?.toUpperCase()}
)} {/* Coin */}
{}} />
VS
{/* Green Player */}
{playerGreen || 'Waiting...'} {userSide === 'green' && YOU} {formatCoins(greenAmt)}
{greenPct.toFixed(0)}% {localPhase === 'result' && winnerSide === 'green' && (
)}
{/* Result ticket info */} {localPhase === 'result' && reveal?.ticket != null && (
Roll: {reveal.ticket.toFixed(4).replace('.', ',')}
)} {/* User result banner */} {userInGame && showResult && (
{userWon ? ( ) : ( )} {userWon ? 'YOU WON!' : 'YOU LOST'} {userWon && ( +{formatCoins(winnerPrize)} )}
)} {/* Footer */}
Provably Fair {localPhase === 'waiting' && !duel.joiner && ( )} {showResult && ( )}
); } // ============================================ // JOIN DIALOG // ============================================ function JoinDialog({ battle, user, onConfirm, onClose }) { const [amount, setAmount] = useState(''); const [loading, setLoading] = useState(false); const minJoin = Math.max(100, Math.floor(battle.amount * (1 - (battle.join_tolerance_pct || 0) / 100))); const maxJoin = Math.ceil(battle.amount * (1 + (battle.join_tolerance_pct || 0) / 100)); const maxAllowed = Math.min(maxJoin, user?.balance || maxJoin); useEffect(() => { const defaultAmt = Math.min(Math.max(battle.amount, minJoin), maxAllowed); setAmount(formatCoins(defaultAmt)); }, [battle.amount, minJoin, maxAllowed]); const amountCents = parseCoinsInput(amount); const joiningSide = battle.creator_side === 'red' ? 'green' : 'red'; // Calculate odds const totalPot = battle.amount + amountCents; const yourChance = totalPot > 0 ? (amountCents / totalPot) * 100 : 50; const creatorChance = 100 - yourChance; const handleJoin = async () => { if (amountCents < minJoin || amountCents > maxAllowed) return; setLoading(true); try { await onConfirm(battle, amountCents); onClose(); } catch (err) { console.error('Join failed:', err); } finally { setLoading(false); } }; return (
e.stopPropagation()}>
Battle #{battle.id}

Join Battle

{/* Battle Info */}
Creator {battle.creator}
Wager
{formatCoins(battle.amount)}
Their Side
Your Side
{/* Amount Input */} {(battle.join_tolerance_pct || 0) > 0 && (
setAmount(e.target.value)} className="cf-input" /> setAmount(formatCoins(Number(e.target.value)))} className="cf-slider" />
{formatCoins(minJoin)} – {formatCoins(maxAllowed)}
)} {/* Odds Display */}
Your Chance {yourChance.toFixed(1)}%
Total Pot
{formatCoins(totalPot)}
Their Chance {creatorChance.toFixed(1)}%
); } // ============================================ // RULES MODAL // ============================================ function RulesModal({ isOpen, onClose }) { if (!isOpen) return null; return (
e.stopPropagation()}>
Game Rules

How Coinflip Works

A fast-paced 1v1 betting game where two players compete with a coin flip.

1

Creating a Battle

  • Choose your bet amount and select Red or Green
  • Click "Create Battle" to start your game
  • Wait for an opponent to join
2

Joining a Battle

  • Browse available battles in the lobby
  • Click "Join" on any open battle
  • You'll be assigned the opposite color
3

The Coin Flip

  • Once both players are ready, the coin flips
  • Watch the 3D coin animation
  • The coin lands on Red or Green
4

Winning

  • If the coin lands on your color, you win!
  • Winner receives the pot minus 2% house fee
  • Winnings are instantly credited
{Icons.shield}

Provably Fair

Every flip uses cryptographically secure randomization

{Icons.bolt}

Leverage Mode

Enable leverage to amplify your potential winnings

); } // ============================================ // DAILY REWARDS MODAL // ============================================ // Helper to get SVG icon for reward type const getRewardIcon = (type) => { const typeIcons = { 'xp_boost': Icons.bolt, 'coinflip_discount': Icons.swords, 'barbarian_discount': Icons.shield, 'jackpot_discount': Icons.trophy, 'combo': Icons.crown }; return typeIcons[type] || Icons.gift; }; // Get reward color based on type const getRewardColor = (type) => { const colors = { 'xp_boost': { primary: '#ffd700', secondary: '#ff9500', gradient: 'linear-gradient(135deg, #ffd700, #ff9500)' }, 'coinflip_discount': { primary: '#3b82f6', secondary: '#1d4ed8', gradient: 'linear-gradient(135deg, #3b82f6, #1d4ed8)' }, 'barbarian_discount': { primary: '#ef4444', secondary: '#dc2626', gradient: 'linear-gradient(135deg, #ef4444, #dc2626)' }, 'jackpot_discount': { primary: '#a855f7', secondary: '#7c3aed', gradient: 'linear-gradient(135deg, #a855f7, #7c3aed)' }, 'combo': { primary: '#4fffb0', secondary: '#2dd490', gradient: 'linear-gradient(135deg, #4fffb0, #2dd490)' } }; return colors[type] || { primary: '#4fffb0', secondary: '#2dd490', gradient: 'linear-gradient(135deg, #4fffb0, #2dd490)' }; }; function DailyRewardModal({ isOpen, onClose, user, onClaim }) { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [claiming, setClaiming] = useState(false); const [claimResult, setClaimResult] = useState(null); const [showConfetti, setShowConfetti] = useState(false); useEffect(() => { if (isOpen && user) { setLoading(true); setClaimResult(null); fetch('/api/daily-reward/status', { credentials: 'include' }) .then(res => res.ok ? res.json() : null) .then(data => { setStatus(data); setLoading(false); }) .catch(() => setLoading(false)); } }, [isOpen, user]); const handleClaim = async () => { if (claiming || !status || status.daily_reward_claimed) return; setClaiming(true); try { const res = await fetch('/api/daily-reward/claim', { method: 'POST', credentials: 'include' }); const data = await res.json(); if (res.ok) { setClaimResult(data); setShowConfetti(true); setStatus(prev => ({ ...prev, daily_reward_claimed: true })); onClaim?.(data); setTimeout(() => setShowConfetti(false), 3000); } else { setClaimResult({ error: data.detail || 'Failed to claim reward' }); } } catch (e) { setClaimResult({ error: 'Network error' }); } setClaiming(false); }; if (!isOpen) return null; const streak = status?.login_streak || 1; const reward = status?.today_reward || {}; const claimed = status?.daily_reward_claimed || false; const rewardColor = getRewardColor(reward.type); const allRewards = status?.all_rewards || {}; const visibleDays = []; const startDay = Math.max(1, streak - 3); const endDay = Math.min(30, startDay + 6); for (let i = startDay; i <= endDay; i++) { visibleDays.push({ day: i, reward: allRewards[i] || {}, isCurrent: i === streak, isPast: i < streak, isFuture: i > streak }); } const hasActiveBonuses = status?.active_bonuses && ( status.active_bonuses.xp_boost > 0 || status.active_bonuses.coinflip_discounts > 0 || status.active_bonuses.barbarian_discounts > 0 || status.active_bonuses.jackpot_discounts > 0 ); return (
e.stopPropagation()}> {showConfetti && (
{[...Array(60)].map((_, i) => (
))}
)}
{loading ? (
Loading rewards...
) : !user ? (
{Icons.shield}

Login Required

Please login to claim your daily rewards!

) : ( <>
{getRewardIcon(reward.type)}
{Icons.bolt} {streak} Day Streak

{claimed ? 'Reward Claimed!' : 'Daily Reward'}

{claimed ? 'Come back tomorrow to continue your streak!' : 'Claim your reward and keep your streak alive!' }

Day {streak}
{getRewardIcon(reward.type)}

{reward.name || 'Daily Reward'}

{reward.description || 'Claim your daily reward!'}

{claimed && (
{Icons.check}
)}
Reward Calendar
{visibleDays.map(({ day, reward: dayReward, isCurrent, isPast, isFuture }) => { const dayColor = getRewardColor(dayReward.type); return (
Day {day}
{getRewardIcon(dayReward.type)}
{isPast &&
{Icons.check}
}
); })}
{hasActiveBonuses && (
Active Power-Ups
{status.active_bonuses.xp_boost > 0 && (
{Icons.bolt} +{status.active_bonuses.xp_boost}% XP
)} {status.active_bonuses.coinflip_discounts > 0 && (
{Icons.swords} {status.active_bonuses.coinflip_discounts}x Flip
)} {status.active_bonuses.barbarian_discounts > 0 && (
{Icons.shield} {status.active_bonuses.barbarian_discounts}x Duel
)} {status.active_bonuses.jackpot_discounts > 0 && (
{Icons.trophy} {status.active_bonuses.jackpot_discounts}x Jackpot
)}
)}
{claimed ? (
{Icons.clock} Next reward in Tomorrow
) : ( )} {claimResult?.error && (
{claimResult.error}
)}
)}
); } // ============================================ // 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 COMPONENT // ============================================ function CoinflipApp() { const [user, setUser] = useState(null); const [battles, setBattles] = useState([]); const [history, setHistory] = useState([]); const [leaderboard, setLeaderboard] = useState([]); const [ws, setWs] = useState(null); const [wsConnected, setWsConnected] = useState(false); // Duel overlay state const [activeDuel, setActiveDuel] = useState(null); const [duelReveal, setDuelReveal] = useState(null); const [duelPhase, setDuelPhase] = useState(null); const [duelTimeLeft, setDuelTimeLeft] = useState(0); // Refs to track current values for WebSocket handler (avoid stale closures) const activeDuelRef = useRef(null); const userRef = useRef(null); const duelPhaseRef = useRef(null); // Keep refs in sync with state useEffect(() => { activeDuelRef.current = activeDuel; }, [activeDuel]); useEffect(() => { userRef.current = user; }, [user]); useEffect(() => { duelPhaseRef.current = duelPhase; }, [duelPhase]); // Join dialog state const [joinBattle, setJoinBattle] = useState(null); // Rules modal const [showRules, setShowRules] = useState(false); // Auth modal state const [showAuthModal, setShowAuthModal] = useState(false); const [authModalMode, setAuthModalMode] = useState('login'); // Daily rewards modal const [showDailyModal, setShowDailyModal] = useState(false); // Filter const [filter, setFilter] = useState('all'); // Expose functions for navbar useEffect(() => { window.showCoinflipRules = () => setShowRules(true); window.dailyRewardsModal = { open: () => setShowDailyModal(true), close: () => setShowDailyModal(false) }; window.openAuthModal = (mode = 'login') => { setAuthModalMode(mode); setShowAuthModal(true); }; return () => { delete window.showCoinflipRules; delete window.dailyRewardsModal; delete window.openAuthModal; }; }, []); // Handle logout const handleLogout = async () => { try { await fetch('/auth/logout', { method: 'POST', credentials: 'include' }); setUser(null); window.location.reload(); } catch (err) { console.error('Logout failed:', err); } }; // Handle auth success const handleAuthSuccess = (userData) => { setUser(userData); setShowAuthModal(false); }; // Load user const loadUser = useCallback(async () => { try { const data = await api('/me'); setUser(data); } catch { setUser(null); } }, []); // Load battles const loadBattles = useCallback(async () => { try { const [live, recent] = await Promise.all([ api('/coinflip/live', { auth: false }), api('/coinflip/recent-resolved', { auth: false }) ]); setBattles([...live, ...recent]); } catch { // Fallback try { const data = await api('/coinflip/live', { auth: false }); setBattles(data); } catch {} } }, []); // Load history const loadHistory = useCallback(async () => { try { const data = await api('/coinflip/history', { auth: false }); setHistory(data); } catch {} }, []); // Load leaderboard const loadLeaderboard = useCallback(async () => { try { const data = await api('/coinflip/weekly-leaderboard', { auth: false }); setLeaderboard(data); } catch {} }, []); // Initial load useEffect(() => { loadUser(); loadBattles(); loadHistory(); loadLeaderboard(); }, [loadUser, loadBattles, loadHistory, loadLeaderboard]); // Auto-cleanup expired battles (5 minute timeout) useEffect(() => { const cleanupInterval = setInterval(() => { setBattles(prev => prev.filter(battle => { // Keep resolved battles (they have their own cleanup timer) if (battle.status === 'resolved' || battle.status === 'countdown') { return true; } // Check if open battle has expired (older than 5 minutes) if (battle.status === 'open' && battle.created_at) { let createdAtStr = battle.created_at; if (createdAtStr && !createdAtStr.endsWith('Z') && !createdAtStr.includes('+')) { createdAtStr += 'Z'; } const createdAt = new Date(createdAtStr).getTime(); if (Number.isFinite(createdAt)) { const elapsed = (Date.now() - createdAt) / 1000; // Remove if older than battle timeout (300 seconds = 5 minutes) if (elapsed > CONFIG.battleTimeout) { console.log('[Auto-cleanup] Removing expired battle:', battle.id); return false; } } } return true; })); }, 5000); // Check every 5 seconds return () => clearInterval(cleanupInterval); }, []); // WebSocket connection useEffect(() => { const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/lobby'; const socket = new WebSocket(wsUrl); setWs(socket); socket.onopen = () => setWsConnected(true); socket.onclose = () => setWsConnected(false); socket.onerror = () => setWsConnected(false); socket.onmessage = (ev) => { try { const data = JSON.parse(ev.data); if (data.type === 'battle_created') { setBattles(prev => [{ id: data.id, created_at: data.created_at, amount: data.amount, creator: data.creator, creator_side: data.creator_side, status: 'open', join_tolerance_pct: data.join_tolerance_pct || 0, watchers_count: data.watchers_count || 0, creator_level: data.creator_level || 1 }, ...prev]); } if (data.type === 'battle_filled') { console.log('[WebSocket battle_filled]', data); setDuelReveal(null); setActiveDuel({ id: data.id, amount: data.amount, join_amount: data.join_amount || data.amount, creator: data.creator, joiner: data.joiner, creator_side: data.creator_side, creator_level: data.creator_level || 1, joiner_level: data.joiner_level || 1, status: 'countdown', start_flip_at: data.start_flip_at }); console.log('[WebSocket] Setting duelPhase to countdown'); setDuelPhase('countdown'); // Update the battles list with countdown status setBattles(prev => prev.map(b => b.id === data.id ? { ...b, status: 'countdown', joiner: data.joiner, join_amount: data.join_amount, start_flip_at: data.start_flip_at, joiner_level: data.joiner_level || 1 } : b )); const startMs = new Date(data.start_flip_at).getTime(); const now = Date.now(); const countdown = Math.max(0, startMs - now); setDuelTimeLeft(countdown); // Only update countdown timer - DON'T start flip here! // Wait for battle_resolved from server with winner_side const countdownInterval = setInterval(() => { const remaining = Math.max(0, startMs - Date.now()); setDuelTimeLeft(remaining); if (remaining <= 0) { clearInterval(countdownInterval); // Keep countdown phase until server sends battle_resolved // The phase will change when we receive the result } }, 100); } if (data.type === 'battle_resolved') { const currentActiveDuel = activeDuelRef.current; const currentUser = userRef.current; console.log('[WebSocket Battle Resolved]', { messageData: data, winner: data.winner, winnerSide: data.winner_side, currentUser: currentUser?.username, activeDuelId: currentActiveDuel?.id, dataId: data.id, willSetReveal: currentActiveDuel && currentActiveDuel.id === data.id }); setHistory(prev => [{ id: data.id, amount: data.amount, winner: data.winner, winner_side: data.winner_side }, ...prev].slice(0, 100)); setBattles(prev => prev.map(b => b.id === data.id ? { ...b, status: 'resolved', winner: data.winner, winner_side: data.winner_side } : b )); if (currentActiveDuel && currentActiveDuel.id === data.id) { console.log('[Setting Duel Reveal & Starting Flip]', { revealData: data, currentUsername: currentUser?.username, willUserWin: data.winner === currentUser?.username }); // IMPORTANT: Set reveal data FIRST (so coin knows where to land) // Then set phase to 'flipping' to trigger animation setDuelReveal(data); setDuelPhase('flipping'); // After coin animation (~2.5s), show the result setTimeout(() => { setDuelPhase('reveal'); // Show any pending balance animation now that flip is complete if (window.showPendingBalanceAnimation) { window.showPendingBalanceAnimation(); } }, 2500); } loadLeaderboard(); // Remove after 60s setTimeout(() => { setBattles(prev => prev.filter(b => b.id !== data.id)); }, 60000); } if (data.type === 'watchers') { setBattles(prev => prev.map(b => b.id === data.id ? { ...b, watchers_count: data.count } : b )); } // Live balance update - update user balance without full reload if (data.type === 'balance_update') { const currentUser = userRef.current; if (currentUser && data.username === currentUser.username) { const oldBalance = currentUser.balance || 0; const newBalance = data.balance; const delta = newBalance - oldBalance; setUser(prev => prev ? { ...prev, balance: newBalance } : prev); // Queue animation if mid-flip, show immediately otherwise if (delta !== 0 && window.updateCoinRushBalance) { const isFlipping = duelPhaseRef.current === 'flipping' || duelPhaseRef.current === 'countdown'; window.updateCoinRushBalance(newBalance, delta, !isFlipping); } } } } catch {} }; return () => socket.close(); }, []); // Join battle handler const handleJoin = async (battle, amount) => { const joinAmount = amount || battle.amount; await api('/coinflip/join', { method: 'POST', body: { battle_id: battle.id, amount: joinAmount, client_seed: '' } }); // Balance will be updated via WebSocket balance_update event }; // Filter battles const filteredBattles = battles.filter(b => { if (filter === 'all') return true; if (filter === 'red') return b.creator_side === 'red'; if (filter === 'green') return b.creator_side === 'green'; if (filter === 'high') return b.amount >= 10000; return true; }); // Get navbar component (from navbar-react.jsx) const NavbarComponent = window.CoinRushNavbar; // Get chat component (from chat-react.jsx) const SidebarChatComponent = window.CoinRushChatApp; return (
{/* Floating Glowing Orbs - Background Animation */}
{/* Floating Particles - Small Dots */}
{[...Array(35)].map((_, i) => { // Use seeded pseudo-random for consistent but scattered positions const seed = i * 17 + 7; const x = ((seed * 13) % 90) + 5; const delay = i * 0.2; const duration = 4 + ((seed * 3) % 5); const size = 2 + (i % 4); return (
); })}
{/* React Navbar - Fixed at top */} {NavbarComponent && ( )} {/* Auth Modal (uses shared component from auth-react.jsx with OAuth buttons) */} {window.AuthModal && ( setShowAuthModal(false)} onSuccess={handleAuthSuccess} /> )} {/* Daily Rewards Modal */} setShowDailyModal(false)} user={user} onClaim={() => loadUser()} /> {/* Connection Warning */} {!wsConnected && (
⚠️ Connection lost. Please refresh the page.
)} {/* History Ticker */} {/* Main Layout */}
{/* Left: Create Panel */}
{/* Center: Battles */}
{/* Header */}

Live Battles

{filteredBattles.filter(b => b.status !== 'resolved').length} Active
{['all', 'red', 'green', 'high'].map(f => ( ))}
{/* Battle List */}
{filteredBattles.length > 0 ? ( filteredBattles.map(battle => ( { if ((b.join_tolerance_pct || 0) > 0) { setJoinBattle(b); } else { handleJoin(b); } }} onView={(b) => { setActiveDuel(b); if (b.status === 'resolved') { setDuelPhase('reveal'); setDuelReveal({ winner_side: b.winner_side, winner: b.winner }); } else if (b.status === 'countdown' && b.start_flip_at) { // Calculate remaining countdown time const startMs = new Date(b.start_flip_at).getTime(); const now = Date.now(); const remaining = Math.max(0, startMs - now); setDuelTimeLeft(remaining); setDuelPhase('countdown'); // Start countdown interval const countdownInterval = setInterval(() => { const timeLeft = Math.max(0, startMs - Date.now()); setDuelTimeLeft(timeLeft); if (timeLeft <= 0) { clearInterval(countdownInterval); } }, 100); } else { setDuelPhase(b.joiner ? 'ready' : 'waiting'); } }} /> )) ) : (
{Icons.swords}

No Active Battles

Create the first battle to get started!

)}
{/* Right: Chat */}
{SidebarChatComponent && ( )}
{/* Duel Overlay */} {activeDuel && ( { setActiveDuel(null); setDuelReveal(null); setDuelPhase(null); }} /> )} {/* Join Dialog */} {joinBattle && ( setJoinBattle(null)} /> )} {/* Rules Modal */} setShowRules(false)} />
); } // ============================================ // RENDER // ============================================ const rootEl = document.getElementById('coinflip-root'); if (rootEl) { const root = ReactDOM.createRoot(rootEl); root.render(); }