fix: unify device manager into additional options card with unbounded dot selector

This commit is contained in:
c0mrade
2026-03-08 00:45:47 +03:00
parent edb7ef0488
commit 6dc8ca0d18
2 changed files with 151 additions and 105 deletions

View File

@@ -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>
); );
} }

View File

@@ -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">