Commit Graph

1995 Commits

Author SHA1 Message Date
c0mrade
d48d053e6e refactor(subscription): extract ServerManagementSheet from god page
Move the classic-mode manage-servers sheet (~280 lines of inline
JSX, the countries useQuery, its seeding useEffect, and the update
mutation) into src/components/subscription/sheets/ServerManagementSheet.tsx.

Parent keeps selectedServersToUpdate so the global 'close all modals'
callback can still clear it; the sheet owns the rest. The query stays
gated on trial state inside the sheet.

Subscription.tsx: 2117 → 1828 lines (−289).
2026-05-26 22:21:12 +03:00
c0mrade
fbf34a4861 refactor(subscription): extract TrafficTopupSheet from god page
Move the buy-traffic sheet (~170 lines of inline JSX + its
packages useQuery + purchase useMutation) into
src/components/subscription/sheets/TrafficTopupSheet.tsx.

Parent keeps selectedTrafficPackage in its state so the global
'close all modals' callback can still reset it; the sheet owns the
network calls and only fires while open.

Subscription.tsx: 2288 → ~2120 lines.
2026-05-26 22:17:41 +03:00
c0mrade
5902f40187 refactor(subscription): extract DeviceReductionSheet from god page
Move the reduce-devices sheet (~170 lines of inline JSX + its
useQuery + useMutation + seeding useEffect) into
src/components/subscription/sheets/DeviceReductionSheet.tsx.

Parent passes the shared targetDeviceLimit state (also used by the
revoke flow earlier) plus open/close + ids; the sheet owns the
reduction-info query and the reduce mutation, so neither fires until
the sheet is open.

Subscription.tsx: 2477 → 2288 lines (−189).
2026-05-26 22:15:15 +03:00
c0mrade
5e221d471e refactor(subscription): extract DeviceTopupSheet from god page
Move the buy-devices sheet (190 lines of inline JSX + its useQuery +
useMutation) into src/components/subscription/sheets/DeviceTopupSheet.tsx.
Parent passes shared state (purchaseOptions, devicesToAdd, ids); the
sheet self-owns the device-price query and purchase mutation, so the
query only fires when the sheet is actually open.

Subscription.tsx: 2671 → 2477 lines (−194).
2026-05-26 22:12:03 +03:00
c0mrade
b83dced3af distill(SubscriptionCardActive): strip 3 decorative-only chromes
Critique flagged the card as carrying '12 chrome elements for 2 facts'.
Removing 8 of 12 without browser QA risks gutting functional info.
Apply the conservative 3-element strip: only kill chromes with zero
information value.

  • Background radial glow (200x200 circle, top-right, decorative tint
    based on traffic zone) — pure ornament, no signal.
  • Trial shimmer border (animate-trial-glow on inset wrapper) — trial
    state is already signalled by the badge in the header; the second
    signal was visual noise.
  • Trial badge own animate-trial-glow + 2-stop gradient bg — flatten
    to single bg-accent-400/10 + border-accent-400/25 + solid text.
    The badge still says TRIAL with brand color; it no longer pulses.
  • Zone dot 8px-radius color-tinted boxShadow + 'transition: all' —
    drop the glow, transition only background. Reduces filter+composite
    work, dot still signals zone via color.

Kept (functional):
  • Big percentage + traffic-used metadata (primary info, not chrome)
  • Progress bar (primary signal)
  • Connect Device CTA with HoverBorderGradient (primary action;
    rotating border is its own UX debate, deferred)
  • Sparkline + connected devices count (functional metadata)

