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:
+12
-4
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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': '正在登录…',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user