Files
plm-lottery/app/static/index.html
T
davide 162a63d04a Add a maintenance pause/resume switch and a proper user navbar
RoundConfig gets a paused flag toggled via new POST /admin/pause and
/admin/resume endpoints (audit-logged, surfaced as a "Manutenzione" card in
the admin Parametri view). Pausing only stops the *next* round from opening
once the current one closes — rounds/service.py:open_new_round_if_needed
still lets an in-progress round finish, draw, and pay out its winner
normally. GET /rounds/current exposes lottery_paused so the user page shows
a maintenance banner (even while logged out) instead of silently going idle.

Also replaces the user dashboard's stacked account-bar card + bento-grid
menu with a single sticky navbar (identity row + Deposito/Bet/Prelievo
tabs), and moves the page content into a dedicated .app-shell container so
the navbar itself can span full width.
2026-07-22 10:36:36 +02:00

815 lines
33 KiB
HTML

<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PLM Lottery</title>
<style>
@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: 12px;
}
* { 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;
}
.app-shell { max-width: 480px; margin: 0 auto; padding: 24px 20px 80px; }
.mono { font-family: 'Fira Code', monospace; }
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
}
.card h2 { font-size: 1rem; font-weight: 600; margin: 0 0 4px; }
.card .hint { color: var(--color-muted-foreground); font-size: 0.85rem; margin: 0 0 14px; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--color-border); }
.tab {
flex: 1; text-align: center; padding: 10px 0; font-weight: 600; font-size: 0.9rem;
color: var(--color-muted-foreground); cursor: pointer; border-bottom: 2px solid transparent;
margin-bottom: -1px; transition: color 150ms, border-color 150ms;
}
.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.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 12px; margin-bottom: 6px; }
label:first-child { margin-top: 0; }
input {
width: 100%; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-surface);
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
}
input:focus {
outline: none; border-color: var(--color-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
}
button {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
min-height: 44px; padding: 0 18px; margin-top: 16px; width: 100%;
font-family: inherit; font-size: 0.95rem; font-weight: 600;
background: var(--color-primary); color: var(--color-on-primary);
border: none; border-radius: 8px; cursor: pointer;
transition: filter 150ms, transform 150ms;
}
button:hover { filter: brightness(0.94); }
button:active { transform: scale(0.98); }
button:disabled { opacity: 0.6; cursor: default; }
button:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
button.secondary {
width: auto; margin-top: 0; padding: 0 12px; min-height: 36px;
background: var(--color-background); color: var(--color-foreground);
border: 1px 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.85rem; text-decoration: underline;
}
button.link:hover { filter: none; color: var(--color-foreground); }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
/* --- 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(6px); -webkit-backdrop-filter: blur(6px);
border-bottom: 1px solid var(--color-border); margin-bottom: 20px;
}
.app-navbar-top {
max-width: 480px; margin: 0 auto; padding: 12px 20px 8px;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.app-navbar-top .brand { font-weight: 700; font-size: 1rem; letter-spacing: -0.01em; }
.app-navbar-account { display: flex; align-items: center; gap: 10px; }
.app-navbar-account .name { font-weight: 600; font-size: 0.85rem; }
.app-navbar-tabs { max-width: 480px; margin: 0 auto; padding: 0 12px; display: flex; }
.app-navbar-tabs button.navbar-tab {
flex: 1; width: auto; min-height: auto; margin-top: 0; padding: 8px 4px 10px;
display: flex; flex-direction: column; align-items: center; gap: 3px;
font-size: 0.72rem; font-weight: 600; font-family: inherit; cursor: pointer;
background: none; color: var(--color-muted-foreground);
border: none; border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color 150ms, border-color 150ms;
}
.app-navbar-tabs button.navbar-tab .icon { width: 18px; height: 18px; }
.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); }
.address-box {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
background: var(--color-background); border: 1px solid var(--color-border);
border-radius: 8px; padding: 10px 12px; font-size: 0.85rem; word-break: break-all;
}
.balance-value { font-size: 2rem; font-weight: 700; }
.balance-unit { color: var(--color-muted-foreground); font-size: 1rem; font-weight: 500; }
.icon { width: 16px; height: 16px; flex-shrink: 0; }
.dash-panel { display: none; }
.dash-panel.active { display: block; }
.qr-box { display: flex; justify-content: center; padding: 16px; background: #fff; border: 1px solid var(--color-border); border-radius: 10px; margin-top: 14px; }
.qr-box img { width: 200px; height: 200px; image-rendering: pixelated; }
.hidden { display: none !important; }
.draw-state { display: none; text-align: center; padding: 8px 0 4px; }
.draw-state.active { display: block; }
.draw-spinner {
width: 40px; height: 40px; margin: 0 auto 10px;
border: 3px solid var(--color-border); border-top-color: var(--color-primary);
border-radius: 50%; animation: spin 900ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.draw-state .draw-label { font-weight: 600; font-size: 0.95rem; }
.draw-result { font-size: 1.05rem; font-weight: 700; padding: 6px 0; }
.draw-result.win { color: var(--color-success); }
.draw-result.lose { color: var(--color-muted-foreground); }
#toast-container {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px; z-index: 100; width: calc(100% - 40px); max-width: 440px;
}
.toast {
display: flex; align-items: flex-start; gap: 8px;
padding: 12px 14px; border-radius: 8px; font-size: 0.85rem; font-weight: 500;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
animation: toast-in 200ms 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(8px); } to { opacity: 1; transform: translateY(0); } }
/* --- 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(600px circle at 20% -10%, color-mix(in srgb, var(--color-primary) 16%, transparent), transparent 60%),
radial-gradient(500px circle at 90% 10%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 60%);
}
.hero { text-align: center; padding: 8px 0 28px; }
.hero .eyebrow {
display: inline-flex; align-items: center; gap: 6px;
font-size: 0.75rem; font-weight: 600; letter-spacing: 0.02em;
color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
padding: 4px 10px; border-radius: 999px; margin-bottom: 14px;
}
.hero h1 {
font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 8px;
background: linear-gradient(135deg, 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.95rem; margin: 0 auto; max-width: 360px; }
.hero-steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 22px 0; }
.hero-step {
background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius);
padding: 14px 8px; transition: transform 150ms, border-color 150ms;
}
.hero-step:hover { transform: translateY(-2px); border-color: var(--color-ring); }
.hero-step .step-icon {
width: 32px; height: 32px; margin: 0 auto 8px; border-radius: 999px;
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: 16px; height: 16px; }
.hero-step .step-title { font-size: 0.8rem; font-weight: 600; margin-bottom: 2px; }
.hero-step .step-hint { font-size: 0.72rem; color: var(--color-muted-foreground); line-height: 1.35; }
.trust-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 24px; }
.trust-pill {
font-size: 0.72rem; font-weight: 500; color: var(--color-muted-foreground);
background: var(--color-surface); border: 1px solid var(--color-border);
padding: 5px 10px; border-radius: 999px; display: inline-flex; align-items: center; gap: 5px;
}
.trust-pill .icon { width: 13px; height: 13px; 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 1px color-mix(in srgb, var(--color-primary) 25%, transparent),
0 0 24px color-mix(in srgb, var(--color-primary) 22%, transparent);
animation: glow-pulse 2200ms ease-in-out infinite;
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent), 0 0 16px color-mix(in srgb, var(--color-primary) 16%, transparent); }
50% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 45%, transparent), 0 0 28px color-mix(in srgb, var(--color-primary) 30%, transparent); }
}
.jackpot-bump { animation: jackpot-bump 420ms 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: 10px;
font-size: 0.78rem; padding: 10px 2px 16px; margin-bottom: 8px;
border-bottom: 1px solid var(--color-border);
}
.chain-status-pill { display: inline-flex; align-items: center; gap: 7px; font-weight: 600; color: var(--color-foreground); }
.status-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
background: var(--color-muted-foreground);
}
.status-dot.status-open {
background: var(--color-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 18%, transparent);
}
.status-dot.status-drawing {
background: var(--color-primary);
animation: status-dot-pulse 1400ms 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 5px transparent; }
}
.chain-block { color: var(--color-muted-foreground); white-space: nowrap; }
.maintenance-banner {
display: flex; align-items: flex-start; gap: 8px;
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
color: var(--color-foreground); border-radius: var(--radius);
padding: 12px 14px; font-size: 0.82rem; line-height: 1.4; margin-bottom: 16px;
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
</style>
</head>
<body>
<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">
<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>
<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>
<section id="landing-hero" class="hero">
<h1>PLM Lottery</h1>
<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>
<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">
<label for="reg-password-confirm">Conferma password</label>
<input id="reg-password-confirm" type="password" autocomplete="new-password">
<button onclick="register()" id="register-btn">Crea account</button>
</div>
</section>
<section id="dashboard-section" class="hidden">
<div class="card" id="round-card">
<div class="row-between" id="round-normal-row">
<h2 id="round-title">Round —</h2>
<span class="mono" id="round-timer" style="font-size:1.1rem;font-weight:700">--:--</span>
</div>
<div class="row-between" style="margin-top:10px" id="round-stats-row">
<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>
<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>
</div>
<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>
</div>
</div>
<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>
</div>
</div>
<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>
</div>
</section>
</div>
<div id="toast-container" aria-live="polite"></div>
<script>
const SATS_PER_PLM = 100000000;
let token = localStorage.getItem('plm_token');
let username = localStorage.getItem('plm_username');
let address = localStorage.getItem('plm_address');
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;
}
}
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;
}
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');
}
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);
}
}
let roundCloseAt = null;
let roundTimerInterval = null;
let roundPollTimeout = null;
const ROUND_STATUS_LABELS = {
open: 'aperto',
closing: 'in chiusura',
drawing: 'estrazione in corso',
paying_out: 'pagamento in corso',
};
const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out'];
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 : '—');
document.getElementById('maintenance-banner').classList.toggle('hidden', !data.lottery_paused);
}
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;
}
// 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
let lastJackpotValue = null;
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;
}
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');
}
async function refreshRound() {
try {
const data = await call('GET', '/rounds/current');
updateChainStatusBar(data);
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;
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;
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
updateRoundTimer();
const isDrawing = data.round_id && DRAWING_STATUSES.includes(data.status);
document.getElementById('round-card').classList.toggle('drawing-glow', !!isDrawing);
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);
}
function showDashboard() {
stopChainOnlyPolling();
document.getElementById('landing-hero').classList.add('hidden');
document.getElementById('auth-section').classList.add('hidden');
document.getElementById('app-navbar').classList.remove('hidden');
document.getElementById('dashboard-section').classList.remove('hidden');
document.getElementById('dash-username').textContent = username;
document.getElementById('dash-address').textContent = address;
document.getElementById('dash-qr').src = '/qr/' + encodeURIComponent(address);
refreshMe();
refreshRound();
clearInterval(roundTimerInterval);
roundTimerInterval = setInterval(updateRoundTimer, 1000);
}
function persistSession(data, u) {
token = data.access_token; username = u; address = data.address;
localStorage.setItem('plm_token', token);
localStorage.setItem('plm_username', username);
localStorage.setItem('plm_address', address);
}
async function register() {
const btn = document.getElementById('register-btn');
const u = document.getElementById('reg-username').value;
const p = document.getElementById('reg-password').value;
const pConfirm = document.getElementById('reg-password-confirm').value;
if (p !== pConfirm) {
toast('Le password non coincidono.', 'error');
return;
}
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');
}
});
}
async function login() {
const btn = document.getElementById('login-btn');
const u = document.getElementById('login-username').value;
const p = document.getElementById('login-password').value;
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');
}
});
}
function logout() {
localStorage.clear();
token = username = address = null;
myUserId = null;
activeResultRoundId = null;
revealedRounds.clear();
for (const key of Object.keys(drawStartedAt)) delete drawStartedAt[key];
clearInterval(roundTimerInterval);
clearTimeout(roundPollTimeout);
document.getElementById('app-navbar').classList.add('hidden');
document.getElementById('dashboard-section').classList.add('hidden');
document.getElementById('auth-section').classList.remove('hidden');
document.getElementById('landing-hero').classList.remove('hidden');
startChainOnlyPolling();
}
async function copyAddress() {
try {
await navigator.clipboard.writeText(address);
toast('Indirizzo copiato.', 'success');
} catch (e) {
toast('Impossibile copiare automaticamente.', 'error');
}
}
let myUserId = null;
async function refreshMe() {
const btn = document.getElementById('refresh-btn');
await withLoading(btn, '…', async () => {
try {
const data = await call('GET', '/users/me');
myUserId = data.id;
document.getElementById('dash-balance').textContent = data.balance_sats / SATS_PER_PLM;
} catch (e) {
toast(e.message, 'error');
}
});
}
async function placeBet() {
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');
}
});
refreshMe();
refreshRound();
}
async function withdraw() {
const btn = document.getElementById('withdraw-btn');
const ext = document.getElementById('wd-address').value;
const amtPlm = parseFloat(document.getElementById('wd-amount').value);
const amtSats = Math.round(amtPlm * SATS_PER_PLM);
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');
}
});
refreshMe();
}
if (token) {
showDashboard();
} else {
startChainOnlyPolling();
}
</script>
</body>
</html>