From d0199536939a4553d5dace69453d52b37b6b50b0 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 13:21:22 +0300 Subject: [PATCH 01/38] perf: throttle theme color picker, rewrite beams with CSS animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThemeTab: throttle applyThemeColors via requestAnimationFrame (1x/frame), debounce queryClient.setQueryData 150ms. Fixes browser lag when dragging color sliders (~60 calls/sec × 50+ CSS var writes). BackgroundBeams: replace 20 framer-motion linearGradient JS loops with pure CSS stroke-dashoffset animation on all 50 original Aceternity paths. Single shared gradient, GPU-composited keyframes, zero per-frame JS. --- src/components/admin/ThemeTab.tsx | 29 +++- .../ui/backgrounds/background-beams.tsx | 139 ++++++++++++------ 2 files changed, 122 insertions(+), 46 deletions(-) diff --git a/src/components/admin/ThemeTab.tsx b/src/components/admin/ThemeTab.tsx index 3bfad07..1eaaef2 100644 --- a/src/components/admin/ThemeTab.tsx +++ b/src/components/admin/ThemeTab.tsx @@ -149,24 +149,47 @@ export function ThemeTab() { }, }); - // Update a single color in the draft and apply preview instantly + // Throttle applyThemeColors to once per animation frame + const rafRef = useRef(0); + const querySyncRef = useRef(0); + const updateDraftColor = useCallback( (key: keyof ThemeColors, value: string) => { setDraftColors((prev) => { const next = { ...prev, [key]: value }; - applyThemeColors(next); - queryClient.setQueryData(['theme-colors'], next); + + // Throttle CSS variable updates to 1x per frame + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + applyThemeColors(next); + }); + + // Debounce query cache update (triggers re-renders of other components) + clearTimeout(querySyncRef.current); + querySyncRef.current = window.setTimeout(() => { + queryClient.setQueryData(['theme-colors'], next); + }, 150); + return next; }); }, [queryClient], ); + // Cleanup on unmount + useEffect(() => { + return () => { + cancelAnimationFrame(rafRef.current); + clearTimeout(querySyncRef.current); + }; + }, []); + // Apply a full preset and auto-save to server const applyPreset = useCallback( (colors: Partial) => { setDraftColors((prev) => { const next = { ...prev, ...colors }; + // Preset is a one-shot action — apply immediately (no throttle needed) applyThemeColors(next); queryClient.setQueryData(['theme-colors'], next); // Auto-save preset to server so it persists across navigation diff --git a/src/components/ui/backgrounds/background-beams.tsx b/src/components/ui/backgrounds/background-beams.tsx index e36b828..26a6577 100644 --- a/src/components/ui/backgrounds/background-beams.tsx +++ b/src/components/ui/backgrounds/background-beams.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { motion } from 'framer-motion'; interface Props { settings: Record; } +// All 50 paths from the original Aceternity component const paths = [ 'M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875', 'M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867', @@ -26,13 +26,78 @@ const paths = [ 'M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739', 'M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731', 'M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723', + 'M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715', + 'M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707', + 'M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699', + 'M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691', + 'M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683', + 'M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675', + 'M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667', + 'M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659', + 'M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651', + 'M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643', + 'M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635', + 'M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627', + 'M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619', + 'M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611', + 'M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603', + 'M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595', + 'M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587', + 'M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579', + 'M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571', + 'M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563', + 'M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555', + 'M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547', + 'M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539', + 'M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531', + 'M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523', + 'M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515', + 'M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507', + 'M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499', + 'M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491', + 'M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483', ]; -// Static background paths (subset for the radial gradient overlay) -const bgPath = - 'M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803'; +// Concatenated static background path +const bgPath = paths.join(''); + +// Seeded pseudo-random for deterministic values (avoids hydration mismatches) +function seededRandom(seed: number) { + const x = Math.sin(seed * 9301 + 49297) * 49297; + return x - Math.floor(x); +} + +// Pre-compute beam animation params at module level (stable across renders) +const beamParams = paths.map((_, i) => ({ + duration: 10 + seededRandom(i) * 10, // 10-20s + delay: seededRandom(i + 100) * 10, // 0-10s +})); + +// CSS keyframes injected once +const STYLE_ID = 'bg-beams-style'; +function ensureStyles() { + if (typeof document === 'undefined') return; + if (document.getElementById(STYLE_ID)) return; + + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + @keyframes beamDash { + 0% { stroke-dashoffset: 1; opacity: 0; } + 2% { opacity: 1; } + 80% { opacity: 1; } + 100% { stroke-dashoffset: -1; opacity: 0; } + } + `; + document.head.appendChild(style); +} export default React.memo(function BackgroundBeams({ settings: _settings }: Props) { + // Inject CSS once on first render + React.useEffect(() => { + ensureStyles(); + }, []); + return (
- {/* Static background paths */} - + {/* Static background — all paths at very low opacity */} + - {/* Animated beam paths with moving gradients */} - {paths.map((path, index) => ( - ( + ))} - {/* Animated linear gradients that move along the paths */} - {paths.map((_, index) => ( - - - - - - - ))} + {/* Single shared gradient for all beams (cyan → purple → magenta) */} + + + + + + {/* Radial gradient for static background */} Date: Wed, 25 Feb 2026 13:27:14 +0300 Subject: [PATCH 02/38] fix: stop beams background from causing UI flickering in browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove opacity from beamDash keyframes — opacity on SVG paths triggers full-page repaints. Animate only stroke-dashoffset (compositor-friendly). Reduce animated paths from 50 to 17 (every 3rd). Isolate container with contain:strict + will-change:transform for own compositor layer. --- .../ui/backgrounds/background-beams.tsx | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/components/ui/backgrounds/background-beams.tsx b/src/components/ui/backgrounds/background-beams.tsx index 26a6577..9763131 100644 --- a/src/components/ui/backgrounds/background-beams.tsx +++ b/src/components/ui/backgrounds/background-beams.tsx @@ -67,6 +67,11 @@ function seededRandom(seed: number) { return x - Math.floor(x); } +// Animate only every 3rd path (17 beams) — enough visual density, 3x fewer repaints +const animatedPaths = paths + .map((path, i) => ({ path, paramIndex: i })) + .filter((_, i) => i % 3 === 0); + // Pre-compute beam animation params at module level (stable across renders) const beamParams = paths.map((_, i) => ({ duration: 10 + seededRandom(i) * 10, // 10-20s @@ -81,12 +86,13 @@ function ensureStyles() { const style = document.createElement('style'); style.id = STYLE_ID; + // Only animate stroke-dashoffset — no opacity changes = no repaints + // The beam appears when dashoffset brings the segment into view + // and disappears when it exits — pure compositor work style.textContent = ` @keyframes beamDash { - 0% { stroke-dashoffset: 1; opacity: 0; } - 2% { opacity: 1; } - 80% { opacity: 1; } - 100% { stroke-dashoffset: -1; opacity: 0; } + 0% { stroke-dashoffset: 1; } + 100% { stroke-dashoffset: -1; } } `; document.head.appendChild(style); @@ -99,7 +105,10 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop }, []); return ( -
+
- {/* Animated beams — CSS stroke-dashoffset animation, GPU composited */} - {paths.map((path, i) => ( + {/* Animated beams — only every 3rd path (17 beams), pure stroke-dashoffset */} + {animatedPaths.map(({ path, paramIndex }) => ( ))} From 821e991f51db6033d3e0f2befecf15c364d0e3e8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 00:53:50 +0300 Subject: [PATCH 03/38] fix: block wheel spin without active subscription - Add has_subscription to WheelConfig type - Disable spin button when user has no subscription - Add noSubscription guard in handleUnifiedSpin handler - Show warning banner directing user to activate subscription - Prevent banner stacking (noSubscription takes priority over daily limit) - Add noSubscription i18n keys for ru, en, zh, fa --- src/api/wheel.ts | 1 + src/locales/en.json | 3 ++- src/locales/fa.json | 3 ++- src/locales/ru.json | 3 ++- src/locales/zh.json | 3 ++- src/pages/Wheel.tsx | 43 +++++++++++++++++++++++++++++-------------- 6 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/api/wheel.ts b/src/api/wheel.ts index a507b77..1c36d94 100644 --- a/src/api/wheel.ts +++ b/src/api/wheel.ts @@ -26,6 +26,7 @@ export interface WheelConfig { can_pay_days: boolean; user_balance_kopeks: number; required_balance_kopeks: number; + has_subscription: boolean; } export interface SpinAvailability { diff --git a/src/locales/en.json b/src/locales/en.json index e91b0dd..15f991c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -858,7 +858,8 @@ "cannotSpin": "Cannot spin right now", "insufficientBalance": "Insufficient balance", "topUpRequired": "Top up your balance to pay for spin", - "starsNotAvailable": "Stars payment not available. Open the app via Telegram." + "starsNotAvailable": "Stars payment not available. Open the app via Telegram.", + "noSubscription": "An active subscription is required to spin the wheel. Activate your subscription first." }, "payWithStars": "Pay with Stars", "payWithDays": "Pay with days", diff --git a/src/locales/fa.json b/src/locales/fa.json index 838ad66..f09239c 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -694,7 +694,8 @@ "cannotSpin": "فعلاً امکان چرخش نیست", "insufficientBalance": "موجودی کافی نیست", "topUpRequired": "برای پرداخت شارژ کنید", - "starsNotAvailable": "پرداخت Stars در دسترس نیست. برنامه را از طریق تلگرام باز کنید." + "starsNotAvailable": "پرداخت Stars در دسترس نیست. برنامه را از طریق تلگرام باز کنید.", + "noSubscription": "برای چرخاندن گردونه نیاز به اشتراک فعال دارید. ابتدا اشتراک خود را فعال کنید." }, "payWithStars": "پرداخت {stars} Stars", "processingPayment": "در حال پردازش...", diff --git a/src/locales/ru.json b/src/locales/ru.json index 0326819..f4b01bb 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -887,7 +887,8 @@ "cannotSpin": "Сейчас нельзя крутить", "insufficientBalance": "Недостаточно средств на балансе", "topUpRequired": "Пополните баланс для оплаты спина", - "starsNotAvailable": "Оплата Stars недоступна. Откройте приложение через Telegram." + "starsNotAvailable": "Оплата Stars недоступна. Откройте приложение через Telegram.", + "noSubscription": "Для вращения колеса необходима активная подписка. Активируйте подписку в разделе «Подписка»." }, "payWithStars": "Оплатить Stars", "payWithDays": "Оплатить днями", diff --git a/src/locales/zh.json b/src/locales/zh.json index da947c9..5f6537f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -698,7 +698,8 @@ "cannotSpin": "当前无法抽奖", "insufficientBalance": "余额不足", "topUpRequired": "请充值以支付抽奖费用", - "starsNotAvailable": "Stars支付不可用。请通过Telegram打开应用。" + "starsNotAvailable": "Stars支付不可用。请通过Telegram打开应用。", + "noSubscription": "需要有效订阅才能转动轮盘。请先激活订阅。" }, "payWithStars": "支付 {stars} Stars", "processingPayment": "处理中...", diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 63b2dcc..9540793 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -403,6 +403,7 @@ export default function Wheel() { }; const handleUnifiedSpin = () => { + if (noSubscription) return; if (paymentType === 'telegram_stars') { if (!config?.spin_cost_stars_enabled || !config?.spin_cost_stars) { notify.warning(t('wheel.starsNotAvailable')); @@ -505,10 +506,12 @@ export default function Wheel() { // Stars via Telegram invoice don't require ruble balance, so only check daily limit const dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit; + const noSubscription = !config.has_subscription; const spinDisabled = isSpinning || isPayingStars || dailyLimitReached || + noSubscription || (paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin); return ( @@ -614,22 +617,34 @@ export default function Wheel() { )} + {/* No subscription hint */} + {!isSpinning && noSubscription && ( +
+

{t('wheel.errors.noSubscription')}

+
+ )} {/* Cannot spin hint — only show for days payment (Stars via invoice always works) */} - {!isSpinning && paymentType !== 'telegram_stars' && !config.can_spin && ( -
-

- {config.can_spin_reason === 'daily_limit_reached' - ? t('wheel.errors.dailyLimitReached') - : t('wheel.errors.cannotSpin')} -

-
- )} + {!isSpinning && + !noSubscription && + paymentType !== 'telegram_stars' && + !config.can_spin && ( +
+

+ {config.can_spin_reason === 'daily_limit_reached' + ? t('wheel.errors.dailyLimitReached') + : t('wheel.errors.cannotSpin')} +

+
+ )} {/* Daily limit hint for Stars payment (not covered by can_spin check) */} - {!isSpinning && paymentType === 'telegram_stars' && dailyLimitReached && ( -
-

{t('wheel.errors.dailyLimitReached')}

-
- )} + {!isSpinning && + !noSubscription && + paymentType === 'telegram_stars' && + dailyLimitReached && ( +
+

{t('wheel.errors.dailyLimitReached')}

+
+ )} {/* Inline Result Card */} {spinResult && !isSpinning && ( From 4d14e3e8062c321e56fc37e79ed6cc16fa83df2a Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 03:14:15 +0300 Subject: [PATCH 04/38] feat: add fullscreen QR code for subscription connection - New ConnectionQR page with fullscreen QR display (no modal) - QR button in InstallationGuide header next to platform selector - Respects hideLink setting: shows URL below QR only when visible - i18n translations for ru, en, zh, fa --- package-lock.json | 10 ++ package.json | 1 + src/App.tsx | 9 ++ .../connection/InstallationGuide.tsx | 27 ++++ src/locales/en.json | 5 +- src/locales/fa.json | 5 +- src/locales/ru.json | 5 +- src/locales/zh.json | 5 +- src/pages/Connection.tsx | 10 ++ src/pages/ConnectionQR.tsx | 116 ++++++++++++++++++ 10 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 src/pages/ConnectionQR.tsx diff --git a/package-lock.json b/package-lock.json index de67f80..ad8a110 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", @@ -5673,6 +5674,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 4d9b4d1..24e2a15 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", diff --git a/src/App.tsx b/src/App.tsx index 916a3bf..aee6f56 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ const Polls = lazy(() => import('./pages/Polls')); const Info = lazy(() => import('./pages/Info')); const Wheel = lazy(() => import('./pages/Wheel')); const Connection = lazy(() => import('./pages/Connection')); +const ConnectionQR = lazy(() => import('./pages/ConnectionQR')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); @@ -322,6 +323,14 @@ function App() { } /> + + + + } + /> void; isTelegramWebApp: boolean; onGoBack: () => void; + onOpenQR?: () => void; } export default function InstallationGuide({ @@ -50,6 +51,7 @@ export default function InstallationGuide({ onOpenDeepLink, isTelegramWebApp, onGoBack, + onOpenQR, }: Props) { const { t, i18n } = useTranslation(); const { isLight } = useTheme(); @@ -206,6 +208,31 @@ export default function InstallationGuide({

{getBaseTranslation('installationGuideHeader', 'subscription.connection.title')}

+ {appConfig.subscriptionUrl && onOpenQR && ( + + )} {availablePlatforms.length > 1 && (
{currentPlatformSvg && ( diff --git a/src/locales/en.json b/src/locales/en.json index 15f991c..66840dd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -422,7 +422,10 @@ "notConfigured": "Connection methods are not configured yet", "notConfiguredUser": "Connection setup is in progress. Please check back later.", "notConfiguredAdmin": "No connection apps have been configured. Set them up in the Apps settings.", - "goToApps": "Go to Apps settings" + "goToApps": "Go to Apps settings", + "qrTitle": "Subscription QR Code", + "qrScanHint": "Scan to connect", + "qrButton": "QR Code" }, "myDevices": "My Devices", "noDevices": "No connected devices", diff --git a/src/locales/fa.json b/src/locales/fa.json index f09239c..ea719c6 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -327,7 +327,10 @@ "notConfigured": "روش‌های اتصال هنوز پیکربندی نشده‌اند", "notConfiguredUser": "تنظیمات اتصال در حال انجام است. لطفاً بعداً مراجعه کنید.", "notConfiguredAdmin": "هیچ برنامه اتصالی پیکربندی نشده است. آن‌ها را در تنظیمات برنامه‌ها تنظیم کنید.", - "goToApps": "رفتن به تنظیمات برنامه‌ها" + "goToApps": "رفتن به تنظیمات برنامه‌ها", + "qrTitle": "کد QR اشتراک", + "qrScanHint": "برای اتصال با دوربین اسکن کنید", + "qrButton": "کد QR" }, "additionalOptions": { "title": "گزینه‌های اضافی", diff --git a/src/locales/ru.json b/src/locales/ru.json index f4b01bb..c5bae48 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -445,7 +445,10 @@ "notConfigured": "Способы подключения ещё не настроены", "notConfiguredUser": "Настройка подключения в процессе. Пожалуйста, загляните позже.", "notConfiguredAdmin": "Приложения для подключения не настроены. Настройте их в разделе Приложения.", - "goToApps": "Перейти к настройке приложений" + "goToApps": "Перейти к настройке приложений", + "qrTitle": "QR-код подписки", + "qrScanHint": "Отсканируйте для подключения", + "qrButton": "QR-код" }, "myDevices": "Мои устройства", "noDevices": "Нет подключенных устройств", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5f6537f..a3dab1f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -327,7 +327,10 @@ "notConfigured": "连接方式尚未配置", "notConfiguredUser": "连接设置正在进行中,请稍后再来查看。", "notConfiguredAdmin": "尚未配置连接应用。请在应用设置中进行配置。", - "goToApps": "前往应用设置" + "goToApps": "前往应用设置", + "qrTitle": "订阅二维码", + "qrScanHint": "使用相机扫描以连接", + "qrButton": "二维码" }, "additionalOptions": { "title": "附加选项", diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index 2b250cb..21e7e9b 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -35,6 +35,15 @@ export default function Connection() { navigate(-1); }, [navigate]); + const handleOpenQR = useCallback(() => { + navigate('/connection/qr', { + state: { + url: appConfig?.subscriptionUrl, + hideLink: appConfig?.hideLink ?? false, + }, + }); + }, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -184,6 +193,7 @@ export default function Connection() { onOpenDeepLink={openDeepLink} isTelegramWebApp={isTelegramWebApp} onGoBack={handleGoBack} + onOpenQR={handleOpenQR} /> ); } diff --git a/src/pages/ConnectionQR.tsx b/src/pages/ConnectionQR.tsx new file mode 100644 index 0000000..b09cffe --- /dev/null +++ b/src/pages/ConnectionQR.tsx @@ -0,0 +1,116 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { QRCodeSVG } from 'qrcode.react'; +import { useAuthStore } from '../store/auth'; +import { useBranding } from '../hooks/useBranding'; + +interface ConnectionQRState { + url: string; + hideLink: boolean; +} + +function isValidState(state: unknown): state is ConnectionQRState { + if (!state || typeof state !== 'object') return false; + const s = state as Record; + return typeof s.url === 'string' && s.url.length > 0 && typeof s.hideLink === 'boolean'; +} + +export default function ConnectionQR() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isLoading = useAuthStore((state) => state.isLoading); + const { appName } = useBranding(); + + const state = location.state as unknown; + const validState = isValidState(state) ? state : null; + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + navigate('/login', { replace: true }); + } + }, [isAuthenticated, isLoading, navigate]); + + useEffect(() => { + if (!isLoading && isAuthenticated && !validState) { + navigate('/connection', { replace: true }); + } + }, [isLoading, isAuthenticated, validState, navigate]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + navigate(-1); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [navigate]); + + if (isLoading || !validState) { + return null; + } + + return ( +
+ {/* Close button */} + + + {/* Content */} +
+ {/* Branding name */} + {appName && ( +

+ {appName} +

+ )} + + {/* Title */} +

+ {t('subscription.connection.qrTitle')} +

+ + {/* Hint */} +

+ {t('subscription.connection.qrScanHint')} +

+ + {/* QR code container */} +
+ +
+ + {/* URL display */} + {!validState.hideLink && ( +

+ {validState.url} +

+ )} +
+
+ ); +} From e94d81fe5a25341172bb787146fd80d70067a140 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 04:02:27 +0300 Subject: [PATCH 05/38] fix: partner system bugs - commission field, withdrawal UX, admin amount 1. Add desired commission percent field to partner application form and admin review page (Bug 1) 2. Add prominent "Requested Amount" row in admin withdrawal detail (Bug 2) 3. Switch withdrawal input from kopecks to rubles with auto-conversion on submit (Bug 3) Includes i18n updates for all 4 locales (ru, en, zh, fa). --- src/api/partners.ts | 3 +++ src/locales/en.json | 14 ++++++++----- src/locales/fa.json | 14 ++++++++----- src/locales/ru.json | 14 ++++++++----- src/locales/zh.json | 14 ++++++++----- src/pages/AdminApplicationReview.tsx | 8 +++++++ src/pages/AdminWithdrawalDetail.tsx | 8 +++++++ src/pages/ReferralPartnerApply.tsx | 23 ++++++++++++++++++++ src/pages/ReferralWithdrawalRequest.tsx | 28 ++++++++++++++----------- 9 files changed, 94 insertions(+), 32 deletions(-) diff --git a/src/api/partners.ts b/src/api/partners.ts index 007474f..9cfa82b 100644 --- a/src/api/partners.ts +++ b/src/api/partners.ts @@ -10,6 +10,7 @@ export interface PartnerApplicationInfo { telegram_channel: string | null; description: string | null; expected_monthly_referrals: number | null; + desired_commission_percent: number | null; admin_comment: string | null; approved_commission_percent: number | null; created_at: string; @@ -41,6 +42,7 @@ export interface PartnerApplicationRequest { telegram_channel?: string; description?: string; expected_monthly_referrals?: number; + desired_commission_percent?: number; } // ==================== Admin-facing types ==================== @@ -56,6 +58,7 @@ export interface AdminPartnerApplicationItem { telegram_channel: string | null; description: string | null; expected_monthly_referrals: number | null; + desired_commission_percent: number | null; status: string; admin_comment: string | null; approved_commission_percent: number | null; diff --git a/src/locales/en.json b/src/locales/en.json index 66840dd..105f662 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -755,7 +755,9 @@ "description": "Description", "descriptionPlaceholder": "Tell us about your audience and how you plan to promote...", "expectedReferrals": "Expected Monthly Referrals", - "expectedReferralsPlaceholder": "Estimated number per month" + "expectedReferralsPlaceholder": "Estimated number per month", + "desiredCommission": "Desired Commission (%)", + "desiredCommissionPlaceholder": "From 1 to 100%" } }, "withdrawal": { @@ -776,9 +778,9 @@ "submitRequest": "Submit Request", "requestError": "Failed to create withdrawal request. Please try again.", "fields": { - "amount": "Amount (kopeks)", - "amountPlaceholder": "Enter amount in kopeks", - "amountHint": "Enter the amount in kopeks (100 kopeks = 1 {{currency}})", + "amount": "Amount", + "amountPlaceholder": "Enter amount in {{currency}}", + "amountHint": "Minimum: {{min}} {{currency}}", "paymentDetails": "Payment Details", "paymentDetailsPlaceholder": "Card number, wallet address, or other payment info..." }, @@ -2046,6 +2048,7 @@ "balance": "Balance", "totalReferrals": "Total referrals", "totalEarnings": "Total earnings", + "requestedAmount": "Requested Amount", "createdAt": "Created", "paymentDetails": "Payment Details", "noPaymentDetails": "No payment details provided", @@ -2101,7 +2104,8 @@ "website": "Website", "channel": "Channel", "description": "Description", - "expectedReferrals": "Expected referrals/mo" + "expectedReferrals": "Expected referrals/mo", + "desiredCommission": "Desired commission" }, "approveDialog": { "title": "Approve Application", diff --git a/src/locales/fa.json b/src/locales/fa.json index ea719c6..25e5cf4 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -594,7 +594,9 @@ "description": "توضیحات", "descriptionPlaceholder": "درباره مخاطبان خود و نحوه تبلیغ بگویید...", "expectedReferrals": "تعداد معرفی ماهانه مورد انتظار", - "expectedReferralsPlaceholder": "تعداد تخمینی در ماه" + "expectedReferralsPlaceholder": "تعداد تخمینی در ماه", + "desiredCommission": "درصد کمیسیون مورد نظر", + "desiredCommissionPlaceholder": "از ۱ تا ۱۰۰٪" } }, "withdrawal": { @@ -615,9 +617,9 @@ "submitRequest": "ارسال درخواست", "requestError": "ایجاد درخواست برداشت ناموفق بود. لطفاً دوباره تلاش کنید.", "fields": { - "amount": "مبلغ (کوپک)", - "amountPlaceholder": "مبلغ را به کوپک وارد کنید", - "amountHint": "مبلغ را به کوپک وارد کنید (۱۰۰ کوپک = ۱ {{currency}})", + "amount": "مبلغ", + "amountPlaceholder": "مبلغ را وارد کنید", + "amountHint": "حداقل: {{min}} {{currency}}", "paymentDetails": "اطلاعات پرداخت", "paymentDetailsPlaceholder": "شماره کارت، آدرس کیف پول یا سایر اطلاعات پرداخت..." }, @@ -1729,6 +1731,7 @@ "balance": "موجودی", "totalReferrals": "مجموع معرفی‌ها", "totalEarnings": "مجموع درآمد", + "requestedAmount": "مبلغ درخواستی", "createdAt": "تاریخ ایجاد", "paymentDetails": "اطلاعات پرداخت", "noPaymentDetails": "اطلاعات پرداختی ارائه نشده", @@ -1784,7 +1787,8 @@ "website": "وب‌سایت", "channel": "کانال", "description": "توضیحات", - "expectedReferrals": "معرفی مورد انتظار/ماه" + "expectedReferrals": "معرفی مورد انتظار/ماه", + "desiredCommission": "کمیسیون مورد نظر" }, "approveDialog": { "title": "تأیید درخواست", diff --git a/src/locales/ru.json b/src/locales/ru.json index c5bae48..d6d06d0 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -783,7 +783,9 @@ "description": "Описание", "descriptionPlaceholder": "Расскажите о вашей аудитории и планах продвижения...", "expectedReferrals": "Ожидаемое количество рефералов в месяц", - "expectedReferralsPlaceholder": "Примерное количество в месяц" + "expectedReferralsPlaceholder": "Примерное количество в месяц", + "desiredCommission": "Желаемый процент комиссии", + "desiredCommissionPlaceholder": "От 1 до 100%" } }, "withdrawal": { @@ -804,9 +806,9 @@ "submitRequest": "Отправить запрос", "requestError": "Не удалось создать запрос на вывод. Попробуйте снова.", "fields": { - "amount": "Сумма (копейки)", - "amountPlaceholder": "Введите сумму в копейках", - "amountHint": "Введите сумму в копейках (100 копеек = 1 {{currency}})", + "amount": "Сумма", + "amountPlaceholder": "Введите сумму в {{currency}}", + "amountHint": "Минимум: {{min}} {{currency}}", "paymentDetails": "Реквизиты для оплаты", "paymentDetailsPlaceholder": "Номер карты, адрес кошелька или другие реквизиты..." }, @@ -2567,6 +2569,7 @@ "balance": "Баланс", "totalReferrals": "Всего рефералов", "totalEarnings": "Общий заработок", + "requestedAmount": "Запрошенная сумма", "createdAt": "Создано", "paymentDetails": "Реквизиты для оплаты", "noPaymentDetails": "Реквизиты не указаны", @@ -2622,7 +2625,8 @@ "website": "Сайт", "channel": "Канал", "description": "Описание", - "expectedReferrals": "Ожидаемых рефералов/мес" + "expectedReferrals": "Ожидаемых рефералов/мес", + "desiredCommission": "Желаемая комиссия" }, "approveDialog": { "title": "Одобрить заявку", diff --git a/src/locales/zh.json b/src/locales/zh.json index a3dab1f..122b6ae 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -594,7 +594,9 @@ "description": "描述", "descriptionPlaceholder": "请介绍您的受众以及推广计划...", "expectedReferrals": "预计每月推荐数", - "expectedReferralsPlaceholder": "每月预估数量" + "expectedReferralsPlaceholder": "每月预估数量", + "desiredCommission": "期望佣金比例 (%)", + "desiredCommissionPlaceholder": "1 到 100%" } }, "withdrawal": { @@ -615,9 +617,9 @@ "submitRequest": "提交请求", "requestError": "创建提现请求失败,请重试。", "fields": { - "amount": "金额(戈比)", - "amountPlaceholder": "输入金额(戈比)", - "amountHint": "请输入戈比金额(100戈比 = 1 {{currency}})", + "amount": "金额", + "amountPlaceholder": "输入金额({{currency}})", + "amountHint": "最低:{{min}} {{currency}}", "paymentDetails": "付款信息", "paymentDetailsPlaceholder": "银行卡号、钱包地址或其他付款信息..." }, @@ -1728,6 +1730,7 @@ "balance": "余额", "totalReferrals": "总推荐数", "totalEarnings": "总收益", + "requestedAmount": "请求金额", "createdAt": "创建时间", "paymentDetails": "付款信息", "noPaymentDetails": "未提供付款信息", @@ -1783,7 +1786,8 @@ "website": "网站", "channel": "频道", "description": "描述", - "expectedReferrals": "预计每月推荐数" + "expectedReferrals": "预计每月推荐数", + "desiredCommission": "期望佣金" }, "approveDialog": { "title": "批准申请", diff --git a/src/pages/AdminApplicationReview.tsx b/src/pages/AdminApplicationReview.tsx index 35f83e5..fdcc93b 100644 --- a/src/pages/AdminApplicationReview.tsx +++ b/src/pages/AdminApplicationReview.tsx @@ -136,6 +136,14 @@ export default function AdminApplicationReview() { {app.expected_monthly_referrals}
)} + {app.desired_commission_percent != null && ( +
+ + {t('admin.partners.applicationFields.desiredCommission')}: + {' '} + {app.desired_commission_percent}% +
+ )}
diff --git a/src/pages/AdminWithdrawalDetail.tsx b/src/pages/AdminWithdrawalDetail.tsx index d243bbb..6dbb260 100644 --- a/src/pages/AdminWithdrawalDetail.tsx +++ b/src/pages/AdminWithdrawalDetail.tsx @@ -126,6 +126,14 @@ export default function AdminWithdrawalDetail() {

{t('admin.withdrawals.detail.userInfo')}

+
+
+ {t('admin.withdrawals.detail.requestedAmount')} +
+
+ {formatWithCurrency(detail.amount_kopeks / 100, 0)} +
+
diff --git a/src/pages/ReferralPartnerApply.tsx b/src/pages/ReferralPartnerApply.tsx index 9638c04..766e063 100644 --- a/src/pages/ReferralPartnerApply.tsx +++ b/src/pages/ReferralPartnerApply.tsx @@ -15,6 +15,7 @@ export default function ReferralPartnerApply() { telegram_channel: '', description: '', expected_monthly_referrals: undefined, + desired_commission_percent: undefined, }); // Guard: redirect if already approved or pending @@ -50,6 +51,9 @@ export default function ReferralPartnerApply() { if (form.expected_monthly_referrals) { payload.expected_monthly_referrals = form.expected_monthly_referrals; } + if (form.desired_commission_percent) { + payload.desired_commission_percent = form.desired_commission_percent; + } applyMutation.mutate(payload); }; @@ -126,6 +130,25 @@ export default function ReferralPartnerApply() { placeholder={t('referral.partner.fields.expectedReferralsPlaceholder')} />
+
+ + + setForm({ + ...form, + desired_commission_percent: e.target.value ? Number(e.target.value) : undefined, + }) + } + placeholder={t('referral.partner.fields.desiredCommissionPlaceholder')} + /> +
{applyMutation.isError && ( diff --git a/src/pages/ReferralWithdrawalRequest.tsx b/src/pages/ReferralWithdrawalRequest.tsx index 5338812..ac9a75a 100644 --- a/src/pages/ReferralWithdrawalRequest.tsx +++ b/src/pages/ReferralWithdrawalRequest.tsx @@ -12,7 +12,7 @@ export default function ReferralWithdrawalRequest() { const { formatWithCurrency, currencySymbol } = useCurrency(); const [form, setForm] = useState({ - amount_kopeks: 0, + amount_rubles: 0, payment_details: '', }); @@ -40,9 +40,9 @@ export default function ReferralWithdrawalRequest() { const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (form.payment_details.length < 5) return; - if (form.amount_kopeks <= 0) return; + if (form.amount_rubles <= 0) return; withdrawMutation.mutate({ - amount_kopeks: form.amount_kopeks, + amount_kopeks: form.amount_rubles * 100, payment_details: form.payment_details, }); }; @@ -64,21 +64,25 @@ export default function ReferralWithdrawalRequest() { setForm({ ...form, - amount_kopeks: e.target.value ? Number(e.target.value) : 0, + amount_rubles: e.target.value ? Number(e.target.value) : 0, }) } - placeholder={t('referral.withdrawal.fields.amountPlaceholder')} + placeholder={t('referral.withdrawal.fields.amountPlaceholder', { + currency: currencySymbol, + })} />

- {t('referral.withdrawal.fields.amountHint', { currency: currencySymbol })} + {t('referral.withdrawal.fields.amountHint', { + min: balance ? Math.ceil(balance.min_amount_kopeks / 100) : 0, + currency: currencySymbol, + })}

@@ -115,12 +119,12 @@ export default function ReferralWithdrawalRequest() { disabled={ withdrawMutation.isPending || form.payment_details.length < 5 || - form.amount_kopeks <= 0 + form.amount_rubles <= 0 } className={`btn-primary flex-1 px-5 ${ withdrawMutation.isPending || form.payment_details.length < 5 || - form.amount_kopeks <= 0 + form.amount_rubles <= 0 ? 'opacity-50' : '' }`} From 82987fd49a861a4ad167c10f89d51e88e8ecee51 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 04:08:15 +0300 Subject: [PATCH 06/38] fix: review fixes - Math.round kopecks, fa locale, admin list commission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add Math.round() to rubles→kopecks conversion (floating point safety) 2. Add missing {{currency}} template var in fa.json amountPlaceholder 3. Show desired_commission_percent in admin applications list --- src/locales/fa.json | 2 +- src/pages/AdminPartners.tsx | 6 ++++++ src/pages/ReferralWithdrawalRequest.tsx | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/locales/fa.json b/src/locales/fa.json index 25e5cf4..08b1833 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -618,7 +618,7 @@ "requestError": "ایجاد درخواست برداشت ناموفق بود. لطفاً دوباره تلاش کنید.", "fields": { "amount": "مبلغ", - "amountPlaceholder": "مبلغ را وارد کنید", + "amountPlaceholder": "مبلغ را به {{currency}} وارد کنید", "amountHint": "حداقل: {{min}} {{currency}}", "paymentDetails": "اطلاعات پرداخت", "paymentDetailsPlaceholder": "شماره کارت، آدرس کیف پول یا سایر اطلاعات پرداخت..." diff --git a/src/pages/AdminPartners.tsx b/src/pages/AdminPartners.tsx index f9f878e..7aa063e 100644 --- a/src/pages/AdminPartners.tsx +++ b/src/pages/AdminPartners.tsx @@ -242,6 +242,12 @@ export default function AdminPartners() { {app.expected_monthly_referrals}
)} + {app.desired_commission_percent != null && ( +
+ {t('admin.partners.applicationFields.desiredCommission')}:{' '} + {app.desired_commission_percent}% +
+ )} {/* Actions */} diff --git a/src/pages/ReferralWithdrawalRequest.tsx b/src/pages/ReferralWithdrawalRequest.tsx index ac9a75a..4fc69cc 100644 --- a/src/pages/ReferralWithdrawalRequest.tsx +++ b/src/pages/ReferralWithdrawalRequest.tsx @@ -42,7 +42,7 @@ export default function ReferralWithdrawalRequest() { if (form.payment_details.length < 5) return; if (form.amount_rubles <= 0) return; withdrawMutation.mutate({ - amount_kopeks: form.amount_rubles * 100, + amount_kopeks: Math.round(form.amount_rubles * 100), payment_details: form.payment_details, }); }; From 27f85a1db115ca386c5658786147800e33f484bc Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 04:53:37 +0300 Subject: [PATCH 07/38] fix: add subscription tab to desktop nav, fix device dots overflow, show available referral balance - Add Subscription tab to desktop header navigation (was only in mobile) - Fix device dots overflow for large limits (>10) by using progress bar - Show available referral balance on dashboard instead of total earnings - Add available_balance and withdrawn fields to ReferralInfo type --- src/components/dashboard/StatsGrid.tsx | 4 +- .../dashboard/SubscriptionCardActive.tsx | 43 +++++++++++++------ src/components/layout/AppShell/AppShell.tsx | 2 + src/pages/Dashboard.tsx | 2 +- src/types/index.ts | 3 ++ 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx index 64d6bb5..8c3ebbb 100644 --- a/src/components/dashboard/StatsGrid.tsx +++ b/src/components/dashboard/StatsGrid.tsx @@ -42,7 +42,7 @@ export default function StatsGrid({ refLoading, }: StatsGridProps) { const { t } = useTranslation(); - const { formatAmount, currencySymbol, formatPositive } = useCurrency(); + const { formatAmount, currencySymbol } = useCurrency(); const { isDark } = useTheme(); const g = getGlassColors(isDark); @@ -83,7 +83,7 @@ export default function StatsGrid({ label: t('dashboard.stats.referrals'), value: `${referralCount}`, valueColor: g.text, - subtitle: `${formatPositive(earningsRubles)} ${currencySymbol}`, + subtitle: `${formatAmount(earningsRubles)} ${currencySymbol}`, subtitleColor: zone.mainHex, to: '/referral', icon: (color: string) => ( diff --git a/src/components/dashboard/SubscriptionCardActive.tsx b/src/components/dashboard/SubscriptionCardActive.tsx index 099faaf..df934d7 100644 --- a/src/components/dashboard/SubscriptionCardActive.tsx +++ b/src/components/dashboard/SubscriptionCardActive.tsx @@ -240,19 +240,38 @@ export default function SubscriptionCardActive({ - {/* Device dots */} - - {/* Expired info grid */} + {/* Expired date */}
- {[ - { label: t('dashboard.expired.traffic'), value: '0 GB' }, - { label: t('dashboard.expired.devices'), value: '0' }, - { label: t('dashboard.expired.expiredDate'), value: formattedDate }, - ].map((item, i) => ( -
-
- {item.label} -
-
{item.value}
-
- ))} +
+ {t('dashboard.expired.expiredDate')} +
+
+ {formattedDate} +
{/* Action buttons */} diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index a416c3f..a8de6b8 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -187,6 +187,25 @@ export default function Subscription() { // Extract subscription from response (null if no subscription) const subscription = subscriptionResponse?.subscription ?? null; + // Live countdown timer + const [countdown, setCountdown] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 }); + useEffect(() => { + if (!subscription?.end_date) return; + const endTime = new Date(subscription.end_date).getTime(); + const tick = () => { + const diff = Math.max(0, endTime - Date.now()); + setCountdown({ + days: Math.floor(diff / 86_400_000), + hours: Math.floor((diff % 86_400_000) / 3_600_000), + minutes: Math.floor((diff % 3_600_000) / 60_000), + seconds: Math.floor((diff % 60_000) / 1_000), + }); + }; + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [subscription?.end_date]); + const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({ queryKey: ['purchase-options'], queryFn: subscriptionApi.getPurchaseOptions, @@ -679,7 +698,6 @@ export default function Subscription() { const zone = getTrafficZone(usedPercent); const connectedDevices = devicesData?.total ?? 0; const formattedDate = new Date(subscription.end_date).toLocaleDateString(); - const daysLeft = subscription.days_left; return (
{isUnlimited ? t('dashboard.unlimited') : t(zone.labelKey)} - {subscription.is_trial && ( - - {t('subscription.trialStatus')} - - )}
{/* Plan name */} @@ -770,11 +775,11 @@ export default function Subscription() { color: subscription.is_active ? zone.mainHex : '#FF3B5C', }} > - {subscription.is_trial - ? t('subscription.trialStatus') - : subscription.is_active - ? t('subscription.active') - : t('subscription.expired')} + {subscription.is_active + ? subscription.is_trial + ? t('subscription.trialStatus') + : t('subscription.active') + : t('subscription.expired')} @@ -992,57 +997,114 @@ export default function Subscription() { {/* ─── Stats Row ─── */}
- {/* Days left */} -
-
+ {/* Countdown timer */} + {(() => { + const isUrgent = countdown.days <= 3; + const isExpired = !subscription.is_active; + return (
- +
+
+ +
+ {t('dashboard.remaining')} +
+ {isExpired ? ( +
+ {t('subscription.expired')} +
+ ) : ( +
+ {countdown.days > 0 && ( + <> + + {countdown.days} + + + {t('subscription.daysShort')} + + + )} + + {String(countdown.hours).padStart(2, '0')} + + + : + + + {String(countdown.minutes).padStart(2, '0')} + + + : + + + {String(countdown.seconds).padStart(2, '0')} + +
+ )} +
+ {t('subscription.expiresAt')}: {formattedDate} +
- {t('dashboard.remaining')} -
-
- - {daysLeft > 0 - ? daysLeft - : subscription.hours_left > 0 - ? `${subscription.hours_left}h` - : `${subscription.minutes_left}m`} - - - {daysLeft > 0 ? t('subscription.daysShort') : ''} - -
-
+ ); + })()} {/* Devices */}
-
+
- - {/* Expires */} -
-
- {t('subscription.expiresAt')} -
-
- {formattedDate} -
-
- - {/* Traffic */} -
-
- {t('subscription.traffic')} -
-
- {isUnlimited ? '∞' : `${usedPercent.toFixed(0)}%`} -
-
{/* ─── Locations ─── */} From 126f9ab9b9dae357876951d6ae6077587c544be4 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 06:47:30 +0300 Subject: [PATCH 09/38] refactor: extract purchase/renewal flow to separate page Move ~1900 lines of purchase/renewal logic from Subscription.tsx into a new SubscriptionPurchase.tsx page at /subscription/purchase. - Create SubscriptionPurchase.tsx with tariffs grid, classic mode wizard, switch preview modal, and promo handling - Create PurchaseCTAButton.tsx with animated gradient border, state-aware colors and context-aware text - Create subscriptionHelpers.ts with shared utilities - Add CopyIcon to shared icons module - Register /subscription/purchase route with lazy loading - Update SubscriptionCardExpired and PromoOffersSection navigators - Add CTA translation keys to ru.json and en.json - Remove all scrollToExtend references --- src/App.tsx | 11 + src/components/PromoOffersSection.tsx | 2 +- .../dashboard/SubscriptionCardExpired.tsx | 3 +- src/components/icons/index.tsx | 16 + .../subscription/PurchaseCTAButton.tsx | 93 + src/locales/en.json | 5 + src/locales/ru.json | 5 + src/pages/Subscription.tsx | 1956 +---------------- src/pages/SubscriptionPurchase.tsx | 1900 ++++++++++++++++ src/utils/subscriptionHelpers.ts | 42 + 10 files changed, 2091 insertions(+), 1942 deletions(-) create mode 100644 src/components/subscription/PurchaseCTAButton.tsx create mode 100644 src/pages/SubscriptionPurchase.tsx create mode 100644 src/utils/subscriptionHelpers.ts diff --git a/src/App.tsx b/src/App.tsx index aee6f56..04adf9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import Dashboard from './pages/Dashboard'; // User pages - lazy load const Subscription = lazy(() => import('./pages/Subscription')); +const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase')); const Balance = lazy(() => import('./pages/Balance')); const Referral = lazy(() => import('./pages/Referral')); const Support = lazy(() => import('./pages/Support')); @@ -203,6 +204,16 @@ function App() { } /> + + + + + + } + /> { - navigate('/subscription', { state: { scrollToExtend: true } }); + navigate('/subscription/purchase'); }; const handleDeactivateClick = () => { diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 1a18c5a..57f5329 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -139,8 +139,7 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa {/* Action buttons */}
( ); +export const CopyIcon = ({ className }: IconProps) => ( + + + +); + export const XIcon = ({ className }: IconProps) => ( + +
+ {/* Left: icon + text */} +
+ {/* Sparkle icon */} +
+ +
+
+
{buttonText}
+
{hintText}
+
+
+ + {/* Right: chevron */} + +
+ + + ); +} diff --git a/src/locales/en.json b/src/locales/en.json index 105f662..e7d818b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -318,6 +318,11 @@ "price": "Price", "noSubscription": "You don't have an active subscription", "getSubscription": "Get Subscription", + "cta": { + "expiredHint": "Choose a plan and pay", + "trialHint": "More traffic and devices", + "activeHint": "Renewal and plan change" + }, "connectionInfo": "Connection Info", "copyLink": "Copy Link", "copied": "Copied!", diff --git a/src/locales/ru.json b/src/locales/ru.json index d6d06d0..b4cae25 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -336,6 +336,11 @@ "price": "Цена", "noSubscription": "У вас нет активной подписки", "getSubscription": "Оформить подписку", + "cta": { + "expiredHint": "Выберите тариф и оплатите", + "trialHint": "Больше трафика и устройств", + "activeHint": "Продление и смена тарифа" + }, "connectionInfo": "Данные для подключения", "copyLink": "Копировать ссылку", "copied": "Скопировано!", diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index a8de6b8..5a9f9f1 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1,95 +1,30 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { useLocation, useNavigate } from 'react-router'; -import { AxiosError } from 'axios'; +import { useNavigate } from 'react-router'; import { subscriptionApi } from '../api/subscription'; -import { promoApi } from '../api/promo'; import TrafficProgressBar from '../components/dashboard/TrafficProgressBar'; +import { HoverBorderGradient } from '../components/ui/hover-border-gradient'; import { getTrafficZone } from '../utils/trafficZone'; import { formatTraffic } from '../utils/formatTraffic'; import { getGlassColors } from '../utils/glassTheme'; import { useTheme } from '../hooks/useTheme'; -import { HoverBorderGradient } from '../components/ui/hover-border-gradient'; -import type { - PurchaseSelection, - PeriodOption, - Tariff, - TariffPeriod, - ClassicPurchaseOptions, -} from '../types'; import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; import { useCurrency } from '../hooks/useCurrency'; import { useCloseOnSuccessNotification } from '../store/successNotification'; -import i18n from '../i18n'; - -// Helper to extract error message from axios/api errors -const getErrorMessage = (error: unknown): string => { - if (error instanceof AxiosError) { - const detail = error.response?.data?.detail; - if (typeof detail === 'string') return detail; - if (typeof detail === 'object' && detail?.message) return detail.message; - } - if (error instanceof Error) return error.message; - return i18n.t('common.error'); -}; - -// Helper to extract insufficient balance error details -const getInsufficientBalanceError = ( - error: unknown, -): { required: number; balance: number; missingAmount?: number } | null => { - if (error instanceof AxiosError) { - const detail = error.response?.data?.detail; - // Support both 'insufficient_balance' and 'insufficient_funds' codes - if ( - typeof detail === 'object' && - (detail?.code === 'insufficient_balance' || detail?.code === 'insufficient_funds') - ) { - return { - required: detail.required || detail.total_price || 0, - balance: detail.balance || 0, - missingAmount: detail.missing_amount || detail.missingAmount || 0, - }; - } - } - return null; -}; - -// Icons -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -// Convert country code to flag emoji -const getFlagEmoji = (countryCode: string): string => { - if (!countryCode || countryCode.length !== 2) return ''; - const codePoints = countryCode - .toUpperCase() - .split('') - .map((char) => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); -}; - -type PurchaseStep = 'period' | 'traffic' | 'servers' | 'devices' | 'confirm'; +import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton'; +import { CopyIcon, CheckIcon } from '../components/icons'; +import { + getErrorMessage, + getInsufficientBalanceError, + getFlagEmoji, +} from '../utils/subscriptionHelpers'; export default function Subscription() { const { t } = useTranslation(); const queryClient = useQueryClient(); - const location = useLocation(); - const navigate = useNavigate(); const { formatAmount, currencySymbol } = useCurrency(); + const navigate = useNavigate(); const { isDark } = useTheme(); const g = getGlassColors(isDark); const [copied, setCopied] = useState(false); @@ -97,67 +32,6 @@ export default function Subscription() { // Helper to format price from kopeks const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`; - // Helper to apply promo discount to a price, stacking with existing promo group discount - const applyPromoDiscount = ( - priceKopeks: number, - existingOriginalPrice?: number | null, - ): { - price: number; - original: number | null; - percent: number | null; - isPromoGroup: boolean; - } => { - const hasExisting = (existingOriginalPrice ?? 0) > priceKopeks; - const hasPromo = !!activeDiscount?.is_active && !!activeDiscount.discount_percent; - - if (!hasExisting && !hasPromo) { - return { price: priceKopeks, original: null, percent: null, isPromoGroup: false }; - } - - let finalPrice = priceKopeks; - if (hasPromo) { - finalPrice = Math.round(priceKopeks * (1 - activeDiscount!.discount_percent! / 100)); - } - - if (hasExisting) { - // Promo group discount exists — calculate combined percent from deepest original - const combinedPercent = hasPromo - ? Math.round((1 - finalPrice / existingOriginalPrice!) * 100) - : Math.round((1 - priceKopeks / existingOriginalPrice!) * 100); - return { - price: finalPrice, - original: existingOriginalPrice!, - percent: combinedPercent, - isPromoGroup: true, - }; - } - - // Only promo offer discount (no promo group) - return { - price: finalPrice, - original: priceKopeks, - percent: activeDiscount!.discount_percent!, - isPromoGroup: false, - }; - }; - - // Purchase state (classic mode) - const [currentStep, setCurrentStep] = useState('period'); - const [selectedPeriod, setSelectedPeriod] = useState(null); - const [selectedTraffic, setSelectedTraffic] = useState(null); - const [selectedServers, setSelectedServers] = useState([]); - const [selectedDevices, setSelectedDevices] = useState(1); - const [showPurchaseForm, setShowPurchaseForm] = useState(false); - - // Tariffs mode state - const [selectedTariff, setSelectedTariff] = useState(null); - const [selectedTariffPeriod, setSelectedTariffPeriod] = useState(null); - const [showTariffPurchase, setShowTariffPurchase] = useState(false); - // Custom days/traffic state - const [customDays, setCustomDays] = useState(30); - const [customTrafficGb, setCustomTrafficGb] = useState(50); - const [useCustomDays, setUseCustomDays] = useState(false); - const [useCustomTraffic, setUseCustomTraffic] = useState(false); // Device/traffic topup state const [showDeviceTopup, setShowDeviceTopup] = useState(false); const [devicesToAdd, setDevicesToAdd] = useState(1); @@ -206,109 +80,13 @@ export default function Subscription() { return () => clearInterval(id); }, [subscription?.end_date]); - const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({ + // Purchase options (needed for balance_kopeks in device/traffic/server management) + const { data: purchaseOptions } = useQuery({ queryKey: ['purchase-options'], queryFn: subscriptionApi.getPurchaseOptions, }); - // Fetch active promo discount - const { data: activeDiscount } = useQuery({ - queryKey: ['active-discount'], - queryFn: promoApi.getActiveDiscount, - staleTime: 30000, - }); - - // Check if in tariffs mode (moved up to be available for useEffect) const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; - const classicOptions = !isTariffsMode ? (purchaseOptions as ClassicPurchaseOptions) : null; - const tariffs = - isTariffsMode && purchaseOptions && 'tariffs' in purchaseOptions ? purchaseOptions.tariffs : []; - - // Get truly available servers for a given period (same filter as rendering) - const getAvailableServers = useCallback( - (period: PeriodOption | null) => { - if (!period?.servers.options) return []; - return period.servers.options.filter((server) => { - if (!server.is_available) return false; - if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) return false; - return true; - }); - }, - [subscription?.is_trial], - ); - - // Determine which steps are needed - const steps = useMemo(() => { - const result: PurchaseStep[] = ['period']; - if (selectedPeriod?.traffic.selectable && (selectedPeriod.traffic.options?.length ?? 0) > 0) { - result.push('traffic'); - } - const availableServers = getAvailableServers(selectedPeriod); - // Skip server selection step if only 1 server available (auto-select it) - if (availableServers.length > 1) { - result.push('servers'); - } - if (selectedPeriod && selectedPeriod.devices.max > selectedPeriod.devices.min) { - result.push('devices'); - } - result.push('confirm'); - return result; - }, [selectedPeriod, getAvailableServers]); - - const currentStepIndex = steps.indexOf(currentStep); - const isFirstStep = currentStepIndex === 0; - const isLastStep = currentStepIndex === steps.length - 1; - - // Initialize selection from options (classic mode only) - useEffect(() => { - if (classicOptions && !selectedPeriod) { - const defaultPeriod = - classicOptions.periods.find((p) => p.id === classicOptions.selection.period_id) || - classicOptions.periods[0]; - setSelectedPeriod(defaultPeriod); - setSelectedTraffic(classicOptions.selection.traffic_value); - const availableServers = getAvailableServers(defaultPeriod); - const availableServerUuids = new Set(availableServers.map((s) => s.uuid)); - // If only 1 server available, auto-select it (step will be skipped) - if (availableServers.length === 1) { - setSelectedServers([availableServers[0].uuid]); - } else { - setSelectedServers( - classicOptions.selection.servers.filter((uuid) => availableServerUuids.has(uuid)), - ); - } - setSelectedDevices(classicOptions.selection.devices); - } - }, [classicOptions, selectedPeriod, getAvailableServers]); - - // Build selection object - const currentSelection: PurchaseSelection = useMemo( - () => ({ - period_id: selectedPeriod?.id, - period_days: selectedPeriod?.period_days, - traffic_value: selectedTraffic ?? undefined, - servers: selectedServers, - devices: selectedDevices, - }), - [selectedPeriod, selectedTraffic, selectedServers, selectedDevices], - ); - - // Preview query - const { data: preview, isLoading: previewLoading } = useQuery({ - queryKey: ['purchase-preview', currentSelection], - queryFn: () => subscriptionApi.previewPurchase(currentSelection), - enabled: !!selectedPeriod && showPurchaseForm && currentStep === 'confirm', - }); - - const purchaseMutation = useMutation({ - mutationFn: () => subscriptionApi.submitPurchase(currentSelection), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['subscription'] }); - queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); - setShowPurchaseForm(false); - setCurrentStep('period'); - }, - }); const autopayMutation = useMutation({ mutationFn: (enabled: boolean) => subscriptionApi.updateAutopay(enabled), @@ -348,96 +126,15 @@ export default function Subscription() { }, }); - // Refs for auto-scroll - const switchModalRef = useRef(null); - const tariffPurchaseRef = useRef(null); - const tariffsCardRef = useRef(null); - - // Tariff switch preview - const [switchTariffId, setSwitchTariffId] = useState(null); - - // Auto-close all modals/forms when success notification appears (e.g., subscription purchased via WebSocket) + // Auto-close all modals/forms when success notification appears const handleCloseAllModals = useCallback(() => { - setShowPurchaseForm(false); - setShowTariffPurchase(false); setShowDeviceTopup(false); setShowDeviceReduction(false); setShowTrafficTopup(false); setShowServerManagement(false); - setSwitchTariffId(null); - setSelectedTariff(null); - setSelectedTariffPeriod(null); }, []); useCloseOnSuccessNotification(handleCloseAllModals); - const { data: switchPreview, isLoading: switchPreviewLoading } = useQuery({ - queryKey: ['tariff-switch-preview', switchTariffId], - queryFn: () => subscriptionApi.previewTariffSwitch(switchTariffId!), - enabled: !!switchTariffId, - }); - - // Tariff switch mutation - const switchTariffMutation = useMutation({ - mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['subscription'] }); - queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); - setSwitchTariffId(null); - }, - onError: (error: unknown) => { - // Handle subscription_expired error - redirect to purchase flow - if (error instanceof AxiosError) { - const detail = error.response?.data?.detail; - if ( - typeof detail === 'object' && - detail?.error_code === 'subscription_expired' && - detail?.use_purchase_flow === true - ) { - // Find the tariff user was trying to switch to and open purchase form - const targetTariff = tariffs.find((t) => t.id === switchTariffId); - if (targetTariff) { - setSwitchTariffId(null); - setSelectedTariff(targetTariff); - setSelectedTariffPeriod(targetTariff.periods[0] || null); - setShowTariffPurchase(true); - // Refetch purchase-options to get updated expired status - queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); - } - } - } - }, - }); - - // Tariff purchase mutation - const tariffPurchaseMutation = useMutation({ - mutationFn: () => { - if (!selectedTariff) { - throw new Error('Tariff not selected'); - } - // For daily tariffs, always use 1 day - const isDailyTariff = - selectedTariff.is_daily || - (selectedTariff.daily_price_kopeks && selectedTariff.daily_price_kopeks > 0); - const days = isDailyTariff - ? 1 - : useCustomDays - ? customDays - : selectedTariffPeriod?.days || 30; - const trafficGb = - useCustomTraffic && selectedTariff.custom_traffic_enabled ? customTrafficGb : undefined; - return subscriptionApi.purchaseTariff(selectedTariff.id, days, trafficGb); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['subscription'] }); - queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); - setShowTariffPurchase(false); - setSelectedTariff(null); - setSelectedTariffPeriod(null); - setUseCustomDays(false); - setUseCustomTraffic(false); - }, - }); - // Device price query const { data: devicePriceData } = useQuery({ queryKey: ['device-price', devicesToAdd], @@ -589,40 +286,6 @@ export default function Subscription() { refreshTrafficMutation.mutate(); }, [subscription, refreshTrafficMutation]); - // Auto-scroll to switch tariff modal when it appears - useEffect(() => { - if (switchTariffId && switchModalRef.current) { - const timer = setTimeout(() => { - switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, 100); - return () => clearTimeout(timer); - } - }, [switchTariffId]); - - // Auto-scroll to tariff purchase form when it appears - useEffect(() => { - if (showTariffPurchase && tariffPurchaseRef.current) { - const timer = setTimeout(() => { - tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, 100); - return () => clearTimeout(timer); - } - }, [showTariffPurchase]); - - // Auto-scroll to tariffs section when coming from Dashboard "Продлить" button - useEffect(() => { - const state = location.state as { scrollToExtend?: boolean } | null; - // Wait for tariffs to load before scrolling - if (state?.scrollToExtend && tariffsCardRef.current && tariffs.length > 0) { - const timer = setTimeout(() => { - tariffsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, 100); - // Clear the state to prevent re-scrolling on subsequent renders - window.history.replaceState({}, document.title); - return () => clearTimeout(timer); - } - }, [location.state, tariffs.length]); - const copyUrl = () => { if (subscription?.subscription_url) { navigator.clipboard.writeText(subscription.subscription_url); @@ -631,51 +294,7 @@ export default function Subscription() { } }; - const toggleServer = (uuid: string) => { - if (selectedServers.includes(uuid)) { - if (selectedServers.length > 1) { - setSelectedServers(selectedServers.filter((s) => s !== uuid)); - } - } else { - setSelectedServers([...selectedServers, uuid]); - } - }; - - const goToNextStep = () => { - const nextIndex = currentStepIndex + 1; - if (nextIndex < steps.length) { - setCurrentStep(steps[nextIndex]); - } - }; - - const goToPrevStep = () => { - const prevIndex = currentStepIndex - 1; - if (prevIndex >= 0) { - setCurrentStep(steps[prevIndex]); - } - }; - - const resetPurchase = () => { - setShowPurchaseForm(false); - setCurrentStep('period'); - }; - - const getStepLabel = (step: PurchaseStep) => { - switch (step) { - case 'period': - return t('subscription.stepPeriod'); - case 'traffic': - return t('subscription.stepTraffic'); - case 'servers': - return t('subscription.stepServers'); - case 'devices': - return t('subscription.stepDevices'); - case 'confirm': - return t('subscription.stepConfirm'); - } - }; - - if (isLoading || optionsLoading) { + if (isLoading) { return (
@@ -2409,1549 +2028,8 @@ export default function Subscription() {
)} - {/* Tariffs Section - Combined Purchase/Extend/Switch like MiniApp */} - {isTariffsMode && tariffs.length > 0 && ( -
-
-

- {subscription?.is_daily && !subscription?.is_trial - ? t('subscription.switchTariff.title') - : subscription && !subscription.is_trial - ? t('subscription.extend') - : t('subscription.getSubscription')} -

-
- - {/* Trial upgrade prompt */} - {subscription?.is_trial && ( -
-
-
- -
-
-
- {t('subscription.trialUpgrade.title')} -
-
- {t('subscription.trialUpgrade.description')} -
-
-
-
- )} - - {/* Expired subscription notice - prompt to purchase new tariff */} - {isTariffsMode && - purchaseOptions && - 'subscription_is_expired' in purchaseOptions && - purchaseOptions.subscription_is_expired && ( -
-
-
- -
-
-
- {t('subscription.expiredBanner.title')} -
-
- {t('subscription.expiredBanner.selectTariff')} -
-
-
-
- )} - - {/* Legacy subscription notice - if user has subscription without tariff */} - {subscription && !subscription.is_trial && !subscription.tariff_id && ( -
-
- {t('subscription.legacy.selectTariffTitle')} -
-
- {t('subscription.legacy.selectTariffDescription')} -
-
- {t('subscription.legacy.currentSubContinues')} -
-
- )} - - {/* Switch Tariff Preview Modal */} - {switchTariffId && ( -
-
-

- {t('subscription.switchTariff.title')} -

- -
- - {switchPreviewLoading ? ( -
-
-
- ) : ( - switchPreview && - (() => { - // Find the target tariff to get daily price info - const targetTariff = tariffs.find((t) => t.id === switchTariffId); - const dailyPrice = - targetTariff?.daily_price_kopeks ?? targetTariff?.price_per_day_kopeks ?? 0; - const isDailyTariff = dailyPrice > 0; - - return ( - <> -
-
- {t('subscription.switchTariff.currentTariff')} - - {switchPreview.current_tariff_name || '-'} - -
-
- {t('subscription.switchTariff.newTariff')} - - {switchPreview.new_tariff_name} - -
-
- {t('subscription.switchTariff.remainingDays')} - {switchPreview.remaining_days} -
-
- - {/* Daily tariff info */} - {isDailyTariff && ( -
-
- {t('subscription.switchTariff.dailyPayment')} -
-
- {formatPrice(dailyPrice)} -
-
- {t('subscription.switchTariff.dailyChargeDescription')} -
-
- )} - -
-
- - {t('subscription.switchTariff.upgradeCost')} - - {/* Discount badge */} - {switchPreview.discount_percent && switchPreview.discount_percent > 0 && ( - - -{switchPreview.discount_percent}% - - )} -
-
- {/* Show original price with strikethrough if discount */} - {switchPreview.discount_percent && - switchPreview.discount_percent > 0 && - switchPreview.base_upgrade_cost_kopeks && - switchPreview.base_upgrade_cost_kopeks > 0 && ( - - {formatPrice(switchPreview.base_upgrade_cost_kopeks)} - - )} - - {switchPreview.upgrade_cost_kopeks > 0 - ? switchPreview.upgrade_cost_label - : t('subscription.switchTariff.free')} - -
-
- - {!switchPreview.has_enough_balance && - switchPreview.upgrade_cost_kopeks > 0 && ( - - )} - - - - {/* Show error (except subscription_expired which redirects to purchase) */} - {switchTariffMutation.isError && - (() => { - const detail = - switchTariffMutation.error instanceof AxiosError - ? switchTariffMutation.error.response?.data?.detail - : null; - // Skip displaying if it's subscription_expired (handled by redirect) - if ( - typeof detail === 'object' && - detail?.error_code === 'subscription_expired' - ) { - return null; - } - return ( -
- {getErrorMessage(switchTariffMutation.error)} -
- ); - })()} - - ); - })() - )} -
- )} - - {!showTariffPurchase ? ( - <> - {/* Promo group discount banner */} - {tariffs.some((t) => t.promo_group_name) && ( -
-
- - - -
-
-
- {t('subscription.promoGroup.yourGroup', { - name: tariffs.find((t) => t.promo_group_name)?.promo_group_name, - })} -
-
- {t('subscription.promoGroup.personalDiscountsApplied')} -
-
-
- )} - {/* Tariff List - current tariff first */} -
- {[...tariffs] - // Hide Trial tariffs for users who already have trial subscription - .filter((tariff) => { - if (subscription?.is_trial && tariff.name.toLowerCase().includes('trial')) { - return false; - } - return true; - }) - .sort((a, b) => { - const aIsCurrent = a.is_current || a.id === subscription?.tariff_id; - const bIsCurrent = b.is_current || b.id === subscription?.tariff_id; - if (aIsCurrent && !bIsCurrent) return -1; - if (!aIsCurrent && bIsCurrent) return 1; - return 0; - }) - .map((tariff) => { - const isCurrentTariff = - tariff.is_current || tariff.id === subscription?.tariff_id; - // Check if subscription is expired from purchaseOptions - const isSubscriptionExpired = - isTariffsMode && - purchaseOptions && - 'subscription_is_expired' in purchaseOptions && - purchaseOptions.subscription_is_expired === true; - // canSwitch only if subscription is active (not expired, not trial) - const canSwitch = - subscription && - subscription.tariff_id && - !isCurrentTariff && - !subscription.is_trial && - !isSubscriptionExpired && - subscription.is_active; - // Если есть подписка БЕЗ tariff_id (классическая) - разрешить выбрать тариф - const isLegacySubscription = - subscription && !subscription.is_trial && !subscription.tariff_id; - - return ( -
-
-
-
{tariff.name}
- {tariff.description && ( -
- {tariff.description} -
- )} -
- {isCurrentTariff && ( - - {t('subscription.currentTariff')} - - )} -
-
- {/* Traffic */} -
- - - - - {tariff.traffic_limit_label} - -
- {/* Devices */} -
- - - - - {t('subscription.devices', { count: tariff.device_limit })} - -
- {/* Traffic Reset */} - {tariff.traffic_reset_mode && - tariff.traffic_reset_mode !== 'NO_RESET' && ( -
- - - - - {t(`subscription.trafficReset.${tariff.traffic_reset_mode}`)} - -
- )} -
- {/* Price info - daily or period-based */} -
- {(() => { - // Daily tariff price (is_daily + daily_price_kopeks) - const dailyPrice = - tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0; - const originalDailyPrice = tariff.original_daily_price_kopeks || 0; - if (dailyPrice > 0) { - const promoDaily = applyPromoDiscount( - dailyPrice, - originalDailyPrice > dailyPrice ? originalDailyPrice : undefined, - ); - return ( - - - {formatPrice(promoDaily.price)} - - {promoDaily.original && - promoDaily.original > promoDaily.price && ( - - {formatPrice(promoDaily.original)} - - )} - {t('subscription.tariff.perDay')} - {/* Show discount badge */} - {promoDaily.percent && promoDaily.percent > 0 && ( - - -{promoDaily.percent}% - - )} - - ); - } - // Period-based price - if (tariff.periods.length > 0) { - const firstPeriod = tariff.periods[0]; - const promoPeriod = applyPromoDiscount( - firstPeriod?.price_kopeks || 0, - firstPeriod?.original_price_kopeks, - ); - return ( - - {t('subscription.from')} - - {formatPrice(promoPeriod.price)} - - {promoPeriod.original && - promoPeriod.original > promoPeriod.price && ( - - {formatPrice(promoPeriod.original)} - - )} - {promoPeriod.percent && promoPeriod.percent > 0 && ( - - -{promoPeriod.percent}% - - )} - - ); - } - // Fallback - return ( - - {t('subscription.tariff.flexiblePayment')} - - ); - })()} -
- - {/* Action Buttons */} -
- {isCurrentTariff ? ( - /* Current tariff - extend button (hide for daily tariffs) */ - subscription?.is_daily ? ( -
- {t('subscription.currentTariff')} -
- ) : ( - - ) - ) : isLegacySubscription ? ( - /* Legacy subscription without tariff - allow selecting tariff for renewal */ - - ) : canSwitch ? ( - /* Other tariffs with existing tariff - switch button */ - - ) : ( - /* No subscription or trial - purchase button */ - - )} -
-
- ); - })} -
- - ) : ( - selectedTariff && ( - /* Tariff Purchase Form */ -
-
-

{selectedTariff.name}

- -
- - {/* Tariff Info */} -
-
-
- {t('subscription.traffic')}: - - {selectedTariff.traffic_limit_label} - -
-
- {t('subscription.devices')}: - - {selectedTariff.device_limit} - {selectedTariff.extra_devices_count > 0 && ( - - (+{selectedTariff.extra_devices_count}) - - )} - -
-
-
- - {/* Daily Tariff Purchase */} - {selectedTariff.is_daily || - (selectedTariff.daily_price_kopeks && selectedTariff.daily_price_kopeks > 0) ? ( -
-
-
- {t('subscription.dailyPurchase.costPerDay')} -
-
- {formatPrice(selectedTariff.daily_price_kopeks || 0)} -
-
-
-
- - {t('subscription.dailyPurchase.chargedDaily')} -
-
- - {t('subscription.dailyPurchase.canPause')} -
-
- - {t('subscription.dailyPurchase.pausedOnLowBalance')} -
-
- - {/* Purchase button for daily tariff */} - {(() => { - const dailyPrice = selectedTariff.daily_price_kopeks || 0; - const hasEnoughBalance = - purchaseOptions && dailyPrice <= purchaseOptions.balance_kopeks; - - return ( -
- {purchaseOptions && !hasEnoughBalance && ( - - )} - - - - {tariffPurchaseMutation.isError && - !getInsufficientBalanceError(tariffPurchaseMutation.error) && ( -
- {getErrorMessage(tariffPurchaseMutation.error)} -
- )} - {tariffPurchaseMutation.isError && - getInsufficientBalanceError(tariffPurchaseMutation.error) && ( -
- -
- )} -
- ); - })()} -
- ) : ( - <> - {/* Period Selection for non-daily tariffs */} -
-
- {t('subscription.selectPeriod')} -
- - {/* Fixed periods */} - {selectedTariff.periods.length > 0 && !useCustomDays && ( -
- {selectedTariff.periods.map((period) => { - const promoPeriod = applyPromoDiscount( - period.price_kopeks, - period.original_price_kopeks, - ); - const displayDiscount = promoPeriod.percent; - const displayOriginal = promoPeriod.original; - const displayPrice = promoPeriod.price; - const displayPerMonth = - displayPrice !== period.price_kopeks - ? Math.round(displayPrice / Math.max(1, period.days / 30)) - : period.price_per_month_kopeks; - - return ( - - ); - })} -
- )} - - {/* Custom days option */} - {selectedTariff.custom_days_enabled && - (selectedTariff.price_per_day_kopeks ?? 0) > 0 && ( -
-
- - {t('subscription.customDays.title')} - - -
- {useCustomDays && ( -
-
- setCustomDays(parseInt(e.target.value))} - className="flex-1 accent-accent-500" - /> - - setCustomDays( - Math.max( - selectedTariff.min_days ?? 1, - Math.min( - selectedTariff.max_days ?? 365, - parseInt(e.target.value) || - (selectedTariff.min_days ?? 1), - ), - ), - ) - } - className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100" - /> -
- {(() => { - const basePrice = - customDays * (selectedTariff.price_per_day_kopeks ?? 0); - const existingOriginal = - selectedTariff.original_price_per_day_kopeks && - selectedTariff.original_price_per_day_kopeks > - (selectedTariff.price_per_day_kopeks ?? 0) - ? customDays * selectedTariff.original_price_per_day_kopeks - : undefined; - const promoCustom = applyPromoDiscount( - basePrice, - existingOriginal, - ); - return ( -
- - {t('subscription.days', { count: customDays })} ×{' '} - {formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/ - {t('subscription.customDays.perDay')} - -
- - {formatPrice(promoCustom.price)} - - {promoCustom.original && - promoCustom.original > promoCustom.price && ( - <> - - {formatPrice(promoCustom.original)} - - - -{promoCustom.percent}% - - - )} -
-
- ); - })()} -
- )} -
- )} -
- - {/* Custom traffic option */} - {selectedTariff.custom_traffic_enabled && - (selectedTariff.traffic_price_per_gb_kopeks ?? 0) > 0 && ( -
-
- {t('subscription.customTraffic.label')} -
-
-
- - {t('subscription.customTraffic.selectVolume')} - - -
- {!useCustomTraffic && ( -
- {t('subscription.customTraffic.default', { - label: selectedTariff.traffic_limit_label, - })} -
- )} - {useCustomTraffic && ( -
-
- setCustomTrafficGb(parseInt(e.target.value))} - className="flex-1 accent-accent-500" - /> -
- - setCustomTrafficGb( - Math.max( - selectedTariff.min_traffic_gb ?? 1, - Math.min( - selectedTariff.max_traffic_gb ?? 1000, - parseInt(e.target.value) || - (selectedTariff.min_traffic_gb ?? 1), - ), - ), - ) - } - className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100" - /> - {t('common.units.gb')} -
-
-
- - {customTrafficGb} {t('common.units.gb')} ×{' '} - {formatPrice(selectedTariff.traffic_price_per_gb_kopeks ?? 0)}/ - {t('common.units.gb')} - - - + - {formatPrice( - customTrafficGb * - (selectedTariff.traffic_price_per_gb_kopeks ?? 0), - )} - -
-
- )} -
-
- )} - - {/* Summary & Purchase */} - {(selectedTariffPeriod || useCustomDays) && ( -
- {(() => { - // Calculate prices with promo discount - const basePeriodPrice = useCustomDays - ? customDays * (selectedTariff.price_per_day_kopeks ?? 0) - : selectedTariffPeriod?.price_kopeks || 0; - const existingPeriodOriginal = useCustomDays - ? selectedTariff.original_price_per_day_kopeks && - selectedTariff.original_price_per_day_kopeks > - (selectedTariff.price_per_day_kopeks ?? 0) - ? customDays * selectedTariff.original_price_per_day_kopeks - : undefined - : selectedTariffPeriod?.original_price_kopeks && - selectedTariffPeriod.original_price_kopeks > - selectedTariffPeriod.price_kopeks - ? selectedTariffPeriod.original_price_kopeks - : undefined; - const promoPeriod = applyPromoDiscount( - basePeriodPrice, - existingPeriodOriginal, - ); - - const trafficPrice = - useCustomTraffic && selectedTariff.custom_traffic_enabled - ? customTrafficGb * (selectedTariff.traffic_price_per_gb_kopeks ?? 0) - : 0; - - const totalPrice = promoPeriod.price + trafficPrice; - const originalTotal = promoPeriod.original - ? promoPeriod.original + trafficPrice - : null; - - return ( - <> - {/* Price breakdown */} -
- {useCustomDays ? ( -
- - {t('subscription.stepPeriod')}:{' '} - {t('subscription.days', { count: customDays })} - -
- {formatPrice(promoPeriod.price)} - {promoPeriod.original && - promoPeriod.original > promoPeriod.price && ( - - {formatPrice(promoPeriod.original)} - - )} -
-
- ) : ( - selectedTariffPeriod && ( - <> - {/* Если есть доп. устройства - показываем разбивку */} - {(selectedTariffPeriod.extra_devices_count ?? 0) > 0 && - selectedTariffPeriod.base_tariff_price_kopeks ? ( - <> -
- - {t('subscription.baseTariff')}:{' '} - {selectedTariffPeriod.label} - - - {formatPrice( - selectedTariffPeriod.base_tariff_price_kopeks, - )} - -
-
- - {t('subscription.extraDevices')} ( - {selectedTariffPeriod.extra_devices_count}) - - - + - {formatPrice( - selectedTariffPeriod.extra_devices_cost_kopeks ?? 0, - )} - -
- - ) : ( -
- - {t('subscription.summary.period', { - label: selectedTariffPeriod.label, - })} - -
- {formatPrice(promoPeriod.price)} - {promoPeriod.original && - promoPeriod.original > promoPeriod.price && ( - - {formatPrice(promoPeriod.original)} - - )} -
-
- )} - - ) - )} - {useCustomTraffic && selectedTariff.custom_traffic_enabled && ( -
- - {t('subscription.summary.traffic', { gb: customTrafficGb })} - - +{formatPrice(trafficPrice)} -
- )} -
- - {/* Promo discount info */} - {promoPeriod.percent && ( -
- - {t('promo.discountApplied')} -{promoPeriod.percent}% - -
- )} - -
- - {t('subscription.total')} - -
- - {formatPrice(totalPrice)} - - {originalTotal && ( -
- {formatPrice(originalTotal)} -
- )} -
-
- - - - ); - })()} - - {tariffPurchaseMutation.isError && - !getInsufficientBalanceError(tariffPurchaseMutation.error) && ( -
- {getErrorMessage(tariffPurchaseMutation.error)} -
- )} - {tariffPurchaseMutation.isError && - getInsufficientBalanceError(tariffPurchaseMutation.error) && ( -
- -
- )} -
- )} - - )} -
- ) - )} -
- )} - - {/* Purchase/Extend Section - Classic Mode */} - {classicOptions && classicOptions.periods.length > 0 && ( -
-
-

- {subscription && !subscription.is_trial - ? t('subscription.extend') - : t('subscription.getSubscription')} -

- {!showPurchaseForm && ( - - )} -
- - {showPurchaseForm && ( -
- {/* Step Indicator */} -
-
- {t('subscription.step', { current: currentStepIndex + 1, total: steps.length })} -
-
- {steps.map((step, idx) => ( -
- ))} -
-
- -
- {getStepLabel(currentStep)} -
- - {/* Step: Period Selection */} - {currentStep === 'period' && classicOptions && ( -
- {classicOptions.periods.map((period) => { - const promoPeriod = applyPromoDiscount( - period.price_kopeks, - period.original_price_kopeks, - ); - - return ( - - ); - })} -
- )} - - {/* Step: Traffic Selection */} - {currentStep === 'traffic' && selectedPeriod?.traffic.options && ( -
- {selectedPeriod.traffic.options.map((option) => { - const promoTraffic = applyPromoDiscount( - option.price_kopeks, - option.original_price_kopeks, - ); - - return ( - - ); - })} -
- )} - - {/* Step: Server Selection */} - {currentStep === 'servers' && selectedPeriod?.servers.options && ( -
- {selectedPeriod.servers.options - // Hide unavailable (disabled) servers and trial servers for existing trial users - .filter((server) => { - if (!server.is_available) return false; - if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) { - return false; - } - return true; - }) - .map((server) => { - const promoServer = applyPromoDiscount( - server.price_kopeks, - server.original_price_kopeks, - ); - - return ( - - ); - })} -
- )} - - {/* Step: Device Selection */} - {currentStep === 'devices' && selectedPeriod && ( -
-
- -
-
{selectedDevices}
-
{t('subscription.devices')}
-
- -
-
-
- {t('subscription.devicesFree', { count: selectedPeriod.devices.min })} -
- {selectedPeriod.devices.max > selectedPeriod.devices.min && ( -
- {formatPrice(selectedPeriod.devices.price_per_device_kopeks)}{' '} - {t('subscription.perExtraDevice')} -
- )} -
-
- )} - - {/* Step: Confirm */} - {currentStep === 'confirm' && ( -
- {previewLoading ? ( -
-
-
- ) : preview ? ( -
- {/* Active promo discount banner */} - {activeDiscount?.is_active && activeDiscount.discount_percent && ( -
- - - - - {t('promo.discountApplied')} -{activeDiscount.discount_percent}% - -
- )} - - {preview.breakdown.map((item, idx) => ( -
- {item.label} - {item.value} -
- ))} - - {(() => { - const promoTotal = applyPromoDiscount( - preview.total_price_kopeks, - preview.original_price_kopeks, - ); - - return ( -
- - {t('subscription.total')} - -
-
- {formatPrice(promoTotal.price)} -
- {promoTotal.original && promoTotal.original > promoTotal.price && ( -
- {formatPrice(promoTotal.original)} -
- )} -
-
- ); - })()} - - {preview.discount_label && ( -
- {preview.discount_label} -
- )} - - {!preview.can_purchase && - (preview.missing_amount_kopeks > 0 ? ( - - ) : preview.status_message ? ( -
- {preview.status_message} -
- ) : null)} -
- ) : null} -
- )} - - {/* Navigation Buttons */} -
- {!isFirstStep && ( - - )} - - {isFirstStep && ( - - )} - - {!isLastStep ? ( - - ) : ( - - )} -
- - {purchaseMutation.isError && ( -
- {getErrorMessage(purchaseMutation.error)} -
- )} -
- )} -
- )} + {/* Purchase / Renewal CTA */} +
); } diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx new file mode 100644 index 0000000..3aa99ce --- /dev/null +++ b/src/pages/SubscriptionPurchase.tsx @@ -0,0 +1,1900 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import { AxiosError } from 'axios'; +import { subscriptionApi } from '../api/subscription'; +import { promoApi } from '../api/promo'; +import { getGlassColors } from '../utils/glassTheme'; +import { useTheme } from '../hooks/useTheme'; +import type { + PurchaseSelection, + PeriodOption, + Tariff, + TariffPeriod, + ClassicPurchaseOptions, +} from '../types'; +import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; +import { useCurrency } from '../hooks/useCurrency'; +import { useCloseOnSuccessNotification } from '../store/successNotification'; +import { CheckIcon } from '../components/icons'; +import { + getErrorMessage, + getInsufficientBalanceError, + type PurchaseStep, +} from '../utils/subscriptionHelpers'; + +export default function SubscriptionPurchase() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { formatAmount, currencySymbol } = useCurrency(); + const { isDark } = useTheme(); + const g = getGlassColors(isDark); + + const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`; + + // Subscription query (shares cache with /subscription page) + const { data: subscriptionResponse, isLoading } = useQuery({ + queryKey: ['subscription'], + queryFn: subscriptionApi.getSubscription, + retry: false, + staleTime: 0, + refetchOnMount: 'always', + }); + const subscription = subscriptionResponse?.subscription ?? null; + + // Purchase options + const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({ + queryKey: ['purchase-options'], + queryFn: subscriptionApi.getPurchaseOptions, + }); + + // Active promo discount + const { data: activeDiscount } = useQuery({ + queryKey: ['active-discount'], + queryFn: promoApi.getActiveDiscount, + staleTime: 30000, + }); + + // Sales mode detection + const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; + const classicOptions = !isTariffsMode ? (purchaseOptions as ClassicPurchaseOptions) : null; + const tariffs = + isTariffsMode && purchaseOptions && 'tariffs' in purchaseOptions ? purchaseOptions.tariffs : []; + + // Helper to apply promo discount + const applyPromoDiscount = ( + priceKopeks: number, + existingOriginalPrice?: number | null, + ): { + price: number; + original: number | null; + percent: number | null; + isPromoGroup: boolean; + } => { + const hasExisting = (existingOriginalPrice ?? 0) > priceKopeks; + const hasPromo = !!activeDiscount?.is_active && !!activeDiscount.discount_percent; + + if (!hasExisting && !hasPromo) { + return { price: priceKopeks, original: null, percent: null, isPromoGroup: false }; + } + + let finalPrice = priceKopeks; + if (hasPromo) { + finalPrice = Math.round(priceKopeks * (1 - activeDiscount!.discount_percent! / 100)); + } + + if (hasExisting) { + const combinedPercent = hasPromo + ? Math.round((1 - finalPrice / existingOriginalPrice!) * 100) + : Math.round((1 - priceKopeks / existingOriginalPrice!) * 100); + return { + price: finalPrice, + original: existingOriginalPrice!, + percent: combinedPercent, + isPromoGroup: true, + }; + } + + return { + price: finalPrice, + original: priceKopeks, + percent: activeDiscount!.discount_percent!, + isPromoGroup: false, + }; + }; + + // Classic mode state + const [currentStep, setCurrentStep] = useState('period'); + const [selectedPeriod, setSelectedPeriod] = useState(null); + const [selectedTraffic, setSelectedTraffic] = useState(null); + const [selectedServers, setSelectedServers] = useState([]); + const [selectedDevices, setSelectedDevices] = useState(1); + const [showPurchaseForm, setShowPurchaseForm] = useState(false); + + // Tariffs mode state + const [selectedTariff, setSelectedTariff] = useState(null); + const [selectedTariffPeriod, setSelectedTariffPeriod] = useState(null); + const [showTariffPurchase, setShowTariffPurchase] = useState(false); + const [customDays, setCustomDays] = useState(30); + const [customTrafficGb, setCustomTrafficGb] = useState(50); + const [useCustomDays, setUseCustomDays] = useState(false); + const [useCustomTraffic, setUseCustomTraffic] = useState(false); + + // Refs for auto-scroll + const switchModalRef = useRef(null); + const tariffPurchaseRef = useRef(null); + + // Tariff switch + const [switchTariffId, setSwitchTariffId] = useState(null); + + // Auto-close all modals on success notification + const handleCloseAllModals = () => { + setShowPurchaseForm(false); + setShowTariffPurchase(false); + setSwitchTariffId(null); + setSelectedTariff(null); + setSelectedTariffPeriod(null); + }; + useCloseOnSuccessNotification(handleCloseAllModals); + + // Get available servers + const getAvailableServers = useCallback( + (period: PeriodOption | null) => { + if (!period?.servers.options) return []; + return period.servers.options.filter((server) => { + if (!server.is_available) return false; + if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) return false; + return true; + }); + }, + [subscription?.is_trial], + ); + + // Steps for classic mode + const steps = useMemo(() => { + const result: PurchaseStep[] = ['period']; + if (selectedPeriod?.traffic.selectable && (selectedPeriod.traffic.options?.length ?? 0) > 0) { + result.push('traffic'); + } + const availableServers = getAvailableServers(selectedPeriod); + if (availableServers.length > 1) { + result.push('servers'); + } + if (selectedPeriod && selectedPeriod.devices.max > selectedPeriod.devices.min) { + result.push('devices'); + } + result.push('confirm'); + return result; + }, [selectedPeriod, getAvailableServers]); + + const currentStepIndex = steps.indexOf(currentStep); + const isFirstStep = currentStepIndex === 0; + const isLastStep = currentStepIndex === steps.length - 1; + + // Initialize classic mode selection + useEffect(() => { + if (classicOptions && !selectedPeriod) { + const defaultPeriod = + classicOptions.periods.find((p) => p.id === classicOptions.selection.period_id) || + classicOptions.periods[0]; + setSelectedPeriod(defaultPeriod); + setSelectedTraffic(classicOptions.selection.traffic_value); + const availableServers = getAvailableServers(defaultPeriod); + const availableServerUuids = new Set(availableServers.map((s) => s.uuid)); + if (availableServers.length === 1) { + setSelectedServers([availableServers[0].uuid]); + } else { + setSelectedServers( + classicOptions.selection.servers.filter((uuid) => availableServerUuids.has(uuid)), + ); + } + setSelectedDevices(classicOptions.selection.devices); + } + }, [classicOptions, selectedPeriod, getAvailableServers]); + + // Build classic mode selection + const currentSelection: PurchaseSelection = useMemo( + () => ({ + period_id: selectedPeriod?.id, + period_days: selectedPeriod?.period_days, + traffic_value: selectedTraffic ?? undefined, + servers: selectedServers, + devices: selectedDevices, + }), + [selectedPeriod, selectedTraffic, selectedServers, selectedDevices], + ); + + // Preview query (classic) + const { data: preview, isLoading: previewLoading } = useQuery({ + queryKey: ['purchase-preview', currentSelection], + queryFn: () => subscriptionApi.previewPurchase(currentSelection), + enabled: !!selectedPeriod && showPurchaseForm && currentStep === 'confirm', + }); + + // Classic purchase mutation + const purchaseMutation = useMutation({ + mutationFn: () => subscriptionApi.submitPurchase(currentSelection), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + navigate('/subscription', { replace: true }); + }, + }); + + // Switch preview query + const { data: switchPreview, isLoading: switchPreviewLoading } = useQuery({ + queryKey: ['tariff-switch-preview', switchTariffId], + queryFn: () => subscriptionApi.previewTariffSwitch(switchTariffId!), + enabled: !!switchTariffId, + }); + + // Tariff switch mutation + const switchTariffMutation = useMutation({ + mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + setSwitchTariffId(null); + navigate('/subscription', { replace: true }); + }, + onError: (error: unknown) => { + if (error instanceof AxiosError) { + const detail = error.response?.data?.detail; + if ( + typeof detail === 'object' && + detail?.error_code === 'subscription_expired' && + detail?.use_purchase_flow === true + ) { + const targetTariff = tariffs.find((tariff) => tariff.id === switchTariffId); + if (targetTariff) { + setSwitchTariffId(null); + setSelectedTariff(targetTariff); + setSelectedTariffPeriod(targetTariff.periods[0] || null); + setShowTariffPurchase(true); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + } + } + } + }, + }); + + // Tariff purchase mutation + const tariffPurchaseMutation = useMutation({ + mutationFn: () => { + if (!selectedTariff) { + throw new Error('Tariff not selected'); + } + const isDailyTariff = + selectedTariff.is_daily || + (selectedTariff.daily_price_kopeks && selectedTariff.daily_price_kopeks > 0); + const days = isDailyTariff + ? 1 + : useCustomDays + ? customDays + : selectedTariffPeriod?.days || 30; + const trafficGb = + useCustomTraffic && selectedTariff.custom_traffic_enabled ? customTrafficGb : undefined; + return subscriptionApi.purchaseTariff(selectedTariff.id, days, trafficGb); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + navigate('/subscription', { replace: true }); + }, + }); + + // Auto-scroll effects + useEffect(() => { + if (switchTariffId && switchModalRef.current) { + const timer = setTimeout(() => { + switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + return () => clearTimeout(timer); + } + }, [switchTariffId]); + + useEffect(() => { + if (showTariffPurchase && tariffPurchaseRef.current) { + const timer = setTimeout(() => { + tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + return () => clearTimeout(timer); + } + }, [showTariffPurchase]); + + // Classic mode helpers + const toggleServer = (uuid: string) => { + if (selectedServers.includes(uuid)) { + if (selectedServers.length > 1) { + setSelectedServers(selectedServers.filter((s) => s !== uuid)); + } + } else { + setSelectedServers([...selectedServers, uuid]); + } + }; + + const goToNextStep = () => { + const nextIndex = currentStepIndex + 1; + if (nextIndex < steps.length) { + setCurrentStep(steps[nextIndex]); + } + }; + + const goToPrevStep = () => { + const prevIndex = currentStepIndex - 1; + if (prevIndex >= 0) { + setCurrentStep(steps[prevIndex]); + } + }; + + const resetPurchase = () => { + setShowPurchaseForm(false); + setCurrentStep('period'); + }; + + const getStepLabel = (step: PurchaseStep) => { + switch (step) { + case 'period': + return t('subscription.stepPeriod'); + case 'traffic': + return t('subscription.stepTraffic'); + case 'servers': + return t('subscription.stepServers'); + case 'devices': + return t('subscription.stepDevices'); + case 'confirm': + return t('subscription.stepConfirm'); + } + }; + + if (isLoading || optionsLoading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header with back link */} +
+ +

+ {subscription?.is_daily && !subscription?.is_trial + ? t('subscription.switchTariff.title') + : subscription && !subscription.is_trial + ? t('subscription.extend') + : t('subscription.getSubscription')} +

+
+ + {/* Tariffs Section */} + {isTariffsMode && tariffs.length > 0 && ( +
+ {/* Trial upgrade prompt */} + {subscription?.is_trial && ( +
+
+
+ +
+
+
+ {t('subscription.trialUpgrade.title')} +
+
+ {t('subscription.trialUpgrade.description')} +
+
+
+
+ )} + + {/* Expired subscription notice */} + {isTariffsMode && + purchaseOptions && + 'subscription_is_expired' in purchaseOptions && + purchaseOptions.subscription_is_expired && ( +
+
+
+ +
+
+
+ {t('subscription.expiredBanner.title')} +
+
+ {t('subscription.expiredBanner.selectTariff')} +
+
+
+
+ )} + + {/* Legacy subscription notice */} + {subscription && !subscription.is_trial && !subscription.tariff_id && ( +
+
+ {t('subscription.legacy.selectTariffTitle')} +
+
+ {t('subscription.legacy.selectTariffDescription')} +
+
+ {t('subscription.legacy.currentSubContinues')} +
+
+ )} + + {/* Switch Tariff Preview Modal */} + {switchTariffId && ( +
+
+

+ {t('subscription.switchTariff.title')} +

+ +
+ + {switchPreviewLoading ? ( +
+
+
+ ) : ( + switchPreview && + (() => { + const targetTariff = tariffs.find((tariff) => tariff.id === switchTariffId); + const dailyPrice = + targetTariff?.daily_price_kopeks ?? targetTariff?.price_per_day_kopeks ?? 0; + const isDailyTariff = dailyPrice > 0; + + return ( + <> +
+
+ {t('subscription.switchTariff.currentTariff')} + + {switchPreview.current_tariff_name || '-'} + +
+
+ {t('subscription.switchTariff.newTariff')} + + {switchPreview.new_tariff_name} + +
+
+ {t('subscription.switchTariff.remainingDays')} + {switchPreview.remaining_days} +
+
+ + {isDailyTariff && ( +
+
+ {t('subscription.switchTariff.dailyPayment')} +
+
+ {formatPrice(dailyPrice)} +
+
+ {t('subscription.switchTariff.dailyChargeDescription')} +
+
+ )} + +
+
+ + {t('subscription.switchTariff.upgradeCost')} + + {switchPreview.discount_percent && switchPreview.discount_percent > 0 && ( + + -{switchPreview.discount_percent}% + + )} +
+
+ {switchPreview.discount_percent && + switchPreview.discount_percent > 0 && + switchPreview.base_upgrade_cost_kopeks && + switchPreview.base_upgrade_cost_kopeks > 0 && ( + + {formatPrice(switchPreview.base_upgrade_cost_kopeks)} + + )} + + {switchPreview.upgrade_cost_kopeks > 0 + ? switchPreview.upgrade_cost_label + : t('subscription.switchTariff.free')} + +
+
+ + {!switchPreview.has_enough_balance && + switchPreview.upgrade_cost_kopeks > 0 && ( + + )} + + + + {switchTariffMutation.isError && + (() => { + const detail = + switchTariffMutation.error instanceof AxiosError + ? switchTariffMutation.error.response?.data?.detail + : null; + if ( + typeof detail === 'object' && + detail?.error_code === 'subscription_expired' + ) { + return null; + } + return ( +
+ {getErrorMessage(switchTariffMutation.error)} +
+ ); + })()} + + ); + })() + )} +
+ )} + + {!showTariffPurchase ? ( + <> + {/* Promo group discount banner */} + {tariffs.some((tariff) => tariff.promo_group_name) && ( +
+
+ + + +
+
+
+ {t('subscription.promoGroup.yourGroup', { + name: tariffs.find((tariff) => tariff.promo_group_name)?.promo_group_name, + })} +
+
+ {t('subscription.promoGroup.personalDiscountsApplied')} +
+
+
+ )} + + {/* Tariff Grid */} +
+ {[...tariffs] + .filter((tariff) => { + if (subscription?.is_trial && tariff.name.toLowerCase().includes('trial')) { + return false; + } + return true; + }) + .sort((a, b) => { + const aIsCurrent = a.is_current || a.id === subscription?.tariff_id; + const bIsCurrent = b.is_current || b.id === subscription?.tariff_id; + if (aIsCurrent && !bIsCurrent) return -1; + if (!aIsCurrent && bIsCurrent) return 1; + return 0; + }) + .map((tariff) => { + const isCurrentTariff = + tariff.is_current || tariff.id === subscription?.tariff_id; + const isSubscriptionExpired = + isTariffsMode && + purchaseOptions && + 'subscription_is_expired' in purchaseOptions && + purchaseOptions.subscription_is_expired === true; + const canSwitch = + subscription && + subscription.tariff_id && + !isCurrentTariff && + !subscription.is_trial && + !isSubscriptionExpired && + subscription.is_active; + const isLegacySubscription = + subscription && !subscription.is_trial && !subscription.tariff_id; + + return ( +
+
+
+
{tariff.name}
+ {tariff.description && ( +
+ {tariff.description} +
+ )} +
+ {isCurrentTariff && ( + + {t('subscription.currentTariff')} + + )} +
+
+
+ + + + + {tariff.traffic_limit_label} + +
+
+ + + + + {t('subscription.devices', { count: tariff.device_limit })} + +
+ {tariff.traffic_reset_mode && + tariff.traffic_reset_mode !== 'NO_RESET' && ( +
+ + + + + {t(`subscription.trafficReset.${tariff.traffic_reset_mode}`)} + +
+ )} +
+ {/* Price info */} +
+ {(() => { + const dailyPrice = + tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0; + const originalDailyPrice = tariff.original_daily_price_kopeks || 0; + if (dailyPrice > 0) { + const promoDaily = applyPromoDiscount( + dailyPrice, + originalDailyPrice > dailyPrice ? originalDailyPrice : undefined, + ); + return ( + + + {formatPrice(promoDaily.price)} + + {promoDaily.original && + promoDaily.original > promoDaily.price && ( + + {formatPrice(promoDaily.original)} + + )} + {t('subscription.tariff.perDay')} + {promoDaily.percent && promoDaily.percent > 0 && ( + + -{promoDaily.percent}% + + )} + + ); + } + if (tariff.periods.length > 0) { + const firstPeriod = tariff.periods[0]; + const promoPeriod = applyPromoDiscount( + firstPeriod?.price_kopeks || 0, + firstPeriod?.original_price_kopeks, + ); + return ( + + {t('subscription.from')} + + {formatPrice(promoPeriod.price)} + + {promoPeriod.original && + promoPeriod.original > promoPeriod.price && ( + + {formatPrice(promoPeriod.original)} + + )} + {promoPeriod.percent && promoPeriod.percent > 0 && ( + + -{promoPeriod.percent}% + + )} + + ); + } + return ( + + {t('subscription.tariff.flexiblePayment')} + + ); + })()} +
+ + {/* Action Buttons */} +
+ {isCurrentTariff ? ( + subscription?.is_daily ? ( +
+ {t('subscription.currentTariff')} +
+ ) : ( + + ) + ) : isLegacySubscription ? ( + + ) : canSwitch ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ + ) : ( + selectedTariff && ( + /* Tariff Purchase Form */ +
+
+

{selectedTariff.name}

+ +
+ + {/* Tariff Info */} +
+
+
+ {t('subscription.traffic')}: + + {selectedTariff.traffic_limit_label} + +
+
+ {t('subscription.devices')}: + + {selectedTariff.device_limit} + {selectedTariff.extra_devices_count > 0 && ( + + (+{selectedTariff.extra_devices_count}) + + )} + +
+
+
+ + {/* Daily Tariff Purchase */} + {selectedTariff.is_daily || + (selectedTariff.daily_price_kopeks && selectedTariff.daily_price_kopeks > 0) ? ( +
+
+
+ {t('subscription.dailyPurchase.costPerDay')} +
+
+ {formatPrice(selectedTariff.daily_price_kopeks || 0)} +
+
+
+
+ + {t('subscription.dailyPurchase.chargedDaily')} +
+
+ + {t('subscription.dailyPurchase.canPause')} +
+
+ + {t('subscription.dailyPurchase.pausedOnLowBalance')} +
+
+ + {(() => { + const dailyPrice = selectedTariff.daily_price_kopeks || 0; + const hasEnoughBalance = + purchaseOptions && dailyPrice <= purchaseOptions.balance_kopeks; + + return ( +
+ {purchaseOptions && !hasEnoughBalance && ( + + )} + + + + {tariffPurchaseMutation.isError && + !getInsufficientBalanceError(tariffPurchaseMutation.error) && ( +
+ {getErrorMessage(tariffPurchaseMutation.error)} +
+ )} + {tariffPurchaseMutation.isError && + getInsufficientBalanceError(tariffPurchaseMutation.error) && ( +
+ +
+ )} +
+ ); + })()} +
+ ) : ( + <> + {/* Period Selection for non-daily tariffs */} +
+
+ {t('subscription.selectPeriod')} +
+ + {selectedTariff.periods.length > 0 && !useCustomDays && ( +
+ {selectedTariff.periods.map((period) => { + const promoPeriod = applyPromoDiscount( + period.price_kopeks, + period.original_price_kopeks, + ); + const displayDiscount = promoPeriod.percent; + const displayOriginal = promoPeriod.original; + const displayPrice = promoPeriod.price; + const displayPerMonth = + displayPrice !== period.price_kopeks + ? Math.round(displayPrice / Math.max(1, period.days / 30)) + : period.price_per_month_kopeks; + + return ( + + ); + })} +
+ )} + + {/* Custom days option */} + {selectedTariff.custom_days_enabled && + (selectedTariff.price_per_day_kopeks ?? 0) > 0 && ( +
+
+ + {t('subscription.customDays.title')} + + +
+ {useCustomDays && ( +
+
+ setCustomDays(parseInt(e.target.value))} + className="flex-1 accent-accent-500" + /> + + setCustomDays( + Math.max( + selectedTariff.min_days ?? 1, + Math.min( + selectedTariff.max_days ?? 365, + parseInt(e.target.value) || + (selectedTariff.min_days ?? 1), + ), + ), + ) + } + className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100" + /> +
+ {(() => { + const basePrice = + customDays * (selectedTariff.price_per_day_kopeks ?? 0); + const existingOriginal = + selectedTariff.original_price_per_day_kopeks && + selectedTariff.original_price_per_day_kopeks > + (selectedTariff.price_per_day_kopeks ?? 0) + ? customDays * selectedTariff.original_price_per_day_kopeks + : undefined; + const promoCustom = applyPromoDiscount( + basePrice, + existingOriginal, + ); + return ( +
+ + {t('subscription.days', { count: customDays })} ×{' '} + {formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/ + {t('subscription.customDays.perDay')} + +
+ + {formatPrice(promoCustom.price)} + + {promoCustom.original && + promoCustom.original > promoCustom.price && ( + <> + + {formatPrice(promoCustom.original)} + + + -{promoCustom.percent}% + + + )} +
+
+ ); + })()} +
+ )} +
+ )} +
+ + {/* Custom traffic option */} + {selectedTariff.custom_traffic_enabled && + (selectedTariff.traffic_price_per_gb_kopeks ?? 0) > 0 && ( +
+
+ {t('subscription.customTraffic.label')} +
+
+
+ + {t('subscription.customTraffic.selectVolume')} + + +
+ {!useCustomTraffic && ( +
+ {t('subscription.customTraffic.default', { + label: selectedTariff.traffic_limit_label, + })} +
+ )} + {useCustomTraffic && ( +
+
+ setCustomTrafficGb(parseInt(e.target.value))} + className="flex-1 accent-accent-500" + /> +
+ + setCustomTrafficGb( + Math.max( + selectedTariff.min_traffic_gb ?? 1, + Math.min( + selectedTariff.max_traffic_gb ?? 1000, + parseInt(e.target.value) || + (selectedTariff.min_traffic_gb ?? 1), + ), + ), + ) + } + className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100" + /> + {t('common.units.gb')} +
+
+
+ + {customTrafficGb} {t('common.units.gb')} ×{' '} + {formatPrice(selectedTariff.traffic_price_per_gb_kopeks ?? 0)}/ + {t('common.units.gb')} + + + + + {formatPrice( + customTrafficGb * + (selectedTariff.traffic_price_per_gb_kopeks ?? 0), + )} + +
+
+ )} +
+
+ )} + + {/* Summary & Purchase */} + {(selectedTariffPeriod || useCustomDays) && ( +
+ {(() => { + const basePeriodPrice = useCustomDays + ? customDays * (selectedTariff.price_per_day_kopeks ?? 0) + : selectedTariffPeriod?.price_kopeks || 0; + const existingPeriodOriginal = useCustomDays + ? selectedTariff.original_price_per_day_kopeks && + selectedTariff.original_price_per_day_kopeks > + (selectedTariff.price_per_day_kopeks ?? 0) + ? customDays * selectedTariff.original_price_per_day_kopeks + : undefined + : selectedTariffPeriod?.original_price_kopeks && + selectedTariffPeriod.original_price_kopeks > + selectedTariffPeriod.price_kopeks + ? selectedTariffPeriod.original_price_kopeks + : undefined; + const promoPeriod = applyPromoDiscount( + basePeriodPrice, + existingPeriodOriginal, + ); + + const trafficPrice = + useCustomTraffic && selectedTariff.custom_traffic_enabled + ? customTrafficGb * (selectedTariff.traffic_price_per_gb_kopeks ?? 0) + : 0; + + const totalPrice = promoPeriod.price + trafficPrice; + const originalTotal = promoPeriod.original + ? promoPeriod.original + trafficPrice + : null; + + return ( + <> +
+ {useCustomDays ? ( +
+ + {t('subscription.stepPeriod')}:{' '} + {t('subscription.days', { count: customDays })} + +
+ {formatPrice(promoPeriod.price)} + {promoPeriod.original && + promoPeriod.original > promoPeriod.price && ( + + {formatPrice(promoPeriod.original)} + + )} +
+
+ ) : ( + selectedTariffPeriod && ( + <> + {(selectedTariffPeriod.extra_devices_count ?? 0) > 0 && + selectedTariffPeriod.base_tariff_price_kopeks ? ( + <> +
+ + {t('subscription.baseTariff')}:{' '} + {selectedTariffPeriod.label} + + + {formatPrice( + selectedTariffPeriod.base_tariff_price_kopeks, + )} + +
+
+ + {t('subscription.extraDevices')} ( + {selectedTariffPeriod.extra_devices_count}) + + + + + {formatPrice( + selectedTariffPeriod.extra_devices_cost_kopeks ?? 0, + )} + +
+ + ) : ( +
+ + {t('subscription.summary.period', { + label: selectedTariffPeriod.label, + })} + +
+ {formatPrice(promoPeriod.price)} + {promoPeriod.original && + promoPeriod.original > promoPeriod.price && ( + + {formatPrice(promoPeriod.original)} + + )} +
+
+ )} + + ) + )} + {useCustomTraffic && selectedTariff.custom_traffic_enabled && ( +
+ + {t('subscription.summary.traffic', { gb: customTrafficGb })} + + +{formatPrice(trafficPrice)} +
+ )} +
+ + {promoPeriod.percent && ( +
+ + {t('promo.discountApplied')} -{promoPeriod.percent}% + +
+ )} + +
+ + {t('subscription.total')} + +
+ + {formatPrice(totalPrice)} + + {originalTotal && ( +
+ {formatPrice(originalTotal)} +
+ )} +
+
+ + + + ); + })()} + + {tariffPurchaseMutation.isError && + !getInsufficientBalanceError(tariffPurchaseMutation.error) && ( +
+ {getErrorMessage(tariffPurchaseMutation.error)} +
+ )} + {tariffPurchaseMutation.isError && + getInsufficientBalanceError(tariffPurchaseMutation.error) && ( +
+ +
+ )} +
+ )} + + )} +
+ ) + )} +
+ )} + + {/* Purchase/Extend Section - Classic Mode */} + {classicOptions && classicOptions.periods.length > 0 && ( +
+
+

+ {subscription && !subscription.is_trial + ? t('subscription.extend') + : t('subscription.getSubscription')} +

+ {!showPurchaseForm && ( + + )} +
+ + {showPurchaseForm && ( +
+ {/* Step Indicator */} +
+
+ {t('subscription.step', { current: currentStepIndex + 1, total: steps.length })} +
+
+ {steps.map((step, idx) => ( +
+ ))} +
+
+ +
+ {getStepLabel(currentStep)} +
+ + {/* Step: Period Selection */} + {currentStep === 'period' && classicOptions && ( +
+ {classicOptions.periods.map((period) => { + const promoPeriod = applyPromoDiscount( + period.price_kopeks, + period.original_price_kopeks, + ); + + return ( + + ); + })} +
+ )} + + {/* Step: Traffic Selection */} + {currentStep === 'traffic' && selectedPeriod?.traffic.options && ( +
+ {selectedPeriod.traffic.options.map((option) => { + const promoTraffic = applyPromoDiscount( + option.price_kopeks, + option.original_price_kopeks, + ); + + return ( + + ); + })} +
+ )} + + {/* Step: Server Selection */} + {currentStep === 'servers' && selectedPeriod?.servers.options && ( +
+ {selectedPeriod.servers.options + .filter((server) => { + if (!server.is_available) return false; + if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) { + return false; + } + return true; + }) + .map((server) => { + const promoServer = applyPromoDiscount( + server.price_kopeks, + server.original_price_kopeks, + ); + + return ( + + ); + })} +
+ )} + + {/* Step: Device Selection */} + {currentStep === 'devices' && selectedPeriod && ( +
+
+ +
+
{selectedDevices}
+
{t('subscription.devices')}
+
+ +
+
+
+ {t('subscription.devicesFree', { count: selectedPeriod.devices.min })} +
+ {selectedPeriod.devices.max > selectedPeriod.devices.min && ( +
+ {formatPrice(selectedPeriod.devices.price_per_device_kopeks)}{' '} + {t('subscription.perExtraDevice')} +
+ )} +
+
+ )} + + {/* Step: Confirm */} + {currentStep === 'confirm' && ( +
+ {previewLoading ? ( +
+
+
+ ) : preview ? ( +
+ {activeDiscount?.is_active && activeDiscount.discount_percent && ( +
+ + + + + {t('promo.discountApplied')} -{activeDiscount.discount_percent}% + +
+ )} + + {preview.breakdown.map((item, idx) => ( +
+ {item.label} + {item.value} +
+ ))} + + {(() => { + const promoTotal = applyPromoDiscount( + preview.total_price_kopeks, + preview.original_price_kopeks, + ); + + return ( +
+ + {t('subscription.total')} + +
+
+ {formatPrice(promoTotal.price)} +
+ {promoTotal.original && promoTotal.original > promoTotal.price && ( +
+ {formatPrice(promoTotal.original)} +
+ )} +
+
+ ); + })()} + + {preview.discount_label && ( +
+ {preview.discount_label} +
+ )} + + {!preview.can_purchase && + (preview.missing_amount_kopeks > 0 ? ( + + ) : preview.status_message ? ( +
+ {preview.status_message} +
+ ) : null)} +
+ ) : null} +
+ )} + + {/* Navigation Buttons */} +
+ {!isFirstStep && ( + + )} + + {isFirstStep && ( + + )} + + {!isLastStep ? ( + + ) : ( + + )} +
+ + {purchaseMutation.isError && ( +
+ {getErrorMessage(purchaseMutation.error)} +
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/utils/subscriptionHelpers.ts b/src/utils/subscriptionHelpers.ts new file mode 100644 index 0000000..8be7f94 --- /dev/null +++ b/src/utils/subscriptionHelpers.ts @@ -0,0 +1,42 @@ +import { AxiosError } from 'axios'; +import i18n from '../i18n'; + +export type PurchaseStep = 'period' | 'traffic' | 'servers' | 'devices' | 'confirm'; + +export const getErrorMessage = (error: unknown): string => { + if (error instanceof AxiosError) { + const detail = error.response?.data?.detail; + if (typeof detail === 'string') return detail; + if (typeof detail === 'object' && detail?.message) return detail.message; + } + if (error instanceof Error) return error.message; + return i18n.t('common.error'); +}; + +export const getInsufficientBalanceError = ( + error: unknown, +): { required: number; balance: number; missingAmount?: number } | null => { + if (error instanceof AxiosError) { + const detail = error.response?.data?.detail; + if ( + typeof detail === 'object' && + (detail?.code === 'insufficient_balance' || detail?.code === 'insufficient_funds') + ) { + return { + required: detail.required || detail.total_price || 0, + balance: detail.balance || 0, + missingAmount: detail.missing_amount || detail.missingAmount || 0, + }; + } + } + return null; +}; + +export const getFlagEmoji = (countryCode: string): string => { + if (!countryCode || countryCode.length !== 2) return ''; + const codePoints = countryCode + .toUpperCase() + .split('') + .map((char) => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); +}; From 0bc817fa7f201e9176a586bb1b5c0a68c9406bf6 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 06:52:31 +0300 Subject: [PATCH 10/38] fix: move CTA button above additional options section --- src/pages/Subscription.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 5a9f9f1..1402449 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1104,6 +1104,9 @@ export default function Subscription() {
)} + {/* Purchase / Renewal CTA */} + + {/* Additional Options (Buy Devices) */} {subscription && subscription.is_active && !subscription.is_trial && (
)} - - {/* Purchase / Renewal CTA */} -
); } From bdc201b5ea5e359a7f9d97bd86202be35654a7fe Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 07:02:59 +0300 Subject: [PATCH 11/38] fix: eliminate hover flickering across all pages - Extract CountdownTimer into React.memo component to isolate 1s interval re-renders from the full Subscription page - Add box-shadow transition to .hover-border-gradient (was instant) - Remove conflicting p-[1.5px] from PurchaseCTAButton (CSS border already handles 1.5px) - Replace transition-all with specific properties on bento-card-hover, bento-card-glow, device dots, toggle switches, progress bars - Reduce translateY hover from -4px to -2px to prevent cursor losing hover target during fast vertical mouse movement --- .../subscription/PurchaseCTAButton.tsx | 4 +- src/pages/Subscription.tsx | 290 ++++++++++-------- src/styles/globals.css | 25 +- 3 files changed, 174 insertions(+), 145 deletions(-) diff --git a/src/components/subscription/PurchaseCTAButton.tsx b/src/components/subscription/PurchaseCTAButton.tsx index 7b8be09..b9e0f2f 100644 --- a/src/components/subscription/PurchaseCTAButton.tsx +++ b/src/components/subscription/PurchaseCTAButton.tsx @@ -32,10 +32,10 @@ export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonPro
; +}) { + const { t } = useTranslation(); + const [countdown, setCountdown] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 }); + + useEffect(() => { + const endTime = new Date(endDate).getTime(); + const tick = () => { + const diff = Math.max(0, endTime - Date.now()); + setCountdown({ + days: Math.floor(diff / 86_400_000), + hours: Math.floor((diff % 86_400_000) / 3_600_000), + minutes: Math.floor((diff % 3_600_000) / 60_000), + seconds: Math.floor((diff % 60_000) / 1_000), + }); + }; + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [endDate]); + + const isExpired = !isActive; + const isUrgent = countdown.days <= 3; + + const formattedDate = new Date(endDate).toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + + return ( +
+
+
+ +
+ {t('dashboard.remaining')} +
+ {isExpired ? ( +
+ {t('subscription.expired')} +
+ ) : ( +
+ {countdown.days > 0 && ( + <> + + {countdown.days} + + + {t('subscription.daysShort')} + + + )} + + {String(countdown.hours).padStart(2, '0')} + + + : + + + {String(countdown.minutes).padStart(2, '0')} + + + : + + + {String(countdown.seconds).padStart(2, '0')} + +
+ )} +
+ {t('subscription.expiresAt')}: {formattedDate} +
+
+ ); +}); + export default function Subscription() { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -61,25 +201,6 @@ export default function Subscription() { // Extract subscription from response (null if no subscription) const subscription = subscriptionResponse?.subscription ?? null; - // Live countdown timer - const [countdown, setCountdown] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 }); - useEffect(() => { - if (!subscription?.end_date) return; - const endTime = new Date(subscription.end_date).getTime(); - const tick = () => { - const diff = Math.max(0, endTime - Date.now()); - setCountdown({ - days: Math.floor(diff / 86_400_000), - hours: Math.floor((diff % 86_400_000) / 3_600_000), - minutes: Math.floor((diff % 3_600_000) / 60_000), - seconds: Math.floor((diff % 60_000) / 1_000), - }); - }; - tick(); - const id = setInterval(tick, 1000); - return () => clearInterval(id); - }, [subscription?.end_date]); - // Purchase options (needed for balance_kopeks in device/traffic/server management) const { data: purchaseOptions } = useQuery({ queryKey: ['purchase-options'], @@ -316,7 +437,6 @@ export default function Subscription() { (trafficData?.is_unlimited ?? false) || subscription.traffic_limit_gb === 0; const zone = getTrafficZone(usedPercent); const connectedDevices = devicesData?.total ?? 0; - const formattedDate = new Date(subscription.end_date).toLocaleDateString(); return (
(
- - - + {isAdmin && ( + <> + {/* Separator before admin */} +
+ + + {t('admin.nav.title')} + + + )} + + + {/* Right side actions */} +
+ + + + +
-
- + - {/* Mobile Header */} - {}} - headerHeight={headerHeight} - isFullscreen={isMobileFullscreen} - safeAreaInset={safeAreaInset} - contentSafeAreaInset={contentSafeAreaInset} - telegramPlatform={platform} - wheelEnabled={wheelEnabled} - referralEnabled={referralEnabled} - hasContests={hasContests} - hasPolls={hasPolls} - /> + {/* Mobile Header */} + {}} + headerHeight={headerHeight} + isFullscreen={isMobileFullscreen} + safeAreaInset={safeAreaInset} + contentSafeAreaInset={contentSafeAreaInset} + telegramPlatform={platform} + wheelEnabled={wheelEnabled} + referralEnabled={referralEnabled} + hasContests={hasContests} + hasPolls={hasPolls} + /> - {/* Desktop spacer */} -
+ {/* Desktop spacer */} +
- {/* Mobile spacer */} -
+ {/* Mobile spacer */} +
- {/* Main content */} -
{children}
+ {/* Main content */} +
{children}
- {/* Mobile Bottom Navigation */} - + {/* Mobile Bottom Navigation */} + +
); } diff --git a/src/styles/globals.css b/src/styles/globals.css index 4f46194..b61e32b 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -268,10 +268,10 @@ } } - /* Optimize main scrollable content */ + /* Main content — containment removed to avoid GPU layer conflicts + with isolation: isolate on content wrapper */ main { - /* Prevent layout shifts during scroll */ - contain: layout style; + contain: content; } /* Light theme - Champagne */ From 12c97a2c5ebf0d3dc776f581589a9d4280fbdc2e Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 08:14:20 +0300 Subject: [PATCH 14/38] fix: render animated background via portal at z-index:-1 to stop implicit compositing Previous isolation:isolate fix didn't work because: - position:fixed elements still see through isolation boundary - backdrop-filter samples all rendered content regardless of stacking context - The real issue: animated background at z-index:0 with will-change:transform forces Chrome to implicitly composite EVERY overlapping element Fix: render BackgroundRenderer via createPortal on document.body with z-index:-1, placing it below the root stacking context. This eliminates implicit compositing entirely. Also: - Remove backdrop-blur-xl from desktop header (was resampling bg 60fps) - Remove will-change:opacity from card ::after pseudo-element - Replace transition-all with transition-colors on header buttons --- .../backgrounds/BackgroundRenderer.tsx | 16 +- src/components/layout/AppShell/AppShell.tsx | 282 +++++++++--------- src/styles/globals.css | 1 - 3 files changed, 150 insertions(+), 149 deletions(-) diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index b802cf3..d9dcb27 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -1,4 +1,5 @@ import { Suspense, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { useQuery } from '@tanstack/react-query'; import { brandingApi } from '@/api/branding'; import type { AnimationConfig, BackgroundType } from '@/components/ui/backgrounds/types'; @@ -83,20 +84,25 @@ export function BackgroundRenderer() { ? reduceMobileSettings(effectiveConfig.settings) : effectiveConfig.settings; - return ( + // Render via portal on document.body with z-index: -1. + // This places the animated background BELOW the root stacking context, + // preventing Chrome's implicit compositing from promoting every + // overlapping element to its own GPU layer (the root cause of flickering). + return createPortal(
0 ? `blur(${effectiveConfig.blur}px)` : undefined, contain: 'strict', - willChange: 'transform', - transform: 'translateZ(0)', + backfaceVisibility: 'hidden', }} > -
+
, + document.body, ); } diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index bd9a2a6..4752ed0 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -280,162 +280,158 @@ export function AppShell({ children }: AppShellProps) { return (
- {/* Animated background — on its own GPU layer, below everything */} + {/* Animated background renders via portal on document.body at z-index: -1 */} - {/* Content layer — isolated from background to prevent backdrop-filter - from resampling the animated background every frame (Chromium bug) */} -
- {/* Global components */} - - - + {/* Global components */} + + + - {/* Desktop Header */} -
-
- {/* Logo */} - -
- - {logoLetter} - - {hasCustomLogo && logoUrl && ( - {appName - )} -
- {appName} - - - {/* Center Navigation */} - - - {/* Right side actions */} -
- - - - + {logoLetter} + + {hasCustomLogo && logoUrl && ( + {appName + )}
+ {appName} + + + {/* Center Navigation */} + + + {/* Right side actions */} +
+ + + +
-
+
+ - {/* Mobile Header */} - {}} - headerHeight={headerHeight} - isFullscreen={isMobileFullscreen} - safeAreaInset={safeAreaInset} - contentSafeAreaInset={contentSafeAreaInset} - telegramPlatform={platform} - wheelEnabled={wheelEnabled} - referralEnabled={referralEnabled} - hasContests={hasContests} - hasPolls={hasPolls} - /> + {/* Mobile Header */} + {}} + headerHeight={headerHeight} + isFullscreen={isMobileFullscreen} + safeAreaInset={safeAreaInset} + contentSafeAreaInset={contentSafeAreaInset} + telegramPlatform={platform} + wheelEnabled={wheelEnabled} + referralEnabled={referralEnabled} + hasContests={hasContests} + hasPolls={hasPolls} + /> - {/* Desktop spacer */} -
+ {/* Desktop spacer */} +
- {/* Mobile spacer */} -
+ {/* Mobile spacer */} +
- {/* Main content */} -
{children}
+ {/* Main content */} +
{children}
- {/* Mobile Bottom Navigation */} - -
+ {/* Mobile Bottom Navigation */} +
); } diff --git a/src/styles/globals.css b/src/styles/globals.css index b61e32b..5746cf1 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -400,7 +400,6 @@ img.twemoji { transition: opacity 0.3s ease; pointer-events: none; z-index: 10; - will-change: opacity; } .bento-card-hover:hover::after { From 7f17d95ed6f21b07fee5bd2201e1754611028209 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 08:18:24 +0300 Subject: [PATCH 15/38] fix: replace framer-motion with CSS keyframes in boxes background The boxes background was creating 225 motion.div elements, each running independent opacity animations via framer-motion JS on the main thread. This blocked hover transitions on cards, causing visible flickering. CSS @keyframes run on the compositor thread and have zero main-thread cost, eliminating the interference with user interactions. --- .../ui/backgrounds/background-boxes.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/components/ui/backgrounds/background-boxes.tsx b/src/components/ui/backgrounds/background-boxes.tsx index 766b6fd..55b0350 100644 --- a/src/components/ui/backgrounds/background-boxes.tsx +++ b/src/components/ui/backgrounds/background-boxes.tsx @@ -1,5 +1,4 @@ import React, { useMemo } from 'react'; -import { motion } from 'framer-motion'; import { sanitizeColor, clampNumber } from './types'; interface Props { @@ -37,6 +36,13 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) { return (
+ {/* CSS keyframes for box fade — runs on compositor thread, zero main-thread cost */} +
{cells.map((cell, i) => ( - ))}
From fe32322c323cce342a343c21acde9422855a9295 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 08:27:07 +0300 Subject: [PATCH 16/38] perf: remove permanent GPU layer promotion from cards to fix flickering Remove translateZ(0) from base states of .bento-card, .card, .glass and their light theme variants. Remove transform-gpu from Card.tsx. Change bentoFadeIn animation to end with transform:none instead of translateY(0). GPU promotion now only occurs during hover/active interactions, preventing Chrome from creating excessive compositing layers that cause flickering with animated backgrounds. --- src/components/data-display/Card/Card.tsx | 2 -- src/styles/globals.css | 7 +------ 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/data-display/Card/Card.tsx b/src/components/data-display/Card/Card.tsx index f96a263..a2d3c3b 100644 --- a/src/components/data-display/Card/Card.tsx +++ b/src/components/data-display/Card/Card.tsx @@ -12,8 +12,6 @@ const cardVariants = cva( 'border border-dark-700/40 bg-dark-900/70', 'rounded-[var(--bento-radius)]', 'transition-[border-color,background-color,box-shadow,transform,opacity] duration-200', - // GPU acceleration - 'transform-gpu', // Glass border inset 'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)]', ], diff --git a/src/styles/globals.css b/src/styles/globals.css index 5746cf1..0951096 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -349,7 +349,6 @@ img.twemoji { @apply border border-dark-700/40 bg-dark-900/70; border-radius: var(--bento-radius); padding: var(--bento-padding); - transform: translateZ(0); /* Glass Border - Inset Highlight */ box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05); /* Stagger animation support */ @@ -485,7 +484,6 @@ img.twemoji { .card { @apply border border-dark-700/40 bg-dark-900/70 p-5 transition-colors duration-200 sm:p-6; border-radius: var(--bento-radius); - transform: translateZ(0); box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05); } @@ -511,7 +509,6 @@ img.twemoji { /* Glass effect - Dark (optimized for mobile) */ .glass { @apply border border-dark-700/30 bg-dark-900/80; - transform: translateZ(0); } /* Enable backdrop-blur only on desktop where it's performant */ @@ -695,7 +692,6 @@ img.twemoji { .light .card { @apply border-champagne-300/50 bg-champagne-50/90 p-5 shadow-sm sm:p-6; border-radius: var(--bento-radius); - transform: translateZ(0); box-shadow: inset 0 1px 0 0 rgba(0, 0, 0, 0.03); } @@ -716,7 +712,6 @@ img.twemoji { /* Glass effect - Light (optimized for mobile) */ .light .glass { @apply border border-champagne-300/40 bg-champagne-50/90; - transform: translateZ(0); } @media (min-width: 1024px) { @@ -1110,7 +1105,7 @@ img.twemoji { } 100% { opacity: 1; - transform: translateY(0); + transform: none; } } From d89c534c0b21bff91747002a6e96bf12d114fcc2 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 08:46:22 +0300 Subject: [PATCH 17/38] fix: rewrite BackgroundBoxes from 225 DOM divs to single canvas element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: 225 individually animated DOM elements forced Chrome's compositor to create separate paint regions per element. Any hover state change on page content triggered re-compositing of all 225 regions, causing visible flickering on all pages. Canvas fix: single element renders the entire grid effect via requestAnimationFrame. Canvas content is pixel-based and cannot be affected by DOM hover state changes on other elements. Additional fixes: - Fix z-index collision: portal z-index -1 → -2 (was same as body::before noise) - Replace all transition-all with specific properties on .btn, .btn-icon, .input, .nav-item, .bottom-nav-item, checkbox - Remove backdrop-blur-sm from desktop .bento-card and .card (forced Chrome to re-sample animated background pixels on every hover) --- .../backgrounds/BackgroundRenderer.tsx | 2 +- .../ui/backgrounds/background-boxes.tsx | 186 +++++++++++++----- src/styles/globals.css | 34 ++-- 3 files changed, 160 insertions(+), 62 deletions(-) diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index d9dcb27..d712f5d 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -92,7 +92,7 @@ export function BackgroundRenderer() {
0 ? `blur(${effectiveConfig.blur}px)` : undefined, contain: 'strict', diff --git a/src/components/ui/backgrounds/background-boxes.tsx b/src/components/ui/backgrounds/background-boxes.tsx index 55b0350..62af05e 100644 --- a/src/components/ui/backgrounds/background-boxes.tsx +++ b/src/components/ui/backgrounds/background-boxes.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useRef, useMemo } from 'react'; import { sanitizeColor, clampNumber } from './types'; interface Props { @@ -16,60 +16,150 @@ const COLORS = [ '#c4b5fd', ]; +function hexToRgb(hex: string): [number, number, number] { + const v = parseInt(hex.slice(1), 16); + return [(v >> 16) & 255, (v >> 8) & 255, v & 255]; +} + +interface CellData { + rgb: [number, number, number]; + phase: number; + period: number; +} + +// Pre-computed transform constants for skewX(-48deg) skewY(14deg) scale(0.675) +const SKEW_X_TAN = Math.tan((-48 * Math.PI) / 180); +const SKEW_Y_TAN = Math.tan((14 * Math.PI) / 180); +const GRID_SCALE = 0.675; + +/** + * Animated boxes background rendered on a single element. + * + * Previous implementation used 225 DOM
elements with CSS @keyframes. + * Chrome's compositor created a separate paint region per div; any hover + * state change on page content forced Chrome to re-composite all 225 regions, + * causing visible flickering. A single canvas bypasses the DOM style/layout/paint + * pipeline entirely — hover interactions on other elements cannot trigger + * repaints of canvas content. + */ export default React.memo(function BackgroundBoxes({ settings }: Props) { + const canvasRef = useRef(null); const rows = clampNumber(settings.rows, 4, 30, 15); const cols = clampNumber(settings.cols, 4, 30, 15); const boxColor = sanitizeColor(settings.boxColor, '#818cf8'); - const cells = useMemo(() => { - const result: { color: string; delay: number; duration: number }[] = []; - for (let i = 0; i < rows * cols; i++) { - result.push({ - color: - boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor, - delay: Math.random() * 8, - duration: 3 + Math.random() * 4, - }); - } - return result; + const cells = useMemo((): CellData[] => { + return Array.from({ length: rows * cols }, () => ({ + rgb: hexToRgb( + boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor, + ), + phase: Math.random() * 8, + period: 3 + Math.random() * 4, + })); }, [rows, cols, boxColor]); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let animId = 0; + + const resize = () => { + const dpr = devicePixelRatio || 1; + const parent = canvas.parentElement; + if (!parent) return; + const rect = parent.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + }; + + resize(); + window.addEventListener('resize', resize); + + const draw = (now: number) => { + const t = now * 0.001; + const w = canvas.width; + const h = canvas.height; + + ctx.clearRect(0, 0, w, h); + ctx.save(); + + // Transform origin: center of viewport + const cx = w / 2; + const cy = h / 2; + ctx.translate(cx, cy); + + // Replicate CSS: skewX(-48deg) skewY(14deg) scale(0.675) + ctx.transform(1, 0, SKEW_X_TAN, 1, 0, 0); + ctx.transform(1, SKEW_Y_TAN, 0, 1, 0, 0); + ctx.scale(GRID_SCALE, GRID_SCALE); + + ctx.translate(-cx, -cy); + + // Grid: 300% of viewport, offset by -100% (matches the original CSS layout) + const gw = w * 3; + const gh = h * 3; + const ox = -w; + const oy = -h; + const cellW = gw / cols; + const cellH = gh / rows; + + // Draw colored cells + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + const cycleT = ((t + cell.phase) % cell.period) / cell.period; + const alpha = 0.15 * Math.sin(cycleT * Math.PI); + if (alpha < 0.005) continue; + + const col = i % cols; + const row = (i - col) / cols; + const [r, g, b] = cell.rgb; + + ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`; + ctx.fillRect(ox + col * cellW, oy + row * cellH, cellW, cellH); + } + + // Draw grid lines as a single batch (much cheaper than 225 individual strokeRects) + ctx.strokeStyle = 'rgba(51,65,85,0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); + + for (let r = 0; r <= rows; r++) { + const y = oy + r * cellH; + ctx.moveTo(ox, y); + ctx.lineTo(ox + gw, y); + } + for (let c = 0; c <= cols; c++) { + const x = ox + c * cellW; + ctx.moveTo(x, oy); + ctx.lineTo(x, oy + gh); + } + + ctx.stroke(); + ctx.restore(); + + animId = requestAnimationFrame(draw); + }; + + animId = requestAnimationFrame(draw); + + return () => { + cancelAnimationFrame(animId); + window.removeEventListener('resize', resize); + }; + }, [cells, rows, cols]); + return ( -
- {/* CSS keyframes for box fade — runs on compositor thread, zero main-thread cost */} - -
- {cells.map((cell, i) => ( -
- ))} -
-
+ ); }); diff --git a/src/styles/globals.css b/src/styles/globals.css index 0951096..8ad536d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -360,7 +360,7 @@ img.twemoji { @media (min-width: 1024px) { .bento-card { - @apply bg-dark-900/50 backdrop-blur-sm; + @apply bg-dark-900/60; } } @@ -455,7 +455,7 @@ img.twemoji { @media (min-width: 1024px) { .light .bento-card { - @apply bg-champagne-50/80 backdrop-blur-sm; + @apply bg-champagne-50/85; } } @@ -487,10 +487,9 @@ img.twemoji { box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05); } - /* Enable backdrop-blur only on desktop */ @media (min-width: 1024px) { .card { - @apply bg-dark-900/50 backdrop-blur-sm; + @apply bg-dark-900/60; } } @@ -520,7 +519,8 @@ img.twemoji { /* Buttons - Dark */ .btn { - @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 ease-smooth focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-900 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50; + @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium duration-200 ease-smooth focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-900 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50; + transition-property: color, background-color, border-color, box-shadow, transform, opacity; } .btn-primary { @@ -540,7 +540,8 @@ img.twemoji { } .btn-icon { - @apply rounded-lg p-2 text-dark-400 transition-all duration-200 hover:bg-dark-800 hover:text-dark-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 active:scale-95; + @apply rounded-lg p-2 text-dark-400 duration-200 hover:bg-dark-800 hover:text-dark-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 active:scale-95; + transition-property: color, background-color, transform; } /* Highlighted button for onboarding */ @@ -550,7 +551,8 @@ img.twemoji { /* Inputs - Dark */ .input { - @apply w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-100 placeholder-dark-500 transition-all duration-200 focus:border-accent-500/50 focus:outline-none focus:ring-2 focus:ring-accent-500/20; + @apply w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-100 placeholder-dark-500 duration-200 focus:border-accent-500/50 focus:outline-none focus:ring-2 focus:ring-accent-500/20; + transition-property: color, background-color, border-color, box-shadow; } .input-error { @@ -613,7 +615,8 @@ img.twemoji { } .progress-fill { - @apply h-full rounded-full transition-all duration-500 ease-smooth; + @apply h-full rounded-full duration-500 ease-smooth; + transition-property: width, background-color; } /* Stat card - Dark */ @@ -627,7 +630,8 @@ img.twemoji { /* Navigation - Dark */ .nav-item { - @apply flex items-center gap-3 rounded-xl px-4 py-3 text-dark-400 transition-all duration-200 hover:bg-dark-800/50 hover:text-dark-100; + @apply flex items-center gap-3 rounded-xl px-4 py-3 text-dark-400 duration-200 hover:bg-dark-800/50 hover:text-dark-100; + transition-property: color, background-color; } .nav-item-active { @@ -654,7 +658,8 @@ img.twemoji { } .bottom-nav-item { - @apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl px-3 py-2.5 text-dark-500 transition-all duration-200; + @apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl px-3 py-2.5 text-dark-500 duration-200; + transition-property: color, background-color; } .bottom-nav-item:hover { @@ -662,7 +667,8 @@ img.twemoji { } .bottom-nav-item-active { - @apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl bg-accent-500/15 px-3 py-2.5 text-accent-400 transition-all duration-200; + @apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl bg-accent-500/15 px-3 py-2.5 text-accent-400 duration-200; + transition-property: color, background-color; } /* Divider - Dark */ @@ -697,7 +703,7 @@ img.twemoji { @media (min-width: 1024px) { .light .card { - @apply bg-champagne-50/80 backdrop-blur-sm; + @apply bg-champagne-50/85; } } @@ -1252,7 +1258,9 @@ input[type='checkbox'] { border-radius: 0.375rem; background-color: rgb(var(--color-dark-700)); cursor: pointer; - transition: all 0.15s ease; + transition: + border-color 0.15s ease, + background-color 0.15s ease; position: relative; flex-shrink: 0; } From 96bcc76d695ee7e26ad2538e1733f439e6a2983b Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 08:51:56 +0300 Subject: [PATCH 18/38] fix: prevent countdown timer overflow on narrow mobile screens Add min-w-0 + overflow-hidden to the countdown card to prevent it from blowing out the grid column. Reduce number font from 20px to 18px on mobile (20px on sm+), tighten gaps and colon sizes to fit within the 50% grid column on 375px screens. --- src/pages/Subscription.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 52733bf..ec29ae3 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -60,7 +60,7 @@ const CountdownTimer = memo(function CountdownTimer({ return (
) : ( -
+
{countdown.days > 0 && ( <> {countdown.days} - + {t('subscription.daysShort')} )} {String(countdown.hours).padStart(2, '0')} : {String(countdown.minutes).padStart(2, '0')} : {String(countdown.seconds).padStart(2, '0')} From d567817e0564f2438d4192eb7b2321e1725da266 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 09:27:36 +0300 Subject: [PATCH 19/38] fix: show progress bar instead of dots when device_limit > 10 Match the Dashboard behavior: show individual dots for <= 10 devices, switch to a compact progress bar for higher limits (e.g. 18 devices). Prevents dot overflow breaking the connect-device card layout on mobile. --- src/pages/Subscription.tsx | 41 ++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index ec29ae3..fcef62e 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -692,18 +692,37 @@ export default function Subscription() { })}
- )} - {/* ─── Stats Row ─── */} -
- {/* Countdown timer — isolated to prevent 1s re-renders of entire page */} + {/* ─── Countdown ─── */} +
- - {/* Devices */} -
-
-
- -
- {t('subscription.devices')} -
-
- - {connectedDevices} - - - / {subscription.device_limit} - -
-
{/* ─── Locations ─── */} From d2f02d605c5990bc88fbade5f6fa6e7624abd70b Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 09:38:33 +0300 Subject: [PATCH 21/38] fix: clean up expired trial card - remove redundant badge and subtitle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove red "Пробный период" badge from expired trial card - Remove "Пробный период завершен" subtitle text - Simplify header layout (no justify-between needed without badge) - Change "Тарифы" button link to /subscription/purchase --- .../dashboard/SubscriptionCardExpired.tsx | 84 +++++-------------- 1 file changed, 22 insertions(+), 62 deletions(-) diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 57f5329..dce57ef 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -50,73 +50,33 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa /> {/* Header */} -
-
- {/* Clock icon */} -
- -
-
-

- {subscription.is_trial - ? t('dashboard.expired.trialTitle') - : t('dashboard.expired.title')} -

- - {subscription.is_trial - ? t('dashboard.expired.trialSubtitle') - : t('dashboard.expired.paidSubtitle')} - -
-
- - {/* Badge */} +
+ {/* Clock icon */}
- {subscription.is_trial && ( - - )} - {subscription.is_trial ? t('subscription.trialStatus') : t('subscription.expired')} +
+

+ {subscription.is_trial ? t('dashboard.expired.trialTitle') : t('dashboard.expired.title')} +

{/* Expired date */} @@ -149,7 +109,7 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa {t('dashboard.expired.renew')} Date: Fri, 27 Feb 2026 09:48:28 +0300 Subject: [PATCH 22/38] feat: add reset traffic toggle on tariff switch - Add reset_traffic parameter to previewTariffSwitch and switchTariff API calls - Add reset_traffic_available/reset_traffic_default fields to TariffSwitchPreview type - Add toggle switch in tariff switch preview modal (shown when backend enables it) - Initialize toggle from server default (reset_traffic_default) - Pass reset_traffic flag on switch execution - Add translations for all 4 locales (ru, en, zh, fa) --- src/api/subscription.ts | 9 ++++++- src/locales/en.json | 4 ++- src/locales/fa.json | 4 ++- src/locales/ru.json | 4 ++- src/locales/zh.json | 4 ++- src/pages/SubscriptionPurchase.tsx | 42 +++++++++++++++++++++++++++++- src/types/index.ts | 3 +++ 7 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 99fe63d..857d86f 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -345,6 +345,7 @@ export const subscriptionApi = { // Preview tariff switch cost previewTariffSwitch: async ( tariffId: number, + resetTraffic?: boolean, ): Promise<{ can_switch: boolean; current_tariff_id: number | null; @@ -364,10 +365,14 @@ export const subscriptionApi = { base_upgrade_cost_kopeks?: number; discount_percent?: number; discount_kopeks?: number; + // Traffic reset on switch + reset_traffic_available?: boolean; + reset_traffic_default?: boolean; }> => { const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', { tariff_id: tariffId, - period_days: 30, // Default period for switch + period_days: 30, + ...(resetTraffic !== undefined && { reset_traffic: resetTraffic }), }); return response.data; }, @@ -375,6 +380,7 @@ export const subscriptionApi = { // Switch to a different tariff switchTariff: async ( tariffId: number, + resetTraffic?: boolean, ): Promise<{ success: boolean; message: string; @@ -389,6 +395,7 @@ export const subscriptionApi = { const response = await apiClient.post('/cabinet/subscription/tariff/switch', { tariff_id: tariffId, period_days: 30, + ...(resetTraffic !== undefined && { reset_traffic: resetTraffic }), }); return response.data; }, diff --git a/src/locales/en.json b/src/locales/en.json index e7d818b..1c614a2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -483,7 +483,9 @@ "switched": "Tariff switched", "notEnoughBalance": "Insufficient balance", "dailyPayment": "Daily payment", - "dailyChargeDescription": "Charged daily from your balance" + "dailyChargeDescription": "Charged daily from your balance", + "resetTraffic": "Reset traffic", + "resetTrafficHint": "Reset used traffic when switching tariff" }, "switchTraffic": { "title": "Change Traffic", diff --git a/src/locales/fa.json b/src/locales/fa.json index 08b1833..933a10b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -378,7 +378,9 @@ "remainingDays": "روزهای باقی‌مانده", "switch": "تغییر", "title": "تغییر تعرفه", - "upgradeCost": "هزینه ارتقا" + "upgradeCost": "هزینه ارتقا", + "resetTraffic": "بازنشانی ترافیک", + "resetTrafficHint": "بازنشانی ترافیک مصرفی هنگام تغییر تعرفه" }, "promoGroup": { "yourGroup": "گروه شما: {{name}}", diff --git a/src/locales/ru.json b/src/locales/ru.json index b4cae25..6631864 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -506,7 +506,9 @@ "switched": "Тариф изменён", "notEnoughBalance": "Недостаточно средств", "dailyPayment": "Оплата за день", - "dailyChargeDescription": "Списывается ежедневно с баланса" + "dailyChargeDescription": "Списывается ежедневно с баланса", + "resetTraffic": "Сбросить трафик", + "resetTrafficHint": "Обнулить использованный трафик при смене тарифа" }, "switchTraffic": { "title": "Изменить трафик", diff --git a/src/locales/zh.json b/src/locales/zh.json index 122b6ae..0faad4f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -378,7 +378,9 @@ "remainingDays": "剩余天数", "switch": "切换", "title": "切换套餐", - "upgradeCost": "升级费用" + "upgradeCost": "升级费用", + "resetTraffic": "重置流量", + "resetTrafficHint": "切换套餐时重置已使用流量" }, "promoGroup": { "yourGroup": "您的组:{{name}}", diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index 3aa99ce..991c501 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -128,12 +128,14 @@ export default function SubscriptionPurchase() { // Tariff switch const [switchTariffId, setSwitchTariffId] = useState(null); + const [resetTrafficOnSwitch, setResetTrafficOnSwitch] = useState(false); // Auto-close all modals on success notification const handleCloseAllModals = () => { setShowPurchaseForm(false); setShowTariffPurchase(false); setSwitchTariffId(null); + setResetTrafficOnSwitch(false); setSelectedTariff(null); setSelectedTariffPeriod(null); }; @@ -230,13 +232,21 @@ export default function SubscriptionPurchase() { enabled: !!switchTariffId, }); + // Initialize reset traffic toggle from server default + useEffect(() => { + if (switchPreview?.reset_traffic_default !== undefined) { + setResetTrafficOnSwitch(switchPreview.reset_traffic_default); + } + }, [switchPreview?.reset_traffic_default]); + // Tariff switch mutation const switchTariffMutation = useMutation({ - mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId), + mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId, resetTrafficOnSwitch), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); setSwitchTariffId(null); + setResetTrafficOnSwitch(false); navigate('/subscription', { replace: true }); }, onError: (error: unknown) => { @@ -250,6 +260,7 @@ export default function SubscriptionPurchase() { const targetTariff = tariffs.find((tariff) => tariff.id === switchTariffId); if (targetTariff) { setSwitchTariffId(null); + setResetTrafficOnSwitch(false); setSelectedTariff(targetTariff); setSelectedTariffPeriod(targetTariff.periods[0] || null); setShowTariffPurchase(true); @@ -598,6 +609,35 @@ export default function SubscriptionPurchase() {
+ {/* Reset traffic toggle */} + {switchPreview.reset_traffic_available && ( + + )} + {!switchPreview.has_enough_balance && switchPreview.upgrade_cost_kopeks > 0 && ( Date: Fri, 27 Feb 2026 09:50:13 +0300 Subject: [PATCH 23/38] revert: remove user-facing reset traffic toggle Reset traffic on tariff switch is an admin-only setting. Backend handles it via admin settings system (data-driven, no frontend code needed). Reverted all user-facing toggle UI, API params, types, and translations. --- src/api/subscription.ts | 9 +----- src/locales/en.json | 4 +-- src/locales/fa.json | 4 +-- src/locales/ru.json | 4 +-- src/locales/zh.json | 4 +-- src/pages/SubscriptionPurchase.tsx | 45 +++--------------------------- src/types/index.ts | 3 -- 7 files changed, 9 insertions(+), 64 deletions(-) diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 857d86f..99fe63d 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -345,7 +345,6 @@ export const subscriptionApi = { // Preview tariff switch cost previewTariffSwitch: async ( tariffId: number, - resetTraffic?: boolean, ): Promise<{ can_switch: boolean; current_tariff_id: number | null; @@ -365,14 +364,10 @@ export const subscriptionApi = { base_upgrade_cost_kopeks?: number; discount_percent?: number; discount_kopeks?: number; - // Traffic reset on switch - reset_traffic_available?: boolean; - reset_traffic_default?: boolean; }> => { const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', { tariff_id: tariffId, - period_days: 30, - ...(resetTraffic !== undefined && { reset_traffic: resetTraffic }), + period_days: 30, // Default period for switch }); return response.data; }, @@ -380,7 +375,6 @@ export const subscriptionApi = { // Switch to a different tariff switchTariff: async ( tariffId: number, - resetTraffic?: boolean, ): Promise<{ success: boolean; message: string; @@ -395,7 +389,6 @@ export const subscriptionApi = { const response = await apiClient.post('/cabinet/subscription/tariff/switch', { tariff_id: tariffId, period_days: 30, - ...(resetTraffic !== undefined && { reset_traffic: resetTraffic }), }); return response.data; }, diff --git a/src/locales/en.json b/src/locales/en.json index 1c614a2..e7d818b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -483,9 +483,7 @@ "switched": "Tariff switched", "notEnoughBalance": "Insufficient balance", "dailyPayment": "Daily payment", - "dailyChargeDescription": "Charged daily from your balance", - "resetTraffic": "Reset traffic", - "resetTrafficHint": "Reset used traffic when switching tariff" + "dailyChargeDescription": "Charged daily from your balance" }, "switchTraffic": { "title": "Change Traffic", diff --git a/src/locales/fa.json b/src/locales/fa.json index 933a10b..08b1833 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -378,9 +378,7 @@ "remainingDays": "روزهای باقی‌مانده", "switch": "تغییر", "title": "تغییر تعرفه", - "upgradeCost": "هزینه ارتقا", - "resetTraffic": "بازنشانی ترافیک", - "resetTrafficHint": "بازنشانی ترافیک مصرفی هنگام تغییر تعرفه" + "upgradeCost": "هزینه ارتقا" }, "promoGroup": { "yourGroup": "گروه شما: {{name}}", diff --git a/src/locales/ru.json b/src/locales/ru.json index 6631864..b4cae25 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -506,9 +506,7 @@ "switched": "Тариф изменён", "notEnoughBalance": "Недостаточно средств", "dailyPayment": "Оплата за день", - "dailyChargeDescription": "Списывается ежедневно с баланса", - "resetTraffic": "Сбросить трафик", - "resetTrafficHint": "Обнулить использованный трафик при смене тарифа" + "dailyChargeDescription": "Списывается ежедневно с баланса" }, "switchTraffic": { "title": "Изменить трафик", diff --git a/src/locales/zh.json b/src/locales/zh.json index 0faad4f..122b6ae 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -378,9 +378,7 @@ "remainingDays": "剩余天数", "switch": "切换", "title": "切换套餐", - "upgradeCost": "升级费用", - "resetTraffic": "重置流量", - "resetTrafficHint": "切换套餐时重置已使用流量" + "upgradeCost": "升级费用" }, "promoGroup": { "yourGroup": "您的组:{{name}}", diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index 991c501..9e91891 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -128,14 +128,13 @@ export default function SubscriptionPurchase() { // Tariff switch const [switchTariffId, setSwitchTariffId] = useState(null); - const [resetTrafficOnSwitch, setResetTrafficOnSwitch] = useState(false); // Auto-close all modals on success notification const handleCloseAllModals = () => { setShowPurchaseForm(false); setShowTariffPurchase(false); setSwitchTariffId(null); - setResetTrafficOnSwitch(false); + setSelectedTariff(null); setSelectedTariffPeriod(null); }; @@ -232,21 +231,14 @@ export default function SubscriptionPurchase() { enabled: !!switchTariffId, }); - // Initialize reset traffic toggle from server default - useEffect(() => { - if (switchPreview?.reset_traffic_default !== undefined) { - setResetTrafficOnSwitch(switchPreview.reset_traffic_default); - } - }, [switchPreview?.reset_traffic_default]); - // Tariff switch mutation const switchTariffMutation = useMutation({ - mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId, resetTrafficOnSwitch), + mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); setSwitchTariffId(null); - setResetTrafficOnSwitch(false); + navigate('/subscription', { replace: true }); }, onError: (error: unknown) => { @@ -260,7 +252,7 @@ export default function SubscriptionPurchase() { const targetTariff = tariffs.find((tariff) => tariff.id === switchTariffId); if (targetTariff) { setSwitchTariffId(null); - setResetTrafficOnSwitch(false); + setSelectedTariff(targetTariff); setSelectedTariffPeriod(targetTariff.periods[0] || null); setShowTariffPurchase(true); @@ -609,35 +601,6 @@ export default function SubscriptionPurchase() {
- {/* Reset traffic toggle */} - {switchPreview.reset_traffic_available && ( - - )} - {!switchPreview.has_enough_balance && switchPreview.upgrade_cost_kopeks > 0 && ( Date: Sat, 28 Feb 2026 06:37:40 +0300 Subject: [PATCH 24/38] fix: improve light theme visibility for dashboard and subscription cards - Increase card opacity and shadow contrast in glassTheme for light mode - Add accent-tinted borders and shadows to subscription cards (active, expired, trial) - Fix grid patterns to use dark lines instead of white in light mode - Fix trial CTA button: solid green with dark text instead of invisible transparent - Fix trial icon background: light accent gradient instead of hardcoded dark - Add + prefix to referral earnings in StatsGrid --- src/components/dashboard/StatsGrid.tsx | 2 +- .../dashboard/SubscriptionCardActive.tsx | 6 +- .../dashboard/SubscriptionCardExpired.tsx | 16 ++++-- src/components/dashboard/TrialOfferCard.tsx | 55 ++++++++++++++----- src/pages/Subscription.tsx | 8 ++- src/utils/glassTheme.ts | 6 +- 6 files changed, 65 insertions(+), 28 deletions(-) diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx index 8c3ebbb..6ac21c7 100644 --- a/src/components/dashboard/StatsGrid.tsx +++ b/src/components/dashboard/StatsGrid.tsx @@ -83,7 +83,7 @@ export default function StatsGrid({ label: t('dashboard.stats.referrals'), value: `${referralCount}`, valueColor: g.text, - subtitle: `${formatAmount(earningsRubles)} ${currencySymbol}`, + subtitle: `+${formatAmount(earningsRubles)} ${currencySymbol}`, subtitleColor: zone.mainHex, to: '/referral', icon: (color: string) => ( diff --git a/src/components/dashboard/SubscriptionCardActive.tsx b/src/components/dashboard/SubscriptionCardActive.tsx index df934d7..76ba2a7 100644 --- a/src/components/dashboard/SubscriptionCardActive.tsx +++ b/src/components/dashboard/SubscriptionCardActive.tsx @@ -73,9 +73,11 @@ export default function SubscriptionCardActive({ background: g.cardBg, border: subscription.is_trial ? '1px solid rgba(62,219,176,0.15)' - : `1px solid ${g.cardBorder}`, + : isDark + ? `1px solid ${g.cardBorder}` + : `1px solid ${zone.mainHex}25`, padding: '28px 28px 24px', - boxShadow: g.shadow, + boxShadow: isDark ? g.shadow : `0 2px 16px ${zone.mainHex}12, 0 0 0 1px ${zone.mainHex}08`, }} > {/* Trial shimmer border */} diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index dce57ef..38ef1a1 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -20,8 +20,10 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa className="relative overflow-hidden rounded-3xl" style={{ background: g.cardBg, - border: '1px solid rgba(255,70,70,0.12)', - boxShadow: g.shadow, + border: isDark ? '1px solid rgba(255,70,70,0.12)' : '1px solid rgba(255,59,92,0.2)', + boxShadow: isDark + ? g.shadow + : '0 2px 16px rgba(255,59,92,0.1), 0 0 0 1px rgba(255,59,92,0.06)', padding: '28px 28px 24px', }} > @@ -40,10 +42,14 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa /> {/* Grid pattern */}