index.html ships <html lang='ru'> and that attribute never changed at
runtime, regardless of the user's selected language. Consequences:
- screen readers pronounced en/zh/fa content using Russian phonics
- browsers offered to translate already-translated content
- the Telegram login button and an OAuth widget read documentElement.lang
to set their own locale and got the wrong one
LanguageSwitcher had a half-fix that only touched dir for fa, and only
locally — i18n changes from anywhere else (initial detect, Telegram
adopt) were not covered.
Move both lang and dir updates into a single languageChanged subscriber
inside i18n.ts, run once for the initial detected language, and drop
the now-redundant dir-setting in LanguageSwitcher. Layout direction
flips automatically for the full RTL set (fa + future ar/he/ur).
Previously, recharts (337 KB raw) lived inside an auto-generated shared
chunk named after the small useChartColors hook because that hook was
the first import path pulling recharts in. Any change to the hook
busted the chunk hash and forced users to re-download the whole
charting library.
Same story for @tiptap and the prosemirror peers — they were grouped
under a 'tiptap-video' chunk named after the local extension file.
Explicit vendor-recharts and vendor-tiptap buckets give those libs
stable cache-keys and predictable names that match the rest of the
vendor-* chunking scheme. Bundle sizes are net-neutral; what improves
is cache stability across releases.
Six routes were wrapped in <ErrorBoundary level='app'> around LazyPage.
After the previous commit moved a level='page' boundary inside LazyPage,
the outer 'app' boundary became unreachable — the inner one catches
first.
Strip the dead wrappers (PurchaseSuccess, QuickPurchase, AutoLogin,
TopUpResult, GiftSubscription, GiftResult). Behavior is identical, the
route blocks are 25 fewer lines, and there's only one boundary to
reason about per route.
A render error in any route used to bubble all the way up to the single
page-level boundary at AppWithNavigator, replacing the entire shell
(sidebar, header, blocking overlays) with the contained error UI — even
if only one sub-page was broken.
Embed an ErrorBoundary inside LazyPage so the smallest meaningful unit
(a route's lazy chunk + its rendered tree) is the boundary scope. Shell
chrome stays alive, the user can navigate elsewhere, and chunk-load
failures still get the lazyWithRetry reload path because the boundary
sits outside Suspense.
queryClient.invalidateQueries({ queryKey: ['user'] }) appears twice in
the codebase but no useQuery with that key is registered anywhere — the
auth user is a zustand store, not a React Query cache entry. The
invalidations were silent no-ops that suggested wiring that did not
exist.
The actual refresh path (await getMe + setUser) was already in place
right above each invalidate, so the cleanup is purely cosmetic +
documentation: replace with a comment explaining where the user state
actually lives, so the next reader doesn't try to invalidate it again.
The scope add-popover wrapped its tabs/search/list in role=dialog with
no aria-modal and no focus trap. That promises modality to AT but
delivers a non-modal popover users can click past — confusing.
Remove the wrapper role. The trigger button already exposes the popup
via aria-haspopup=listbox + aria-expanded, and the inner div carries
role=listbox aria-multiselectable. Nothing for AT to misinterpret now.
Two admin paths previously had empty catch blocks around copyToClipboard:
the user-detail copy helper and AdminLandings handleCopyUrl. The adapter
already attempts the legacy execCommand fallback, so reaching the catch
means the operation truly failed — staying silent left admins thinking
their click worked when it hadn't.
Both now fire notify.error so the failure is visible.
Bulk-action modal had role=dialog on the overlay but no focus trap,
no aria-labelledby, no scroll lock, and an ad-hoc Escape listener
that conflicted with anything else listening to document keydown.
Move role=dialog/aria-modal onto the focus-trapped content element,
wire aria-labelledby to the h3 title, and replace the ad-hoc
keydown effect with useFocusTrap (Tab cycle + Esc + body scroll
lock + focus restore on close). Loading state suppresses Esc but
keeps focus trapped, so Tab still cycles inside the progress view.
Header checkbox for 'select all subscriptions' read allVisibleSubscriptionIds,
subscriptionSelection, isMultiTariff, toggleAllSubscriptions from a stale
closure because those values were declared AFTER the columns useMemo. The
warned-about deps were absent, so the header only refreshed by accident
when expandedRows or getFilteredSubs changed.
Relocate filteredUsers / allVisibleSubscriptionIds / toggleAllSubscriptions
above the columns block, list them as deps. Header now reflects current
selection without piggybacking on unrelated re-renders.
Same commit clears the matching AdminInfoPageEditor warning with an
explicit eslint-disable-line comment — the activeLocale omission was
already intentional (initial-content lock), just not silenced.
60s setInterval becomes refetchInterval, dropping the cancelled-flag
ceremony and the manual loading/setState plumbing. Two independent
queries — one failure no longer poisons the other (Promise.all
previously rejected the whole batch on a single 5xx).
User list query keys all 8 filter inputs so pagination/search/filter
changes auto-refetch with proper dedup. Tariffs/promoGroups/campaigns/
partners become long-staleTime lookup queries — fired once and cached
across remounts of the page. Existing setUsers/setTariffs/etc. setters
stay; sync useEffects copy query.data into them so downstream selection
and handler code is untouched. loadUsers becomes a thin refetch wrapper
for handleRefresh and mutation handlers.
Drops manual activeTab/userId loader triggers — query enabled
gating now handles all per-tab fetching. Wrappers around refetch
keep mutation handler call sites unchanged. Removes 4 dead
wrappers (loadTariffs/Referrals/Gifts/PromoGroups) only called
by the now-deleted activeTab useEffect.
Replace the 13-line manual fetch with a useQuery hook keyed on userId. loadUser is
kept as a thin wrapper around userQuery.refetch() so all 25+ existing call sites in
mutation handlers continue to work unchanged. Sync useEffects copy data → setUser,
isFetching → setLoading, and isError → navigate, preserving the original
'load fails → redirect to /admin/users' behavior.
Remaining 12 loaders (loadSyncStatus/loadTariffs/loadTickets/loadReferrals/loadPanelInfo/
loadNodeUsage/loadDevices/loadSubscriptionData/loadGifts/loadPromoGroups/loadTicketDetail/
loadRequestHistory) NOT migrated in this commit — some are parameterized, some chain via
Promise.all in loadSubscriptionData; they need a dedicated refactor with functional
testing across all 7 tabs and the mutation flows. This commit ships the lowest-risk,
highest-leverage piece (userQuery has the most call sites).
All 10 tab data fetches + the initial status load move from manual useState +
useEffect + loadTabData(switch) pattern to per-tab useQuery hooks with enabled
gating. Each query syncs into the existing state vars via useEffect so the JSX
and mutation handlers stay unchanged; handleSearch/handleUnban/handleToggle/handleSet
now call query.refetch() directly; refresh button uses refetchActiveTab helper.
Net effect: tab switches return to cached data instantly (background revalidate),
no duplicate fetches, no manual loading/error wiring. The reports query has
reportHours in its queryKey so changing the period auto-refetches without the
extra useEffect that previously handled it.
Wrap the error message blocks in role=alert so assistive technology announces
authentication failures and reset-password errors when they appear (WCAG 4.1.3).
Subscription copy-URL button only had a title attribute; back and QR-open buttons in
InstallationGuide had no accessible name. Add aria-label so screen readers announce
the action (WCAG 2.4.6).
Replace manual fetchStats/fetchExtendedStats (useState + useEffect + setInterval(30s)
+ console.error) with two useQuery hooks using refetchInterval. Keeps existing variable
names (stats/loading/error/referrers/campaigns/payments/systemInfo) so JSX is unchanged.
handleRestartNode/handleToggleNode/retry button now refetch via the query.
Replace manual useState + useEffect + useCallback + console.error fetch pattern with
useQuery for the users list and stats. Adds caching, dedupe, stale-time, automatic
loading/error state — and the refresh button now uses refetch() (single source of truth).
Wrap template-selection and send-mode button groups in role=radiogroup +
aria-labelledby; mark each option button as role=radio with aria-checked
(WCAG 4.1.2). Labels get matching id for the labelledby relationship.
Sweep non-brand blue across admin pages, AnalyticsTab, TvQuickConnect, and menuLayout
style picker to accent tokens. Telegram-brand blue kept in BroadcastPreview (TG chat
mockup), the blocking screens (TG channel/bot CTAs), and the SuccessNotificationModal hero.
Sweep orange across SubscriptionPurchase/GiftSubscription/PromoOffersSection/
AdminTrafficUsage/AnalyticsTab/withdrawalUtils to warning-* tokens. Stars brand
yellow→orange gradient kept in TopUpAmount.
Map text/bg/border-gray-N → dark-N across the four blocking screens (Maintenance,
Blacklisted, ChannelSubscription, AccountDeleted) so muted text + decorative surfaces
follow the design tokens. BroadcastPreview's email mockup and MessageMediaGrid's
fullscreen overlay intentionally keep their literal gray/white (mock chrome).
Replace gradient CTAs that fade between same-token shades with flat backgrounds
(TopUpAmount accent submit, ChannelSubscriptionScreen single-channel button, three
SuccessNotificationModal action buttons). Stars yellow→orange gradient kept as a
Telegram brand cue; decorative card-header gradients left as visual identity.
Both pages were rendering with raw Tailwind gray-* + a non-existent border-primary-600
class (invisible spinner). Map: bg-gray-50 → bg-dark-950, text-gray-900 → text-dark-50,
text-gray-500 → text-dark-400, border-primary-600 → border-accent-500. Pages now match
the dark theme and the spinner is visible.
Sweep all amber-N color classes to warning-N (uniformly pending/warning semantics).
Yellow and orange left for per-file review (mixed Stars brand + semantic uses).
Sweep 30 files: text/bg/border/ring/from/to/via/fill/stroke/shadow/divide/decoration/
outline/placeholder-red-N → -error-N. All red usages were semantically error/danger
(no brand red), so tokens now flow through the design-system CSS variables and respond
to palette overrides.
Add htmlFor/id pairs for template name, message text, button text, valid hours,
discount percent, test duration, and active discount hours (WCAG 1.3.1). Test-squads
section is a checkbox group, not a single control.
Add htmlFor/id pairs for display name, description, country code, price, max users
and sort order (WCAG 1.3.1). Original name is a read-only display, not a control.
Add htmlFor/id pairs for the name + auto-assign inputs (AdminPromoGroupCreate) and the
content textarea (AdminPinnedMessageCreate). AdminPromoOfferSend skipped — its labels
sit above button-groups, not native controls (a separate role=group/radiogroup pass).
Add htmlFor/id pairs for tariff selector, name, start parameter, trial subscription
days/traffic/devices and tariff duration days inputs (WCAG 1.3.1). Group labels (server
selector, bonus-type buttons) deferred — they need role=group, not htmlFor.
- htmlFor/id pairs for title, slug, read-time, excerpt and featured-image inputs
- add an ariaLabel prop to ColoredItemCombobox (applied to the trigger button) and
pass the category/tag labels so the comboboxes have an accessible name (WCAG 1.3.1/4.1.2)
Add htmlFor/id pairs across the partner-apply form (company, channel, website,
description, expected referrals, desired commission) and the withdrawal-request form
(amount, payment details) so labels are programmatically tied to their controls (WCAG 1.3.1).
Add role=switch + aria-checked + aria-label to the custom toggle buttons so screen
readers announce on/off state (WCAG 4.1.2): autopay (Subscription) and the custom-days
/ custom-traffic toggles (SubscriptionPurchase).
Add htmlFor/id pairs for the create-ticket subject and message fields so screen
readers announce the label and clicking the label focuses the control (WCAG 1.3.1).
- role=dialog/aria-modal/aria-labelledby/aria-describedby on the onboarding tooltip
- trap focus inside the tooltip while the tour runs; Esc skips (lockScroll off so
scrollIntoView can still bring each step's target into view)
- merge the focus-trap ref with the existing measurement ref via a callback ref
- aria-hidden on the click-to-advance target overlay (redundant with the Next button)
Telegram CloudStorage token recovery (resilience against WebView localStorage wipes):
- mirror the refresh token to per-user CloudStorage on login, remove it on logout
- restoreRefreshTokenFromCloud() recovers the refresh token in initialize() when
localStorage is empty, then the normal refresh flow re-establishes the session
- move the initialize() bootstrap call from auth.ts module-load into main.tsx after
the Telegram SDK init(), since launch params + CloudStorage are unavailable before init
- best-effort: no-ops outside Telegram and on any error -> falls back to prior behavior
Accessibility:
- role=alertdialog + aria-modal + aria-labelledby + focus management (useFocusTrap)
on Maintenance, Blacklisted, ChannelSubscription and AccountDeleted blocking screens
- aria-label on ColorPicker Hue/Saturation/Lightness range inputs
Modal a11y:
- focus-trap + role=dialog/aria-modal/aria-labelledby on AdminRoles delete confirm
and AdminBanSystem user-detail modal (+ close aria-label)
Telegram theme/language sync (first run only, explicit choice always wins):
- getTelegramColorScheme() / getTelegramLanguageCode() helpers in useTelegramSDK
- useTheme initial state follows the Telegram client color scheme when no stored theme
- applyTelegramLanguage() adopts the Telegram client language when no stored choice,
called from main.tsx after SDK init (launch params unavailable before init)