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.
This commit is contained in:
c0mrade
2026-03-08 00:17:04 +03:00
parent 7bb75aa920
commit edb7ef0488
6 changed files with 926 additions and 825 deletions

View File

@@ -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 (
<div className="px-2">
<input
type="range"
min={min}
max={max}
value={selected}
onChange={(e) => {
const val = Number(e.target.value);
if (val >= Math.max(min, connectedCount)) {
onChange(val);
}
}}
className="w-full accent-accent-400"
style={{ accentColor }}
/>
<div className="mt-2 flex justify-between text-[10px] text-dark-50/30">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center gap-0">
{/* Track background */}
<div
className="relative flex items-center rounded-full p-1.5"
style={{
background: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)',
}}
>
{/* Filled track */}
<div
className="pointer-events-none absolute bottom-1.5 left-1.5 top-1.5 rounded-full transition-all duration-300 ease-out"
style={{
width: `${((selected - min) / (max - min)) * 100}%`,
background: `linear-gradient(90deg, ${accentColor}40, ${accentColor}90)`,
}}
/>
{/* 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 (
<button
key={value}
type="button"
onClick={() => {
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}`}
>
<div
className="rounded-full transition-all duration-300"
style={{
width: isCurrent ? (total <= 8 ? 16 : 12) : total <= 8 ? 10 : 8,
height: isCurrent ? (total <= 8 ? 16 : 12) : total <= 8 ? 10 : 8,
background: isFilled
? isLocked
? isDark
? 'rgba(255,255,255,0.2)'
: 'rgba(0,0,0,0.2)'
: accentColor
: isDark
? 'rgba(255,255,255,0.12)'
: 'rgba(0,0,0,0.12)',
boxShadow: isCurrent && isFilled ? `0 0 8px ${accentColor}60` : 'none',
border: isCurrent
? `2px solid ${isFilled ? accentColor : isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.25)'}`
: 'none',
}}
/>
</button>
);
})}
</div>
</div>
);
}
// ─── 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 (
<div
className="relative overflow-hidden rounded-3xl"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '24px 28px',
}}
>
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<h2 className="text-base font-bold tracking-tight text-dark-50">
{t('subscription.deviceManager.title')}
</h2>
<button onClick={onClose} className="text-sm text-dark-400 hover:text-dark-200">
</button>
</div>
{isInitializing ? (
<div className="flex items-center justify-center py-8">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-accent-400/30 border-t-accent-400" />
</div>
) : (
<div className="space-y-5">
{/* Number badge + label */}
<div className="flex items-center gap-3">
<div
className="flex h-12 w-12 items-center justify-center rounded-2xl text-lg font-bold"
style={{
background: `${accentColor}15`,
color: accentColor,
border: `1px solid ${accentColor}25`,
}}
>
{selectedLimit}
</div>
<span className="text-base font-medium text-dark-100">
{t('subscription.deviceManager.devicesLabel')}
</span>
</div>
{/* Dot selector */}
{maxLimit > minLimit && (
<DotStepSelector
min={minLimit}
max={maxLimit}
current={currentLimit}
selected={selectedLimit}
connectedCount={connectedCount}
onChange={handleChange}
accentColor={accentColor}
isDark={isDark}
/>
)}
{/* New limit info */}
{mode !== 'idle' && (
<div className="text-center text-sm" style={{ color: g.textSecondary }}>
{t('subscription.additionalOptions.newDeviceLimit', { count: selectedLimit })}
</div>
)}
{/* Purchase info */}
{mode === 'purchase' && priceData?.available && priceData.price_per_device_label && (
<div className="text-center">
<div className="mb-2 text-sm" style={{ color: g.textSecondary }}>
{priceData.discount_percent && priceData.discount_percent > 0 ? (
<span>
<span className="line-through" style={{ color: g.textMuted }}>
{formatPrice(priceData.original_price_per_device_kopeks || 0)}
</span>
<span className="mx-1">{priceData.price_per_device_label}</span>
</span>
) : (
priceData.price_per_device_label
)}
/{t('subscription.perDevice').replace('/ ', '')} (
{t('subscription.days', { count: priceData.days_left })})
</div>
{priceData.discount_percent && priceData.discount_percent > 0 && (
<div className="mb-2">
<span className="inline-block rounded-full bg-success-500/20 px-2.5 py-0.5 text-sm font-medium text-success-400">
-{priceData.discount_percent}%
</span>
</div>
)}
{priceData.total_price_kopeks === 0 ? (
<div className="text-2xl font-bold text-success-400">
{t('subscription.switchTariff.free')}
</div>
) : (
<div className="text-2xl font-bold text-accent-400">
{priceData.discount_percent &&
priceData.discount_percent > 0 &&
priceData.base_total_price_kopeks && (
<span className="mr-2 text-lg line-through" style={{ color: g.textMuted }}>
{formatPrice(priceData.base_total_price_kopeks)}
</span>
)}
{priceData.total_price_label}
</div>
)}
</div>
)}
{/* Purchase unavailable reason */}
{mode === 'purchase' && priceData?.available === false && priceData.reason && (
<div className="rounded-lg bg-warning-500/10 p-3 text-center text-sm text-warning-400">
{priceData.reason}
</div>
)}
{/* Reduce unavailable reason */}
{mode === 'reduce' && reductionInfo?.available === false && reductionInfo.reason && (
<div className="rounded-lg bg-warning-500/10 p-3 text-center text-sm text-warning-400">
{reductionInfo.reason}
</div>
)}
{/* Reduce: connected devices warning */}
{mode === 'reduce' &&
reductionInfo?.available !== false &&
connectedCount > selectedLimit && (
<div className="rounded-lg bg-warning-500/10 p-3 text-center text-sm text-warning-400">
{t('subscription.additionalOptions.disconnectDevicesFirst', {
count: connectedCount,
})}
</div>
)}
{/* Reduce info */}
{mode === 'reduce' && reductionInfo && connectedCount <= selectedLimit && (
<div className="space-y-1 text-center text-sm" style={{ color: g.textSecondary }}>
<div>
{t('subscription.additionalOptions.connectedDevices', { count: connectedCount })}
</div>
</div>
)}
{/* Insufficient balance */}
{mode === 'purchase' &&
priceData?.available &&
priceData.total_price_kopeks &&
priceData.total_price_kopeks > balanceKopeks && (
<InsufficientBalancePrompt
missingAmountKopeks={priceData.total_price_kopeks - balanceKopeks}
compact
onBeforeTopUp={async () => {
await subscriptionApi.saveDevicesCart(devicesToAdd);
}}
/>
)}
{/* Action button */}
<button onClick={handleSubmit} disabled={!canSubmit} className="btn-primary w-full py-3">
{isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</span>
) : mode === 'reduce' ? (
t('subscription.additionalOptions.reduce')
) : (
t('subscription.additionalOptions.buy')
)}
</button>
{/* Error */}
{mutationError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(mutationError)}
</div>
)}
</div>
)}
</div>
);
}
// ─── 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 (
<div
className="relative overflow-hidden rounded-3xl"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '24px 28px',
}}
>
<h2 className="mb-4 text-base font-bold tracking-tight text-dark-50">
{t('subscription.additionalOptions.title')}
</h2>
<button
onClick={onClick}
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'}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">{t('subscription.deviceManager.title')}</div>
<div className="mt-1 text-sm text-dark-400">
{t('subscription.additionalOptions.currentDeviceLimit', { count: deviceLimit })}
</div>
</div>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
</div>
);
}

