/* ==================================================================== * File: /assets/js/websocket-handler.js * Purpose: Handle real-time updates via WebSocket for BetPro Exchange * Version: 3.1 (Production-Optimized) * Updated: 2025-07-28 * ==================================================================== */ (() => { 'use strict'; /* ------------------------------------------------- * Config * ------------------------------------------------- */ const CFG = Object.freeze({ WS_URL: 'wss://live.betpro.com.ng:6001', HEARTBEAT_INTERVAL: 30_000, // ms – align with server HEARTBEAT_TIMEOUT: 5_000, // ms RECONNECT_INITIAL: 1_000, // ms RECONNECT_MAX: 30_000, // ms RECONNECT_EXP: 1.5, // exponential factor DEBUG: false }); /* ------------------------------------------------- * Simple helpers * ------------------------------------------------- */ const $ = (id) => document.getElementById(id); const $$ = (sel, ctx = document) => ctx.querySelectorAll(sel); const els = { statusDot: $('statusDot'), statusTxt: $('statusText'), lastUpd: $('lastUpdated'), oddsGrid: $('oddsContainer') || document.querySelector('.market-card') }; const notify = (msg, type = 'info') => { const box = $('notification') || document.createElement('div'); if (!box.id) { box.id = 'notification'; box.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 10px 20px; border-radius: 4px; background: ${type === 'error' ? '#dc2626' : type === 'warning' ? '#eab308' : '#00cc99'}; color: #fff; z-index: 1000; transition: opacity 0.5s; opacity: 0; `; document.body.appendChild(box); } box.textContent = msg; box.className = `notification ${type} show`; box.style.opacity = '1'; setTimeout(() => { box.style.opacity = '0'; box.classList.remove('show'); }, 3_000); }; const updateStatus = (txt, cls) => { if (els.statusTxt) els.statusTxt.textContent = txt; if (els.statusDot) { els.statusDot.className = 'status-dot ' + cls; els.statusDot.style.cssText = ` width: 10px; height: 10px; border-radius: 50%; display: inline-block; background: ${cls === 'connected' ? '#00cc99' : cls === 'connecting' ? '#eab308' : '#dc2626'}; `; } }; const sanitize = (str) => { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }; /* ------------------------------------------------- * State * ------------------------------------------------- */ const S = { socket: null, hbTimer: null, hbTimeout: null, reconnectTimer: null, reconnectAttempts: 0, subscriptions: new Set(), get marketId() { return new URLSearchParams(location.search).get('market_id'); }, get marketType() { return new URLSearchParams(location.search).get('market_type') || 'sports'; }, get sport() { return new URLSearchParams(location.search).get('sport') || null; } }; /* ------------------------------------------------- * WebSocket plumbing * ------------------------------------------------- */ function connect() { if (!S.marketId) { notify('⚠️ Market ID missing in URL', 'error'); updateStatus('No market ID', 'disconnected'); return; } clearTimers(); updateStatus('Connecting…', 'connecting'); try { S.socket = new WebSocket(CFG.WS_URL); } catch (err) { console.error('WS init error:', err); notify('WebSocket initialization failed', 'error'); scheduleReconnect(); return; } S.socket.addEventListener('open', onOpen); S.socket.addEventListener('message', onMessage); S.socket.addEventListener('error', onError); S.socket.addEventListener('close', onClose); } function onOpen() { updateStatus('Connected', 'connected'); S.reconnectAttempts = 0; // Authenticate const token = localStorage.getItem('auth_token'); if (token) { S.socket.send(JSON.stringify({ action: 'authenticate', token })); } else { notify('Please log in to receive updates', 'warning'); window.location.href = '/login'; } // Subscribe to market const sub = { action: 'subscribe', market_id: S.marketId, market_type: S.marketType, sport: S.sport }; S.socket.send(JSON.stringify(sub)); S.subscriptions.add(JSON.stringify(sub)); if (CFG.DEBUG) console.log('Subscribed:', sub); startHeartbeat(); } function onMessage(ev) { try { const msg = JSON.parse(ev.data); if (CFG.DEBUG) console.log('WS ⇢', msg); switch (msg.type) { case 'heartbeat': clearTimeout(S.hbTimeout); break; case 'welcome': notify('Connected to BetPro Exchange', 'info'); break; case 'odds_update': if (Array.isArray(msg.outcomes)) renderOddsUpdate(msg); break; case 'market_update': renderMarketUpdate(msg); break; case 'matched_bet': notify(`Bet ${msg.bet_id} matched at odds ${msg.odds}`, 'info'); updateBetslip(msg); break; case 'admin_broadcast': notify(sanitize(msg.message), msg.severity); break; case 'market_status': updateMarketStatus(msg); break; case 'bet_update': notify(`Bet ${msg.bet_id} ${msg.status}`, 'info'); updateBetslip(msg); break; case 'error': notify(msg.error === 'unauthenticated' ? 'Please log in' : msg.error, 'error'); if (msg.redirect) window.location.href = msg.redirect; break; case 'status': if (msg.status === 'authenticated') notify('Authenticated successfully', 'info'); break; } } catch (err) { console.warn('WS parse error:', err); notify('Invalid message received', 'error'); } } function onError(e) { console.error('WS error:', e); notify('WebSocket error – reconnecting…', 'warning'); } function onClose(e) { updateStatus('Disconnected', 'disconnected'); stopHeartbeat(); scheduleReconnect(e.code !== 1000); if (CFG.DEBUG) console.warn(`WS closed (${e.code})`); } /* ------------------------------------------------- * Heartbeat * ------------------------------------------------- */ function startHeartbeat() { stopHeartbeat(); S.hbTimer = setInterval(() => { if (S.socket?.readyState === WebSocket.OPEN) { S.socket.send(JSON.stringify({ type: 'ping' })); S.hbTimeout = setTimeout(() => { console.warn('Heartbeat timeout – closing...'); S.socket.close(4000, 'Heartbeat timeout'); }, CFG.HEARTBEAT_TIMEOUT); } }, CFG.HEARTBEAT_INTERVAL); } function stopHeartbeat() { clearInterval(S.hbTimer); clearTimeout(S.hbTimeout); S.hbTimer = S.hbTimeout = null; } /* ------------------------------------------------- * Reconnect logic * ------------------------------------------------- */ function scheduleReconnect(enable = true) { if (!enable) return; const delay = Math.min( CFG.RECONNECT_INITIAL * Math.pow(CFG.RECONNECT_EXP, S.reconnectAttempts++), CFG.RECONNECT_MAX ); updateStatus(`Reconnecting in ${Math.round(delay / 1000)}s`, 'reconnecting'); S.reconnectTimer = setTimeout(connect, delay); } function clearTimers() { clearTimeout(S.reconnectTimer); stopHeartbeat(); S.reconnectTimer = null; } /* ------------------------------------------------- * DOM rendering utilities * ------------------------------------------------- */ function renderMarketUpdate({ market_id, market_name, outcomes, market_type, sport }) { const titleEl = $('marketTitle') || document.querySelector('.match-title'); if (titleEl && market_name) titleEl.textContent = sanitize(market_name); if (!Array.isArray(outcomes) || !els.oddsGrid) return; els.oddsGrid.innerHTML = outcomes.map(o => `
${sanitize(o.name)}
${(+o.probability || 0).toFixed(1)}%
`).join(''); touchLastUpdated(); notify(`Market ${market_name} updated`, 'info'); } function renderOddsUpdate({ market_id, outcomes }) { if (!Array.isArray(outcomes) || !els.oddsGrid) return; touchLastUpdated(); outcomes.forEach(u => { const row = els.oddsGrid.querySelector(`.outcome-row[data-outcome-id="${u.id}"]`); if (!row) return; const backVal = row.querySelector('.back-btn'); const layVal = row.querySelector('.lay-btn'); if (backVal && u.back_odds) animateOdds(backVal, u.back_odds); if (layVal && u.lay_odds) animateOdds(layVal, u.lay_odds); }); } function updateMarketStatus({ market_id, status, reason }) { const card = els.oddsGrid?.closest(`.market-card[data-market-id="${market_id}"]`); if (card) { const statusEl = card.querySelector('.market-status') || document.createElement('div'); if (!statusEl.className) { statusEl.className = 'market-status'; card.insertBefore(statusEl, card.querySelector('.outcome-row')); } statusEl.textContent = status === 'suspended' ? 'Suspended' : status === 'active' ? 'Live Now' : 'Closed'; statusEl.style.color = status === 'suspended' ? '#dc2626' : '#00cc99'; if (reason) notify(`Market ${market_id} ${status}: ${sanitize(reason)}`, status === 'suspended' ? 'warning' : 'info'); } } function updateBetslip({ bet_id, market_id, outcome_id, status, odds, stake }) { const betslipCount = $('betslip-count'); if (!betslipCount) return; let count = parseInt(betslipCount.dataset.value || 0); if (status === 'matched' || status === 'placed') count++; else if (status === 'canceled') count = Math.max(0, count - 1); betslipCount.dataset.value = count; betslipCount.textContent = count; if (CFG.DEBUG) console.log('Betslip updated:', { bet_id, status, count }); } function animateOdds(el, newVal) { const old = parseFloat(el.textContent); const nv = parseFloat(newVal); if (old === nv) return; el.classList.add('odds-update'); el.textContent = nv.toFixed(2); el.style.cssText = ` background: ${el.classList.contains('back-btn') ? '#00cc99' : '#2563eb'}; transition: background-color 0.2s ease; `; setTimeout(() => { el.classList.remove('odds-update'); el.style.background = ''; }, 1000); } function touchLastUpdated() { if (els.lastUpd) els.lastUpd.textContent = new Date().toLocaleTimeString(); } /* ------------------------------------------------- * Bet placement * ------------------------------------------------- */ function placeBet(marketId, outcomeId, odds, type) { if (!S.socket || S.socket.readyState !== WebSocket.OPEN) { notify('Not connected to server', 'error'); return; } if (!localStorage.getItem('auth_token')) { notify('Please log in to place bets', 'error'); window.location.href = '/login'; return; } S.socket.send(JSON.stringify({ action: 'place_bet', market_id: marketId, outcome_id: outcomeId, type, odds, stake: 100 // Example stake; adjust as needed })); notify('Bet placement requested', 'info'); } /* ------------------------------------------------- * Initialize * ------------------------------------------------- */ document.addEventListener('DOMContentLoaded', () => { // Hook up control buttons $('connectBtn')?.addEventListener('click', connect); $('reconnectBtn')?.addEventListener('click', () => { clearTimers(); connect(); }); $('disconnectBtn')?.addEventListener('click', () => S.socket?.close(1000, 'User disconnect')); // Hook up bet buttons document.addEventListener('click', (e) => { if (e.target.classList.contains('odds-btn')) { const row = e.target.closest('.outcome-row'); const marketCard = e.target.closest('.market-card'); placeBet( marketCard?.dataset.marketId, row?.dataset.outcomeId, e.target.dataset.odds, e.target.dataset.type ); } }); // Auto-start connect(); }); })();