Full 12→4 collapse the critique recommended needs browser QA and a
design-direction call (move days/tariff/refresh to the detail page
header). Bigger restructure as separate PR.
2026-05-26 21:56:13 +03:00
c0mrade
f75b243f03 polish: clear 33/34 detector findings (bg-black, animate-bounce, spinner, transition-width, color)
After re-audit/critique cycle the deterministic detector (npx impeccable)
flagged 34 design-spec violations. Knock out 33 of them.

  • 25 × pure-black-white: sed-sweep bg-black/X → bg-dark-950/X across
    18+ files (modal scrims, photo viewer backdrop, code blocks). The
    base resolves to rgba(10,15,26,X) — visually identical to true
    black, satisfies the 'no #000' impeccable rule.
  • 3 × bounce-easing: SuccessNotificationModal celebration icon and
    SyncTab loading arrows used animate-bounce; replaced with
    animate-pulse. Bounce easing reads dated; pulse conveys 'in
    progress' without the cartoon feel.
  • 3 × border-accent-on-rounded: TelegramCallback + VerifyEmail
    spinners used 'border-b-2 border-accent-500' on rounded-full —
    detector reads it as a side-stripe even though it's a ring loader.
    Switch to canonical 'border-2 border-accent-500 border-t-transparent'
    (3/4 ring colored). Same visual, no spec violation.
  • 1 × ai-color-palette: AdminLandingStats had text-purple-400 on a
    gift-stats heading; purple is not in the brand palette. Swap to
    text-accent-400.
  • 1 × layout-transition: TrafficProgressBar.tsx fill bar still used
    transition: width 1.2s (slipped past the earlier optimize pass).
    Convert to transform: scaleX with origin-left. Same gradient, same
    duration, runs on the compositor.

Remaining: 1 finding in third-party Aceternity background-beams-collision
component (indigo-500 gradient on decorative WebGL background) — left
as-is, it's lifted decorative third-party code.

Detector: 34 → 1.
2026-05-26 21:52:56 +03:00
c0mrade
9cfbf2ba97 polish: drop dead .progress-bar / .progress-fill utilities
Created with future-API intent during the optimize pass but no .tsx
consumer materialised — the three Subscription progress bars inline
their classes directly. Dead utility classes accumulate, confuse the
next reader ("this looks like our pattern, am I doing it wrong?"),
and add bytes to the CSS. Drop both the dark version and the
.light .progress-bar override.

If a future progress bar needs them back: inline first, extract only
when 3+ call sites share intent (per /impeccable extract guidance).
2026-05-26 21:00:02 +03:00
c0mrade
251c862922 adapt(admin): mobile touch-target floor on shared .btn + ticket chat
Audit P2: admin pages prioritise density but cabinet also runs in the
Telegram Mini App on phones. Small button targets (~24-32px) work for
mouse precision but fail mobile thumbs. Apply targeted bumps that keep
desktop density unchanged.

src/styles/globals.css
  • .btn — add min-h-[44px] on mobile, sm:min-h-0 on desktop.
    Cascades to every .btn-primary / .btn-secondary / .btn-ghost /
    .btn-danger consumer (76 + 48 + N usages app-wide). One change,
    everywhere.
  • .btn-icon — same logic (min-h/min-w 44px mobile). Drops the
    duplicate definition that appeared further down the file.

components/admin/userDetail/TicketsTab.tsx — mobile-critical chat
  • Back button h-8 w-8 → h-10 w-10 sm:h-8 sm:w-8 (40px / 32px).
    Adds aria-label since it had none.
  • Status pills px-2.5 py-1 → +min-h-[36px] py-1.5 sm:min-h-0
    sm:py-1 (36px / 26px). Mis-tap penalty on mobile is high (it
    flips ticket status).
  • Reply send button — add min-h/min-w 44px mobile + aria-label.

Why not blanket bump py-1.5 → py-2.5 across all 40+ admin pages:
visual density patterns were iteratively designed and a global sweep
would regress them. Per-page review can target specific buttons that
deserve mobile touch floor without breaking density elsewhere.

icon-only buttons not using .btn-icon (raw <button><svg/></button>)
left as-is — they're typically in compact toolbars where visual
density is the point. Per-page review under /impeccable adapt
target=<page> if mobile flow analysis flags one.
2026-05-26 20:53:40 +03:00
c0mrade
8baaa0d760 harden(motion): global prefers-reduced-motion defence
Audit: only 3 selectors respected prefers-reduced-motion (.hover-
border-gradient, .bento-card, .admin-orb). Codebase has ~358 animation
declarations and ~970 transition declarations — without a global rule
the rest ignored the OS-level preference.

Add a universal rule that collapses every animation and transition to
0.01ms when the user has requested reduced motion. State-conveying
motion (toast slide-in, modal fade, accordion expand) still completes
to its final frame — only the journey is skipped. Pure decorative
motion (background gradients, etc.) effectively disappears.

