/**
* 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)}>
)}
onView(battle)}>
{isResolved ? Icons.refresh : Icons.eye}
{canJoin && (
onJoin(battle)} disabled={isJoining}>
{isJoining ? '...' : 'Join'}
)}
);
}
// ============================================
// 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 */}
setSide('red')}
>
R
RED
Ruby Side
VS
setSide('green')}
>
G
GREEN
Emerald Side
{/* Amount Row */}
{['5', '10', '25', '50', '100', '250', '500'].map(p => (
setAmount(p + ',00')} className="preset-btn">{p}
))}
{user?.balance > 0 && (
setAmount(formatCoins(user.balance))} className="preset-btn preset-btn--max">MAX
)}
{/* Tolerance - With Custom Input */}
Deviation
{[0, 5, 10, 25].map(t => (
{ setTolerance(t); setCustomTolerance(''); }}
className={clsx('tol-chip', tolerance === t && !customTolerance && 'tol-chip--active')}
>
±{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 ? (
handleCreate(false)} disabled={loading} className="cta-btn cta-btn--primary">
{loading ? 'Creating...' : 'Create'}
handleCreate(true)} disabled={loading} className="cta-btn cta-btn--free">
{Icons.gift} Free ({user.free_battles_available})
) : (
handleCreate(false)} disabled={loading} className="cta-btn cta-btn--primary cta-btn--full">
{Icons.bolt}
{loading ? 'Creating...' : 'Create Battle'}
)
) : (
window.openAuthModal?.('login')} className="cta-btn cta-btn--login">
{Icons.users}
Login to Play
)}
);
}
// ============================================
// 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 && (
)}
{/* 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' && (
)}
{localPhase === '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 && (
Cancel
)}
{showResult && (
{userWon ? 'Collect' : 'Close'}
)}
);
}
// ============================================
// 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()}>
{Icons.close}
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 && (
)}
{/* Odds Display */}
Your Chance
{yourChance.toFixed(1)}%
Total Pot
{formatCoins(totalPot)}
Their Chance
{creatorChance.toFixed(1)}%
Cancel
maxAllowed}
>
{loading ? 'Joining...' : 'Join Battle'}
);
}
// ============================================
// RULES MODAL
// ============================================
function RulesModal({ isOpen, onClose }) {
if (!isOpen) return null;
return (
e.stopPropagation()}>
{Icons.close}
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
Got it, let's play!
);
}
// ============================================
// 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) => (
))}
)}
{Icons.close}
{loading ? (
) : !user ? (
{Icons.shield}
Login Required
Please login to claim your daily rewards!
{ onClose(); window.openAuthModal?.('login'); }}
>
Log In
) : (
<>
{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
) : (
{claiming ? (
<>
Claiming...
>
) : (
<>
{Icons.gift}
Claim Reward
>
)}
)}
{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 => (
setFilter(f)}
className={clsx('cf-filter-tab', filter === f && 'cf-filter-tab--active')}
>
{f === 'all' ? 'All' : f === 'high' ? (
<>{Icons.crown} High>
) : f.charAt(0).toUpperCase() + f.slice(1)}
))}
setShowRules(true)}>
{Icons.info}
Rules
{/* 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( );
}