mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
490
src/components/subscription/DeviceManager.tsx
Normal file
490
src/components/subscription/DeviceManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}}%",
|
||||
|
||||
@@ -351,6 +351,10 @@
|
||||
"manageServers": "مدیریت سرورها",
|
||||
"manageServersTitle": "مدیریت سرورها"
|
||||
},
|
||||
"deviceManager": {
|
||||
"title": "تغییر تعداد دستگاهها",
|
||||
"devicesLabel": "دستگاهها"
|
||||
},
|
||||
"serverManagement": {
|
||||
"statusLegend": "✅ — متصل • ➕ — اضافه خواهد شد (پولی) • ➖ — قطع خواهد شد",
|
||||
"discountBanner": "تخفیف سرور شما: -{{percent}}%",
|
||||
|
||||
@@ -557,6 +557,10 @@
|
||||
"manageServers": "Управление серверами",
|
||||
"manageServersTitle": "Управление серверами"
|
||||
},
|
||||
"deviceManager": {
|
||||
"title": "Изменить кол-во устройств",
|
||||
"devicesLabel": "Устройства"
|
||||
},
|
||||
"serverManagement": {
|
||||
"statusLegend": "✅ — подключено • ➕ — будет добавлено (платно) • ➖ — будет отключено",
|
||||
"discountBanner": "Ваша скидка на серверы: -{{percent}}%",
|
||||
|
||||
@@ -351,6 +351,10 @@
|
||||
"manageServers": "服务器管理",
|
||||
"manageServersTitle": "服务器管理"
|
||||
},
|
||||
"deviceManager": {
|
||||
"title": "更改设备数量",
|
||||
"devicesLabel": "设备"
|
||||
},
|
||||
"serverManagement": {
|
||||
"statusLegend": "✅ — 已连接 • ➕ — 将添加(付费)• ➖ — 将断开",
|
||||
"discountBanner": "您的服务器折扣:-{{percent}}%",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user