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 ( - - ); - })} - - {/* "+" 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' && ( - - )} - - {/* 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 ( - - ); -} 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 ? ( + ) : ( - setShowDeviceManager(true)} - /> - ))} +
+
+

{t('subscription.buyDevices')}

+ +
- {/* 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 */} +
+ +
+
{devicesToAdd}
+
+ {t('subscription.additionalOptions.devicesUnit')} +
+
+ +
+ + {/* 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.isError && ( +
+ {getErrorMessage(devicePurchaseMutation.error)} +
+ )} +
+ )} +
+ )} + + {/* Reduce Devices */}
- {!showTrafficTopup ? ( + {!showDeviceReduction ? (
-
- ⚠️ {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) => ( - - ))} + {/* Device limit selector */} +
+ +
+
+ {targetDeviceLimit} +
+
+ {t('subscription.additionalOptions.devicesUnit')} +
+
+
- {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.isError && ( +
+ {getErrorMessage(deviceReductionMutation.error)} +
+ )} +
+ ) : ( +
+ +
+ )} +
+ )} +
+ + {/* Buy Traffic */} + {subscription.traffic_limit_gb > 0 && ( +
+ {!showTrafficTopup ? ( + + ) : ( +
+
+

+ {t('subscription.additionalOptions.buyTrafficTitle')} +

+ +
+ +
+ ⚠️ {t('subscription.additionalOptions.trafficWarning')} +
+ + {!trafficPackages || trafficPackages.length === 0 ? ( +
+ {t('subscription.additionalOptions.trafficUnavailable')} +
+ ) : ( +
+
+ {trafficPackages.map((pkg) => ( + + ))} +
+ + {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.isError && ( +
+ {getErrorMessage(trafficPurchaseMutation.error)} +
+ )} +
+ )} +
+ )} +
+ )} + + {/* Server Management - only in classic mode */} + {!isTariffsMode && ( +
+ {!showServerManagement ? ( + + ) : ( +
+
+

+ {t('subscription.additionalOptions.manageServersTitle')} +

+ +
+ + {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 ( + + ); + })} +
+ + {(() => { + 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); - }} /> )} + - +
+ ) : ( +
+ {t('subscription.serverManagement.selectServersHint')} +
); })()} - {trafficPurchaseMutation.isError && ( -
- {getErrorMessage(trafficPurchaseMutation.error)} -
- )} -
- )} -
- )} -
- )} - - {/* Server Management - only in classic mode */} - {!isTariffsMode && ( -
- {!showServerManagement ? ( - - ) : ( -
-
-

- {t('subscription.additionalOptions.manageServersTitle')} -

- -
- - {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 ( - - ); - })} -
- - {(() => { - 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.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 && (