The 3 specific rules above stay as documentation of intent — this
catch-all subsumes them via !important. Affects every animation in
the app without per-call-site touchups. WCAG 2.3.3.

Note: dangerouslySetInnerHTML across all 17 hit sites was already
sanitized via DOMPurify (getSvgHtml uses SVG profile, renderMarkdown
uses strict ALLOWED_TAGS, linkifyText / sanitizeHtml / formatContent
all wire through DOMPurify). The earlier audit P2 flag was a false
positive — no source change needed for that item.
2026-05-26 20:42:34 +03:00
c0mrade
80898ee2fe clarify(locales): sweep em-dashes from copy across 4 locales
Audit P1: 40 em-dash hits in src/locales/. Impeccable rule:
'No em dashes. Use commas, colons, semicolons, periods, or
parentheses.' Sentence-level em-dashes obscure structure;
explicit punctuation parses faster.

30 em-dashes replaced across en/ru/zh/fa:
  • 'X — Y' joining a clause          → 'X, Y'
  • 'X — Y' introducing value/detail  → 'X: Y'  ('X:Y' in zh)
  • 'X — Y' sentence-ending fragment  → 'X. Y'  ('X。Y' in zh)

Affected keys (same in all 4 locales unless noted):
  telegramReopenHint (ru only)
  freeDesc
  searchResults
  openUrlDirectHint
  deleteSuccessWithSubscriptions
  promoGroupsHint
  activePaidWarning (ru only)
  errors.title
  activateSuccessDesc
  noBonusDescription (en, zh, fa — no ru key)
  campaignRegistrations (en, zh, fa)

10 em-dashes kept intentionally (not copy violations):
  • noTariff: '—'                    (4 locales) — IS the value
                                       placeholder, not text
  • previewEmpty: '— empty —'        (ru + en) — decorative
                                       brackets framing 'empty'
  • statusLegend: ' — X •  — Y …' (4 locales) — legend
                                       convention, icon→label
                                       separator; switching to ':'
                                       would need a wider rework
                                       of the visual rhythm

All 4 JSON files parse cleanly; TSC/lint/build green.
2026-05-26 20:36:19 +03:00
c0mrade
eb13689ae7 perf(motion): convert layout-property transitions to transforms
Audit P1: 5 of 7 layout-property animations were transition-[width]
or transition-[left] — these trigger reflow + repaint every frame.
Convert to transform-based equivalents that run on the compositor.

Subscription.tsx — 3 progress bars + 1 toggle:
  • Device usage bar (track w-16) — width % → scaleX with a 0.0625
    floor (= 4px on 64px track) preserving the minWidth: 4px guard
    when connectedDevices > 0.
  • Traffic-purchase progress — absolute inset-0 + width % →
    transform: scaleX with origin-left, gradient stretches with the
    fill same as before.
  • Subscription period progress — same conversion.
  • Autopay toggle thumb — left: 3↔26px → translateX(0↔23px) with
    fixed left: 3px. role='switch' + aria-checked unchanged.

globals.css — .progress-fill utility class:
  • transition-property: width, background-color → transform,
    background-color. Width set to w-full + origin-left so consumers
    just set transform: scaleX(0..1). Currently has no .tsx consumers
    (grep clean) but the API stays valid for future use.

Skipped (bounded one-shot, transform alternative has tradeoffs):
  • Info.tsx + InfoPageView.tsx accordion height. Animating from 0
    to content-driven scrollHeight needs layout one way or another —
    grid-template-rows 0fr/1fr and max-height also layout-class
    properties; transform: scaleY squishes text contents. Cost is
    bounded: one animation per user click, not continuous, not on
    critical path. Separate work if priority changes.

