The dashboard now speaks seven languages but every failure path still showed
the API's raw English text ("insufficient balance", "current password is
incorrect"), which is the most frequent and least forgiving part of the UI to
leave untranslated.
Rather than teach the API about locales, it keeps answering in one language
and hands the client something to translate: `detail` becomes
{code, message, params}, where message stays English for non-dashboard
consumers (curl, tests) and code maps onto `error.<code>` in i18n.js. An
unknown code falls back to message, so a client older or newer than the server
degrades to English instead of a blank toast.
Domain exceptions (BetError, WithdrawalError) subclass the new ApiError and
carry the code from where the failure actually happens; str(exc) is still the
English message, so existing tests keep matching on it. Interpolated values
travel in params rather than baked into the English sentence — amounts as
*_sats, from which the frontend derives a *_plm sibling, so each language can
place them wherever its grammar wants.
admin.js reads detail.message defensively: the admin endpoints still return a
bare string, but the shared auth dependencies now return the structured form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
775 lines
31 KiB
JavaScript
775 lines
31 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) 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
|
|
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',
|
|
};
|
|
|
|
function updateChainStatusBar(data) {
|
|
const dot = document.getElementById('chain-status-dot');
|
|
const label = document.getElementById('chain-status-label');
|
|
const block = document.getElementById('chain-block');
|
|
|
|
// 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() {
|
|
document.getElementById('chain-status-dot').className = 'status-dot status-offline';
|
|
document.getElementById('chain-status-label').textContent = t('chain.connectionLost');
|
|
}
|
|
|
|
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() {
|
|
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(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 amtSats = isFullAmount
|
|
? myBalanceSats
|
|
: Math.round(parseFloat(document.getElementById('wd-amount').value) * 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();
|
|
if (token) {
|
|
refreshRound();
|
|
refreshMe();
|
|
} else {
|
|
refreshChainStatusOnly();
|
|
}
|
|
const persisted = getPersistedResult();
|
|
if (persisted && !document.getElementById('draw-result').classList.contains('hidden')) {
|
|
renderPersistedResult(persisted);
|
|
}
|
|
}
|
|
|
|
renderBetButton();
|
|
connectRoundEvents();
|
|
initAuthState();
|