Files
plm-lottery/app/static/app.js
T
davideandClaude Opus 5 d4e0974881 Let JS own the live status labels instead of sharing them with data-i18n
#chain-status-label and #draw-label carried a data-i18n attribute *and* were
written from live state by app.js, so a language switch had both mechanisms
fighting over them: applyStaticTranslations reset each to its static default
and the next poll put the real value back. On the draw label that was a
flicker. On the status bar it was a false statement — with the connection
down, the bar went back to claiming "connecting" until a further fetch failed,
up to a full poll interval later.

The attribute is gone from both. The status bar is now rendered from
remembered state (last payload, plus whether we're in the offline state)
rather than straight from the response that triggered it, so a language switch
repaints it correctly and immediately, with no fetch involved.

logout() also stops wiping the chosen language: localStorage.clear() took
plm_lang with it, dropping the user back to the browser-detected default on
the one screen where they'd have to go find the switcher again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:55:37 +02:00

824 lines
33 KiB
JavaScript

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);
}
// innerHTML, not textContent: several of these buttons wrap an <svg> icon and a
// <span data-i18n=...>, both of which a textContent round-trip would flatten away
// — losing the icon for good and, worse, stripping the data-i18n hook so the
// button would stop following later language changes.
async function withLoading(button, label, fn) {
const original = button.innerHTML;
button.disabled = true;
button.textContent = label;
try {
await fn();
} finally {
button.disabled = false;
button.innerHTML = original;
applyStaticTranslations(button); // the snapshot may predate a language switch made while loading
}
}
const REQUEST_TIMEOUT_MS = 15000;
// Without a timeout, a single request that never resolves (server-side hang —
// stuck DB session, unresponsive Electrum connection...) would stall the whole
// sequential polling chain forever: the UI just freezes on whatever was last
// rendered, with no error and no "connessione persa" (that only fires on a
// rejected fetch, never on one that's merely stuck).
async function call(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let res;
try {
res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal });
} catch (e) {
throw new Error(e.name === 'AbortError' ? t('toast.requestTimeout') : e.message);
} finally {
clearTimeout(timeoutId);
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// A token the server no longer accepts can't be recovered from by retrying:
// without this every poll keeps failing against a dashboard that still looks
// logged in, toasting "session expired" forever. Drop back to the login form.
if (res.status === 401 && data.detail?.code === 'session_expired' && token) logout();
throw new Error(apiErrorMessage(data.detail) || res.statusText);
}
return data;
}
// The API is single-language by design: it answers with a stable machine code
// plus an English message (app/api/errors.py), and picking the words is the
// client's job. Unknown code (older/newer server, an endpoint not converted
// yet) → show the English message rather than nothing.
function apiErrorMessage(detail) {
if (!detail) return null;
if (typeof detail === 'string') return detail; // endpoints still returning a bare string
// FastAPI's own request-validation failures (422) use a list of field errors
// instead, in English and phrased for an API client ("Input should be a valid
// integer"). Nothing here can act on which field it was, so say the one useful
// thing — the request was malformed — in the user's language.
if (Array.isArray(detail)) return t('error.invalid_request');
return tOrNull('error.' + detail.code, errorParams(detail.params)) || detail.message || null;
}
// Amounts cross the wire in sats (`*_sats`); every translated string wants PLM,
// so expose both and let each language's phrasing pick. Done generically here
// so a new *_sats param needs no client change.
function errorParams(params) {
const out = { ...(params || {}) };
for (const [key, value] of Object.entries(params || {})) {
if (key.endsWith('_sats') && typeof value === 'number') {
out[key.slice(0, -5) + '_plm'] = value / SATS_PER_PLM;
}
}
return out;
}
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);
}
}
// Bumped on every logout/login so an in-flight refreshRound() started under a
// previous session can detect it's now stale — a fetch can still be awaiting
// its response after logout() clears the timeout-based poll chain, and without
// this guard it would re-arm scheduleNextRoundPoll() and resurrect a "zombie"
// dashboard poll running in parallel with the logged-out chain-only poll.
let sessionEpoch = 0;
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;
let lastResultInterval = null;
const ROUND_STATUS_KEYS = {
open: 'round.status.open',
closing: 'round.status.closing',
drawing: 'round.status.drawing',
paying_out: 'round.status.paying_out',
};
const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out'];
// One distinct message per DRAW sub-phase (see CLAUDE.md's "three separate
// on-chain confirmations" note) instead of a single generic spinner label —
// takes the round data so the drawing phase can surface the draw block once known.
function drawingLabelFor(data) {
if (data.status === 'closing') {
return t('draw.closing');
}
if (data.status === 'drawing') {
return t('draw.drawing');
}
// paying_out
if (data.draw_block_height != null) {
return t('draw.payingOutBlock', { height: data.draw_block_height });
}
return t('draw.payingOut');
}
// One label per real round status, not just the coarse open/drawing/waiting
// grouping — the status bar should show the same phase distinction as the
// draw-state panel (drawingLabelFor above), just condensed to a short phrase.
const CHAIN_STATUS_KEYS = {
waiting: 'chain.status.waiting',
open: 'chain.status.open',
closing: 'chain.status.closing',
drawing: 'chain.status.drawing',
paying_out: 'chain.status.paying_out',
};
// The bar is rendered from remembered state rather than straight from the
// response that triggered it, so a language switch can repaint it immediately
// instead of waiting for the next poll. That wait used to make it lie: with the
// connection down, switching language reset the label to "connecting" until a
// further fetch failed.
let lastChainData = null;
let chainOffline = false;
function updateChainStatusBar(data) {
lastChainData = data;
chainOffline = false;
renderChainStatusBar();
}
function renderChainStatusBar() {
const dot = document.getElementById('chain-status-dot');
const label = document.getElementById('chain-status-label');
const block = document.getElementById('chain-block');
if (chainOffline) {
dot.className = 'status-dot status-offline';
label.textContent = t('chain.connectionLost');
return; // block height deliberately left showing its last known value
}
if (lastChainData === null) {
label.textContent = t('chain.connecting');
return;
}
const data = lastChainData;
// The dot's color/pulse only distinguishes waiting/open/drawing (that's all
// the CSS defines) — closing and paying_out both pulse like drawing, they
// just get their own text label below.
let dotKey;
if (!data.round_id) dotKey = 'waiting';
else if (DRAWING_STATUSES.includes(data.status)) dotKey = 'drawing';
else dotKey = 'open';
const labelKey = data.round_id && data.status in CHAIN_STATUS_KEYS ? data.status : 'waiting';
dot.className = 'status-dot status-' + dotKey;
label.textContent = t(CHAIN_STATUS_KEYS[labelKey]);
block.textContent = t('chain.block', { n: 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() {
chainOffline = true;
renderChainStatusBar();
}
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();
checkLastRoundResult();
}
});
// The win/lose box's content lives in localStorage, not just in-memory state —
// a page reload (or a completely fresh tab) must be able to redraw it exactly
// as it was, without waiting for a new poll or re-running the reveal
// animation. This is the single source of truth for "what result box (if any)
// is currently shown"; refreshRound() and checkLastRoundResult() below both
// read/write it instead of keeping their own separate notion of "revealed".
const PERSISTED_RESULT_KEY = 'plm_persisted_result';
function getPersistedResult() {
try {
return JSON.parse(localStorage.getItem(PERSISTED_RESULT_KEY));
} catch (e) {
return null;
}
}
function persistResult(roundId, won, amountSats) {
localStorage.setItem(PERSISTED_RESULT_KEY, JSON.stringify({ round_id: roundId, won, amount_sats: amountSats }));
}
function clearPersistedResult() {
localStorage.removeItem(PERSISTED_RESULT_KEY);
}
function renderPersistedResult(result) {
setRoundInfoVisible(false);
setResultBoxVisible(
true,
result.won ? t('result.win', { amount: result.amount_sats / SATS_PER_PLM }) : t('result.lose'),
result.won ? 'win' : 'lose'
);
}
// The most recent round_id refreshRound() actually saw from the server (null
// meaning "confirmed no active round"; undefined meaning "haven't polled yet").
// Lets checkLastRoundResult() below avoid clobbering a round that's already
// known to be open/in-progress by the time its own (slower, DB-backed) request
// resolves.
let currentRoundIdSeen;
// Backstop for the live reveal in refreshRound(): that one only works if a poll
// happens to land while the round is still "paying_out" (winner_user_id is
// dropped from /rounds/current the instant the round flips to "closed" — see
// rounds/service.get_active_round). A backgrounded tab, a missed poll, or a
// late page load can miss that window entirely, in which case the live path
// never fires and the player would otherwise never learn the outcome. This
// reads GET /users/me/last-round-result, which reports the durable DB record
// instead of an ephemeral snapshot, so it always catches up eventually.
async function checkLastRoundResult() {
if (!token) return;
let data;
try {
data = await call('GET', '/users/me/last-round-result');
} catch (e) {
return; // silent — this is a backstop, refreshRound()'s own error handling already covers the primary path
}
if (data.round_id == null) return;
const persisted = getPersistedResult();
if (persisted && persisted.round_id === data.round_id) return; // already showing/known
if (currentRoundIdSeen != null && currentRoundIdSeen !== data.round_id) return; // a newer round is already in progress on screen
persistResult(data.round_id, data.won, data.amount_sats);
renderPersistedResult({ won: data.won, amount_sats: data.amount_sats });
if (data.won) {
const won = data.amount_sats / SATS_PER_PLM;
toast(t('toast.roundWon', { id: data.round_id, amount: won }), 'success');
refreshMe();
}
}
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;
}
}
// The round's normal info (title/timer/players/jackpot) vs. the drawing-phase
// spinner box vs. the personalized win/lose box are three independently
// toggled pieces, not three mutually-exclusive "screens" — during closing/
// drawing/paying_out, EVERY viewer sees the drawing box (generic phase
// progress), and a player who bet in that round ALSO sees the win/lose box at
// the same time once revealed, instead of the two fighting over one slot.
function setRoundInfoVisible(show) {
document.getElementById('round-normal-row').classList.toggle('hidden', !show);
document.getElementById('round-stats-row').classList.toggle('hidden', !show);
}
function setDrawingBoxVisible(show, label) {
document.getElementById('draw-state').classList.toggle('active', show);
if (show && label) document.getElementById('draw-label').textContent = label;
}
function setResultBoxVisible(show, html, cls) {
const el = document.getElementById('draw-result');
if (show) {
el.className = 'draw-result ' + cls;
el.innerHTML = html;
}
el.classList.toggle('hidden', !show);
}
// RoundConfig.bet_amount_sats is admin-editable at runtime, so the button label
// can't be a fixed "(10 PLM)" string in the translation files — it's rendered
// from whatever /rounds/current last reported, in the current language.
let betAmountSats = null;
function renderBetButton() {
const btn = document.getElementById('bet-btn');
// Skipped while the button is showing its loading label: withLoading restores
// the pre-click markup on its own, and the next poll re-renders anyway.
if (btn.disabled) return;
btn.textContent = betAmountSats === null
? t('bet.buttonNoAmount')
: t('bet.button', { amount: betAmountSats / SATS_PER_PLM });
}
function showNormalState() {
setRoundInfoVisible(true);
setDrawingBoxVisible(false);
setResultBoxVisible(false);
}
async function refreshRound() {
const seq = ++roundRequestSeq;
const epoch = sessionEpoch;
try {
const data = await call('GET', '/rounds/current');
if (epoch !== sessionEpoch) return; // session ended (or a new one started) while this was in flight
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
? t('round.title', { id: data.round_id, status: data.status in ROUND_STATUS_KEYS ? t(ROUND_STATUS_KEYS[data.status]) : data.status })
: t('round.none');
betAmountSats = data.bet_amount_sats;
renderBetButton();
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();
currentRoundIdSeen = data.round_id || null;
const isDrawing = data.round_id && DRAWING_STATUSES.includes(data.status);
document.getElementById('round-card').classList.toggle('drawing-glow', !!isDrawing);
const persisted = getPersistedResult();
if (isDrawing) {
setRoundInfoVisible(false);
// The drawing-phase box (spinner + phase label) is generic status info —
// every viewer sees it for the whole closing/drawing/paying_out phase,
// regardless of whether they played in this round.
setDrawingBoxVisible(true, drawingLabelFor(data));
// The cosmetic reveal delay is anchored to the server's closes_at, not to
// any client-side "when did I first see this" timestamp — a page reload
// (or repeated reloads) can never reset it, since it's derived purely
// from server-provided values that don't change for this round.
const elapsedMs = serverNow() - new Date(data.closes_at);
const minMs = data.draw_animation_seconds * 1000;
const alreadyKnown = persisted && persisted.round_id === data.round_id;
// myUserId may not be loaded yet on the very first tick after a reload
// (refreshMe() and refreshRound() run concurrently) — fall back to the
// persisted result rather than risk showing nothing or the wrong side.
const canReveal =
data.user_played && data.winner_user_id != null && (alreadyKnown || elapsedMs >= minMs) && myUserId != null;
if (canReveal) {
const won = data.winner_user_id === myUserId;
if (!alreadyKnown) {
persistResult(data.round_id, won, data.winner_amount_sats);
if (won) {
const wonAmount = (data.winner_amount_sats / SATS_PER_PLM);
toast(t('toast.roundWon', { id: data.round_id, amount: wonAmount }), 'success');
refreshMe(); // the win toast is useless if the balance card still shows the pre-payout amount
}
}
renderPersistedResult({ won, amount_sats: data.winner_amount_sats });
} else if (alreadyKnown) {
renderPersistedResult(persisted);
} else {
setResultBoxVisible(false);
}
} else {
setDrawingBoxVisible(false);
if (data.round_id && (!persisted || data.round_id !== persisted.round_id)) {
// a genuinely new round is open — clear any previous result and go back to normal
clearPersistedResult();
showNormalState();
} else if (!data.round_id && !persisted) {
// nothing has ever been revealed and there's no active round — plain empty state
showNormalState();
} else if (persisted) {
// no active round right now (cooldown, or a page reload after the round
// fully closed) — keep the persisted result on screen regardless, until
// a genuinely new round replaces it above.
renderPersistedResult(persisted);
}
}
scheduleNextRoundPoll(isDrawing);
} catch (e) {
if (epoch !== sessionEpoch) return; // session ended (or a new one started) while this was in flight
noteFetchOutcome(false);
scheduleNextRoundPoll(false);
}
}
function scheduleNextRoundPoll(fast) {
clearTimeout(roundPollTimeout);
roundPollTimeout = setTimeout(refreshRound, fast ? 3000 : 15000);
}
async function showDashboard() {
sessionEpoch++; // invalidate any dashboard poll chain left over from a previous login
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);
// Render instantly from localStorage, before the network round-trip below —
// otherwise a reload right after a win/lose flashes an empty round card for
// a moment. refreshRound()'s own response reconciles this shortly after
// (e.g. hides it again if a new round has since opened).
const persisted = getPersistedResult();
if (persisted) renderPersistedResult(persisted);
// Awaited so myUserId is populated before refreshRound() decides whether
// data.winner_user_id === myUserId — otherwise that comparison could race
// against an unset myUserId right after a reload.
await refreshMe();
// Awaited too, and before refreshRound(): on a brand-new browser/device that
// never saw this round live (nothing in localStorage), this is the only
// thing that knows the outcome once the round has fully closed. Resolving
// it first means refreshRound() finds the answer already in place instead
// of momentarily rendering "no result" and then flipping to the win/lose
// box a moment later once this backstop catches up.
await checkLastRoundResult();
refreshRound();
clearInterval(lastResultInterval);
lastResultInterval = setInterval(checkLastRoundResult, 20000);
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(t('toast.passwordMismatch'), 'error');
return;
}
await withLoading(btn, t('loading.creating'), async () => {
try {
const data = await call('POST', '/auth/register', { username: u, password: p });
persistSession(data, u);
toast(t('toast.accountCreated'), '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, t('loading.loggingIn'), async () => {
try {
const data = await call('POST', '/auth/login', { username: u, password: p });
persistSession(data, u);
toast(t('toast.loginSuccess'), 'success');
showDashboard();
} catch (e) {
toast(e.message, 'error');
}
});
}
function resetToLoggedOutUI() {
sessionEpoch++; // invalidate any refreshRound() still in flight from the dashboard we're leaving
token = username = address = null;
myUserId = null;
currentRoundIdSeen = undefined;
clearInterval(roundTimerInterval);
clearTimeout(roundPollTimeout);
clearInterval(lastResultInterval);
lastResultInterval = null;
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() {
// The chosen language is a device preference, not session state — clearing it
// on logout would drop the user back to the browser-detected default on the
// very screen where they'd have to find the switcher again.
const lang = localStorage.getItem(LANG_STORAGE_KEY);
localStorage.clear();
if (lang) localStorage.setItem(LANG_STORAGE_KEY, lang);
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(t('toast.addressCopied'), 'success');
} catch (e) {
toast(t('toast.copyFailed'), 'error');
}
}
let myUserId = null;
let myBalanceSats = 0; // confirmed, spendable balance — what withdrawals/bets can actually draw from
// Shows the pending-inclusive balance (confirmed + own change still unconfirmed
// in a broadcast bet/withdrawal — see compute_pending_balance in
// app/wallet/balance.py) so the number doesn't drop by more than the amount
// actually spent while a tx is in flight. Green once settled, amber while
// has_pending is true so it's clear the figure isn't final yet.
function setBalanceDisplay(elementId, pendingBalanceSats, hasPending) {
const el = document.getElementById(elementId);
el.textContent = pendingBalanceSats / SATS_PER_PLM;
el.classList.toggle('balance-pending', hasPending);
el.classList.toggle('balance-confirmed', !hasPending);
}
async function refreshMe() {
const btn = document.getElementById('refresh-btn');
await withLoading(btn, t('loading.refreshing'), async () => {
try {
const data = await call('GET', '/users/me');
myUserId = data.id;
myBalanceSats = data.balance_sats;
setBalanceDisplay('dash-balance', data.pending_balance_sats, data.has_pending);
document.getElementById('navbar-balance').textContent = (data.pending_balance_sats / SATS_PER_PLM) + ' PLM';
document.getElementById('navbar-balance').classList.toggle('balance-pending', data.has_pending);
document.getElementById('navbar-balance').classList.toggle('balance-confirmed', !data.has_pending);
document.getElementById('profile-username').textContent = data.username;
document.getElementById('profile-address').textContent = data.address;
setBalanceDisplay('profile-balance', data.pending_balance_sats, data.has_pending);
document.getElementById('profile-created-at').textContent = new Date(data.created_at).toLocaleDateString(currentDateLocale());
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(t('toast.newPasswordMismatch'), 'error');
return;
}
if (newPassword.length < 8) {
toast(t('toast.passwordTooShort'), 'error');
return;
}
await withLoading(btn, t('loading.updating'), 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(t('toast.passwordUpdated'), 'success');
} catch (e) {
toast(e.message, 'error');
}
});
}
async function placeBet() {
const btn = document.getElementById('bet-btn');
await withLoading(btn, t('loading.sendingBet'), async () => {
try {
const data = await call('POST', '/bets', {});
toast(t('toast.betPlaced', { id: 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 amount = parseFloat(document.getElementById('wd-amount').value);
// Caught here rather than left to the server: an empty or non-numeric field
// parses to NaN, which JSON.stringify sends as null, which comes back as a
// 422 whose only readable text is an English HTTP status line.
if (!isFullAmount && !(amount > 0)) {
toast(t('error.invalid_amount'), 'error');
return;
}
const amtSats = isFullAmount ? myBalanceSats : Math.round(amount * SATS_PER_PLM);
await withLoading(btn, t('loading.sending'), async () => {
try {
await call('POST', '/withdrawals', { external_address: ext, amount_sats: amtSats });
toast(t('toast.withdrawSent'), 'success');
document.getElementById('wd-full-amount').checked = false;
toggleWithdrawFullAmount();
document.getElementById('wd-amount').value = '';
} catch (e) {
toast(e.message, 'error');
}
});
refreshMe();
}
// Server push: an SSE channel that notifies the instant round/bet/balance
// state changes anywhere (see app/rounds/events.py), instead of everyone
// waiting for their next poll tick. The message carries no payload — it just
// means "something changed", so we react by immediately re-running the same
// refreshes the polling loop would eventually do on its own. Polling is left
// completely in place as a fallback: if this connection is blocked/dropped
// (proxy, browser setting, flaky network), the page keeps working exactly as
// before, just without the instant nudge.
let roundEventSource = null;
function onRoundServerEvent() {
if (token) {
refreshRound();
refreshMe();
checkLastRoundResult();
} else {
refreshChainStatusOnly();
}
}
function connectRoundEvents() {
if (roundEventSource) return;
roundEventSource = new EventSource('/rounds/stream');
roundEventSource.addEventListener('update', onRoundServerEvent);
// Fires on the initial connection AND every successful auto-reconnect (the
// browser retries this on its own after a drop) — re-syncs immediately
// instead of leaving the page on whatever it last knew until the next event
// or poll tick, which would otherwise widen the "missed while disconnected"
// window to the full reconnect gap.
roundEventSource.addEventListener('open', onRoundServerEvent);
}
// Called by i18n.js's setLanguage() after applying static [data-i18n] translations —
// re-renders the dynamic bits that live outside that mechanism (status labels,
// round title, draw-phase label, persisted win/lose box, profile date) since
// those are built from server data + t() rather than fixed markup.
function onLanguageChange() {
renderBetButton();
renderChainStatusBar(); // repaints from remembered state, without waiting for the next poll
if (token) {
refreshRound();
refreshMe();
} else {
refreshChainStatusOnly();
}
const persisted = getPersistedResult();
if (persisted && !document.getElementById('draw-result').classList.contains('hidden')) {
renderPersistedResult(persisted);
}
}
renderBetButton();
renderChainStatusBar();
connectRoundEvents();
initAuthState();