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).
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.
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.
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).
Sweep all amber-N color classes to warning-N (uniformly pending/warning semantics).
Yellow and orange left for per-file review (mixed Stars brand + semantic uses).
Sweep 30 files: text/bg/border/ring/from/to/via/fill/stroke/shadow/divide/decoration/
outline/placeholder-red-N → -error-N. All red usages were semantically error/danger
(no brand red), so tokens now flow through the design-system CSS variables and respond
to palette overrides.
Add role=switch + aria-checked + aria-label to the custom toggle buttons so screen
readers announce on/off state (WCAG 4.1.2): autopay (Subscription) and the custom-days
/ custom-traffic toggles (SubscriptionPurchase).
On revoke, RemnaWave panel clears all device HWIDs along with the
subscription URL rotation, but the cabinet was only invalidating
['subscription'], ['connection-link'], ['subscriptions-list'] — leaving
['devices', subscriptionId] cached. UI showed the now-stale device list
until the user manually refreshed.
The GET /subscription/devices endpoint reads HWIDs live from RemnaWave
(no DB cache to clear), so invalidating the TanStack query is
sufficient — the immediate refetch returns the now-empty device list.
- Extract DEVICE_ALIAS_MAX_LENGTH to src/constants/devices.ts so the
user page and admin page share one source of truth (mirroring the
bot's ALIAS_MAX_LENGTH=64). Was duplicated as a magic 64 in both
files (MED-5).
- Replace ✓ / ✕ / ✏️ glyphs in AdminUserDetail.tsx with inline SVGs,
matching the icon style already used by Subscription.tsx for the
same rename/save/cancel actions. Eliminates the platform-dependent
emoji rendering inconsistency (MED-4).
- Add haptic feedback to the user-side renameDeviceMutation —
notification('success') on save, notification('error') on failure.
Brings it in line with the other Subscription.tsx mutations
(LOW-9).
- Translate the new rename keys into zh.json and fa.json (en.json
already covered in the previous follow-up). Cabinet now ships full
parity across all 4 locales for the rename UI.
Code-review follow-up on 321c65b.
- prettier ate the raw glyphs (✓/✕/✏️) in AdminUserDetail.tsx and emitted
\u2713 / \u2715 / \u270F\uFE0F as JSX text children. JSX does NOT
process \u… escapes inside element children, so admin users saw the
literal six-character strings on Save/Cancel/Rename buttons. Wrap each
glyph in a JS expression {'…'} so it survives both prettier and JSX.
- renameDeviceMutation.onSuccess on Subscription.tsx no longer
unconditionally clears the edit state. If the user already navigated
to a different device while the previous save was in flight, we keep
their in-progress input. Same shape applied to AdminUserDetail.tsx —
the alias is snapshot before the await so a race can't smuggle the
wrong value into the request, and the post-success reset is guarded.
- AdminUserDetail rename error path now surfaces the backend's
response.data.detail (e.g. '64-char limit' message) instead of the
generic localized userActions.error toast.
- Add the new rename i18n keys to src/locales/en.json so English users
no longer fall back to Russian labels. ua/zh/fa to follow in a
dedicated translation pass.
Pairs with the bot commit that adds the user_device_aliases table.
Surfaces the new local_name field on the existing devices list in
both the user-facing subscription page and the admin user-detail
page, with the same inline-edit pattern in both places.
User-side (src/pages/Subscription.tsx):
- pencil button next to each device row toggles edit mode
- input is focused automatically, capped at 64 chars (matches the
backend ALIAS_MAX_LENGTH and DB column width)
- Enter saves, Escape cancels, empty input clears the alias
- display priority: local_name → device_model → platform
- works identically in classic / single-tariff / multi-tariff —
subscriptionId is forwarded as a query param like every other
device-management endpoint already does
Admin-side (src/pages/AdminUserDetail.tsx):
- same pencil + inline input pattern, admin acts on behalf of the
user. notify.success on save, loadDevices() refresh.
API (src/api/subscription.ts + src/api/adminUsers.ts):
- new renameDevice(hwid, name, subscriptionId?) on subscriptionApi
- new renameUserDevice(userId, hwid, name) on adminUsersApi
- existing getDevices/getUserDevices contracts widened with
local_name?: string | null on the returned device shape
Locales (src/locales/ru.json):
- subscription.renameDevice / .renameDeviceSave / .renameDeviceCancel
/ .renameDevicePlaceholder / .deviceRenamed
- admin.users.detail.devices.rename / .renameSave / .renamed
The per-device price line in the buy-devices modal showed a
strikethrough 'Бесплатно' for users with a promo-group discount,
because original_price_per_device_kopeks was missing in the API
response and formatPrice(0) renders 'Бесплатно'. Now the
strikethrough is rendered only when original_price_per_device_kopeks
is present and non-zero, mirroring the existing guard on the total
price block.
The reissue button was inside the Additional Options card which is gated
by device_limit !== 0, so it was hidden when device_limit was 0. Move it
to its own card block with independent visibility condition.
- SubscriptionListCard: show traffic progress bar for limited subs
in multi-tariff list (was hidden because isActive excluded limited)
- SubscriptionListCard: amber border/background for limited status
cards (was using default neutral style, inconsistent with rest of UI)
- Subscription page: hide delete button for limited subs in
multi-tariff mode (limited subs still have valid days remaining)
- SubscriptionPurchase: allow tariff switch for limited subs
(was blocked by is_active-only gate in canSwitch formula)
- AdminUserDetail: include limited status in "already purchased"
tariff filter to prevent duplicate subscription creation
- Dashboard card: change date label from "Истекла" to "Активна до"
for limited subscriptions (traffic exhausted but time remaining)
- Dashboard button: change text from "Трафик исчерпан" (dead-end) to
"Докупить трафик" (actionable) for limited status
- Subscription page: CountdownTimer shows remaining time for limited
subs instead of "Истекла" (was passing is_active=false)
- PurchaseCTAButton: don't treat limited as expired — limited subs
still have time, should show extend CTA not "buy new subscription"
* Make SBP default and first top-up option (#389)
* fix(connection): happ cryptolink flow + ui fixes (#385)
* fix(connection): respect happ_cryptolink mode
- read connect_mode from connection-link endpoint
- auto-redirect to happ cryptolink flow when mode is HAPP_CRYPTOLINK
- keep installation guide behavior for other modes
* docs(readme): fix source build step 2
- use compose service name for build/create commands
- copy static files from created compose container
- remove compose container via docker compose rm
* fix(connection): handle mode and ws path
- treat any non-guide connect mode as direct happ redirect
- wait for connection-link response before rendering installation guide
- build websocket URL from VITE_API_URL (supports /api and absolute URLs)
* docs(readme): fix docker cp static path
- copy /usr/share/nginx/html contents via html/.
- prevent nested /srv/cabinet/html deployment and 404 on root
* fix(connection): keep guide, use happ links
- restore guide page behavior for /connection (no auto redirect)
- use happ cryptolink URL in /connection/qr when happ crypt mode is active
- replace subscription page connection URL with happ cryptolink from connection-link API
* fix(connection): avoid wrong redirect flow
- force happ scheme deeplink in HAPP_CRYPTOLINK mode for guide connect buttons
- use cabinet redirect page only inside Telegram Mini App
- open deeplink directly in regular browsers
* fix(connection): enforce happ cryptolink
- prioritize backend crypt deeplink fields in HAPP_CRYPTOLINK mode
- fallback to local crypt4/crypt3 generation from subscription URL when backend returns plain happ://sub...
- apply same resolution for guide open action, qr page source and subscription page link display/copy
* fix(subscription): truncate long connect link
- render connection URL in a single line with ellipsis on /subscriptions
- preserve full URL in tooltip for easier manual copy
- keep copy button behavior unchanged
* fix(connection): build cryptolink from happ sub
- allow cryptolink fallback generation from happ://sub... URLs in addition to http(s)
- prevent plain happ://sub links from leaking into UI in HAPP_CRYPTOLINK mode
* fix: allow saving 0% period discount in promo groups
Changed condition from percent > 0 to percent >= 0 so that
admins can explicitly set 0% discount for a period. Previously
0% entries were silently dropped and not sent to the backend.
* fix: always send period_discounts in promo group updates
When all period discounts were removed, the frontend sent undefined
(field absent from JSON), so the backend never cleared them.
Now always sends the field - empty object {} to clear, or populated
object to update.
---------
Co-authored-by: zavul0nn <34007368+zavul0nn@users.noreply.github.com>
Co-authored-by: Dxnil <62987903+D4nilKO@users.noreply.github.com>
- Status badge (e.g. 'ПРИОСТАНОВЛЕНА (НЕДОСТАТОЧНО СРЕДСТВ)') now
constrained with max-w-[55%] to prevent overlapping tariff name
- Password too short error shown inline under password field during
registration instead of at the top of the page (invisible on mobile)
In single-tariff mode, Subscriptions list auto-redirects to detail page.
Back button from detail now goes to / (dashboard) instead of /subscriptions,
breaking the redirect loop.
Subscriptions.tsx: navigate() in render → <Navigate> component (React safe).
Subscription.tsx: same fix for multi-tariff redirect.
AdminUserDetail.tsx: useRef guard for one-time auto-select instead of
activeSubscriptionId in dependency array (prevented manual selection).
SubscriptionCardActive: passes ?sub= to /connection navigation.
Connection: QR state includes subscriptionId for back-navigation.
ConnectionQR: preserves subscription context on back button.
Dashboard + Subscription: localStorage traffic cache keyed per-subscription
to prevent cooldown bleed between different subscriptions.
- New WebBackButton component: visible on web, hidden in Telegram
(native back button handles navigation there)
- Added to: Subscription detail, SubscriptionPurchase, RenewSubscription
- RenewSubscription: removed max-w-lg, grid layout for period cards
- SubscriptionListCard: always show autopay status (green check / red cross)
for regular tariffs, show auto-charge status for daily tariffs
- Hide autopay for trial subscriptions
- Autopay mutation: invalidate subscriptions-list for instant UI update
- Types: added is_daily, is_daily_paused to SubscriptionListItem
Renewal flow (per-subscription):
- New /subscriptions/:id/renew page showing only period options for
the specific tariff with balance display and renew button
- PurchaseCTAButton links to /renew in multi-tariff mode
Purchase flow (new tariffs only):
- Hide already purchased tariffs (is_purchased flag from backend)
- Fallback UI when all tariffs are owned
- Types: added is_purchased, all_tariffs_purchased fields
- Remove custom back buttons from Subscription detail and SubscriptionPurchase
pages — native Telegram mini-app back button already handles navigation
- SubscriptionListCard: distinct trial badge with star icon and amber styling,
trial cards get amber border/background for visual differentiation
- SubscriptionPurchase: change "Купить тариф" to "Новый тариф" when adding
a new tariff in multi-subscription mode
- Subscription detail: show tariff name as title in multi-tariff mode
- SubscriptionListCard: replace status dot with localized status badge,
add traffic progress bar with color coding, use locale-aware date format
- Subscriptions page: apply glass theme colors, improve empty state styling,
add SVG plus icon for buy button, skeleton loader with glass theme
- Subscription detail: invalidate subscriptions-list cache on mutations
- Add SubscriptionListItem and SubscriptionsListResponse types
- Add getSubscriptions/getSubscriptionById API methods
- Add optional subscriptionId parameter to all 30+ API methods
- Create /subscriptions page with subscription list cards
- Add /subscription/:subscriptionId route for per-subscription management
- Dashboard: show multi-tariff navigation block when user has 2+ subscriptions
- Subscription page: read subscriptionId from URL params, pass to all queries
- SubscriptionPurchase: accept subscriptionId from query params
- SubscriptionCardExpired: pass subscription.id to API calls
- Full backward compatibility: without subscriptionId, works as before
- Add is_limited to Subscription type
- Show yellow traffic-exhausted badge on Subscription and AdminUsers pages
- SubscriptionCardExpired: amber theme with warning icon and buy-traffic CTA for limited
- Dashboard: route limited subscriptions to SubscriptionCardExpired
- Subscription page: show additional options (buy traffic) for limited subs
- Use is_limited boolean instead of status string comparison
- Add trafficLimited translations for ru, en, zh, fa
- AdminUserDetail StatusBadge: add limited yellow styling
Added staleTime: 0 and refetchOnMount: 'always' to purchase-options
queries in SubscriptionPurchase and Subscription pages to prevent
stale balance_kopeks from causing incorrect insufficient funds display.
- Fix guard: available === false alone now properly gates the unavailability
message (was incorrectly AND-ed with !max_device_limit)
- Remove dead code block that was unreachable after guard fix
- Add device-price cache invalidation after successful purchase
- Show SubscriptionCardExpired for disabled daily subscriptions
- Use togglePause() API for resume instead of renewSubscription()
- Add "Suspended" status badge and balance query invalidation
- Add dashboard.suspended and subscription.pause.suspended translations (ru, en, zh, fa)
- QuickPurchase: публичная страница покупки с выбором тарифа/периода/оплаты
- PurchaseSuccess: статус покупки с поллингом и копированием ссылки
- AdminLandings: управление лендингами (создание, сортировка, удаление)
- AdminLandingEditor: полнофункциональный редактор лендинга
- API-клиент landings.ts для всех эндпоинтов
- Роутинг /buy/:slug и /buy/success/:token
- Локализация ru/en/zh/fa для всех текстов лендинга
- Санитизация HTML/CSS, rate limit защита, DOMPurify
Normal traffic zone now uses configurable accent palette instead of
hardcoded success/green. Added useTrafficZone hook that resolves
mainHex dynamically from theme colors. Replaced emerald/teal classes
with success palette in SuccessNotificationModal. Fixed hardcoded
green hex values in Subscription and SubscriptionPurchase pages.