Translate the user-facing dashboard into 7 languages
Adds app/static/i18n.js: a flat key -> string table for en/it/es/fr/de/ru/zh, loaded before app.js so t() is available everywhere. No build step and no fetch, consistent with the rest of these static pages. Language comes from localStorage, then navigator.language, then en. Static markup is translated by attribute (data-i18n and its -html/-placeholder/ -title/-aria-label/-alt variants); anything rendered from server data goes through t() in app.js and is re-rendered by onLanguageChange(). An element belongs to one mechanism or the other, never both, or the two overwrite each other — which is why #bet-btn has no data-i18n: its label carries the admin-configurable bet amount, so renderBetButton() owns it and reads the amount from /rounds/current instead of hardcoding "10 PLM" in seven files. The switcher sits in the chain-bar rather than the navbar because the navbar is hidden until login, which would leave the landing page and the login form untranslatable for exactly the users who need to switch. It uses language names rather than flag emoji: flags don't render on every platform and don't map one-to-one onto languages. withLoading now snapshots innerHTML instead of textContent — several of these buttons wrap an <svg> plus a <span data-i18n>, both of which a textContent round-trip flattened away, permanently losing the icon and the translation hook. It re-applies translations to the restored subtree in case the language changed while the request was in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+85
-44
@@ -13,15 +13,20 @@ function toast(message, type) {
|
||||
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.textContent;
|
||||
const original = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.textContent = label;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = original;
|
||||
button.innerHTML = original;
|
||||
applyStaticTranslations(button); // the snapshot may predate a language switch made while loading
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +46,7 @@ async function call(method, path, body) {
|
||||
try {
|
||||
res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal });
|
||||
} catch (e) {
|
||||
throw new Error(e.name === 'AbortError' ? 'Richiesta al server scaduta.' : e.message);
|
||||
throw new Error(e.name === 'AbortError' ? t('toast.requestTimeout') : e.message);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
@@ -83,11 +88,11 @@ let roundTimerInterval = null;
|
||||
let roundPollTimeout = null;
|
||||
let lastResultInterval = null;
|
||||
|
||||
const ROUND_STATUS_LABELS = {
|
||||
open: 'aperto',
|
||||
closing: 'in chiusura',
|
||||
drawing: 'estrazione in corso',
|
||||
paying_out: 'pagamento al vincitore in corso',
|
||||
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'];
|
||||
@@ -97,27 +102,27 @@ const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out'];
|
||||
// takes the round data so the drawing phase can surface the draw block once known.
|
||||
function drawingLabelFor(data) {
|
||||
if (data.status === 'closing') {
|
||||
return 'Round chiuso — in attesa di conferma dell\'ultima giocata prima di estrarre il vincitore…';
|
||||
return t('draw.closing');
|
||||
}
|
||||
if (data.status === 'drawing') {
|
||||
return 'In attesa del prossimo blocco per estrarre il vincitore…';
|
||||
return t('draw.drawing');
|
||||
}
|
||||
// paying_out
|
||||
if (data.draw_block_height != null) {
|
||||
return 'Vincitore estratto dal blocco #' + data.draw_block_height + ' — pagamento al vincitore in corso…';
|
||||
return t('draw.payingOutBlock', { height: data.draw_block_height });
|
||||
}
|
||||
return 'Vincitore estratto — pagamento al vincitore in corso…';
|
||||
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_LABELS = {
|
||||
waiting: 'In attesa del prossimo round',
|
||||
open: 'Round aperto',
|
||||
closing: 'Round chiuso — attesa conferma puntate',
|
||||
drawing: 'Estrazione in corso',
|
||||
paying_out: 'Pagamento al vincitore in corso',
|
||||
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) {
|
||||
@@ -133,11 +138,11 @@ function updateChainStatusBar(data) {
|
||||
else if (DRAWING_STATUSES.includes(data.status)) dotKey = 'drawing';
|
||||
else dotKey = 'open';
|
||||
|
||||
const labelKey = data.round_id && data.status in CHAIN_STATUS_LABELS ? data.status : 'waiting';
|
||||
const labelKey = data.round_id && data.status in CHAIN_STATUS_KEYS ? data.status : 'waiting';
|
||||
|
||||
dot.className = 'status-dot status-' + dotKey;
|
||||
label.textContent = CHAIN_STATUS_LABELS[labelKey];
|
||||
block.textContent = 'Blocco ' + (data.chain_tip_height != null ? '#' + data.chain_tip_height : '—');
|
||||
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);
|
||||
}
|
||||
@@ -151,7 +156,7 @@ let consecutiveFetchFailures = 0;
|
||||
|
||||
function showConnectionLost() {
|
||||
document.getElementById('chain-status-dot').className = 'status-dot status-offline';
|
||||
document.getElementById('chain-status-label').textContent = 'Connessione al server persa — riprovo…';
|
||||
document.getElementById('chain-status-label').textContent = t('chain.connectionLost');
|
||||
}
|
||||
|
||||
function noteFetchOutcome(ok) {
|
||||
@@ -228,7 +233,7 @@ function renderPersistedResult(result) {
|
||||
setRoundInfoVisible(false);
|
||||
setResultBoxVisible(
|
||||
true,
|
||||
result.won ? '🎉 Hai vinto! +' + (result.amount_sats / SATS_PER_PLM) + ' PLM' : 'Non hai vinto questa volta.',
|
||||
result.won ? t('result.win', { amount: result.amount_sats / SATS_PER_PLM }) : t('result.lose'),
|
||||
result.won ? 'win' : 'lose'
|
||||
);
|
||||
}
|
||||
@@ -265,7 +270,7 @@ async function checkLastRoundResult() {
|
||||
renderPersistedResult({ won: data.won, amount_sats: data.amount_sats });
|
||||
if (data.won) {
|
||||
const won = data.amount_sats / SATS_PER_PLM;
|
||||
toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success');
|
||||
toast(t('toast.roundWon', { id: data.round_id, amount: won }), 'success');
|
||||
refreshMe();
|
||||
}
|
||||
}
|
||||
@@ -319,6 +324,21 @@ function setResultBoxVisible(show, html, cls) {
|
||||
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);
|
||||
@@ -336,8 +356,10 @@ async function refreshRound() {
|
||||
noteFetchOutcome(true);
|
||||
updateChainStatusBar(data);
|
||||
document.getElementById('round-title').textContent = data.round_id
|
||||
? 'Round #' + data.round_id + ' — ' + (ROUND_STATUS_LABELS[data.status] || data.status)
|
||||
: 'Nessun round attivo';
|
||||
? 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;
|
||||
@@ -384,7 +406,7 @@ async function refreshRound() {
|
||||
persistResult(data.round_id, won, data.winner_amount_sats);
|
||||
if (won) {
|
||||
const wonAmount = (data.winner_amount_sats / SATS_PER_PLM);
|
||||
toast('Hai vinto il round #' + data.round_id + '! +' + wonAmount + ' PLM', 'success');
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -471,14 +493,14 @@ async function register() {
|
||||
const p = document.getElementById('reg-password').value;
|
||||
const pConfirm = document.getElementById('reg-password-confirm').value;
|
||||
if (p !== pConfirm) {
|
||||
toast('Le password non coincidono.', 'error');
|
||||
toast(t('toast.passwordMismatch'), 'error');
|
||||
return;
|
||||
}
|
||||
await withLoading(btn, 'Creazione…', async () => {
|
||||
await withLoading(btn, t('loading.creating'), async () => {
|
||||
try {
|
||||
const data = await call('POST', '/auth/register', { username: u, password: p });
|
||||
persistSession(data, u);
|
||||
toast('Account creato.', 'success');
|
||||
toast(t('toast.accountCreated'), 'success');
|
||||
showDashboard();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
@@ -490,11 +512,11 @@ 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, 'Accesso…', async () => {
|
||||
await withLoading(btn, t('loading.loggingIn'), async () => {
|
||||
try {
|
||||
const data = await call('POST', '/auth/login', { username: u, password: p });
|
||||
persistSession(data, u);
|
||||
toast('Accesso riuscito.', 'success');
|
||||
toast(t('toast.loginSuccess'), 'success');
|
||||
showDashboard();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
@@ -555,9 +577,9 @@ function initAuthState() {
|
||||
async function copyAddress() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
toast('Indirizzo copiato.', 'success');
|
||||
toast(t('toast.addressCopied'), 'success');
|
||||
} catch (e) {
|
||||
toast('Impossibile copiare automaticamente.', 'error');
|
||||
toast(t('toast.copyFailed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,7 +600,7 @@ function setBalanceDisplay(elementId, pendingBalanceSats, hasPending) {
|
||||
|
||||
async function refreshMe() {
|
||||
const btn = document.getElementById('refresh-btn');
|
||||
await withLoading(btn, '…', async () => {
|
||||
await withLoading(btn, t('loading.refreshing'), async () => {
|
||||
try {
|
||||
const data = await call('GET', '/users/me');
|
||||
myUserId = data.id;
|
||||
@@ -590,7 +612,7 @@ async function refreshMe() {
|
||||
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('it-IT');
|
||||
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;
|
||||
@@ -615,15 +637,15 @@ async function changePassword() {
|
||||
const newPasswordConfirm = document.getElementById('settings-new-password-confirm').value;
|
||||
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
toast('Le nuove password non coincidono.', 'error');
|
||||
toast(t('toast.newPasswordMismatch'), 'error');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast('La nuova password deve avere almeno 8 caratteri.', 'error');
|
||||
toast(t('toast.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await withLoading(btn, 'Aggiornamento…', async () => {
|
||||
await withLoading(btn, t('loading.updating'), async () => {
|
||||
try {
|
||||
await call('POST', '/users/me/change-password', {
|
||||
current_password: currentPassword,
|
||||
@@ -632,7 +654,7 @@ async function changePassword() {
|
||||
document.getElementById('settings-current-password').value = '';
|
||||
document.getElementById('settings-new-password').value = '';
|
||||
document.getElementById('settings-new-password-confirm').value = '';
|
||||
toast('Password aggiornata.', 'success');
|
||||
toast(t('toast.passwordUpdated'), 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
@@ -641,10 +663,10 @@ async function changePassword() {
|
||||
|
||||
async function placeBet() {
|
||||
const btn = document.getElementById('bet-btn');
|
||||
await withLoading(btn, 'Invio bet…', async () => {
|
||||
await withLoading(btn, t('loading.sendingBet'), async () => {
|
||||
try {
|
||||
const data = await call('POST', '/bets', {});
|
||||
toast('Bet piazzata sul round #' + data.round_id + '.', 'success');
|
||||
toast(t('toast.betPlaced', { id: data.round_id }), 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
@@ -660,10 +682,10 @@ async function withdraw() {
|
||||
const amtSats = isFullAmount
|
||||
? myBalanceSats
|
||||
: Math.round(parseFloat(document.getElementById('wd-amount').value) * SATS_PER_PLM);
|
||||
await withLoading(btn, 'Invio…', async () => {
|
||||
await withLoading(btn, t('loading.sending'), async () => {
|
||||
try {
|
||||
await call('POST', '/withdrawals', { external_address: ext, amount_sats: amtSats });
|
||||
toast('Withdrawal inviato.', 'success');
|
||||
toast(t('toast.withdrawSent'), 'success');
|
||||
document.getElementById('wd-full-amount').checked = false;
|
||||
toggleWithdrawFullAmount();
|
||||
document.getElementById('wd-amount').value = '';
|
||||
@@ -706,5 +728,24 @@ function connectRoundEvents() {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user