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)
Bot deep-links open the Mini App directly on nested routes (/admin,
/balance/top-up, /info, /profile, ...) where React Router history has a
single entry, so navigate(-1) was a no-op and the native back button
looked dead. Fall back to the derived parent route when there is no
in-app history (window.history.state.idx === 0).
Companion to bedolaga-bot a527df23. Bot's purchase-tariff endpoint
now accepts optional subscription_id to resolve the EXACT target row
by ID instead of doing a (user_id, tariff_id) re-lookup that races
with concurrent panel webhooks (produces 'Тариф уже активен' +
refund + no extension).
Frontend changes:
* subscriptionApi.purchaseTariff() — new optional subscriptionId arg
appended after trafficGb. Forwarded as subscription_id in POST body.
* SubscriptionPurchase.tsx — forwards URL searchParam subscriptionId
(set when user lands here from 'Renew this subscription').
* SubscriptionCardExpired.tsx — passes subscription.id when renewing
an expired daily tariff (the .purchaseTariff(tariff_id, 1) call).