From eed077b0197f215c8f74f70a2bf0b73fd41d4628 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Sat, 7 Mar 2026 20:33:55 +0300
Subject: [PATCH 1/9] feat: add gift purchase UI states for telegram recipients
- Add recipient_in_bot and bot_link fields to PurchaseStatus API type
- Show bot link instruction when gift recipient is not in bot
- Show "gift sent via Telegram" message when recipient is in bot
- Fix duplicate text rendering in GiftPendingActivationState
- Handle recipientInBot=null edge case safely
- Add gift notification translations for ru, en, zh, fa locales
---
src/api/landings.ts | 2 ++
src/locales/en.json | 5 +++
src/locales/fa.json | 5 +++
src/locales/ru.json | 5 +++
src/locales/zh.json | 5 +++
src/pages/PurchaseSuccess.tsx | 62 +++++++++++++++++++++++++++++++++--
6 files changed, 81 insertions(+), 3 deletions(-)
diff --git a/src/api/landings.ts b/src/api/landings.ts
index 18b5b1d..f557de0 100644
--- a/src/api/landings.ts
+++ b/src/api/landings.ts
@@ -126,6 +126,8 @@ export interface PurchaseStatus {
cabinet_email: string | null;
cabinet_password: string | null;
auto_login_token: string | null;
+ recipient_in_bot: boolean | null;
+ bot_link: string | null;
}
// ============================================================
diff --git a/src/locales/en.json b/src/locales/en.json
index 83e5890..130143d 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -4060,6 +4060,11 @@
"giftSentSuccess": "Gift sent!",
"giftSentDesc": "Recipient will be notified by email",
"giftPendingActivationDesc": "The recipient already has an active subscription. They will receive a link to activate the gift.",
+ "giftTelegramSent": "Gift notification sent to recipient via Telegram",
+ "giftTelegramNotInBot": "Recipient hasn't started the bot yet. Send them this link:",
+ "giftTelegramPendingSent": "Activation request sent to recipient via Telegram",
+ "giftTelegramPendingNotInBot": "Recipient hasn't started the bot yet. Send them the bot link so they can activate the gift:",
+ "openBot": "Open bot",
"autoLoginFailed": "Auto-login failed",
"autoLoginProcessing": "Signing in...",
"discount": {
diff --git a/src/locales/fa.json b/src/locales/fa.json
index f6c21df..89df8b6 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -3552,6 +3552,11 @@
"giftSentSuccess": "هدیه ارسال شد!",
"giftSentDesc": "گیرنده از طریق ایمیل مطلع خواهد شد",
"giftPendingActivationDesc": "گیرنده در حال حاضر اشتراک فعال دارد. لینک فعالسازی هدیه برای او ارسال خواهد شد.",
+ "giftTelegramSent": "اطلاعیه هدیه از طریق تلگرام به گیرنده ارسال شد",
+ "giftTelegramNotInBot": "گیرنده هنوز ربات را شروع نکرده است. این لینک را برای او ارسال کنید:",
+ "giftTelegramPendingSent": "درخواست فعالسازی از طریق تلگرام به گیرنده ارسال شد",
+ "giftTelegramPendingNotInBot": "گیرنده هنوز در ربات نیست. لینک ربات را برایش ارسال کنید تا بتواند هدیه را فعال کند:",
+ "openBot": "باز کردن ربات",
"autoLoginFailed": "ورود خودکار ناموفق بود",
"autoLoginProcessing": "در حال ورود...",
"periodLabels": {
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 9fde123..4f5022e 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -4620,6 +4620,11 @@
"giftSentSuccess": "Подарок отправлен!",
"giftSentDesc": "Получатель получит уведомление на email",
"giftPendingActivationDesc": "У получателя уже есть активная подписка. Ему будет отправлена ссылка для активации подарка.",
+ "giftTelegramSent": "Мы отправили получателю уведомление о подарке в Telegram",
+ "giftTelegramNotInBot": "Получатель пока не пользуется ботом. Отправьте ему эту ссылку, чтобы он мог забрать подарок:",
+ "giftTelegramPendingSent": "Мы отправили получателю предложение активировать подарок в Telegram",
+ "giftTelegramPendingNotInBot": "Получатель пока не пользуется ботом. Отправьте ему ссылку, чтобы он мог активировать подарок:",
+ "openBot": "Ссылка на бота",
"autoLoginFailed": "Не удалось выполнить автоматический вход",
"autoLoginProcessing": "Выполняется вход...",
"discount": {
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 1213972..23640a2 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -3551,6 +3551,11 @@
"giftSentSuccess": "礼物已发送!",
"giftSentDesc": "收件人将通过邮件收到通知",
"giftPendingActivationDesc": "收件人已有活跃订阅。他们将收到激活礼物的链接。",
+ "giftTelegramSent": "已通过 Telegram 向收件人发送通知",
+ "giftTelegramNotInBot": "收件人尚未启动机器人。请将此链接发送给他们:",
+ "giftTelegramPendingSent": "已通过 Telegram 向收件人发送激活请求",
+ "giftTelegramPendingNotInBot": "收件人尚未启动机器人。请将机器人链接发送给他们以激活礼物:",
+ "openBot": "打开机器人",
"autoLoginFailed": "自动登录失败",
"autoLoginProcessing": "正在登录...",
"periodLabels": {
diff --git a/src/pages/PurchaseSuccess.tsx b/src/pages/PurchaseSuccess.tsx
index a4e9575..94ac3f5 100644
--- a/src/pages/PurchaseSuccess.tsx
+++ b/src/pages/PurchaseSuccess.tsx
@@ -218,6 +218,9 @@ function SuccessState({
periodDays,
isGift,
giftMessage,
+ recipientInBot,
+ botLink,
+ contactType,
}: {
subscriptionUrl: string | null;
cryptoLink: string | null;
@@ -227,6 +230,9 @@ function SuccessState({
periodDays: number | null;
isGift: boolean;
giftMessage: string | null;
+ recipientInBot: boolean | null;
+ botLink: string | null;
+ contactType: string | null;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
@@ -299,7 +305,13 @@ function SuccessState({
{tariffName} — {periodDays} {t('landing.daysAccess')}
)}
- {displayContact && (
+ {isGift && contactType === 'telegram' && recipientInBot === true && (
+ {t('landing.giftTelegramSent')}
+ )}
+ {isGift && contactType === 'telegram' && recipientInBot !== true && (
+ {t('landing.giftTelegramNotInBot')}
+ )}
+ {!(isGift && contactType === 'telegram') && displayContact && (
{isGift
? t('landing.giftSentTo', { contact: displayContact })
@@ -313,6 +325,18 @@ function SuccessState({
)}
+ {/* Bot link for telegram gifts where recipient is not in bot */}
+ {isGift && contactType === 'telegram' && recipientInBot !== true && botLink && (
+
+ {t('landing.openBot')}
+
+ )}
+
{/* QR Code */}
{displayUrl && (
@@ -507,11 +531,17 @@ function GiftPendingActivationState({
periodDays,
recipientContactValue,
giftMessage,
+ recipientInBot,
+ botLink,
+ contactType,
}: {
tariffName: string | null;
periodDays: number | null;
recipientContactValue: string | null;
giftMessage: string | null;
+ recipientInBot: boolean | null;
+ botLink: string | null;
+ contactType: string | null;
}) {
const { t } = useTranslation();
@@ -556,18 +586,38 @@ function GiftPendingActivationState({
{tariffName} — {periodDays} {t('landing.daysAccess')}
)}
- {recipientContactValue && (
+ {contactType === 'telegram' && recipientInBot === true && (
+
{t('landing.giftTelegramPendingSent')}
+ )}
+ {contactType === 'telegram' && recipientInBot !== true && (
+
{t('landing.giftTelegramPendingNotInBot')}
+ )}
+ {contactType !== 'telegram' && (
+
{t('landing.giftPendingActivationDesc')}
+ )}
+ {contactType !== 'telegram' && recipientContactValue && (
{t('landing.giftSentTo', { contact: recipientContactValue })}
)}
-
{t('landing.giftPendingActivationDesc')}
{giftMessage && (
{t('landing.giftMessage')}: {giftMessage}
)}
+
+ {/* Bot link for telegram gifts where recipient is not in bot */}
+ {contactType === 'telegram' && recipientInBot !== true && botLink && (
+
+ {t('landing.openBot')}
+
+ )}
);
}
@@ -756,6 +806,9 @@ export default function PurchaseSuccess() {
periodDays={purchaseStatus.period_days}
isGift={purchaseStatus.is_gift}
giftMessage={purchaseStatus.gift_message}
+ recipientInBot={purchaseStatus.recipient_in_bot}
+ botLink={purchaseStatus.bot_link}
+ contactType={purchaseStatus.contact_type}
/>
) : isGiftPendingActivation ? (
) : isPendingActivation ? (
From 8ce4b1a24a42ffe77fa9ce10df385d4f216ddb59 Mon Sep 17 00:00:00 2001
From: c0mrade
Date: Sat, 7 Mar 2026 23:25:33 +0300
Subject: [PATCH 2/9] refactor: move ConnectionQR into standard Layout with
proper navigation
- Wrap /connection/qr route in ProtectedRoute with Layout
- Remove fullscreen overlay and custom close button
- Add AdminBackButton with replace prop to avoid history cycles
- Use replace navigation from Connection to QR page
- Add replace prop support to AdminBackButton component
---
src/App.tsx | 8 +-
src/components/admin/AdminBackButton.tsx | 4 +-
src/pages/Connection.tsx | 1 +
src/pages/ConnectionQR.tsx | 106 +++++++----------------
4 files changed, 42 insertions(+), 77 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index 46e0ea8..5b484ca 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -406,9 +406,11 @@ function App() {
-
-
+
+
+
+
+
}
/>
{
navigate('/connection/qr', {
+ replace: true,
state: {
url: appConfig?.subscriptionUrl,
hideLink: appConfig?.hideLink ?? false,
diff --git a/src/pages/ConnectionQR.tsx b/src/pages/ConnectionQR.tsx
index b09cffe..5671a2a 100644
--- a/src/pages/ConnectionQR.tsx
+++ b/src/pages/ConnectionQR.tsx
@@ -2,8 +2,8 @@ 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';
+import { AdminBackButton } from '@/components/admin';
interface ConnectionQRState {
url: string;
@@ -20,96 +20,56 @@ 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) {
+ if (!validState) {
navigate('/connection', { replace: true });
}
- }, [isLoading, isAuthenticated, validState, navigate]);
+ }, [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) {
+ if (!validState) {
return null;
}
return (
-
- {/* Close button */}
-
navigate(-1)}
- className="absolute left-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-dark-800/60 backdrop-blur-sm transition-colors hover:bg-dark-700/80"
- aria-label={t('common.close')}
- >
-
-
-
-
+
+
+
+
{t('subscription.connection.qrTitle')}
+
- {/* Content */}
-
- {/* Branding name */}
- {appName && (
-
- {appName}
+
+
+ {appName && (
+
+ {appName}
+
+ )}
+
+
+ {t('subscription.connection.qrScanHint')}
- )}
- {/* Title */}
-
- {t('subscription.connection.qrTitle')}
-
+
+
+
- {/* Hint */}
-
- {t('subscription.connection.qrScanHint')}
-
-
- {/* QR code container */}
-
-
+ {!validState.hideLink && (
+
+ {validState.url}
+
+ )}
-
- {/* URL display */}
- {!validState.hideLink && (
-
- {validState.url}
-
- )}
);
From 7bb75aa92045d911533506cf922cbc8a45ef0968 Mon Sep 17 00:00:00 2001
From: c0mrade
Date: Sat, 7 Mar 2026 23:35:06 +0300
Subject: [PATCH 3/9] fix: use platform-conditional replace for QR navigation
to preserve Telegram back behavior
---
src/pages/Connection.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx
index 9300219..1907721 100644
--- a/src/pages/Connection.tsx
+++ b/src/pages/Connection.tsx
@@ -37,7 +37,7 @@ export default function Connection() {
const handleOpenQR = useCallback(() => {
navigate('/connection/qr', {
- replace: true,
+ replace: !isTelegramWebApp,
state: {
url: appConfig?.subscriptionUrl,
hideLink: appConfig?.hideLink ?? false,
From edb7ef0488b0ae994b7a37be9b95d1ab007feb09 Mon Sep 17 00:00:00 2001
From: c0mrade
Date: Sun, 8 Mar 2026 00:17:04 +0300
Subject: [PATCH 4/9] feat: unified device manager with dot-based selector
Replace separate buy/reduce device sections with a single DeviceManager component featuring a dot-step selector for intuitive device limit adjustment.
---
src/components/subscription/DeviceManager.tsx | 490 +++++++
src/locales/en.json | 4 +
src/locales/fa.json | 4 +
src/locales/ru.json | 4 +
src/locales/zh.json | 4 +
src/pages/Subscription.tsx | 1245 ++++++-----------
6 files changed, 926 insertions(+), 825 deletions(-)
create mode 100644 src/components/subscription/DeviceManager.tsx
diff --git a/src/components/subscription/DeviceManager.tsx b/src/components/subscription/DeviceManager.tsx
new file mode 100644
index 0000000..7b896ca
--- /dev/null
+++ b/src/components/subscription/DeviceManager.tsx
@@ -0,0 +1,490 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { subscriptionApi } from '@/api/subscription';
+import { useCurrency } from '@/hooks/useCurrency';
+import { useHaptic } from '@/platform';
+import InsufficientBalancePrompt from '@/components/InsufficientBalancePrompt';
+import { getGlassColors } from '@/utils/glassTheme';
+import { getErrorMessage } from '@/utils/subscriptionHelpers';
+
+// ─── Types ───
+
+interface DeviceManagerProps {
+ subscription: {
+ device_limit: number;
+ is_active: boolean;
+ is_trial: boolean;
+ };
+ balanceKopeks: number;
+ isDark: boolean;
+ accentColor: string;
+ onClose: () => void;
+}
+
+// ─── DotStepSelector ───
+
+interface DotStepSelectorProps {
+ min: number;
+ max: number;
+ current: number;
+ selected: number;
+ connectedCount: number;
+ onChange: (value: number) => void;
+ accentColor: string;
+ isDark: boolean;
+}
+
+function DotStepSelector({
+ min,
+ max,
+ current,
+ selected,
+ connectedCount,
+ onChange,
+ accentColor,
+ isDark,
+}: DotStepSelectorProps) {
+ const total = max - min + 1;
+
+ if (total <= 0) return null;
+
+ // For large ranges (>15), show a compact slider-like view
+ if (total > 15) {
+ return (
+
+
{
+ const val = Number(e.target.value);
+ if (val >= Math.max(min, connectedCount)) {
+ onChange(val);
+ }
+ }}
+ className="w-full accent-accent-400"
+ style={{ accentColor }}
+ />
+
+ {min}
+ {max}
+
+
+ );
+ }
+
+ return (
+
+ {/* Track background */}
+
+ {/* Filled track */}
+
+
+ {/* Dots */}
+ {Array.from({ length: total }, (_, i) => {
+ const value = min + i;
+ const isFilled = value <= selected;
+ const isCurrent = value === current;
+ const isLocked = value < connectedCount && value < selected;
+ const isDisabled = value < Math.max(min, connectedCount) && value < current;
+
+ return (
+
{
+ if (value >= Math.max(min, connectedCount) || value > current) {
+ onChange(value);
+ }
+ }}
+ disabled={isDisabled && value < connectedCount}
+ className="relative z-10 flex items-center justify-center transition-all duration-200"
+ style={{
+ width: total <= 8 ? 32 : total <= 12 ? 24 : 20,
+ height: total <= 8 ? 32 : total <= 12 ? 24 : 20,
+ }}
+ aria-label={`${value}`}
+ >
+
+
+ );
+ })}
+
+
+ );
+}
+
+// ─── DeviceManager ───
+
+export default function DeviceManager({
+ subscription,
+ balanceKopeks,
+ isDark,
+ accentColor,
+ onClose,
+}: DeviceManagerProps) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const { formatAmount, currencySymbol } = useCurrency();
+ const { impact: hapticImpact } = useHaptic();
+ const g = getGlassColors(isDark);
+
+ const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`;
+
+ const currentLimit = subscription.device_limit;
+ const [selectedLimit, setSelectedLimit] = useState(currentLimit);
+
+ // Determine mode
+ const devicesToAdd = selectedLimit > currentLimit ? selectedLimit - currentLimit : 0;
+ const mode: 'idle' | 'purchase' | 'reduce' =
+ selectedLimit > currentLimit ? 'purchase' : selectedLimit < currentLimit ? 'reduce' : 'idle';
+
+ // Fetch initial range data (price info gives us max_device_limit)
+ const { data: priceData } = useQuery({
+ queryKey: ['device-price', devicesToAdd || 1],
+ queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd || 1),
+ enabled: !!subscription,
+ placeholderData: (previousData) => previousData,
+ });
+
+ // Fetch reduction info (gives us min_device_limit, connected_devices_count)
+ const { data: reductionInfo } = useQuery({
+ queryKey: ['device-reduction-info'],
+ queryFn: subscriptionApi.getDeviceReductionInfo,
+ enabled: !!subscription,
+ });
+
+ const minLimit = reductionInfo?.min_device_limit ?? currentLimit;
+ const maxLimit = priceData?.max_device_limit ?? currentLimit;
+ const connectedCount = reductionInfo?.connected_devices_count ?? 0;
+ const isInitializing = !priceData || !reductionInfo;
+
+ // Reset selectedLimit when currentLimit changes (after successful mutation)
+ useEffect(() => {
+ setSelectedLimit(currentLimit);
+ }, [currentLimit]);
+
+ const handleChange = useCallback(
+ (value: number) => {
+ setSelectedLimit(value);
+ try {
+ hapticImpact('light');
+ } catch {
+ /* not available */
+ }
+ },
+ [hapticImpact],
+ );
+
+ // Purchase mutation
+ const purchaseMutation = useMutation({
+ mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({ queryKey: ['devices'] });
+ queryClient.invalidateQueries({ queryKey: ['device-price'] });
+ onClose();
+ },
+ });
+
+ // Reduce mutation
+ const reduceMutation = useMutation({
+ mutationFn: () => subscriptionApi.reduceDevices(selectedLimit),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({ queryKey: ['devices'] });
+ queryClient.invalidateQueries({ queryKey: ['device-reduction-info'] });
+ onClose();
+ },
+ });
+
+ const isPending = purchaseMutation.isPending || reduceMutation.isPending;
+
+ const canSubmit = (() => {
+ if (mode === 'idle' || isPending) return false;
+ if (mode === 'purchase') {
+ if (!priceData?.available) return false;
+ if (priceData.total_price_kopeks && priceData.total_price_kopeks > balanceKopeks)
+ return false;
+ return true;
+ }
+ if (mode === 'reduce') {
+ if (reductionInfo?.available === false) return false;
+ if (selectedLimit < connectedCount) return false;
+ if (selectedLimit < minLimit) return false;
+ return true;
+ }
+ return false;
+ })();
+
+ const handleSubmit = () => {
+ if (mode === 'purchase') purchaseMutation.mutate();
+ if (mode === 'reduce') reduceMutation.mutate();
+ };
+
+ const mutationError = purchaseMutation.error || reduceMutation.error;
+
+ return (
+
+ {/* Header */}
+
+
+ {t('subscription.deviceManager.title')}
+
+
+ ✕
+
+
+
+ {isInitializing ? (
+
+
+
+ ) : (
+
+ {/* Number badge + label */}
+
+
+ {selectedLimit}
+
+
+ {t('subscription.deviceManager.devicesLabel')}
+
+
+
+ {/* Dot selector */}
+ {maxLimit > minLimit && (
+
+ )}
+
+ {/* New limit info */}
+ {mode !== 'idle' && (
+
+ {t('subscription.additionalOptions.newDeviceLimit', { count: selectedLimit })}
+
+ )}
+
+ {/* Purchase info */}
+ {mode === 'purchase' && priceData?.available && priceData.price_per_device_label && (
+
+
+ {priceData.discount_percent && priceData.discount_percent > 0 ? (
+
+
+ {formatPrice(priceData.original_price_per_device_kopeks || 0)}
+
+ {priceData.price_per_device_label}
+
+ ) : (
+ priceData.price_per_device_label
+ )}
+ /{t('subscription.perDevice').replace('/ ', '')} (
+ {t('subscription.days', { count: priceData.days_left })})
+
+
+ {priceData.discount_percent && priceData.discount_percent > 0 && (
+
+
+ -{priceData.discount_percent}%
+
+
+ )}
+
+ {priceData.total_price_kopeks === 0 ? (
+
+ {t('subscription.switchTariff.free')}
+
+ ) : (
+
+ {priceData.discount_percent &&
+ priceData.discount_percent > 0 &&
+ priceData.base_total_price_kopeks && (
+
+ {formatPrice(priceData.base_total_price_kopeks)}
+
+ )}
+ {priceData.total_price_label}
+
+ )}
+
+ )}
+
+ {/* Purchase unavailable reason */}
+ {mode === 'purchase' && priceData?.available === false && priceData.reason && (
+
+ {priceData.reason}
+
+ )}
+
+ {/* Reduce unavailable reason */}
+ {mode === 'reduce' && reductionInfo?.available === false && reductionInfo.reason && (
+
+ {reductionInfo.reason}
+
+ )}
+
+ {/* Reduce: connected devices warning */}
+ {mode === 'reduce' &&
+ reductionInfo?.available !== false &&
+ connectedCount > selectedLimit && (
+
+ {t('subscription.additionalOptions.disconnectDevicesFirst', {
+ count: connectedCount,
+ })}
+
+ )}
+
+ {/* Reduce info */}
+ {mode === 'reduce' && reductionInfo && connectedCount <= selectedLimit && (
+
+
+ {t('subscription.additionalOptions.connectedDevices', { count: connectedCount })}
+
+
+ )}
+
+ {/* Insufficient balance */}
+ {mode === 'purchase' &&
+ priceData?.available &&
+ priceData.total_price_kopeks &&
+ priceData.total_price_kopeks > balanceKopeks && (
+
{
+ await subscriptionApi.saveDevicesCart(devicesToAdd);
+ }}
+ />
+ )}
+
+ {/* Action button */}
+
+ {isPending ? (
+
+
+
+ ) : mode === 'reduce' ? (
+ t('subscription.additionalOptions.reduce')
+ ) : (
+ t('subscription.additionalOptions.buy')
+ )}
+
+
+ {/* Error */}
+ {mutationError && (
+
+ {getErrorMessage(mutationError)}
+
+ )}
+
+ )}
+
+ );
+}
+
+// ─── Trigger Button ───
+
+interface DeviceManagerTriggerProps {
+ deviceLimit: number;
+ isDark: boolean;
+ onClick: () => void;
+}
+
+export function DeviceManagerTrigger({ deviceLimit, isDark, onClick }: DeviceManagerTriggerProps) {
+ const { t } = useTranslation();
+ const g = getGlassColors(isDark);
+
+ return (
+
+
+ {t('subscription.additionalOptions.title')}
+
+
+
+
+
{t('subscription.deviceManager.title')}
+
+ {t('subscription.additionalOptions.currentDeviceLimit', { count: deviceLimit })}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/locales/en.json b/src/locales/en.json
index 130143d..25edf3d 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -529,6 +529,10 @@
"manageServers": "Server management",
"manageServersTitle": "Server management"
},
+ "deviceManager": {
+ "title": "Change device count",
+ "devicesLabel": "Devices"
+ },
"serverManagement": {
"statusLegend": "✅ — connected • ➕ — will be added (paid) • ➖ — will be disconnected",
"discountBanner": "Your server discount: -{{percent}}%",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 89df8b6..53e82a9 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -351,6 +351,10 @@
"manageServers": "مدیریت سرورها",
"manageServersTitle": "مدیریت سرورها"
},
+ "deviceManager": {
+ "title": "تغییر تعداد دستگاهها",
+ "devicesLabel": "دستگاهها"
+ },
"serverManagement": {
"statusLegend": "✅ — متصل • ➕ — اضافه خواهد شد (پولی) • ➖ — قطع خواهد شد",
"discountBanner": "تخفیف سرور شما: -{{percent}}%",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 4f5022e..7b814c4 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -557,6 +557,10 @@
"manageServers": "Управление серверами",
"manageServersTitle": "Управление серверами"
},
+ "deviceManager": {
+ "title": "Изменить кол-во устройств",
+ "devicesLabel": "Устройства"
+ },
"serverManagement": {
"statusLegend": "✅ — подключено • ➕ — будет добавлено (платно) • ➖ — будет отключено",
"discountBanner": "Ваша скидка на серверы: -{{percent}}%",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 23640a2..2c2e054 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -351,6 +351,10 @@
"manageServers": "服务器管理",
"manageServersTitle": "服务器管理"
},
+ "deviceManager": {
+ "title": "更改设备数量",
+ "devicesLabel": "设备"
+ },
"serverManagement": {
"statusLegend": "✅ — 已连接 • ➕ — 将添加(付费)• ➖ — 将断开",
"discountBanner": "您的服务器折扣:-{{percent}}%",
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx
index 6e0a54a..b85ff46 100644
--- a/src/pages/Subscription.tsx
+++ b/src/pages/Subscription.tsx
@@ -13,6 +13,7 @@ import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { useCurrency } from '../hooks/useCurrency';
import { useCloseOnSuccessNotification } from '../store/successNotification';
import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton';
+import DeviceManager, { DeviceManagerTrigger } from '../components/subscription/DeviceManager';
import { CopyIcon, CheckIcon } from '../components/icons';
import {
getErrorMessage,
@@ -175,10 +176,7 @@ export default function Subscription() {
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`;
// Device/traffic topup state
- const [showDeviceTopup, setShowDeviceTopup] = useState(false);
- const [devicesToAdd, setDevicesToAdd] = useState(1);
- const [showDeviceReduction, setShowDeviceReduction] = useState(false);
- const [targetDeviceLimit, setTargetDeviceLimit] = useState(1);
+ const [showDeviceManager, setShowDeviceManager] = useState(false);
const [showTrafficTopup, setShowTrafficTopup] = useState(false);
const [selectedTrafficPackage, setSelectedTrafficPackage] = useState(null);
const [showServerManagement, setShowServerManagement] = useState(false);
@@ -255,61 +253,12 @@ export default function Subscription() {
// Auto-close all modals/forms when success notification appears
const handleCloseAllModals = useCallback(() => {
- setShowDeviceTopup(false);
- setShowDeviceReduction(false);
+ setShowDeviceManager(false);
setShowTrafficTopup(false);
setShowServerManagement(false);
}, []);
useCloseOnSuccessNotification(handleCloseAllModals);
- // Device price query
- const { data: devicePriceData } = useQuery({
- queryKey: ['device-price', devicesToAdd],
- queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd),
- enabled: showDeviceTopup && !!subscription,
- });
-
- // Device purchase mutation
- const devicePurchaseMutation = useMutation({
- mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['subscription'] });
- queryClient.invalidateQueries({ queryKey: ['devices'] });
- setShowDeviceTopup(false);
- setDevicesToAdd(1);
- },
- });
-
- // Device reduction info query
- const { data: deviceReductionInfo } = useQuery({
- queryKey: ['device-reduction-info'],
- queryFn: subscriptionApi.getDeviceReductionInfo,
- enabled: showDeviceReduction && !!subscription,
- });
-
- // Initialize target device limit when reduction info loads
- useEffect(() => {
- if (deviceReductionInfo && showDeviceReduction) {
- setTargetDeviceLimit(
- Math.max(
- deviceReductionInfo.min_device_limit,
- deviceReductionInfo.current_device_limit - 1,
- ),
- );
- }
- }, [deviceReductionInfo, showDeviceReduction]);
-
- // Device reduction mutation
- const deviceReductionMutation = useMutation({
- mutationFn: () => subscriptionApi.reduceDevices(targetDeviceLimit),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['subscription'] });
- queryClient.invalidateQueries({ queryKey: ['devices'] });
- queryClient.invalidateQueries({ queryKey: ['device-reduction-info'] });
- setShowDeviceReduction(false);
- },
- });
-
// Traffic packages query
const { data: trafficPackages } = useQuery({
queryKey: ['traffic-packages'],
@@ -1125,233 +1074,60 @@ export default function Subscription() {
{/* Purchase / Renewal CTA */}
- {/* Additional Options (Buy Devices) */}
+ {/* Device Manager */}
{subscription &&
subscription.is_active &&
!subscription.is_trial &&
- subscription.device_limit !== 0 && (
-
-
- {t('subscription.additionalOptions.title')}
-
+ subscription.device_limit !== 0 &&
+ (showDeviceManager ? (
+
setShowDeviceManager(false)}
+ />
+ ) : (
+ setShowDeviceManager(true)}
+ />
+ ))}
- {/* Buy Devices */}
- {!showDeviceTopup ? (
- setShowDeviceTopup(true)}
- className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
- >
-
-
-
- {t('subscription.additionalOptions.buyDevices')}
-
-
- {t('subscription.additionalOptions.currentDeviceLimit', {
- count: subscription.device_limit,
- })}
-
-
-
-
-
-
-
- ) : (
-
-
-
{t('subscription.buyDevices')}
- setShowDeviceTopup(false)}
- className="text-sm text-dark-400 hover:text-dark-200"
- >
- ✕
-
-
+ {/* Additional Options (Traffic, Servers) */}
+ {subscription && subscription.is_active && !subscription.is_trial && (
+
+
+ {t('subscription.additionalOptions.title')}
+
- {/* Check if completely unavailable (no subscription, price not set, etc.) */}
- {devicePriceData?.available === false && !devicePriceData?.max_device_limit ? (
-
- {devicePriceData.reason ||
- t('subscription.additionalOptions.devicesUnavailable')}
-
- ) : (
-
- {/* Device selector - show even at max limit */}
-
-
setDevicesToAdd(Math.max(1, devicesToAdd - 1))}
- disabled={devicesToAdd <= 1}
- className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
- >
- -
-
-
-
{devicesToAdd}
-
- {t('subscription.additionalOptions.devicesUnit')}
-
-
-
setDevicesToAdd(devicesToAdd + 1)}
- disabled={
- devicePriceData?.max_device_limit
- ? (devicePriceData.current_device_limit || 0) + devicesToAdd >=
- devicePriceData.max_device_limit
- : false
- }
- className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
- >
- +
-
-
-
- {/* Show limit info when at or near max */}
- {devicePriceData?.max_device_limit && (
-
- {t('subscription.additionalOptions.currentDeviceLimit', {
- count: devicePriceData.current_device_limit || subscription.device_limit,
- })}{' '}
- /{' '}
- {t('subscription.additionalOptions.maxDevices', {
- count: devicePriceData.max_device_limit,
- })}
-
- )}
-
- {/* Show reason if can't add requested amount */}
- {devicePriceData?.available === false && devicePriceData?.reason && (
-
- {devicePriceData.reason}
-
- )}
-
- {/* Price info - only when available */}
- {devicePriceData?.available && devicePriceData.price_per_device_label && (
-
-
- {/* Show original price with strikethrough if discount */}
- {devicePriceData.discount_percent &&
- devicePriceData.discount_percent > 0 ? (
-
-
- {formatPrice(devicePriceData.original_price_per_device_kopeks || 0)}
-
- {devicePriceData.price_per_device_label}
-
- ) : (
- devicePriceData.price_per_device_label
- )}
- /{t('subscription.perDevice').replace('/ ', '')} (
- {t('subscription.days', { count: devicePriceData.days_left })})
-
- {/* Discount badge */}
- {devicePriceData.discount_percent &&
- devicePriceData.discount_percent > 0 && (
-
-
- -{devicePriceData.discount_percent}%
-
-
- )}
- {/* Total price - show as free if 100% discount or 0 */}
- {devicePriceData.total_price_kopeks === 0 ? (
-
- {t('subscription.switchTariff.free')}
-
- ) : (
-
- {/* Show original total with strikethrough if discount */}
- {devicePriceData.discount_percent &&
- devicePriceData.discount_percent > 0 &&
- devicePriceData.base_total_price_kopeks && (
-
- {formatPrice(devicePriceData.base_total_price_kopeks)}
-
- )}
- {devicePriceData.total_price_label}
-
- )}
-
- )}
-
- {devicePriceData?.available &&
- purchaseOptions &&
- devicePriceData.total_price_kopeks &&
- devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks && (
-
{
- await subscriptionApi.saveDevicesCart(devicesToAdd);
- }}
- />
- )}
-
- devicePurchaseMutation.mutate()}
- disabled={
- devicePurchaseMutation.isPending ||
- !devicePriceData?.available ||
- !!(
- devicePriceData?.total_price_kopeks &&
- purchaseOptions &&
- devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks
- )
- }
- className="btn-primary w-full py-3"
- >
- {devicePurchaseMutation.isPending ? (
-
-
-
- ) : (
- t('subscription.additionalOptions.buy')
- )}
-
-
- {devicePurchaseMutation.isError && (
-
- {getErrorMessage(devicePurchaseMutation.error)}
-
- )}
-
- )}
-
- )}
-
- {/* Reduce Devices */}
+ {/* Buy Traffic */}
+ {subscription.traffic_limit_gb > 0 && (
- {!showDeviceReduction ? (
+ {!showTrafficTopup ? (
setShowDeviceReduction(true)}
+ onClick={() => setShowTrafficTopup(true)}
className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
>
- {t('subscription.additionalOptions.reduceDevices')}
+ {t('subscription.additionalOptions.buyTraffic')}
- {t('subscription.additionalOptions.reduceDevicesDescription')}
+ {t('subscription.additionalOptions.currentTrafficLimit', {
+ limit: subscription.traffic_limit_gb,
+ used: subscription.traffic_used_gb.toFixed(1),
+ })}
- {t('subscription.additionalOptions.reduceDevicesTitle')}
+ {t('subscription.additionalOptions.buyTrafficTitle')}
setShowDeviceReduction(false)}
+ onClick={() => {
+ setShowTrafficTopup(false);
+ setSelectedTrafficPackage(null);
+ }}
className="text-sm text-dark-400 hover:text-dark-200"
>
✕
- {deviceReductionInfo?.available === false ? (
+
+ ⚠️ {t('subscription.additionalOptions.trafficWarning')}
+
+
+ {!trafficPackages || trafficPackages.length === 0 ? (
- {deviceReductionInfo.reason ||
- t('subscription.additionalOptions.reduceUnavailable')}
-
- ) : deviceReductionInfo ? (
-
- {/* Device limit selector */}
-
-
- setTargetDeviceLimit(
- Math.max(
- Math.max(
- deviceReductionInfo.min_device_limit,
- deviceReductionInfo.connected_devices_count,
- ),
- targetDeviceLimit - 1,
- ),
- )
- }
- disabled={
- targetDeviceLimit <=
- Math.max(
- deviceReductionInfo.min_device_limit,
- deviceReductionInfo.connected_devices_count,
- )
- }
- className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
- >
- -
-
-
-
- {targetDeviceLimit}
-
-
- {t('subscription.additionalOptions.devicesUnit')}
-
-
-
- setTargetDeviceLimit(
- Math.min(
- deviceReductionInfo.current_device_limit - 1,
- targetDeviceLimit + 1,
- ),
- )
- }
- disabled={
- targetDeviceLimit >= deviceReductionInfo.current_device_limit - 1
- }
- className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
- >
- +
-
-
-
- {/* Info */}
-
-
- {t('subscription.additionalOptions.currentDeviceLimit', {
- count: deviceReductionInfo.current_device_limit,
- })}
-
-
- {t('subscription.additionalOptions.minDeviceLimit', {
- count: deviceReductionInfo.min_device_limit,
- })}
-
-
- {t('subscription.additionalOptions.connectedDevices', {
- count: deviceReductionInfo.connected_devices_count,
- })}
-
-
-
- {/* Warning if connected devices block reduction */}
- {deviceReductionInfo.connected_devices_count >
- deviceReductionInfo.min_device_limit && (
-
- {t('subscription.additionalOptions.disconnectDevicesFirst', {
- count: deviceReductionInfo.connected_devices_count,
- })}
-
- )}
-
- {/* New limit preview */}
-
-
- {t('subscription.additionalOptions.newDeviceLimit', {
- count: targetDeviceLimit,
- })}
-
-
-
-
deviceReductionMutation.mutate()}
- disabled={
- deviceReductionMutation.isPending ||
- targetDeviceLimit >= deviceReductionInfo.current_device_limit ||
- targetDeviceLimit < deviceReductionInfo.min_device_limit ||
- targetDeviceLimit < deviceReductionInfo.connected_devices_count
- }
- className="btn-primary w-full py-3"
- >
- {deviceReductionMutation.isPending ? (
-
-
- {t('subscription.additionalOptions.reducing')}
-
- ) : (
- t('subscription.additionalOptions.reduce')
- )}
-
-
- {deviceReductionMutation.isError && (
-
- {getErrorMessage(deviceReductionMutation.error)}
-
- )}
+ {t('subscription.additionalOptions.trafficUnavailable')}
) : (
-
-
+
+
+ {trafficPackages.map((pkg) => (
+
setSelectedTrafficPackage(pkg.gb)}
+ className={`rounded-xl border p-4 text-center transition-all ${
+ selectedTrafficPackage === pkg.gb
+ ? 'border-accent-500 bg-accent-500/10'
+ : isDark
+ ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
+ : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
+ }`}
+ >
+
+ {pkg.is_unlimited
+ ? '♾️ ' + t('subscription.additionalOptions.unlimited')
+ : `${pkg.gb} ${t('common.units.gb')}`}
+
+ {/* Discount badge */}
+ {pkg.discount_percent && pkg.discount_percent > 0 && (
+
+
+ -{pkg.discount_percent}%
+
+
+ )}
+ {/* Price with original strikethrough if discount */}
+
+ {pkg.discount_percent &&
+ pkg.discount_percent > 0 &&
+ pkg.base_price_kopeks ? (
+ <>
+
+ {formatPrice(pkg.base_price_kopeks)}
+
+ {formatPrice(pkg.price_kopeks)}
+ >
+ ) : (
+ formatPrice(pkg.price_kopeks)
+ )}
+
+
+ ))}
+
+
+ {selectedTrafficPackage !== null &&
+ (() => {
+ const selectedPkg = trafficPackages.find(
+ (p) => p.gb === selectedTrafficPackage,
+ );
+ const hasEnoughBalance =
+ !selectedPkg ||
+ !purchaseOptions ||
+ selectedPkg.price_kopeks <= purchaseOptions.balance_kopeks;
+ const missingAmount =
+ selectedPkg && purchaseOptions
+ ? selectedPkg.price_kopeks - purchaseOptions.balance_kopeks
+ : 0;
+
+ return (
+ <>
+ {!hasEnoughBalance && missingAmount > 0 && (
+
{
+ await subscriptionApi.saveTrafficCart(selectedTrafficPackage);
+ }}
+ />
+ )}
+
+ trafficPurchaseMutation.mutate(selectedTrafficPackage)
+ }
+ disabled={trafficPurchaseMutation.isPending || !hasEnoughBalance}
+ className="btn-primary w-full py-3"
+ >
+ {trafficPurchaseMutation.isPending ? (
+
+
+
+ ) : selectedPkg?.is_unlimited ? (
+ t('subscription.additionalOptions.buyUnlimited')
+ ) : (
+ t('subscription.additionalOptions.buyTrafficGb', {
+ gb: selectedTrafficPackage,
+ })
+ )}
+
+ >
+ );
+ })()}
+
+ {trafficPurchaseMutation.isError && (
+
+ {getErrorMessage(trafficPurchaseMutation.error)}
+
+ )}
)}
)}
+ )}
- {/* Buy Traffic */}
- {subscription.traffic_limit_gb > 0 && (
-
- {!showTrafficTopup ? (
-
setShowTrafficTopup(true)}
- className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
- >
-
-
-
- {t('subscription.additionalOptions.buyTraffic')}
-
-
- {t('subscription.additionalOptions.currentTrafficLimit', {
- limit: subscription.traffic_limit_gb,
- used: subscription.traffic_used_gb.toFixed(1),
+ {/* Server Management - only in classic mode */}
+ {!isTariffsMode && (
+
+ {!showServerManagement ? (
+
setShowServerManagement(true)}
+ className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
+ >
+
+
+
+ {t('subscription.additionalOptions.manageServers')}
+
+
+ {t('subscription.servers', { count: subscription.servers?.length || 0 })}
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ {t('subscription.additionalOptions.manageServersTitle')}
+
+ {
+ setShowServerManagement(false);
+ setSelectedServersToUpdate([]);
+ }}
+ className="text-sm text-dark-400 hover:text-dark-200"
+ >
+ ✕
+
+
+
+ {countriesLoading ? (
+
+ ) : countriesData && countriesData.countries.length > 0 ? (
+
+
+ {t('subscription.serverManagement.statusLegend')}
+
+
+ {countriesData.discount_percent > 0 && (
+
+ 🎁{' '}
+ {t('subscription.serverManagement.discountBanner', {
+ percent: countriesData.discount_percent,
})}
-
-
-
-
-
-
- ) : (
-
-
-
- {t('subscription.additionalOptions.buyTrafficTitle')}
-
- {
- setShowTrafficTopup(false);
- setSelectedTrafficPackage(null);
- }}
- className="text-sm text-dark-400 hover:text-dark-200"
- >
- ✕
-
-
+ )}
-
- ⚠️ {t('subscription.additionalOptions.trafficWarning')}
-
-
- {!trafficPackages || trafficPackages.length === 0 ? (
-
- {t('subscription.additionalOptions.trafficUnavailable')}
-
- ) : (
-
-
- {trafficPackages.map((pkg) => (
-
setSelectedTrafficPackage(pkg.gb)}
- className={`rounded-xl border p-4 text-center transition-all ${
- selectedTrafficPackage === pkg.gb
- ? 'border-accent-500 bg-accent-500/10'
- : isDark
- ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
- : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
- }`}
- >
-
- {pkg.is_unlimited
- ? '♾️ ' + t('subscription.additionalOptions.unlimited')
- : `${pkg.gb} ${t('common.units.gb')}`}
-
- {/* Discount badge */}
- {pkg.discount_percent && pkg.discount_percent > 0 && (
-
-
- -{pkg.discount_percent}%
-
-
- )}
- {/* Price with original strikethrough if discount */}
-
- {pkg.discount_percent &&
- pkg.discount_percent > 0 &&
- pkg.base_price_kopeks ? (
- <>
-
- {formatPrice(pkg.base_price_kopeks)}
-
- {formatPrice(pkg.price_kopeks)}
- >
- ) : (
- formatPrice(pkg.price_kopeks)
- )}
-
-
- ))}
-
-
- {selectedTrafficPackage !== null &&
- (() => {
- const selectedPkg = trafficPackages.find(
- (p) => p.gb === selectedTrafficPackage,
- );
- const hasEnoughBalance =
- !selectedPkg ||
- !purchaseOptions ||
- selectedPkg.price_kopeks <= purchaseOptions.balance_kopeks;
- const missingAmount =
- selectedPkg && purchaseOptions
- ? selectedPkg.price_kopeks - purchaseOptions.balance_kopeks
- : 0;
+
+ {countriesData.countries
+ .filter((country) => country.is_available || country.is_connected)
+ .map((country) => {
+ const isCurrentlyConnected = country.is_connected;
+ const isSelected = selectedServersToUpdate.includes(country.uuid);
+ const willBeAdded = !isCurrentlyConnected && isSelected;
+ const willBeRemoved = isCurrentlyConnected && !isSelected;
return (
- <>
- {!hasEnoughBalance && missingAmount > 0 && (
-
{
- await subscriptionApi.saveTrafficCart(selectedTrafficPackage);
- }}
- />
- )}
-
- trafficPurchaseMutation.mutate(selectedTrafficPackage)
+ {
+ if (isSelected) {
+ setSelectedServersToUpdate((prev) =>
+ prev.filter((u) => u !== country.uuid),
+ );
+ } else {
+ setSelectedServersToUpdate((prev) => [...prev, country.uuid]);
}
- disabled={trafficPurchaseMutation.isPending || !hasEnoughBalance}
- className="btn-primary w-full py-3"
- >
- {trafficPurchaseMutation.isPending ? (
-
-
-
- ) : selectedPkg?.is_unlimited ? (
- t('subscription.additionalOptions.buyUnlimited')
- ) : (
- t('subscription.additionalOptions.buyTrafficGb', {
- gb: selectedTrafficPackage,
- })
- )}
-
- >
- );
- })()}
-
- {trafficPurchaseMutation.isError && (
-
- {getErrorMessage(trafficPurchaseMutation.error)}
-
- )}
-
- )}
-
- )}
-
- )}
-
- {/* Server Management - only in classic mode */}
- {!isTariffsMode && (
-
- {!showServerManagement ? (
-
setShowServerManagement(true)}
- className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
- >
-
-
-
- {t('subscription.additionalOptions.manageServers')}
-
-
- {t('subscription.servers', { count: subscription.servers?.length || 0 })}
-
-
-
-
-
-
-
- ) : (
-
-
-
- {t('subscription.additionalOptions.manageServersTitle')}
-
- {
- setShowServerManagement(false);
- setSelectedServersToUpdate([]);
- }}
- className="text-sm text-dark-400 hover:text-dark-200"
- >
- ✕
-
-
-
- {countriesLoading ? (
-
- ) : countriesData && countriesData.countries.length > 0 ? (
-
-
- {t('subscription.serverManagement.statusLegend')}
-
-
- {countriesData.discount_percent > 0 && (
-
- 🎁{' '}
- {t('subscription.serverManagement.discountBanner', {
- percent: countriesData.discount_percent,
- })}
-
- )}
-
-
- {countriesData.countries
- .filter((country) => country.is_available || country.is_connected)
- .map((country) => {
- const isCurrentlyConnected = country.is_connected;
- const isSelected = selectedServersToUpdate.includes(country.uuid);
- const willBeAdded = !isCurrentlyConnected && isSelected;
- const willBeRemoved = isCurrentlyConnected && !isSelected;
-
- return (
-
{
- if (isSelected) {
- setSelectedServersToUpdate((prev) =>
- prev.filter((u) => u !== country.uuid),
- );
- } else {
- setSelectedServersToUpdate((prev) => [...prev, country.uuid]);
- }
- }}
- disabled={!country.is_available && !isCurrentlyConnected}
- className={`flex w-full items-center justify-between rounded-xl border p-3 text-left transition-all ${
- isSelected
- ? willBeAdded
- ? 'border-success-500 bg-success-500/10'
- : 'border-accent-500 bg-accent-500/10'
+ }}
+ disabled={!country.is_available && !isCurrentlyConnected}
+ className={`flex w-full items-center justify-between rounded-xl border p-3 text-left transition-all ${
+ isSelected
+ ? willBeAdded
+ ? 'border-success-500 bg-success-500/10'
+ : 'border-accent-500 bg-accent-500/10'
+ : willBeRemoved
+ ? 'border-error-500/50 bg-error-500/5'
+ : isDark
+ ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
+ : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
+ } ${!country.is_available && !isCurrentlyConnected ? 'cursor-not-allowed opacity-50' : ''}`}
+ >
+
+
+ {willBeAdded
+ ? '➕'
: willBeRemoved
- ? 'border-error-500/50 bg-error-500/5'
- : isDark
- ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
- : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
- } ${!country.is_available && !isCurrentlyConnected ? 'cursor-not-allowed opacity-50' : ''}`}
- >
-
-
- {willBeAdded
- ? '➕'
- : willBeRemoved
- ? '➖'
- : isSelected
- ? '✅'
- : '⚪'}
-
-
-
- {country.name}
- {country.has_discount && !isCurrentlyConnected && (
-
- -{country.discount_percent}%
+ ? '➖'
+ : isSelected
+ ? '✅'
+ : '⚪'}
+
+
+
+ {country.name}
+ {country.has_discount && !isCurrentlyConnected && (
+
+ -{country.discount_percent}%
+
+ )}
+
+ {willBeAdded && (
+
+ +{formatPrice(country.price_kopeks)}{' '}
+ {t('subscription.serverManagement.forDays', {
+ days: countriesData.days_left,
+ })}
+ {country.has_discount && (
+
+ {formatPrice(
+ Math.round(
+ (country.base_price_kopeks *
+ countriesData.days_left) /
+ 30,
+ ),
+ )}
)}
- {willBeAdded && (
-
- +{formatPrice(country.price_kopeks)}{' '}
- {t('subscription.serverManagement.forDays', {
- days: countriesData.days_left,
- })}
- {country.has_discount && (
-
- {formatPrice(
- Math.round(
- (country.base_price_kopeks *
- countriesData.days_left) /
- 30,
- ),
- )}
-
- )}
-
- )}
- {!willBeAdded && !isCurrentlyConnected && (
-
- {formatPrice(country.price_per_month_kopeks)}
- {t('subscription.serverManagement.perMonth')}
- {country.has_discount && (
-
- {formatPrice(country.base_price_kopeks)}
-
- )}
-
- )}
- {!country.is_available && !isCurrentlyConnected && (
-
- {t('subscription.serverManagement.unavailable')}
-
- )}
-
-
- {country.country_code && (
-
- {getFlagEmoji(country.country_code)}
-
- )}
-
- );
- })}
-
-
- {(() => {
- const currentConnected = countriesData.countries
- .filter((c) => c.is_connected)
- .map((c) => c.uuid);
- const added = selectedServersToUpdate.filter(
- (u) => !currentConnected.includes(u),
- );
- const removed = currentConnected.filter(
- (u) => !selectedServersToUpdate.includes(u),
- );
- const hasChanges = added.length > 0 || removed.length > 0;
-
- // Calculate cost for added servers
- const addedServers = countriesData.countries.filter((c) =>
- added.includes(c.uuid),
- );
- const totalCost = addedServers.reduce(
- (sum, s) => sum + s.price_kopeks,
- 0,
- );
- const hasEnoughBalance =
- !purchaseOptions || totalCost <= purchaseOptions.balance_kopeks;
- const missingAmount = purchaseOptions
- ? totalCost - purchaseOptions.balance_kopeks
- : 0;
-
- return hasChanges ? (
-
- {added.length > 0 && (
-
-
- {t('subscription.serverManagement.toAdd')}
- {' '}
-
- {addedServers.map((s) => s.name).join(', ')}
-
-
- )}
- {removed.length > 0 && (
-
-
- {t('subscription.serverManagement.toDisconnect')}
- {' '}
-
- {countriesData.countries
- .filter((c) => removed.includes(c.uuid))
- .map((s) => s.name)
- .join(', ')}
-
-
- )}
- {totalCost > 0 && (
-
-
- {t('subscription.serverManagement.paymentProrated')}
-
-
- {formatPrice(totalCost)}
+ )}
+ {!willBeAdded && !isCurrentlyConnected && (
+
+ {formatPrice(country.price_per_month_kopeks)}
+ {t('subscription.serverManagement.perMonth')}
+ {country.has_discount && (
+
+ {formatPrice(country.base_price_kopeks)}
+
+ )}
+
+ )}
+ {!country.is_available && !isCurrentlyConnected && (
+
+ {t('subscription.serverManagement.unavailable')}
+
+ )}
- )}
-
- {totalCost > 0 && !hasEnoughBalance && missingAmount > 0 && (
-
- )}
-
-
- updateCountriesMutation.mutate(selectedServersToUpdate)
- }
- disabled={
- updateCountriesMutation.isPending ||
- selectedServersToUpdate.length === 0 ||
- (totalCost > 0 && !hasEnoughBalance)
- }
- className="btn-primary w-full py-3"
- >
- {updateCountriesMutation.isPending ? (
-
-
+ {country.country_code && (
+
+ {getFlagEmoji(country.country_code)}
- ) : (
- t('subscription.serverManagement.applyChanges')
)}
-
- ) : (
-
- {t('subscription.serverManagement.selectServersHint')}
-
- );
- })()}
+ );
+ })}
+
- {updateCountriesMutation.isError && (
-
- {getErrorMessage(updateCountriesMutation.error)}
+ {(() => {
+ const currentConnected = countriesData.countries
+ .filter((c) => c.is_connected)
+ .map((c) => c.uuid);
+ const added = selectedServersToUpdate.filter(
+ (u) => !currentConnected.includes(u),
+ );
+ const removed = currentConnected.filter(
+ (u) => !selectedServersToUpdate.includes(u),
+ );
+ const hasChanges = added.length > 0 || removed.length > 0;
+
+ // Calculate cost for added servers
+ const addedServers = countriesData.countries.filter((c) =>
+ added.includes(c.uuid),
+ );
+ const totalCost = addedServers.reduce((sum, s) => sum + s.price_kopeks, 0);
+ const hasEnoughBalance =
+ !purchaseOptions || totalCost <= purchaseOptions.balance_kopeks;
+ const missingAmount = purchaseOptions
+ ? totalCost - purchaseOptions.balance_kopeks
+ : 0;
+
+ return hasChanges ? (
+
+ {added.length > 0 && (
+
+
+ {t('subscription.serverManagement.toAdd')}
+ {' '}
+
+ {addedServers.map((s) => s.name).join(', ')}
+
+
+ )}
+ {removed.length > 0 && (
+
+
+ {t('subscription.serverManagement.toDisconnect')}
+ {' '}
+
+ {countriesData.countries
+ .filter((c) => removed.includes(c.uuid))
+ .map((s) => s.name)
+ .join(', ')}
+
+
+ )}
+ {totalCost > 0 && (
+
+
+ {t('subscription.serverManagement.paymentProrated')}
+
+
+ {formatPrice(totalCost)}
+
+
+ )}
+
+ {totalCost > 0 && !hasEnoughBalance && missingAmount > 0 && (
+
+ )}
+
+
+ updateCountriesMutation.mutate(selectedServersToUpdate)
+ }
+ disabled={
+ updateCountriesMutation.isPending ||
+ selectedServersToUpdate.length === 0 ||
+ (totalCost > 0 && !hasEnoughBalance)
+ }
+ className="btn-primary w-full py-3"
+ >
+ {updateCountriesMutation.isPending ? (
+
+
+
+ ) : (
+ t('subscription.serverManagement.applyChanges')
+ )}
+
- )}
-
- ) : (
-
- {t('subscription.serverManagement.noServersAvailable')}
-
- )}
-
- )}
-
- )}
-
- )}
+ ) : (
+
+ {t('subscription.serverManagement.selectServersHint')}
+
+ );
+ })()}
+
+ {updateCountriesMutation.isError && (
+
+ {getErrorMessage(updateCountriesMutation.error)}
+
+ )}
+
+ ) : (
+
+ {t('subscription.serverManagement.noServersAvailable')}
+
+ )}
+
+ )}
+
+ )}
+
+ )}
{/* My Devices Section */}
{subscription && (
From 6dc8ca0d18bd0e23d6fa05b169f40686f6b2584c Mon Sep 17 00:00:00 2001
From: c0mrade
Date: Sun, 8 Mar 2026 00:45:47 +0300
Subject: [PATCH 5/9] fix: unify device manager into additional options card
with unbounded dot selector
---
src/components/subscription/DeviceManager.tsx | 215 +++++++++++-------
src/pages/Subscription.tsx | 41 ++--
2 files changed, 151 insertions(+), 105 deletions(-)
diff --git a/src/components/subscription/DeviceManager.tsx b/src/components/subscription/DeviceManager.tsx
index 7b896ca..4031bb9 100644
--- a/src/components/subscription/DeviceManager.tsx
+++ b/src/components/subscription/DeviceManager.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { subscriptionApi } from '@/api/subscription';
@@ -23,10 +23,11 @@ interface DeviceManagerProps {
}
// ─── DotStepSelector ───
+// Unified: bounded ranges show fixed dots, unbounded adds a "+" tail that grows
interface DotStepSelectorProps {
min: number;
- max: number;
+ max: number | null; // null = unlimited
current: number;
selected: number;
connectedCount: number;
@@ -45,12 +46,18 @@ function DotStepSelector({
accentColor,
isDark,
}: DotStepSelectorProps) {
- const total = max - min + 1;
+ const isUnlimited = max === null;
+ const scrollRef = useRef(null);
+
+ // For unbounded: dots go from min to max(current, selected)
+ // For bounded: dots go from min to max
+ const upperBound = isUnlimited ? Math.max(current, selected) : max;
+ const total = upperBound - min + 1;
if (total <= 0) return null;
- // For large ranges (>15), show a compact slider-like view
- if (total > 15) {
+ // For bounded large ranges (>15), show range input
+ if (!isUnlimited && total > 15) {
return (
+ isCurrent ? (total <= 8 ? 16 : 12) : total <= 8 ? 10 : 8;
+
+ const handlePlusClick = () => {
+ onChange(selected + 1);
+ // Scroll to end after new dot appears
+ requestAnimationFrame(() => {
+ scrollRef.current?.scrollTo({
+ left: scrollRef.current.scrollWidth,
+ behavior: 'smooth',
+ });
+ });
+ };
+
+ // Track width calculation for filled portion
+ const filledRatio = total > 1 ? (selected - min) / (upperBound - min) : 0;
+
return (
-
- {/* Track background */}
+
@@ -97,32 +125,30 @@ function DotStepSelector({
{Array.from({ length: total }, (_, i) => {
const value = min + i;
const isFilled = value <= selected;
- const isCurrent = value === current;
+ const isCur = value === current;
const isLocked = value < connectedCount && value < selected;
- const isDisabled = value < Math.max(min, connectedCount) && value < current;
+ const floorValue = Math.max(min, connectedCount);
+ const isDisabled = value < floorValue && value < current;
return (
{
- if (value >= Math.max(min, connectedCount) || value > current) {
+ if (value >= floorValue || value > current) {
onChange(value);
}
}}
- disabled={isDisabled && value < connectedCount}
- className="relative z-10 flex items-center justify-center transition-all duration-200"
- style={{
- width: total <= 8 ? 32 : total <= 12 ? 24 : 20,
- height: total <= 8 ? 32 : total <= 12 ? 24 : 20,
- }}
+ disabled={isDisabled}
+ className="relative z-10 flex shrink-0 items-center justify-center transition-all duration-200"
+ style={{ width: dotSize, height: dotSize }}
aria-label={`${value}`}
>
);
})}
+
+ {/* "+" tail for unlimited */}
+ {isUnlimited && (
+
+
+
+ )}
);
@@ -171,7 +232,7 @@ export default function DeviceManager({
const mode: 'idle' | 'purchase' | 'reduce' =
selectedLimit > currentLimit ? 'purchase' : selectedLimit < currentLimit ? 'reduce' : 'idle';
- // Fetch initial range data (price info gives us max_device_limit)
+ // Fetch price data (gives us max_device_limit)
const { data: priceData } = useQuery({
queryKey: ['device-price', devicesToAdd || 1],
queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd || 1),
@@ -187,10 +248,14 @@ export default function DeviceManager({
});
const minLimit = reductionInfo?.min_device_limit ?? currentLimit;
- const maxLimit = priceData?.max_device_limit ?? currentLimit;
+ // null/undefined max_device_limit = unlimited
+ const maxLimit = priceData?.max_device_limit ?? null;
const connectedCount = reductionInfo?.connected_devices_count ?? 0;
const isInitializing = !priceData || !reductionInfo;
+ // Can we show the selector? Need range > 0 or unlimited
+ const hasRange = maxLimit === null || maxLimit > minLimit;
+
// Reset selectedLimit when currentLimit changes (after successful mutation)
useEffect(() => {
setSelectedLimit(currentLimit);
@@ -258,30 +323,22 @@ export default function DeviceManager({
return (
{/* Header */}
-
-
- {t('subscription.deviceManager.title')}
-
+
+
{t('subscription.deviceManager.title')}
✕
{isInitializing ? (
-
+
) : (
-
+
{/* Number badge + label */}
- {/* Dot selector */}
- {maxLimit > minLimit && (
+ {/* Dot selector — unified for bounded and unbounded */}
+ {hasRange && (
- {isPending ? (
-
-
-
- ) : mode === 'reduce' ? (
- t('subscription.additionalOptions.reduce')
- ) : (
- t('subscription.additionalOptions.buy')
- )}
-
+ {mode !== 'idle' && (
+
+ {isPending ? (
+
+
+
+ ) : mode === 'reduce' ? (
+ t('subscription.additionalOptions.reduce')
+ ) : (
+ t('subscription.additionalOptions.buy')
+ )}
+
+ )}
{/* Error */}
{mutationError && (
@@ -438,7 +501,7 @@ export default function DeviceManager({
);
}
-// ─── Trigger Button ───
+// ─── Trigger Button (no card wrapper — renders inside parent card) ───
interface DeviceManagerTriggerProps {
deviceLimit: number;
@@ -448,43 +511,29 @@ interface DeviceManagerTriggerProps {
export function DeviceManagerTrigger({ deviceLimit, isDark, onClick }: DeviceManagerTriggerProps) {
const { t } = useTranslation();
- const g = getGlassColors(isDark);
return (
-
-
- {t('subscription.additionalOptions.title')}
-
-
-
-
-
{t('subscription.deviceManager.title')}
-
- {t('subscription.additionalOptions.currentDeviceLimit', { count: deviceLimit })}
-
+
+
+
{t('subscription.deviceManager.title')}
+
+ {t('subscription.additionalOptions.currentDeviceLimit', { count: deviceLimit })}
-
-
-
-
-
+
+
+
+
+
);
}
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx
index b85ff46..93c1892 100644
--- a/src/pages/Subscription.tsx
+++ b/src/pages/Subscription.tsx
@@ -1074,28 +1074,7 @@ export default function Subscription() {
{/* Purchase / Renewal CTA */}
- {/* Device Manager */}
- {subscription &&
- subscription.is_active &&
- !subscription.is_trial &&
- subscription.device_limit !== 0 &&
- (showDeviceManager ? (
-
setShowDeviceManager(false)}
- />
- ) : (
- setShowDeviceManager(true)}
- />
- ))}
-
- {/* Additional Options (Traffic, Servers) */}
+ {/* Additional Options (Devices, Traffic, Servers) */}
{subscription && subscription.is_active && !subscription.is_trial && (
+ {/* Device Manager */}
+ {subscription.device_limit !== 0 &&
+ (showDeviceManager ? (
+
setShowDeviceManager(false)}
+ />
+ ) : (
+ setShowDeviceManager(true)}
+ />
+ ))}
+
{/* Buy Traffic */}
{subscription.traffic_limit_gb > 0 && (
From 71a0111a04d51f25ab8f4b226018519aeb3abcdc Mon Sep 17 00:00:00 2001
From: c0mrade
Date: Sun, 8 Mar 2026 01:00:21 +0300
Subject: [PATCH 6/9] revert: remove device manager redesign, restore original
device UI
---
src/components/subscription/DeviceManager.tsx | 539 -------
src/locales/en.json | 4 -
src/locales/fa.json | 4 -
src/locales/ru.json | 4 -
src/locales/zh.json | 4 -
src/pages/Subscription.tsx | 1242 +++++++++++------
6 files changed, 825 insertions(+), 972 deletions(-)
delete mode 100644 src/components/subscription/DeviceManager.tsx
diff --git a/src/components/subscription/DeviceManager.tsx b/src/components/subscription/DeviceManager.tsx
deleted file mode 100644
index 4031bb9..0000000
--- a/src/components/subscription/DeviceManager.tsx
+++ /dev/null
@@ -1,539 +0,0 @@
-import { useState, useEffect, useCallback, useRef } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { subscriptionApi } from '@/api/subscription';
-import { useCurrency } from '@/hooks/useCurrency';
-import { useHaptic } from '@/platform';
-import InsufficientBalancePrompt from '@/components/InsufficientBalancePrompt';
-import { getGlassColors } from '@/utils/glassTheme';
-import { getErrorMessage } from '@/utils/subscriptionHelpers';
-
-// ─── Types ───
-
-interface DeviceManagerProps {
- subscription: {
- device_limit: number;
- is_active: boolean;
- is_trial: boolean;
- };
- balanceKopeks: number;
- isDark: boolean;
- accentColor: string;
- onClose: () => void;
-}
-
-// ─── DotStepSelector ───
-// Unified: bounded ranges show fixed dots, unbounded adds a "+" tail that grows
-
-interface DotStepSelectorProps {
- min: number;
- max: number | null; // null = unlimited
- current: number;
- selected: number;
- connectedCount: number;
- onChange: (value: number) => void;
- accentColor: string;
- isDark: boolean;
-}
-
-function DotStepSelector({
- min,
- max,
- current,
- selected,
- connectedCount,
- onChange,
- accentColor,
- isDark,
-}: DotStepSelectorProps) {
- const isUnlimited = max === null;
- const scrollRef = useRef(null);
-
- // For unbounded: dots go from min to max(current, selected)
- // For bounded: dots go from min to max
- const upperBound = isUnlimited ? Math.max(current, selected) : max;
- const total = upperBound - min + 1;
-
- if (total <= 0) return null;
-
- // For bounded large ranges (>15), show range input
- if (!isUnlimited && total > 15) {
- return (
-
-
{
- const val = Number(e.target.value);
- if (val >= Math.max(min, connectedCount)) {
- onChange(val);
- }
- }}
- className="w-full accent-accent-400"
- style={{ accentColor }}
- />
-
- {min}
- {max}
-
-
- );
- }
-
- const dotSize = total <= 8 ? 32 : total <= 12 ? 24 : 20;
- const innerSize = (isCurrent: boolean) =>
- isCurrent ? (total <= 8 ? 16 : 12) : total <= 8 ? 10 : 8;
-
- const handlePlusClick = () => {
- onChange(selected + 1);
- // Scroll to end after new dot appears
- requestAnimationFrame(() => {
- scrollRef.current?.scrollTo({
- left: scrollRef.current.scrollWidth,
- behavior: 'smooth',
- });
- });
- };
-
- // Track width calculation for filled portion
- const filledRatio = total > 1 ? (selected - min) / (upperBound - min) : 0;
-
- return (
-
-
- {/* Filled track */}
-
-
- {/* Dots */}
- {Array.from({ length: total }, (_, i) => {
- const value = min + i;
- const isFilled = value <= selected;
- const isCur = value === current;
- const isLocked = value < connectedCount && value < selected;
- const floorValue = Math.max(min, connectedCount);
- const isDisabled = value < floorValue && value < current;
-
- return (
-
{
- if (value >= floorValue || value > current) {
- onChange(value);
- }
- }}
- disabled={isDisabled}
- className="relative z-10 flex shrink-0 items-center justify-center transition-all duration-200"
- style={{ width: dotSize, height: dotSize }}
- aria-label={`${value}`}
- >
-
-
- );
- })}
-
- {/* "+" tail for unlimited */}
- {isUnlimited && (
-
-
-
- )}
-
-
- );
-}
-
-// ─── DeviceManager ───
-
-export default function DeviceManager({
- subscription,
- balanceKopeks,
- isDark,
- accentColor,
- onClose,
-}: DeviceManagerProps) {
- const { t } = useTranslation();
- const queryClient = useQueryClient();
- const { formatAmount, currencySymbol } = useCurrency();
- const { impact: hapticImpact } = useHaptic();
- const g = getGlassColors(isDark);
-
- const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`;
-
- const currentLimit = subscription.device_limit;
- const [selectedLimit, setSelectedLimit] = useState(currentLimit);
-
- // Determine mode
- const devicesToAdd = selectedLimit > currentLimit ? selectedLimit - currentLimit : 0;
- const mode: 'idle' | 'purchase' | 'reduce' =
- selectedLimit > currentLimit ? 'purchase' : selectedLimit < currentLimit ? 'reduce' : 'idle';
-
- // Fetch price data (gives us max_device_limit)
- const { data: priceData } = useQuery({
- queryKey: ['device-price', devicesToAdd || 1],
- queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd || 1),
- enabled: !!subscription,
- placeholderData: (previousData) => previousData,
- });
-
- // Fetch reduction info (gives us min_device_limit, connected_devices_count)
- const { data: reductionInfo } = useQuery({
- queryKey: ['device-reduction-info'],
- queryFn: subscriptionApi.getDeviceReductionInfo,
- enabled: !!subscription,
- });
-
- const minLimit = reductionInfo?.min_device_limit ?? currentLimit;
- // null/undefined max_device_limit = unlimited
- const maxLimit = priceData?.max_device_limit ?? null;
- const connectedCount = reductionInfo?.connected_devices_count ?? 0;
- const isInitializing = !priceData || !reductionInfo;
-
- // Can we show the selector? Need range > 0 or unlimited
- const hasRange = maxLimit === null || maxLimit > minLimit;
-
- // Reset selectedLimit when currentLimit changes (after successful mutation)
- useEffect(() => {
- setSelectedLimit(currentLimit);
- }, [currentLimit]);
-
- const handleChange = useCallback(
- (value: number) => {
- setSelectedLimit(value);
- try {
- hapticImpact('light');
- } catch {
- /* not available */
- }
- },
- [hapticImpact],
- );
-
- // Purchase mutation
- const purchaseMutation = useMutation({
- mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['subscription'] });
- queryClient.invalidateQueries({ queryKey: ['devices'] });
- queryClient.invalidateQueries({ queryKey: ['device-price'] });
- onClose();
- },
- });
-
- // Reduce mutation
- const reduceMutation = useMutation({
- mutationFn: () => subscriptionApi.reduceDevices(selectedLimit),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['subscription'] });
- queryClient.invalidateQueries({ queryKey: ['devices'] });
- queryClient.invalidateQueries({ queryKey: ['device-reduction-info'] });
- onClose();
- },
- });
-
- const isPending = purchaseMutation.isPending || reduceMutation.isPending;
-
- const canSubmit = (() => {
- if (mode === 'idle' || isPending) return false;
- if (mode === 'purchase') {
- if (!priceData?.available) return false;
- if (priceData.total_price_kopeks && priceData.total_price_kopeks > balanceKopeks)
- return false;
- return true;
- }
- if (mode === 'reduce') {
- if (reductionInfo?.available === false) return false;
- if (selectedLimit < connectedCount) return false;
- if (selectedLimit < minLimit) return false;
- return true;
- }
- return false;
- })();
-
- const handleSubmit = () => {
- if (mode === 'purchase') purchaseMutation.mutate();
- if (mode === 'reduce') reduceMutation.mutate();
- };
-
- const mutationError = purchaseMutation.error || reduceMutation.error;
-
- return (
-
- {/* Header */}
-
-
{t('subscription.deviceManager.title')}
-
- ✕
-
-
-
- {isInitializing ? (
-
-
-
- ) : (
-
- {/* Number badge + label */}
-
-
- {selectedLimit}
-
-
- {t('subscription.deviceManager.devicesLabel')}
-
-
-
- {/* Dot selector — unified for bounded and unbounded */}
- {hasRange && (
-
- )}
-
- {/* New limit info */}
- {mode !== 'idle' && (
-
- {t('subscription.additionalOptions.newDeviceLimit', { count: selectedLimit })}
-
- )}
-
- {/* Purchase info */}
- {mode === 'purchase' && priceData?.available && priceData.price_per_device_label && (
-
-
- {priceData.discount_percent && priceData.discount_percent > 0 ? (
-
-
- {formatPrice(priceData.original_price_per_device_kopeks || 0)}
-
- {priceData.price_per_device_label}
-
- ) : (
- priceData.price_per_device_label
- )}
- /{t('subscription.perDevice').replace('/ ', '')} (
- {t('subscription.days', { count: priceData.days_left })})
-
-
- {priceData.discount_percent && priceData.discount_percent > 0 && (
-
-
- -{priceData.discount_percent}%
-
-
- )}
-
- {priceData.total_price_kopeks === 0 ? (
-
- {t('subscription.switchTariff.free')}
-
- ) : (
-
- {priceData.discount_percent &&
- priceData.discount_percent > 0 &&
- priceData.base_total_price_kopeks && (
-
- {formatPrice(priceData.base_total_price_kopeks)}
-
- )}
- {priceData.total_price_label}
-
- )}
-
- )}
-
- {/* Purchase unavailable reason */}
- {mode === 'purchase' && priceData?.available === false && priceData.reason && (
-
- {priceData.reason}
-
- )}
-
- {/* Reduce unavailable reason */}
- {mode === 'reduce' && reductionInfo?.available === false && reductionInfo.reason && (
-
- {reductionInfo.reason}
-
- )}
-
- {/* Reduce: connected devices warning */}
- {mode === 'reduce' &&
- reductionInfo?.available !== false &&
- connectedCount > selectedLimit && (
-
- {t('subscription.additionalOptions.disconnectDevicesFirst', {
- count: connectedCount,
- })}
-
- )}
-
- {/* Reduce info */}
- {mode === 'reduce' && reductionInfo && connectedCount <= selectedLimit && (
-
-
- {t('subscription.additionalOptions.connectedDevices', { count: connectedCount })}
-
-
- )}
-
- {/* Insufficient balance */}
- {mode === 'purchase' &&
- priceData?.available &&
- priceData.total_price_kopeks &&
- priceData.total_price_kopeks > balanceKopeks && (
-
{
- await subscriptionApi.saveDevicesCart(devicesToAdd);
- }}
- />
- )}
-
- {/* Action button */}
- {mode !== 'idle' && (
-
- {isPending ? (
-
-
-
- ) : mode === 'reduce' ? (
- t('subscription.additionalOptions.reduce')
- ) : (
- t('subscription.additionalOptions.buy')
- )}
-
- )}
-
- {/* Error */}
- {mutationError && (
-
- {getErrorMessage(mutationError)}
-
- )}
-
- )}
-
- );
-}
-
-// ─── Trigger Button (no card wrapper — renders inside parent card) ───
-
-interface DeviceManagerTriggerProps {
- deviceLimit: number;
- isDark: boolean;
- onClick: () => void;
-}
-
-export function DeviceManagerTrigger({ deviceLimit, isDark, onClick }: DeviceManagerTriggerProps) {
- const { t } = useTranslation();
-
- return (
-
-
-
-
{t('subscription.deviceManager.title')}
-
- {t('subscription.additionalOptions.currentDeviceLimit', { count: deviceLimit })}
-
-
-
-
-
-
-
- );
-}
diff --git a/src/locales/en.json b/src/locales/en.json
index 25edf3d..130143d 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -529,10 +529,6 @@
"manageServers": "Server management",
"manageServersTitle": "Server management"
},
- "deviceManager": {
- "title": "Change device count",
- "devicesLabel": "Devices"
- },
"serverManagement": {
"statusLegend": "✅ — connected • ➕ — will be added (paid) • ➖ — will be disconnected",
"discountBanner": "Your server discount: -{{percent}}%",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 53e82a9..89df8b6 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -351,10 +351,6 @@
"manageServers": "مدیریت سرورها",
"manageServersTitle": "مدیریت سرورها"
},
- "deviceManager": {
- "title": "تغییر تعداد دستگاهها",
- "devicesLabel": "دستگاهها"
- },
"serverManagement": {
"statusLegend": "✅ — متصل • ➕ — اضافه خواهد شد (پولی) • ➖ — قطع خواهد شد",
"discountBanner": "تخفیف سرور شما: -{{percent}}%",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 7b814c4..4f5022e 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -557,10 +557,6 @@
"manageServers": "Управление серверами",
"manageServersTitle": "Управление серверами"
},
- "deviceManager": {
- "title": "Изменить кол-во устройств",
- "devicesLabel": "Устройства"
- },
"serverManagement": {
"statusLegend": "✅ — подключено • ➕ — будет добавлено (платно) • ➖ — будет отключено",
"discountBanner": "Ваша скидка на серверы: -{{percent}}%",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 2c2e054..23640a2 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -351,10 +351,6 @@
"manageServers": "服务器管理",
"manageServersTitle": "服务器管理"
},
- "deviceManager": {
- "title": "更改设备数量",
- "devicesLabel": "设备"
- },
"serverManagement": {
"statusLegend": "✅ — 已连接 • ➕ — 将添加(付费)• ➖ — 将断开",
"discountBanner": "您的服务器折扣:-{{percent}}%",
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx
index 93c1892..6e0a54a 100644
--- a/src/pages/Subscription.tsx
+++ b/src/pages/Subscription.tsx
@@ -13,7 +13,6 @@ import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { useCurrency } from '../hooks/useCurrency';
import { useCloseOnSuccessNotification } from '../store/successNotification';
import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton';
-import DeviceManager, { DeviceManagerTrigger } from '../components/subscription/DeviceManager';
import { CopyIcon, CheckIcon } from '../components/icons';
import {
getErrorMessage,
@@ -176,7 +175,10 @@ export default function Subscription() {
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`;
// Device/traffic topup state
- const [showDeviceManager, setShowDeviceManager] = useState(false);
+ const [showDeviceTopup, setShowDeviceTopup] = useState(false);
+ const [devicesToAdd, setDevicesToAdd] = useState(1);
+ const [showDeviceReduction, setShowDeviceReduction] = useState(false);
+ const [targetDeviceLimit, setTargetDeviceLimit] = useState(1);
const [showTrafficTopup, setShowTrafficTopup] = useState(false);
const [selectedTrafficPackage, setSelectedTrafficPackage] = useState(null);
const [showServerManagement, setShowServerManagement] = useState(false);
@@ -253,12 +255,61 @@ export default function Subscription() {
// Auto-close all modals/forms when success notification appears
const handleCloseAllModals = useCallback(() => {
- setShowDeviceManager(false);
+ setShowDeviceTopup(false);
+ setShowDeviceReduction(false);
setShowTrafficTopup(false);
setShowServerManagement(false);
}, []);
useCloseOnSuccessNotification(handleCloseAllModals);
+ // Device price query
+ const { data: devicePriceData } = useQuery({
+ queryKey: ['device-price', devicesToAdd],
+ queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd),
+ enabled: showDeviceTopup && !!subscription,
+ });
+
+ // Device purchase mutation
+ const devicePurchaseMutation = useMutation({
+ mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({ queryKey: ['devices'] });
+ setShowDeviceTopup(false);
+ setDevicesToAdd(1);
+ },
+ });
+
+ // Device reduction info query
+ const { data: deviceReductionInfo } = useQuery({
+ queryKey: ['device-reduction-info'],
+ queryFn: subscriptionApi.getDeviceReductionInfo,
+ enabled: showDeviceReduction && !!subscription,
+ });
+
+ // Initialize target device limit when reduction info loads
+ useEffect(() => {
+ if (deviceReductionInfo && showDeviceReduction) {
+ setTargetDeviceLimit(
+ Math.max(
+ deviceReductionInfo.min_device_limit,
+ deviceReductionInfo.current_device_limit - 1,
+ ),
+ );
+ }
+ }, [deviceReductionInfo, showDeviceReduction]);
+
+ // Device reduction mutation
+ const deviceReductionMutation = useMutation({
+ mutationFn: () => subscriptionApi.reduceDevices(targetDeviceLimit),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({ queryKey: ['devices'] });
+ queryClient.invalidateQueries({ queryKey: ['device-reduction-info'] });
+ setShowDeviceReduction(false);
+ },
+ });
+
// Traffic packages query
const { data: trafficPackages } = useQuery({
queryKey: ['traffic-packages'],
@@ -1074,57 +1125,233 @@ export default function Subscription() {
{/* Purchase / Renewal CTA */}
- {/* Additional Options (Devices, Traffic, Servers) */}
- {subscription && subscription.is_active && !subscription.is_trial && (
-
-
- {t('subscription.additionalOptions.title')}
-
+ {/* Additional Options (Buy Devices) */}
+ {subscription &&
+ subscription.is_active &&
+ !subscription.is_trial &&
+ subscription.device_limit !== 0 && (
+
+
+ {t('subscription.additionalOptions.title')}
+
- {/* Device Manager */}
- {subscription.device_limit !== 0 &&
- (showDeviceManager ? (
-
setShowDeviceManager(false)}
- />
+ {/* Buy Devices */}
+ {!showDeviceTopup ? (
+ setShowDeviceTopup(true)}
+ className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
+ >
+
+
+
+ {t('subscription.additionalOptions.buyDevices')}
+
+
+ {t('subscription.additionalOptions.currentDeviceLimit', {
+ count: subscription.device_limit,
+ })}
+
+
+
+
+
+
+
) : (
- setShowDeviceManager(true)}
- />
- ))}
+
+
+
{t('subscription.buyDevices')}
+ setShowDeviceTopup(false)}
+ className="text-sm text-dark-400 hover:text-dark-200"
+ >
+ ✕
+
+
- {/* Buy Traffic */}
- {subscription.traffic_limit_gb > 0 && (
+ {/* Check if completely unavailable (no subscription, price not set, etc.) */}
+ {devicePriceData?.available === false && !devicePriceData?.max_device_limit ? (
+
+ {devicePriceData.reason ||
+ t('subscription.additionalOptions.devicesUnavailable')}
+
+ ) : (
+
+ {/* Device selector - show even at max limit */}
+
+
setDevicesToAdd(Math.max(1, devicesToAdd - 1))}
+ disabled={devicesToAdd <= 1}
+ className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
+ >
+ -
+
+
+
{devicesToAdd}
+
+ {t('subscription.additionalOptions.devicesUnit')}
+
+
+
setDevicesToAdd(devicesToAdd + 1)}
+ disabled={
+ devicePriceData?.max_device_limit
+ ? (devicePriceData.current_device_limit || 0) + devicesToAdd >=
+ devicePriceData.max_device_limit
+ : false
+ }
+ className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
+ >
+ +
+
+
+
+ {/* Show limit info when at or near max */}
+ {devicePriceData?.max_device_limit && (
+
+ {t('subscription.additionalOptions.currentDeviceLimit', {
+ count: devicePriceData.current_device_limit || subscription.device_limit,
+ })}{' '}
+ /{' '}
+ {t('subscription.additionalOptions.maxDevices', {
+ count: devicePriceData.max_device_limit,
+ })}
+
+ )}
+
+ {/* Show reason if can't add requested amount */}
+ {devicePriceData?.available === false && devicePriceData?.reason && (
+
+ {devicePriceData.reason}
+
+ )}
+
+ {/* Price info - only when available */}
+ {devicePriceData?.available && devicePriceData.price_per_device_label && (
+
+
+ {/* Show original price with strikethrough if discount */}
+ {devicePriceData.discount_percent &&
+ devicePriceData.discount_percent > 0 ? (
+
+
+ {formatPrice(devicePriceData.original_price_per_device_kopeks || 0)}
+
+ {devicePriceData.price_per_device_label}
+
+ ) : (
+ devicePriceData.price_per_device_label
+ )}
+ /{t('subscription.perDevice').replace('/ ', '')} (
+ {t('subscription.days', { count: devicePriceData.days_left })})
+
+ {/* Discount badge */}
+ {devicePriceData.discount_percent &&
+ devicePriceData.discount_percent > 0 && (
+
+
+ -{devicePriceData.discount_percent}%
+
+
+ )}
+ {/* Total price - show as free if 100% discount or 0 */}
+ {devicePriceData.total_price_kopeks === 0 ? (
+
+ {t('subscription.switchTariff.free')}
+
+ ) : (
+
+ {/* Show original total with strikethrough if discount */}
+ {devicePriceData.discount_percent &&
+ devicePriceData.discount_percent > 0 &&
+ devicePriceData.base_total_price_kopeks && (
+
+ {formatPrice(devicePriceData.base_total_price_kopeks)}
+
+ )}
+ {devicePriceData.total_price_label}
+
+ )}
+
+ )}
+
+ {devicePriceData?.available &&
+ purchaseOptions &&
+ devicePriceData.total_price_kopeks &&
+ devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks && (
+
{
+ await subscriptionApi.saveDevicesCart(devicesToAdd);
+ }}
+ />
+ )}
+
+ devicePurchaseMutation.mutate()}
+ disabled={
+ devicePurchaseMutation.isPending ||
+ !devicePriceData?.available ||
+ !!(
+ devicePriceData?.total_price_kopeks &&
+ purchaseOptions &&
+ devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks
+ )
+ }
+ className="btn-primary w-full py-3"
+ >
+ {devicePurchaseMutation.isPending ? (
+
+
+
+ ) : (
+ t('subscription.additionalOptions.buy')
+ )}
+
+
+ {devicePurchaseMutation.isError && (
+
+ {getErrorMessage(devicePurchaseMutation.error)}
+
+ )}
+
+ )}
+
+ )}
+
+ {/* Reduce Devices */}
- {!showTrafficTopup ? (
+ {!showDeviceReduction ? (
setShowTrafficTopup(true)}
+ onClick={() => setShowDeviceReduction(true)}
className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
>
- {t('subscription.additionalOptions.buyTraffic')}
+ {t('subscription.additionalOptions.reduceDevices')}
- {t('subscription.additionalOptions.currentTrafficLimit', {
- limit: subscription.traffic_limit_gb,
- used: subscription.traffic_used_gb.toFixed(1),
- })}
+ {t('subscription.additionalOptions.reduceDevicesDescription')}
- {t('subscription.additionalOptions.buyTrafficTitle')}
+ {t('subscription.additionalOptions.reduceDevicesTitle')}
{
- setShowTrafficTopup(false);
- setSelectedTrafficPackage(null);
- }}
+ onClick={() => setShowDeviceReduction(false)}
className="text-sm text-dark-400 hover:text-dark-200"
>
✕
-
- ⚠️ {t('subscription.additionalOptions.trafficWarning')}
-
-
- {!trafficPackages || trafficPackages.length === 0 ? (
+ {deviceReductionInfo?.available === false ? (
- {t('subscription.additionalOptions.trafficUnavailable')}
+ {deviceReductionInfo.reason ||
+ t('subscription.additionalOptions.reduceUnavailable')}
- ) : (
+ ) : deviceReductionInfo ? (
-
- {trafficPackages.map((pkg) => (
-
setSelectedTrafficPackage(pkg.gb)}
- className={`rounded-xl border p-4 text-center transition-all ${
- selectedTrafficPackage === pkg.gb
- ? 'border-accent-500 bg-accent-500/10'
- : isDark
- ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
- : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
- }`}
- >
-
- {pkg.is_unlimited
- ? '♾️ ' + t('subscription.additionalOptions.unlimited')
- : `${pkg.gb} ${t('common.units.gb')}`}
-
- {/* Discount badge */}
- {pkg.discount_percent && pkg.discount_percent > 0 && (
-
-
- -{pkg.discount_percent}%
-
-
- )}
- {/* Price with original strikethrough if discount */}
-
- {pkg.discount_percent &&
- pkg.discount_percent > 0 &&
- pkg.base_price_kopeks ? (
- <>
-
- {formatPrice(pkg.base_price_kopeks)}
-
- {formatPrice(pkg.price_kopeks)}
- >
- ) : (
- formatPrice(pkg.price_kopeks)
- )}
-
-
- ))}
+ {/* Device limit selector */}
+
+
+ setTargetDeviceLimit(
+ Math.max(
+ Math.max(
+ deviceReductionInfo.min_device_limit,
+ deviceReductionInfo.connected_devices_count,
+ ),
+ targetDeviceLimit - 1,
+ ),
+ )
+ }
+ disabled={
+ targetDeviceLimit <=
+ Math.max(
+ deviceReductionInfo.min_device_limit,
+ deviceReductionInfo.connected_devices_count,
+ )
+ }
+ className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
+ >
+ -
+
+
+
+ {targetDeviceLimit}
+
+
+ {t('subscription.additionalOptions.devicesUnit')}
+
+
+
+ setTargetDeviceLimit(
+ Math.min(
+ deviceReductionInfo.current_device_limit - 1,
+ targetDeviceLimit + 1,
+ ),
+ )
+ }
+ disabled={
+ targetDeviceLimit >= deviceReductionInfo.current_device_limit - 1
+ }
+ className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
+ >
+ +
+
- {selectedTrafficPackage !== null &&
- (() => {
- const selectedPkg = trafficPackages.find(
- (p) => p.gb === selectedTrafficPackage,
+ {/* Info */}
+
+
+ {t('subscription.additionalOptions.currentDeviceLimit', {
+ count: deviceReductionInfo.current_device_limit,
+ })}
+
+
+ {t('subscription.additionalOptions.minDeviceLimit', {
+ count: deviceReductionInfo.min_device_limit,
+ })}
+
+
+ {t('subscription.additionalOptions.connectedDevices', {
+ count: deviceReductionInfo.connected_devices_count,
+ })}
+
+
+
+ {/* Warning if connected devices block reduction */}
+ {deviceReductionInfo.connected_devices_count >
+ deviceReductionInfo.min_device_limit && (
+
+ {t('subscription.additionalOptions.disconnectDevicesFirst', {
+ count: deviceReductionInfo.connected_devices_count,
+ })}
+
+ )}
+
+ {/* New limit preview */}
+
+
+ {t('subscription.additionalOptions.newDeviceLimit', {
+ count: targetDeviceLimit,
+ })}
+
+
+
+
deviceReductionMutation.mutate()}
+ disabled={
+ deviceReductionMutation.isPending ||
+ targetDeviceLimit >= deviceReductionInfo.current_device_limit ||
+ targetDeviceLimit < deviceReductionInfo.min_device_limit ||
+ targetDeviceLimit < deviceReductionInfo.connected_devices_count
+ }
+ className="btn-primary w-full py-3"
+ >
+ {deviceReductionMutation.isPending ? (
+
+
+ {t('subscription.additionalOptions.reducing')}
+
+ ) : (
+ t('subscription.additionalOptions.reduce')
+ )}
+
+
+ {deviceReductionMutation.isError && (
+
+ {getErrorMessage(deviceReductionMutation.error)}
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+
+ {/* Buy Traffic */}
+ {subscription.traffic_limit_gb > 0 && (
+
+ {!showTrafficTopup ? (
+
setShowTrafficTopup(true)}
+ className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
+ >
+
+
+
+ {t('subscription.additionalOptions.buyTraffic')}
+
+
+ {t('subscription.additionalOptions.currentTrafficLimit', {
+ limit: subscription.traffic_limit_gb,
+ used: subscription.traffic_used_gb.toFixed(1),
+ })}
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ {t('subscription.additionalOptions.buyTrafficTitle')}
+
+ {
+ setShowTrafficTopup(false);
+ setSelectedTrafficPackage(null);
+ }}
+ className="text-sm text-dark-400 hover:text-dark-200"
+ >
+ ✕
+
+
+
+
+ ⚠️ {t('subscription.additionalOptions.trafficWarning')}
+
+
+ {!trafficPackages || trafficPackages.length === 0 ? (
+
+ {t('subscription.additionalOptions.trafficUnavailable')}
+
+ ) : (
+
+
+ {trafficPackages.map((pkg) => (
+
setSelectedTrafficPackage(pkg.gb)}
+ className={`rounded-xl border p-4 text-center transition-all ${
+ selectedTrafficPackage === pkg.gb
+ ? 'border-accent-500 bg-accent-500/10'
+ : isDark
+ ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
+ : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
+ }`}
+ >
+
+ {pkg.is_unlimited
+ ? '♾️ ' + t('subscription.additionalOptions.unlimited')
+ : `${pkg.gb} ${t('common.units.gb')}`}
+
+ {/* Discount badge */}
+ {pkg.discount_percent && pkg.discount_percent > 0 && (
+
+
+ -{pkg.discount_percent}%
+
+
+ )}
+ {/* Price with original strikethrough if discount */}
+
+ {pkg.discount_percent &&
+ pkg.discount_percent > 0 &&
+ pkg.base_price_kopeks ? (
+ <>
+
+ {formatPrice(pkg.base_price_kopeks)}
+
+ {formatPrice(pkg.price_kopeks)}
+ >
+ ) : (
+ formatPrice(pkg.price_kopeks)
+ )}
+
+
+ ))}
+
+
+ {selectedTrafficPackage !== null &&
+ (() => {
+ const selectedPkg = trafficPackages.find(
+ (p) => p.gb === selectedTrafficPackage,
+ );
+ const hasEnoughBalance =
+ !selectedPkg ||
+ !purchaseOptions ||
+ selectedPkg.price_kopeks <= purchaseOptions.balance_kopeks;
+ const missingAmount =
+ selectedPkg && purchaseOptions
+ ? selectedPkg.price_kopeks - purchaseOptions.balance_kopeks
+ : 0;
+
+ return (
+ <>
+ {!hasEnoughBalance && missingAmount > 0 && (
+
{
+ await subscriptionApi.saveTrafficCart(selectedTrafficPackage);
+ }}
+ />
+ )}
+
+ trafficPurchaseMutation.mutate(selectedTrafficPackage)
+ }
+ disabled={trafficPurchaseMutation.isPending || !hasEnoughBalance}
+ className="btn-primary w-full py-3"
+ >
+ {trafficPurchaseMutation.isPending ? (
+
+
+
+ ) : selectedPkg?.is_unlimited ? (
+ t('subscription.additionalOptions.buyUnlimited')
+ ) : (
+ t('subscription.additionalOptions.buyTrafficGb', {
+ gb: selectedTrafficPackage,
+ })
+ )}
+
+ >
+ );
+ })()}
+
+ {trafficPurchaseMutation.isError && (
+
+ {getErrorMessage(trafficPurchaseMutation.error)}
+
+ )}
+
+ )}
+
+ )}
+
+ )}
+
+ {/* Server Management - only in classic mode */}
+ {!isTariffsMode && (
+
+ {!showServerManagement ? (
+
setShowServerManagement(true)}
+ className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
+ >
+
+
+
+ {t('subscription.additionalOptions.manageServers')}
+
+
+ {t('subscription.servers', { count: subscription.servers?.length || 0 })}
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ {t('subscription.additionalOptions.manageServersTitle')}
+
+ {
+ setShowServerManagement(false);
+ setSelectedServersToUpdate([]);
+ }}
+ className="text-sm text-dark-400 hover:text-dark-200"
+ >
+ ✕
+
+
+
+ {countriesLoading ? (
+
+ ) : countriesData && countriesData.countries.length > 0 ? (
+
+
+ {t('subscription.serverManagement.statusLegend')}
+
+
+ {countriesData.discount_percent > 0 && (
+
+ 🎁{' '}
+ {t('subscription.serverManagement.discountBanner', {
+ percent: countriesData.discount_percent,
+ })}
+
+ )}
+
+
+ {countriesData.countries
+ .filter((country) => country.is_available || country.is_connected)
+ .map((country) => {
+ const isCurrentlyConnected = country.is_connected;
+ const isSelected = selectedServersToUpdate.includes(country.uuid);
+ const willBeAdded = !isCurrentlyConnected && isSelected;
+ const willBeRemoved = isCurrentlyConnected && !isSelected;
+
+ return (
+
{
+ if (isSelected) {
+ setSelectedServersToUpdate((prev) =>
+ prev.filter((u) => u !== country.uuid),
+ );
+ } else {
+ setSelectedServersToUpdate((prev) => [...prev, country.uuid]);
+ }
+ }}
+ disabled={!country.is_available && !isCurrentlyConnected}
+ className={`flex w-full items-center justify-between rounded-xl border p-3 text-left transition-all ${
+ isSelected
+ ? willBeAdded
+ ? 'border-success-500 bg-success-500/10'
+ : 'border-accent-500 bg-accent-500/10'
+ : willBeRemoved
+ ? 'border-error-500/50 bg-error-500/5'
+ : isDark
+ ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
+ : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
+ } ${!country.is_available && !isCurrentlyConnected ? 'cursor-not-allowed opacity-50' : ''}`}
+ >
+
+
+ {willBeAdded
+ ? '➕'
+ : willBeRemoved
+ ? '➖'
+ : isSelected
+ ? '✅'
+ : '⚪'}
+
+
+
+ {country.name}
+ {country.has_discount && !isCurrentlyConnected && (
+
+ -{country.discount_percent}%
+
+ )}
+
+ {willBeAdded && (
+
+ +{formatPrice(country.price_kopeks)}{' '}
+ {t('subscription.serverManagement.forDays', {
+ days: countriesData.days_left,
+ })}
+ {country.has_discount && (
+
+ {formatPrice(
+ Math.round(
+ (country.base_price_kopeks *
+ countriesData.days_left) /
+ 30,
+ ),
+ )}
+
+ )}
+
+ )}
+ {!willBeAdded && !isCurrentlyConnected && (
+
+ {formatPrice(country.price_per_month_kopeks)}
+ {t('subscription.serverManagement.perMonth')}
+ {country.has_discount && (
+
+ {formatPrice(country.base_price_kopeks)}
+
+ )}
+
+ )}
+ {!country.is_available && !isCurrentlyConnected && (
+
+ {t('subscription.serverManagement.unavailable')}
+
+ )}
+
+
+ {country.country_code && (
+
+ {getFlagEmoji(country.country_code)}
+
+ )}
+
+ );
+ })}
+
+
+ {(() => {
+ const currentConnected = countriesData.countries
+ .filter((c) => c.is_connected)
+ .map((c) => c.uuid);
+ const added = selectedServersToUpdate.filter(
+ (u) => !currentConnected.includes(u),
+ );
+ const removed = currentConnected.filter(
+ (u) => !selectedServersToUpdate.includes(u),
+ );
+ const hasChanges = added.length > 0 || removed.length > 0;
+
+ // Calculate cost for added servers
+ const addedServers = countriesData.countries.filter((c) =>
+ added.includes(c.uuid),
+ );
+ const totalCost = addedServers.reduce(
+ (sum, s) => sum + s.price_kopeks,
+ 0,
);
const hasEnoughBalance =
- !selectedPkg ||
- !purchaseOptions ||
- selectedPkg.price_kopeks <= purchaseOptions.balance_kopeks;
- const missingAmount =
- selectedPkg && purchaseOptions
- ? selectedPkg.price_kopeks - purchaseOptions.balance_kopeks
- : 0;
+ !purchaseOptions || totalCost <= purchaseOptions.balance_kopeks;
+ const missingAmount = purchaseOptions
+ ? totalCost - purchaseOptions.balance_kopeks
+ : 0;
- return (
- <>
- {!hasEnoughBalance && missingAmount > 0 && (
+ return hasChanges ? (
+
+ {added.length > 0 && (
+
+
+ {t('subscription.serverManagement.toAdd')}
+ {' '}
+
+ {addedServers.map((s) => s.name).join(', ')}
+
+
+ )}
+ {removed.length > 0 && (
+
+
+ {t('subscription.serverManagement.toDisconnect')}
+ {' '}
+
+ {countriesData.countries
+ .filter((c) => removed.includes(c.uuid))
+ .map((s) => s.name)
+ .join(', ')}
+
+
+ )}
+ {totalCost > 0 && (
+
+
+ {t('subscription.serverManagement.paymentProrated')}
+
+
+ {formatPrice(totalCost)}
+
+
+ )}
+
+ {totalCost > 0 && !hasEnoughBalance && missingAmount > 0 && (
{
- await subscriptionApi.saveTrafficCart(selectedTrafficPackage);
- }}
/>
)}
+
- trafficPurchaseMutation.mutate(selectedTrafficPackage)
+ updateCountriesMutation.mutate(selectedServersToUpdate)
+ }
+ disabled={
+ updateCountriesMutation.isPending ||
+ selectedServersToUpdate.length === 0 ||
+ (totalCost > 0 && !hasEnoughBalance)
}
- disabled={trafficPurchaseMutation.isPending || !hasEnoughBalance}
className="btn-primary w-full py-3"
>
- {trafficPurchaseMutation.isPending ? (
+ {updateCountriesMutation.isPending ? (
- ) : selectedPkg?.is_unlimited ? (
- t('subscription.additionalOptions.buyUnlimited')
) : (
- t('subscription.additionalOptions.buyTrafficGb', {
- gb: selectedTrafficPackage,
- })
+ t('subscription.serverManagement.applyChanges')
)}
- >
+
+ ) : (
+
+ {t('subscription.serverManagement.selectServersHint')}
+
);
})()}
- {trafficPurchaseMutation.isError && (
-
- {getErrorMessage(trafficPurchaseMutation.error)}
-
- )}
-
- )}
-
- )}
-
- )}
-
- {/* Server Management - only in classic mode */}
- {!isTariffsMode && (
-
- {!showServerManagement ? (
-
setShowServerManagement(true)}
- className={`w-full rounded-xl border p-4 text-left transition-colors ${isDark ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600' : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'}`}
- >
-
-
-
- {t('subscription.additionalOptions.manageServers')}
-
-
- {t('subscription.servers', { count: subscription.servers?.length || 0 })}
-
-
-
-
-
-
-
- ) : (
-
-
-
- {t('subscription.additionalOptions.manageServersTitle')}
-
- {
- setShowServerManagement(false);
- setSelectedServersToUpdate([]);
- }}
- className="text-sm text-dark-400 hover:text-dark-200"
- >
- ✕
-
-
-
- {countriesLoading ? (
-
- ) : countriesData && countriesData.countries.length > 0 ? (
-
-
- {t('subscription.serverManagement.statusLegend')}
-
-
- {countriesData.discount_percent > 0 && (
-
- 🎁{' '}
- {t('subscription.serverManagement.discountBanner', {
- percent: countriesData.discount_percent,
- })}
-
- )}
-
-
- {countriesData.countries
- .filter((country) => country.is_available || country.is_connected)
- .map((country) => {
- const isCurrentlyConnected = country.is_connected;
- const isSelected = selectedServersToUpdate.includes(country.uuid);
- const willBeAdded = !isCurrentlyConnected && isSelected;
- const willBeRemoved = isCurrentlyConnected && !isSelected;
-
- return (
-
{
- if (isSelected) {
- setSelectedServersToUpdate((prev) =>
- prev.filter((u) => u !== country.uuid),
- );
- } else {
- setSelectedServersToUpdate((prev) => [...prev, country.uuid]);
- }
- }}
- disabled={!country.is_available && !isCurrentlyConnected}
- className={`flex w-full items-center justify-between rounded-xl border p-3 text-left transition-all ${
- isSelected
- ? willBeAdded
- ? 'border-success-500 bg-success-500/10'
- : 'border-accent-500 bg-accent-500/10'
- : willBeRemoved
- ? 'border-error-500/50 bg-error-500/5'
- : isDark
- ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
- : 'border-champagne-300/60 bg-champagne-200/40 hover:border-champagne-400'
- } ${!country.is_available && !isCurrentlyConnected ? 'cursor-not-allowed opacity-50' : ''}`}
- >
-
-
- {willBeAdded
- ? '➕'
- : willBeRemoved
- ? '➖'
- : isSelected
- ? '✅'
- : '⚪'}
-
-
-
- {country.name}
- {country.has_discount && !isCurrentlyConnected && (
-
- -{country.discount_percent}%
-
- )}
-
- {willBeAdded && (
-
- +{formatPrice(country.price_kopeks)}{' '}
- {t('subscription.serverManagement.forDays', {
- days: countriesData.days_left,
- })}
- {country.has_discount && (
-
- {formatPrice(
- Math.round(
- (country.base_price_kopeks *
- countriesData.days_left) /
- 30,
- ),
- )}
-
- )}
-
- )}
- {!willBeAdded && !isCurrentlyConnected && (
-
- {formatPrice(country.price_per_month_kopeks)}
- {t('subscription.serverManagement.perMonth')}
- {country.has_discount && (
-
- {formatPrice(country.base_price_kopeks)}
-
- )}
-
- )}
- {!country.is_available && !isCurrentlyConnected && (
-
- {t('subscription.serverManagement.unavailable')}
-
- )}
-
-
- {country.country_code && (
-
- {getFlagEmoji(country.country_code)}
-
- )}
-
- );
- })}
-
-
- {(() => {
- const currentConnected = countriesData.countries
- .filter((c) => c.is_connected)
- .map((c) => c.uuid);
- const added = selectedServersToUpdate.filter(
- (u) => !currentConnected.includes(u),
- );
- const removed = currentConnected.filter(
- (u) => !selectedServersToUpdate.includes(u),
- );
- const hasChanges = added.length > 0 || removed.length > 0;
-
- // Calculate cost for added servers
- const addedServers = countriesData.countries.filter((c) =>
- added.includes(c.uuid),
- );
- const totalCost = addedServers.reduce((sum, s) => sum + s.price_kopeks, 0);
- const hasEnoughBalance =
- !purchaseOptions || totalCost <= purchaseOptions.balance_kopeks;
- const missingAmount = purchaseOptions
- ? totalCost - purchaseOptions.balance_kopeks
- : 0;
-
- return hasChanges ? (
-
- {added.length > 0 && (
-
-
- {t('subscription.serverManagement.toAdd')}
- {' '}
-
- {addedServers.map((s) => s.name).join(', ')}
-
-
- )}
- {removed.length > 0 && (
-
-
- {t('subscription.serverManagement.toDisconnect')}
- {' '}
-
- {countriesData.countries
- .filter((c) => removed.includes(c.uuid))
- .map((s) => s.name)
- .join(', ')}
-
-
- )}
- {totalCost > 0 && (
-
-
- {t('subscription.serverManagement.paymentProrated')}
-
-
- {formatPrice(totalCost)}
-
-
- )}
-
- {totalCost > 0 && !hasEnoughBalance && missingAmount > 0 && (
-
- )}
-
-
- updateCountriesMutation.mutate(selectedServersToUpdate)
- }
- disabled={
- updateCountriesMutation.isPending ||
- selectedServersToUpdate.length === 0 ||
- (totalCost > 0 && !hasEnoughBalance)
- }
- className="btn-primary w-full py-3"
- >
- {updateCountriesMutation.isPending ? (
-
-
-
- ) : (
- t('subscription.serverManagement.applyChanges')
- )}
-
+ {updateCountriesMutation.isError && (
+
+ {getErrorMessage(updateCountriesMutation.error)}
- ) : (
-
- {t('subscription.serverManagement.selectServersHint')}
-
- );
- })()}
-
- {updateCountriesMutation.isError && (
-
- {getErrorMessage(updateCountriesMutation.error)}
-
- )}
-
- ) : (
-
- {t('subscription.serverManagement.noServersAvailable')}
-
- )}
-
- )}
-
- )}
-
- )}
+ )}
+
+ ) : (
+
+ {t('subscription.serverManagement.noServersAvailable')}
+
+ )}
+
+ )}
+
+ )}
+
+ )}
{/* My Devices Section */}
{subscription && (
From f1102d278354ae3225f8b36029590d8c01b74ea0 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Sun, 8 Mar 2026 14:17:45 +0300
Subject: [PATCH 7/9] fix: add purchase-options cache invalidation on balance
changes
- WebSocketNotifications: invalidate purchase-options on all 10 balance-affecting events
- Dashboard: invalidate purchase-options after trial activation
- Balance: invalidate purchase-options after payment return and promocode activation
- PromoOffersSection: invalidate purchase-options and balance after promo claim
---
src/components/PromoOffersSection.tsx | 2 ++
src/components/WebSocketNotifications.tsx | 10 ++++++++++
src/pages/Balance.tsx | 2 ++
src/pages/Dashboard.tsx | 1 +
4 files changed, 15 insertions(+)
diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx
index edc9674..a8accdf 100644
--- a/src/components/PromoOffersSection.tsx
+++ b/src/components/PromoOffersSection.tsx
@@ -116,6 +116,8 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
+ queryClient.invalidateQueries({ queryKey: ['balance'] });
setSuccessMessage(result.message);
setClaimingId(null);
setTimeout(() => setSuccessMessage(null), 5000);
diff --git a/src/components/WebSocketNotifications.tsx b/src/components/WebSocketNotifications.tsx
index 5d93cbe..d411263 100644
--- a/src/components/WebSocketNotifications.tsx
+++ b/src/components/WebSocketNotifications.tsx
@@ -41,6 +41,7 @@ export default function WebSocketNotifications() {
});
// Refresh data
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -67,6 +68,7 @@ export default function WebSocketNotifications() {
duration: 5000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -82,6 +84,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -95,6 +98,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -151,6 +155,7 @@ export default function WebSocketNotifications() {
duration: 5000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -180,6 +185,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -195,6 +201,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -220,6 +227,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -329,6 +337,7 @@ export default function WebSocketNotifications() {
duration: 8000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['referral-stats'] });
refreshUser();
return;
@@ -375,6 +384,7 @@ export default function WebSocketNotifications() {
duration: 6000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx
index f6f9809..f591ccc 100644
--- a/src/pages/Balance.tsx
+++ b/src/pages/Balance.tsx
@@ -79,6 +79,7 @@ export default function Balance() {
refreshUser();
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
showToast({
type: 'success',
@@ -164,6 +165,7 @@ export default function Balance() {
await refetchBalance();
await refreshUser();
queryClient.invalidateQueries({ queryKey: ['transactions'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
}
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { detail?: string } } };
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index b355b6d..2861117 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -87,6 +87,7 @@ export default function Dashboard() {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
},
onError: (error: { response?: { data?: { detail?: string } } }) => {
From bda95ed23f3b445c9a4a295a3be65310dae039e0 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Sun, 8 Mar 2026 14:26:05 +0300
Subject: [PATCH 8/9] fix: show infinity symbol for unlimited traffic on
landing page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Display ∞ instead of 0 when traffic_limit_gb is 0 (unlimited),
matching the pattern used in TrialOfferCard and Dashboard.
---
src/pages/QuickPurchase.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx
index 537f03e..c50899d 100644
--- a/src/pages/QuickPurchase.tsx
+++ b/src/pages/QuickPurchase.tsx
@@ -316,7 +316,7 @@ function TariffCard({
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
- {tariff.traffic_limit_gb} {t('landing.gb', 'GB')}
+ {tariff.traffic_limit_gb === 0 ? '∞' : tariff.traffic_limit_gb} {t('landing.gb', 'GB')}
Date: Sun, 8 Mar 2026 14:58:23 +0300
Subject: [PATCH 9/9] fix: mobile layout overflow on landing page
- Add min-w-0 to grid children to prevent CSS Grid blowout on mobile
- Add break-words to SanitizedHtml to wrap long URLs in footer text
---
src/pages/QuickPurchase.tsx | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx
index c50899d..2e18611 100644
--- a/src/pages/QuickPurchase.tsx
+++ b/src/pages/QuickPurchase.tsx
@@ -462,7 +462,9 @@ function SanitizedHtml({ html, className }: { html: string; className?: string }
return container.innerHTML;
}, [html]);
- return
;
+ return (
+
+ );
}
function SummaryCard({
@@ -1000,7 +1002,7 @@ export default function QuickPurchase() {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
- className="space-y-6"
+ className="min-w-0 space-y-6"
>
{/* Period tabs */}
{allPeriods.length > 0 && (
@@ -1104,7 +1106,7 @@ export default function QuickPurchase() {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
- className="lg:sticky lg:top-8 lg:self-start"
+ className="min-w-0 lg:sticky lg:top-8 lg:self-start"
>