diff --git a/app/static/admin.js b/app/static/admin.js index dfc5023..4ebca8b 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -1,4 +1,12 @@ const SATS_PER_PLM = 100000000; + +// Display formatter: a raw sats/SATS_PER_PLM division renders binary +// floating-point artefacts (0.7000000000000001) in the tables below (B-22). +// Input fields keep the raw value — they have to stay parseable. +function fmtPlm(sats) { + if (sats === null || sats === undefined) return '—'; + return new Intl.NumberFormat('it-IT', { maximumFractionDigits: 8 }).format(sats / SATS_PER_PLM); +} let adminToken = sessionStorage.getItem('plm_admin_token'); function toast(message, type) { @@ -218,7 +226,7 @@ async function loadUsers() { ${u.id} ${escapeHtml(u.username)} ${escapeHtml(u.address)} - ${u.balance_sats / SATS_PER_PLM} + ${fmtPlm(u.balance_sats)} ${fmtDate(u.created_at)} @@ -288,9 +296,9 @@ async function loadRounds() { ${badge(r.status)} ${fmtDate(r.opened_at)} ${r.winner_username ? escapeHtml(r.winner_username) : '—'} - ${r.pool_amount_sats != null ? r.pool_amount_sats / SATS_PER_PLM : '—'} - ${r.winner_amount_sats != null ? r.winner_amount_sats / SATS_PER_PLM : '—'} - ${r.fee_amount_sats != null ? r.fee_amount_sats / SATS_PER_PLM : '—'} + ${fmtPlm(r.pool_amount_sats)} + ${fmtPlm(r.winner_amount_sats)} + ${fmtPlm(r.fee_amount_sats)} ${r.payout_txid ? escapeHtml(r.payout_txid) : '—'} `).join('') || 'Nessun round ancora.'; diff --git a/app/static/app.js b/app/static/app.js index 9ba8b2c..3bba472 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1,5 +1,19 @@ const SATS_PER_PLM = 100000000; +// Every amount displayed goes through here. A bare sats/SATS_PER_PLM division +// leaks binary floating-point artefacts into the UI — a 0.7 PLM jackpot rendering +// as 0.7000000000000001 (B-22). Trailing zeros are trimmed so ordinary amounts +// stay readable, and grouping follows the selected language. +// Amounts sent *to* the server must NOT use this — they keep going through +// Math.round(x * SATS_PER_PLM), since this returns a formatted string. +function formatPlm(sats) { + if (sats === null || sats === undefined || Number.isNaN(sats)) return '—'; + return new Intl.NumberFormat(currentDateLocale(), { + minimumFractionDigits: 0, + maximumFractionDigits: 8, + }).format(sats / SATS_PER_PLM); +} + let token = localStorage.getItem('plm_token'); let username = localStorage.getItem('plm_username'); let address = localStorage.getItem('plm_address'); @@ -17,17 +31,35 @@ function toast(message, type) { // , 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. +// +// Only the outermost call owns the markup. refreshMe() is fired from the SSE +// handler, the poll chain, placeBet, withdraw and showDashboard, all sharing +// #refresh-btn: two overlapping calls used to make the second one snapshot the +// *loading* label and then restore it permanently, leaving the button stuck on +// "Aggiornamento…" (B-23). A nested call now just awaits the one already running. +const _loadingByButton = new WeakMap(); + async function withLoading(button, label, fn) { + const inFlight = _loadingByButton.get(button); + if (inFlight) { + await inFlight.catch(() => {}); // its own caller reports the failure + return 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 run = (async () => { + try { + await fn(); + } finally { + button.disabled = false; + button.innerHTML = original; + applyStaticTranslations(button); // the snapshot may predate a language switch made while loading + _loadingByButton.delete(button); + } + })(); + _loadingByButton.set(button, run); + return run; } const REQUEST_TIMEOUT_MS = 15000; @@ -83,7 +115,7 @@ 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; + out[key.slice(0, -5) + '_plm'] = formatPlm(value); } } return out; @@ -292,7 +324,7 @@ function renderPersistedResult(result) { setRoundInfoVisible(false); setResultBoxVisible( true, - result.won ? t('result.win', { amount: result.amount_sats / SATS_PER_PLM }) : t('result.lose'), + result.won ? t('result.win', { amount: formatPlm(result.amount_sats) }) : t('result.lose'), result.won ? 'win' : 'lose' ); } @@ -328,7 +360,7 @@ async function checkLastRoundResult() { 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; + const won = formatPlm(data.amount_sats); toast(t('toast.roundWon', { id: data.round_id, amount: won }), 'success'); refreshMe(); } @@ -395,7 +427,7 @@ function renderBetButton() { if (btn.disabled) return; btn.textContent = betAmountSats === null ? t('bet.buttonNoAmount') - : t('bet.button', { amount: betAmountSats / SATS_PER_PLM }); + : t('bet.button', { amount: formatPlm(betAmountSats) }); } function showNormalState() { @@ -422,7 +454,7 @@ async function refreshRound() { 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; + jackpotEl.textContent = formatPlm(data.jackpot_sats); if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) { jackpotEl.classList.remove('jackpot-bump'); void jackpotEl.offsetWidth; // restart the animation @@ -464,7 +496,7 @@ async function refreshRound() { if (!alreadyKnown) { persistResult(data.round_id, won, data.winner_amount_sats); if (won) { - const wonAmount = (data.winner_amount_sats / SATS_PER_PLM); + const wonAmount = formatPlm(data.winner_amount_sats); 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 } @@ -555,6 +587,12 @@ async function register() { toast(t('toast.passwordMismatch'), 'error'); return; } + // Mirrors what the server now enforces (app/auth/routes.py's RegisterRequest), + // so the failure is immediate and translated instead of a generic 422 (B-12). + if (p.length < 8) { + toast(t('toast.passwordTooShort'), 'error'); + return; + } await withLoading(btn, t('loading.creating'), async () => { try { const data = await call('POST', '/auth/register', { username: u, password: p }); @@ -657,7 +695,7 @@ let myBalanceSats = 0; // confirmed, spendable balance — what withdrawals/bets // 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.textContent = formatPlm(pendingBalanceSats); el.classList.toggle('balance-pending', hasPending); el.classList.toggle('balance-confirmed', !hasPending); } @@ -670,14 +708,14 @@ async function refreshMe() { 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').textContent = formatPlm(data.pending_balance_sats) + ' 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; + document.getElementById('wd-full-amount-value').textContent = formatPlm(data.balance_sats); if (document.getElementById('wd-full-amount').checked) { document.getElementById('wd-amount').value = data.balance_sats / SATS_PER_PLM; } diff --git a/app/static/i18n.js b/app/static/i18n.js index 14fbdc0..d83645f 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -132,6 +132,11 @@ const TRANSLATIONS = { 'error.session_expired': 'Session expired, please log in again.', 'error.invalid_request': 'Invalid request, please check the entered data.', 'error.invalid_amount': 'Enter an amount greater than zero.', + 'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.', + 'error.amount_below_dust_limit': 'The amount is too small to be sent.', + 'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.', + 'error.internal_error': 'Unexpected server error. Please try again shortly.', + 'error.guide_unavailable': 'The guide is not available right now.', 'loading.creating': 'Creating…', 'loading.loggingIn': 'Logging in…', @@ -264,6 +269,11 @@ const TRANSLATIONS = { 'error.session_expired': 'Sessione scaduta, accedi di nuovo.', 'error.invalid_request': 'Richiesta non valida, controlla i dati inseriti.', 'error.invalid_amount': 'Inserisci un importo maggiore di zero.', + 'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.', + 'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.", + 'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.', + 'error.internal_error': 'Errore inatteso del server. Riprova tra poco.', + 'error.guide_unavailable': 'La guida non è disponibile in questo momento.', 'loading.creating': 'Creazione…', 'loading.loggingIn': 'Accesso…', @@ -396,6 +406,11 @@ const TRANSLATIONS = { 'error.session_expired': 'Sesión caducada, vuelve a iniciar sesión.', 'error.invalid_request': 'Solicitud no válida, revisa los datos introducidos.', 'error.invalid_amount': 'Introduce un importe mayor que cero.', + 'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.', + 'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.', + 'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.', + 'error.internal_error': 'Error inesperado del servidor. Inténtalo de nuevo en un momento.', + 'error.guide_unavailable': 'La guía no está disponible en este momento.', 'loading.creating': 'Creando…', 'loading.loggingIn': 'Entrando…', @@ -528,6 +543,11 @@ const TRANSLATIONS = { 'error.session_expired': 'Session expirée, veuillez vous reconnecter.', 'error.invalid_request': 'Requête invalide, vérifiez les données saisies.', 'error.invalid_amount': 'Saisissez un montant supérieur à zéro.', + 'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.', + 'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.", + 'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.", + 'error.internal_error': 'Erreur inattendue du serveur. Veuillez réessayer dans un instant.', + 'error.guide_unavailable': "Le guide n'est pas disponible pour le moment.", 'loading.creating': 'Création…', 'loading.loggingIn': 'Connexion…', @@ -660,6 +680,11 @@ const TRANSLATIONS = { 'error.session_expired': 'Sitzung abgelaufen, bitte melde dich erneut an.', 'error.invalid_request': 'Ungültige Anfrage, bitte überprüfe die eingegebenen Daten.', 'error.invalid_amount': 'Gib einen Betrag größer als null ein.', + 'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.', + 'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.', + 'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.', + 'error.internal_error': 'Unerwarteter Serverfehler. Bitte versuche es in Kürze erneut.', + 'error.guide_unavailable': 'Die Anleitung ist derzeit nicht verfügbar.', 'loading.creating': 'Wird erstellt…', 'loading.loggingIn': 'Anmeldung…', @@ -792,6 +817,11 @@ const TRANSLATIONS = { 'error.session_expired': 'Сессия истекла, войдите снова.', 'error.invalid_request': 'Некорректный запрос, проверьте введённые данные.', 'error.invalid_amount': 'Введите сумму больше нуля.', + 'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.', + 'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.', + 'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.', + 'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.', + 'error.guide_unavailable': 'Руководство сейчас недоступно.', 'loading.creating': 'Создание…', 'loading.loggingIn': 'Вход…', @@ -924,6 +954,11 @@ const TRANSLATIONS = { 'error.session_expired': '会话已过期,请重新登录。', 'error.invalid_request': '请求无效,请检查填写的内容。', 'error.invalid_amount': '请输入大于零的金额。', + 'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。', + 'error.amount_below_dust_limit': '金额过小,无法发送。', + 'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。', + 'error.internal_error': '服务器发生意外错误,请稍后重试。', + 'error.guide_unavailable': '指南当前不可用。', 'loading.creating': '正在创建…', 'loading.loggingIn': '正在登录…', diff --git a/app/static/index.html b/app/static/index.html index 28a55e1..ceb9b39 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -131,11 +131,11 @@
- + - + - +