Show a draw animation and win/lose reveal on the user dashboard

When the round leaves "open" (closing/drawing/paying_out), the round
card swaps its timer for a spinning "Estrazione del vincitore in
corso…" state instead — bets are already rejected server-side once the
round isn't open, this just reflects that visually. Once winner_user_id
is set AND at least draw_animation_seconds has elapsed since the round
started closing (client-tracked per round_id), it reveals "🎉 Hai
vinto! +N PLM" (compared against the user's own id from /users/me) or
"Non hai vinto questa volta.", with a success toast on a win. The
result stays on screen through the cooldown gap and only clears once a
genuinely new round opens (tracked via activeResultRoundId), not the
instant the old round has no active status.

Polling is now dynamic (setTimeout-chained, not setInterval): 3s while
the round is closing/drawing/paying_out for a responsive reveal, 15s
while open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:03:42 +02:00
co-authored by Claude Sonnet 5
parent 669c3fb714
commit 7e4b603ae3
+106 -7
View File
@@ -140,6 +140,19 @@
.hidden { display: none !important; }
.draw-state { display: none; text-align: center; padding: 8px 0 4px; }
.draw-state.active { display: block; }
.draw-spinner {
width: 40px; height: 40px; margin: 0 auto 10px;
border: 3px solid var(--color-border); border-top-color: var(--color-primary);
border-radius: 50%; animation: spin 900ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.draw-state .draw-label { font-weight: 600; font-size: 0.95rem; }
.draw-result { font-size: 1.05rem; font-weight: 700; padding: 6px 0; }
.draw-result.win { color: var(--color-success); }
.draw-result.lose { color: var(--color-muted-foreground); }
#toast-container {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px; z-index: 100; width: calc(100% - 40px); max-width: 440px;
@@ -201,11 +214,11 @@
</div>
<div class="card">
<div class="row-between">
<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">
<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>
@@ -215,6 +228,13 @@
<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>
<nav class="menu" aria-label="Sezioni">
@@ -337,7 +357,7 @@ function switchPanel(name) {
let roundCloseAt = null;
let roundTimerInterval = null;
let roundPollInterval = null;
let roundPollTimeout = null;
const ROUND_STATUS_LABELS = {
open: 'aperto',
@@ -346,6 +366,15 @@ const ROUND_STATUS_LABELS = {
paying_out: 'pagamento in corso',
};
const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out'];
// 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
function updateRoundTimer() {
const el = document.getElementById('round-timer');
if (!roundCloseAt) { el.textContent = '--:--'; return; }
@@ -355,6 +384,30 @@ function updateRoundTimer() {
el.textContent = mm + ':' + ss;
}
function showDrawingState() {
document.getElementById('round-normal-row').classList.add('hidden');
document.getElementById('round-stats-row').classList.add('hidden');
document.getElementById('draw-state').classList.add('active');
document.getElementById('draw-result').classList.add('hidden');
}
function showResultState(html, cls) {
document.getElementById('round-normal-row').classList.add('hidden');
document.getElementById('round-stats-row').classList.add('hidden');
document.getElementById('draw-state').classList.remove('active');
const el = document.getElementById('draw-result');
el.className = 'draw-result ' + cls;
el.innerHTML = html;
el.classList.remove('hidden');
}
function showNormalState() {
document.getElementById('round-normal-row').classList.remove('hidden');
document.getElementById('round-stats-row').classList.remove('hidden');
document.getElementById('draw-state').classList.remove('active');
document.getElementById('draw-result').classList.add('hidden');
}
async function refreshRound() {
try {
const data = await call('GET', '/rounds/current');
@@ -365,7 +418,48 @@ async function refreshRound() {
document.getElementById('round-jackpot').textContent = data.jackpot_sats / SATS_PER_PLM;
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
updateRoundTimer();
} catch (e) {}
const isDrawing = data.round_id && DRAWING_STATUSES.includes(data.status);
if (isDrawing) {
if (!(data.round_id in drawStartedAt)) drawStartedAt[data.round_id] = Date.now();
const elapsedMs = Date.now() - drawStartedAt[data.round_id];
const minMs = data.draw_animation_seconds * 1000;
if (data.winner_user_id != null && elapsedMs >= minMs && !revealedRounds.has(data.round_id)) {
revealedRounds.add(data.round_id);
activeResultRoundId = data.round_id;
if (myUserId != null && data.winner_user_id === myUserId) {
const won = (data.winner_amount_sats / SATS_PER_PLM);
showResultState('🎉 Hai vinto! +' + won + ' PLM', 'win');
toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success');
} else {
showResultState('Non hai vinto questa volta.', 'lose');
}
} else if (!revealedRounds.has(data.round_id)) {
showDrawingState();
}
} else if (data.round_id && data.round_id !== activeResultRoundId) {
// a genuinely new round is open — clear any previous result and go back to normal
activeResultRoundId = null;
showNormalState();
} else if (!data.round_id && activeResultRoundId == null) {
// nothing has ever been revealed and there's no active round — plain empty state
showNormalState();
}
// else: no active round right now, but we just revealed a result for the last
// one — keep it on screen through the cooldown gap instead of flashing back to
// "Nessun round attivo".
scheduleNextRoundPoll(isDrawing);
} catch (e) {
scheduleNextRoundPoll(false);
}
}
function scheduleNextRoundPoll(fast) {
clearTimeout(roundPollTimeout);
roundPollTimeout = setTimeout(refreshRound, fast ? 3000 : 15000);
}
function showDashboard() {
@@ -377,9 +471,7 @@ function showDashboard() {
refreshMe();
refreshRound();
clearInterval(roundTimerInterval);
clearInterval(roundPollInterval);
roundTimerInterval = setInterval(updateRoundTimer, 1000);
roundPollInterval = setInterval(refreshRound, 15000);
}
function persistSession(data, u) {
@@ -429,8 +521,12 @@ async function login() {
function logout() {
localStorage.clear();
token = username = address = null;
myUserId = null;
activeResultRoundId = null;
revealedRounds.clear();
for (const key of Object.keys(drawStartedAt)) delete drawStartedAt[key];
clearInterval(roundTimerInterval);
clearInterval(roundPollInterval);
clearTimeout(roundPollTimeout);
document.getElementById('dashboard-section').classList.add('hidden');
document.getElementById('auth-section').classList.remove('hidden');
}
@@ -444,11 +540,14 @@ async function copyAddress() {
}
}
let myUserId = null;
async function refreshMe() {
const btn = document.getElementById('refresh-btn');
await withLoading(btn, '…', async () => {
try {
const data = await call('GET', '/users/me');
myUserId = data.id;
document.getElementById('dash-balance').textContent = data.balance_sats / SATS_PER_PLM;
} catch (e) {
toast(e.message, 'error');