';
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 @@