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-21 10:27:05 +02:00
< style >
2026-07-21 10:46:55 +02:00
@ import url ( 'https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600&family=Fira+Sans:wght@400;500;600;700&display=swap' ) ;
: root {
--color-background : #F8FAFC ;
--color-surface : #FFFFFF ;
--color-foreground : #0F172A ;
--color-muted-foreground : #64748B ;
--color-border : #E2E8F0 ;
--color-primary : #F59E0B ;
--color-on-primary : #0F172A ;
--color-accent : #7C3AED ;
--color-destructive : #DC2626 ;
--color-destructive-bg : #FEF2F2 ;
--color-success : #16A34A ;
--color-success-bg : #F0FDF4 ;
--color-ring : #F59E0B ;
--radius : 12 px ;
}
* { box-sizing : border-box ; }
body {
font-family : 'Fira Sans' , system-ui , sans-serif ;
background : var ( -- color - background );
color : var ( -- color - foreground );
line-height : 1.5 ;
}
2026-07-22 10:36:36 +02:00
. app-shell { max-width : 480 px ; margin : 0 auto ; padding : 24 px 20 px 80 px ; }
2026-07-21 10:46:55 +02:00
2026-07-22 10:36:36 +02:00
. mono { font-family : 'Fira Code' , monospace ; }
2026-07-21 10:46:55 +02:00
. card {
background : var ( -- color - surface );
border : 1 px solid var ( -- color - border );
border-radius : var ( -- radius );
padding : 20 px ;
margin-bottom : 16 px ;
}
. card h2 { font-size : 1 rem ; font-weight : 600 ; margin : 0 0 4 px ; }
. card . hint { color : var ( -- color - muted - foreground ); font-size : 0.85 rem ; margin : 0 0 14 px ; }
. tabs { display : flex ; gap : 4 px ; margin-bottom : 16 px ; border-bottom : 1 px solid var ( -- color - border ); }
. tab {
flex : 1 ; text-align : center ; padding : 10 px 0 ; font-weight : 600 ; font-size : 0.9 rem ;
color : var ( -- color - muted - foreground ); cursor : pointer ; border-bottom : 2 px solid transparent ;
margin-bottom : -1 px ; transition : color 150 ms , border-color 150 ms ;
}
. tab . active { color : var ( -- color - foreground ); border-bottom-color : var ( -- color - primary ); }
. tab-panel { display : none ; }
. tab-panel . active { display : block ; }
label { display : block ; font-size : 0.85 rem ; font-weight : 500 ; color : var ( -- color - muted - foreground ); margin-top : 12 px ; margin-bottom : 6 px ; }
label : first-child { margin-top : 0 ; }
input {
width : 100 % ; padding : 10 px 12 px ; font-size : 0.95 rem ; font-family : inherit ;
border : 1 px solid var ( -- color - border ); border-radius : 8 px ; background : var ( -- color - surface );
color : var ( -- color - foreground ); transition : border-color 150 ms , box-shadow 150 ms ;
}
input : focus {
outline : none ; border-color : var ( -- color - ring );
box-shadow : 0 0 0 3 px color-mix ( in srgb , var ( -- color - ring ) 25 % , transparent );
}
button {
display : inline-flex ; align-items : center ; justify-content : center ; gap : 6 px ;
min-height : 44 px ; padding : 0 18 px ; margin-top : 16 px ; width : 100 % ;
font-family : inherit ; font-size : 0.95 rem ; font-weight : 600 ;
background : var ( -- color - primary ); color : var ( -- color - on - primary );
border : none ; border-radius : 8 px ; cursor : pointer ;
transition : filter 150 ms , transform 150 ms ;
}
button : hover { filter : brightness ( 0.94 ); }
button : active { transform : scale ( 0.98 ); }
button : disabled { opacity : 0.6 ; cursor : default ; }
button : focus-visible { outline : 2 px solid var ( -- color - ring ); outline-offset : 2 px ; }
button . secondary {
width : auto ; margin-top : 0 ; padding : 0 12 px ; min-height : 36 px ;
background : var ( -- color - background ); color : var ( -- color - foreground );
border : 1 px solid var ( -- color - border );
}
button . link {
width : auto ; margin-top : 0 ; padding : 0 ; min-height : auto ;
background : none ; color : var ( -- color - muted - foreground ); font-weight : 500 ;
font-size : 0.85 rem ; text-decoration : underline ;
}
button . link : hover { filter : none ; color : var ( -- color - foreground ); }
. row-between { display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; }
2026-07-22 10:36:36 +02:00
/* --- sticky app navbar: identity row + section tabs, shown only when logged in --- */
. app-navbar {
position : sticky ; top : 0 ; z-index : 20 ;
background : color-mix ( in srgb , var ( -- color - surface ) 92 % , transparent );
backdrop-filter : blur ( 6 px ); -webkit- backdrop-filter : blur ( 6 px );
border-bottom : 1 px solid var ( -- color - border ); margin-bottom : 20 px ;
}
. app-navbar-top {
max-width : 480 px ; margin : 0 auto ; padding : 12 px 20 px 8 px ;
display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ;
}
. app-navbar-top . brand { font-weight : 700 ; font-size : 1 rem ; letter-spacing : -0.01 em ; }
. app-navbar-account { display : flex ; align-items : center ; gap : 10 px ; }
. app-navbar-account . name { font-weight : 600 ; font-size : 0.85 rem ; }
. app-navbar-tabs { max-width : 480 px ; margin : 0 auto ; padding : 0 12 px ; display : flex ; }
. app-navbar-tabs button . navbar-tab {
flex : 1 ; width : auto ; min-height : auto ; margin-top : 0 ; padding : 8 px 4 px 10 px ;
display : flex ; flex-direction : column ; align-items : center ; gap : 3 px ;
font-size : 0.72 rem ; font-weight : 600 ; font-family : inherit ; cursor : pointer ;
background : none ; color : var ( -- color - muted - foreground );
border : none ; border-bottom : 2 px solid transparent ; margin-bottom : -1 px ;
transition : color 150 ms , border-color 150 ms ;
}
. app-navbar-tabs button . navbar-tab . icon { width : 18 px ; height : 18 px ; }
. app-navbar-tabs button . navbar-tab . active { color : var ( -- color - primary ); border-bottom-color : var ( -- color - primary ); }
. app-navbar-tabs button . navbar-tab : hover { filter : none ; color : var ( -- color - foreground ); }
. app-navbar-tabs button . navbar-tab . active : hover { color : var ( -- color - primary ); }
2026-07-21 10:46:55 +02:00
. address-box {
display : flex ; align-items : center ; justify-content : space-between ; gap : 8 px ;
background : var ( -- color - background ); border : 1 px solid var ( -- color - border );
border-radius : 8 px ; padding : 10 px 12 px ; font-size : 0.85 rem ; word-break : break-all ;
}
. balance-value { font-size : 2 rem ; font-weight : 700 ; }
. balance-unit { color : var ( -- color - muted - foreground ); font-size : 1 rem ; font-weight : 500 ; }
. icon { width : 16 px ; height : 16 px ; flex-shrink : 0 ; }
2026-07-21 10:52:06 +02:00
. dash-panel { display : none ; }
. dash-panel . active { display : block ; }
. qr-box { display : flex ; justify-content : center ; padding : 16 px ; background : #fff ; border : 1 px solid var ( -- color - border ); border-radius : 10 px ; margin-top : 14 px ; }
. qr-box img { width : 200 px ; height : 200 px ; image-rendering : pixelated ; }
2026-07-21 10:46:55 +02:00
. hidden { display : none !important ; }
2026-07-21 16:03:42 +02:00
. draw-state { display : none ; text-align : center ; padding : 8 px 0 4 px ; }
. draw-state . active { display : block ; }
. draw-spinner {
width : 40 px ; height : 40 px ; margin : 0 auto 10 px ;
border : 3 px solid var ( -- color - border ); border-top-color : var ( -- color - primary );
border-radius : 50 % ; animation : spin 900 ms linear infinite ;
}
@ keyframes spin { to { transform : rotate ( 360 deg ); } }
. draw-state . draw-label { font-weight : 600 ; font-size : 0.95 rem ; }
. draw-result { font-size : 1.05 rem ; font-weight : 700 ; padding : 6 px 0 ; }
. draw-result . win { color : var ( -- color - success ); }
. draw-result . lose { color : var ( -- color - muted - foreground ); }
2026-07-21 10:46:55 +02:00
# toast-container {
position : fixed ; bottom : 20 px ; left : 50 % ; transform : translateX ( -50 % );
display : flex ; flex-direction : column ; gap : 8 px ; z-index : 100 ; width : calc ( 100 % - 40 px ); max-width : 440 px ;
}
. toast {
display : flex ; align-items : flex-start ; gap : 8 px ;
padding : 12 px 14 px ; border-radius : 8 px ; font-size : 0.85 rem ; font-weight : 500 ;
box-shadow : 0 4 px 12 px rgba ( 15 , 23 , 42 , 0.12 );
animation : toast-in 200 ms ease-out ;
}
. toast . success { background : var ( -- color - success - bg ); color : var ( -- color - success ); }
. toast . error { background : var ( -- color - destructive - bg ); color : var ( -- color - destructive ); }
@ keyframes toast-in { from { opacity : 0 ; transform : translateY ( 8 px ); } to { opacity : 1 ; transform : translateY ( 0 ); } }
2026-07-22 10:16:51 +02:00
/* --- landing hero (shown only when logged out) --- */
body {
position : relative ;
isolation : isolate ;
}
body :: before {
content : '' ;
position : fixed ;
inset : 0 ;
z-index : -1 ;
background :
radial-gradient ( 600 px circle at 20 % -10 % , color-mix ( in srgb , var ( -- color - primary ) 16 % , transparent ), transparent 60 % ),
radial-gradient ( 500 px circle at 90 % 10 % , color-mix ( in srgb , var ( -- color - accent ) 12 % , transparent ), transparent 60 % );
}
. hero { text-align : center ; padding : 8 px 0 28 px ; }
. hero . eyebrow {
display : inline-flex ; align-items : center ; gap : 6 px ;
font-size : 0.75 rem ; font-weight : 600 ; letter-spacing : 0.02 em ;
color : var ( -- color - primary ); background : color-mix ( in srgb , var ( -- color - primary ) 12 % , transparent );
border : 1 px solid color-mix ( in srgb , var ( -- color - primary ) 30 % , transparent );
padding : 4 px 10 px ; border-radius : 999 px ; margin-bottom : 14 px ;
}
. hero h1 {
font-size : 1.75 rem ; font-weight : 700 ; letter-spacing : -0.02 em ; margin : 0 0 8 px ;
background : linear-gradient ( 135 deg , var ( -- color - foreground ), var ( -- color - accent ) 120 % );
-webkit- background-clip : text ; background-clip : text ; color : transparent ;
}
. hero p . lead { color : var ( -- color - muted - foreground ); font-size : 0.95 rem ; margin : 0 auto ; max-width : 360 px ; }
. hero-steps { display : grid ; grid-template-columns : repeat ( 3 , 1 fr ); gap : 8 px ; margin : 22 px 0 ; }
. hero-step {
background : var ( -- color - surface ); border : 1 px solid var ( -- color - border ); border-radius : var ( -- radius );
padding : 14 px 8 px ; transition : transform 150 ms , border-color 150 ms ;
}
. hero-step : hover { transform : translateY ( -2 px ); border-color : var ( -- color - ring ); }
. hero-step . step-icon {
width : 32 px ; height : 32 px ; margin : 0 auto 8 px ; border-radius : 999 px ;
background : color-mix ( in srgb , var ( -- color - primary ) 14 % , transparent ); color : var ( -- color - primary );
display : flex ; align-items : center ; justify-content : center ;
}
. hero-step . step-icon . icon { width : 16 px ; height : 16 px ; }
. hero-step . step-title { font-size : 0.8 rem ; font-weight : 600 ; margin-bottom : 2 px ; }
. hero-step . step-hint { font-size : 0.72 rem ; color : var ( -- color - muted - foreground ); line-height : 1.35 ; }
. trust-row { display : flex ; flex-wrap : wrap ; justify-content : center ; gap : 8 px ; margin-bottom : 24 px ; }
. trust-pill {
font-size : 0.72 rem ; font-weight : 500 ; color : var ( -- color - muted - foreground );
background : var ( -- color - surface ); border : 1 px solid var ( -- color - border );
padding : 5 px 10 px ; border-radius : 999 px ; display : inline-flex ; align-items : center ; gap : 5 px ;
}
. trust-pill . icon { width : 13 px ; height : 13 px ; color : var ( -- color - success ); flex-shrink : 0 ; }
/* --- glowing card while a round is drawing --- */
. card . drawing-glow {
border-color : color-mix ( in srgb , var ( -- color - primary ) 55 % , var ( -- color - border ));
box-shadow : 0 0 0 1 px color-mix ( in srgb , var ( -- color - primary ) 25 % , transparent ),
0 0 24 px color-mix ( in srgb , var ( -- color - primary ) 22 % , transparent );
animation : glow-pulse 2200 ms ease-in-out infinite ;
}
@ keyframes glow-pulse {
0 %, 100 % { box-shadow : 0 0 0 1 px color-mix ( in srgb , var ( -- color - primary ) 25 % , transparent ), 0 0 16 px color-mix ( in srgb , var ( -- color - primary ) 16 % , transparent ); }
50 % { box-shadow : 0 0 0 1 px color-mix ( in srgb , var ( -- color - primary ) 45 % , transparent ), 0 0 28 px color-mix ( in srgb , var ( -- color - primary ) 30 % , transparent ); }
}
. jackpot-bump { animation : jackpot-bump 420 ms ease-out ; }
@ keyframes jackpot-bump {
0 % { transform : scale ( 1 ); }
30 % { transform : scale ( 1.12 ); color : var ( -- color - primary ); }
100 % { transform : scale ( 1 ); }
}
/* --- network / lottery status strip, shown on every screen --- */
. chain-bar {
display : flex ; align-items : center ; justify-content : space-between ; gap : 10 px ;
font-size : 0.78 rem ; padding : 10 px 2 px 16 px ; margin-bottom : 8 px ;
border-bottom : 1 px solid var ( -- color - border );
}
. chain-status-pill { display : inline-flex ; align-items : center ; gap : 7 px ; font-weight : 600 ; color : var ( -- color - foreground ); }
. status-dot {
width : 8 px ; height : 8 px ; border-radius : 50 % ; flex-shrink : 0 ;
background : var ( -- color - muted - foreground );
}
. status-dot . status-open {
background : var ( -- color - success );
box-shadow : 0 0 0 3 px color-mix ( in srgb , var ( -- color - success ) 18 % , transparent );
}
. status-dot . status-drawing {
background : var ( -- color - primary );
animation : status-dot-pulse 1400 ms ease-in-out infinite ;
}
. status-dot . status-waiting { background : var ( -- color - muted - foreground ); }
@ keyframes status-dot-pulse {
0 %, 100 % { box-shadow : 0 0 0 0 color-mix ( in srgb , var ( -- color - primary ) 45 % , transparent ); }
50 % { box-shadow : 0 0 0 5 px transparent ; }
}
. chain-block { color : var ( -- color - muted - foreground ); white-space : nowrap ; }
2026-07-22 10:36:36 +02:00
. maintenance-banner {
display : flex ; align-items : flex-start ; gap : 8 px ;
background : color-mix ( in srgb , var ( -- color - primary ) 12 % , var ( -- color - surface ));
border : 1 px solid color-mix ( in srgb , var ( -- color - primary ) 40 % , transparent );
color : var ( -- color - foreground ); border-radius : var ( -- radius );
padding : 12 px 14 px ; font-size : 0.82 rem ; line-height : 1.4 ; margin-bottom : 16 px ;
}
2026-07-21 10:46:55 +02:00
@ media ( prefers-reduced-motion : reduce ) {
* { animation : none !important ; transition : none !important ; }
}
2026-07-21 10:27:05 +02:00
</ style >
</ head >
< body >
2026-07-22 10:36:36 +02:00
< nav class = "app-navbar hidden" id = "app-navbar" aria-label = "Sezioni" >
< div class = "app-navbar-top" >
< span class = "brand" > PLM Lottery</ span >
< div class = "app-navbar-account" >
< span class = "name" id = "dash-username" ></ span >
< button class = "link" onclick = "logout()" > Esci</ button >
</ 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 >
</ 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 10:16:51 +02:00
< div class = "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 >
< div class = "draw-label" > Estrazione del vincitore in corso…</ div >
</ 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..." >
< label for = "wd-amount" > Importo (PLM)</ label >
< input id = "wd-amount" inputmode = "decimal" placeholder = "es. 2" >
< 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
</ 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
}
async function call ( method , path , body ) {
const headers = { 'Content-Type' : 'application/json' };
if ( token ) headers [ 'Authorization' ] = 'Bearer ' + token ;
const res = await fetch ( path , { method , headers , body : body ? JSON . stringify ( body ) : undefined });
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 ) {
for ( const key of [ 'deposit' , 'bet' , 'withdraw' ]) {
document . getElementById ( 'nav-' + key ). classList . toggle ( 'active' , key === name );
document . getElementById ( 'panel-' + key ). classList . toggle ( 'active' , key === name );
}
}
2026-07-21 10:57:39 +02:00
let roundCloseAt = null ;
let roundTimerInterval = null ;
2026-07-21 16:03:42 +02:00
let roundPollTimeout = null ;
2026-07-21 10:57:39 +02:00
const ROUND_STATUS_LABELS = {
open : 'aperto' ,
closing : 'in chiusura' ,
drawing : 'estrazione in corso' ,
paying_out : 'pagamento in corso' ,
};
2026-07-21 16:03:42 +02:00
const DRAWING_STATUSES = [ 'closing' , 'drawing' , 'paying_out' ];
2026-07-22 10:16:51 +02:00
const CHAIN_STATUS_LABELS = {
waiting : 'In attesa del prossimo round' ,
open : 'Round aperto' ,
drawing : 'Estrazione in corso' ,
};
function updateChainStatusBar ( data ) {
const dot = document . getElementById ( 'chain-status-dot' );
const label = document . getElementById ( 'chain-status-label' );
const block = document . getElementById ( 'chain-block' );
let statusKey ;
if ( ! data . round_id ) statusKey = 'waiting' ;
else if ( DRAWING_STATUSES . includes ( data . status )) statusKey = 'drawing' ;
else statusKey = 'open' ;
dot . className = 'status-dot status-' + statusKey ;
label . textContent = CHAIN_STATUS_LABELS [ statusKey ];
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
}
let chainOnlyInterval = null ;
async function refreshChainStatusOnly () {
try {
const data = await call ( 'GET' , '/rounds/current' );
updateChainStatusBar ( data );
} catch ( e ) {
// leave the last-known status on screen rather than blanking it out
}
}
function startChainOnlyPolling () {
refreshChainStatusOnly ();
clearInterval ( chainOnlyInterval );
chainOnlyInterval = setInterval ( refreshChainStatusOnly , 15000 );
}
function stopChainOnlyPolling () {
clearInterval ( chainOnlyInterval );
chainOnlyInterval = null ;
}
2026-07-21 16:03:42 +02:00
// Per round_id: when we first saw it enter a drawing status (client clock), and
// whether the win/lose result has already been shown for it. Local-only state,
// not persisted — a page reload just re-derives it from the next poll.
const drawStartedAt = {};
const revealedRounds = new Set ();
let activeResultRoundId = null ; // round_id whose win/lose result is on screen, if any
2026-07-22 10:16:51 +02:00
let lastJackpotValue = null ;
2026-07-21 16:03:42 +02:00
2026-07-21 10:57:39 +02:00
function updateRoundTimer () {
const el = document . getElementById ( 'round-timer' );
if ( ! roundCloseAt ) { el . textContent = '--:--' ; return ; }
const totalSec = Math . max ( 0 , Math . floor (( roundCloseAt - new Date ()) / 1000 ));
const mm = String ( Math . floor ( totalSec / 60 )). padStart ( 2 , '0' );
const ss = String ( totalSec % 60 ). padStart ( 2 , '0' );
el . textContent = mm + ':' + ss ;
}
2026-07-21 16:03:42 +02:00
function showDrawingState () {
document . getElementById ( 'round-normal-row' ). classList . add ( 'hidden' );
document . getElementById ( 'round-stats-row' ). classList . add ( 'hidden' );
document . getElementById ( 'draw-state' ). classList . add ( 'active' );
document . getElementById ( 'draw-result' ). classList . add ( 'hidden' );
}
function showResultState ( html , cls ) {
document . getElementById ( 'round-normal-row' ). classList . add ( 'hidden' );
document . getElementById ( 'round-stats-row' ). classList . add ( 'hidden' );
document . getElementById ( 'draw-state' ). classList . remove ( 'active' );
const el = document . getElementById ( 'draw-result' );
el . className = 'draw-result ' + cls ;
el . innerHTML = html ;
el . classList . remove ( 'hidden' );
}
function showNormalState () {
document . getElementById ( 'round-normal-row' ). classList . remove ( 'hidden' );
document . getElementById ( 'round-stats-row' ). classList . remove ( 'hidden' );
document . getElementById ( 'draw-state' ). classList . remove ( 'active' );
document . getElementById ( 'draw-result' ). classList . add ( 'hidden' );
}
2026-07-21 10:57:39 +02:00
async function refreshRound () {
try {
const data = await call ( 'GET' , '/rounds/current' );
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-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
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-21 16:03:42 +02:00
if ( isDrawing ) {
if ( ! ( data . round_id in drawStartedAt )) drawStartedAt [ data . round_id ] = Date . now ();
const elapsedMs = Date . now () - drawStartedAt [ data . round_id ];
const minMs = data . draw_animation_seconds * 1000 ;
if ( data . winner_user_id != null && elapsedMs >= minMs && ! revealedRounds . has ( data . round_id )) {
revealedRounds . add ( data . round_id );
activeResultRoundId = data . round_id ;
if ( myUserId != null && data . winner_user_id === myUserId ) {
const won = ( data . winner_amount_sats / SATS_PER_PLM );
showResultState ( '🎉 Hai vinto! +' + won + ' PLM' , 'win' );
toast ( 'Hai vinto il round #' + data . round_id + '! +' + won + ' PLM' , 'success' );
} else {
showResultState ( 'Non hai vinto questa volta.' , 'lose' );
}
} else if ( ! revealedRounds . has ( data . round_id )) {
showDrawingState ();
}
} else if ( data . round_id && data . round_id !== activeResultRoundId ) {
// a genuinely new round is open — clear any previous result and go back to normal
activeResultRoundId = null ;
showNormalState ();
} else if ( ! data . round_id && activeResultRoundId == null ) {
// nothing has ever been revealed and there's no active round — plain empty state
showNormalState ();
}
// else: no active round right now, but we just revealed a result for the last
// one — keep it on screen through the cooldown gap instead of flashing back to
// "Nessun round attivo".
scheduleNextRoundPoll ( isDrawing );
} catch ( e ) {
scheduleNextRoundPoll ( false );
}
}
function scheduleNextRoundPoll ( fast ) {
clearTimeout ( roundPollTimeout );
roundPollTimeout = setTimeout ( refreshRound , fast ? 3000 : 15000 );
2026-07-21 10:57:39 +02:00
}
2026-07-21 10:27:05 +02:00
function showDashboard () {
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-21 10:27:05 +02:00
refreshMe ();
2026-07-21 10:57:39 +02:00
refreshRound ();
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
}
function logout () {
localStorage . clear ();
token = username = address = null ;
2026-07-21 16:03:42 +02:00
myUserId = null ;
activeResultRoundId = null ;
revealedRounds . clear ();
for ( const key of Object . keys ( drawStartedAt )) delete drawStartedAt [ key ];
2026-07-21 10:57:39 +02:00
clearInterval ( roundTimerInterval );
2026-07-21 16:03:42 +02:00
clearTimeout ( roundPollTimeout );
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-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-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-21 10:46:55 +02:00
document . getElementById ( 'dash-balance' ). textContent = data . balance_sats / SATS_PER_PLM ;
} 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-21 10:38:26 +02:00
const amtPlm = parseFloat ( document . getElementById ( 'wd-amount' ). value );
const amtSats = Math . round ( amtPlm * 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' );
} catch ( e ) {
toast ( e . message , 'error' );
}
});
2026-07-21 10:27:05 +02:00
refreshMe ();
}
2026-07-22 10:16:51 +02:00
if ( token ) {
showDashboard ();
} else {
startChainOnlyPolling ();
}
2026-07-21 10:27:05 +02:00
</ script >
</ body >
</ html >