Допы (5 карточек) рисовали 4+1: orphan-fill из двух arbitrary-вариантов
конфликтовал по специфичности — правило :last-child:nth-child(odd) бьёт
ресет :last-child, поэтому 5-я карточка тянулась на 2 колонки и на
десктопе. Заменил на один mobile-вариант max-lg:, как в AdminRemnawave:
десктоп — 5 в ряд, планшет/мобайл — 2 колонки + последняя на всю ширину.
Сводку тоже перевёл на lg:grid-cols-5 (было sm) — на планшете 5 колонок
давали ~115px и резали суммы; теперь до десктопа 2 колонки.
Две карточки — количество и сумма возвратов за период (красный тон).
Сетку сводки перевёл на 2/5 (10 карточек делятся ровно, без пустых
ячеек). StatCard получил invertDelta: для «меньше — лучше» метрик рост
красится в красный, падение в зелёный (стрелка по факту). 4 локали.
- Числовой формат даты (01.06.2026) — постоянная ширина в любой локали,
раньше короткое имя месяца («мая» vs «сент.») меняло ширину кнопки.
- min-w + justify-start + truncate на триггере: плейсхолдер и выбранная
дата дают одинаковую ширину — поле не прыгает по горизонтали.
- fixedWeeks + showOutsideDays: всегда 6 недель, высота календаря не
скачет при листании месяцев (5↔6 рядов).
- collisionPadding + max-w-[calc(100vw-1rem)]: поповер не вылезает за край
экрана на мобайле.
- Контейнеры диапазона дат (продажи, трафик) переведены на flex-wrap /
full-width на мобайле — два поля больше не вылезают за ширину.
Сводка (8 карточек) была 2/3/4 колонки — на 3-колоночном брейкпоинте
последний ряд оставлял пустую ячейку. Перевёл на 2/4 (8 делится ровно).
Допы (5 карточек) тянули сироту на 2- и 3-колоночном — теперь 2/5 с
orphan-fill: последняя нечётная карточка занимает всю ширину на мобайле
(паттерн как в новостях/подписках).
Тёмный DateField теперь везде, где был браузерный <input type="date">: фильтр
периода в Трафике, диапазон дат в Платежах и Аудит-логе, срок действия промокода.
Единый вид во всём кабинете. (datetime-local не трогал — там нужно ещё и время.)
Нативный <input type="date"> выглядел чужеродно (браузерный светлый календарь).
Новый общий компонент DateField: react-day-picker внутри Radix Popover, тёмная
тема через CSS-переменные .rdp-dark, навигация — наши иконки из барреля,
локализация месяцев/дней по языку кабинета (date-fns). Принимает/отдаёт
'YYYY-MM-DD' — drop-in замена нативного инпута. Подключён в селекторе периода
статистики (свой период). Остальные места — следующими шагами.
Статистика по умолчанию открывается за текущий месяц с 1-го числа до сегодня
(month-to-date), а не за фиксированные 30 дней. Добавлена кнопка «Этот месяц»
в селектор периода; util getMonthToDateRange/isMonthToDate.
Доделал душевный вид: карточки внутри вкладок (Триалы/Продажи/Продления/Допы/
Депозиты) тоже получили иконки в цветных чипах с тонами — единый стиль со сводкой
(регистрации/триалы/конверсия/длительность, продажи/выручка/чек/топ-тариф,
продления/%, допы/GB/устройства, пополнения/сумма/средний). Крупные суммы — без
копеек.
- StatCard: лейбл сверху, чип+значение в одной строке (чип центрируется ровно по
значению, дельта ушла под строку) — иконки больше не «плавают» по вертикали
из-за разной высоты карточек.
- «Активные подписки»: TicketIcon вместо SubscriptionIcon (та визуально как искра
и дублировала «Новые триалы»).
- StatCard: иконка в мягком цветном чипе (tone success/accent/warning/…) в духе
статистики Remnawave; дельта под значением. Обратно совместимо (valueClassName).
- Карточки сводки получили осмысленные иконки (доход/подписки/триалы/конверсия/
продления/допы/пополнения) и цветовые тоны.
- Скачок при переключении периодов убран: на все запросы статистики добавлен
placeholderData: keepPreviousData — старые данные остаются на экране, пока
грузятся новые, без вспышки скелетона и прыжка вёрстки.
Карточки сводки резали суммы («252…», «100.0…»): на десктопе их было 8 в ряд
(слишком узко), а дельта стояла в той же строке и доедала место.
- Дельта переехала ПОД значение — значение получает всю ширину карточки.
- Сетка сводки: убрал xl:grid-cols-8, максимум 4 в ряд (карточки шире).
StatCard получил опциональную дельту (стрелка + %), а страница статистики тянет
сводку ещё и за предыдущий равный период и считает рост/падение для метрик
периода: выручка, новые триалы, продления, доход с допов, ручные пополнения.
Для «всего времени» сравнение не показывается (не с чем сравнивать).
Полировка статистики продаж (читаемее/информативнее):
- BreakdownList — категорийные разбивки списком (иконка/цвет + значение + доля % +
пропорциональный мини-бар) вместо bar-графика. В «Депозиты → по методам» теперь
с иконками платёжек (PaymentMethodIcon), в «Продажи → по тарифам» — с долями.
- StackedBarChart — «по дням разбивкой» теперь stacked-бар вместо наслаивающихся
полупрозрачных area (читалось плохо); тултип по дню показывает все серии.
- Продажи: добавлена карточка «Выручка» (total_revenue бэк отдавал, но не показывался).
The extra lights that appear during a spin (and bunched into a line through the centre) were the SPARKLE_POSITIONS effect — yellow animate-ping dots rendered only while isSpinning. Moving/scattering them never satisfied; they're unwanted during the spin. Remove them outright: while spinning, only the rim LEDs remain — no extra lights, no centre line.
The center streak doesn't appear in screen recordings, which is the signature of a GPU-compositing artifact — CSS filters on SVG elements mis-render when the wheel group is promoted to a layer during the spin. Replace the two SVG CSS filters with compositing-safe equivalents: LED-glow blur -> radial gradient fill, emoji drop-shadow -> SVG-native feDropShadow. (HTML background blur left as-is; it's not on the SVG and isn't a light.)
Confirmed from the user's spin video: the sparkles bunched into the middle during a spin because they were placed at 16-40% from centre (some right by the hub). Push them all to a 34-43% radius (golden-angle distributed) so they ring the outer wheel and the centre stays clear — no sparkles crossing or near the centre.
The real cause of the center line: SPARKLE_POSITIONS (yellow dots shown only while spinning) used `20 + (i*10)%60` / `15 + (i*13)%70`. For the first ~6 sparkles the modulo never wraps, so top/left increment linearly and the dots land on a straight diagonal through the centre — the 'line of lights' during the spin. Scatter them on a golden-angle spiral inside the wheel instead.
Reverts the earlier LED-glow change (it wasn't the cause): the rim-light blur is restored to the original.
The rim lights are static cx/cy SVG circles; their glow used a CSS filter:blur(2px). During the spin the wheel's transform transition promotes the SVG to a GPU layer, and Chrome mis-renders CSS filters on geometry-positioned SVG elements — the blurs streak toward the view-box origin, drawing a line of lights through the centre. Replace the blur with a soft radial-gradient fill (no filter, so the artefact can't occur).
Also drive both the chase speed and the per-dot delay from one --led-dur var so the chase stays in order at the faster spin speed instead of scrambling (the delay was a fixed 6s value that broke once the duration dropped to 2s).
On sm+ the regular news cards sit in a 2-col grid; an odd count left an empty cell. Move the featured card out of the grid (it was col-span-full anyway, which also skewed nth-child parity) and stretch the last regular card to span both columns when it's the lone one in its row — same orphan-fill used for the admin stat cards.
The panel marks each node version with its product logo. Add an XrayIcon (the Xray core brand pinwheel, custom SVG like RemnawaveIcon) and put RemnawaveIcon before the remnanode version + XrayIcon before the xray version, matching the original node rows. Drop the redundant 'xray' word since the logo conveys it.
A personal glyph-by-glyph re-review of the migration (the "steering wheel"
guard) found two wrong matches:
- MaintenanceScreen: a wrench/maintenance svg had been mapped to AdjustmentsIcon
(sliders) → use a proper WrenchIcon (PiWrench).
- InstallationGuide tutorial button: an open-book svg had been mapped to
DocumentIcon (file) → use BookOpenIcon (PiBookOpen).
Add WrenchIcon and BookOpenIcon to the central barrel. All other replacements
were verified correct (paper-plane→Send, server→HardDrives, warning-triangle→
Warning, etc.).
Audit-driven sweep: replaced 168 hand-written inline <svg> icons across 77
files with the central Phosphor (react-icons/pi) components from
@/components/icons, preserving each icon's size classes and colour (dynamic
stroke colours via the parent's currentColor, RefreshIcon's spinning state,
conditional rotate-180 chevrons).
Verified: tsc + vite build + eslint clean; an adversarial diff review of all
changed files found the replacements correct (70 files clean, 0 blockers).
Remaining inline <svg> dropped from 262 to ~95 — the survivors are legitimate
non-icons (brand/provider logos, loading spinners & framer-motion animations,
charts/sparklines, background decoration) plus a few ambiguous glyphs.
PurchaseCTAButton (the "Продлить подписку" CTA) rendered a single inline
sparkle; swap it for the SubscriptionIcon component so it matches the two-star
sparkle in the navigation. Colour is inherited from the badge container.
The inline single sparkle didn't match the two-star sparkle shown in the nav.
Render the same SubscriptionIcon component so the button matches the nav exactly.
The expired-card quick-renew button showed a lightning bolt, inconsistent with
the sparkle used on every other subscription CTA. Match the subscription CTA.
- Drop the Duotone variants (their translucent secondary fill looked murky on
the dark theme) in favour of the clean regular weight across all icons
- WheelIcon: PiSteeringWheel → PiDiceFive (a steering wheel made no sense for
the fortune-wheel feature)
Replace the cabinet's hand-written heroicons-style SVG icon components with
the Remnawave panel's own icon family — Phosphor via react-icons/pi (Duotone).
All icons now live in a single central barrel (src/components/icons:
index + extended-icons + editor-icons, 144 icons); every feature file imports
from it instead of redefining inline SVGs (~440 icon defs removed).
- Add react-icons dependency
- Desktop nav (AppShell) now uses the central Phosphor icons, removing a
stroke-vs-duotone inconsistency the partial migration introduced
- Icons with custom props (SortIcon's direction, expandable chevrons) kept as
thin Phosphor wrappers; the RemnawaveIcon brand logo and animated SVGs
(checkmarks, spinners) are intentionally left as-is
- Restore the original per-call-site icon sizes wherever the centralized
default differed (211 call sites)
Surface: users (and especially Серёжа в bugs topic) were seeing raw i18n keys
like `subscription.promoGroup.title` rendered as literal text on the renewal
screen. Audit found 118 keys referenced in code that were absent from all 4
locale files, plus 140 keys missing only in zh/fa (silently falling back to
Russian for those users).
Root causes:
- cf52999 (extract TariffPickerGrid) introduced `subscription.promoGroup.title`
that never existed — should have been `subscription.promoGroup.yourGroup`.
- Subscription.tsx used `subscription.autopay.title` (object path) while
SubscriptionListCard.tsx used `subscription.autopay` (leaf) — i18next can't
hold both shapes for the same key.
- Many user-flow keys (subscription.notFound, subscriptions.empty, common.ok,
successNotification.*, etc.) were never added when their components landed.
- Historical drift had ~140 keys in ru/en that never got zh/fa translations.
Fix:
- Rename TariffPickerGrid usage `promoGroup.title` → `promoGroup.yourGroup`.
- Rename Subscription.tsx usage `autopay.title` → `autopay`.
- Add 116 brand-new keys across ru/en/zh/fa (464 entries).
- Add 8 partially-missing keys (en+ru gaps) for landing periods, dashboard
campaign/referrer stats, and promo offer failure counts.
- Translate 280 entries to close the zh/fa drift.
Final state: 0 keys missing in any of the 4 locale files for any code-referenced
translation key. Build + tsc green.
Third and final step in the AdminTrafficUsage decomp. Move the
filter dropdowns + PeriodSelector into
src/components/admin/trafficUsage/filters/:
- PeriodSelector.tsx (103 lines): fixed period tabs (1/3/7/14/30
days) + custom date-range mode. Exports the PERIODS constant
so the parent's prefetch effect iterates the same canonical
list.
- TariffFilter.tsx (133 lines): pop-over multi-select for tariff
names.
- StatusFilter.tsx (147 lines): pop-over multi-select for
subscription statuses. Exports STATUS_COLORS alongside.
- NodeFilter.tsx (135 lines): pop-over multi-select for traffic
nodes (with flag emoji + traffic-share numbers).
- CountryFilter.tsx (132 lines): pop-over multi-select for
source countries.
All five are pure controlled components — parent owns every
Set<string> filter state and feeds them via {available, selected,
onChange}. The parent imports them and drops 9 now-unused icon
imports (FilterIcon, ChevronDownIcon, ServerIcon, CalendarIcon,
StatusIcon, GlobeIcon — they live inside the filter files now).
AdminTrafficUsage.tsx: 1593 → 977 lines (-616).
Cumulative across the 3-step admin-traffic decomp:
1904 → 977 (-927, -49%).
Second step in the AdminTrafficUsage decomp. Move the 15 inline SVG
icons (Search/Chevron/Refresh/Download/Sort/Filter/Server/Calendar/X/
Status/Globe/Shield/ServerSmall) into a single barrel at
src/components/admin/trafficUsage/TrafficIcons.tsx. Pure SVG, no
behavioural change.
AdminTrafficUsage.tsx: 1734 → 1593 lines (-141).
First step in the AdminTrafficUsage decomp. Two new files under
src/components/admin/trafficUsage/:
- trafficUsageHelpers.ts (166 lines): the TanStack Table module
augmentation (sticky/align/bold meta), formatters (formatBytes,
formatCurrency, formatShortDate, formatGbPerDay,
toBackendSortField, getFlagEmoji wrapper), risk-assessment helpers
(bytesToGbPerDay, getRatio, getRowBgColor, getNodeTextColor,
getRiskLevel, getCompositeRisk), and the RISK_STYLES constant.
- RiskBadge.tsx (36 lines): the small per-row risk indicator
(dot + GB/d value + mini progress bar). Pure presentational.
Parent imports everything from the new modules; the inline
'TanStack Table module augmentation' lives in the helpers file
so the declaration merge still picks up across the codebase
once the helpers are imported.
AdminTrafficUsage.tsx: 1904 → 1734 lines (-170).
Fourth and final step in the AdminBulkActions decomp.
SubscriptionSubRow: the expanded per-user secondary <tr> shown when
the operator drills into multi-subscription users (tariff, status,
days remaining, traffic bar, devices). Pure presentational.
StatusBadge: the pill used both inside the sub-row and the primary
user-table column (subscription_status). Co-located here because
both consumers want the same visual contract.
AdminBulkActions.tsx: 1367 → 1182 lines (-185).
Cumulative across the 4-step admin decomp: 2539 → 1182 (-1357, -53%).
Third step in the AdminBulkActions decomp. Move the 154-line
pop-over multi-select used by the tariff / status / node filters
into src/components/admin/bulkActions/MultiSelectDropdown.tsx.
Pure controlled primitive: closes on outside click, provides
select-all / deselect-all helpers, uses the shared ChevronDownIcon
from DropdownSelect.tsx. Exports MultiSelectOption type alongside.
Parent drops its inline ChevronDownIcon import (now only referenced
by the moved sibling) and imports MultiSelectDropdown + the option
type from the new module.
AdminBulkActions.tsx: 1517 → 1367 lines (-150).
Second step in the AdminBulkActions decomp. Two new files:
- actionTargets.ts (27 lines): SUBSCRIPTION_LEVEL_ACTIONS set +
isSubscriptionLevelAction(). The classification is the source of
truth shared between FloatingActionBar (renders two grouped menus
when multi-tariff is on) and the parent page (gates selection-count
semantics + the active-paid count in delete-subscription).
- FloatingActionBar.tsx (304 lines): the bottom-dock selection
toolbar. Pure presentational — parent owns selection state and
supplies onAction (opens the modal with the picked action type)
+ onToggleAllSubscriptions. The full 11-item action config (icons
+ label keys + colour classes) lives inside the file as a private
ActionConfig array.
Parent now imports FloatingActionBar + isSubscriptionLevelAction
from the new modules; the inline ActionConfig interface is gone.
AdminBulkActions.tsx: 1819 → 1517 lines (-302).
Cumulative across the 2-step admin decomp: 2539 → 1517 (-1022, -40%).
First and largest step in the AdminBulkActions decomp. Two new
files under src/components/admin/bulkActions/:
- DropdownSelect.tsx (47 lines): the small native-<select> wrapper
used both by the filter row and the ActionModal's tariff/promo
picker. Exports DropdownSelect + the shared ChevronDownIcon +
DropdownOption type.
- ActionModal.tsx (712 lines): the per-action configuration +
execution modal. Wraps three internal views — ProgressView (live
SSE log + counters while a job streams), ErrorDetails (collapsible
per-row error list after completion), and the per-action form
(days / tariff / traffic / balance / promo-group / grant /
set-devices / delete-* confirmations). Exports the ModalState
and ProgressState types alongside.
Self-contained: parent feeds modal state + reference data (tariffs,
promo groups, users for active-paid counting) and receives
onExecute(params) when the operator confirms. No queries or
mutations here — execution flow stays in the parent's
handleExecuteAction.
Parent keeps a local CheckIcon (still used by MultiSelectDropdown
checkboxes + the user-table column defs) and re-imports
ChevronDownIcon / DropdownOption from the new file.
AdminBulkActions.tsx: 2539 → 1796 lines (-743).
The largest and last structural extraction in the SubscriptionPurchase
decomp. Moves the classic-mode purchase flow (period → traffic →
servers → devices → confirm) into a self-contained step wizard at
src/components/subscription/purchase/ClassicPurchaseWizard.tsx.
The wizard self-owns:
- the preview query (gated on confirm step + form open)
- the submitPurchase mutation
- all six pieces of wizard state (currentStep, selectedPeriod,
selectedTraffic, selectedServers, selectedDevices, showPurchaseForm)
- the steps memo + currentSelection memo + currentStepIndex/isFirst/isLast
- the init useEffect (seeds from classicOptions.selection)
- toggleServer / goToNextStep / goToPrevStep / resetPurchase / getStepLabel
- its own useCloseOnSuccessNotification handler (closes form + resets
step on global success notification) — replaces the parent's
handleCloseAllModals dependency for the classic flow
Parent shrinks to a pure orchestrator: route params, three top-level
queries (subscription, purchase-options, subscriptions-list), mode
detection, two pieces of tariffs-mode state, the success-notification
handler for the remaining tariff modals, and JSX that delegates to
the four extracted sub-components.
SubscriptionPurchase.tsx: 908 → 370 lines (-538).
Cumulative across the 5-step decomp: 2054 → 370 (-1684, -82%).
Drops 11 parent imports (useEffect, useMemo, useCallback, useMutation,
useQueryClient, useNavigate, PurchaseSelection, PeriodOption,
InsufficientBalancePrompt, CheckIcon, Twemoji, useCurrency,
usePromoDiscount, getErrorMessage, PurchaseStep).
Move the tariff selection surface (promo-group banner, all-purchased
empty state, and the 1/2-col tariff cards grid with per-tariff CTAs)
into src/components/subscription/purchase/TariffPickerGrid.tsx.
The grid is pure presentation — it owns no queries or mutations.
Two callbacks back into the parent:
- onSelectTariff(tariff) → parent sets selectedTariff + showForm
- onSwitchTariff(tariffId) → parent sets switchTariffId
Internal helpers (formatPrice via useCurrency, applyPromoDiscount
via usePromoDiscount, glass tokens via useTheme + getGlassColors,
navigate for the 'all purchased' back-to-list button) all derive
inside the component — nothing threaded as props beyond data.
SubscriptionPurchase.tsx: 1202 → 908 lines (-294).
The single biggest extraction in the SubscriptionPurchase decomp.
Moves the per-tariff purchase form into a self-contained component
at src/components/subscription/purchase/TariffPurchaseForm.tsx.
The form self-owns:
- the purchaseTariff mutation (mode-aware: daily-tariff activate vs
period-based + custom-days / custom-traffic toggles + summary)
- the auto-scroll-into-view ref + effect on mount
- selectedTariffPeriod / customDays / customTrafficGb / useCustomDays /
useCustomTraffic — five pieces of state that were parent-level but
only ever read inside this form. Form remounts with fresh state
when the parent passes a new tariff (key={selectedTariff.id}).
Parent only owns selectedTariff + showTariffPurchase (the two pieces
the orchestrator needs to decide what to render) and threads them
into <TariffPurchaseForm>. Tariff card click handlers drop their
period-seeding line (the form seeds itself from tariff.periods[0]
on mount). The subscription_expired fallback callback simplifies
for the same reason.
SubscriptionPurchase.tsx: 1838 → 1222 lines (-616). Drops useRef,
TariffPeriod, and getInsufficientBalanceError from parent imports.
Move the tariff-switch preview modal (137 lines of inline JSX
+ its previewTariffSwitch query + switchTariff mutation + the
auto-scroll-into-view useEffect + the ref) into a self-contained
sheet at src/components/subscription/sheets/SwitchTariffSheet.tsx.
Parent keeps the trigger (switchTariffId selection state lives on
the TariffPickerGrid card click) but no longer holds the query, the
mutation, the ref, or the side-effect. The subscription_expired
backend signal — which used to call three setters at parent level
(setSelectedTariff, setSelectedTariffPeriod, setShowTariffPurchase)
— now flows through a single onExpiredFallback(tariff) callback.
SubscriptionPurchase.tsx: 2010 → 1841 lines (-169).
PRODUCT.md anti-references call out the SaaS-template / emoji-in-chrome
register: the reference products (Linear, Stripe, Things 3) don't put
platform emoji in product chrome. Three small drift spots:
- Balance saved-cards link used a 💳 emoji as the leading icon.
Replaced with a new CreditCardIcon added to the barrel (Heroicons-
style stroke, matches the chevron / chat / wallet siblings).
- Subscription paused-info banner used a ⏸️ emoji. Replaced with a
new PauseIcon added to the barrel. Same 24×24 stroke, currentColor,
preserves the urgent-400 tint via inline style.
- Balance payment-method min/max range used a literal en-dash ('–')
in source. PRODUCT.md's anti-em-dash rule covers the en-dash by
the same logic. Switched to a translated 'to' connector
(t('common.rangeTo', 'to')) so other locales can translate freely.
Previously the bottom nav swapped Support out for Wheel whenever the
operator enabled the wheel feature flag. This left a frustrated
paying customer (a key persona — already mid-ticket, opening the
app to follow up) unable to find help from the primary nav: they had
to know to dig into the hamburger drawer.
Wheel is a brand-moment surface, Support is a critical-path surface.
Replacing the second with the first is the wrong IA trade.
New rule: Support is the floor of the bottom nav and never moves.
Slot 4 still carries an optional item, with Wheel winning over
Referral when both are enabled (operator opted into Wheel as a
deliberate brand moment; Referral users can use the hamburger).
Critique P3 issue.
DESIGN.md 'Tunable-but-Scarce Rule' caps accent at <=10% of any
screen and reserves the accent + status hues for action / status,
never decoration. Four sites were spending those tokens on pure
chrome:
- Subscription.tsx purchased-traffic bar: zone.mainHex linear-gradient
on a non-status progress bar; the per-purchase bar inherited the
user's GLOBAL traffic-zone color (a fresh purchase could read
'critical' just because the user's overall usage was). Replaced
with solid accent-500 fill — same affordance, honest semantics.
- SubscriptionCardActive tariff badge: zone-colored linear-gradient
background + zone-colored label. The tariff name has no traffic-zone
semantics, so tinting it by the global traffic zone was a
Status-Hue Lockout violation. Switched to glass innerBg/innerBorder.
- Balance hero card: bg-gradient-to-br from-accent-500/10 + glow prop
on Card. Removed; flat surface, the giant numeric is the affordance.
Eliminates the SaaS hero-metric template tell.
- Login background: two stacked fixed inset gradients (linear +
accent radial halo) read as the airdrop / crypto aesthetic
PRODUCT.md explicitly anti-references. Replaced with the plain
body bg-dark-950.
Three issues from the post-PRODUCT.md /impeccable critique pass:
P0 — backdrop-blur-xl shipped to mobile on the two hero cards
(SubscriptionCardActive + Subscription detail). DESIGN.md
'Mobile-Without-Blur Rule' restricts backdrop-blur to ≥1024px.
Gate with lg: prefix so phones (half the audience) get the opaque
surface and skip the scroll-jank-inducing GPU blur layer.
P1 — Two destructive device-deletion sites still called the bare
browser confirm(). The codebase already imports useDestructiveConfirm
and uses it correctly for revoke (line 484). Bring the all-devices
and per-device delete paths onto the same platform-aware path, so
inside Telegram the user gets the native destructive popup with
haptic + theme, and on web they get the inline destructive panel.
P2 (partial) — Removed the decorative ambient radial-gradient halo
behind the Subscription detail hero card and the trial-shimmer border
overlay. Same chrome was distilled out of SubscriptionCardActive
earlier in this branch; the rationale is identical (zone/accent hue
leaking into pure decoration violates DESIGN.md Tunable-but-Scarce
+ Status-Hue Lockout rules). Trial state is conveyed by the header
badge.
The five extracted sheet components each had a hand-inlined chevron
SVG copy-pasted from the original god page. Swap them for the
existing ChevronRightIcon from src/components/icons — same visual,
one source of truth (Heroicons-aligned path), and pre-baked sizing
through the cn() className merge.
Note on the broader 'icon unification' audit recommendation: this
project does not depend on lucide-react. It already ships a 703-line
hand-rolled icon barrel at src/components/icons/index.tsx. The real
cleanup is migrating the ~139 inline SVGs across the app to that
barrel, not adding a new icon library. This commit consolidates the
five duplicated chevrons introduced by the recent sheet extraction;
broader inline-SVG migration is a separate, larger refactor.
Move the delete-subscription expand/confirm block into
src/components/subscription/sheets/DeleteSubscriptionSheet.tsx.
The sheet itself decides between the Telegram destructive native dialog
and the inline web confirm panel, so the parent no longer needs the
platform discriminator. It owns deleteLoading and the API call; the
parent passes onDeleted (navigate + invalidate) and the textSecondary
glass color token used in the warning copy.
Subscription.tsx: 1828 → 1750 lines.
Drops now-unused imports (usePlatform; deleteLoading state).
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).
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.
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).