Author SHA1 Message Date
davideandClaude Sonnet 5 e5af15087c Polish /report-bug's visual design and refine bug report semantics
Design pass on the bug report page, staying inside the site's existing
design system (tokens, IBM Plex Sans, card/badge/pill components, stroke
icon set) rather than introducing a new one:
- A slim top bar (brand mark + back-to-home pill button + language switcher)
  replaces the bare floating heading, so the page reads as part of the
  product instead of an orphaned form.
- The "write in English" notice moves inside the form card, right above the
  field it applies to, and switches from the amber "needs attention" tone to
  an accent-tinted info tone, so it doesn't visually collide with the
  bug-status badges' own use of amber for "not read yet".
- "Your reports" is promoted to a proper labeled section with a cleaner row
  layout (truncated description with a title tooltip, compact date).
- A character counter on the description field.
- The back-to-home control is now a bordered pill with an arrow icon instead
  of a bare text link with a hardcoded "←", which also meant dropping that
  hardcoded arrow from all 7 translations.

Also, two content refinements based on feedback:
- Max description length dropped from 5000 to 2000 characters, enforced on
  both the textarea and the API's Pydantic validator.
- The "read" status is relabeled from a passive "read"/"letta" to an active
  "acknowledged"/"presa in carico" (and each other language's own equivalent
  helpdesk term) — it communicates a team is on it, not just that someone
  glanced at it. Only the label changed; the underlying "read" status value
  in the API/DB is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 16:28:37 +02:00
davideandClaude Sonnet 5 a384b08044 Translate /report-bug into all 7 languages, require English in the report itself
The bug report form previously shipped as plain Italian only. It now shares
i18n.js with / (same TRANSLATIONS table, new bugReport.* keys in all 7
languages, own language switcher since the page has no navbar to hang one
off), so a non-Italian speaker can read the form and their own report
history in their language.

The description field itself still has to reach the admin panel in English
(operator-facing, untranslated by design), so the page states that
explicitly via a standing banner (bugReport.englishNotice) — translated
into every language rather than left in English, so the instruction to
write in English is itself understandable to whoever's reading it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 16:03:38 +02:00
davideandClaude Sonnet 5 ee4e845c89 Add user bug reporting with admin triage (open/read/resolved)
Turns the /report-bug placeholder into a real form (POST /bug-reports,
optionally attributed to the logged-in user) and adds a "Segnalazioni bug"
section to /admin to view and triage them. A logged-in reporter can also
check their own report's status via GET /bug-reports/mine, since anonymous
submissions have no user to show a history to.

Status is a three-state lifecycle (open -> read -> resolved) rather than a
plain boolean, so an admin can acknowledge a report distinctly from actually
fixing it. The schema went through two migrations because the first one
(add bug_reports table) had already been applied against the running
instance with a `resolved` boolean before the three-state design was
decided, so a follow-up migration backfills it into `status` instead of
rewriting already-applied history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 15:50:40 +02:00
davideandClaude Sonnet 5 977bb762c7 Deduplicate the 70/30 prize split formula
pool_amount_sats * 70 // 100 was hardcoded identically in both
rounds/scheduler.py (the actual payout) and api/routes/rounds.py (the
advertised jackpot). They happened to agree, but nothing enforced it —
changing one without the other would have made GET /rounds/current's
jackpot silently diverge from the real payout. Extract winner_share()
into rounds/service.py as the single source of truth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 15:04:33 +02:00
davideandClaude Sonnet 5 fe909bedcf Don't double-count a bet/withdrawal's own change in pending balance (B-51)
A change output's confirmation is credited by two independent, unordered
paths: the Electrum listener (event-driven, near-instant — credits it as
a UtxoEvent and folds it into cached_balance_sats via recompute_balance)
and this module's PendingTransaction.status flip (tx/confirmation.py,
polled every 10s). The listener normally wins that race, so for the gap
until the poller catches up, compute_pending_balance kept adding the same
change on top of a cached_balance_sats that already included it —
observed live as a user's displayed balance briefly jumping by exactly
the change amount before self-correcting a few seconds later.

