5 Commits
Author SHA1 Message Date
davideandClaude Opus 5 d4e0974881 Let JS own the live status labels instead of sharing them with data-i18n
#chain-status-label and #draw-label carried a data-i18n attribute *and* were
written from live state by app.js, so a language switch had both mechanisms
fighting over them: applyStaticTranslations reset each to its static default
and the next poll put the real value back. On the draw label that was a
flicker. On the status bar it was a false statement — with the connection
down, the bar went back to claiming "connecting" until a further fetch failed,
up to a full poll interval later.

The attribute is gone from both. The status bar is now rendered from
remembered state (last payload, plus whether we're in the offline state)
rather than straight from the response that triggered it, so a language switch
repaints it correctly and immediately, with no fetch involved.

logout() also stops wiping the chosen language: localStorage.clear() took
plm_lang with it, dropping the user back to the browser-detected default on
the one screen where they'd have to go find the switcher again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:55:37 +02:00
davideandClaude Opus 5 28c1179e9b Recover from expired sessions and malformed requests in the dashboard
Three failure paths that ended at an English HTTP status line or at no
recovery at all:

An empty or non-numeric withdrawal amount parsed to NaN, which JSON.stringify
sends as null, which pydantic rejects with a 422 — and FastAPI's validation
errors use a list of field objects rather than the {code, message} shape, so
apiErrorMessage fell through to res.statusText and the user read
"Unprocessable Content". The amount is now checked before the request, and the
list shape maps to a translated "invalid request" as a backstop for any other
field that fails validation.

A token the server no longer accepts left the dashboard looking logged in
while every poll failed, re-toasting "session expired" indefinitely. call()
now logs out on that specific code, dropping back to the login form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:54:56 +02:00
davideandClaude Opus 5 5c9ccc0344 Reject withdrawal addresses that aren't PLM
embit's Script.from_address accepts a well-formed bech32 address from any
chain: a Bitcoin bc1... parses into a perfectly valid witness program. So a
withdrawal to a BTC address built, signed and broadcast normally on PLM, and
the funds landed on a script nobody holds the key for — silently, with no
error anywhere. A malformed address fared slightly better only in that it
crashed the request with an unhandled 500.

is_valid_plm_address checks the HRP as well as the parse, and runs first in
request_withdrawal, before a single UTXO is touched. It matches what the
withdrawal form already told the user (bech32 plm1q... only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:45:07 +02:00
davideandClaude Opus 5 0cf35147ad Answer user-facing API failures with a machine-readable error code
The dashboard now speaks seven languages but every failure path still showed
the API's raw English text ("insufficient balance", "current password is
incorrect"), which is the most frequent and least forgiving part of the UI to
leave untranslated.

Rather than teach the API about locales, it keeps answering in one language
and hands the client something to translate: `detail` becomes
{code, message, params}, where message stays English for non-dashboard
consumers (curl, tests) and code maps onto `error.<code>` in i18n.js. An
unknown code falls back to message, so a client older or newer than the server
degrades to English instead of a blank toast.

Domain exceptions (BetError, WithdrawalError) subclass the new ApiError and
carry the code from where the failure actually happens; str(exc) is still the
English message, so existing tests keep matching on it. Interpolated values
travel in params rather than baked into the English sentence — amounts as
*_sats, from which the frontend derives a *_plm sibling, so each language can
place them wherever its grammar wants.

admin.js reads detail.message defensively: the admin endpoints still return a
bare string, but the shared auth dependencies now return the structured form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:44:39 +02:00
davideandClaude Opus 5 7048fe7ea6 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>
2026-07-26 21:43:35 +02:00
17 changed files with 1449 additions and 144 deletions
+10
View File
@@ -130,6 +130,16 @@ 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`).
**API error contract** (`app/api/errors.py`): the API is single-language by design. User-facing failures answer with a structured `detail``{"code", "message", "params"}` — where `message` is English for non-dashboard consumers and `code` is what the frontend maps onto `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). Domain exceptions (`BetError`, `WithdrawalError`) subclass `ApiError` and carry the code from where the failure actually happens; `str(exc)` is still the English message. When adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass interpolated values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling automatically) rather than baking them into the English text.
## 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):
+47
View File
@@ -0,0 +1,47 @@
"""Machine-readable error codes for user-facing API failures.
The dashboard is multilingual (app/static/i18n.js) but the API is not: every
message produced here stays English. What travels alongside it is a stable
`code` the client maps onto its own translated string (`error.<code>`), falling
back to `message` for any code it doesn't recognize — so a non-dashboard
consumer (curl, tests, a future client) still gets something readable without
having to know the code table.
`detail` is therefore an object rather than the FastAPI-default bare string:
{"code": "insufficient_balance", "message": "insufficient balance", "params": {}}
`params` carries the values interpolated into the message (amounts, limits) so
the translated string can place them wherever its own grammar needs them,
instead of the client having to parse them back out of the English text.
"""
from typing import Any
from fastapi import HTTPException
class ApiError(Exception):
"""Domain-layer error carrying the code the client will translate.
Subclassed per domain (BetError, WithdrawalError) so services keep raising
their own exception type. `str(exc)` is still the plain English message.
"""
def __init__(self, code: str, message: str, **params: Any) -> None:
super().__init__(message)
self.code = code
self.message = message
self.params = params
def as_detail(self) -> dict[str, Any]:
return {"code": self.code, "message": self.message, "params": self.params}
def http_error(status_code: int, code: str, message: str, **params: Any) -> HTTPException:
"""HTTPException whose detail is the structured object described above."""
return HTTPException(status_code, ApiError(code, message, **params).as_detail())
def from_api_error(status_code: int, exc: ApiError) -> HTTPException:
return HTTPException(status_code, exc.as_detail())
+8 -3
View File
@@ -1,7 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import from_api_error, http_error
from app.auth.dependencies import get_current_user
from app.bets.service import BetError, place_bet
from app.db.models import User
@@ -25,13 +26,17 @@ async def create_bet(
) -> BetResponse:
listener = request.app.state.electrum_listener
if listener.client is None:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "not connected to the network, try again shortly")
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"network_unavailable",
"not connected to the network, try again shortly",
)
async with request.app.state.user_locks.acquire(user.id):
try:
participant = await place_bet(session, listener.client, user)
except BetError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
raise from_api_error(status.HTTP_400_BAD_REQUEST, exc) from exc
return BetResponse(
round_id=participant.round_id,
+11 -3
View File
@@ -1,8 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
from app.auth.dependencies import get_current_user
from app.auth.security import hash_password, verify_password
from app.db.models import Round, RoundParticipant, User
@@ -56,9 +57,16 @@ async def change_password(
admin-only /admin/users/{id}/reset-password (which is for a user who's
actually locked out and can't provide it)."""
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "current password is incorrect")
raise http_error(
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
)
if len(body.new_password) < _MIN_PASSWORD_LENGTH:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"new password must be at least {_MIN_PASSWORD_LENGTH} characters")
raise http_error(
status.HTTP_400_BAD_REQUEST,
"password_too_short",
f"new password must be at least {_MIN_PASSWORD_LENGTH} characters",
minimum=_MIN_PASSWORD_LENGTH,
)
user.password_hash = hash_password(body.new_password)
await session.commit()
+8 -3
View File
@@ -1,7 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import from_api_error, http_error
from app.auth.dependencies import get_current_user
from app.db.models import User
from app.db.session import get_session
@@ -31,7 +32,11 @@ async def create_withdrawal(
) -> WithdrawalResponse:
listener = request.app.state.electrum_listener
if listener.client is None:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "not connected to the network, try again shortly")
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"network_unavailable",
"not connected to the network, try again shortly",
)
async with request.app.state.user_locks.acquire(user.id):
try:
@@ -39,7 +44,7 @@ async def create_withdrawal(
session, listener.client, user, body.external_address, body.amount_sats
)
except WithdrawalError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
raise from_api_error(status.HTTP_400_BAD_REQUEST, exc) from exc
return WithdrawalResponse(
txid=withdrawal.txid,
+4 -3
View File
@@ -1,8 +1,9 @@
from fastapi import Depends, HTTPException, Request, status
from fastapi import Depends, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
from app.auth.security import decode_access_token
from app.db.models import User
from app.db.session import get_session
@@ -17,11 +18,11 @@ async def get_current_user(
try:
user_id = decode_access_token(credentials.credentials)
except Exception as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token") from exc
raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "invalid token") from exc
user = await session.scalar(select(User).where(User.id == user_id))
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "user not found")
return user
+9 -4
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
from app.auth.security import create_access_token, hash_password, verify_password
from app.db.models import User
from app.db.session import get_session
@@ -30,7 +31,7 @@ async def register(
) -> TokenResponse:
existing = await session.scalar(select(User).where(User.username == body.username))
if existing is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "username already taken")
raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken")
password_hash = hash_password(body.password)
@@ -54,7 +55,11 @@ async def register(
request.app.state.electrum_listener.address_for_new_user(user.id, user.address)
return TokenResponse(access_token=create_access_token(user.id), address=user.address)
raise HTTPException(status.HTTP_409_CONFLICT, "could not allocate a derivation index, retry")
raise http_error(
status.HTTP_409_CONFLICT,
"derivation_index_conflict",
"could not allocate a derivation index, retry",
)
class LoginRequest(BaseModel):
@@ -66,5 +71,5 @@ class LoginRequest(BaseModel):
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)) -> TokenResponse:
user = await session.scalar(select(User).where(User.username == body.username))
if user is None or not verify_password(body.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
raise http_error(status.HTTP_401_UNAUTHORIZED, "invalid_credentials", "invalid credentials")
return TokenResponse(access_token=create_access_token(user.id), address=user.address)
+7 -6
View File
@@ -4,6 +4,7 @@ from embit import script
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import ApiError
from app.audit.log import write_audit_log
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
from app.electrum.client import ElectrumClient
@@ -15,18 +16,18 @@ from app.wallet.hd import derive_pool_address, derive_user_key
from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction
class BetError(Exception):
class BetError(ApiError):
pass
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
round_ = await open_new_round_if_needed(session)
if round_ is None:
raise BetError("no round open right now, please try again shortly")
raise BetError("no_round_open", "no round open right now, please try again shortly")
config = await get_round_config(session)
if not round_accepts_bets(round_, config.round_duration_seconds):
raise BetError("the current round is closing, please try again shortly")
raise BetError("round_closing", "the current round is closing, please try again shortly")
already_playing = await session.scalar(
select(RoundParticipant).where(
@@ -34,7 +35,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
)
)
if already_playing is not None:
raise BetError("you already have an active bet in the current round")
raise BetError("already_betting", "you already have an active bet in the current round")
bet_amount = config.bet_amount_sats
@@ -44,7 +45,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
)
).all()
if sum(u.amount_sats for u in unspent) < bet_amount:
raise BetError("insufficient balance")
raise BetError("insufficient_balance", "insufficient balance", required_sats=bet_amount)
user_key = derive_user_key(user.derivation_index)
from_script = script.p2wpkh(user_key.to_public())
@@ -61,7 +62,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
fee_rate_sat_vb=config.fee_rate_sat_vb,
)
except InsufficientFundsError as exc:
raise BetError(str(exc)) from exc
raise BetError(exc.code, str(exc)) from exc
await client.broadcast(built.raw_hex)
+3 -1
View File
@@ -26,7 +26,9 @@ async function callAdmin(method, path, body) {
const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken };
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
// detail is a bare string on the admin endpoints, but the shared dependencies
// (auth) answer with the structured {code, message} form of app/api/errors.py.
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
return data;
}
+162 -49
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,15 +46,49 @@ 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);
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
if (!res.ok) {
// A token the server no longer accepts can't be recovered from by retrying:
// without this every poll keeps failing against a dashboard that still looks
// logged in, toasting "session expired" forever. Drop back to the login form.
if (res.status === 401 && data.detail?.code === 'session_expired' && token) logout();
throw new Error(apiErrorMessage(data.detail) || res.statusText);
}
return data;
}
// The API is single-language by design: it answers with a stable machine code
// plus an English message (app/api/errors.py), and picking the words is the
// client's job. Unknown code (older/newer server, an endpoint not converted
// yet) → show the English message rather than nothing.
function apiErrorMessage(detail) {
if (!detail) return null;
if (typeof detail === 'string') return detail; // endpoints still returning a bare string
// FastAPI's own request-validation failures (422) use a list of field errors
// instead, in English and phrased for an API client ("Input should be a valid
// integer"). Nothing here can act on which field it was, so say the one useful
// thing — the request was malformed — in the user's language.
if (Array.isArray(detail)) return t('error.invalid_request');
return tOrNull('error.' + detail.code, errorParams(detail.params)) || detail.message || null;
}
// Amounts cross the wire in sats (`*_sats`); every translated string wants PLM,
// so expose both and let each language's phrasing pick. Done generically here
// so a new *_sats param needs no client change.
function errorParams(params) {
const out = { ...(params || {}) };
for (const [key, value] of Object.entries(params || {})) {
if (key.endsWith('_sats') && typeof value === 'number') {
out[key.slice(0, -5) + '_plm'] = value / SATS_PER_PLM;
}
}
return out;
}
function switchTab(name) {
document.getElementById('tab-login').classList.toggle('active', name === 'login');
document.getElementById('tab-register').classList.toggle('active', name === 'register');
@@ -83,11 +122,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,34 +136,59 @@ 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',
};
// The bar is rendered from remembered state rather than straight from the
// response that triggered it, so a language switch can repaint it immediately
// instead of waiting for the next poll. That wait used to make it lie: with the
// connection down, switching language reset the label to "connecting" until a
// further fetch failed.
let lastChainData = null;
let chainOffline = false;
function updateChainStatusBar(data) {
lastChainData = data;
chainOffline = false;
renderChainStatusBar();
}
function renderChainStatusBar() {
const dot = document.getElementById('chain-status-dot');
const label = document.getElementById('chain-status-label');
const block = document.getElementById('chain-block');
if (chainOffline) {
dot.className = 'status-dot status-offline';
label.textContent = t('chain.connectionLost');
return; // block height deliberately left showing its last known value
}
if (lastChainData === null) {
label.textContent = t('chain.connecting');
return;
}
const data = lastChainData;
// The dot's color/pulse only distinguishes waiting/open/drawing (that's all
// the CSS defines) — closing and paying_out both pulse like drawing, they
// just get their own text label below.
@@ -133,11 +197,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);
}
@@ -150,8 +214,8 @@ const STALE_AFTER_FAILURES = 2;
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…';
chainOffline = true;
renderChainStatusBar();
}
function noteFetchOutcome(ok) {
@@ -228,7 +292,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 +329,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 +383,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 +415,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 +465,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 +552,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 +571,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');
@@ -519,7 +600,12 @@ function resetToLoggedOutUI() {
}
function logout() {
// The chosen language is a device preference, not session state — clearing it
// on logout would drop the user back to the browser-detected default on the
// very screen where they'd have to find the switcher again.
const lang = localStorage.getItem(LANG_STORAGE_KEY);
localStorage.clear();
if (lang) localStorage.setItem(LANG_STORAGE_KEY, lang);
resetToLoggedOutUI();
}
@@ -555,9 +641,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 +664,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 +676,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 +701,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 +718,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 +727,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');
}
@@ -657,13 +743,19 @@ async function withdraw() {
const btn = document.getElementById('withdraw-btn');
const ext = document.getElementById('wd-address').value;
const isFullAmount = document.getElementById('wd-full-amount').checked;
const amtSats = isFullAmount
? myBalanceSats
: Math.round(parseFloat(document.getElementById('wd-amount').value) * SATS_PER_PLM);
await withLoading(btn, 'Invio…', async () => {
const amount = parseFloat(document.getElementById('wd-amount').value);
// Caught here rather than left to the server: an empty or non-numeric field
// parses to NaN, which JSON.stringify sends as null, which comes back as a
// 422 whose only readable text is an English HTTP status line.
if (!isFullAmount && !(amount > 0)) {
toast(t('error.invalid_amount'), 'error');
return;
}
const amtSats = isFullAmount ? myBalanceSats : Math.round(amount * SATS_PER_PLM);
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 +798,26 @@ 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();
renderChainStatusBar(); // repaints from remembered state, without waiting for the next poll
if (token) {
refreshRound();
refreshMe();
} else {
refreshChainStatusOnly();
}
const persisted = getPersistedResult();
if (persisted && !document.getElementById('draw-result').classList.contains('hidden')) {
renderPersistedResult(persisted);
}
}
renderBetButton();
renderChainStatusBar();
connectRoundEvents();
initAuthState();
+999
View File
@@ -0,0 +1,999 @@
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.',
// Keyed by the `code` the API returns in its structured error detail
// (app/api/errors.py). A code with no entry here falls back to the API's
// own English `message`, so a new server-side error is never a blank toast.
'error.network_unavailable': 'Not connected to the network, please try again shortly.',
'error.no_round_open': 'No round is open right now, please try again shortly.',
'error.round_closing': 'The current round is closing, please try again shortly.',
'error.already_betting': 'You already have an active bet in the current round.',
'error.insufficient_balance': 'Insufficient balance.',
'error.amount_below_network_fee': 'The amount is too small to cover the network fee.',
'error.invalid_address': 'Not a valid PLM address (it must start with plm1q…).',
'error.amount_below_minimum': 'The minimum withdrawal amount is {minimum_plm} PLM.',
'error.current_password_incorrect': 'The current password is incorrect.',
'error.password_too_short': 'The new password must be at least {minimum} characters.',
'error.username_taken': 'This username is already taken.',
'error.invalid_credentials': 'Wrong username or password.',
'error.derivation_index_conflict': 'Registration failed, please try again.',
'error.session_expired': 'Session expired, please log in again.',
'error.invalid_request': 'Invalid request, please check the entered data.',
'error.invalid_amount': 'Enter an amount greater than zero.',
'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.',
'error.network_unavailable': 'Nessuna connessione alla rete, riprova tra poco.',
'error.no_round_open': 'Nessun round aperto in questo momento, riprova tra poco.',
'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.',
'error.already_betting': 'Hai già una bet attiva nel round corrente.',
'error.insufficient_balance': 'Saldo insufficiente.',
'error.amount_below_network_fee': "L'importo è troppo basso per coprire la fee di rete.",
'error.invalid_address': 'Indirizzo PLM non valido (deve iniziare con plm1q…).',
'error.amount_below_minimum': "L'importo minimo di prelievo è {minimum_plm} PLM.",
'error.current_password_incorrect': 'La password attuale non è corretta.',
'error.password_too_short': 'La nuova password deve avere almeno {minimum} caratteri.',
'error.username_taken': 'Questo username è già stato preso.',
'error.invalid_credentials': 'Username o password errati.',
'error.derivation_index_conflict': 'Registrazione non riuscita, riprova.',
'error.session_expired': 'Sessione scaduta, accedi di nuovo.',
'error.invalid_request': 'Richiesta non valida, controlla i dati inseriti.',
'error.invalid_amount': 'Inserisci un importo maggiore di zero.',
'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.',
'error.network_unavailable': 'Sin conexión con la red, inténtalo de nuevo en un momento.',
'error.no_round_open': 'No hay ninguna ronda abierta ahora mismo, inténtalo de nuevo en un momento.',
'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.',
'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.',
'error.insufficient_balance': 'Saldo insuficiente.',
'error.amount_below_network_fee': 'El importe es demasiado pequeño para cubrir la comisión de red.',
'error.invalid_address': 'Dirección PLM no válida (debe empezar por plm1q…).',
'error.amount_below_minimum': 'El importe mínimo de retiro es {minimum_plm} PLM.',
'error.current_password_incorrect': 'La contraseña actual no es correcta.',
'error.password_too_short': 'La nueva contraseña debe tener al menos {minimum} caracteres.',
'error.username_taken': 'Este nombre de usuario ya está en uso.',
'error.invalid_credentials': 'Usuario o contraseña incorrectos.',
'error.derivation_index_conflict': 'No se pudo completar el registro, inténtalo de nuevo.',
'error.session_expired': 'Sesión caducada, vuelve a iniciar sesión.',
'error.invalid_request': 'Solicitud no válida, revisa los datos introducidos.',
'error.invalid_amount': 'Introduce un importe mayor que cero.',
'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é.',
'error.network_unavailable': 'Pas de connexion au réseau, réessayez dans un instant.',
'error.no_round_open': "Aucun round n'est ouvert pour le moment, réessayez dans un instant.",
'error.round_closing': 'Le round en cours est en train de se fermer, réessayez dans un instant.',
'error.already_betting': 'Vous avez déjà une mise active dans le round en cours.',
'error.insufficient_balance': 'Solde insuffisant.',
'error.amount_below_network_fee': 'Le montant est trop faible pour couvrir les frais de réseau.',
'error.invalid_address': 'Adresse PLM invalide (elle doit commencer par plm1q…).',
'error.amount_below_minimum': 'Le montant minimum de retrait est de {minimum_plm} PLM.',
'error.current_password_incorrect': "Le mot de passe actuel n'est pas correct.",
'error.password_too_short': 'Le nouveau mot de passe doit comporter au moins {minimum} caractères.',
'error.username_taken': "Ce nom d'utilisateur est déjà pris.",
'error.invalid_credentials': "Nom d'utilisateur ou mot de passe incorrect.",
'error.derivation_index_conflict': "L'inscription a échoué, veuillez réessayer.",
'error.session_expired': 'Session expirée, veuillez vous reconnecter.',
'error.invalid_request': 'Requête invalide, vérifiez les données saisies.',
'error.invalid_amount': 'Saisissez un montant supérieur à zéro.',
'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.',
'error.network_unavailable': 'Keine Verbindung zum Netzwerk, bitte versuche es gleich erneut.',
'error.no_round_open': 'Derzeit ist keine Runde offen, bitte versuche es gleich erneut.',
'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.',
'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.',
'error.insufficient_balance': 'Nicht genügend Guthaben.',
'error.amount_below_network_fee': 'Der Betrag ist zu klein, um die Netzwerkgebühr zu decken.',
'error.invalid_address': 'Keine gültige PLM-Adresse (sie muss mit plm1q… beginnen).',
'error.amount_below_minimum': 'Der Mindestauszahlungsbetrag beträgt {minimum_plm} PLM.',
'error.current_password_incorrect': 'Das aktuelle Passwort ist nicht korrekt.',
'error.password_too_short': 'Das neue Passwort muss mindestens {minimum} Zeichen lang sein.',
'error.username_taken': 'Dieser Benutzername ist bereits vergeben.',
'error.invalid_credentials': 'Benutzername oder Passwort falsch.',
'error.derivation_index_conflict': 'Registrierung fehlgeschlagen, bitte erneut versuchen.',
'error.session_expired': 'Sitzung abgelaufen, bitte melde dich erneut an.',
'error.invalid_request': 'Ungültige Anfrage, bitte überprüfe die eingegebenen Daten.',
'error.invalid_amount': 'Gib einen Betrag größer als null ein.',
'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': 'Вывод средств отправлен.',
'error.network_unavailable': 'Нет соединения с сетью, повторите попытку чуть позже.',
'error.no_round_open': 'Сейчас нет открытого раунда, повторите попытку чуть позже.',
'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.',
'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.',
'error.insufficient_balance': 'Недостаточно средств.',
'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.',
'error.invalid_address': 'Некорректный адрес PLM (он должен начинаться с plm1q…).',
'error.amount_below_minimum': 'Минимальная сумма вывода — {minimum_plm} PLM.',
'error.current_password_incorrect': 'Текущий пароль указан неверно.',
'error.password_too_short': 'Новый пароль должен содержать не менее {minimum} символов.',
'error.username_taken': 'Это имя пользователя уже занято.',
'error.invalid_credentials': 'Неверное имя пользователя или пароль.',
'error.derivation_index_conflict': 'Не удалось завершить регистрацию, попробуйте ещё раз.',
'error.session_expired': 'Сессия истекла, войдите снова.',
'error.invalid_request': 'Некорректный запрос, проверьте введённые данные.',
'error.invalid_amount': 'Введите сумму больше нуля.',
'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': '提现已发送。',
'error.network_unavailable': '未连接到网络,请稍后重试。',
'error.no_round_open': '当前没有开放的回合,请稍后重试。',
'error.round_closing': '当前回合正在结束,请稍后重试。',
'error.already_betting': '你在当前回合已有一笔有效下注。',
'error.insufficient_balance': '余额不足。',
'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。',
'error.invalid_address': 'PLM 地址无效(必须以 plm1q… 开头)。',
'error.amount_below_minimum': '最低提现金额为 {minimum_plm} PLM。',
'error.current_password_incorrect': '当前密码不正确。',
'error.password_too_short': '新密码长度至少需要 {minimum} 个字符。',
'error.username_taken': '该用户名已被占用。',
'error.invalid_credentials': '用户名或密码错误。',
'error.derivation_index_conflict': '注册失败,请重试。',
'error.session_expired': '会话已过期,请重新登录。',
'error.invalid_request': '请求无效,请检查填写的内容。',
'error.invalid_amount': '请输入大于零的金额。',
'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);
}
// Like t(), but returns null instead of echoing the key back when nothing is
// defined for it — lets a caller fall back to a string of its own (e.g. the
// API's English message for an error code this build doesn't know yet).
function tOrNull(key, params) {
const str = TRANSLATIONS[currentLang]?.[key] ?? TRANSLATIONS.en[key];
return str === undefined ? null : interpolate(str, 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);
+85 -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,84 @@
<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>
<!-- No data-i18n on this one or on #draw-label below: both are written by
app.js from live state, and letting applyStaticTranslations() also own
them made a language switch flash (or, here, assert) a stale value. -->
<span id="chain-status-label">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 +149,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">Drawing the winner</div>
</div>
<div class="hidden" id="draw-result"></div>
@@ -151,81 +168,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 +254,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);
+27
View File
@@ -0,0 +1,27 @@
"""Validation for PLM addresses supplied by the user (withdrawal destinations).
embit's `Script.from_address` accepts a well-formed bech32 address from *any*
chain — a Bitcoin `bc1...` parses fine and yields a perfectly valid witness
program — so parse-success alone is not a sufficient check here: a withdrawal
to a `bc1...` address would build, sign and broadcast normally on PLM and land
on a script nobody holds the key for. The HRP check below is what makes the
destination actually PLM, and it matches what the withdrawal form already
tells the user (bech32 `plm1q...` only).
"""
from embit import script
from embit.base import EmbitError
from app.wallet.plm_network import PLM_MAINNET
_BECH32_PREFIX = PLM_MAINNET["bech32"] + "1"
def is_valid_plm_address(address: str) -> bool:
if not address.startswith(_BECH32_PREFIX):
return False
try:
script.Script.from_address(address)
except EmbitError:
return False
return True
+13 -3
View File
@@ -19,7 +19,13 @@ RBF_SEQUENCE = 0xFFFFFFFD
class InsufficientFundsError(Exception):
pass
"""`code` is the machine-readable identifier the API layer forwards to the
client so it can translate the failure (see app/api/errors.py); the message
itself stays English."""
def __init__(self, message: str, code: str = "insufficient_balance") -> None:
super().__init__(message)
self.code = code
@dataclass
@@ -80,7 +86,9 @@ def build_signed_transaction(
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
recipient_amount = amount_sats - fee
if recipient_amount <= 0:
raise InsufficientFundsError("amount too small to cover the network fee")
raise InsufficientFundsError(
"amount too small to cover the network fee", code="amount_below_network_fee"
)
change = total_in - amount_sats
# TransactionInput.txid is natural/display byte order (as in tx_hash from Electrum);
@@ -145,7 +153,9 @@ def build_payout_transaction(
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
winner_amount = winner_share_sats - fee
if winner_amount <= 0:
raise InsufficientFundsError("winner share too small to cover the network fee")
raise InsufficientFundsError(
"winner share too small to cover the network fee", code="winner_share_below_network_fee"
)
change = total_in - target
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
+16 -4
View File
@@ -2,26 +2,38 @@ from embit import script
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import ApiError
from app.audit.log import write_audit_log
from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.wallet.address import is_valid_plm_address
from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_user_key
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_signed_transaction
class WithdrawalError(Exception):
class WithdrawalError(ApiError):
pass
async def request_withdrawal(
session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int
) -> Withdrawal:
# Checked before anything else: an address from another chain parses fine as a
# witness program (see wallet/address.py), so without this the tx would build,
# broadcast and be irrecoverable rather than fail.
if not is_valid_plm_address(external_address):
raise WithdrawalError("invalid_address", "not a valid PLM bech32 address")
config = await get_round_config(session)
if amount_sats < config.bet_amount_sats:
raise WithdrawalError(f"amount below the minimum of {config.bet_amount_sats} sats")
raise WithdrawalError(
"amount_below_minimum",
f"amount below the minimum of {config.bet_amount_sats} sats",
minimum_sats=config.bet_amount_sats,
)
unspent = (
await session.scalars(
@@ -29,7 +41,7 @@ async def request_withdrawal(
)
).all()
if sum(u.amount_sats for u in unspent) < amount_sats:
raise WithdrawalError("insufficient balance")
raise WithdrawalError("insufficient_balance", "insufficient balance", required_sats=amount_sats)
user_key = derive_user_key(user.derivation_index)
from_script = script.p2wpkh(user_key.to_public())
@@ -46,7 +58,7 @@ async def request_withdrawal(
fee_rate_sat_vb=config.fee_rate_sat_vb,
)
except InsufficientFundsError as exc:
raise WithdrawalError(str(exc)) from exc
raise WithdrawalError(exc.code, str(exc)) from exc
await client.broadcast(built.raw_hex)
+25
View File
@@ -97,3 +97,28 @@ async def test_withdrawal_rejects_insufficient_balance(session_factory):
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError, match="insufficient balance"):
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)
@pytest.mark.parametrize(
"address",
[
"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", # valid bech32, wrong chain
"plm1qbogus", # right HRP, broken checksum
"not-an-address",
],
)
async def test_withdrawal_rejects_non_plm_address(session_factory, address):
"""The bc1 case is the one that matters: embit parses it into a perfectly
valid witness program, so without the HRP check the withdrawal would build,
sign and broadcast on PLM, sending the funds somewhere nobody holds a key
for. It has to fail before a single UTXO is touched."""
user_id = await _make_funded_user(session_factory, 3, 2_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError) as exc_info:
await request_withdrawal(session, client, user, address, BET_AMOUNT_SATS)
assert exc_info.value.code == "invalid_address"
assert not client.broadcasted