Adds app/static/logo.svg — the official Palladium logo (fetched from the palladium-coin/palladium-web-site repo), reprocessed for crisp display at small sizes: the white/blue boundary is sharpened (steep contrast curve instead of the soft anti-aliasing baked into the low-res source) and the outer circle uses an analytically computed alpha edge instead of a rasterized ellipse, so it stays perfectly round with no pixel staircase at any size. Shape and colors are otherwise identical to the source artwork. Wired up as the navbar brand mark (replacing the plain amber "P" square) in index.html, and as the favicon for both index.html and admin.html. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1117 lines
49 KiB
HTML
1117 lines
49 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>
|
|
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
|
<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);
|
|
}
|
|
input:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
|
|
|
|
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; }
|
|
|
|
.checkbox-row {
|
|
display: flex; align-items: center; gap: 8px;
|
|
font-size: 0.85rem; font-weight: 500; color: var(--color-foreground);
|
|
margin-top: 14px; cursor: pointer;
|
|
}
|
|
.checkbox-row input[type="checkbox"] {
|
|
width: 18px; height: 18px; min-height: auto; flex-shrink: 0; accent-color: var(--color-primary); cursor: pointer;
|
|
}
|
|
|
|
/* --- 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: 50%; flex-shrink: 0; display: block; }
|
|
.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">
|
|
<img class="brand-mark" src="/logo.svg" alt="">
|
|
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">
|
|
<label class="checkbox-row">
|
|
<input type="checkbox" id="wd-full-amount" onchange="toggleWithdrawFullAmount()">
|
|
Preleva l'intero importo (<span class="mono" id="wd-full-amount-value">—</span> PLM)
|
|
</label>
|
|
<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;
|
|
let myBalanceSats = 0;
|
|
|
|
async function refreshMe() {
|
|
const btn = document.getElementById('refresh-btn');
|
|
await withLoading(btn, '…', async () => {
|
|
try {
|
|
const data = await call('GET', '/users/me');
|
|
myUserId = data.id;
|
|
myBalanceSats = data.balance_sats;
|
|
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');
|
|
document.getElementById('wd-full-amount-value').textContent = data.balance_sats / SATS_PER_PLM;
|
|
if (document.getElementById('wd-full-amount').checked) {
|
|
document.getElementById('wd-amount').value = data.balance_sats / SATS_PER_PLM;
|
|
}
|
|
} catch (e) {
|
|
toast(e.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleWithdrawFullAmount() {
|
|
const checked = document.getElementById('wd-full-amount').checked;
|
|
const amountInput = document.getElementById('wd-amount');
|
|
amountInput.disabled = checked;
|
|
if (checked) amountInput.value = myBalanceSats / SATS_PER_PLM;
|
|
}
|
|
|
|
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 isFullAmount = document.getElementById('wd-full-amount').checked;
|
|
const amtSats = isFullAmount
|
|
? myBalanceSats
|
|
: Math.round(parseFloat(document.getElementById('wd-amount').value) * SATS_PER_PLM);
|
|
await withLoading(btn, 'Invio…', async () => {
|
|
try {
|
|
await call('POST', '/withdrawals', { external_address: ext, amount_sats: amtSats });
|
|
toast('Withdrawal inviato.', 'success');
|
|
document.getElementById('wd-full-amount').checked = false;
|
|
toggleWithdrawFullAmount();
|
|
document.getElementById('wd-amount').value = '';
|
|
} catch (e) {
|
|
toast(e.message, 'error');
|
|
}
|
|
});
|
|
refreshMe();
|
|
}
|
|
|
|
initAuthState();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|