mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
fix: unify device manager into additional options card with unbounded dot selector
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { subscriptionApi } from '@/api/subscription';
|
import { subscriptionApi } from '@/api/subscription';
|
||||||
@@ -23,10 +23,11 @@ interface DeviceManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── DotStepSelector ───
|
// ─── DotStepSelector ───
|
||||||
|
// Unified: bounded ranges show fixed dots, unbounded adds a "+" tail that grows
|
||||||
|
|
||||||
interface DotStepSelectorProps {
|
interface DotStepSelectorProps {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number | null; // null = unlimited
|
||||||
current: number;
|
current: number;
|
||||||
selected: number;
|
selected: number;
|
||||||
connectedCount: number;
|
connectedCount: number;
|
||||||
@@ -45,12 +46,18 @@ function DotStepSelector({
|
|||||||
accentColor,
|
accentColor,
|
||||||
isDark,
|
isDark,
|
||||||
}: DotStepSelectorProps) {
|
}: DotStepSelectorProps) {
|
||||||
const total = max - min + 1;
|
const isUnlimited = max === null;
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(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;
|
if (total <= 0) return null;
|
||||||
|
|
||||||
// For large ranges (>15), show a compact slider-like view
|
// For bounded large ranges (>15), show range input
|
||||||
if (total > 15) {
|
if (!isUnlimited && total > 15) {
|
||||||
return (
|
return (
|
||||||
<div className="px-2">
|
<div className="px-2">
|
||||||
<input
|
<input
|
||||||
@@ -75,9 +82,30 @@ function DotStepSelector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dotSize = total <= 8 ? 32 : total <= 12 ? 24 : 20;
|
||||||
|
const innerSize = (isCurrent: boolean) =>
|
||||||
|
isCurrent ? (total <= 8 ? 16 : 12) : total <= 8 ? 10 : 8;
|
||||||
|
|
||||||
|
const handlePlusClick = () => {
|
||||||
|
onChange(selected + 1);
|
||||||
|
// Scroll to end after new dot appears
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollRef.current?.scrollTo({
|
||||||
|
left: scrollRef.current.scrollWidth,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track width calculation for filled portion
|
||||||
|
const filledRatio = total > 1 ? (selected - min) / (upperBound - min) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center gap-0">
|
<div
|
||||||
{/* Track background */}
|
ref={scrollRef}
|
||||||
|
className="flex items-center justify-center gap-0 overflow-x-auto"
|
||||||
|
style={{ scrollbarWidth: 'none' }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="relative flex items-center rounded-full p-1.5"
|
className="relative flex items-center rounded-full p-1.5"
|
||||||
style={{
|
style={{
|
||||||
@@ -88,7 +116,7 @@ function DotStepSelector({
|
|||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute bottom-1.5 left-1.5 top-1.5 rounded-full transition-all duration-300 ease-out"
|
className="pointer-events-none absolute bottom-1.5 left-1.5 top-1.5 rounded-full transition-all duration-300 ease-out"
|
||||||
style={{
|
style={{
|
||||||
width: `${((selected - min) / (max - min)) * 100}%`,
|
width: `${filledRatio * 100}%`,
|
||||||
background: `linear-gradient(90deg, ${accentColor}40, ${accentColor}90)`,
|
background: `linear-gradient(90deg, ${accentColor}40, ${accentColor}90)`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -97,32 +125,30 @@ function DotStepSelector({
|
|||||||
{Array.from({ length: total }, (_, i) => {
|
{Array.from({ length: total }, (_, i) => {
|
||||||
const value = min + i;
|
const value = min + i;
|
||||||
const isFilled = value <= selected;
|
const isFilled = value <= selected;
|
||||||
const isCurrent = value === current;
|
const isCur = value === current;
|
||||||
const isLocked = value < connectedCount && value < selected;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (value >= Math.max(min, connectedCount) || value > current) {
|
if (value >= floorValue || value > current) {
|
||||||
onChange(value);
|
onChange(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isDisabled && value < connectedCount}
|
disabled={isDisabled}
|
||||||
className="relative z-10 flex items-center justify-center transition-all duration-200"
|
className="relative z-10 flex shrink-0 items-center justify-center transition-all duration-200"
|
||||||
style={{
|
style={{ width: dotSize, height: dotSize }}
|
||||||
width: total <= 8 ? 32 : total <= 12 ? 24 : 20,
|
|
||||||
height: total <= 8 ? 32 : total <= 12 ? 24 : 20,
|
|
||||||
}}
|
|
||||||
aria-label={`${value}`}
|
aria-label={`${value}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="rounded-full transition-all duration-300"
|
className="rounded-full transition-all duration-300"
|
||||||
style={{
|
style={{
|
||||||
width: isCurrent ? (total <= 8 ? 16 : 12) : total <= 8 ? 10 : 8,
|
width: innerSize(isCur),
|
||||||
height: isCurrent ? (total <= 8 ? 16 : 12) : total <= 8 ? 10 : 8,
|
height: innerSize(isCur),
|
||||||
background: isFilled
|
background: isFilled
|
||||||
? isLocked
|
? isLocked
|
||||||
? isDark
|
? isDark
|
||||||
@@ -132,8 +158,8 @@ function DotStepSelector({
|
|||||||
: isDark
|
: isDark
|
||||||
? 'rgba(255,255,255,0.12)'
|
? 'rgba(255,255,255,0.12)'
|
||||||
: 'rgba(0,0,0,0.12)',
|
: 'rgba(0,0,0,0.12)',
|
||||||
boxShadow: isCurrent && isFilled ? `0 0 8px ${accentColor}60` : 'none',
|
boxShadow: isCur && isFilled ? `0 0 8px ${accentColor}60` : 'none',
|
||||||
border: isCurrent
|
border: isCur
|
||||||
? `2px solid ${isFilled ? accentColor : isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.25)'}`
|
? `2px solid ${isFilled ? accentColor : isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.25)'}`
|
||||||
: 'none',
|
: 'none',
|
||||||
}}
|
}}
|
||||||
@@ -141,6 +167,41 @@ function DotStepSelector({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* "+" tail for unlimited */}
|
||||||
|
{isUnlimited && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePlusClick}
|
||||||
|
className="relative z-10 flex shrink-0 items-center justify-center transition-all duration-200"
|
||||||
|
style={{ width: dotSize, height: dotSize }}
|
||||||
|
aria-label="add"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: total <= 8 ? 16 : 12,
|
||||||
|
height: total <= 8 ? 16 : 12,
|
||||||
|
background: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)',
|
||||||
|
border: `1.5px dashed ${isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.25)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width={total <= 8 ? 8 : 6}
|
||||||
|
height={total <= 8 ? 8 : 6}
|
||||||
|
viewBox="0 0 8 8"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 1v6M1 4h6"
|
||||||
|
stroke={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -171,7 +232,7 @@ export default function DeviceManager({
|
|||||||
const mode: 'idle' | 'purchase' | 'reduce' =
|
const mode: 'idle' | 'purchase' | 'reduce' =
|
||||||
selectedLimit > currentLimit ? 'purchase' : selectedLimit < currentLimit ? 'reduce' : 'idle';
|
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({
|
const { data: priceData } = useQuery({
|
||||||
queryKey: ['device-price', devicesToAdd || 1],
|
queryKey: ['device-price', devicesToAdd || 1],
|
||||||
queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd || 1),
|
queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd || 1),
|
||||||
@@ -187,10 +248,14 @@ export default function DeviceManager({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const minLimit = reductionInfo?.min_device_limit ?? currentLimit;
|
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 connectedCount = reductionInfo?.connected_devices_count ?? 0;
|
||||||
const isInitializing = !priceData || !reductionInfo;
|
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)
|
// Reset selectedLimit when currentLimit changes (after successful mutation)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedLimit(currentLimit);
|
setSelectedLimit(currentLimit);
|
||||||
@@ -258,30 +323,22 @@ export default function DeviceManager({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden rounded-3xl"
|
className={`rounded-xl border p-5 ${isDark ? 'border-dark-700/50 bg-dark-800/50' : 'border-champagne-300/60 bg-champagne-200/40'}`}
|
||||||
style={{
|
|
||||||
background: g.cardBg,
|
|
||||||
border: `1px solid ${g.cardBorder}`,
|
|
||||||
boxShadow: g.shadow,
|
|
||||||
padding: '24px 28px',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-base font-bold tracking-tight text-dark-50">
|
<h3 className="font-medium text-dark-100">{t('subscription.deviceManager.title')}</h3>
|
||||||
{t('subscription.deviceManager.title')}
|
|
||||||
</h2>
|
|
||||||
<button onClick={onClose} className="text-sm text-dark-400 hover:text-dark-200">
|
<button onClick={onClose} className="text-sm text-dark-400 hover:text-dark-200">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isInitializing ? (
|
{isInitializing ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-6">
|
||||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-accent-400/30 border-t-accent-400" />
|
<span className="h-5 w-5 animate-spin rounded-full border-2 border-accent-400/30 border-t-accent-400" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-4">
|
||||||
{/* Number badge + label */}
|
{/* Number badge + label */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -299,8 +356,8 @@ export default function DeviceManager({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dot selector */}
|
{/* Dot selector — unified for bounded and unbounded */}
|
||||||
{maxLimit > minLimit && (
|
{hasRange && (
|
||||||
<DotStepSelector
|
<DotStepSelector
|
||||||
min={minLimit}
|
min={minLimit}
|
||||||
max={maxLimit}
|
max={maxLimit}
|
||||||
@@ -414,7 +471,12 @@ export default function DeviceManager({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action button */}
|
{/* Action button */}
|
||||||
<button onClick={handleSubmit} disabled={!canSubmit} className="btn-primary w-full py-3">
|
{mode !== 'idle' && (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="btn-primary w-full py-3"
|
||||||
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<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 className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
@@ -425,6 +487,7 @@ export default function DeviceManager({
|
|||||||
t('subscription.additionalOptions.buy')
|
t('subscription.additionalOptions.buy')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{mutationError && (
|
{mutationError && (
|
||||||
@@ -438,7 +501,7 @@ export default function DeviceManager({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Trigger Button ───
|
// ─── Trigger Button (no card wrapper — renders inside parent card) ───
|
||||||
|
|
||||||
interface DeviceManagerTriggerProps {
|
interface DeviceManagerTriggerProps {
|
||||||
deviceLimit: number;
|
deviceLimit: number;
|
||||||
@@ -448,21 +511,8 @@ interface DeviceManagerTriggerProps {
|
|||||||
|
|
||||||
export function DeviceManagerTrigger({ deviceLimit, isDark, onClick }: DeviceManagerTriggerProps) {
|
export function DeviceManagerTrigger({ deviceLimit, isDark, onClick }: DeviceManagerTriggerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const g = getGlassColors(isDark);
|
|
||||||
|
|
||||||
return (
|
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
|
<button
|
||||||
onClick={onClick}
|
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'}`}
|
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'}`}
|
||||||
@@ -485,6 +535,5 @@ export function DeviceManagerTrigger({ deviceLimit, isDark, onClick }: DeviceMan
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1074,11 +1074,23 @@ export default function Subscription() {
|
|||||||
{/* Purchase / Renewal CTA */}
|
{/* Purchase / Renewal CTA */}
|
||||||
<PurchaseCTAButton subscription={subscription} />
|
<PurchaseCTAButton subscription={subscription} />
|
||||||
|
|
||||||
|
{/* Additional Options (Devices, Traffic, Servers) */}
|
||||||
|
{subscription && subscription.is_active && !subscription.is_trial && (
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* Device Manager */}
|
{/* Device Manager */}
|
||||||
{subscription &&
|
{subscription.device_limit !== 0 &&
|
||||||
subscription.is_active &&
|
|
||||||
!subscription.is_trial &&
|
|
||||||
subscription.device_limit !== 0 &&
|
|
||||||
(showDeviceManager ? (
|
(showDeviceManager ? (
|
||||||
<DeviceManager
|
<DeviceManager
|
||||||
subscription={subscription}
|
subscription={subscription}
|
||||||
@@ -1095,21 +1107,6 @@ export default function Subscription() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Additional Options (Traffic, Servers) */}
|
|
||||||
{subscription && subscription.is_active && !subscription.is_trial && (
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Buy Traffic */}
|
{/* Buy Traffic */}
|
||||||
{subscription.traffic_limit_gb > 0 && (
|
{subscription.traffic_limit_gb > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user