2026-07-21 10:27:05 +02:00
<!doctype html>
< html lang = "it" >
< head >
< meta charset = "utf-8" >
2026-07-21 10:46:55 +02:00
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
2026-07-22 10:16:51 +02:00
< title > PLM Lottery</ title >
2026-07-22 15:39:47 +02:00
< link rel = "icon" type = "image/svg+xml" href = "/logo.svg" >
2026-07-22 23:20:40 +02:00
< link rel = "stylesheet" href = "/style.css" >
2026-07-21 10:27:05 +02:00
</ head >
< body >
2026-07-22 14:50:17 +02:00
< nav class = "hidden" id = "app-navbar" aria-label = "Sezioni" >
2026-07-22 10:36:36 +02:00
< div class = "app-navbar-top" >
2026-07-22 14:50:17 +02:00
< div class = "app-navbar-top-inner" >
2026-07-22 15:39:47 +02:00
< span class = "brand" >
< img class = "brand-mark" src = "/logo.svg" alt = "" >
PLM Lottery
</ span >
2026-07-22 14:50:17 +02:00
< div class = "app-navbar-account" >
< span class = "navbar-username" id = "dash-username" ></ span >
< span class = "navbar-balance mono" >
< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "2" y = "7" width = "20" height = "14" rx = "2" />< path d = "M16 21V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16" /></ svg >
< span id = "navbar-balance" > — PLM</ span >
</ span >
< a class = "link icon-link" href = "/guida" target = "_blank" rel = "noopener" title = "Guida" aria-label = "Apri la guida utente" >
< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< circle cx = "12" cy = "12" r = "10" />< path d = "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 2-3 4" />< path d = "M12 17h.01" /></ svg >
</ a >
< a class = "link icon-link" href = "https://github.com/REPLACE_ME/plm-lottery/issues/new" target = "_blank" rel = "noopener" title = "Segnala un bug" aria-label = "Segnala un bug su GitHub" >
< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M8 2v3M16 2v3M12 12v-2a2 2 0 1 1 2 2h-2Z" />< rect x = "6" y = "10" width = "12" height = "10" rx = "4" />< path d = "M6 15H3M21 15h-3M9 20v-3M15 20v-3" /></ svg >
</ a >
< button class = "link icon-link" onclick = "logout()" title = "Esci" aria-label = "Esci dall'account" >
< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />< path d = "M16 17l5-5-5-5M21 12H9" /></ svg >
</ button >
</ div >
2026-07-22 10:36:36 +02:00
</ div >
</ div >
< div class = "app-navbar-tabs" >
< button class = "navbar-tab active" id = "nav-deposit" onclick = "switchPanel('deposit')" >
< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "2" y = "7" width = "20" height = "14" rx = "2" />< path d = "M16 21V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16" /></ svg >
Deposito
</ button >
< button class = "navbar-tab" id = "nav-bet" onclick = "switchPanel('bet')" >
< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "2" y = "6" width = "20" height = "12" rx = "2" />< path d = "M6 12h.01M12 12h.01M18 12h.01" /></ svg >
Bet
</ button >
< button class = "navbar-tab" id = "nav-withdraw" onclick = "switchPanel('withdraw')" >
< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M12 19V5M5 12l7-7 7 7" /></ svg >
Prelievo
</ button >
2026-07-22 12:00:09 +02:00
< button class = "navbar-tab" id = "nav-profile" onclick = "switchPanel('profile')" >
< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />< circle cx = "12" cy = "7" r = "4" /></ svg >
Profilo
</ button >
2026-07-22 10:36:36 +02:00
</ div >
</ nav >
< div class = "app-shell" >
2026-07-22 10:16:51 +02:00
< div class = "chain-bar" id = "chain-bar" >
< span class = "chain-status-pill" >
< span class = "status-dot" id = "chain-status-dot" ></ span >
< span id = "chain-status-label" > Connessione…</ span >
</ span >
< span class = "chain-block mono" id = "chain-block" > Blocco —</ span >
</ div >
2026-07-22 10:36:36 +02:00
< div class = "maintenance-banner hidden" id = "maintenance-banner" >
< span > ⚠️</ span >
< span > Manutenzione in programma: il round in corso viene completato regolarmente (vincitore incluso), ma il round successivo non si aprirà finché la manutenzione non sarà terminata.</ span >
</ div >
2026-07-22 10:16:51 +02:00
< section id = "landing-hero" class = "hero" >
2026-07-21 10:46:55 +02:00
< h1 > PLM Lottery</ h1 >
2026-07-22 10:16:51 +02:00
< p class = "lead" > Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.</ p >
< div class = "hero-steps" >
< div class = "hero-step" >
< div class = "step-icon" >< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "2" y = "7" width = "20" height = "14" rx = "2" />< path d = "M16 21V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16" /></ svg ></ div >
< div class = "step-title" > 1. Deposita</ div >
< div class = "step-hint" > Ricevi un indirizzo PLM personale, tuo per sempre</ div >
</ div >
< div class = "hero-step" >
< div class = "step-icon" >< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "2" y = "6" width = "20" height = "12" rx = "2" />< path d = "M6 12h.01M12 12h.01M18 12h.01" /></ svg ></ div >
< div class = "step-title" > 2. Gioca</ div >
< div class = "step-hint" > Una bet a quota fissa per entrare nel round corrente</ div >
</ div >
< div class = "hero-step" >
< div class = "step-icon" >< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M8 21h8M12 17v4M7 4h10v4a5 5 0 0 1-10 0V4Z" />< path d = "M7 5H4a1 1 0 0 0-1 1v1a4 4 0 0 0 4 4M17 5h3a1 1 0 0 1 1 1v1a4 4 0 0 1-4 4" /></ svg ></ div >
< div class = "step-title" > 3. Vinci</ div >
< div class = "step-hint" > Estrazione dal blocco, montepremi accreditato subito</ div >
</ div >
</ div >
< div class = "trust-row" >
< span class = "trust-pill" >< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M20 6 9 17l-5-5" /></ svg > Quota fissa dichiarata</ span >
< span class = "trust-pill" >< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M20 6 9 17l-5-5" /></ svg > Estrazione da hash di blocco</ span >
< span class = "trust-pill" >< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M20 6 9 17l-5-5" /></ svg > Prelievo libero in ogni momento</ span >
</ div >
</ section >
2026-07-21 10:27:05 +02:00
2026-07-21 10:46:55 +02:00
< section id = "auth-section" class = "card" >
< div class = "tabs" >
< div class = "tab active" id = "tab-login" onclick = "switchTab('login')" > Login</ div >
< div class = "tab" id = "tab-register" onclick = "switchTab('register')" > Registrati</ div >
</ div >
< div class = "tab-panel active" id = "panel-login" >
< label for = "login-username" > Username</ label >
< input id = "login-username" autocomplete = "username" >
< label for = "login-password" > Password</ label >
< input id = "login-password" type = "password" autocomplete = "current-password" >
< button onclick = "login()" id = "login-btn" > Accedi</ button >
</ div >
< div class = "tab-panel" id = "panel-register" >
< label for = "reg-username" > Username</ label >
< input id = "reg-username" autocomplete = "username" >
< label for = "reg-password" > Password</ label >
< input id = "reg-password" type = "password" autocomplete = "new-password" >
2026-07-21 14:22:03 +02:00
< label for = "reg-password-confirm" > Conferma password</ label >
< input id = "reg-password-confirm" type = "password" autocomplete = "new-password" >
2026-07-21 10:46:55 +02:00
< button onclick = "register()" id = "register-btn" > Crea account</ button >
2026-07-21 10:27:05 +02:00
</ div >
</ section >
< section id = "dashboard-section" class = "hidden" >
2026-07-22 14:50:17 +02:00
< div class = "card round-card" id = "round-card" >
2026-07-21 16:03:42 +02:00
< div class = "row-between" id = "round-normal-row" >
2026-07-21 10:57:39 +02:00
< h2 id = "round-title" > Round —</ h2 >
< span class = "mono" id = "round-timer" style = "font-size:1.1rem;font-weight:700" > --:--</ span >
</ div >
2026-07-21 16:03:42 +02:00
< div class = "row-between" style = "margin-top:10px" id = "round-stats-row" >
2026-07-21 10:57:39 +02:00
< div >
< div class = "hint" style = "margin-bottom:2px" > Giocatori</ div >
< span class = "mono" id = "round-players" > —</ span >
</ div >
< div style = "text-align:right" >
< div class = "hint" style = "margin-bottom:2px" > Jackpot</ div >
< span class = "mono" id = "round-jackpot" > —</ span > < span class = "balance-unit" > PLM</ span >
</ div >
</ div >
2026-07-21 16:03:42 +02:00
< div class = "draw-state" id = "draw-state" >
< div class = "draw-spinner" ></ div >
2026-07-22 23:21:04 +02:00
< div class = "draw-label" id = "draw-label" > Estrazione del vincitore in corso…</ div >
2026-07-21 16:03:42 +02:00
</ div >
< div class = "hidden" id = "draw-result" ></ div >
2026-07-21 10:57:39 +02:00
</ div >
2026-07-21 10:52:06 +02:00
< div class = "dash-panel active" id = "panel-deposit" >
< div class = "card" >
< h2 > Saldo interno</ h2 >
< p class = "hint" > Aggiornato dopo 1 conferma sulla rete</ p >
< div class = "row-between" >
< div >< span class = "balance-value mono" id = "dash-balance" > —</ span > < span class = "balance-unit" > PLM</ span ></ div >
< button class = "secondary" onclick = "refreshMe()" id = "refresh-btn" aria-label = "Aggiorna saldo" >
< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" /></ svg >
Aggiorna
</ button >
</ div >
</ div >
< div class = "card" >
< h2 > Indirizzo di deposito</ h2 >
< p class = "hint" > È anche l'indirizzo su cui ricevi eventuali vincite</ p >
< div class = "address-box" >
< span class = "mono" id = "dash-address" ></ span >
< button class = "secondary" onclick = "copyAddress()" aria-label = "Copia indirizzo" >
< svg class = "icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "9" y = "9" width = "13" height = "13" rx = "2" />< path d = "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></ svg >
</ button >
</ div >
< div class = "qr-box" >
< img id = "dash-qr" alt = "QR code dell'indirizzo di deposito" >
</ div >
2026-07-21 10:46:55 +02:00
</ div >
</ div >
2026-07-21 10:52:06 +02:00
< div class = "dash-panel" id = "panel-bet" >
< div class = "card" >
< h2 > Bet</ h2 >
< p class = "hint" > Ingresso fisso al round corrente</ p >
< button onclick = "placeBet()" id = "bet-btn" > Piazza bet (10 PLM)</ button >
2026-07-21 10:46:55 +02:00
</ div >
</ div >
2026-07-21 10:52:06 +02:00
< div class = "dash-panel" id = "panel-withdraw" >
< div class = "card" >
< h2 > Withdrawal</ h2 >
< p class = "hint" > Invia fondi a un indirizzo PLM esterno</ p >
< label for = "wd-address" > Indirizzo esterno</ label >
< input id = "wd-address" class = "mono" placeholder = "plm1q..." >
2026-07-22 14:29:05 +02:00
< p class = "hint" > Solo indirizzi P2WPKH bech32 (quelli che iniziano con < code > plm1q...</ code > ). Indirizzi legacy (< code > P...</ code > ) o P2SH non sono supportati.</ p >
2026-07-21 10:52:06 +02:00
< label for = "wd-amount" > Importo (PLM)</ label >
< input id = "wd-amount" inputmode = "decimal" placeholder = "es. 2" >
2026-07-22 14:59:02 +02:00
< label class = "checkbox-row" >
< input type = "checkbox" id = "wd-full-amount" onchange = "toggleWithdrawFullAmount()" >
Preleva l'intero importo (< span class = "mono" id = "wd-full-amount-value" > —</ span > PLM)
</ label >
2026-07-21 10:52:06 +02:00
< button onclick = "withdraw()" id = "withdraw-btn" > Preleva</ button >
</ div >
2026-07-21 10:46:55 +02:00
</ div >
2026-07-21 10:27:05 +02:00
2026-07-22 12:00:09 +02:00
< div class = "dash-panel" id = "panel-profile" >
< div class = "card" >
< h2 > Profilo</ h2 >
< p class = "hint" > Le tue informazioni account</ p >
< label > Username</ label >
< div class = "address-box" >< span id = "profile-username" > —</ span ></ div >
< label > Indirizzo di deposito</ label >
< div class = "address-box" >< span class = "mono" id = "profile-address" > —</ span ></ div >
< label > Saldo interno</ label >
< div class = "address-box" >< span class = "mono" id = "profile-balance" > —</ span > < span class = "balance-unit" > PLM</ span ></ div >
< label > Utente dal</ label >
< div class = "address-box" >< span id = "profile-created-at" > —</ span ></ div >
</ div >
< div class = "card" >
< h2 > Impostazioni</ h2 >
< p class = "hint" > Cambia la password del tuo account</ p >
< label for = "settings-current-password" > Password attuale</ label >
< input id = "settings-current-password" type = "password" autocomplete = "current-password" >
< label for = "settings-new-password" > Nuova password</ label >
< input id = "settings-new-password" type = "password" autocomplete = "new-password" >
< label for = "settings-new-password-confirm" > Conferma nuova password</ label >
< input id = "settings-new-password-confirm" type = "password" autocomplete = "new-password" >
< button onclick = "changePassword()" id = "change-password-btn" > Aggiorna password</ button >
</ div >
</ div >
2026-07-21 10:27:05 +02:00
</ section >
2026-07-22 10:36:36 +02:00
</ div >
2026-07-21 10:46:55 +02:00
< div id = "toast-container" aria-live = "polite" ></ div >
2026-07-21 10:27:05 +02:00
< script >
2026-07-21 10:46:55 +02:00
const SATS_PER_PLM = 100000000 ;
2026-07-21 10:27:05 +02:00
let token = localStorage . getItem ( 'plm_token' );
let username = localStorage . getItem ( 'plm_username' );
let address = localStorage . getItem ( 'plm_address' );
2026-07-21 10:46:55 +02:00
function toast ( message , type ) {
const container = document . getElementById ( 'toast-container' );
const el = document . createElement ( 'div' );
el . className = 'toast ' + type ;
el . textContent = message ;
container . appendChild ( el );
setTimeout (() => el . remove (), 4000 );
}
async function withLoading ( button , label , fn ) {
const original = button . textContent ;
button . disabled = true ;
button . textContent = label ;
try {
await fn ();
} finally {
button . disabled = false ;
button . textContent = original ;
}
2026-07-21 10:27:05 +02:00
}
2026-07-23 08:53:23 +02:00
const REQUEST_TIMEOUT_MS = 15000 ;
// Without a timeout, a single request that never resolves (server-side hang —
// stuck DB session, unresponsive Electrum connection...) would stall the whole
// sequential polling chain forever: the UI just freezes on whatever was last
// rendered, with no error and no "connessione persa" (that only fires on a
// rejected fetch, never on one that's merely stuck).
2026-07-21 10:27:05 +02:00
async function call ( method , path , body ) {
const headers = { 'Content-Type' : 'application/json' };
if ( token ) headers [ 'Authorization' ] = 'Bearer ' + token ;
2026-07-23 08:53:23 +02:00
const controller = new AbortController ();
const timeoutId = setTimeout (() => controller . abort (), REQUEST_TIMEOUT_MS );
let res ;
try {
res = await fetch ( path , { method , headers , body : body ? JSON . stringify ( body ) : undefined , signal : controller . signal });
} catch ( e ) {
throw new Error ( e . name === 'AbortError' ? 'Richiesta al server scaduta.' : e . message );
} finally {
clearTimeout ( timeoutId );
}
2026-07-21 10:27:05 +02:00
const data = await res . json (). catch (() => ({}));
if ( ! res . ok ) throw new Error ( data . detail || res . statusText );
return data ;
}
2026-07-21 10:46:55 +02:00
function switchTab ( name ) {
document . getElementById ( 'tab-login' ). classList . toggle ( 'active' , name === 'login' );
document . getElementById ( 'tab-register' ). classList . toggle ( 'active' , name === 'register' );
document . getElementById ( 'panel-login' ). classList . toggle ( 'active' , name === 'login' );
document . getElementById ( 'panel-register' ). classList . toggle ( 'active' , name === 'register' );
}
2026-07-21 10:52:06 +02:00
function switchPanel ( name ) {
2026-07-22 12:00:09 +02:00
for ( const key of [ 'deposit' , 'bet' , 'withdraw' , 'profile' ]) {
2026-07-21 10:52:06 +02:00
document . getElementById ( 'nav-' + key ). classList . toggle ( 'active' , key === name );
document . getElementById ( 'panel-' + key ). classList . toggle ( 'active' , key === name );
}
}
2026-07-23 08:53:23 +02:00
// Bumped on every logout/login so an in-flight refreshRound() started under a
// previous session can detect it's now stale — a fetch can still be awaiting
// its response after logout() clears the timeout-based poll chain, and without
// this guard it would re-arm scheduleNextRoundPoll() and resurrect a "zombie"
// dashboard poll running in parallel with the logged-out chain-only poll.
let sessionEpoch = 0 ;
2026-07-21 10:57:39 +02:00
let roundCloseAt = null ;
2026-07-22 14:07:09 +02:00
let serverTimeOffsetMs = 0 ; // serverNow - clientNow, so every client's countdown agrees regardless of local clock skew
function serverNow () { return new Date ( Date . now () + serverTimeOffsetMs ); }
// refreshRound() is triggered from several independent sources (poll timer, timer-hits-zero,
// visibilitychange, placeBet, showDashboard) whose requests can resolve out of order over the
// network. Track the latest applied response so a slow, stale one can never revert the UI to an
// older round's state after a newer response has already moved it forward.
let roundRequestSeq = 0 ;
let roundAppliedSeq = 0 ;
2026-07-21 10:57:39 +02:00
let roundTimerInterval = null ;
2026-07-21 16:03:42 +02:00
let roundPollTimeout = null ;
2026-07-23 08:53:23 +02:00
let lastResultInterval = null ;
2026-07-21 10:57:39 +02:00
const ROUND_STATUS_LABELS = {
open : 'aperto' ,
closing : 'in chiusura' ,
drawing : 'estrazione in corso' ,
2026-07-23 10:09:12 +02:00
paying_out : 'pagamento al vincitore in corso' ,
2026-07-21 10:57:39 +02:00
};
2026-07-21 16:03:42 +02:00
const DRAWING_STATUSES = [ 'closing' , 'drawing' , 'paying_out' ];
2026-07-22 23:21:04 +02:00
// One distinct message per DRAW sub-phase (see CLAUDE.md's "three separate
// on-chain confirmations" note) instead of a single generic spinner label —
// takes the round data so the drawing phase can surface the draw block once known.
function drawingLabelFor ( data ) {
if ( data . status === 'closing' ) {
return 'Round chiuso — in attesa di conferma dell\'ultima giocata prima di estrarre il vincitore…' ;
}
if ( data . status === 'drawing' ) {
return 'In attesa del prossimo blocco per estrarre il vincitore…' ;
}
// paying_out
if ( data . draw_block_height != null ) {
2026-07-23 10:09:12 +02:00
return 'Vincitore estratto dal blocco #' + data . draw_block_height + ' — pagamento al vincitore in corso…' ;
2026-07-22 23:21:04 +02:00
}
2026-07-23 10:09:12 +02:00
return 'Vincitore estratto — pagamento al vincitore in corso…' ;
2026-07-22 23:21:04 +02:00
}
// One label per real round status, not just the coarse open/drawing/waiting
// grouping — the status bar should show the same phase distinction as the
// draw-state panel (drawingLabelFor above), just condensed to a short phrase.
2026-07-22 10:16:51 +02:00
const CHAIN_STATUS_LABELS = {
waiting : 'In attesa del prossimo round' ,
open : 'Round aperto' ,
2026-07-22 23:21:04 +02:00
closing : 'Round chiuso — attesa conferma puntate' ,
2026-07-22 10:16:51 +02:00
drawing : 'Estrazione in corso' ,
2026-07-23 10:09:12 +02:00
paying_out : 'Pagamento al vincitore in corso' ,
2026-07-22 10:16:51 +02:00
};
function updateChainStatusBar ( data ) {
const dot = document . getElementById ( 'chain-status-dot' );
const label = document . getElementById ( 'chain-status-label' );
const block = document . getElementById ( 'chain-block' );
2026-07-22 23:21:04 +02:00
// The dot's color/pulse only distinguishes waiting/open/drawing (that's all
// the CSS defines) — closing and paying_out both pulse like drawing, they
// just get their own text label below.
let dotKey ;
if ( ! data . round_id ) dotKey = 'waiting' ;
else if ( DRAWING_STATUSES . includes ( data . status )) dotKey = 'drawing' ;
else dotKey = 'open' ;
2026-07-22 10:16:51 +02:00
2026-07-22 23:21:04 +02:00
const labelKey = data . round_id && data . status in CHAIN_STATUS_LABELS ? data . status : 'waiting' ;
dot . className = 'status-dot status-' + dotKey ;
label . textContent = CHAIN_STATUS_LABELS [ labelKey ];
2026-07-22 10:16:51 +02:00
block . textContent = 'Blocco ' + ( data . chain_tip_height != null ? '#' + data . chain_tip_height : '—' );
2026-07-22 10:36:36 +02:00
document . getElementById ( 'maintenance-banner' ). classList . toggle ( 'hidden' , ! data . lottery_paused );
2026-07-22 10:16:51 +02:00
}
2026-07-22 12:00:09 +02:00
// After a couple of consecutive failed polls (network blip, server restart,
// tab suspended too long...), say so explicitly instead of silently leaving
// whatever status happened to be on screen — a frozen "Round aperto" that's
// actually minutes stale is worse than an honest "connessione persa".
const STALE_AFTER_FAILURES = 2 ;
let consecutiveFetchFailures = 0 ;
function showConnectionLost () {
document . getElementById ( 'chain-status-dot' ). className = 'status-dot status-offline' ;
document . getElementById ( 'chain-status-label' ). textContent = 'Connessione al server persa — riprovo…' ;
}
function noteFetchOutcome ( ok ) {
if ( ok ) {
consecutiveFetchFailures = 0 ;
return ;
}
consecutiveFetchFailures ++ ;
if ( consecutiveFetchFailures >= STALE_AFTER_FAILURES ) showConnectionLost ();
}
2026-07-22 10:16:51 +02:00
let chainOnlyInterval = null ;
async function refreshChainStatusOnly () {
try {
const data = await call ( 'GET' , '/rounds/current' );
updateChainStatusBar ( data );
2026-07-22 12:00:09 +02:00
noteFetchOutcome ( true );
2026-07-22 10:16:51 +02:00
} catch ( e ) {
2026-07-22 12:00:09 +02:00
noteFetchOutcome ( false );
2026-07-22 10:16:51 +02:00
}
}
function startChainOnlyPolling () {
refreshChainStatusOnly ();
clearInterval ( chainOnlyInterval );
chainOnlyInterval = setInterval ( refreshChainStatusOnly , 15000 );
}
function stopChainOnlyPolling () {
clearInterval ( chainOnlyInterval );
chainOnlyInterval = null ;
}
2026-07-22 12:00:09 +02:00
// Background tabs get their timers throttled hard by the browser (sometimes to
// once a minute or less) — waiting for the next lazy tick after the user comes
// back could show a stale round state for a while. Refresh immediately instead
// as soon as the tab becomes visible again.
document . addEventListener ( 'visibilitychange' , () => {
if ( document . visibilityState !== 'visible' ) return ;
if ( chainOnlyInterval !== null ) {
refreshChainStatusOnly ();
} else if ( token ) {
refreshRound ();
2026-07-23 08:53:23 +02:00
checkLastRoundResult ();
2026-07-22 12:00:09 +02:00
}
});
2026-07-23 10:09:12 +02:00
// The win/lose box's content lives in localStorage, not just in-memory state —
// a page reload (or a completely fresh tab) must be able to redraw it exactly
// as it was, without waiting for a new poll or re-running the reveal
// animation. This is the single source of truth for "what result box (if any)
// is currently shown"; refreshRound() and checkLastRoundResult() below both
// read/write it instead of keeping their own separate notion of "revealed".
const PERSISTED_RESULT_KEY = 'plm_persisted_result' ;
2026-07-23 08:53:23 +02:00
2026-07-23 10:09:12 +02:00
function getPersistedResult () {
try {
return JSON . parse ( localStorage . getItem ( PERSISTED_RESULT_KEY ));
} catch ( e ) {
return null ;
}
2026-07-23 08:53:23 +02:00
}
2026-07-23 10:09:12 +02:00
function persistResult ( roundId , won , amountSats ) {
localStorage . setItem ( PERSISTED_RESULT_KEY , JSON . stringify ({ round_id : roundId , won , amount_sats : amountSats }));
}
function clearPersistedResult () {
localStorage . removeItem ( PERSISTED_RESULT_KEY );
}
function renderPersistedResult ( result ) {
setRoundInfoVisible ( false );
setResultBoxVisible (
true ,
result . won ? '🎉 Hai vinto! +' + ( result . amount_sats / SATS_PER_PLM ) + ' PLM' : 'Non hai vinto questa volta.' ,
result . won ? 'win' : 'lose'
);
}
// The most recent round_id refreshRound() actually saw from the server (null
// meaning "confirmed no active round"; undefined meaning "haven't polled yet").
// Lets checkLastRoundResult() below avoid clobbering a round that's already
// known to be open/in-progress by the time its own (slower, DB-backed) request
// resolves.
let currentRoundIdSeen ;
2026-07-23 08:53:23 +02:00
// Backstop for the live reveal in refreshRound(): that one only works if a poll
// happens to land while the round is still "paying_out" (winner_user_id is
// dropped from /rounds/current the instant the round flips to "closed" — see
// rounds/service.get_active_round). A backgrounded tab, a missed poll, or a
// late page load can miss that window entirely, in which case the live path
// never fires and the player would otherwise never learn the outcome. This
// reads GET /users/me/last-round-result, which reports the durable DB record
// instead of an ephemeral snapshot, so it always catches up eventually.
async function checkLastRoundResult () {
if ( ! token ) return ;
let data ;
try {
data = await call ( 'GET' , '/users/me/last-round-result' );
} catch ( e ) {
return ; // silent — this is a backstop, refreshRound()'s own error handling already covers the primary path
}
if ( data . round_id == null ) return ;
2026-07-23 10:09:12 +02:00
const persisted = getPersistedResult ();
if ( persisted && persisted . round_id === data . round_id ) return ; // already showing/known
if ( currentRoundIdSeen != null && currentRoundIdSeen !== data . round_id ) return ; // a newer round is already in progress on screen
persistResult ( data . round_id , data . won , data . amount_sats );
renderPersistedResult ({ won : data . won , amount_sats : data . amount_sats });
2026-07-23 08:53:23 +02:00
if ( data . won ) {
const won = data . amount_sats / SATS_PER_PLM ;
toast ( 'Hai vinto il round #' + data . round_id + '! +' + won + ' PLM' , 'success' );
refreshMe ();
}
}
2026-07-22 10:16:51 +02:00
let lastJackpotValue = null ;
2026-07-21 16:03:42 +02:00
2026-07-22 12:00:09 +02:00
let timerHitZero = false ;
2026-07-21 10:57:39 +02:00
function updateRoundTimer () {
const el = document . getElementById ( 'round-timer' );
2026-07-22 12:00:09 +02:00
if ( ! roundCloseAt ) { el . textContent = '--:--' ; timerHitZero = false ; return ; }
2026-07-22 14:07:09 +02:00
const rawSec = Math . floor (( roundCloseAt - serverNow ()) / 1000 );
2026-07-22 12:00:09 +02:00
const totalSec = Math . max ( 0 , rawSec );
2026-07-21 10:57:39 +02:00
const mm = String ( Math . floor ( totalSec / 60 )). padStart ( 2 , '0' );
const ss = String ( totalSec % 60 ). padStart ( 2 , '0' );
el . textContent = mm + ':' + ss ;
2026-07-22 12:00:09 +02:00
// The countdown alone can't know the round actually closed server-side — poll
// right away instead of waiting up to 15s for the next scheduled tick, so the
// card doesn't sit on "00:00 · aperto" longer than necessary.
if ( rawSec <= 0 && ! timerHitZero ) {
timerHitZero = true ;
refreshRound ();
} else if ( rawSec > 0 ) {
timerHitZero = false ;
}
2026-07-21 10:57:39 +02:00
}
2026-07-23 10:09:12 +02:00
// The round's normal info (title/timer/players/jackpot) vs. the drawing-phase
// spinner box vs. the personalized win/lose box are three independently
// toggled pieces, not three mutually-exclusive "screens" — during closing/
// drawing/paying_out, EVERY viewer sees the drawing box (generic phase
// progress), and a player who bet in that round ALSO sees the win/lose box at
// the same time once revealed, instead of the two fighting over one slot.
function setRoundInfoVisible ( show ) {
document . getElementById ( 'round-normal-row' ). classList . toggle ( 'hidden' , ! show );
document . getElementById ( 'round-stats-row' ). classList . toggle ( 'hidden' , ! show );
2026-07-21 16:03:42 +02:00
}
2026-07-23 10:09:12 +02:00
function setDrawingBoxVisible ( show , label ) {
document . getElementById ( 'draw-state' ). classList . toggle ( 'active' , show );
if ( show && label ) document . getElementById ( 'draw-label' ). textContent = label ;
}
function setResultBoxVisible ( show , html , cls ) {
2026-07-21 16:03:42 +02:00
const el = document . getElementById ( 'draw-result' );
2026-07-23 10:09:12 +02:00
if ( show ) {
el . className = 'draw-result ' + cls ;
el . innerHTML = html ;
}
el . classList . toggle ( 'hidden' , ! show );
2026-07-21 16:03:42 +02:00
}
function showNormalState () {
2026-07-23 10:09:12 +02:00
setRoundInfoVisible ( true );
setDrawingBoxVisible ( false );
setResultBoxVisible ( false );
2026-07-21 16:03:42 +02:00
}
2026-07-21 10:57:39 +02:00
async function refreshRound () {
2026-07-22 14:07:09 +02:00
const seq = ++ roundRequestSeq ;
2026-07-23 08:53:23 +02:00
const epoch = sessionEpoch ;
2026-07-21 10:57:39 +02:00
try {
const data = await call ( 'GET' , '/rounds/current' );
2026-07-23 08:53:23 +02:00
if ( epoch !== sessionEpoch ) return ; // session ended (or a new one started) while this was in flight
2026-07-22 14:07:09 +02:00
if ( seq < roundAppliedSeq ) return ; // a newer refreshRound() call already applied its result
roundAppliedSeq = seq ;
2026-07-22 12:00:09 +02:00
noteFetchOutcome ( true );
2026-07-22 10:16:51 +02:00
updateChainStatusBar ( data );
2026-07-21 10:57:39 +02:00
document . getElementById ( 'round-title' ). textContent = data . round_id
? 'Round #' + data . round_id + ' — ' + ( ROUND_STATUS_LABELS [ data . status ] || data . status )
: 'Nessun round attivo' ;
document . getElementById ( 'round-players' ). textContent = data . participant_count ;
2026-07-22 10:16:51 +02:00
const jackpotEl = document . getElementById ( 'round-jackpot' );
const jackpotValue = data . jackpot_sats / SATS_PER_PLM ;
jackpotEl . textContent = jackpotValue ;
if ( lastJackpotValue !== null && jackpotValue !== lastJackpotValue ) {
jackpotEl . classList . remove ( 'jackpot-bump' );
void jackpotEl . offsetWidth ; // restart the animation
jackpotEl . classList . add ( 'jackpot-bump' );
}
lastJackpotValue = jackpotValue ;
2026-07-22 14:07:09 +02:00
if ( data . server_time ) serverTimeOffsetMs = new Date ( data . server_time ) - new Date ();
2026-07-21 10:57:39 +02:00
roundCloseAt = data . closes_at ? new Date ( data . closes_at ) : null ;
updateRoundTimer ();
2026-07-21 16:03:42 +02:00
2026-07-23 10:09:12 +02:00
currentRoundIdSeen = data . round_id || null ;
2026-07-21 16:03:42 +02:00
const isDrawing = data . round_id && DRAWING_STATUSES . includes ( data . status );
2026-07-22 10:16:51 +02:00
document . getElementById ( 'round-card' ). classList . toggle ( 'drawing-glow' , !! isDrawing );
2026-07-23 10:09:12 +02:00
const persisted = getPersistedResult ();
2026-07-21 16:03:42 +02:00
if ( isDrawing ) {
2026-07-23 10:09:12 +02:00
setRoundInfoVisible ( false );
// The drawing-phase box (spinner + phase label) is generic status info —
// every viewer sees it for the whole closing/drawing/paying_out phase,
// regardless of whether they played in this round.
setDrawingBoxVisible ( true , drawingLabelFor ( data ));
2026-07-21 16:03:42 +02:00
2026-07-23 10:09:12 +02:00
// The cosmetic reveal delay is anchored to the server's closes_at, not to
// any client-side "when did I first see this" timestamp — a page reload
// (or repeated reloads) can never reset it, since it's derived purely
// from server-provided values that don't change for this round.
const elapsedMs = serverNow () - new Date ( data . closes_at );
const minMs = data . draw_animation_seconds * 1000 ;
const alreadyKnown = persisted && persisted . round_id === data . round_id ;
// myUserId may not be loaded yet on the very first tick after a reload
// (refreshMe() and refreshRound() run concurrently) — fall back to the
// persisted result rather than risk showing nothing or the wrong side.
const canReveal =
data . user_played && data . winner_user_id != null && ( alreadyKnown || elapsedMs >= minMs ) && myUserId != null ;
if ( canReveal ) {
const won = data . winner_user_id === myUserId ;
if ( ! alreadyKnown ) {
persistResult ( data . round_id , won , data . winner_amount_sats );
if ( won ) {
const wonAmount = ( data . winner_amount_sats / SATS_PER_PLM );
toast ( 'Hai vinto il round #' + data . round_id + '! +' + wonAmount + ' PLM' , 'success' );
refreshMe (); // the win toast is useless if the balance card still shows the pre-payout amount
}
2026-07-21 16:03:42 +02:00
}
2026-07-23 10:09:12 +02:00
renderPersistedResult ({ won , amount_sats : data . winner_amount_sats });
} else if ( alreadyKnown ) {
renderPersistedResult ( persisted );
} else {
setResultBoxVisible ( false );
2026-07-21 16:03:42 +02:00
}
2026-07-23 10:09:12 +02:00
} else {
setDrawingBoxVisible ( false );
if ( data . round_id && ( ! persisted || data . round_id !== persisted . round_id )) {
// a genuinely new round is open — clear any previous result and go back to normal
clearPersistedResult ();
showNormalState ();
} else if ( ! data . round_id && ! persisted ) {
// nothing has ever been revealed and there's no active round — plain empty state
showNormalState ();
} else if ( persisted ) {
// no active round right now (cooldown, or a page reload after the round
// fully closed) — keep the persisted result on screen regardless, until
// a genuinely new round replaces it above.
renderPersistedResult ( persisted );
2026-07-22 14:07:09 +02:00
}
2026-07-21 16:03:42 +02:00
}
2026-07-22 23:21:04 +02:00
scheduleNextRoundPoll ( isDrawing );
2026-07-21 16:03:42 +02:00
} catch ( e ) {
2026-07-23 08:53:23 +02:00
if ( epoch !== sessionEpoch ) return ; // session ended (or a new one started) while this was in flight
2026-07-22 12:00:09 +02:00
noteFetchOutcome ( false );
2026-07-21 16:03:42 +02:00
scheduleNextRoundPoll ( false );
}
}
function scheduleNextRoundPoll ( fast ) {
clearTimeout ( roundPollTimeout );
roundPollTimeout = setTimeout ( refreshRound , fast ? 3000 : 15000 );
2026-07-21 10:57:39 +02:00
}
2026-07-23 10:09:12 +02:00
async function showDashboard () {
2026-07-23 08:53:23 +02:00
sessionEpoch ++ ; // invalidate any dashboard poll chain left over from a previous login
2026-07-22 10:16:51 +02:00
stopChainOnlyPolling ();
document . getElementById ( 'landing-hero' ). classList . add ( 'hidden' );
2026-07-21 10:27:05 +02:00
document . getElementById ( 'auth-section' ). classList . add ( 'hidden' );
2026-07-22 10:36:36 +02:00
document . getElementById ( 'app-navbar' ). classList . remove ( 'hidden' );
2026-07-21 10:27:05 +02:00
document . getElementById ( 'dashboard-section' ). classList . remove ( 'hidden' );
document . getElementById ( 'dash-username' ). textContent = username ;
document . getElementById ( 'dash-address' ). textContent = address ;
2026-07-21 10:52:06 +02:00
document . getElementById ( 'dash-qr' ). src = '/qr/' + encodeURIComponent ( address );
2026-07-23 10:09:12 +02:00
// Render instantly from localStorage, before the network round-trip below —
// otherwise a reload right after a win/lose flashes an empty round card for
// a moment. refreshRound()'s own response reconciles this shortly after
// (e.g. hides it again if a new round has since opened).
const persisted = getPersistedResult ();
if ( persisted ) renderPersistedResult ( persisted );
// Awaited so myUserId is populated before refreshRound() decides whether
// data.winner_user_id === myUserId — otherwise that comparison could race
// against an unset myUserId right after a reload.
await refreshMe ();
2026-07-23 10:52:50 +02:00
// Awaited too, and before refreshRound(): on a brand-new browser/device that
// never saw this round live (nothing in localStorage), this is the only
// thing that knows the outcome once the round has fully closed. Resolving
// it first means refreshRound() finds the answer already in place instead
// of momentarily rendering "no result" and then flipping to the win/lose
// box a moment later once this backstop catches up.
await checkLastRoundResult ();
2026-07-21 10:57:39 +02:00
refreshRound ();
2026-07-23 08:53:23 +02:00
clearInterval ( lastResultInterval );
lastResultInterval = setInterval ( checkLastRoundResult , 20000 );
2026-07-21 10:57:39 +02:00
clearInterval ( roundTimerInterval );
roundTimerInterval = setInterval ( updateRoundTimer , 1000 );
2026-07-21 10:27:05 +02:00
}
2026-07-21 10:46:55 +02:00
function persistSession ( data , u ) {
2026-07-21 10:27:05 +02:00
token = data . access_token ; username = u ; address = data . address ;
localStorage . setItem ( 'plm_token' , token );
localStorage . setItem ( 'plm_username' , username );
localStorage . setItem ( 'plm_address' , address );
2026-07-21 10:46:55 +02:00
}
async function register () {
const btn = document . getElementById ( 'register-btn' );
const u = document . getElementById ( 'reg-username' ). value ;
const p = document . getElementById ( 'reg-password' ). value ;
2026-07-21 14:22:03 +02:00
const pConfirm = document . getElementById ( 'reg-password-confirm' ). value ;
if ( p !== pConfirm ) {
toast ( 'Le password non coincidono.' , 'error' );
return ;
}
2026-07-21 10:46:55 +02:00
await withLoading ( btn , 'Creazione…' , async () => {
try {
const data = await call ( 'POST' , '/auth/register' , { username : u , password : p });
persistSession ( data , u );
toast ( 'Account creato.' , 'success' );
showDashboard ();
} catch ( e ) {
toast ( e . message , 'error' );
}
});
2026-07-21 10:27:05 +02:00
}
async function login () {
2026-07-21 10:46:55 +02:00
const btn = document . getElementById ( 'login-btn' );
2026-07-21 10:27:05 +02:00
const u = document . getElementById ( 'login-username' ). value ;
const p = document . getElementById ( 'login-password' ). value ;
2026-07-21 10:46:55 +02:00
await withLoading ( btn , 'Accesso…' , async () => {
try {
const data = await call ( 'POST' , '/auth/login' , { username : u , password : p });
persistSession ( data , u );
toast ( 'Accesso riuscito.' , 'success' );
showDashboard ();
} catch ( e ) {
toast ( e . message , 'error' );
}
});
2026-07-21 10:27:05 +02:00
}
2026-07-22 12:00:09 +02:00
function resetToLoggedOutUI () {
2026-07-23 08:53:23 +02:00
sessionEpoch ++ ; // invalidate any refreshRound() still in flight from the dashboard we're leaving
2026-07-21 10:27:05 +02:00
token = username = address = null ;
2026-07-21 16:03:42 +02:00
myUserId = null ;
2026-07-23 10:09:12 +02:00
currentRoundIdSeen = undefined ;
2026-07-21 10:57:39 +02:00
clearInterval ( roundTimerInterval );
2026-07-21 16:03:42 +02:00
clearTimeout ( roundPollTimeout );
2026-07-23 08:53:23 +02:00
clearInterval ( lastResultInterval );
lastResultInterval = null ;
2026-07-22 10:36:36 +02:00
document . getElementById ( 'app-navbar' ). classList . add ( 'hidden' );
2026-07-21 10:27:05 +02:00
document . getElementById ( 'dashboard-section' ). classList . add ( 'hidden' );
document . getElementById ( 'auth-section' ). classList . remove ( 'hidden' );
2026-07-22 10:16:51 +02:00
document . getElementById ( 'landing-hero' ). classList . remove ( 'hidden' );
startChainOnlyPolling ();
2026-07-21 10:27:05 +02:00
}
2026-07-22 12:00:09 +02:00
function logout () {
localStorage . clear ();
resetToLoggedOutUI ();
}
// Fires in every OTHER tab of this origin when one tab clears/changes plm_token
// (e.g. via logout()) — keeps all open tabs in sync instead of leaving stale
// ones showing a dashboard for a session that no longer exists anywhere else.
window . addEventListener ( 'storage' , ( event ) => {
if ( event . key === 'plm_token' && ! event . newValue ) {
resetToLoggedOutUI ();
}
});
// Bfcache restores a frozen snapshot of the DOM/JS state from before the user
// navigated away, without re-running this script — so a stale "logged in" (or
// stale "logged out") view could persist across back/forward navigation. Cache-
// Control: no-store on this response should already prevent that, but re-derive
// the UI from storage here too as a safety net for browsers that ignore it.
window . addEventListener ( 'pageshow' , ( event ) => {
if ( event . persisted ) initAuthState ();
});
function initAuthState () {
token = localStorage . getItem ( 'plm_token' );
username = localStorage . getItem ( 'plm_username' );
address = localStorage . getItem ( 'plm_address' );
if ( token ) {
showDashboard ();
} else {
resetToLoggedOutUI ();
}
}
2026-07-21 10:46:55 +02:00
async function copyAddress () {
try {
await navigator . clipboard . writeText ( address );
toast ( 'Indirizzo copiato.' , 'success' );
} catch ( e ) {
toast ( 'Impossibile copiare automaticamente.' , 'error' );
}
}
2026-07-21 10:38:26 +02:00
2026-07-21 16:03:42 +02:00
let myUserId = null ;
2026-07-23 10:09:12 +02:00
let myBalanceSats = 0 ; // confirmed, spendable balance — what withdrawals/bets can actually draw from
// Shows the pending-inclusive balance (confirmed + own change still unconfirmed
// in a broadcast bet/withdrawal — see compute_pending_balance in
// app/wallet/balance.py) so the number doesn't drop by more than the amount
// actually spent while a tx is in flight. Green once settled, amber while
// has_pending is true so it's clear the figure isn't final yet.
function setBalanceDisplay ( elementId , pendingBalanceSats , hasPending ) {
const el = document . getElementById ( elementId );
el . textContent = pendingBalanceSats / SATS_PER_PLM ;
el . classList . toggle ( 'balance-pending' , hasPending );
el . classList . toggle ( 'balance-confirmed' , ! hasPending );
}
2026-07-21 16:03:42 +02:00
2026-07-21 10:27:05 +02:00
async function refreshMe () {
2026-07-21 10:46:55 +02:00
const btn = document . getElementById ( 'refresh-btn' );
await withLoading ( btn , '…' , async () => {
try {
const data = await call ( 'GET' , '/users/me' );
2026-07-21 16:03:42 +02:00
myUserId = data . id ;
2026-07-22 14:59:02 +02:00
myBalanceSats = data . balance_sats ;
2026-07-23 10:09:12 +02:00
setBalanceDisplay ( 'dash-balance' , data . pending_balance_sats , data . has_pending );
document . getElementById ( 'navbar-balance' ). textContent = ( data . pending_balance_sats / SATS_PER_PLM ) + ' PLM' ;
document . getElementById ( 'navbar-balance' ). classList . toggle ( 'balance-pending' , data . has_pending );
document . getElementById ( 'navbar-balance' ). classList . toggle ( 'balance-confirmed' , ! data . has_pending );
2026-07-22 12:00:09 +02:00
document . getElementById ( 'profile-username' ). textContent = data . username ;
document . getElementById ( 'profile-address' ). textContent = data . address ;
2026-07-23 10:09:12 +02:00
setBalanceDisplay ( 'profile-balance' , data . pending_balance_sats , data . has_pending );
2026-07-22 12:00:09 +02:00
document . getElementById ( 'profile-created-at' ). textContent = new Date ( data . created_at ). toLocaleDateString ( 'it-IT' );
2026-07-22 14:59:02 +02:00
document . getElementById ( 'wd-full-amount-value' ). textContent = data . balance_sats / SATS_PER_PLM ;
if ( document . getElementById ( 'wd-full-amount' ). checked ) {
document . getElementById ( 'wd-amount' ). value = data . balance_sats / SATS_PER_PLM ;
}
2026-07-22 12:00:09 +02:00
} catch ( e ) {
toast ( e . message , 'error' );
}
});
}
2026-07-22 14:59:02 +02:00
function toggleWithdrawFullAmount () {
const checked = document . getElementById ( 'wd-full-amount' ). checked ;
const amountInput = document . getElementById ( 'wd-amount' );
amountInput . disabled = checked ;
if ( checked ) amountInput . value = myBalanceSats / SATS_PER_PLM ;
}
2026-07-22 12:00:09 +02:00
async function changePassword () {
const btn = document . getElementById ( 'change-password-btn' );
const currentPassword = document . getElementById ( 'settings-current-password' ). value ;
const newPassword = document . getElementById ( 'settings-new-password' ). value ;
const newPasswordConfirm = document . getElementById ( 'settings-new-password-confirm' ). value ;
if ( newPassword !== newPasswordConfirm ) {
toast ( 'Le nuove password non coincidono.' , 'error' );
return ;
}
if ( newPassword . length < 8 ) {
toast ( 'La nuova password deve avere almeno 8 caratteri.' , 'error' );
return ;
}
await withLoading ( btn , 'Aggiornamento…' , async () => {
try {
await call ( 'POST' , '/users/me/change-password' , {
current_password : currentPassword ,
new_password : newPassword ,
});
document . getElementById ( 'settings-current-password' ). value = '' ;
document . getElementById ( 'settings-new-password' ). value = '' ;
document . getElementById ( 'settings-new-password-confirm' ). value = '' ;
toast ( 'Password aggiornata.' , 'success' );
2026-07-21 10:46:55 +02:00
} catch ( e ) {
toast ( e . message , 'error' );
}
});
2026-07-21 10:27:05 +02:00
}
async function placeBet () {
2026-07-21 10:46:55 +02:00
const btn = document . getElementById ( 'bet-btn' );
await withLoading ( btn , 'Invio bet…' , async () => {
try {
const data = await call ( 'POST' , '/bets' , {});
toast ( 'Bet piazzata sul round #' + data . round_id + '.' , 'success' );
} catch ( e ) {
toast ( e . message , 'error' );
}
});
2026-07-21 10:27:05 +02:00
refreshMe ();
2026-07-21 10:57:39 +02:00
refreshRound ();
2026-07-21 10:27:05 +02:00
}
async function withdraw () {
2026-07-21 10:46:55 +02:00
const btn = document . getElementById ( 'withdraw-btn' );
2026-07-21 10:27:05 +02:00
const ext = document . getElementById ( 'wd-address' ). value ;
2026-07-22 14:59:02 +02:00
const isFullAmount = document . getElementById ( 'wd-full-amount' ). checked ;
const amtSats = isFullAmount
? myBalanceSats
: Math . round ( parseFloat ( document . getElementById ( 'wd-amount' ). value ) * SATS_PER_PLM );
2026-07-21 10:46:55 +02:00
await withLoading ( btn , 'Invio…' , async () => {
try {
await call ( 'POST' , '/withdrawals' , { external_address : ext , amount_sats : amtSats });
toast ( 'Withdrawal inviato.' , 'success' );
2026-07-22 14:59:02 +02:00
document . getElementById ( 'wd-full-amount' ). checked = false ;
toggleWithdrawFullAmount ();
document . getElementById ( 'wd-amount' ). value = '' ;
2026-07-21 10:46:55 +02:00
} catch ( e ) {
toast ( e . message , 'error' );
}
});
2026-07-21 10:27:05 +02:00
refreshMe ();
}
2026-07-23 10:52:50 +02:00
// Server push: an SSE channel that notifies the instant round/bet/balance
// state changes anywhere (see app/rounds/events.py), instead of everyone
// waiting for their next poll tick. The message carries no payload — it just
// means "something changed", so we react by immediately re-running the same
// refreshes the polling loop would eventually do on its own. Polling is left
// completely in place as a fallback: if this connection is blocked/dropped
// (proxy, browser setting, flaky network), the page keeps working exactly as
// before, just without the instant nudge.
let roundEventSource = null ;
function onRoundServerEvent () {
if ( token ) {
refreshRound ();
refreshMe ();
checkLastRoundResult ();
} else {
refreshChainStatusOnly ();
}
}
function connectRoundEvents () {
if ( roundEventSource ) return ;
roundEventSource = new EventSource ( '/rounds/stream' );
roundEventSource . addEventListener ( 'update' , onRoundServerEvent );
// Fires on the initial connection AND every successful auto-reconnect (the
// browser retries this on its own after a drop) — re-syncs immediately
// instead of leaving the page on whatever it last knew until the next event
// or poll tick, which would otherwise widen the "missed while disconnected"
// window to the full reconnect gap.
roundEventSource . addEventListener ( 'open' , onRoundServerEvent );
}
connectRoundEvents ();
2026-07-22 12:00:09 +02:00
initAuthState ();
2026-07-21 10:27:05 +02:00
</ script >
</ body >
</ html >