Companion to the bot-side change: the variables hint now renders two
groups — type-specific vars and common ones ({service_name},
{cabinet_url}, {support_username}, {username}, {email}, {date}) that
work in every template. The block is visible even for types with no
vars of their own (previously it was hidden entirely, looking like
placeholders didn't exist). Common chips insert at cursor the same way.
'String contained an illegal UTF-16 sequence' on first Mini App / site
open for users with a truncated emoji in their Telegram name.
The existing surrogate guard only patched the ENCODE direction
(encodeURI/encodeURIComponent). But Telegram percent-encodes such names
as CESU-8 (%ED%A0%BD) in tgWebAppData, and decodeURI/decodeURIComponent
throw on those bytes in every engine (JSC wording is the reported one) —
any decode of init-data-derived strings crashed into the ErrorBoundary.
Not valibot: valibot 1.0.0 has no UTF-16 well-formedness validation
(verified against the installed package), and upstream pins it exactly,
so there is nothing to bump.
decodeURI/decodeURIComponent now try the native decoder first and fall
back to lenient WHATWG-style UTF-8 decoding with U+FFFD replacement —
the same semantics URLSearchParams applies to the same bytes. Decoded
values still flow through existing validation (getSafeRedirectPath etc),
so nothing becomes less strict.
Companion to the bot-side fix: defaults now arrive with {placeholders}
instead of baked-in sample values (example.com links).
- inline preview tab (iframe srcDoc) — works on mobile and in Telegram;
the separate desktop-only preview page and its route are removed
- preview/test render the CURRENT editor content with sample values
- test email: recipient input + detailed backend error messages
- variable chips insert at cursor instead of copying to clipboard
- 'insert default template' button to migrate broken saved overrides
- fa added to language tabs
- Lava returns to a path-based result URL (/balance/top-up/result/:method) because Lava
Business rejects query strings in success/fail URLs. Add the route and read the method
from the path param as a fallback (alongside ?method=) so external-browser redirects
still poll the right payment. Pairs with the backend fix.
- ConnectedAccounts: the "Привязка Telegram временно недоступна" message overflowed the
card (it sat in the non-shrinking action column and never wrapped). Constrain its width
and wrap it (max-w + break-words) so it stays inside the card. Verified by rendering.
Both reported in the Bedolaga bug topic.
- health probe: tolerant timeout (12s) + retry before flagging the backend down. A
hardcoded 5s probe racing auth bootstrap falsely showed ServiceUnavailableScreen on
slow devices / cold mobile connections while the 30s API requests would have
succeeded ("works on one device, not another on the same Wi-Fi"). The recovery poll
self-reschedules with the tolerant timeout so slow devices auto-recover.
- TopUpAmount: fetch payment methods with a real query (fixes the infinite spinner on a
cold cache / browser-back) and use canonical RUB for quick-amount chips so FX rounding
can't reject a min-amount selection in non-RUB locales.
- settings UI: render secret values as a masked password input (pairs with the backend
secret masking); leaving the field empty keeps the stored secret.
- deps: npm audit fix (18 -> 5 advisories).
Also bundles in-progress settings env-lock UI work.
The hint claimed the provider opens 'inside the MiniApp/tab' — that is the broken behavior that caused #654272 (a same-WebView navigation to the provider can't hand off to a bank app / SBP). After the fix the link opens in the EXTERNAL browser inside Telegram regardless of the toggle. Rewrote the hint in all four locales: ON = open immediately (no button panel), OFF = show an 'Open payment' button; both open externally in Telegram so bank-app/SBP payments work; Stars/CryptoBot (t.me/ links) always open natively and ignore the flag.
Note: the external-browser open is unconditional (both toggle states, all methods incl. WATA) — the toggle only controls auto-open vs a button tap.
The reported #654272 case ('функция немедленного открытия ссылки', reproduced on RollyPay AND YooKassa, fine on desktop) is the open_url_direct flow — not the connection deep-link opener fixed in 325e221.
When a payment method has open_url_direct, the cabinet did window.location.href = payment_url INSIDE the Telegram in-app WebView. SBP/RollyPay/YooKassa pages then hand off to a bank app via a custom scheme, which the WebView can't open: Android shows net::ERR_UNKNOWN_URL_SCHEME, iOS opens nothing ('приложение не определяется'); link generation logs fine. Desktop works because it's a real browser.
Add openPaymentUrl(): in Telegram open via openLink (external browser — the OS hands off to the bank app, return_url brings the user back); on web keep same-tab navigation (no popup blocker). Applied to TopUpAmount (top-up) and GiftSubscription (gift purchase). QuickPurchase is a web landing page (no platform abstraction) and is unaffected.
Telegram bug #654272: opening the connection app link (immediate-open / connect button)
showed a full-page net::ERR_UNKNOWN_URL_SCHEME on Android and silently failed on iOS, while
working on desktop.
Cause: a programmatic top-level navigation to a custom scheme (happ://, v2rayng://, …) via
window.location.href is rendered as a full-page error inside in-app WebViews (Telegram/Yandex)
on Android and does nothing on iOS — also wiping the fallback UI.
Add openAppScheme(): http(s) navigate normally; custom schemes launch via a hidden, contained
iframe so a failed launch never replaces the page — the app opens if installed, otherwise the
manual 'Open app' link stays usable (a user tap is the reliable trigger). Applied in
DeepLinkRedirect.tsx, Connection.tsx and the static public/miniapp/redirect.html (which now
also surfaces the manual button immediately instead of after 2s).
A lone (unpaired) UTF-16 surrogate — a truncated emoji in a backend name/remark
embedded in a subscription/connection URL — makes encodeURI/encodeURIComponent throw
a URIError on iOS WebKit (V8: 'URI malformed'; Safari/JSC: 'String contained an
illegal UTF-16 sequence'). qrcode.react calls encodeURI internally, so such a value
crashed the QR render and tripped the page-level ErrorBoundary ('Something went
wrong / String contained an illegal UTF-16 sequence / Try again').
Rather than wrap each (current and future) call site — and third-party libs we can't
edit — sanitise at the single chokepoint: patch the global encodeURI/encodeURIComponent
at bootstrap to replace lone surrogates with U+FFFD (same remedy as toWellFormed()).
Fail-safe, not fail-broken: verified byte-identical output for all well-formed strings
(URLs unaffected) and no-throw on lone surrogates for the real call patterns (qrcode.react
and btoa(unescape(encodeURIComponent(...)))). A fast path skips the rewrite when no
surrogate code units are present, so the hot path (every request URL) is untouched.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Map promocode errors by stable backend code (detail.code) instead of brittle English
substring matching, so previously-unmapped codes (active_discount_exists, daily_limit,
no_subscription_for_days, trial_*, etc.) show the right message instead of 'server error';
added the missing keys to all 4 locales.
valid_until now anchors to end-of-day in the admin's LOCAL timezone on both create AND
edit read-back, so codes no longer expire at the start of the day and editing no longer
drifts the date forward for negative-offset admins.
Allow 0-hour discount ('until first purchase'). Route user-facing discount deactivation
to the endpoint that rolls back promocode usage when the discount came from a promocode,
and parse the structured {code,message} error.
In a grid, the wrapping <Link> stretched to the row height but the
StatCard inside only took content height, so the referral card (which has
a subValue line) rendered taller than the balance card ('один толще
другого'). Make StatCard fill its cell with h-full and stretch the
StatsGrid links. Verified by rendering both cards against the built CSS:
balance 86px->94px, now equal to referral's 94px.
Bring every stat strip across the admin panel and user cabinet onto the
canonical StatCard (icon chip + label-on-top + tone-coloured value),
matching the sales/Remnawave style the rest of the app already used.
- Convert ~20 hand-rolled stat strips across 21 pages/components
- Remove 4 divergent local StatCard components (Dashboard, Users,
BanSystem, Payments) and migrate Remnawave's local StatCard (33 cards)
- Extend StatCard with optional subValue, loading skeleton, trailing slot,
and icon-size normalisation (all backward-compatible)
- StatsGrid nav tiles now use StatCard while keeping link/chevron/loading
- Preserve all data, i18n keys, conditionals and interactive filters;
fix the dead 'info' colour by mapping it to accent
The header used flex with the capsule centered by mx-auto, so the capsule's
auto-margins recomputed whenever anything in the row changed (and compounded
with framer-motion's shared-layout measurement on route change) — making the
whole row, incl. the language button, twitch when navigating (notably into
/admin).
Switch the inner row to grid grid-cols-[1fr_auto_1fr]: the capsule is anchored
to the true viewport center by the symmetric side columns, and the actions are
pinned to the right column (justify-self-end). A width change in one zone can no
longer shift another, so nothing jumps.
Wrap the always-visible nav items in a single rounded track (segmented-control
look): rounded-full border + subtle bg + backdrop blur, centered between the
logo and the right-side actions via mx-auto (fills the previously empty middle).
- All items stay visible with icon + label — no scroll, no shrink, no collapse,
no hover-reveal. Purely a visual grouping.
- Items become rounded-full pills; the framer-motion active indicator slides
within the capsule and is lifted above the track (bg + ring + shadow).
- Admin link stays inside the capsule after a thin divider, warning-tinted.
- Right-side actions sit at the edge (dropped ml-auto since nav now centers).
The nav was centered inside a max-w-6xl grid column; 8 icon+label items were
wider than that column, so the centered overflow spilled LEFT over the logo
(ZeroPing's tail poked out from under 'Главная'). Switch to a full-width flex
row — logo (shrink-0) | nav | controls (shrink-0, pinned right via ml-auto) —
widen the container cap to 1600px, slightly compact the items, and drop the
invisible theme toggle from layout when it's not toggleable. Icons + labels
all stay visible: no scroll, no shrink, no overlap.
The desktop top-bar nav rendered icon-only buttons whose label only slid out
on hover (max-w-0 -> group-hover:max-w-40) — mystery-meat, jumpy, hard to
scan. Replace with always-visible 'icon + label' links and a single
framer-motion pill (shared layoutId) that smoothly slides to the active item;
compact spacing, refined active/hover states.
Also delete DesktopSidebar.tsx — dead code since the cabinet's inception
(never imported anywhere, only re-exported from the barrel).
Full scan of every page/component for the Connected-Accounts overflow class
(long user data with no truncation pushing siblings off the card on mobile),
adversarially verified. Applied min-w-0 + truncate (break-all for copyable
values, break-words for titles) on text columns and shrink-0 on fixed
siblings across ~23 files: AppHeader, dashboard/SubscriptionCardActive,
subscription purchase/sheets (TariffPurchaseForm, ClassicPurchaseWizard,
SwitchTariffSheet), SuccessNotificationModal, PurchaseSuccess, Wheel sub
picker, ReferralNetwork UserDetailPanel, and the admin pages (AnalyticsTab,
BrandingTab, SubscriptionSubRow, user-detail Info/Referrals tabs,
ApplicationReview, PartnerCampaignAssign, Partners, PromocodeStats,
PromoOfferSend, RemnawaveSquadDetail, TariffCreate, Tickets, Withdrawals).
Truncating the Google/Discord sub id with an ellipsis hid it. Stack the
status badge above the unlink button (flex-col) on the right so the right
column is narrow and the identifier gets the room to render in full;
truncate stays only as a safety net for extreme values.
Same flex-overflow class as the Connected-Accounts card — a long
user-controlled string with no truncation pushes its sibling badge/button
out of the card on narrow screens. Found via a scan of the high-risk
screens; fixed with min-w-0 + truncate/break-words on the text column and
shrink-0 on the fixed siblings:
- Balance: promocode-target tariff name vs days-left badge
- ReferralNetwork CampaignDetailPanel: start_parameter + top-referrer username
- ServerManagementSheet: country/server name vs flag emoji
- Contests / Polls: contest/poll title vs prize/reward badge
A long Google sub (numeric, ~21 digits) pushed 'Привязан' and 'Отвязать'
past the right edge of the account card on mobile. Classic flex overflow:
the left identifier column had no min-w-0, the identifier <p> no truncate,
and the right actions no shrink-0. Add min-w-0 + truncate to the text column
and shrink-0 to the actions so the id ellipsizes and the controls stay put.
The unsaved-order banner used ad-hoc inline button classes (rounded-lg
bg-warning-500 / border bg-dark-700) instead of the shared btn-primary /
btn-secondary, plus t(...) || 'русский текст' fallbacks that would leak
Russian to non-RU locales. The keys exist in all four locales, so the
fallbacks were dead code — switched to the shared buttons and plain keys.
Statistics tab now uses the shared StatCard + BreakdownList (ranked bars +
share %) and the currency-aware formatter — matching the rest of the admin
stats instead of ad-hoc 'card p-4 text-center' / text-3xl tiles — and gains
proper loading, error and empty states.
Also surface backend-supported config the form couldn't reach (forcing DB edits):
- promo_validity_days (model + schema-bounded 1-365) is now editable in Settings.
- per-prize manual_probability override (0-1) now has an input in the prize form.
After a Stars payment the wheel matched its landing sector to the won prize
by display_name/emoji and, on any miss, span to Math.random()*360 — which
often pointed at a winning slot. The poll result also carried the SPIN id in
prize_id (not the WheelPrize id), so it never matched and always hit that
random fallback. In a backgrounded browser tab the poll often times out,
so the wheel 'landed' on месяц/50₽ while the real result was Ничего; the
Mini App was fine because it gets the result inline.
- Match the sector by prize_id (exact), with name/emoji only as a defensive
fallback for old payloads.
- Never land on a random angle: when the prize can't be resolved (poll
timeout/error) land on the neutral 'Nothing' sector instead.
- Carry the real WheelPrize id (now exposed by the bot history endpoint)
through the poll result.
When linking an email that belongs to another account, the backend now mails a
one-time code to it (merge_verification='email_code') instead of asking for that
account's password. Add a 6-digit code step: on the email_code response show the
code input, verify via authApi.verifyEmailMerge → on success redirect to /merge.
Preview/execute already go through apiClient (JWT), so they satisfy the new
initiator-bound merge endpoints. i18n in ru/en/zh/fa.
Backend now requires a signed, expiring token to download media (a raw file_id
404s). Carry the per-attachment `token` (and the legacy `media_token`) from the
ticket response through MessageMediaGrid and append it in ticketsApi.getMediaUrl,
so attachments keep rendering while leaked URLs expire. Must deploy together with
the backend change.
public/miniapp/redirect.html read the `url` query param and assigned it to both
the manual <a href> and window.location.href with no validation. Served on the
cabinet's own origin, this was an open redirect (phishing) and — in Telegram /
WebView contexts that execute javascript: URIs in top-level navigation — a
DOM-XSS able to exfiltrate the refresh_token from localStorage (account takeover).
Add isSafeAppLink(): only a custom app deep link passes (scheme://, excluding
http/https/file/blob/about/intent/content and the javascript/data/vbscript
script schemes); anything else renders the no-URL state instead of navigating or
setting href. Mirrors the validation already in src/pages/DeepLinkRedirect.tsx.
The backend account-takeover fix now requires the existing account's password
before merging an email that already belongs to another account. Show a clear
message (profile.emailMergePasswordRequired) on that response instead of the
generic "already registered" dead-end, so the legitimate owner knows to enter
that account's password (in both password fields) to complete the merge. i18n
in ru/en/zh/fa.
On a full-screen blocking overlay in the Telegram Mini App there were two exit
affordances: the native back button — which ran SPA navigation on the hidden
route underneath, so it didn't dismiss the block and kept flip-flopping between
Back and Close as the route changed — and a redundant in-page "Close" button on
the service-unavailable screen.
Make the native back button the single, stable exit: while any blocking screen
is active, TelegramBackButton shows one Back button whose click closes the Mini
App (closeTelegramApp) instead of navigating the SPA — no route change, no
Back/Close flip-flop. The normal route-based back button is restored once the
block clears. Remove the in-page Close button.
The status screens (service-unavailable, maintenance, channel-subscription,
blacklist, account-deleted) all shared a generic flat look — an icon in a grey
circle, a title, a subtitle, three raw pulsing dots — that didn't match the
app's premium dark-glass aesthetic.
Introduce one shared BlockingShell that all five compose: an opaque themed
canvas with a self-contained accent glow, a centered glass card
(rounded-[--bento-radius] border bg-dark-900/80 backdrop-blur + inset
highlight), a gradient-ringed icon medallion (warning/error/info accent)
instead of a flat grey circle, font-display typography, the canonical Button
for every action, and a subtle framer-motion scale/slide entrance. Accent per
screen: warning (maintenance, account-deleted, service-unavailable), error
(blacklist), info (channel).
Behavior is preserved 1:1 — every i18n key, the channel list + open buttons,
the telegram deep-link, retry/check/close, the recovery poll, focus trap and
aria all carried over verbatim; only the visuals change.
Two Telegram-Mini-App follow-ups to the backend-unavailable screen:
- Reliable "Close" button (Telegram only) that actually EXITS the Mini App
instead of routing back. closeTelegramApp() tries the legacy
window.Telegram.WebApp.close() global (telegram-web-app.js), then the SDK
closeMiniApp(), then the raw postEvent('web_app_close') — all emit the same
close event, so it can't silently fail. Guarded so the first path never
silently no-ops.
- Eliminate the flash of the /login page before the outage screen. On the
bootstrap path (the app has never reached the backend) reportPossibleBackendDown()
now flips the screen IMMEDIATELY and synchronously instead of waiting on the
confirm probe, so the overlay is up before isLoading flips and /login can never
paint uncovered. Add an eager checkBackendOnStartup() liveness ping at launch
(parallel with auth) so even the no-stored-token / fresh-Telegram path that
makes no early request shows the screen at once. The confirm-probe still guards
already-loaded sessions from a one-off blip.
i18n close key added in ru/en/zh/fa.
When the backend was down the cabinet got stuck on a blank loader: the bootstrap
token refresh used bare axios with no timeout (hung forever), and every
interceptor guard required an HTTP response — so a transport-level failure had
zero handling and no error UI ever appeared.
Add a full-screen ServiceUnavailableScreen (extends the existing blocking-store
pattern) shown whenever the backend is unreachable, that auto-recovers when it
returns:
- New 'backend_unavailable' blocking type + BlockingOverlay branch; mirrors
MaintenanceScreen, CloudWarningIcon, i18n in ru/en/zh/fa.
- api/health.ts: pingBackend() probes the root /health/unified (bypasses the
/api baseURL + interceptor); URL derived from the origin for remote/sub-path
deploys; 502/503/504 count as down. reportPossibleBackendDown() confirms an
outage with a liveness probe before flipping, so a one-off blip never blanks a
loaded app.
- Detected from the response interceptor (no-response) and the bootstrap refresh
path; doRefresh gets a timeout so it can no longer hang.
- A transport failure during refresh now PRESERVES the session (distinguished
from a rejected token via lastFailureWasTransport) instead of logging the user
out, so recovery actually resumes.
- Recovery lifts the overlay + refetches for an already-loaded session, and only
hard-reloads when the initial bootstrap never reached the backend — no lost
form state. Manual retry + 5s auto-poll. Dev proxy for /health.
The buy-traffic warning said purchased traffic "does not carry over to the next
period", which contradicts how add-ons actually work: a package lives 30 days
and stays in the limit across the monthly reset within those 30 days. Reword to
"valid for 30 days from purchase" (ru/en/zh/fa) so users aren't misled.