Three failure paths that ended at an English HTTP status line or at no
recovery at all:
An empty or non-numeric withdrawal amount parsed to NaN, which JSON.stringify
sends as null, which pydantic rejects with a 422 — and FastAPI's validation
errors use a list of field objects rather than the {code, message} shape, so
apiErrorMessage fell through to res.statusText and the user read
"Unprocessable Content". The amount is now checked before the request, and the
list shape maps to a translated "invalid request" as a backstop for any other
field that fails validation.
A token the server no longer accepts left the dashboard looking logged in
while every poll failed, re-toasting "session expired" indefinitely. call()
now logs out on that specific code, dropping back to the login form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dashboard now speaks seven languages but every failure path still showed
the API's raw English text ("insufficient balance", "current password is
incorrect"), which is the most frequent and least forgiving part of the UI to
leave untranslated.
Rather than teach the API about locales, it keeps answering in one language
and hands the client something to translate: `detail` becomes
{code, message, params}, where message stays English for non-dashboard
consumers (curl, tests) and code maps onto `error.<code>` in i18n.js. An
unknown code falls back to message, so a client older or newer than the server
degrades to English instead of a blank toast.
Domain exceptions (BetError, WithdrawalError) subclass the new ApiError and
carry the code from where the failure actually happens; str(exc) is still the
English message, so existing tests keep matching on it. Interpolated values
travel in params rather than baked into the English sentence — amounts as
*_sats, from which the frontend derives a *_plm sibling, so each language can
place them wherever its grammar wants.
admin.js reads detail.message defensively: the admin endpoints still return a
bare string, but the shared auth dependencies now return the structured form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds app/static/i18n.js: a flat key -> string table for en/it/es/fr/de/ru/zh,
loaded before app.js so t() is available everywhere. No build step and no
fetch, consistent with the rest of these static pages. Language comes from
localStorage, then navigator.language, then en.
Static markup is translated by attribute (data-i18n and its -html/-placeholder/
-title/-aria-label/-alt variants); anything rendered from server data goes
through t() in app.js and is re-rendered by onLanguageChange(). An element
belongs to one mechanism or the other, never both, or the two overwrite each
other — which is why #bet-btn has no data-i18n: its label carries the
admin-configurable bet amount, so renderBetButton() owns it and reads the
amount from /rounds/current instead of hardcoding "10 PLM" in seven files.
The switcher sits in the chain-bar rather than the navbar because the navbar
is hidden until login, which would leave the landing page and the login form
untranslatable for exactly the users who need to switch. It uses language
names rather than flag emoji: flags don't render on every platform and don't
map one-to-one onto languages.
withLoading now snapshots innerHTML instead of textContent — several of these
buttons wrap an <svg> plus a <span data-i18n>, both of which a textContent
round-trip flattened away, permanently losing the icon and the translation
hook. It re-applies translations to the restored subtree in case the language
changed while the request was in flight.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors the earlier CSS extraction (style.css/admin.css) — app/static/ is
now split cleanly by file type (markup, styles, script) instead of mixing
JS inline in the HTML. No behavior change: the script content moved
verbatim, referenced via <script src>. FastAPI's existing StaticFiles mount
serves the new files automatically, same as the CSS files already do.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>