Compare commits
5
Commits
8a3dfd4592
...
d4e0974881
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4e0974881 | ||
|
|
28c1179e9b | ||
|
|
5c9ccc0344 | ||
|
|
0cf35147ad | ||
|
|
7048fe7ea6 |
@@ -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 4–6 minutes worst case, 2–4 minutes best case — independent of `draw_animation_seconds`, which only sets a cosmetic minimum for the frontend animation.
|
||||
|
||||
## Internationalization (user-facing page only)
|
||||
|
||||
`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is available everywhere. Language comes from `localStorage.plm_lang`, falling back to `navigator.language`, falling back to `en`; the switcher lives in the **chain-bar, not the navbar**, deliberately — the navbar is hidden until login, which would leave the landing page and the login form untranslatable for exactly the users who need the switch.
|
||||
|
||||
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`), applied by `applyStaticTranslations(root?)` on `DOMContentLoaded` and on every switch. Anything rendered from server data is built with `t()` in `app.js` instead, and re-rendered by `onLanguageChange()` — an element must be in one camp or the other, never both, or the two mechanisms overwrite each other (this is why `#bet-btn` has no `data-i18n`: its label carries the admin-configurable bet amount, so `renderBetButton()` owns it).
|
||||
- **Every language must have exactly the same key set.** There is no fallback beyond `en`, and a missing key renders as the raw key string.
|
||||
- `/admin` is intentionally **not** translated (operator-facing, Italian only), and neither is `/guida` (serves `docs/guida-utente.md`).
|
||||
|
||||
**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):
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user