From 6dc8ca0d18bd0e23d6fa05b169f40686f6b2584c Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 8 Mar 2026 00:45:47 +0300 Subject: [PATCH] fix: unify device manager into additional options card with unbounded dot selector --- src/components/subscription/DeviceManager.tsx | 215 +++++++++++------- src/pages/Subscription.tsx | 41 ++-- 2 files changed, 151 insertions(+), 105 deletions(-) diff --git a/src/components/subscription/DeviceManager.tsx b/src/components/subscription/DeviceManager.tsx index 7b896ca..4031bb9 100644 --- a/src/components/subscription/DeviceManager.tsx +++ b/src/components/subscription/DeviceManager.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { subscriptionApi } from '@/api/subscription'; @@ -23,10 +23,11 @@ interface DeviceManagerProps { } // ─── DotStepSelector ─── +// Unified: bounded ranges show fixed dots, unbounded adds a "+" tail that grows interface DotStepSelectorProps { min: number; - max: number; + max: number | null; // null = unlimited current: number; selected: number; connectedCount: number; @@ -45,12 +46,18 @@ function DotStepSelector({ accentColor, isDark, }: DotStepSelectorProps) { - const total = max - min + 1; + const isUnlimited = max === null; + const scrollRef = useRef(null); + + // For unbounded: dots go from min to max(current, selected) + // For bounded: dots go from min to max + const upperBound = isUnlimited ? Math.max(current, selected) : max; + const total = upperBound - min + 1; if (total <= 0) return null; - // For large ranges (>15), show a compact slider-like view - if (total > 15) { + // For bounded large ranges (>15), show range input + if (!isUnlimited && total > 15) { return (
+ isCurrent ? (total <= 8 ? 16 : 12) : total <= 8 ? 10 : 8; + + const handlePlusClick = () => { + onChange(selected + 1); + // Scroll to end after new dot appears + requestAnimationFrame(() => { + scrollRef.current?.scrollTo({ + left: scrollRef.current.scrollWidth, + behavior: 'smooth', + }); + }); + }; + + // Track width calculation for filled portion + const filledRatio = total > 1 ? (selected - min) / (upperBound - min) : 0; + return ( -
- {/* Track background */} +
@@ -97,32 +125,30 @@ function DotStepSelector({ {Array.from({ length: total }, (_, i) => { const value = min + i; const isFilled = value <= selected; - const isCurrent = value === current; + const isCur = value === current; const isLocked = value < connectedCount && value < selected; - const isDisabled = value < Math.max(min, connectedCount) && value < current; + const floorValue = Math.max(min, connectedCount); + const isDisabled = value < floorValue && value < current; return ( + )}
); @@ -171,7 +232,7 @@ export default function DeviceManager({ const mode: 'idle' | 'purchase' | 'reduce' = selectedLimit > currentLimit ? 'purchase' : selectedLimit < currentLimit ? 'reduce' : 'idle'; - // Fetch initial range data (price info gives us max_device_limit) + // Fetch price data (gives us max_device_limit) const { data: priceData } = useQuery({ queryKey: ['device-price', devicesToAdd || 1], queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd || 1), @@ -187,10 +248,14 @@ export default function DeviceManager({ }); const minLimit = reductionInfo?.min_device_limit ?? currentLimit; - const maxLimit = priceData?.max_device_limit ?? currentLimit; + // null/undefined max_device_limit = unlimited + const maxLimit = priceData?.max_device_limit ?? null; const connectedCount = reductionInfo?.connected_devices_count ?? 0; const isInitializing = !priceData || !reductionInfo; + // Can we show the selector? Need range > 0 or unlimited + const hasRange = maxLimit === null || maxLimit > minLimit; + // Reset selectedLimit when currentLimit changes (after successful mutation) useEffect(() => { setSelectedLimit(currentLimit); @@ -258,30 +323,22 @@ export default function DeviceManager({ return (
{/* Header */} -
-

- {t('subscription.deviceManager.title')} -

+
+

{t('subscription.deviceManager.title')}

{isInitializing ? ( -
+
) : ( -
+
{/* Number badge + label */}
- {/* Dot selector */} - {maxLimit > minLimit && ( + {/* Dot selector — unified for bounded and unbounded */} + {hasRange && ( - {isPending ? ( - - - - ) : mode === 'reduce' ? ( - t('subscription.additionalOptions.reduce') - ) : ( - t('subscription.additionalOptions.buy') - )} - + {mode !== 'idle' && ( + + )} {/* Error */} {mutationError && ( @@ -438,7 +501,7 @@ export default function DeviceManager({ ); } -// ─── Trigger Button ─── +// ─── Trigger Button (no card wrapper — renders inside parent card) ─── interface DeviceManagerTriggerProps { deviceLimit: number; @@ -448,43 +511,29 @@ interface DeviceManagerTriggerProps { export function DeviceManagerTrigger({ deviceLimit, isDark, onClick }: DeviceManagerTriggerProps) { const { t } = useTranslation(); - const g = getGlassColors(isDark); return ( -
-

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

- -
+ + + +
+ ); } diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index b85ff46..93c1892 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1074,28 +1074,7 @@ export default function Subscription() { {/* Purchase / Renewal CTA */} - {/* Device Manager */} - {subscription && - subscription.is_active && - !subscription.is_trial && - subscription.device_limit !== 0 && - (showDeviceManager ? ( - setShowDeviceManager(false)} - /> - ) : ( - setShowDeviceManager(true)} - /> - ))} - - {/* Additional Options (Traffic, Servers) */} + {/* Additional Options (Devices, Traffic, Servers) */} {subscription && subscription.is_active && !subscription.is_trial && (
+ {/* Device Manager */} + {subscription.device_limit !== 0 && + (showDeviceManager ? ( + setShowDeviceManager(false)} + /> + ) : ( + setShowDeviceManager(true)} + /> + ))} + {/* Buy Traffic */} {subscription.traffic_limit_gb > 0 && (