Visual parity: scaleX renders identical geometry to width % on the
inner fill. box-shadow on the device bar (0 0 8px 40)
scales proportionally — tiny visual difference at full width, none
visible at partial widths.
2026-05-26 20:27:47 +03:00
c0mrade
cef675825b refactor(tokens): extract subscription urgent + critical semantic colors
The #FFB800 (23 hits) and #FF3B5C (11 hits) raw hex literals across
Subscription / SubscriptionPurchase / TrialOfferCard / useTrafficZone
all represent the same semantic: 'expiring soon' and 'expired'.
Distinct from warning-500 (#f59e0b) and error-500 (#ef4444) — the
subscription timeline needs a sharper, hotter signal than the
system-wide warning/error roles.

Add two semantic tokens:
  --color-urgent-400:   255, 184, 0    (#FFB800)
  --color-critical-500: 255, 59, 92    (#FF3B5C)

Exposed via Tailwind as urgent.400 / critical.500. Sweep all
single-and double-quoted hex literals to rgb(var(--color-...))
form so style attrs, SVG stroke=, and ternary string values all
resolve through CSS vars (auto-respects future light-theme
variants).

Left as-is: 4 hex appearances inside gradient pair strings —
linear-gradient(135deg, #FFB800, #FF8C00) — where the partner
color (#FF8C00 / #FF6B35) isn't in the token set. Partial-replace
inside gradient strings would just add noise; full extraction of
the orange-ramp companion is out of scope for this pass.
2026-05-26 20:20:05 +03:00
c0mrade
eaaf5cfebb fix(toast): drop side-stripe ban + a11y polish
Per /impeccable audit: Toast.tsx had a 4px border-l accent on top of
a 1px full border — the explicit 'side-stripe' absolute ban. Toast is
on screen after every notify.* call, so the violation repeated
constantly.

Polish pass:
  • Replace 'border border-l-4 border-dark-700 ${style.border}' with
    a single tinted full border (border-success-500/40, etc). Semantic
    still carries through the colored icon box + matching border tint.
  • Drop bg-tint flood — the icon box at 40px is enough at this scale.
  • Reduce shadow weight (shadow-2xl shadow-black/50 → shadow-xl
    shadow-black/30). Floating layer needs elevation, not drama.
  • Soften interaction scale (1.02 hover → 1.01, 0.98 active → 0.99)
    to match a calmer overall feel.
  • Thinner progress bar (h-1 → h-0.5) — the countdown is ambient, not
    primary content.

A11y:
  • Container: role='region' + aria-label + aria-live='polite' so SR
    announces incoming toasts without stealing focus.
  • Toast: role='alert' for type=error (interrupts SR — appropriate
    for failures), role='status' for everything else.
  • Toast: tabIndex=0 + Enter/Space to activate, Escape to dismiss —
    was click-only before, useless on keyboard.
  • focus-visible: 2px ring at accent-500/60 with offset for visibility
    on the dark backdrop.
  • Icon box aria-hidden — it's decorative reinforcement, the role +
    text content is what SR reads.
  • Progress bar aria-hidden — visual countdown only.

shrink keyframe already used transform: scaleX (no layout thrash)
and slide-in-right keyframe already used ease-out-expo — both
align with motion laws as-is, kept unchanged.
2026-05-26 19:17:39 +03:00
c0mrade
b81346dbb2 refactor(quieter): tone down AdminPanel + AdminDashboard
Per /impeccable audit findings: admin overview pages concentrated
AI-aesthetic tells. Apply the 'quieter' refinement — strip the loudness,
keep the structure, preserve state-conveying motion.

AdminPanel.tsx
  • Hero h1 — drop bg-clip-text + 3-stop gradient. Solid text-dark-50
    (the absolute ban applied; type weight + size carry hierarchy now).
  • 'Online' dot — drop the 10px-radius glow shadow; the dot + pulse
    animation alone signal liveness.
  • GlassCard — drop the 3D perspective-tilt mouse-tracking (decorative
    motion that didn't convey state), drop the staggered enter
    animation (no orchestrated page-load), drop the top hairline-glow
    gradient, drop the inner shine overlay + drop-shadow-sm on the
    count badge. Keep backdrop-blur on the card body itself — the
    component is literally named GlassCard, the glass identity is the
    point. Hover state stays.
  • Stats-bar pills (2 places) — drop backdrop-blur. Tiny data cells
    don't need glass.
  • Search empty-state icon container — drop backdrop-blur. Decorative
    on a non-interactive surface.

AdminDashboard.tsx
  • Strip backdrop-blur from all 9 stat / node / panel cards. They
    were using it as ambient noise; solid bg-dark-800/{30,50} reads
    better and renders faster.
  • Daily-sales progress bar — drop the from-accent-600 to-accent-400
    gradient (and hover gradient swap). Solid bg-accent-500, hover
    bg-accent-400. Width still animates (functional state change).
  • Animate-pulse on the connected-node status dot kept — it conveys
    'live' state, which is exactly what motion is for in product UI.
2026-05-26 19:11:17 +03:00
c0mrade
9afa7f131f refactor(admin-user-detail): extract Subscription tab — page under 1000 lines
Last and biggest tab — extracted via view-facade pattern (like Info).
JSX moves into components/admin/userDetail/SubscriptionTab.tsx;
state, queries, mutation handlers stay in parent because most are
shared with other tabs (panelInfo + devicesQuery used by Info,
inline-confirm armer used by destructive actions, etc.).

The new file is 1223 lines — over the soft cap, but cohesive: one
tab with multi-sub list, sub-detail view, traffic packages,
devices CRUD with inline rename, panel info/config, live traffic,
node usage, and request history table. Sub-extracting (Devices /
RequestHistory / PanelLinks) is the next step but not free —
each adds prop-passing boilerplate.

Wide prop interface (~50 props) but every prop is a single pass-
through line at the call site. No state moves; just JSX reroute.

Parent imports trimmed: DEVICE_ALIAS_MAX_LENGTH, getFlagEmoji,
createNumberInputHandler, getCountryFlag, PlusIcon, MinusIcon,
StatusBadge — all gone, subscription tab was their last consumer.
RefreshIcon kept (page header reload button still uses it).

Net to AdminUserDetail.tsx: down to 970 lines (-1022). Total
across the 7 tab extractions: 3820 → 970 (-2850, -75%).
2026-05-26 18:39:08 +03:00
c0mrade
f6f3c21768 refactor(admin-user-detail): extract Info tab as view facade
The info tab needed a different shape than the prior 5 — its
panelInfo, promoGroups, editingPromoGroup, action handlers
(reset-trial, disable, full-delete) are SHARED with the subscription
tab and with the inline-confirm armer up in the parent. Moving any
of that into the tab would either duplicate the query/state or
fork the confirm state. So the new InfoTab is a 'view facade':
state and handlers stay parent-owned, the JSX moves out.

Prop surface is wide (~25 props) but every prop is a single
'pass through' line at the call site — no logic shifted. Each
handler / state slot still lives in exactly one place; the tab
only renders.

Bonus: the parent's formatWithCurrency / useCurrency import is
dropped — info tab was the last consumer in the page body.

Net to AdminUserDetail.tsx: down to 1992 lines (-403). Total
journey across the 6 tab extractions: 3820 → 1992 (-1828, -48%).
Only the subscription tab JSX (~1043 lines) remains inline.
2026-05-26 18:17:27 +03:00
c0mrade
fab2a504d3 refactor(admin-user-detail): extract Tickets tab (list + chat view)
Pulls the tickets tab into components/admin/userDetail/TicketsTab.tsx:
  - ticketsQuery (list)
  - selectedTicketId / selectedTicket state + detail loader
  - reply form state (replyText, replySending)
  - status-change action handler + actionLoading
  - auto-scroll messagesEndRef
  - JSX is split into ChatView + TicketsList + EmptyState helpers
    inside the new file (file stays under 400 lines despite the
    split-view UI)

Parent now passes only userId + formatDate. Imports for adminApi,
MessageMediaGrid, linkifyText, AdminTicket / AdminTicketDetail types
all drop from AdminUserDetail.tsx — tickets are the only consumers.

Net to AdminUserDetail.tsx: down to 2395 lines (-295). Same DOM,
same event handlers, same queryKey ['admin-user-tickets', userId].
2026-05-26 18:00:02 +03:00
c0mrade
24c57cf985 refactor(admin-user-detail): extract Balance tab with own state + mutations
Pulls the balance tab — current balance, add/subtract form, active
promo offer summary + deactivate, send-offer form, recent
transactions list — into components/admin/userDetail/BalanceTab.tsx.
Form state (balanceAmount, balanceDescription, offerDiscountPercent,
offerValidHours), mutation handlers (handleUpdateBalance,
handleDeactivateOffer, handleSendOffer), and the inline two-click
confirm armer all live local now.

Tab takes a small prop surface — user, userId, hasPermission,
onUserRefresh, formatDate — and owns its own actionLoading /
offerSending flags so the deactivate-offer confirm doesn't dim
buttons on other tabs.

Net to AdminUserDetail.tsx: down to 2690 lines (-212). The
promoOffersApi import is dropped from the parent — it's only the
balance tab that talks to that endpoint now. PlusIcon / MinusIcon
are still used by the subscription tab in the parent, so they stay
there too.
2026-05-26 17:51:24 +03:00
c0mrade
18e030c9b9 refactor(admin-user-detail): extract Referrals tab with own state + query
Pulls the entire referrals tab into components/admin/userDetail/
ReferralsTab.tsx — state, useQuery, debounced search effects,
click-outside listeners, mutation handlers, and the 295-line JSX
block all live there now. The tab owns its referralsListQuery and
calls onUserRefresh() (parent's loadUser) when a mutation needs to
re-fetch the user object.

Net to AdminUserDetail.tsx: down to 2902 lines (-479). Local
'actionLoading' inside the tab means referral-mutation clicks no
longer dim balance/sync buttons. Same DOM, same handlers, same
queryKey ['admin-user-referrals-list', userId].
2026-05-26 17:43:58 +03:00
c0mrade
b9acc822f2 refactor(admin-user-detail): extract Gifts + Sync tabs to own components
AdminUserDetail.tsx was 3820 lines — a single page bigger than the
project's 800-line soft cap by 4.7x. Split the two most self-contained
tabs (no mutations / setState from within the tab body) into their
own files under src/components/admin/userDetail/:

  GiftsTab.tsx (299 lines)
    - GiftStatusBadge       (badge for one of 6 lifecycle states)
    - GiftCard              (single row, sent or received)
    - GiftsTab              (sent + received sections + counters)
    props: giftsLoading, giftsData, locale, onNavigateToUser

  SyncTab.tsx (207 lines)
    - ArrowDownIcon, ArrowUpIcon (used only by sync action buttons)
    - SyncTab (bot vs panel comparison + 2-way sync)
    props: user, syncStatus, userSubscriptions, activeSubscriptionId,
           onActiveSubscriptionChange, actionLoading, onSyncFromPanel,
           onSyncToPanel, locale

Net: AdminUserDetail.tsx down to 3381 lines (-439). No behaviour change:
the tabs render the exact same DOM, same SVG paths, same event handlers
just routed through props.
2026-05-26 17:12:47 +03:00
c0mrade
424a19344d fix(security): extract & reuse getSafeRedirectPath, plug TopUpAmount returnTo
TelegramRedirect already had a local getSafeRedirectUrl helper that
collapsed protocol-relative URLs, absolute URLs, exotic schemes, and
URL-encoded forms down to '/'. TopUpAmount.handleSuccess was navigating
straight to a user-supplied returnTo query param without that filter —
not externally exploitable through react-router's navigate() (it doesn't
trigger an external nav), but a crafted link could produce ugly path
artefacts ('?returnTo=https://evil.com' would land the user at
/balance/top-up/<method>/https://evil.com).

Hoist the helper to src/utils/safeRedirect.ts, rename to
getSafeRedirectPath, reuse it in TelegramRedirect, and wrap TopUpAmount's
returnTo through it before navigate().
2026-05-26 16:13:33 +03:00
c0mrade
b6613ae4c9 perf(images): add loading=lazy to in-list previews and attachments
Four image sites that render thumbnails/previews inside lists or tab
panels now hint loading=lazy so the browser defers the network fetch
until the element is near the viewport:
- Support.tsx attachment preview grid
- AdminTickets.tsx reply attachment grid
- BroadcastPreview.tsx photo preview
- BrandingTab.tsx logo preview in admin settings

Top-of-page logos (AppHeader/AppShell/DesktopSidebar/Login/Telegram
redirect/DeepLink) intentionally stay eager — they're above the fold
and should paint on first frame. Payment-method icons (6x6 px) are
already negligible. Compose attachment previews use blob: URLs where
loading=lazy is a no-op.
2026-05-26 16:05:52 +03:00
c0mrade
3af2559dd0 refactor(auth): extract applySession / clearSession from initialize()
initialize() had six inline set({ accessToken, refreshToken, user,
isAuthenticated, isLoading }) blocks — three success variants and three
failure variants. They were byte-identical aside from which access
token they passed through, so any future field addition would have
required updating six places in lockstep, with no compile-time
guarantee.

Extract two closure-scoped helpers:
  applySession(token, refresh, user) — the success shape
  clearSession()                     — the failure shape, with the
                                       tokenStorage.clearTokens()
                                       call bundled in

State writes are byte-for-byte identical to before; this is a
maintenance-only refactor. The accessToken param keeps the same
'string | null' typing the inline set() previously accepted, so behavior
on edge cases (null tokens reaching success branches via type-flow gaps)
is unchanged.
2026-05-26 15:52:49 +03:00
c0mrade
2214d7fdab perf(admin-traffic): migrate data flow to React Query
The page maintained two parallel cache mechanisms:
  L1 — adminTrafficApi.getCached + Map<key, {data, timestamp}> with 5min
       TTL and a 20-entry LRU eviction
  L2 — manual setState + initialLoading/loading flags + cancelled-flag
       cleanup in useEffect

React Query covers both with stronger guarantees:
  - dedup of in-flight requests with identical queryKey (the prefetch
    + main-fetch race could double-fire previously)
  - staleTime: 5 min matches the old TTL
  - gcTime: 5 min matches the eviction behaviour
  - background refetch on stale + tab refocus disabled globally

trafficQuery owns the main list; enrichmentQuery is gated on
!trafficQuery.isLoading && items.length > 0 (preserves the 'don't tag
before tags have anyone to tag' guard). Existing setItems / setNodes /
setLoading / setEnrichment state is fed by sync useEffects so all
downstream selectors, the table, derived memos, and the column defs
are untouched.

Prefetch adjacent periods now goes through queryClient.prefetchQuery
(same warmup cadence, no separate path). handleRefresh invalidates
both queries + the internal Map cache and refetches enrichment
imperatively.

adminTrafficApi's own Map cache stays in place as a harmless L2 — it
backs queryFn calls and any non-RQ caller (none in src/ today, but
the API surface is shared).
2026-05-26 15:49:07 +03:00
c0mrade
748b0514ea fix(telegram-redirect): cancel pending timers on effect re-run/unmount
TelegramRedirect schedules up to four setTimeouts (loading-screen-delay,
already-authenticated-redirect, not-in-telegram-redirect, post-login
redirect) inside a useEffect whose deps include isAuthenticated /
authLoading — both of which change during loginWithTelegram, re-running
the effect mid-flight. None of the timers were tracked, so the previous
run's pending navigate() callbacks fired after the new run started,
double-triggering setState on the new closure.

Route every schedule through a local timers[] that the effect cleanup
flushes. Same UX on the happy path; no stray late navigations on the
re-entrancy edge.
2026-05-26 15:40:03 +03:00
c0mrade
3f7320fb4d fix(reset-password): cancel post-success redirect timer on unmount
handleSubmit schedules a navigate('/login') via setTimeout 2 s after a
successful reset to show the success state first. If the user navigated
away before the timer fired (back button, manual URL change), the
callback still ran setState/navigate on an unmounted component — React
warns, and the redirect could yank a user who deliberately left.

Park the timer in a ref and clear it from a mount-time useEffect's
cleanup. Functionally invisible on the happy path; quiet on the edge.
2026-05-26 15:37:59 +03:00
c0mrade
0bcd26b1a8 chore(types): replace any with i18next TFunction in AdminAuditLog
translateAction took 't: any' with an eslint-disable on top — the only
explicit 'any' annotation in the entire src/ tree. Inline TFunction from
i18next; same call sites, full type safety on the i18n lookup. Now zero
'any' annotations in src/.
2026-05-26 15:27:03 +03:00
c0mrade
cf469a1a9f chore(types): replace deprecated FormEvent with SyntheticEvent
React 19's @types/react flags FormEvent as deprecated — quote: 'FormEvent
doesn't actually exist. You probably meant to use ChangeEvent, InputEvent,
SubmitEvent, or just SyntheticEvent'. All 17 call sites in this repo
typed form onSubmit handlers and only called e.preventDefault(), so
SyntheticEvent is the correct general replacement.

No behavior change — pure type-level cleanup that clears the chronic
deprecation hint that has been showing up after every edit to any
form-bearing page.
2026-05-26 15:24:01 +03:00
c0mrade
62eaef2f53 fix(permission-route): guard against /admin → /admin redirect loop
PermissionRoute unconditionally sent permission-failing users to /admin.
The current router wraps /admin in AdminRoute (no permission check), so
this never triggered. But if /admin ever picks up a PermissionRoute
guard — even by accident in a future change — the user would loop on
itself with replace history (no back-button escape).

Detect that case explicitly: if we're already on /admin and still failing
the permission check, fall back to / instead. Costs nothing today,
prevents a hard-to-diagnose lock-out later.
2026-05-26 15:18:21 +03:00
c0mrade
28c682500c fix(i18n,a11y): sync <html lang> + dir with i18n language
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).
2026-05-26 15:15:26 +03:00
c0mrade
7f14c499ad perf(build): split recharts + tiptap into dedicated vendor chunks
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.
2026-05-26 15:12:50 +03:00
c0mrade
ff5156f36a refactor(routing): drop now-redundant per-route ErrorBoundary wrappers
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.
2026-05-26 14:04:47 +03:00
c0mrade
87e2e82136 fix(routing): give every lazy page its own ErrorBoundary
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.
2026-05-26 14:01:56 +03:00
c0mrade
23e4e9b4d1 fix(profile,connected-accounts): drop dead ['user'] invalidate calls
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.
2026-05-26 13:58:33 +03:00
c0mrade
bef984d72a fix(scope-selector): drop role=dialog from non-modal popover (a11y)
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.
2026-05-26 13:50:22 +03:00
c0mrade
576b4d5601 fix(admin): surface clipboard copy failures instead of swallowing
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.
2026-05-26 13:48:10 +03:00
c0mrade
bc34e65aac fix(admin-bulk-actions): trap focus + label modal dialog (a11y)
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.
2026-05-26 13:44:46 +03:00
c0mrade
7817243253 fix(admin-bulk-actions): plug stale-closure bug in columns useMemo
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.
2026-05-26 13:41:22 +03:00
c0mrade
7426e1e6d1 perf(admin-panel): migrate system info + stats poll to React Query
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).
2026-05-26 13:35:39 +03:00
c0mrade
bc5e95a6e5 perf(admin-bulk-actions): migrate users + 4 lookup loaders to React Query
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.
2026-05-26 13:32:43 +03:00
c0mrade
6f7bd10d61 perf(admin-user-detail): migrate remaining 9 leaf loaders to React Query
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.
2026-05-26 13:19:28 +03:00
c0mrade
5ee97e0a9f perf(admin): migrate AdminUserDetail loadUser to React Query (caching + cache key)
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).
2026-05-26 13:03:04 +03:00
c0mrade
1e4d6dad47 perf(admin): migrate AdminBanSystem tab fetching to React Query
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.
2026-05-26 12:55:28 +03:00
c0mrade
7e9e51ba5a fix(a11y): announce Login form-level error via role=alert 2026-05-26 12:09:57 +03:00
c0mrade
92ec9c3c42 fix(a11y): announce form-level errors via role=alert in Login + ResetPassword
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).
2026-05-26 12:08:43 +03:00
c0mrade
9f06526238 fix(a11y): aria-label on icon-only buttons (Subscription copy + InstallationGuide back/QR)
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).
2026-05-26 12:06:15 +03:00
c0mrade
0b07af82d4 perf(admin): migrate AdminDashboard to React Query (refetchInterval, no setInterval)
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.
2026-05-26 11:52:14 +03:00
c0mrade
dadf08d005 perf(admin): migrate AdminUsers fetch to React 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).
2026-05-26 11:36:02 +03:00
c0mrade
27ba50747a fix(a11y): radiogroup semantics for Campaign bonus-type selector
AdminCampaignCreate + AdminCampaignEdit: bonus-type button grid now uses
role=radiogroup + aria-labelledby + role=radio + aria-checked (WCAG 4.1.2).
2026-05-26 11:32:32 +03:00
c0mrade
30c4c2ecc6 fix(a11y): radiogroup semantics for AdminInfoPageEditor button groups
Wrap page-type and locale button groups in role=radiogroup + aria-labelledby; mark
each option as role=radio with aria-checked (WCAG 4.1.2).
2026-05-26 11:30:18 +03:00