Format amounts, guard the loading state, and translate the new errors

Amounts were rendered by bare sats/SATS_PER_PLM division, so binary
floating-point artefacts reached the UI — a 0.7 PLM jackpot could display as
0.7000000000000001 (B-22). formatPlm() in app.js and fmtPlm() in admin.js route
every display site through Intl.NumberFormat with the already-resolved language.
Input fields deliberately keep the raw value: a grouped, localized string would
break parseFloat, and amounts sent to the server still go through
Math.round(x * SATS_PER_PLM).

withLoading kept a snapshot of the button's markup and restored it in finally,
but refreshMe() is fired from the SSE handler, the poll chain, placeBet, withdraw
and showDashboard, all sharing #refresh-btn. Two overlapping calls made the second
snapshot the *loading* label and then restore it permanently, leaving the button
stuck on "Aggiornamento…" (B-23). The in-flight promise now lives in a WeakMap
keyed by the button, so a nested call awaits the existing one and only the
outermost call touches the markup.

The registration form mirrors the constraints the server now enforces
(minlength/pattern/required) and register() pre-checks the password length, so the
failure is immediate and translated instead of a generic 422 (B-12).

Five new error codes are translated in all 7 languages — broadcast_failed,
amount_below_dust_limit, withdrawal_to_own_address, internal_error,
guide_unavailable — keeping the key sets identical, as the i18n contract in
CLAUDE.md requires (verified: 123 keys per language).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:35:14 +02:00
co-authored by Claude Opus 5
parent 7c4e9983ea
commit dc4d5761df
4 changed files with 104 additions and 23 deletions
+12 -4
View File
@@ -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() {
<td>${u.id}</td>
<td>${escapeHtml(u.username)}</td>
<td class="addr">${escapeHtml(u.address)}</td>
<td>${u.balance_sats / SATS_PER_PLM}</td>
<td>${fmtPlm(u.balance_sats)}</td>
<td>${fmtDate(u.created_at)}</td>
<td>
<button class="reveal" onclick="revealPrivkey(${u.id}, this)">Mostra</button>
@@ -288,9 +296,9 @@ async function loadRounds() {
<td>${badge(r.status)}</td>
<td>${fmtDate(r.opened_at)}</td>
<td>${r.winner_username ? escapeHtml(r.winner_username) : '—'}</td>
<td>${r.pool_amount_sats != null ? r.pool_amount_sats / SATS_PER_PLM : '—'}</td>
<td>${r.winner_amount_sats != null ? r.winner_amount_sats / SATS_PER_PLM : '—'}</td>
<td>${r.fee_amount_sats != null ? r.fee_amount_sats / SATS_PER_PLM : '—'}</td>
<td>${fmtPlm(r.pool_amount_sats)}</td>
<td>${fmtPlm(r.winner_amount_sats)}</td>
<td>${fmtPlm(r.fee_amount_sats)}</td>
<td class="txid">${r.payout_txid ? escapeHtml(r.payout_txid) : '—'}</td>
</tr>
`).join('') || '<tr><td colspan="8" class="hint">Nessun round ancora.</td></tr>';
+54 -16
View File
@@ -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) {
// <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.
//
// 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;
}
+35
View File
@@ -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': '正在登录…',
+3 -3
View File
@@ -131,11 +131,11 @@
<div class="tab-panel" id="panel-register">
<label for="reg-username" data-i18n="auth.username">Username</label>
<input id="reg-username" autocomplete="username">
<input id="reg-username" autocomplete="username" minlength="3" maxlength="32" pattern="[A-Za-z0-9_.\-]+" required>
<label for="reg-password" data-i18n="auth.password">Password</label>
<input id="reg-password" type="password" autocomplete="new-password">
<input id="reg-password" type="password" autocomplete="new-password" minlength="8" required>
<label for="reg-password-confirm" data-i18n="auth.passwordConfirm">Conferma password</label>
<input id="reg-password-confirm" type="password" autocomplete="new-password">
<input id="reg-password-confirm" type="password" autocomplete="new-password" minlength="8" required>
<button onclick="register()" id="register-btn" data-i18n="auth.registerBtn">Crea account</button>
</div>
</section>