View File

@@ -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}}%",

View File

@@ -351,6 +351,10 @@
"manageServers": "مدیریت سرورها",
"manageServersTitle": "مدیریت سرورها"
},
"deviceManager": {
"title": "تغییر تعداد دستگاه‌ها",
"devicesLabel": "دستگاه‌ها"
},
"serverManagement": {
"statusLegend": "✅ — متصل • — اضافه خواهد شد (پولی) • — قطع خواهد شد",
"discountBanner": "تخفیف سرور شما: -{{percent}}%",

View File

@@ -557,6 +557,10 @@
"manageServers": "Управление серверами",
"manageServersTitle": "Управление серверами"
},
"deviceManager": {
"title": "Изменить кол-во устройств",
"devicesLabel": "Устройства"
},
"serverManagement": {
"statusLegend": "✅ — подключено • — будет добавлено (платно) • — будет отключено",
"discountBanner": "Ваша скидка на серверы: -{{percent}}%",

View File

@@ -351,6 +351,10 @@
"manageServers": "服务器管理",
"manageServersTitle": "服务器管理"
},
"deviceManager": {
"title": "更改设备数量",
"devicesLabel": "设备"
},
"serverManagement": {
"statusLegend": "✅ — 已连接 • — 将添加(付费)• — 将断开",
"discountBanner": "您的服务器折扣:-{{percent}}%",

View File

@@ -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<number>(1);
const [showDeviceManager, setShowDeviceManager] = useState(false);
const [showTrafficTopup, setShowTrafficTopup] = useState(false);
const [selectedTrafficPackage, setSelectedTrafficPackage] = useState<number | null>(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,11 +1074,29 @@ export default function Subscription() {
{/* Purchase / Renewal CTA */}
<PurchaseCTAButton subscription={subscription} />
{/* Additional Options (Buy Devices) */}
{/* Device Manager */}
{subscription &&
subscription.is_active &&
!subscription.is_trial &&
subscription.device_limit !== 0 && (
subscription.device_limit !== 0 &&
(showDeviceManager ? (
<DeviceManager
subscription={subscription}
balanceKopeks={purchaseOptions?.balance_kopeks ?? 0}
isDark={isDark}
accentColor={zone.mainHex}
onClose={() => setShowDeviceManager(false)}
/>
) : (
<DeviceManagerTrigger
deviceLimit={subscription.device_limit}
isDark={isDark}
onClick={() => setShowDeviceManager(true)}
/>
))}
{/* Additional Options (Traffic, Servers) */}
{subscription && subscription.is_active && !subscription.is_trial && (
<div
className="relative overflow-hidden rounded-3xl"
style={{
@@ -1143,375 +1110,6 @@ export default function Subscription() {
{t('subscription.additionalOptions.title')}
</h2>
{/* Buy Devices */}
{!showDeviceTopup ? (
<button
onClick={() => 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'}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">
{t('subscription.additionalOptions.buyDevices')}
</div>
<div className="mt-1 text-sm text-dark-400">
{t('subscription.additionalOptions.currentDeviceLimit', {
count: subscription.device_limit,
})}
</div>
</div>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
) : (
<div
className={`rounded-xl border p-5 ${isDark ? 'border-dark-700/50 bg-dark-800/50' : 'border-champagne-300/60 bg-champagne-200/40'}`}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">{t('subscription.buyDevices')}</h3>
<button
onClick={() => setShowDeviceTopup(false)}
className="text-sm text-dark-400 hover:text-dark-200"
>
</button>
</div>
{/* Check if completely unavailable (no subscription, price not set, etc.) */}
{devicePriceData?.available === false && !devicePriceData?.max_device_limit ? (
<div className="py-4 text-center text-sm text-dark-400">
{devicePriceData.reason ||
t('subscription.additionalOptions.devicesUnavailable')}
</div>
) : (
<div className="space-y-4">
{/* Device selector - show even at max limit */}
<div className="flex items-center justify-center gap-6">
<button
onClick={() => 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"
>
-
</button>
<div className="text-center">
<div className="text-4xl font-bold text-dark-100">{devicesToAdd}</div>
<div className="text-sm text-dark-500">
{t('subscription.additionalOptions.devicesUnit')}
</div>
</div>
<button
onClick={() => 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"
>
+
</button>
</div>
{/* Show limit info when at or near max */}
{devicePriceData?.max_device_limit && (
<div className="text-center text-sm text-dark-400">
{t('subscription.additionalOptions.currentDeviceLimit', {
count: devicePriceData.current_device_limit || subscription.device_limit,
})}{' '}
/{' '}
{t('subscription.additionalOptions.maxDevices', {
count: devicePriceData.max_device_limit,
})}
</div>
)}
{/* Show reason if can't add requested amount */}
{devicePriceData?.available === false && devicePriceData?.reason && (
<div className="rounded-lg bg-warning-500/10 p-3 text-center text-sm text-warning-400">
{devicePriceData.reason}
</div>
)}
{/* Price info - only when available */}
{devicePriceData?.available && devicePriceData.price_per_device_label && (
<div className="text-center">
<div className="mb-2 text-sm text-dark-400">
{/* Show original price with strikethrough if discount */}
{devicePriceData.discount_percent &&
devicePriceData.discount_percent > 0 ? (
<span>
<span className="text-dark-500 line-through">
{formatPrice(devicePriceData.original_price_per_device_kopeks || 0)}
</span>
<span className="mx-1">{devicePriceData.price_per_device_label}</span>
</span>
) : (
devicePriceData.price_per_device_label
)}
/{t('subscription.perDevice').replace('/ ', '')} (
{t('subscription.days', { count: devicePriceData.days_left })})
</div>
{/* Discount badge */}
{devicePriceData.discount_percent &&
devicePriceData.discount_percent > 0 && (
<div className="mb-2">
<span className="inline-block rounded-full bg-success-500/20 px-2.5 py-0.5 text-sm font-medium text-success-400">
-{devicePriceData.discount_percent}%
</span>
</div>
)}
{/* Total price - show as free if 100% discount or 0 */}
{devicePriceData.total_price_kopeks === 0 ? (
<div className="text-2xl font-bold text-success-400">
{t('subscription.switchTariff.free')}
</div>
) : (
<div className="text-2xl font-bold text-accent-400">
{/* Show original total with strikethrough if discount */}
{devicePriceData.discount_percent &&
devicePriceData.discount_percent > 0 &&
devicePriceData.base_total_price_kopeks && (
<span className="mr-2 text-lg text-dark-500 line-through">
{formatPrice(devicePriceData.base_total_price_kopeks)}
</span>
)}
{devicePriceData.total_price_label}
</div>
)}
</div>
)}
{devicePriceData?.available &&
purchaseOptions &&
devicePriceData.total_price_kopeks &&
devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks && (
<InsufficientBalancePrompt
missingAmountKopeks={
devicePriceData.total_price_kopeks - purchaseOptions.balance_kopeks
}
compact
onBeforeTopUp={async () => {
await subscriptionApi.saveDevicesCart(devicesToAdd);
}}
/>
)}
<button
onClick={() => 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 ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</span>
) : (
t('subscription.additionalOptions.buy')
)}
</button>
{devicePurchaseMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(devicePurchaseMutation.error)}
</div>
)}
</div>
)}
</div>
)}
{/* Reduce Devices */}
<div className="mt-4">
{!showDeviceReduction ? (
<button
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'}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">
{t('subscription.additionalOptions.reduceDevices')}
</div>
<div className="mt-1 text-sm text-dark-400">
{t('subscription.additionalOptions.reduceDevicesDescription')}
</div>
</div>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
) : (
<div
className={`rounded-xl border p-5 ${isDark ? 'border-dark-700/50 bg-dark-800/50' : 'border-champagne-300/60 bg-champagne-200/40'}`}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">
{t('subscription.additionalOptions.reduceDevicesTitle')}
</h3>
<button
onClick={() => setShowDeviceReduction(false)}
className="text-sm text-dark-400 hover:text-dark-200"
>
</button>
</div>
{deviceReductionInfo?.available === false ? (
<div className="py-4 text-center text-sm text-dark-400">
{deviceReductionInfo.reason ||
t('subscription.additionalOptions.reduceUnavailable')}
</div>
) : deviceReductionInfo ? (
<div className="space-y-4">
{/* Device limit selector */}
<div className="flex items-center justify-center gap-6">
<button
onClick={() =>
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"
>
-
</button>
<div className="text-center">
<div className="text-4xl font-bold text-dark-100">
{targetDeviceLimit}
</div>
<div className="text-sm text-dark-500">
{t('subscription.additionalOptions.devicesUnit')}
</div>
</div>
<button
onClick={() =>
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"
>
+
</button>
</div>
{/* Info */}
<div className="space-y-1 text-center text-sm text-dark-400">
<div>
{t('subscription.additionalOptions.currentDeviceLimit', {
count: deviceReductionInfo.current_device_limit,
})}
</div>
<div>
{t('subscription.additionalOptions.minDeviceLimit', {
count: deviceReductionInfo.min_device_limit,
})}
</div>
<div>
{t('subscription.additionalOptions.connectedDevices', {
count: deviceReductionInfo.connected_devices_count,
})}
</div>
</div>
{/* Warning if connected devices block reduction */}
{deviceReductionInfo.connected_devices_count >
deviceReductionInfo.min_device_limit && (
<div className="rounded-lg bg-warning-500/10 p-3 text-center text-sm text-warning-400">
{t('subscription.additionalOptions.disconnectDevicesFirst', {
count: deviceReductionInfo.connected_devices_count,
})}
</div>
)}
{/* New limit preview */}
<div className="text-center">
<div className="text-sm text-dark-400">
{t('subscription.additionalOptions.newDeviceLimit', {
count: targetDeviceLimit,
})}
</div>
</div>
<button
onClick={() => 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 ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('subscription.additionalOptions.reducing')}
</span>
) : (
t('subscription.additionalOptions.reduce')
)}
</button>
{deviceReductionMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(deviceReductionMutation.error)}
</div>
)}
</div>
) : (
<div className="flex items-center justify-center py-4">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-accent-400/30 border-t-accent-400" />
</div>
)}
</div>
)}
</div>
{/* Buy Traffic */}
{subscription.traffic_limit_gb > 0 && (
<div className="mt-4">
@@ -1864,10 +1462,7 @@ export default function Subscription() {
const addedServers = countriesData.countries.filter((c) =>
added.includes(c.uuid),
);
const totalCost = addedServers.reduce(
(sum, s) => sum + s.price_kopeks,
0,
);
const totalCost = addedServers.reduce((sum, s) => sum + s.price_kopeks, 0);
const hasEnoughBalance =
!purchaseOptions || totalCost <= purchaseOptions.balance_kopeks;
const missingAmount = purchaseOptions