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 ( + + ); + })} +
+
+ ); +} + +// ─── 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 */} + + + {/* 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')} +

+ +
+ ); +} 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 ? ( - - ) : ( -
-
-

{t('subscription.buyDevices')}

- -
+ {/* 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 */} -
- -
-
{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 */} + {/* Buy Traffic */} + {subscription.traffic_limit_gb > 0 && (
- {!showDeviceReduction ? ( + {!showTrafficTopup ? (
- {deviceReductionInfo?.available === false ? ( +
+ ⚠️ {t('subscription.additionalOptions.trafficWarning')} +
+ + {!trafficPackages || trafficPackages.length === 0 ? (
- {deviceReductionInfo.reason || - t('subscription.additionalOptions.reduceUnavailable')} -
- ) : deviceReductionInfo ? ( -
- {/* Device limit selector */} -
- -
-
- {targetDeviceLimit} -
-
- {t('subscription.additionalOptions.devicesUnit')} -
-
- -
- - {/* 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)} -
- )} + {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)} +
+ )}
)}
)}
+ )} - {/* Buy Traffic */} - {subscription.traffic_limit_gb > 0 && ( -
- {!showTrafficTopup ? ( - + ) : ( +
+
+

+ {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, })}
-
- - - -
- - ) : ( -
-
-

- {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; +
+ {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.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)} + )} + {!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 && ( - - )} - - -
- ) : ( -
- {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 && ( + + )} + +
- )} -
- ) : ( -
- {t('subscription.serverManagement.noServersAvailable')} -
- )} -
- )} -
- )} -
- )} + ) : ( +
+ {t('subscription.serverManagement.selectServersHint')} +
+ ); + })()} + + {updateCountriesMutation.isError && ( +
+ {getErrorMessage(updateCountriesMutation.error)} +
+ )} +
+ ) : ( +
+ {t('subscription.serverManagement.noServersAvailable')} +
+ )} +
+ )} +
+ )} +
+ )} {/* My Devices Section */} {subscription && (