diff --git a/CLAUDE.md b/CLAUDE.md index 45960cb..862f578 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,14 @@ PLAY and WITHDRAW share a **per-user DB lock**: a user can never have a bet-buil So worst case (last bet confirms right at the deadline) is ~3 block times end-to-end; best case (all bets already confirmed before the timer hit zero) is ~2 (draw block + payout block). At PLM's 120s block time that's roughly 4–6 minutes worst case, 2–4 minutes best case — independent of `draw_animation_seconds`, which only sets a cosmetic minimum for the frontend animation. +## Internationalization (user-facing page only) + +`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is available everywhere. Language comes from `localStorage.plm_lang`, falling back to `navigator.language`, falling back to `en`; the switcher lives in the **chain-bar, not the navbar**, deliberately — the navbar is hidden until login, which would leave the landing page and the login form untranslatable for exactly the users who need the switch. + +- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`), applied by `applyStaticTranslations(root?)` on `DOMContentLoaded` and on every switch. Anything rendered from server data is built with `t()` in `app.js` instead, and re-rendered by `onLanguageChange()` — an element must be in one camp or the other, never both, or the two mechanisms overwrite each other (this is why `#bet-btn` has no `data-i18n`: its label carries the admin-configurable bet amount, so `renderBetButton()` owns it). +- **Every language must have exactly the same key set.** There is no fallback beyond `en`, and a missing key renders as the raw key string. +- `/admin` is intentionally **not** translated (operator-facing, Italian only), and neither is `/guida` (serves `docs/guida-utente.md`). + ## Admin dashboard and test UI Two static single-page apps, served directly by FastAPI (`app/main.py` mounts `app/static/` and adds a dedicated `GET /admin` route) — no build step, no framework. Each page's HTML/CSS/JS are separate files (`index.html`/`style.css`/`app.js`, `admin.html`/`admin.css`/`admin.js`), served as plain static files (no bundler): diff --git a/app/static/app.js b/app/static/app.js index 5a24263..00ccf22 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -13,15 +13,20 @@ function toast(message, type) { setTimeout(() => el.remove(), 4000); } +// innerHTML, not textContent: several of these buttons wrap an icon and a +// , 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(); diff --git a/app/static/i18n.js b/app/static/i18n.js new file mode 100644 index 0000000..db0b8f5 --- /dev/null +++ b/app/static/i18n.js @@ -0,0 +1,869 @@ +const SUPPORTED_LANGS = ['en', 'it', 'es', 'fr', 'de', 'ru', 'zh']; +const LANG_STORAGE_KEY = 'plm_lang'; + +// date-locale used for toLocaleDateString, keyed by the same language codes +const DATE_LOCALES = { en: 'en-US', it: 'it-IT', es: 'es-ES', fr: 'fr-FR', de: 'de-DE', ru: 'ru-RU', zh: 'zh-CN' }; + +const TRANSLATIONS = { + en: { + 'nav.ariaSections': 'Sections', + 'nav.guideTitle': 'Guide', + 'nav.guideAria': 'Open the user guide', + 'nav.bugReport': 'Report a bug', + 'nav.logoutTitle': 'Log out', + 'nav.logoutAria': 'Log out of your account', + 'nav.deposit': 'Deposit', + 'nav.bet': 'Bet', + 'nav.withdraw': 'Withdraw', + 'nav.profile': 'Profile', + + 'chain.connecting': 'Connecting…', + 'chain.block': 'Block {n}', + 'chain.connectionLost': 'Connection to server lost — retrying…', + 'maintenance.banner': 'Scheduled maintenance: the current round completes normally (winner included), but the next round will not open until maintenance ends.', + + 'hero.lead': 'Deposit PLM, join the round with a fixed entry fee, and if your number is drawn you win the jackpot.', + 'hero.step1.title': '1. Deposit', + 'hero.step1.hint': 'Get your own personal PLM address, yours forever', + 'hero.step2.title': '2. Play', + 'hero.step2.hint': 'A fixed-cost bet to enter the current round', + 'hero.step3.title': '3. Win', + 'hero.step3.hint': 'Drawn from a block hash, jackpot credited instantly', + 'trust.fixedRate': 'Declared fixed entry fee', + 'trust.blockHash': 'Drawn from a block hash', + 'trust.freeWithdraw': 'Withdraw freely at any time', + + 'auth.tabLogin': 'Login', + 'auth.tabRegister': 'Register', + 'auth.username': 'Username', + 'auth.password': 'Password', + 'auth.passwordConfirm': 'Confirm password', + 'auth.loginBtn': 'Log in', + 'auth.registerBtn': 'Create account', + + 'round.players': 'Players', + 'round.jackpot': 'Jackpot', + 'round.status.open': 'open', + 'round.status.closing': 'closing', + 'round.status.drawing': 'drawing in progress', + 'round.status.paying_out': 'paying out the winner', + 'round.title': 'Round #{id} — {status}', + 'round.none': 'No active round', + 'chain.status.waiting': 'Waiting for the next round', + 'chain.status.open': 'Round open', + 'chain.status.closing': 'Round closed — waiting for bet confirmations', + 'chain.status.drawing': 'Drawing in progress', + 'chain.status.paying_out': 'Paying out the winner', + + 'draw.defaultLabel': 'Drawing the winner…', + 'draw.closing': 'Round closed — waiting for the last bet to confirm before drawing the winner…', + 'draw.drawing': 'Waiting for the next block to draw the winner…', + 'draw.payingOutBlock': 'Winner drawn from block #{height} — paying out the winner…', + 'draw.payingOut': 'Winner drawn — paying out the winner…', + 'result.win': '🎉 You won! +{amount} PLM', + 'result.lose': 'Not a win this time.', + + 'deposit.balanceTitle': 'Internal balance', + 'deposit.balanceHint': 'Updated after 1 network confirmation', + 'deposit.refreshBtn': 'Refresh', + 'deposit.refreshAria': 'Refresh balance', + 'deposit.addressTitle': 'Deposit address', + 'deposit.addressHint': 'This also receives any winnings', + 'deposit.copyAria': 'Copy address', + 'deposit.qrAlt': 'QR code of the deposit address', + + 'bet.title': 'Bet', + 'bet.hint': 'Fixed entry to the current round', + 'bet.button': 'Place bet ({amount} PLM)', + 'bet.buttonNoAmount': 'Place bet', + + 'withdraw.title': 'Withdrawal', + 'withdraw.hint': 'Send funds to an external PLM address', + 'withdraw.addressLabel': 'External address', + 'withdraw.addressHint': 'Only P2WPKH bech32 addresses (starting with plm1q...). Legacy (P...) or P2SH addresses are not supported.', + 'withdraw.amountLabel': 'Amount (PLM)', + 'withdraw.amountPlaceholder': 'e.g. 2', + 'withdraw.fullAmountPrefix': 'Withdraw the full amount (', + 'withdraw.fullAmountSuffix': ' PLM)', + 'withdraw.button': 'Withdraw', + + 'profile.title': 'Profile', + 'profile.hint': 'Your account information', + 'profile.usernameLabel': 'Username', + 'profile.addressLabel': 'Deposit address', + 'profile.balanceLabel': 'Internal balance', + 'profile.createdLabel': 'User since', + 'settings.title': 'Settings', + 'settings.hint': "Change your account's password", + 'settings.currentPasswordLabel': 'Current password', + 'settings.newPasswordLabel': 'New password', + 'settings.newPasswordConfirmLabel': 'Confirm new password', + 'settings.updateBtn': 'Update password', + + 'toast.passwordMismatch': 'Passwords do not match.', + 'toast.accountCreated': 'Account created.', + 'toast.loginSuccess': 'Logged in successfully.', + 'toast.requestTimeout': 'Request to the server timed out.', + 'toast.addressCopied': 'Address copied.', + 'toast.copyFailed': "Couldn't copy automatically.", + 'toast.newPasswordMismatch': 'The new passwords do not match.', + 'toast.passwordTooShort': 'The new password must be at least 8 characters.', + 'toast.passwordUpdated': 'Password updated.', + 'toast.betPlaced': 'Bet placed on round #{id}.', + 'toast.roundWon': 'You won round #{id}! +{amount} PLM', + 'toast.withdrawSent': 'Withdrawal sent.', + + 'loading.creating': 'Creating…', + 'loading.loggingIn': 'Logging in…', + 'loading.sendingBet': 'Placing bet…', + 'loading.updating': 'Updating…', + 'loading.refreshing': '…', + 'loading.sending': 'Sending…', + }, + it: { + 'nav.ariaSections': 'Sezioni', + 'nav.guideTitle': 'Guida', + 'nav.guideAria': 'Apri la guida utente', + 'nav.bugReport': 'Segnala un bug', + 'nav.logoutTitle': 'Esci', + 'nav.logoutAria': "Esci dall'account", + 'nav.deposit': 'Deposito', + 'nav.bet': 'Bet', + 'nav.withdraw': 'Prelievo', + 'nav.profile': 'Profilo', + + 'chain.connecting': 'Connessione…', + 'chain.block': 'Blocco {n}', + 'chain.connectionLost': 'Connessione al server persa — riprovo…', + 'maintenance.banner': 'Manutenzione in programma: il round in corso viene completato regolarmente (vincitore incluso), ma il round successivo non si aprirà finché la manutenzione non sarà terminata.', + + 'hero.lead': 'Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.', + 'hero.step1.title': '1. Deposita', + 'hero.step1.hint': 'Ricevi un indirizzo PLM personale, tuo per sempre', + 'hero.step2.title': '2. Gioca', + 'hero.step2.hint': 'Una bet a quota fissa per entrare nel round corrente', + 'hero.step3.title': '3. Vinci', + 'hero.step3.hint': 'Estrazione dal blocco, montepremi accreditato subito', + 'trust.fixedRate': 'Quota fissa dichiarata', + 'trust.blockHash': 'Estrazione da hash di blocco', + 'trust.freeWithdraw': 'Prelievo libero in ogni momento', + + 'auth.tabLogin': 'Login', + 'auth.tabRegister': 'Registrati', + 'auth.username': 'Username', + 'auth.password': 'Password', + 'auth.passwordConfirm': 'Conferma password', + 'auth.loginBtn': 'Accedi', + 'auth.registerBtn': 'Crea account', + + 'round.players': 'Giocatori', + 'round.jackpot': 'Jackpot', + 'round.status.open': 'aperto', + 'round.status.closing': 'in chiusura', + 'round.status.drawing': 'estrazione in corso', + 'round.status.paying_out': 'pagamento al vincitore in corso', + 'round.title': 'Round #{id} — {status}', + 'round.none': 'Nessun round attivo', + 'chain.status.waiting': 'In attesa del prossimo round', + 'chain.status.open': 'Round aperto', + 'chain.status.closing': 'Round chiuso — attesa conferma puntate', + 'chain.status.drawing': 'Estrazione in corso', + 'chain.status.paying_out': 'Pagamento al vincitore in corso', + + 'draw.defaultLabel': 'Estrazione del vincitore in corso…', + 'draw.closing': "Round chiuso — in attesa di conferma dell'ultima giocata prima di estrarre il vincitore…", + 'draw.drawing': 'In attesa del prossimo blocco per estrarre il vincitore…', + 'draw.payingOutBlock': 'Vincitore estratto dal blocco #{height} — pagamento al vincitore in corso…', + 'draw.payingOut': 'Vincitore estratto — pagamento al vincitore in corso…', + 'result.win': '🎉 Hai vinto! +{amount} PLM', + 'result.lose': 'Non hai vinto questa volta.', + + 'deposit.balanceTitle': 'Saldo interno', + 'deposit.balanceHint': 'Aggiornato dopo 1 conferma sulla rete', + 'deposit.refreshBtn': 'Aggiorna', + 'deposit.refreshAria': 'Aggiorna saldo', + 'deposit.addressTitle': 'Indirizzo di deposito', + 'deposit.addressHint': "È anche l'indirizzo su cui ricevi eventuali vincite", + 'deposit.copyAria': 'Copia indirizzo', + 'deposit.qrAlt': "QR code dell'indirizzo di deposito", + + 'bet.title': 'Bet', + 'bet.hint': 'Ingresso fisso al round corrente', + 'bet.button': 'Piazza bet ({amount} PLM)', + 'bet.buttonNoAmount': 'Piazza bet', + + 'withdraw.title': 'Withdrawal', + 'withdraw.hint': 'Invia fondi a un indirizzo PLM esterno', + 'withdraw.addressLabel': 'Indirizzo esterno', + 'withdraw.addressHint': 'Solo indirizzi P2WPKH bech32 (quelli che iniziano con plm1q...). Indirizzi legacy (P...) o P2SH non sono supportati.', + 'withdraw.amountLabel': 'Importo (PLM)', + 'withdraw.amountPlaceholder': 'es. 2', + 'withdraw.fullAmountPrefix': "Preleva l'intero importo (", + 'withdraw.fullAmountSuffix': ' PLM)', + 'withdraw.button': 'Preleva', + + 'profile.title': 'Profilo', + 'profile.hint': 'Le tue informazioni account', + 'profile.usernameLabel': 'Username', + 'profile.addressLabel': 'Indirizzo di deposito', + 'profile.balanceLabel': 'Saldo interno', + 'profile.createdLabel': 'Utente dal', + 'settings.title': 'Impostazioni', + 'settings.hint': 'Cambia la password del tuo account', + 'settings.currentPasswordLabel': 'Password attuale', + 'settings.newPasswordLabel': 'Nuova password', + 'settings.newPasswordConfirmLabel': 'Conferma nuova password', + 'settings.updateBtn': 'Aggiorna password', + + 'toast.passwordMismatch': 'Le password non coincidono.', + 'toast.accountCreated': 'Account creato.', + 'toast.loginSuccess': 'Accesso riuscito.', + 'toast.requestTimeout': 'Richiesta al server scaduta.', + 'toast.addressCopied': 'Indirizzo copiato.', + 'toast.copyFailed': 'Impossibile copiare automaticamente.', + 'toast.newPasswordMismatch': 'Le nuove password non coincidono.', + 'toast.passwordTooShort': 'La nuova password deve avere almeno 8 caratteri.', + 'toast.passwordUpdated': 'Password aggiornata.', + 'toast.betPlaced': 'Bet piazzata sul round #{id}.', + 'toast.roundWon': 'Hai vinto il round #{id}! +{amount} PLM', + 'toast.withdrawSent': 'Withdrawal inviato.', + + 'loading.creating': 'Creazione…', + 'loading.loggingIn': 'Accesso…', + 'loading.sendingBet': 'Invio bet…', + 'loading.updating': 'Aggiornamento…', + 'loading.refreshing': '…', + 'loading.sending': 'Invio…', + }, + es: { + 'nav.ariaSections': 'Secciones', + 'nav.guideTitle': 'Guía', + 'nav.guideAria': 'Abrir la guía del usuario', + 'nav.bugReport': 'Reportar un error', + 'nav.logoutTitle': 'Salir', + 'nav.logoutAria': 'Cerrar sesión', + 'nav.deposit': 'Depósito', + 'nav.bet': 'Apuesta', + 'nav.withdraw': 'Retiro', + 'nav.profile': 'Perfil', + + 'chain.connecting': 'Conectando…', + 'chain.block': 'Bloque {n}', + 'chain.connectionLost': 'Conexión con el servidor perdida — reintentando…', + 'maintenance.banner': 'Mantenimiento programado: la ronda actual se completa con normalidad (ganador incluido), pero la siguiente ronda no se abrirá hasta que finalice el mantenimiento.', + + 'hero.lead': 'Deposita PLM, únete a la ronda con una cuota fija de entrada, y si sale tu número ganas el bote.', + 'hero.step1.title': '1. Deposita', + 'hero.step1.hint': 'Recibe tu dirección PLM personal, tuya para siempre', + 'hero.step2.title': '2. Juega', + 'hero.step2.hint': 'Una apuesta a cuota fija para entrar en la ronda actual', + 'hero.step3.title': '3. Gana', + 'hero.step3.hint': 'Sorteo a partir del hash de un bloque, bote acreditado al instante', + 'trust.fixedRate': 'Cuota de entrada fija y declarada', + 'trust.blockHash': 'Sorteo a partir del hash de un bloque', + 'trust.freeWithdraw': 'Retiro libre en cualquier momento', + + 'auth.tabLogin': 'Iniciar sesión', + 'auth.tabRegister': 'Registrarse', + 'auth.username': 'Usuario', + 'auth.password': 'Contraseña', + 'auth.passwordConfirm': 'Confirmar contraseña', + 'auth.loginBtn': 'Entrar', + 'auth.registerBtn': 'Crear cuenta', + + 'round.players': 'Jugadores', + 'round.jackpot': 'Bote', + 'round.status.open': 'abierta', + 'round.status.closing': 'cerrando', + 'round.status.drawing': 'sorteo en curso', + 'round.status.paying_out': 'pagando al ganador', + 'round.title': 'Ronda #{id} — {status}', + 'round.none': 'No hay ninguna ronda activa', + 'chain.status.waiting': 'Esperando la próxima ronda', + 'chain.status.open': 'Ronda abierta', + 'chain.status.closing': 'Ronda cerrada — esperando confirmación de apuestas', + 'chain.status.drawing': 'Sorteo en curso', + 'chain.status.paying_out': 'Pagando al ganador', + + 'draw.defaultLabel': 'Sorteando al ganador…', + 'draw.closing': 'Ronda cerrada — esperando la confirmación de la última apuesta antes de sortear al ganador…', + 'draw.drawing': 'Esperando el próximo bloque para sortear al ganador…', + 'draw.payingOutBlock': 'Ganador sorteado en el bloque #{height} — pagando al ganador…', + 'draw.payingOut': 'Ganador sorteado — pagando al ganador…', + 'result.win': '🎉 ¡Has ganado! +{amount} PLM', + 'result.lose': 'Esta vez no has ganado.', + + 'deposit.balanceTitle': 'Saldo interno', + 'deposit.balanceHint': 'Actualizado tras 1 confirmación en la red', + 'deposit.refreshBtn': 'Actualizar', + 'deposit.refreshAria': 'Actualizar saldo', + 'deposit.addressTitle': 'Dirección de depósito', + 'deposit.addressHint': 'También es la dirección donde recibes tus posibles ganancias', + 'deposit.copyAria': 'Copiar dirección', + 'deposit.qrAlt': 'Código QR de la dirección de depósito', + + 'bet.title': 'Apuesta', + 'bet.hint': 'Entrada fija a la ronda actual', + 'bet.button': 'Realizar apuesta ({amount} PLM)', + 'bet.buttonNoAmount': 'Realizar apuesta', + + 'withdraw.title': 'Retiro', + 'withdraw.hint': 'Envía fondos a una dirección PLM externa', + 'withdraw.addressLabel': 'Dirección externa', + 'withdraw.addressHint': 'Solo direcciones P2WPKH bech32 (las que empiezan con plm1q...). No se admiten direcciones legacy (P...) ni P2SH.', + 'withdraw.amountLabel': 'Importe (PLM)', + 'withdraw.amountPlaceholder': 'p.ej. 2', + 'withdraw.fullAmountPrefix': 'Retirar el importe completo (', + 'withdraw.fullAmountSuffix': ' PLM)', + 'withdraw.button': 'Retirar', + + 'profile.title': 'Perfil', + 'profile.hint': 'La información de tu cuenta', + 'profile.usernameLabel': 'Usuario', + 'profile.addressLabel': 'Dirección de depósito', + 'profile.balanceLabel': 'Saldo interno', + 'profile.createdLabel': 'Usuario desde', + 'settings.title': 'Ajustes', + 'settings.hint': 'Cambia la contraseña de tu cuenta', + 'settings.currentPasswordLabel': 'Contraseña actual', + 'settings.newPasswordLabel': 'Nueva contraseña', + 'settings.newPasswordConfirmLabel': 'Confirmar nueva contraseña', + 'settings.updateBtn': 'Actualizar contraseña', + + 'toast.passwordMismatch': 'Las contraseñas no coinciden.', + 'toast.accountCreated': 'Cuenta creada.', + 'toast.loginSuccess': 'Sesión iniciada correctamente.', + 'toast.requestTimeout': 'La solicitud al servidor ha caducado.', + 'toast.addressCopied': 'Dirección copiada.', + 'toast.copyFailed': 'No se pudo copiar automáticamente.', + 'toast.newPasswordMismatch': 'Las nuevas contraseñas no coinciden.', + 'toast.passwordTooShort': 'La nueva contraseña debe tener al menos 8 caracteres.', + 'toast.passwordUpdated': 'Contraseña actualizada.', + 'toast.betPlaced': 'Apuesta realizada en la ronda #{id}.', + 'toast.roundWon': '¡Has ganado la ronda #{id}! +{amount} PLM', + 'toast.withdrawSent': 'Retiro enviado.', + + 'loading.creating': 'Creando…', + 'loading.loggingIn': 'Entrando…', + 'loading.sendingBet': 'Enviando apuesta…', + 'loading.updating': 'Actualizando…', + 'loading.refreshing': '…', + 'loading.sending': 'Enviando…', + }, + fr: { + 'nav.ariaSections': 'Sections', + 'nav.guideTitle': 'Guide', + 'nav.guideAria': "Ouvrir le guide de l'utilisateur", + 'nav.bugReport': 'Signaler un bug', + 'nav.logoutTitle': 'Se déconnecter', + 'nav.logoutAria': 'Se déconnecter du compte', + 'nav.deposit': 'Dépôt', + 'nav.bet': 'Mise', + 'nav.withdraw': 'Retrait', + 'nav.profile': 'Profil', + + 'chain.connecting': 'Connexion…', + 'chain.block': 'Bloc {n}', + 'chain.connectionLost': 'Connexion au serveur perdue — nouvelle tentative…', + 'maintenance.banner': "Maintenance programmée : le round en cours se termine normalement (gagnant inclus), mais le round suivant ne s'ouvrira qu'une fois la maintenance terminée.", + + 'hero.lead': 'Déposez des PLM, rejoignez le round avec une mise fixe, et si votre numéro est tiré vous remportez le jackpot.', + 'hero.step1.title': '1. Déposez', + 'hero.step1.hint': 'Recevez votre adresse PLM personnelle, à vous pour toujours', + 'hero.step2.title': '2. Jouez', + 'hero.step2.hint': 'Une mise à coût fixe pour entrer dans le round en cours', + 'hero.step3.title': '3. Gagnez', + 'hero.step3.hint': "Tirage à partir du hash d'un bloc, jackpot crédité instantanément", + 'trust.fixedRate': "Mise d'entrée fixe et déclarée", + 'trust.blockHash': "Tirage à partir du hash d'un bloc", + 'trust.freeWithdraw': 'Retrait libre à tout moment', + + 'auth.tabLogin': 'Connexion', + 'auth.tabRegister': "S'inscrire", + 'auth.username': "Nom d'utilisateur", + 'auth.password': 'Mot de passe', + 'auth.passwordConfirm': 'Confirmer le mot de passe', + 'auth.loginBtn': 'Se connecter', + 'auth.registerBtn': 'Créer un compte', + + 'round.players': 'Joueurs', + 'round.jackpot': 'Jackpot', + 'round.status.open': 'ouvert', + 'round.status.closing': 'en fermeture', + 'round.status.drawing': 'tirage en cours', + 'round.status.paying_out': 'paiement du gagnant en cours', + 'round.title': 'Round #{id} — {status}', + 'round.none': 'Aucun round actif', + 'chain.status.waiting': 'En attente du prochain round', + 'chain.status.open': 'Round ouvert', + 'chain.status.closing': 'Round fermé — attente de confirmation des mises', + 'chain.status.drawing': 'Tirage en cours', + 'chain.status.paying_out': 'Paiement du gagnant en cours', + + 'draw.defaultLabel': 'Tirage du gagnant en cours…', + 'draw.closing': 'Round fermé — en attente de la confirmation de la dernière mise avant de tirer le gagnant…', + 'draw.drawing': 'En attente du prochain bloc pour tirer le gagnant…', + 'draw.payingOutBlock': 'Gagnant tiré au bloc #{height} — paiement du gagnant en cours…', + 'draw.payingOut': 'Gagnant tiré — paiement du gagnant en cours…', + 'result.win': '🎉 Vous avez gagné ! +{amount} PLM', + 'result.lose': "Pas de gain cette fois-ci.", + + 'deposit.balanceTitle': 'Solde interne', + 'deposit.balanceHint': 'Mis à jour après 1 confirmation sur le réseau', + 'deposit.refreshBtn': 'Actualiser', + 'deposit.refreshAria': 'Actualiser le solde', + 'deposit.addressTitle': 'Adresse de dépôt', + 'deposit.addressHint': "C'est aussi l'adresse sur laquelle vous recevez d'éventuels gains", + 'deposit.copyAria': "Copier l'adresse", + 'deposit.qrAlt': "Code QR de l'adresse de dépôt", + + 'bet.title': 'Mise', + 'bet.hint': 'Entrée fixe pour le round en cours', + 'bet.button': 'Placer une mise ({amount} PLM)', + 'bet.buttonNoAmount': 'Placer une mise', + + 'withdraw.title': 'Retrait', + 'withdraw.hint': 'Envoyez des fonds vers une adresse PLM externe', + 'withdraw.addressLabel': 'Adresse externe', + 'withdraw.addressHint': "Uniquement les adresses P2WPKH bech32 (celles qui commencent par plm1q...). Les adresses legacy (P...) ou P2SH ne sont pas prises en charge.", + 'withdraw.amountLabel': 'Montant (PLM)', + 'withdraw.amountPlaceholder': 'ex. 2', + 'withdraw.fullAmountPrefix': 'Retirer le montant total (', + 'withdraw.fullAmountSuffix': ' PLM)', + 'withdraw.button': 'Retirer', + + 'profile.title': 'Profil', + 'profile.hint': 'Les informations de votre compte', + 'profile.usernameLabel': "Nom d'utilisateur", + 'profile.addressLabel': 'Adresse de dépôt', + 'profile.balanceLabel': 'Solde interne', + 'profile.createdLabel': 'Utilisateur depuis', + 'settings.title': 'Paramètres', + 'settings.hint': 'Changez le mot de passe de votre compte', + 'settings.currentPasswordLabel': 'Mot de passe actuel', + 'settings.newPasswordLabel': 'Nouveau mot de passe', + 'settings.newPasswordConfirmLabel': 'Confirmer le nouveau mot de passe', + 'settings.updateBtn': 'Mettre à jour le mot de passe', + + 'toast.passwordMismatch': 'Les mots de passe ne correspondent pas.', + 'toast.accountCreated': 'Compte créé.', + 'toast.loginSuccess': 'Connexion réussie.', + 'toast.requestTimeout': 'La requête au serveur a expiré.', + 'toast.addressCopied': 'Adresse copiée.', + 'toast.copyFailed': 'Impossible de copier automatiquement.', + 'toast.newPasswordMismatch': 'Les nouveaux mots de passe ne correspondent pas.', + 'toast.passwordTooShort': 'Le nouveau mot de passe doit comporter au moins 8 caractères.', + 'toast.passwordUpdated': 'Mot de passe mis à jour.', + 'toast.betPlaced': 'Mise placée sur le round #{id}.', + 'toast.roundWon': 'Vous avez gagné le round #{id} ! +{amount} PLM', + 'toast.withdrawSent': 'Retrait envoyé.', + + 'loading.creating': 'Création…', + 'loading.loggingIn': 'Connexion…', + 'loading.sendingBet': 'Envoi de la mise…', + 'loading.updating': 'Mise à jour…', + 'loading.refreshing': '…', + 'loading.sending': 'Envoi…', + }, + de: { + 'nav.ariaSections': 'Bereiche', + 'nav.guideTitle': 'Anleitung', + 'nav.guideAria': 'Benutzerhandbuch öffnen', + 'nav.bugReport': 'Fehler melden', + 'nav.logoutTitle': 'Abmelden', + 'nav.logoutAria': 'Vom Konto abmelden', + 'nav.deposit': 'Einzahlung', + 'nav.bet': 'Wette', + 'nav.withdraw': 'Auszahlung', + 'nav.profile': 'Profil', + + 'chain.connecting': 'Verbindung wird hergestellt…', + 'chain.block': 'Block {n}', + 'chain.connectionLost': 'Verbindung zum Server verloren — erneuter Versuch…', + 'maintenance.banner': 'Geplante Wartung: Die laufende Runde wird regulär abgeschlossen (Gewinner inklusive), aber die nächste Runde öffnet erst, wenn die Wartung beendet ist.', + + 'hero.lead': 'Zahle PLM ein, nimm mit einem festen Einsatz an der Runde teil, und wenn deine Zahl gezogen wird, gewinnst du den Jackpot.', + 'hero.step1.title': '1. Einzahlen', + 'hero.step1.hint': 'Erhalte deine persönliche PLM-Adresse, für immer deine', + 'hero.step2.title': '2. Spielen', + 'hero.step2.hint': 'Ein Einsatz mit Festpreis für die Teilnahme an der laufenden Runde', + 'hero.step3.title': '3. Gewinnen', + 'hero.step3.hint': 'Ziehung aus einem Block-Hash, Jackpot sofort gutgeschrieben', + 'trust.fixedRate': 'Fester, offen genannter Einsatz', + 'trust.blockHash': 'Ziehung aus einem Block-Hash', + 'trust.freeWithdraw': 'Jederzeit freie Auszahlung', + + 'auth.tabLogin': 'Anmelden', + 'auth.tabRegister': 'Registrieren', + 'auth.username': 'Benutzername', + 'auth.password': 'Passwort', + 'auth.passwordConfirm': 'Passwort bestätigen', + 'auth.loginBtn': 'Anmelden', + 'auth.registerBtn': 'Konto erstellen', + + 'round.players': 'Spieler', + 'round.jackpot': 'Jackpot', + 'round.status.open': 'offen', + 'round.status.closing': 'wird geschlossen', + 'round.status.drawing': 'Ziehung läuft', + 'round.status.paying_out': 'Auszahlung an den Gewinner läuft', + 'round.title': 'Runde #{id} — {status}', + 'round.none': 'Keine aktive Runde', + 'chain.status.waiting': 'Warten auf die nächste Runde', + 'chain.status.open': 'Runde offen', + 'chain.status.closing': 'Runde geschlossen — warte auf Bestätigung der Wetten', + 'chain.status.drawing': 'Ziehung läuft', + 'chain.status.paying_out': 'Auszahlung an den Gewinner läuft', + + 'draw.defaultLabel': 'Gewinner wird gezogen…', + 'draw.closing': 'Runde geschlossen — warte auf Bestätigung der letzten Wette, bevor der Gewinner gezogen wird…', + 'draw.drawing': 'Warte auf den nächsten Block, um den Gewinner zu ziehen…', + 'draw.payingOutBlock': 'Gewinner aus Block #{height} gezogen — Auszahlung an den Gewinner läuft…', + 'draw.payingOut': 'Gewinner gezogen — Auszahlung an den Gewinner läuft…', + 'result.win': '🎉 Du hast gewonnen! +{amount} PLM', + 'result.lose': 'Diesmal kein Gewinn.', + + 'deposit.balanceTitle': 'Internes Guthaben', + 'deposit.balanceHint': 'Aktualisiert nach 1 Netzwerkbestätigung', + 'deposit.refreshBtn': 'Aktualisieren', + 'deposit.refreshAria': 'Guthaben aktualisieren', + 'deposit.addressTitle': 'Einzahlungsadresse', + 'deposit.addressHint': 'Dies ist auch die Adresse, auf der du eventuelle Gewinne erhältst', + 'deposit.copyAria': 'Adresse kopieren', + 'deposit.qrAlt': 'QR-Code der Einzahlungsadresse', + + 'bet.title': 'Wette', + 'bet.hint': 'Fester Einsatz für die laufende Runde', + 'bet.button': 'Wette platzieren ({amount} PLM)', + 'bet.buttonNoAmount': 'Wette platzieren', + + 'withdraw.title': 'Auszahlung', + 'withdraw.hint': 'Sende Guthaben an eine externe PLM-Adresse', + 'withdraw.addressLabel': 'Externe Adresse', + 'withdraw.addressHint': 'Nur P2WPKH-Bech32-Adressen (beginnend mit plm1q...). Legacy-Adressen (P...) oder P2SH werden nicht unterstützt.', + 'withdraw.amountLabel': 'Betrag (PLM)', + 'withdraw.amountPlaceholder': 'z. B. 2', + 'withdraw.fullAmountPrefix': 'Gesamten Betrag auszahlen (', + 'withdraw.fullAmountSuffix': ' PLM)', + 'withdraw.button': 'Auszahlen', + + 'profile.title': 'Profil', + 'profile.hint': 'Deine Kontoinformationen', + 'profile.usernameLabel': 'Benutzername', + 'profile.addressLabel': 'Einzahlungsadresse', + 'profile.balanceLabel': 'Internes Guthaben', + 'profile.createdLabel': 'Nutzer seit', + 'settings.title': 'Einstellungen', + 'settings.hint': 'Ändere das Passwort deines Kontos', + 'settings.currentPasswordLabel': 'Aktuelles Passwort', + 'settings.newPasswordLabel': 'Neues Passwort', + 'settings.newPasswordConfirmLabel': 'Neues Passwort bestätigen', + 'settings.updateBtn': 'Passwort aktualisieren', + + 'toast.passwordMismatch': 'Die Passwörter stimmen nicht überein.', + 'toast.accountCreated': 'Konto erstellt.', + 'toast.loginSuccess': 'Anmeldung erfolgreich.', + 'toast.requestTimeout': 'Anfrage an den Server ist abgelaufen.', + 'toast.addressCopied': 'Adresse kopiert.', + 'toast.copyFailed': 'Automatisches Kopieren fehlgeschlagen.', + 'toast.newPasswordMismatch': 'Die neuen Passwörter stimmen nicht überein.', + 'toast.passwordTooShort': 'Das neue Passwort muss mindestens 8 Zeichen lang sein.', + 'toast.passwordUpdated': 'Passwort aktualisiert.', + 'toast.betPlaced': 'Wette auf Runde #{id} platziert.', + 'toast.roundWon': 'Du hast Runde #{id} gewonnen! +{amount} PLM', + 'toast.withdrawSent': 'Auszahlung gesendet.', + + 'loading.creating': 'Wird erstellt…', + 'loading.loggingIn': 'Anmeldung…', + 'loading.sendingBet': 'Wette wird gesendet…', + 'loading.updating': 'Aktualisierung…', + 'loading.refreshing': '…', + 'loading.sending': 'Wird gesendet…', + }, + ru: { + 'nav.ariaSections': 'Разделы', + 'nav.guideTitle': 'Инструкция', + 'nav.guideAria': 'Открыть руководство пользователя', + 'nav.bugReport': 'Сообщить об ошибке', + 'nav.logoutTitle': 'Выйти', + 'nav.logoutAria': 'Выйти из аккаунта', + 'nav.deposit': 'Депозит', + 'nav.bet': 'Ставка', + 'nav.withdraw': 'Вывод', + 'nav.profile': 'Профиль', + + 'chain.connecting': 'Подключение…', + 'chain.block': 'Блок {n}', + 'chain.connectionLost': 'Соединение с сервером потеряно — повторная попытка…', + 'maintenance.banner': 'Запланировано техобслуживание: текущий раунд завершится в обычном порядке (включая победителя), но следующий раунд не откроется до окончания техобслуживания.', + + 'hero.lead': 'Внесите PLM, вступите в раунд с фиксированной ставкой, и если выпадет ваш номер — вы выиграете джекпот.', + 'hero.step1.title': '1. Внесите депозит', + 'hero.step1.hint': 'Получите личный адрес PLM, ваш навсегда', + 'hero.step2.title': '2. Играйте', + 'hero.step2.hint': 'Ставка с фиксированной стоимостью для участия в текущем раунде', + 'hero.step3.title': '3. Выигрывайте', + 'hero.step3.hint': 'Розыгрыш по хешу блока, джекпот начисляется сразу', + 'trust.fixedRate': 'Заявленная фиксированная ставка', + 'trust.blockHash': 'Розыгрыш по хешу блока', + 'trust.freeWithdraw': 'Свободный вывод средств в любой момент', + + 'auth.tabLogin': 'Вход', + 'auth.tabRegister': 'Регистрация', + 'auth.username': 'Имя пользователя', + 'auth.password': 'Пароль', + 'auth.passwordConfirm': 'Подтвердите пароль', + 'auth.loginBtn': 'Войти', + 'auth.registerBtn': 'Создать аккаунт', + + 'round.players': 'Игроки', + 'round.jackpot': 'Джекпот', + 'round.status.open': 'открыт', + 'round.status.closing': 'закрывается', + 'round.status.drawing': 'идёт розыгрыш', + 'round.status.paying_out': 'выплата победителю', + 'round.title': 'Раунд #{id} — {status}', + 'round.none': 'Нет активного раунда', + 'chain.status.waiting': 'Ожидание следующего раунда', + 'chain.status.open': 'Раунд открыт', + 'chain.status.closing': 'Раунд закрыт — ожидание подтверждения ставок', + 'chain.status.drawing': 'Идёт розыгрыш', + 'chain.status.paying_out': 'Выплата победителю', + + 'draw.defaultLabel': 'Розыгрыш победителя…', + 'draw.closing': 'Раунд закрыт — ожидание подтверждения последней ставки перед розыгрышем победителя…', + 'draw.drawing': 'Ожидание следующего блока для розыгрыша победителя…', + 'draw.payingOutBlock': 'Победитель определён по блоку #{height} — выплата победителю…', + 'draw.payingOut': 'Победитель определён — выплата победителю…', + 'result.win': '🎉 Вы выиграли! +{amount} PLM', + 'result.lose': 'На этот раз без выигрыша.', + + 'deposit.balanceTitle': 'Внутренний баланс', + 'deposit.balanceHint': 'Обновляется после 1 подтверждения в сети', + 'deposit.refreshBtn': 'Обновить', + 'deposit.refreshAria': 'Обновить баланс', + 'deposit.addressTitle': 'Адрес для депозита', + 'deposit.addressHint': 'Это также адрес, на который вы получаете возможные выигрыши', + 'deposit.copyAria': 'Скопировать адрес', + 'deposit.qrAlt': 'QR-код адреса для депозита', + + 'bet.title': 'Ставка', + 'bet.hint': 'Фиксированный вход в текущий раунд', + 'bet.button': 'Сделать ставку ({amount} PLM)', + 'bet.buttonNoAmount': 'Сделать ставку', + + 'withdraw.title': 'Вывод средств', + 'withdraw.hint': 'Отправьте средства на внешний адрес PLM', + 'withdraw.addressLabel': 'Внешний адрес', + 'withdraw.addressHint': 'Только адреса P2WPKH bech32 (начинающиеся с plm1q...). Устаревшие адреса (P...) или P2SH не поддерживаются.', + 'withdraw.amountLabel': 'Сумма (PLM)', + 'withdraw.amountPlaceholder': 'напр. 2', + 'withdraw.fullAmountPrefix': 'Вывести всю сумму (', + 'withdraw.fullAmountSuffix': ' PLM)', + 'withdraw.button': 'Вывести', + + 'profile.title': 'Профиль', + 'profile.hint': 'Информация о вашем аккаунте', + 'profile.usernameLabel': 'Имя пользователя', + 'profile.addressLabel': 'Адрес для депозита', + 'profile.balanceLabel': 'Внутренний баланс', + 'profile.createdLabel': 'Пользователь с', + 'settings.title': 'Настройки', + 'settings.hint': 'Измените пароль вашего аккаунта', + 'settings.currentPasswordLabel': 'Текущий пароль', + 'settings.newPasswordLabel': 'Новый пароль', + 'settings.newPasswordConfirmLabel': 'Подтвердите новый пароль', + 'settings.updateBtn': 'Обновить пароль', + + 'toast.passwordMismatch': 'Пароли не совпадают.', + 'toast.accountCreated': 'Аккаунт создан.', + 'toast.loginSuccess': 'Вход выполнен успешно.', + 'toast.requestTimeout': 'Истекло время ожидания ответа сервера.', + 'toast.addressCopied': 'Адрес скопирован.', + 'toast.copyFailed': 'Не удалось скопировать автоматически.', + 'toast.newPasswordMismatch': 'Новые пароли не совпадают.', + 'toast.passwordTooShort': 'Новый пароль должен содержать не менее 8 символов.', + 'toast.passwordUpdated': 'Пароль обновлён.', + 'toast.betPlaced': 'Ставка сделана на раунд #{id}.', + 'toast.roundWon': 'Вы выиграли раунд #{id}! +{amount} PLM', + 'toast.withdrawSent': 'Вывод средств отправлен.', + + 'loading.creating': 'Создание…', + 'loading.loggingIn': 'Вход…', + 'loading.sendingBet': 'Отправка ставки…', + 'loading.updating': 'Обновление…', + 'loading.refreshing': '…', + 'loading.sending': 'Отправка…', + }, + zh: { + 'nav.ariaSections': '导航区', + 'nav.guideTitle': '指南', + 'nav.guideAria': '打开用户指南', + 'nav.bugReport': '报告问题', + 'nav.logoutTitle': '退出登录', + 'nav.logoutAria': '退出账户', + 'nav.deposit': '存款', + 'nav.bet': '下注', + 'nav.withdraw': '提现', + 'nav.profile': '个人资料', + + 'chain.connecting': '正在连接…', + 'chain.block': '区块 {n}', + 'chain.connectionLost': '与服务器的连接已断开——正在重试…', + 'maintenance.banner': '计划维护:当前回合将照常完成(包括中奖者),但下一回合要等维护结束后才会开启。', + + 'hero.lead': '存入 PLM,以固定金额参与本回合,若抽中你的号码即可赢得奖池。', + 'hero.step1.title': '1. 存款', + 'hero.step1.hint': '获得专属于你的 PLM 地址,永久有效', + 'hero.step2.title': '2. 参与', + 'hero.step2.hint': '以固定金额下注即可参与当前回合', + 'hero.step3.title': '3. 获胜', + 'hero.step3.hint': '根据区块哈希开奖,奖金即时到账', + 'trust.fixedRate': '公开声明的固定下注金额', + 'trust.blockHash': '根据区块哈希开奖', + 'trust.freeWithdraw': '随时自由提现', + + 'auth.tabLogin': '登录', + 'auth.tabRegister': '注册', + 'auth.username': '用户名', + 'auth.password': '密码', + 'auth.passwordConfirm': '确认密码', + 'auth.loginBtn': '登录', + 'auth.registerBtn': '创建账户', + + 'round.players': '参与人数', + 'round.jackpot': '奖池', + 'round.status.open': '进行中', + 'round.status.closing': '即将结束', + 'round.status.drawing': '正在开奖', + 'round.status.paying_out': '正在向中奖者付款', + 'round.title': '第 {id} 回合 — {status}', + 'round.none': '当前没有进行中的回合', + 'chain.status.waiting': '等待下一回合开启', + 'chain.status.open': '回合进行中', + 'chain.status.closing': '回合已结束——等待下注确认', + 'chain.status.drawing': '正在开奖', + 'chain.status.paying_out': '正在向中奖者付款', + + 'draw.defaultLabel': '正在抽取中奖者…', + 'draw.closing': '回合已结束——在开奖前等待最后一笔下注确认…', + 'draw.drawing': '等待下一个区块以抽取中奖者…', + 'draw.payingOutBlock': '已从区块 #{height} 抽取中奖者——正在向中奖者付款…', + 'draw.payingOut': '中奖者已确定——正在向中奖者付款…', + 'result.win': '🎉 恭喜你赢了!+{amount} PLM', + 'result.lose': '这次没有中奖。', + + 'deposit.balanceTitle': '内部余额', + 'deposit.balanceHint': '在网络确认 1 次后更新', + 'deposit.refreshBtn': '刷新', + 'deposit.refreshAria': '刷新余额', + 'deposit.addressTitle': '存款地址', + 'deposit.addressHint': '这也是接收任何奖金的地址', + 'deposit.copyAria': '复制地址', + 'deposit.qrAlt': '存款地址的二维码', + + 'bet.title': '下注', + 'bet.hint': '以固定金额参与当前回合', + 'bet.button': '下注({amount} PLM)', + 'bet.buttonNoAmount': '下注', + + 'withdraw.title': '提现', + 'withdraw.hint': '将资金发送到外部 PLM 地址', + 'withdraw.addressLabel': '外部地址', + 'withdraw.addressHint': '仅支持 P2WPKH bech32 地址(以 plm1q... 开头)。不支持传统地址(P...)或 P2SH 地址。', + 'withdraw.amountLabel': '金额(PLM)', + 'withdraw.amountPlaceholder': '例如 2', + 'withdraw.fullAmountPrefix': '提取全部金额(', + 'withdraw.fullAmountSuffix': ' PLM)', + 'withdraw.button': '提现', + + 'profile.title': '个人资料', + 'profile.hint': '你的账户信息', + 'profile.usernameLabel': '用户名', + 'profile.addressLabel': '存款地址', + 'profile.balanceLabel': '内部余额', + 'profile.createdLabel': '注册时间', + 'settings.title': '设置', + 'settings.hint': '修改你的账户密码', + 'settings.currentPasswordLabel': '当前密码', + 'settings.newPasswordLabel': '新密码', + 'settings.newPasswordConfirmLabel': '确认新密码', + 'settings.updateBtn': '更新密码', + + 'toast.passwordMismatch': '两次输入的密码不一致。', + 'toast.accountCreated': '账户已创建。', + 'toast.loginSuccess': '登录成功。', + 'toast.requestTimeout': '服务器请求超时。', + 'toast.addressCopied': '地址已复制。', + 'toast.copyFailed': '自动复制失败。', + 'toast.newPasswordMismatch': '两次输入的新密码不一致。', + 'toast.passwordTooShort': '新密码长度至少需要 8 个字符。', + 'toast.passwordUpdated': '密码已更新。', + 'toast.betPlaced': '已在第 {id} 回合下注。', + 'toast.roundWon': '你赢得了第 {id} 回合!+{amount} PLM', + 'toast.withdrawSent': '提现已发送。', + + 'loading.creating': '正在创建…', + 'loading.loggingIn': '正在登录…', + 'loading.sendingBet': '正在下注…', + 'loading.updating': '正在更新…', + 'loading.refreshing': '…', + 'loading.sending': '正在发送…', + }, +}; + +function detectDefaultLang() { + const saved = localStorage.getItem(LANG_STORAGE_KEY); + if (saved && SUPPORTED_LANGS.includes(saved)) return saved; + const nav = (navigator.language || 'en').slice(0, 2).toLowerCase(); + return SUPPORTED_LANGS.includes(nav) ? nav : 'en'; +} + +let currentLang = detectDefaultLang(); + +function interpolate(str, params) { + if (!params) return str; + for (const [k, v] of Object.entries(params)) str = str.replaceAll('{' + k + '}', v); + return str; +} + +function t(key, params) { + const dict = TRANSLATIONS[currentLang] || TRANSLATIONS.en; + return interpolate(dict[key] ?? TRANSLATIONS.en[key] ?? key, params); +} + +function currentDateLocale() { + return DATE_LOCALES[currentLang] || 'en-US'; +} + +// `root` defaults to the whole document; pass a subtree to re-translate just +// that part (see withLoading in app.js, which restores a button's markup from a +// snapshot that may predate a language change). +function applyStaticTranslations(root) { + const scope = root instanceof Element || root instanceof Document ? root : document; + const each = (selector, fn) => { + if (scope !== document && scope.matches?.(selector)) fn(scope); + scope.querySelectorAll(selector).forEach(fn); + }; + each('[data-i18n]', (el) => { el.textContent = t(el.getAttribute('data-i18n')); }); + each('[data-i18n-html]', (el) => { el.innerHTML = t(el.getAttribute('data-i18n-html')); }); + each('[data-i18n-placeholder]', (el) => { el.placeholder = t(el.getAttribute('data-i18n-placeholder')); }); + each('[data-i18n-title]', (el) => { el.title = t(el.getAttribute('data-i18n-title')); }); + each('[data-i18n-aria-label]', (el) => { el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria-label'))); }); + each('[data-i18n-alt]', (el) => { el.alt = t(el.getAttribute('data-i18n-alt')); }); + + if (scope !== document) return; + document.documentElement.lang = currentLang; + const switcher = document.getElementById('lang-switcher'); + if (switcher) switcher.value = currentLang; +} + +function setLanguage(lang) { + if (!SUPPORTED_LANGS.includes(lang) || lang === currentLang) return; + currentLang = lang; + localStorage.setItem(LANG_STORAGE_KEY, lang); + applyStaticTranslations(); + if (typeof onLanguageChange === 'function') onLanguageChange(); +} + +document.addEventListener('DOMContentLoaded', applyStaticTranslations); diff --git a/app/static/index.html b/app/static/index.html index 87e9dde..2048b22 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -1,5 +1,5 @@ - + @@ -9,7 +9,7 @@ -