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:
2026-07-26 21:43:35 +02:00
co-authored by Claude Opus 5
parent 8a3dfd4592
commit 7048fe7ea6
5 changed files with 1059 additions and 109 deletions
+8
View File
@@ -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 46 minutes worst case, 24 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):
+85 -44
View File
@@ -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();
+869
View File
@@ -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 <code>plm1q...</code>). Legacy (<code>P...</code>) 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 <code>plm1q...</code>). Indirizzi legacy (<code>P...</code>) 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 <code>plm1q...</code>). No se admiten direcciones legacy (<code>P...</code>) 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 <code>plm1q...</code>). Les adresses legacy (<code>P...</code>) 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 <code>plm1q...</code>). Legacy-Adressen (<code>P...</code>) 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 (начинающиеся с <code>plm1q...</code>). Устаревшие адреса (<code>P...</code>) или 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 地址(以 <code>plm1q...</code> 开头)。不支持传统地址(<code>P...</code>)或 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);
+82 -65
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="it">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -9,7 +9,7 @@
</head>
<body>
<nav class="hidden" id="app-navbar" aria-label="Sezioni">
<nav class="hidden" id="app-navbar" data-i18n-aria-label="nav.ariaSections" aria-label="Sezioni">
<div class="app-navbar-top">
<div class="app-navbar-top-inner">
<span class="brand">
@@ -22,13 +22,13 @@
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16"/></svg>
<span id="navbar-balance">— PLM</span>
</span>
<a class="link icon-link" href="/guida" target="_blank" rel="noopener" title="Guida" aria-label="Apri la guida utente">
<a class="link icon-link" href="/guida" target="_blank" rel="noopener" data-i18n-title="nav.guideTitle" title="Guida" data-i18n-aria-label="nav.guideAria" aria-label="Apri la guida utente">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 2-3 4"/><path d="M12 17h.01"/></svg>
</a>
<a class="link icon-link" href="https://github.com/REPLACE_ME/plm-lottery/issues/new" target="_blank" rel="noopener" title="Segnala un bug" aria-label="Segnala un bug su GitHub">
<a class="link icon-link" href="https://github.com/REPLACE_ME/plm-lottery/issues/new" target="_blank" rel="noopener" data-i18n-title="nav.bugReport" title="Segnala un bug" data-i18n-aria-label="nav.bugReport" aria-label="Segnala un bug su GitHub">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v3M16 2v3M12 12v-2a2 2 0 1 1 2 2h-2Z"/><rect x="6" y="10" width="12" height="10" rx="4"/><path d="M6 15H3M21 15h-3M9 20v-3M15 20v-3"/></svg>
</a>
<button class="link icon-link" onclick="logout()" title="Esci" aria-label="Esci dall'account">
<button class="link icon-link" onclick="logout()" data-i18n-title="nav.logoutTitle" title="Esci" data-i18n-aria-label="nav.logoutAria" aria-label="Esci dall'account">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>
</button>
</div>
@@ -37,19 +37,19 @@
<div class="app-navbar-tabs">
<button class="navbar-tab active" id="nav-deposit" onclick="switchPanel('deposit')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16"/></svg>
Deposito
<span data-i18n="nav.deposit">Deposito</span>
</button>
<button class="navbar-tab" id="nav-bet" onclick="switchPanel('bet')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 12h.01M12 12h.01M18 12h.01"/></svg>
Bet
<span data-i18n="nav.bet">Bet</span>
</button>
<button class="navbar-tab" id="nav-withdraw" onclick="switchPanel('withdraw')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
Prelievo
<span data-i18n="nav.withdraw">Prelievo</span>
</button>
<button class="navbar-tab" id="nav-profile" onclick="switchPanel('profile')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Profilo
<span data-i18n="nav.profile">Profilo</span>
</button>
</div>
</nav>
@@ -59,67 +59,81 @@
<div class="chain-bar" id="chain-bar">
<span class="chain-status-pill">
<span class="status-dot" id="chain-status-dot"></span>
<span id="chain-status-label">Connessione</span>
<span id="chain-status-label" data-i18n="chain.connecting">Connecting</span>
</span>
<span class="chain-bar-right">
<span class="chain-block mono" id="chain-block"></span>
<!-- Deliberately here and not in the navbar: the navbar is hidden until login,
which would leave the landing page and the login form untranslatable for
anyone who can't read the browser-detected default. -->
<select id="lang-switcher" class="lang-switcher" onchange="setLanguage(this.value)" aria-label="Language">
<option value="en">English</option>
<option value="it">Italiano</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="ru">Русский</option>
<option value="zh">中文</option>
</select>
</span>
<span class="chain-block mono" id="chain-block">Blocco —</span>
</div>
<div class="maintenance-banner hidden" id="maintenance-banner">
<span>⚠️</span>
<span>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.</span>
<span data-i18n="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.</span>
</div>
<section id="landing-hero" class="hero">
<h1>PLM Lottery</h1>
<p class="lead">Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.</p>
<p class="lead" data-i18n="hero.lead">Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.</p>
<div class="hero-steps">
<div class="hero-step">
<div class="step-icon"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16"/></svg></div>
<div class="step-title">1. Deposita</div>
<div class="step-hint">Ricevi un indirizzo PLM personale, tuo per sempre</div>
<div class="step-title" data-i18n="hero.step1.title">1. Deposita</div>
<div class="step-hint" data-i18n="hero.step1.hint">Ricevi un indirizzo PLM personale, tuo per sempre</div>
</div>
<div class="hero-step">
<div class="step-icon"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 12h.01M12 12h.01M18 12h.01"/></svg></div>
<div class="step-title">2. Gioca</div>
<div class="step-hint">Una bet a quota fissa per entrare nel round corrente</div>
<div class="step-title" data-i18n="hero.step2.title">2. Gioca</div>
<div class="step-hint" data-i18n="hero.step2.hint">Una bet a quota fissa per entrare nel round corrente</div>
</div>
<div class="hero-step">
<div class="step-icon"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 21h8M12 17v4M7 4h10v4a5 5 0 0 1-10 0V4Z"/><path d="M7 5H4a1 1 0 0 0-1 1v1a4 4 0 0 0 4 4M17 5h3a1 1 0 0 1 1 1v1a4 4 0 0 1-4 4"/></svg></div>
<div class="step-title">3. Vinci</div>
<div class="step-hint">Estrazione dal blocco, montepremi accreditato subito</div>
<div class="step-title" data-i18n="hero.step3.title">3. Vinci</div>
<div class="step-hint" data-i18n="hero.step3.hint">Estrazione dal blocco, montepremi accreditato subito</div>
</div>
</div>
<div class="trust-row">
<span class="trust-pill"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Quota fissa dichiarata</span>
<span class="trust-pill"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Estrazione da hash di blocco</span>
<span class="trust-pill"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Prelievo libero in ogni momento</span>
<span class="trust-pill"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span data-i18n="trust.fixedRate">Quota fissa dichiarata</span></span>
<span class="trust-pill"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span data-i18n="trust.blockHash">Estrazione da hash di blocco</span></span>
<span class="trust-pill"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span data-i18n="trust.freeWithdraw">Prelievo libero in ogni momento</span></span>
</div>
</section>
<section id="auth-section" class="card">
<div class="tabs">
<div class="tab active" id="tab-login" onclick="switchTab('login')">Login</div>
<div class="tab" id="tab-register" onclick="switchTab('register')">Registrati</div>
<div class="tab active" id="tab-login" onclick="switchTab('login')" data-i18n="auth.tabLogin">Login</div>
<div class="tab" id="tab-register" onclick="switchTab('register')" data-i18n="auth.tabRegister">Registrati</div>
</div>
<div class="tab-panel active" id="panel-login">
<label for="login-username">Username</label>
<label for="login-username" data-i18n="auth.username">Username</label>
<input id="login-username" autocomplete="username">
<label for="login-password">Password</label>
<label for="login-password" data-i18n="auth.password">Password</label>
<input id="login-password" type="password" autocomplete="current-password">
<button onclick="login()" id="login-btn">Accedi</button>
<button onclick="login()" id="login-btn" data-i18n="auth.loginBtn">Accedi</button>
</div>
<div class="tab-panel" id="panel-register">
<label for="reg-username">Username</label>
<label for="reg-username" data-i18n="auth.username">Username</label>
<input id="reg-username" autocomplete="username">
<label for="reg-password">Password</label>
<label for="reg-password" data-i18n="auth.password">Password</label>
<input id="reg-password" type="password" autocomplete="new-password">
<label for="reg-password-confirm">Conferma password</label>
<label for="reg-password-confirm" data-i18n="auth.passwordConfirm">Conferma password</label>
<input id="reg-password-confirm" type="password" autocomplete="new-password">
<button onclick="register()" id="register-btn">Crea account</button>
<button onclick="register()" id="register-btn" data-i18n="auth.registerBtn">Crea account</button>
</div>
</section>
@@ -132,18 +146,18 @@
</div>
<div class="row-between" style="margin-top:10px" id="round-stats-row">
<div>
<div class="hint" style="margin-bottom:2px">Giocatori</div>
<div class="hint" style="margin-bottom:2px" data-i18n="round.players">Giocatori</div>
<span class="mono" id="round-players"></span>
</div>
<div style="text-align:right">
<div class="hint" style="margin-bottom:2px">Jackpot</div>
<div class="hint" style="margin-bottom:2px" data-i18n="round.jackpot">Jackpot</div>
<span class="mono" id="round-jackpot"></span> <span class="balance-unit">PLM</span>
</div>
</div>
<div class="draw-state" id="draw-state">
<div class="draw-spinner"></div>
<div class="draw-label" id="draw-label">Estrazione del vincitore in corso…</div>
<div class="draw-label" id="draw-label" data-i18n="draw.defaultLabel">Estrazione del vincitore in corso…</div>
</div>
<div class="hidden" id="draw-result"></div>
@@ -151,81 +165,83 @@
<div class="dash-panel active" id="panel-deposit">
<div class="card">
<h2>Saldo interno</h2>
<p class="hint">Aggiornato dopo 1 conferma sulla rete</p>
<h2 data-i18n="deposit.balanceTitle">Saldo interno</h2>
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato dopo 1 conferma sulla rete</p>
<div class="row-between">
<div><span class="balance-value mono" id="dash-balance"></span> <span class="balance-unit">PLM</span></div>
<button class="secondary" onclick="refreshMe()" id="refresh-btn" aria-label="Aggiorna saldo">
<button class="secondary" onclick="refreshMe()" id="refresh-btn" data-i18n-aria-label="deposit.refreshAria" aria-label="Aggiorna saldo">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg>
Aggiorna
<span data-i18n="deposit.refreshBtn">Aggiorna</span>
</button>
</div>
</div>
<div class="card">
<h2>Indirizzo di deposito</h2>
<p class="hint">È anche l'indirizzo su cui ricevi eventuali vincite</p>
<h2 data-i18n="deposit.addressTitle">Indirizzo di deposito</h2>
<p class="hint" data-i18n="deposit.addressHint">È anche l'indirizzo su cui ricevi eventuali vincite</p>
<div class="address-box">
<span class="mono" id="dash-address"></span>
<button class="secondary" onclick="copyAddress()" aria-label="Copia indirizzo">
<button class="secondary" onclick="copyAddress()" data-i18n-aria-label="deposit.copyAria" aria-label="Copia indirizzo">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
<div class="qr-box">
<img id="dash-qr" alt="QR code dell'indirizzo di deposito">
<img id="dash-qr" data-i18n-alt="deposit.qrAlt" alt="QR code dell'indirizzo di deposito">
</div>
</div>
</div>
<div class="dash-panel" id="panel-bet">
<div class="card">
<h2>Bet</h2>
<p class="hint">Ingresso fisso al round corrente</p>
<button onclick="placeBet()" id="bet-btn">Piazza bet (10 PLM)</button>
<h2 data-i18n="bet.title">Bet</h2>
<p class="hint" data-i18n="bet.hint">Ingresso fisso al round corrente</p>
<!-- No data-i18n here: the label carries the live bet amount, which is
admin-configurable, so it's rendered by renderBetButton() in app.js. -->
<button onclick="placeBet()" id="bet-btn">Place bet</button>
</div>
</div>
<div class="dash-panel" id="panel-withdraw">
<div class="card">
<h2>Withdrawal</h2>
<p class="hint">Invia fondi a un indirizzo PLM esterno</p>
<label for="wd-address">Indirizzo esterno</label>
<h2 data-i18n="withdraw.title">Withdrawal</h2>
<p class="hint" data-i18n="withdraw.hint">Invia fondi a un indirizzo PLM esterno</p>
<label for="wd-address" data-i18n="withdraw.addressLabel">Indirizzo esterno</label>
<input id="wd-address" class="mono" placeholder="plm1q...">
<p class="hint">Solo indirizzi P2WPKH bech32 (quelli che iniziano con <code>plm1q...</code>). Indirizzi legacy (<code>P...</code>) o P2SH non sono supportati.</p>
<label for="wd-amount">Importo (PLM)</label>
<input id="wd-amount" inputmode="decimal" placeholder="es. 2">
<p class="hint" data-i18n-html="withdraw.addressHint">Solo indirizzi P2WPKH bech32 (quelli che iniziano con <code>plm1q...</code>). Indirizzi legacy (<code>P...</code>) o P2SH non sono supportati.</p>
<label for="wd-amount" data-i18n="withdraw.amountLabel">Importo (PLM)</label>
<input id="wd-amount" inputmode="decimal" data-i18n-placeholder="withdraw.amountPlaceholder" placeholder="es. 2">
<label class="checkbox-row">
<input type="checkbox" id="wd-full-amount" onchange="toggleWithdrawFullAmount()">
Preleva l'intero importo (<span class="mono" id="wd-full-amount-value"></span> PLM)
<span data-i18n="withdraw.fullAmountPrefix">Preleva l'intero importo (</span><span class="mono" id="wd-full-amount-value"></span><span data-i18n="withdraw.fullAmountSuffix"> PLM)</span>
</label>
<button onclick="withdraw()" id="withdraw-btn">Preleva</button>
<button onclick="withdraw()" id="withdraw-btn" data-i18n="withdraw.button">Preleva</button>
</div>
</div>
<div class="dash-panel" id="panel-profile">
<div class="card">
<h2>Profilo</h2>
<p class="hint">Le tue informazioni account</p>
<label>Username</label>
<h2 data-i18n="profile.title">Profilo</h2>
<p class="hint" data-i18n="profile.hint">Le tue informazioni account</p>
<label data-i18n="profile.usernameLabel">Username</label>
<div class="address-box"><span id="profile-username"></span></div>
<label>Indirizzo di deposito</label>
<label data-i18n="profile.addressLabel">Indirizzo di deposito</label>
<div class="address-box"><span class="mono" id="profile-address"></span></div>
<label>Saldo interno</label>
<label data-i18n="profile.balanceLabel">Saldo interno</label>
<div class="address-box"><span class="mono" id="profile-balance"></span> <span class="balance-unit">PLM</span></div>
<label>Utente dal</label>
<label data-i18n="profile.createdLabel">Utente dal</label>
<div class="address-box"><span id="profile-created-at"></span></div>
</div>
<div class="card">
<h2>Impostazioni</h2>
<p class="hint">Cambia la password del tuo account</p>
<label for="settings-current-password">Password attuale</label>
<h2 data-i18n="settings.title">Impostazioni</h2>
<p class="hint" data-i18n="settings.hint">Cambia la password del tuo account</p>
<label for="settings-current-password" data-i18n="settings.currentPasswordLabel">Password attuale</label>
<input id="settings-current-password" type="password" autocomplete="current-password">
<label for="settings-new-password">Nuova password</label>
<label for="settings-new-password" data-i18n="settings.newPasswordLabel">Nuova password</label>
<input id="settings-new-password" type="password" autocomplete="new-password">
<label for="settings-new-password-confirm">Conferma nuova password</label>
<label for="settings-new-password-confirm" data-i18n="settings.newPasswordConfirmLabel">Conferma nuova password</label>
<input id="settings-new-password-confirm" type="password" autocomplete="new-password">
<button onclick="changePassword()" id="change-password-btn">Aggiorna password</button>
<button onclick="changePassword()" id="change-password-btn" data-i18n="settings.updateBtn">Aggiorna password</button>
</div>
</div>
@@ -235,6 +251,7 @@
<div id="toast-container" aria-live="polite"></div>
<script src="/i18n.js"></script>
<script src="/app.js"></script>
</body>
+15
View File
@@ -342,6 +342,21 @@ body::before {
border-bottom: 1px solid var(--color-border);
}
.chain-status-pill { display: inline-flex; align-items: center; gap: 7px; font-weight: 600; color: var(--color-foreground); }
.chain-bar-right { display: inline-flex; align-items: center; gap: 10px; flex-shrink: 0; }
/* Language switcher: a plain <select> styled down to look like the muted text
around it, so it reads as part of the status strip rather than as a form
control. Text labels, not flag emoji flags don't render on every platform
and don't map one-to-one onto languages anyway. */
select.lang-switcher {
font: inherit; font-size: 0.78rem; color: var(--color-muted-foreground);
background: none; border: none; box-shadow: none; padding: 2px 4px;
border-radius: 6px; cursor: pointer;
-webkit-appearance: none; appearance: none;
}
select.lang-switcher:hover { color: var(--color-foreground); background: var(--color-surface-inset); }
select.lang-switcher:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; }
select.lang-switcher option { color: var(--color-foreground); background: var(--color-surface); }
.status-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
background: var(--color-muted-foreground);