Files
plm-lottery/app/static/index.html
T
davideandClaude Sonnet 5 1acb6c3b16 Redesign the user dashboard with a modern fintech-style layout
Restructures app/static/index.html's visual design without touching any JS
logic or element IDs:

- Typography switched to IBM Plex Sans (financial/trustworthy pairing),
  Fira Code kept for addresses and numeric mono values.
- Design tokens extended with elevation shadows, an inset-surface color,
  and a scaled border-radius system; cards/buttons get subtle depth instead
  of flat borders.
- Navigation goes adaptive: a native-app-style fixed bottom tab bar on
  mobile (thumb-reachable), promoted back to an inline tab strip once the
  viewport is wide enough (>=720px) to fit one comfortably under the header.
- Top bar decluttered: balance shown as a highlighted pill (icon + value),
  Guida/Segnala un bug/Esci condensed into icon buttons with tooltips and
  aria-labels instead of full-text links.
- Round-status card gets a subtle gradient "hero" treatment to read as the
  central live widget of the dashboard.
- Content width now adapts from 480px (mobile) to 620px (>=720px) instead
  of staying phone-narrow on desktop; safe-area insets reserved for the
  fixed bottom bar and toast stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:50:17 +02:00

1086 lines
47 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=IBM+Plex+Sans:wght@400;500;600;700&display=swap');
:root {
--color-background: #F8FAFC;
--color-surface: #FFFFFF;
--color-surface-inset: #F1F5F9;
--color-foreground: #0F172A;
--color-muted-foreground: #64748B;
--color-border: #E2E8F0;
--color-primary: #F59E0B;
--color-on-primary: #0F172A;
--color-secondary: #FBBF24;
--color-accent: #7C3AED;
--color-destructive: #DC2626;
--color-destructive-bg: #FEF2F2;
--color-success: #16A34A;
--color-success-bg: #F0FDF4;
--color-ring: #F59E0B;
--radius-sm: 8px;
--radius: 14px;
--radius-lg: 20px;
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow-md: 0 8px 24px -8px rgba(15, 23, 42, 0.14);
--content-width: 480px;
--nav-bottom-height: 68px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
font-family: 'IBM Plex Sans', system-ui, sans-serif;
background: var(--color-background);
color: var(--color-foreground);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.app-shell { max-width: var(--content-width); margin: 0 auto; padding: 20px 20px 32px; }
.mono { font-family: 'Fira Code', ui-monospace, monospace; }
h1, h2, h3 { font-family: inherit; letter-spacing: -0.01em; }
.section-label {
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--color-muted-foreground); margin: 0 2px 10px;
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
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; line-height: 1.45; }
.card .hint:last-child { margin-bottom: 0; }
.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: 14px; margin-bottom: 6px; }
label:first-child { margin-top: 0; }
input {
width: 100%; min-height: 44px; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
border: 1px solid var(--color-border); border-radius: var(--radius-sm); 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: var(--radius-sm); cursor: pointer;
box-shadow: var(--shadow-sm);
transition: filter 150ms, transform 150ms, box-shadow 150ms;
}
button:hover { filter: brightness(0.96); box-shadow: var(--shadow-md); }
button:active { transform: scale(0.98); }
button:disabled { opacity: 0.6; cursor: default; box-shadow: none; }
button:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
button.secondary {
width: auto; margin-top: 0; padding: 0 12px; min-height: 40px;
background: var(--color-surface-inset); color: var(--color-foreground);
border: 1px solid var(--color-border); box-shadow: none;
}
button.secondary:hover { filter: none; background: var(--color-border); box-shadow: none; }
button.link, a.link {
width: auto; min-height: 44px; margin-top: 0; padding: 0 2px;
display: inline-flex; align-items: center;
background: none; color: var(--color-muted-foreground); font-weight: 500;
font-size: 0.82rem; box-shadow: none; text-decoration: none;
}
button.link:hover, a.link:hover { filter: none; color: var(--color-foreground); box-shadow: none; }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
/* --- app navbar: brand/balance row (sticky top) + section nav (bottom tab bar on
mobile, promoted back to an inline tab strip once there's room — see the
min-width breakpoint below), shown only when logged in --- */
.app-navbar-top {
position: sticky; top: 0; z-index: 20;
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid var(--color-border);
}
.app-navbar-top-inner {
max-width: var(--content-width); margin: 0 auto; padding: 12px 20px;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.app-navbar-top .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1rem; letter-spacing: -0.01em; }
.app-navbar-top .brand-mark {
width: 26px; height: 26px; border-radius: 8px; flex-shrink: 0;
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
display: flex; align-items: center; justify-content: center;
color: var(--color-on-primary); font-weight: 800; font-size: 0.8rem;
}
.app-navbar-account { display: flex; align-items: center; gap: 6px; }
.app-navbar-account .navbar-username { display: none; font-weight: 600; font-size: 0.85rem; margin-right: 2px; }
@media (min-width: 420px) { .app-navbar-account .navbar-username { display: inline; } }
.app-navbar-account .navbar-balance {
display: inline-flex; align-items: center; gap: 5px;
font-weight: 700; font-size: 0.85rem; white-space: nowrap;
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-foreground));
padding: 6px 10px; border-radius: 999px;
}
.app-navbar-account .icon-link {
width: 36px; height: 36px; min-height: 36px; padding: 0; margin: 0; border-radius: 999px;
justify-content: center;
}
.app-navbar-account .icon-link:hover { background: var(--color-surface-inset); }
.app-navbar-account .icon-link .icon { width: 18px; height: 18px; }
/* Bottom tab bar on narrow (mobile) viewports — thumb-reachable, app-like. */
.app-navbar-tabs {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
max-width: var(--content-width); margin: 0 auto;
background: color-mix(in srgb, var(--color-surface) 94%, transparent);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border-top: 1px solid var(--color-border);
box-shadow: 0 -8px 24px -12px rgba(15, 23, 42, 0.18);
display: flex; padding: 4px 8px calc(4px + env(safe-area-inset-bottom, 0px));
}
.app-navbar-tabs button.navbar-tab {
flex: 1; width: auto; min-height: 56px; margin-top: 0; padding: 8px 4px;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
font-size: 0.68rem; font-weight: 600; font-family: inherit; cursor: pointer;
background: none; color: var(--color-muted-foreground); box-shadow: none;
border: none; border-radius: var(--radius-sm);
transition: color 150ms, background 150ms;
}
.app-navbar-tabs button.navbar-tab .icon { width: 20px; height: 20px; }
.app-navbar-tabs button.navbar-tab.active { color: var(--color-primary); }
.app-navbar-tabs button.navbar-tab.active .icon { color: var(--color-primary); }
.app-navbar-tabs button.navbar-tab:hover { filter: none; color: var(--color-foreground); background: var(--color-surface-inset); }
.app-navbar-tabs button.navbar-tab.active:hover { color: var(--color-primary); background: none; }
/* Reserve room so fixed content never sits under the bottom bar or the
iOS/Android home-indicator safe area. */
.app-shell { padding-bottom: calc(var(--nav-bottom-height) + env(safe-area-inset-bottom, 0px) + 20px); }
@media (min-width: 720px) {
:root { --content-width: 620px; }
/* Promote the bottom tab bar back to an ordinary inline strip once there's
enough width for it to sit comfortably under the top bar instead of
floating over thumb-reach real estate. */
.app-navbar-tabs {
position: static; box-shadow: none; border-top: none;
border-bottom: 1px solid var(--color-border);
padding: 4px 12px; gap: 4px;
}
.app-navbar-tabs button.navbar-tab { flex-direction: row; min-height: 44px; font-size: 0.85rem; }
.app-shell { padding-bottom: 40px; }
}
.address-box {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
background: var(--color-surface-inset); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); padding: 10px 12px; font-size: 0.85rem; word-break: break-all;
}
.balance-value { font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; }
.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: var(--radius-sm); 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; left: 50%; transform: translateX(-50%);
bottom: calc(var(--nav-bottom-height) + env(safe-area-inset-bottom, 0px) + 12px);
display: flex; flex-direction: column; gap: 8px; z-index: 100; width: calc(100% - 40px); max-width: 440px;
}
@media (min-width: 720px) { #toast-container { bottom: 20px; } }
.toast {
display: flex; align-items: flex-start; gap: 8px;
padding: 12px 14px; border-radius: var(--radius-sm); 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.85rem; 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);
box-shadow: var(--shadow-sm);
padding: 14px 8px; transition: transform 150ms, border-color 150ms, box-shadow 150ms;
}
.hero-step:hover { transform: translateY(-2px); border-color: var(--color-ring); box-shadow: var(--shadow-md); }
.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; }
/* --- round status: a "hero" ticket-style card, always visible above the panels --- */
.card.round-card {
background:
radial-gradient(320px circle at 100% 0%, color-mix(in srgb, var(--color-accent) 10%, transparent), transparent 70%),
var(--color-surface);
border-color: color-mix(in srgb, var(--color-primary) 25%, var(--color-border));
}
.round-card .hint { margin: 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: 12px 2px 16px; margin-bottom: 4px;
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); }
.status-dot.status-offline { background: var(--color-destructive); }
@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="hidden" id="app-navbar" aria-label="Sezioni">
<div class="app-navbar-top">
<div class="app-navbar-top-inner">
<span class="brand"><span class="brand-mark">P</span> PLM Lottery</span>
<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>
</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>
<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>
</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 round-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...">
<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>
<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>
<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>
</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', 'profile']) {
document.getElementById('nav-' + key).classList.toggle('active', key === name);
document.getElementById('panel-' + key).classList.toggle('active', key === name);
}
}
let roundCloseAt = null;
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;
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);
}
// 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();
}
let chainOnlyInterval = null;
async function refreshChainStatusOnly() {
try {
const data = await call('GET', '/rounds/current');
updateChainStatusBar(data);
noteFetchOutcome(true);
} catch (e) {
noteFetchOutcome(false);
}
}
function startChainOnlyPolling() {
refreshChainStatusOnly();
clearInterval(chainOnlyInterval);
chainOnlyInterval = setInterval(refreshChainStatusOnly, 15000);
}
function stopChainOnlyPolling() {
clearInterval(chainOnlyInterval);
chainOnlyInterval = null;
}
// 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();
}
});
// 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;
let timerHitZero = false;
function updateRoundTimer() {
const el = document.getElementById('round-timer');
if (!roundCloseAt) { el.textContent = '--:--'; timerHitZero = false; return; }
const rawSec = Math.floor((roundCloseAt - serverNow()) / 1000);
const totalSec = Math.max(0, rawSec);
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
const ss = String(totalSec % 60).padStart(2, '0');
el.textContent = mm + ':' + ss;
// 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;
}
}
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() {
const seq = ++roundRequestSeq;
try {
const data = await call('GET', '/rounds/current');
if (seq < roundAppliedSeq) return; // a newer refreshRound() call already applied its result
roundAppliedSeq = seq;
noteFetchOutcome(true);
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;
if (data.server_time) serverTimeOffsetMs = new Date(data.server_time) - new Date();
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;
// drop bookkeeping for old rounds so these maps don't grow for the life of the session
for (const key of Object.keys(drawStartedAt)) {
if (Number(key) !== data.round_id) delete drawStartedAt[key];
}
for (const id of revealedRounds) {
if (id !== data.round_id) revealedRounds.delete(id);
}
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".
// The countdown reaching zero doesn't mean the server has actually closed the
// round yet (it still waits for in-flight bets to confirm) — poll faster
// through that gap too, not just once status flips to closing/drawing/paying_out.
const pastDeadline = data.status === 'open' && roundCloseAt !== null && roundCloseAt - serverNow() <= 0;
scheduleNextRoundPoll(isDrawing || pastDeadline);
} catch (e) {
noteFetchOutcome(false);
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 resetToLoggedOutUI() {
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();
}
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();
}
}
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;
document.getElementById('navbar-balance').textContent = (data.balance_sats / SATS_PER_PLM) + ' PLM';
document.getElementById('profile-username').textContent = data.username;
document.getElementById('profile-address').textContent = data.address;
document.getElementById('profile-balance').textContent = data.balance_sats / SATS_PER_PLM;
document.getElementById('profile-created-at').textContent = new Date(data.created_at).toLocaleDateString('it-IT');
} catch (e) {
toast(e.message, 'error');
}
});
}
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');
} 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();
}
initAuthState();
</script>
</body>
</html>