Fix: skip any change output whose (txid, vout) already has a UtxoEvent
for this user before summing pending_change_sats.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 15:03:53 +02:00
davideandClaude Sonnet 5 9207bbcb8f Don't reveal the win banner before winner_amount_sats is known (B-50)
winner_user_id is committed as soon as the draw picks a winner, but
winner_amount_sats isn't set until the payout tx is built afterwards
(a real Electrum listunspent round-trip later, in a separate DB
transaction). The frontend revealed the win banner as soon as
winner_user_id appeared, formatPlm(undefined) rendered as "—", and
the toast/result box briefly showed "You won! +— PLM" until the next
poll picked up the real amount. Gate the winner's own reveal on
winner_amount_sats also being non-null; a loss can still reveal
immediately since it never needs the amount.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 15:03:42 +02:00
20 changed files with 992 additions and 25 deletions
+4 -3
View File
@@ -210,12 +210,13 @@ Two static SPAs served directly by FastAPI (`main.py` mounts `app/static/` and a
Both talk to the same JSON API; there's no admin/user API split beyond `require_admin`.
## Internationalization (`/` only)
## Internationalization (`/` and `/report-bug`)
`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 always available. Language: `localStorage.plm_lang``navigator.language``en`. The switcher sits in the **chain-bar, not the navbar**, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it.
`app/static/i18n.js` holds every user-facing string of `/` and `/report-bug` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch. `/` loads it before `app.js`; `/report-bug` loads it before its own inline script — either way `t()` is always available by the time it's called. Language: `localStorage.plm_lang``navigator.language``en`, shared across both pages since they read/write the same `localStorage` key. On `/` the switcher sits in the **chain-bar, not the navbar**, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it. `/report-bug` has no navbar at all, so its switcher is just a top-right bar of its own.
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`) via `applyStaticTranslations(root?)`; anything rendered from server data uses `t()` in `app.js` and is re-rendered by `onLanguageChange()`. An element belongs to one camp or the other, **never both**, or the two mechanisms overwrite each other — that's why `#bet-btn` has no `data-i18n`: its label carries the configurable bet amount, so `renderBetButton()` owns it.
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`) via `applyStaticTranslations(root?)`; anything rendered from server data uses `t()` directly (`app.js`'s `onLanguageChange()`, `report-bug.html`'s own inline equivalent) and is re-rendered on a language switch. An element belongs to one camp or the other, **never both**, or the two mechanisms overwrite each other — that's why `#bet-btn` has no `data-i18n`: its label carries the configurable bet amount, so `renderBetButton()` owns it.
- **Every language must have exactly the same key set.** There is no fallback beyond `en`; a missing key renders as the raw key string.
- `/report-bug`'s `bugReport.englishNotice` string is itself translated into all 7 languages — it just always *says*, in whichever language the visitor reads, to write the actual bug description in English (so the admin panel, which is Italian-operator-facing and untranslated, doesn't end up with reports in 7 different languages).
- `/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`.
**API error contract** (`app/api/errors.py`) — the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients never special-case unexpected errors, and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text.
+72 -1
View File
@@ -10,7 +10,7 @@ from app.api.timeutil import isoformat_utc
from app.audit.log import write_audit_log
from app.auth.security import hash_password
from app.config import settings
from app.db.models import AuditLog, PendingTransaction, Round, User
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.wallet.address import is_valid_plm_address
@@ -346,3 +346,74 @@ async def list_pending_transactions(
)
for p in entries
]
_BUG_REPORT_STATUSES = ("open", "read", "resolved")
class AdminBugReportResponse(BaseModel):
id: int
description: str
contact: str | None
user_id: int | None
username: str | None
status: str
created_at: str
def _bug_report_response(report: BugReport, username: str | None) -> AdminBugReportResponse:
return AdminBugReportResponse(
id=report.id,
description=report.description,
contact=report.contact,
user_id=report.user_id,
username=username,
status=report.status,
created_at=isoformat_utc(report.created_at),
)
@router.get(
"/bug-reports", response_model=list[AdminBugReportResponse], dependencies=[Depends(require_admin)]
)
async def list_bug_reports(
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
) -> list[AdminBugReportResponse]:
reports = (await session.scalars(select(BugReport).order_by(BugReport.id.desc()).limit(limit))).all()
user_ids = {r.user_id for r in reports if r.user_id is not None}
usernames = {}
if user_ids:
users = (await session.scalars(select(User).where(User.id.in_(user_ids)))).all()
usernames = {u.id: u.username for u in users}
return [
_bug_report_response(r, usernames.get(r.user_id) if r.user_id is not None else None)
for r in reports
]
class BugReportStatusUpdate(BaseModel):
status: str = Field(pattern="^(open|read|resolved)$")
@router.post(
"/bug-reports/{report_id}/status",
response_model=AdminBugReportResponse,
dependencies=[Depends(require_admin)],
)
async def update_bug_report_status(
report_id: int, body: BugReportStatusUpdate, session: AsyncSession = Depends(get_session)
) -> AdminBugReportResponse:
report = await session.get(BugReport, report_id)
if report is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
report.status = body.status
await session.commit()
username = None
if report.user_id is not None:
user = await session.get(User, report.user_id)
username = user.username if user is not None else None
return _bug_report_response(report, username)
+79
View File
@@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.timeutil import isoformat_utc
from app.auth.dependencies import get_current_user, get_optional_user
from app.db.models import BugReport, User
from app.db.session import get_session
router = APIRouter(prefix="/bug-reports", tags=["bug-reports"])
class BugReportCreate(BaseModel):
description: str = Field(min_length=1, max_length=2000)
contact: str | None = Field(default=None, max_length=256)
@field_validator("description")
@classmethod
def _description_not_blank(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("description must not be blank")
return value
@field_validator("contact")
@classmethod
def _contact_stripped(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
return value or None
class BugReportResponse(BaseModel):
id: int
@router.post("", response_model=BugReportResponse, status_code=status.HTTP_201_CREATED)
async def create_bug_report(
body: BugReportCreate,
user: User | None = Depends(get_optional_user),
session: AsyncSession = Depends(get_session),
) -> BugReportResponse:
report = BugReport(
description=body.description,
contact=body.contact,
user_id=user.id if user is not None else None,
)
session.add(report)
await session.commit()
return BugReportResponse(id=report.id)
class MyBugReportResponse(BaseModel):
id: int
description: str
status: str
created_at: str
@router.get("/mine", response_model=list[MyBugReportResponse])
async def list_my_bug_reports(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> list[MyBugReportResponse]:
"""The one user-facing history view for bug reports (anonymous submissions have
no user to attribute this to, so this only ever covers ones filed while logged in)."""
reports = (
await session.scalars(
select(BugReport).where(BugReport.user_id == user.id).order_by(BugReport.id.desc())
)
).all()
return [
MyBugReportResponse(
id=r.id, description=r.description, status=r.status, created_at=isoformat_utc(r.created_at)
)
for r in reports
]
+2 -2
View File
@@ -15,7 +15,7 @@ from app.db.models import RoundParticipant, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
from app.rounds.service import get_active_round
from app.rounds.service import get_active_round, winner_share
router = APIRouter(prefix="/rounds", tags=["rounds"])
@@ -165,7 +165,7 @@ async def current_round(
# upper bound by the payout tx's own fee, which is deducted from the winner's
# share and isn't knowable until the payout is built — a few hundred sat on a
# 1 sat/vB payout, i.e. invisible at PLM amounts, but it is not exact.
jackpot_sats = pool_amount_sats * 70 // 100
jackpot_sats = winner_share(pool_amount_sats)
return CurrentRoundResponse(
server_time=datetime.now(timezone.utc).isoformat(),
+18
View File
@@ -188,6 +188,24 @@ class Withdrawal(Base):
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
class BugReport(Base):
__tablename__ = "bug_reports"
id: Mapped[int] = mapped_column(primary_key=True)
description: Mapped[str] = mapped_column(Text)
contact: Mapped[str | None] = mapped_column(String(256), default=None)
# Set when the reporter was logged in at submission time; the report page is
# reachable both logged-in and logged-out (like GET /rounds/current), so this
# stays nullable rather than requiring auth just to file a report.
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
# open -> read -> resolved, admin-driven (app/api/routes/admin.py). "read" is a
# distinct step from "resolved" so a reporter checking their own status (only
# possible when logged in — see GET /bug-reports/mine) can tell "an admin has
# seen this" apart from "this has actually been fixed".
status: Mapped[str] = mapped_column(String(16), default="open")
created_at: Mapped[datetime] = mapped_column(default=utcnow)
class AuditLog(Base):
__tablename__ = "audit_log"
+2
View File
@@ -15,6 +15,7 @@ import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmati
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler)
from app.api.routes.admin import router as admin_router
from app.api.routes.bets import router as bets_router
from app.api.routes.bug_reports import router as bug_reports_router
from app.api.routes.qr import router as qr_router
from app.api.routes.rounds import router as rounds_router
from app.api.routes.users import router as users_router
@@ -99,6 +100,7 @@ app.include_router(users_router)
app.include_router(bets_router)
app.include_router(withdrawals_router)
app.include_router(admin_router)
app.include_router(bug_reports_router)
app.include_router(qr_router)
app.include_router(rounds_router)
+4 -4
View File
@@ -14,7 +14,7 @@ from app.electrum.scripthash import address_to_scripthash
from app.rounds.config import get_round_config
from app.rounds.draw import draw_winner, header_hex_to_block_hash
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed
from app.rounds.service import open_new_round_if_needed, winner_share
from app.wallet.hd import derive_pool_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
@@ -338,8 +338,8 @@ class RoundScheduler:
await self._log_payout_failure(round_id, winner_user_id, "winner user not found")
return
winner_share = pool_amount_sats * 70 // 100
commission_share = pool_amount_sats - winner_share # remainder from rounding goes to fees
winner_sats = winner_share(pool_amount_sats)
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
# --- Phase 2: build (network read only, no DB write yet) -----------------
try:
@@ -358,7 +358,7 @@ class RoundScheduler:
from_script=pool_script_obj,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
winner_share_sats=winner_sats,
fee_address=fee_address,
commission_sats=commission_share,
change_address=pool_address,
+8
View File
@@ -18,6 +18,14 @@ _ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
# broken in a way we don't anticipate.
_OPEN_ROUND_ATTEMPTS = 3
# 70% winner / 30% fees. Hardcoded by design (see CLAUDE.md) — changing the split
# is a code change, not an admin-editable setting. Single source of truth so the
# advertised jackpot (rounds.py) and the actual payout (scheduler.py) can't diverge.
def winner_share(pool_amount_sats: int) -> int:
return pool_amount_sats * 70 // 100
async def get_active_round(session: AsyncSession) -> Round | None:
"""The round currently in progress (in any non-closed state), if any. Rounds
+8
View File
@@ -133,6 +133,7 @@ table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--color-border); vertical-align: top; }
th { color: var(--color-muted-foreground); font-weight: 500; }
td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; max-width: 200px; }
td.payload-cell { max-width: 360px; white-space: pre-wrap; word-break: break-word; }
.table-wrap { overflow-x: auto; }
.badge {
@@ -144,6 +145,13 @@ td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; m
background: #FEF3C7; color: #92400E; border-color: #F59E0B;
}
.badge.bug-status-open { background: #FEF3C7; color: #92400E; border-color: #F59E0B; }
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
.bug-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.bug-actions button { width: auto; margin-top: 0; min-height: 30px; padding: 4px 10px; font-size: 0.78rem; }
button.reveal {
width: auto; margin-top: 0; padding: 4px 10px; min-height: 30px; font-size: 0.78rem;
background: var(--color-destructive-bg); color: var(--color-destructive); border: 1px solid var(--color-destructive);
+17
View File
@@ -30,6 +30,7 @@
<span class="nav-tab" id="nav-round" onclick="switchView('round')">Round</span>
<span class="nav-tab" id="nav-pending" onclick="switchView('pending')">Transazioni pendenti</span>
<span class="nav-tab" id="nav-audit" onclick="switchView('audit')">Audit log</span>
<span class="nav-tab" id="nav-bugreports" onclick="switchView('bugreports')">Segnalazioni bug</span>
<span class="spacer"></span>
<span class="chain-status-pill">
<span class="status-dot" id="chain-status-dot"></span>
@@ -153,6 +154,22 @@
</div>
</div>
<div class="view" id="view-bugreports">
<h2 class="section-title">Segnalazioni bug</h2>
<p class="hint">Segnalazioni inviate dagli utenti tramite la pagina "Segnala un bug".</p>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr><th>ID</th><th>Descrizione</th><th>Contatto</th><th>Utente</th><th>Quando</th><th>Stato</th></tr>
</thead>
<tbody id="bugreports-tbody"></tbody>
</table>
</div>
</div>
</div>
</main>
</div>
+46 -3
View File
@@ -89,8 +89,8 @@ function stopChainStatusPolling() {
chainStatusInterval = null;
}
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit'];
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog };
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports'];
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports };
let currentAdminView = 'parametri';
function switchView(name) {
@@ -109,7 +109,7 @@ function showDashboard() {
}
async function loadDashboard() {
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog()]);
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog(), loadBugReports()]);
}
async function adminLogin() {
@@ -346,6 +346,49 @@ async function loadAuditLog() {
}
}
const BUG_REPORT_STATUS_LABELS = { open: 'Da leggere', read: 'Presa in carico', resolved: 'Risolta' };
function bugReportBadge(status) {
return `<span class="badge bug-status-${escapeHtml(status)}">${escapeHtml(BUG_REPORT_STATUS_LABELS[status] || status)}</span>`;
}
async function loadBugReports() {
try {
const reports = await callAdmin('GET', '/admin/bug-reports');
const tbody = document.getElementById('bugreports-tbody');
tbody.innerHTML = reports.map((r) => `
<tr>
<td>${r.id}</td>
<td class="payload-cell">${escapeHtml(r.description)}</td>
<td>${r.contact ? escapeHtml(r.contact) : '—'}</td>
<td>${r.username ? escapeHtml(r.username) : '—'}</td>
<td>${fmtDate(r.created_at)}</td>
<td>
${bugReportBadge(r.status)}
<div class="bug-actions">
${r.status === 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'read', this)">Segna come presa in carico</button>` : ''}
${r.status !== 'resolved' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'resolved', this)">Segna come risolta</button>` : ''}
${r.status !== 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'open', this)">Riapri</button>` : ''}
</div>
</td>
</tr>
`).join('') || '<tr><td colspan="6" class="hint">Nessuna segnalazione ricevuta.</td></tr>';
} catch (e) {
toast('Errore nel caricamento segnalazioni: ' + e.message, 'error');
}
}
async function setBugReportStatus(reportId, newStatus, button) {
await withLoading(button, '…', async () => {
try {
await callAdmin('POST', '/admin/bug-reports/' + reportId + '/status', { status: newStatus });
await loadBugReports();
} catch (e) {
toast('Errore: ' + e.message, 'error');
}
});
}
document.getElementById('admin-token').addEventListener('keydown', (e) => {
if (e.key === 'Enter') adminLogin();
});
+12 -2
View File
@@ -488,11 +488,21 @@ async function refreshRound() {
// myUserId may not be loaded yet on the very first tick after a reload
// (refreshMe() and refreshRound() run concurrently) — fall back to the
// persisted result rather than risk showing nothing or the wrong side.
// winner_user_id is committed as soon as the draw picks a winner, but
// winner_amount_sats isn't set until the payout tx is built afterwards
// (a real Electrum round-trip later) — revealing a win before then would
// show "+— PLM". Only the winner's own reveal needs to wait for it.
const iWon = myUserId != null && data.winner_user_id === myUserId;
const amountReady = !iWon || data.winner_amount_sats != null;
const canReveal =
data.user_played && data.winner_user_id != null && (alreadyKnown || elapsedMs >= minMs) && myUserId != null;
data.user_played &&
data.winner_user_id != null &&
(alreadyKnown || elapsedMs >= minMs) &&
myUserId != null &&
amountReady;
if (canReveal) {
const won = data.winner_user_id === myUserId;
const won = iWon;
if (!alreadyKnown) {
persistResult(data.round_id, won, data.winner_amount_sats);
if (won) {
+140
View File
@@ -10,6 +10,26 @@ const TRANSLATIONS = {
'nav.guideTitle': 'Guide',
'nav.guideAria': 'Open the user guide',
'nav.bugReport': 'Report a bug',
'bugReport.pageTitle': 'Report a bug',
'bugReport.heading': 'Report a bug',
'bugReport.intro': 'Found a problem? Describe it below — your report goes straight to the admin panel.',
'bugReport.englishNotice': "Please write your bug report in English, regardless of the language you're browsing in — this helps us handle it faster.",
'bugReport.descriptionLabel': 'What happened?',
'bugReport.descriptionPlaceholder': 'Describe the bug: what you were doing, what you expected, and what happened instead.',
'bugReport.contactLabel': 'Contact (optional)',
'bugReport.contactPlaceholder': "Email or other contact, if you'd like a reply",
'bugReport.submitBtn': 'Send report',
'bugReport.submitting': 'Sending…',
'bugReport.blankError': 'Describe the bug before sending.',
'bugReport.successToast': 'Thanks! Report sent.',
'bugReport.errorPrefix': 'Error sending: ',
'bugReport.myReportsTitle': 'Your reports',
'bugReport.myReportsHint': 'Only reports sent from this account, with the status set by the admin team.',
'bugReport.myReportsEmpty': "You haven't sent any reports yet.",
'bugReport.statusOpen': 'Not read yet',
'bugReport.statusRead': 'Acknowledged',
'bugReport.statusResolved': 'Resolved',
'bugReport.backLink': 'Back to home',
'nav.logoutTitle': 'Log out',
'nav.logoutAria': 'Log out of your account',
'nav.deposit': 'Deposit',
@@ -153,6 +173,26 @@ const TRANSLATIONS = {
'nav.guideTitle': 'Guida',
'nav.guideAria': 'Apri la guida utente',
'nav.bugReport': 'Segnala un bug',
'bugReport.pageTitle': 'Segnala un bug',
'bugReport.heading': 'Segnala un bug',
'bugReport.intro': 'Hai trovato un problema? Descrivilo qui sotto: la segnalazione arriva direttamente al pannello di amministrazione.',
'bugReport.englishNotice': "Scrivi la segnalazione in inglese, indipendentemente dalla lingua che stai usando per navigare: questo ci aiuta a gestirla più velocemente.",
'bugReport.descriptionLabel': 'Cosa è successo?',
'bugReport.descriptionPlaceholder': 'Descrivi il bug: cosa stavi facendo, cosa ti aspettavi e cosa è successo invece.',
'bugReport.contactLabel': 'Contatto (opzionale)',
'bugReport.contactPlaceholder': 'Email o altro recapito, se vuoi essere ricontattato',
'bugReport.submitBtn': 'Invia segnalazione',
'bugReport.submitting': 'Invio…',
'bugReport.blankError': 'Descrivi il bug prima di inviare.',
'bugReport.successToast': 'Grazie! Segnalazione inviata.',
'bugReport.errorPrefix': "Errore nell'invio: ",
'bugReport.myReportsTitle': 'Le tue segnalazioni',
'bugReport.myReportsHint': "Solo le segnalazioni inviate da questo account, con lo stato aggiornato dall'amministrazione.",
'bugReport.myReportsEmpty': 'Non hai ancora inviato segnalazioni.',
'bugReport.statusOpen': 'Da leggere',
'bugReport.statusRead': 'Presa in carico',
'bugReport.statusResolved': 'Risolta',
'bugReport.backLink': 'Torna alla home',
'nav.logoutTitle': 'Esci',
'nav.logoutAria': "Esci dall'account",
'nav.deposit': 'Deposito',
@@ -293,6 +333,26 @@ const TRANSLATIONS = {
'nav.guideTitle': 'Guía',
'nav.guideAria': 'Abrir la guía del usuario',
'nav.bugReport': 'Reportar un error',
'bugReport.pageTitle': 'Reportar un error',
'bugReport.heading': 'Reportar un error',
'bugReport.intro': '¿Encontraste un problema? Descríbelo a continuación: el informe llega directamente al panel de administración.',
'bugReport.englishNotice': 'Escribe el informe en inglés, independientemente del idioma que estés usando para navegar: esto nos ayuda a gestionarlo más rápido.',
'bugReport.descriptionLabel': '¿Qué pasó?',
'bugReport.descriptionPlaceholder': 'Describe el error: qué estabas haciendo, qué esperabas y qué sucedió en su lugar.',
'bugReport.contactLabel': 'Contacto (opcional)',
'bugReport.contactPlaceholder': 'Correo u otro contacto, si quieres que te respondamos',
'bugReport.submitBtn': 'Enviar informe',
'bugReport.submitting': 'Enviando…',
'bugReport.blankError': 'Describe el error antes de enviarlo.',
'bugReport.successToast': '¡Gracias! Informe enviado.',
'bugReport.errorPrefix': 'Error al enviar: ',
'bugReport.myReportsTitle': 'Tus informes',
'bugReport.myReportsHint': 'Solo los informes enviados desde esta cuenta, con el estado actualizado por el equipo de administración.',
'bugReport.myReportsEmpty': 'Todavía no has enviado ningún informe.',
'bugReport.statusOpen': 'Sin leer',
'bugReport.statusRead': 'En curso',
'bugReport.statusResolved': 'Resuelto',
'bugReport.backLink': 'Volver al inicio',
'nav.logoutTitle': 'Salir',
'nav.logoutAria': 'Cerrar sesión',
'nav.deposit': 'Depósito',
@@ -433,6 +493,26 @@ const TRANSLATIONS = {
'nav.guideTitle': 'Guide',
'nav.guideAria': "Ouvrir le guide de l'utilisateur",
'nav.bugReport': 'Signaler un bug',
'bugReport.pageTitle': 'Signaler un bug',
'bugReport.heading': 'Signaler un bug',
'bugReport.intro': "Vous avez trouvé un problème ? Décrivez-le ci-dessous : le signalement arrive directement dans le panneau d'administration.",
'bugReport.englishNotice': "Rédigez votre signalement en anglais, quelle que soit la langue que vous utilisez pour naviguer : cela nous aide à le traiter plus rapidement.",
'bugReport.descriptionLabel': "Que s'est-il passé ?",
'bugReport.descriptionPlaceholder': "Décrivez le bug : ce que vous faisiez, ce que vous attendiez et ce qui s'est passé à la place.",
'bugReport.contactLabel': 'Contact (facultatif)',
'bugReport.contactPlaceholder': 'Email ou autre contact, si vous souhaitez une réponse',
'bugReport.submitBtn': 'Envoyer le signalement',
'bugReport.submitting': 'Envoi…',
'bugReport.blankError': "Décrivez le bug avant d'envoyer.",
'bugReport.successToast': 'Merci ! Signalement envoyé.',
'bugReport.errorPrefix': "Erreur lors de l'envoi : ",
'bugReport.myReportsTitle': 'Vos signalements',
'bugReport.myReportsHint': "Seulement les signalements envoyés depuis ce compte, avec le statut mis à jour par l'équipe d'administration.",
'bugReport.myReportsEmpty': "Vous n'avez encore envoyé aucun signalement.",
'bugReport.statusOpen': 'Non lu',
'bugReport.statusRead': 'Prise en charge',
'bugReport.statusResolved': 'Résolu',
'bugReport.backLink': "Retour à l'accueil",
'nav.logoutTitle': 'Se déconnecter',
'nav.logoutAria': 'Se déconnecter du compte',
'nav.deposit': 'Dépôt',
@@ -573,6 +653,26 @@ const TRANSLATIONS = {
'nav.guideTitle': 'Anleitung',
'nav.guideAria': 'Benutzerhandbuch öffnen',
'nav.bugReport': 'Fehler melden',
'bugReport.pageTitle': 'Fehler melden',
'bugReport.heading': 'Fehler melden',
'bugReport.intro': 'Ein Problem gefunden? Beschreibe es unten — die Meldung geht direkt an das Admin-Panel.',
'bugReport.englishNotice': 'Bitte schreibe die Fehlermeldung auf Englisch, unabhängig von der Sprache, die du gerade verwendest — das hilft uns, sie schneller zu bearbeiten.',
'bugReport.descriptionLabel': 'Was ist passiert?',
'bugReport.descriptionPlaceholder': 'Beschreibe den Fehler: was du getan hast, was du erwartet hast und was stattdessen passiert ist.',
'bugReport.contactLabel': 'Kontakt (optional)',
'bugReport.contactPlaceholder': 'E-Mail oder anderer Kontakt, falls du eine Antwort möchtest',
'bugReport.submitBtn': 'Meldung senden',
'bugReport.submitting': 'Senden…',
'bugReport.blankError': 'Beschreibe den Fehler, bevor du sendest.',
'bugReport.successToast': 'Danke! Meldung gesendet.',
'bugReport.errorPrefix': 'Fehler beim Senden: ',
'bugReport.myReportsTitle': 'Deine Meldungen',
'bugReport.myReportsHint': 'Nur Meldungen, die von diesem Konto gesendet wurden, mit dem vom Admin-Team aktualisierten Status.',
'bugReport.myReportsEmpty': 'Du hast noch keine Meldungen gesendet.',
'bugReport.statusOpen': 'Ungelesen',
'bugReport.statusRead': 'In Bearbeitung',
'bugReport.statusResolved': 'Gelöst',
'bugReport.backLink': 'Zurück zur Startseite',
'nav.logoutTitle': 'Abmelden',
'nav.logoutAria': 'Vom Konto abmelden',
'nav.deposit': 'Einzahlung',
@@ -713,6 +813,26 @@ const TRANSLATIONS = {
'nav.guideTitle': 'Инструкция',
'nav.guideAria': 'Открыть руководство пользователя',
'nav.bugReport': 'Сообщить об ошибке',
'bugReport.pageTitle': 'Сообщить об ошибке',
'bugReport.heading': 'Сообщить об ошибке',
'bugReport.intro': 'Нашли проблему? Опишите её ниже — сообщение сразу попадёт в панель администратора.',
'bugReport.englishNotice': 'Пожалуйста, опишите ошибку на английском языке, независимо от языка интерфейса — это поможет нам обработать её быстрее.',
'bugReport.descriptionLabel': 'Что произошло?',
'bugReport.descriptionPlaceholder': 'Опишите ошибку: что вы делали, что ожидали и что произошло вместо этого.',
'bugReport.contactLabel': 'Контакт (необязательно)',
'bugReport.contactPlaceholder': 'Email или другой контакт, если хотите получить ответ',
'bugReport.submitBtn': 'Отправить сообщение',
'bugReport.submitting': 'Отправка…',
'bugReport.blankError': 'Опишите ошибку перед отправкой.',
'bugReport.successToast': 'Спасибо! Сообщение отправлено.',
'bugReport.errorPrefix': 'Ошибка отправки: ',
'bugReport.myReportsTitle': 'Ваши сообщения',
'bugReport.myReportsHint': 'Только сообщения, отправленные с этого аккаунта, со статусом, обновлённым администрацией.',
'bugReport.myReportsEmpty': 'Вы ещё не отправляли сообщений.',
'bugReport.statusOpen': 'Не прочитано',
'bugReport.statusRead': 'В обработке',
'bugReport.statusResolved': 'Решено',
'bugReport.backLink': 'Назад на главную',
'nav.logoutTitle': 'Выйти',
'nav.logoutAria': 'Выйти из аккаунта',
'nav.deposit': 'Депозит',
@@ -853,6 +973,26 @@ const TRANSLATIONS = {
'nav.guideTitle': '指南',
'nav.guideAria': '打开用户指南',
'nav.bugReport': '报告问题',
'bugReport.pageTitle': '报告问题',
'bugReport.heading': '报告问题',
'bugReport.intro': '发现问题了吗?请在下面描述——您的反馈会直接发送到管理员面板。',
'bugReport.englishNotice': '请用英文描述问题,无论您当前使用的是哪种语言界面——这有助于我们更快处理。',
'bugReport.descriptionLabel': '发生了什么?',
'bugReport.descriptionPlaceholder': '描述问题:您当时在做什么、期望的结果是什么,以及实际发生了什么。',
'bugReport.contactLabel': '联系方式(可选)',
'bugReport.contactPlaceholder': '如果希望得到回复,请留下邮箱或其他联系方式',
'bugReport.submitBtn': '发送反馈',
'bugReport.submitting': '发送中…',
'bugReport.blankError': '请先描述问题再发送。',
'bugReport.successToast': '谢谢!反馈已发送。',
'bugReport.errorPrefix': '发送出错:',
'bugReport.myReportsTitle': '您的反馈',
'bugReport.myReportsHint': '仅显示此账户发送的反馈,状态由管理团队更新。',
'bugReport.myReportsEmpty': '您还没有发送过反馈。',
'bugReport.statusOpen': '待处理',
'bugReport.statusRead': '处理中',
'bugReport.statusResolved': '已解决',
'bugReport.backLink': '返回首页',
'nav.logoutTitle': '退出登录',
'nav.logoutAria': '退出账户',
'nav.deposit': '存款',
+159 -6
View File
@@ -1,16 +1,169 @@
<!DOCTYPE html>
<html lang="it">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Segnala un bug — PLM Lottery</title>
<title data-i18n="bugReport.pageTitle">Report a bug</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="app-shell">
<h1>Segnala un bug</h1>
<p>Questa pagina è un placeholder. Il modulo per la segnalazione dei bug sarà disponibile qui a breve.</p>
<p><a class="link" href="/">&larr; Torna alla home</a></p>
<div class="app-shell app-shell-bugreport">
<div class="bugreport-topbar">
<a class="brand" href="/">
<img class="brand-mark" src="/logo.svg" alt="">
PLM Lottery
</a>
<div class="bugreport-topbar-right">
<a class="back-home-btn" href="/">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
<span data-i18n="bugReport.backLink">Back to home</span>
</a>
<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>
</div>
</div>
<div class="bugreport-hero">
<div class="bugreport-hero-icon">
<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="M12 8v5"/><path d="M12 16h.01"/></svg>
</div>
<div>
<h1 data-i18n="bugReport.heading">Report a bug</h1>
<p data-i18n="bugReport.intro">Found a problem? Describe it below — your report goes straight to the admin panel.</p>
</div>
</div>
<div class="card" id="report-form">
<div class="field-note" id="english-notice">
<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"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
<span data-i18n="bugReport.englishNotice">Please write your bug report in English, regardless of the language you're browsing in — this helps us handle it faster.</span>
</div>
<label for="bug-description" data-i18n="bugReport.descriptionLabel">What happened?</label>
<textarea id="bug-description" rows="6" maxlength="2000" data-i18n-placeholder="bugReport.descriptionPlaceholder" oninput="updateCharCount()"></textarea>
<div class="char-count" id="char-count">0 / 2000</div>
<label for="bug-contact" data-i18n="bugReport.contactLabel">Contact (optional)</label>
<input id="bug-contact" type="text" maxlength="256" data-i18n-placeholder="bugReport.contactPlaceholder">
<button onclick="submitBugReport()" id="bug-submit-btn" data-i18n="bugReport.submitBtn">Send report</button>
</div>
<div class="hidden" id="my-reports-section">
<p class="section-label" data-i18n="bugReport.myReportsTitle">Your reports</p>
<div class="card">
<p class="hint" data-i18n="bugReport.myReportsHint">Only reports sent from this account, with the status set by the admin team.</p>
<div id="my-reports-list"></div>
</div>
</div>
</div>
<div id="toast-container" aria-live="polite"></div>
<script src="/i18n.js"></script>
<script>
function toast(message, type) {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = message;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function updateCharCount() {
const field = document.getElementById('bug-description');
document.getElementById('char-count').textContent = field.value.length + ' / ' + field.maxLength;
}
async function submitBugReport() {
const btn = document.getElementById('bug-submit-btn');
const description = document.getElementById('bug-description').value.trim();
const contact = document.getElementById('bug-contact').value.trim();
if (!description) {
toast(t('bugReport.blankError'), 'error');
return;
}
const headers = { 'Content-Type': 'application/json' };
const token = localStorage.getItem('plm_token');
if (token) headers['Authorization'] = 'Bearer ' + token;
btn.disabled = true;
const original = btn.textContent;
btn.textContent = t('bugReport.submitting');
try {
const res = await fetch('/bug-reports', {
method: 'POST',
headers,
body: JSON.stringify({ description, contact: contact || null }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
document.getElementById('bug-description').value = '';
document.getElementById('bug-contact').value = '';
updateCharCount();
toast(t('bugReport.successToast'), 'success');
loadMyBugReports();
} catch (e) {
toast(t('bugReport.errorPrefix') + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = original;
applyStaticTranslations(btn);
}
}
async function loadMyBugReports() {
const token = localStorage.getItem('plm_token');
const section = document.getElementById('my-reports-section');
if (!token) {
section.classList.add('hidden');
return;
}
try {
const res = await fetch('/bug-reports/mine', { headers: { Authorization: 'Bearer ' + token } });
if (!res.ok) {
section.classList.add('hidden');
return;
}
const reports = await res.json();
section.classList.remove('hidden');
const list = document.getElementById('my-reports-list');
list.innerHTML = reports.map((r) => `
<div class="report-row">
<span class="badge bug-status-${escapeHtml(r.status)}">${escapeHtml(t('bugReport.status' + r.status.charAt(0).toUpperCase() + r.status.slice(1)))}</span>
<span class="report-row-desc" title="${escapeHtml(r.description)}">${escapeHtml(r.description)}</span>
<span class="report-row-date">${new Date(r.created_at).toLocaleDateString(currentDateLocale(), { day: 'numeric', month: 'short', year: 'numeric' })}</span>
</div>
`).join('') || `<p class="hint report-empty">${escapeHtml(t('bugReport.myReportsEmpty'))}</p>`;
} catch (e) {
section.classList.add('hidden');
}
}
// Re-renders server-rendered content (my reports list) on a language switch,
// the same split app.js uses between data-i18n (static markup) and t() (data).
function onLanguageChange() {
loadMyBugReports();
}
updateCharCount();
loadMyBugReports();
</script>
</body>
</html>
+91 -3
View File
@@ -74,16 +74,17 @@ h1, h2, h3 { font-family: inherit; letter-spacing: -0.01em; }
label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 14px; margin-bottom: 6px; }
label:first-child { margin-top: 0; }
input {
input, textarea {
width: 100%; min-height: 44px; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface);
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
}
input:focus {
textarea { resize: vertical; }
input:focus, textarea:focus {
outline: none; border-color: var(--color-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
}
input:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
input:disabled, textarea:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
button {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
@@ -253,6 +254,93 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
/* --- /report-bug: a standalone page (no logged-in navbar), so it gets its
own slim top bar rather than the app's bottom tab bar / sticky header. --- */
.app-shell-bugreport { padding-bottom: 32px; }
.bugreport-topbar {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 4px 0 20px; margin-bottom: 20px; border-bottom: 1px solid var(--color-border);
}
.bugreport-topbar .brand {
display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1rem;
letter-spacing: -0.01em; color: var(--color-foreground); text-decoration: none;
}
.bugreport-topbar .brand-mark { width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0; display: block; }
.bugreport-topbar-right { display: flex; align-items: center; gap: 12px; }
/* Pill button, same idiom as .trust-pill / .chain-status-pill elsewhere on the
site: a bordered chip rather than a bare text link, so "go back" reads as an
actual control instead of fading into the surrounding copy. */
.back-home-btn {
display: inline-flex; align-items: center; gap: 6px;
font-size: 0.8rem; font-weight: 500; color: var(--color-muted-foreground);
background: var(--color-surface); border: 1px solid var(--color-border);
padding: 6px 12px 6px 10px; border-radius: 999px; text-decoration: none;
transition: color 150ms, border-color 150ms, background 150ms;
}
.back-home-btn .icon { width: 15px; height: 15px; }
.back-home-btn:hover {
color: var(--color-foreground); background: var(--color-surface-inset);
border-color: color-mix(in srgb, var(--color-ring) 40%, var(--color-border));
}
.back-home-btn:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
.bugreport-hero { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; }
.bugreport-hero-icon {
width: 44px; height: 44px; flex-shrink: 0; border-radius: 999px;
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
color: var(--color-primary);
display: flex; align-items: center; justify-content: center;
}
.bugreport-hero-icon .icon { width: 22px; height: 22px; }
.bugreport-hero h1 { font-size: 1.3rem; font-weight: 700; margin: 2px 0 4px; text-wrap: balance; }
.bugreport-hero p { color: var(--color-muted-foreground); font-size: 0.9rem; margin: 0; max-width: 46ch; }
/* Info callout, anchored inside the form card right above the field it
applies to not a warning (that's what the amber status badges below are
for), so it gets the accent hue instead, keeping the two meanings visually
distinct. */
.field-note {
display: flex; align-items: flex-start; gap: 10px;
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 28%, transparent);
color: color-mix(in srgb, var(--color-accent) 75%, var(--color-foreground));
border-radius: var(--radius-sm); padding: 10px 12px; font-size: 0.82rem; line-height: 1.4;
margin-bottom: 16px;
}
.field-note .icon { width: 16px; height: 16px; margin-top: 1px; flex-shrink: 0; }
.char-count {
font-variant-numeric: tabular-nums; text-align: right;
font-size: 0.75rem; color: var(--color-muted-foreground); margin-top: 4px;
}
.badge {
display: inline-block; font-size: 0.72rem; font-weight: 600; padding: 2px 8px;
border-radius: 999px; background: var(--color-background); border: 1px solid var(--color-border);
flex-shrink: 0;
}
.badge.bug-status-open { background: color-mix(in srgb, var(--color-primary) 16%, transparent); color: #92400E; border-color: color-mix(in srgb, var(--color-primary) 55%, transparent); }
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
.report-row {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
padding: 12px 0; border-bottom: 1px solid var(--color-border);
}
.report-row:first-child { padding-top: 0; }
.report-row:last-child { padding-bottom: 0; border-bottom: none; }
.report-row-desc {
flex: 1 1 200px; font-size: 0.88rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.report-row-date {
font-size: 0.78rem; color: var(--color-muted-foreground); white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.report-empty { margin: 0; }
/* --- landing hero (shown only when logged out) --- */
body {
position: relative;
+19 -1
View File
@@ -38,6 +38,15 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
user's own address. Adding that to cached_balance_sats gives the balance the
user will end up with once everything currently in flight confirms.
The change output's own confirmation is credited by two independent, unordered
paths: the Electrum listener (event-driven, near-instant app/deposits/service.py
turns it into a UtxoEvent and folds it into cached_balance_sats via
recompute_balance) and this module's PendingTransaction.status flip
(app/tx/confirmation.py, polled every 10s). The listener usually wins that race,
so for the gap until the poller catches up the row is still "pending" here while
the same sats are already inside cached_balance_sats double-counting the
change unless excluded below.
Returns (pending_inclusive_balance_sats, has_pending) has_pending tells the
caller whether this differs from the confirmed-only balance at all.
"""
@@ -54,10 +63,19 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
)
).all()
already_credited = {
(txid, vout)
for txid, vout in (
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user.id))
).all()
}
pending_change_sats = 0
for row in pending:
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
for out in tx.vout:
for vout, out in enumerate(tx.vout):
if (row.current_txid, vout) in already_credited:
continue
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
pending_change_sats += out.value
@@ -0,0 +1,41 @@
"""replace bug_reports.resolved with a three-state status
Revision ID: be71fdac734e
Revises: ee8508d98d34
Create Date: 2026-07-31 15:33:51.780062
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'be71fdac734e'
down_revision: Union[str, Sequence[str], None] = 'ee8508d98d34'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema, preserving existing rows: resolved=True -> 'resolved', else 'open'.
'read' has no equivalent in the old boolean, so nothing backfills into it
every previously-open report starts the new lifecycle at 'open', which is
correct (nobody had acknowledged it yet)."""
op.add_column('bug_reports', sa.Column('status', sa.String(length=16), nullable=True))
op.execute("UPDATE bug_reports SET status = CASE WHEN resolved THEN 'resolved' ELSE 'open' END")
with op.batch_alter_table('bug_reports') as batch_op:
batch_op.alter_column('status', nullable=False)
batch_op.drop_column('resolved')
def downgrade() -> None:
"""Downgrade schema. 'read' collapses back into resolved=False — the same loss
of information any boolean-from-enum downgrade has."""
op.add_column('bug_reports', sa.Column('resolved', sa.BOOLEAN(), nullable=True))
op.execute("UPDATE bug_reports SET resolved = (status = 'resolved')")
with op.batch_alter_table('bug_reports') as batch_op:
batch_op.alter_column('resolved', nullable=False)
batch_op.drop_column('status')
@@ -0,0 +1,41 @@
"""add bug_reports table
Revision ID: ee8508d98d34
Revises: 87a0c640355c
Create Date: 2026-07-31 15:14:14.288552
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ee8508d98d34'
down_revision: Union[str, Sequence[str], None] = '87a0c640355c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('bug_reports',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('contact', sa.String(length=256), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('resolved', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('bug_reports')
# ### end Alembic commands ###
+48
View File
@@ -91,6 +91,54 @@ async def test_pending_balance_matches_confirmed_when_nothing_in_flight(session_
assert pending_balance == 2_000_000_000
async def test_pending_balance_does_not_double_count_change_already_credited(session_factory):
"""The Electrum listener (event-driven) and the confirmation poller (10s
cadence) independently react to the same change output confirming. When the
listener wins that race the common case the change is already a
UtxoEvent inside cached_balance_sats while the PendingTransaction row is
still "pending". compute_pending_balance must not add the change a second
time in that window."""
user_id = await _make_funded_user(session_factory, 4, 1_500_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
await place_bet(session, client, user)
async with session_factory() as session:
pending = (await session.scalars(select(PendingTransaction))).one()
from embit.transaction import Transaction
from app.wallet.plm_network import PLM_MAINNET
tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex))
change_vout, change_out = next(
(i, out) for i, out in enumerate(tx.vout) if out.script_pubkey.address(network=PLM_MAINNET) == user.address
)
user = await session.get(User, user_id)
# Simulate the listener having already credited the change output as
# confirmed, before the poller has flipped `pending.status`.
session.add(
UtxoEvent(
user_id=user_id,
txid=pending.current_txid,
vout=change_vout,
amount_sats=change_out.value,
confirmed_height=101,
)
)
await recompute_balance(session, user_id)
await session.commit()
async with session_factory() as session:
user = await session.get(User, user_id)
pending_balance, has_pending = await compute_pending_balance(session, user)
assert has_pending is True # the PendingTransaction row is still "pending"
assert pending_balance == user.cached_balance_sats # already-credited change isn't added again
async def test_pending_balance_ignores_other_users_pending_transactions(session_factory):
user_id = await _make_funded_user(session_factory, 2, 2_000_000_000)
other_user_id = await _make_funded_user(session_factory, 3, 1_500_000_000)
+181
View File
@@ -0,0 +1,181 @@
import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
import app.wallet.hd as hd
hd._account_key = None
hd.generate_master_key()
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db import base as db_base
import app.db.models # noqa: F401
db_base.engine = create_async_engine(settings.database_url)
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
from app.db import session as db_session
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
async with db_base.engine.begin() as conn:
await conn.run_sync(db_base.Base.metadata.create_all)
from fastapi import FastAPI
from app.api.routes.admin import router as admin_router
from app.api.routes.bug_reports import router as bug_reports_router
from app.auth.routes import router as auth_router
from app.electrum.listener import ElectrumListener
app = FastAPI()
app.include_router(auth_router)
app.include_router(bug_reports_router)
app.include_router(admin_router)
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
await db_base.engine.dispose()
_ADMIN_HEADERS = {"X-Admin-Token": "test-admin-token"}
async def _register(client, username="alice", password="original-password"):
resp = await client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 201
return resp.json()["access_token"]
async def test_anonymous_bug_report_has_no_user(client):
resp = await client.post("/bug-reports", json={"description": "the bet button does nothing"})
assert resp.status_code == 201
resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS)
assert resp.status_code == 200
reports = resp.json()
assert len(reports) == 1
assert reports[0]["description"] == "the bet button does nothing"
assert reports[0]["user_id"] is None
assert reports[0]["username"] is None
assert reports[0]["status"] == "open"
async def test_logged_in_bug_report_is_attributed_to_the_user(client):
token = await _register(client)
resp = await client.post(
"/bug-reports",
headers={"Authorization": f"Bearer {token}"},
json={"description": "withdrawal amount looks wrong", "contact": "alice@example.com"},
)
assert resp.status_code == 201
resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS)
reports = resp.json()
assert reports[0]["username"] == "alice"
assert reports[0]["contact"] == "alice@example.com"
async def test_user_can_see_own_report_status(client):
token = await _register(client)
headers = {"Authorization": f"Bearer {token}"}
resp = await client.post("/bug-reports", headers=headers, json={"description": "some bug"})
report_id = resp.json()["id"]
resp = await client.get("/bug-reports/mine", headers=headers)
assert resp.status_code == 200
reports = resp.json()
assert len(reports) == 1
assert reports[0]["id"] == report_id
assert reports[0]["status"] == "open"
await client.post(
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"}
)
resp = await client.get("/bug-reports/mine", headers=headers)
assert resp.json()[0]["status"] == "read"
async def test_bug_reports_mine_requires_auth(client):
resp = await client.get("/bug-reports/mine")
assert resp.status_code == 401
async def test_bug_reports_mine_only_returns_own_reports(client):
alice_token = await _register(client, username="alice")
bob_token = await _register(client, username="bob", password="bob-password")
await client.post(
"/bug-reports", headers={"Authorization": f"Bearer {alice_token}"}, json={"description": "alice's bug"}
)
resp = await client.get("/bug-reports/mine", headers={"Authorization": f"Bearer {bob_token}"})
assert resp.json() == []
async def test_blank_description_is_rejected(client):
resp = await client.post("/bug-reports", json={"description": " "})
assert resp.status_code == 422
async def test_admin_bug_reports_requires_token(client):
resp = await client.get("/admin/bug-reports")
assert resp.status_code == 403
async def test_admin_can_move_through_open_read_resolved(client):
resp = await client.post("/bug-reports", json={"description": "some bug"})
report_id = resp.json()["id"]
resp = await client.post(
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"}
)
assert resp.status_code == 200
assert resp.json()["status"] == "read"
resp = await client.post(
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "resolved"}
)
assert resp.status_code == 200
assert resp.json()["status"] == "resolved"
resp = await client.post(
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "open"}
)
assert resp.status_code == 200
assert resp.json()["status"] == "open"
async def test_update_status_rejects_unknown_value(client):
resp = await client.post("/bug-reports", json={"description": "some bug"})
report_id = resp.json()["id"]
resp = await client.post(
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "bogus"}
)
assert resp.status_code == 422
async def test_update_status_unknown_report_is_404(client):
resp = await client.post(
"/admin/bug-reports/999/status", headers=_ADMIN_HEADERS, json={"status": "read"}
)
assert resp.status_code == 404