Commit Graph

1396 Commits

Author SHA1 Message Date
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
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
c0mrade
581a2ffb52 fix(a11y): radiogroup semantics for AdminPromoOfferSend button groups
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.
2026-05-26 11:28:37 +03:00
c0mrade
8d6baf8ad0 refactor(theming): TelegramLoginButton muted text → dark-400 token 2026-05-26 11:26:47 +03:00
c0mrade
a2efb7aa48 refactor(theming): blue → accent in PageLoader + ButtonsTab style picker 2026-05-26 11:25:27 +03:00
c0mrade
9bb1c2339a refactor(theming): blue-* → accent-* in admin/internal usages
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.
2026-05-26 11:24:28 +03:00
c0mrade
3df2134cba refactor(theming): orange-* → warning-* for promo/highlight semantics
Sweep orange across SubscriptionPurchase/GiftSubscription/PromoOffersSection/
AdminTrafficUsage/AnalyticsTab/withdrawalUtils to warning-* tokens. Stars brand
yellow→orange gradient kept in TopUpAmount.
2026-05-26 11:22:19 +03:00
c0mrade
d7cbbe077a refactor(theming): yellow-* → warning-* for status/pending semantics
Sweep yellow color classes that signal pending/warning status (admin pending pills,
withdrawal status, risk indicators) → warning-* tokens. Telegram Stars / fortune wheel
yellow kept as brand (TopUpAmount + Wheel + FortuneWheel).
2026-05-26 11:21:16 +03:00
c0mrade
ae7da0c86a refactor(theming): replace gray-* with dark-* tokens on blocking screens
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).
2026-05-26 11:19:49 +03:00
c0mrade
8a442e8aaa refactor(design): flatten remaining success-gradient CTA in SuccessNotificationModal 2026-05-26 11:18:48 +03:00
c0mrade
122f050a1b refactor(design): flatten accent gradient CTAs to solid bg per design law
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.
2026-05-26 11:17:42 +03:00
c0mrade
feee9f9c05 fix(theming): replace off-token TelegramCallback/VerifyEmail with design tokens
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.
2026-05-26 11:14:58 +03:00
c0mrade
bd8a4fad6e refactor(theming): replace amber-* with warning-* tokens
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).
2026-05-26 11:10:50 +03:00
c0mrade
cb7693e3ff refactor(theming): replace green-* / emerald-* with success-* tokens
Sweep: text/bg/border/ring/from/to/via/fill/stroke/shadow-{green,emerald}-N → success-N
(all were semantic success/active/paid states).
2026-05-26 11:09:28 +03:00
c0mrade
9c5e38b594 refactor(theming): replace red-* palette with semantic error-* tokens
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.
2026-05-26 11:08:22 +03:00
c0mrade
e1b48c1ccb fix(a11y): label associations for AdminTariffCreate fields
Add htmlFor/id pairs for name, description, daily price, traffic limit, device limit
and tier level (WCAG 1.3.1).
2026-05-26 10:48:46 +03:00
c0mrade
1ec6d7e979 fix(a11y): label associations for AdminPromoOfferTemplateEdit fields
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.
2026-05-26 10:45:29 +03:00
c0mrade
d42fcfa5dd fix(a11y): label associations for AdminServerEdit fields
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.
2026-05-26 10:42:15 +03:00
c0mrade
5a8195f83f fix(a11y): label associations for AdminPromoGroupCreate + AdminPinnedMessageCreate
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).
2026-05-26 10:39:22 +03:00
c0mrade
a635ca2316 fix(a11y): label associations for AdminPromocodeCreate fields
Add htmlFor/id pairs for code, type, balance bonus, days count, tariff, promo group,
discount percent, discount validity, max uses and valid-until inputs (WCAG 1.3.1).
2026-05-26 10:37:01 +03:00
c0mrade
6f66cb6759 fix(a11y): label associations for AdminCampaignEdit fields
Add htmlFor/id pairs for tariff selector, partner selector, name, start parameter,
subscription days/traffic/devices and tariff duration days inputs (WCAG 1.3.1).
2026-05-26 10:24:58 +03:00