mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
Move the buy-devices sheet (190 lines of inline JSX + its useQuery + useMutation) into src/components/subscription/sheets/DeviceTopupSheet.tsx. Parent passes shared state (purchaseOptions, devicesToAdd, ids); the sheet self-owns the device-price query and purchase mutation, so the query only fires when the sheet is actually open. Subscription.tsx: 2671 → 2477 lines (−194).
2478 lines
112 KiB
TypeScript
2478 lines
112 KiB
TypeScript
import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Navigate, useNavigate, useParams } from 'react-router';
|
||
import { subscriptionApi } from '../api/subscription';
|
||
import { DEVICE_ALIAS_MAX_LENGTH } from '../constants/devices';
|
||
import { WebBackButton } from '../components/WebBackButton';
|
||
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
||
import { usePlatform } from '../platform';
|
||
import TrafficProgressBar from '../components/dashboard/TrafficProgressBar';
|
||
import { HoverBorderGradient } from '../components/ui/hover-border-gradient';
|
||
import { useTrafficZone } from '../hooks/useTrafficZone';
|
||
import { formatTraffic } from '../utils/formatTraffic';
|
||
import { getGlassColors } from '../utils/glassTheme';
|
||
import { copyToClipboard } from '../utils/clipboard';
|
||
import { useTheme } from '../hooks/useTheme';
|
||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||
import { useCurrency } from '../hooks/useCurrency';
|
||
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
||
import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton';
|
||
import { CopyIcon, CheckIcon } from '../components/icons';
|
||
import { useHaptic } from '../platform';
|
||
import { resolveConnectionUrlForUi } from '../utils/connectionLink';
|
||
import {
|
||
getErrorMessage,
|
||
getInsufficientBalanceError,
|
||
getFlagEmoji,
|
||
} from '../utils/subscriptionHelpers';
|
||
import Twemoji from 'react-twemoji';
|
||
import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
|
||
|
||
/** Isolated countdown so 1s interval doesn't re-render the whole page */
|
||
const CountdownTimer = memo(function CountdownTimer({
|
||
endDate,
|
||
isActive,
|
||
glassColors: g,
|
||
}: {
|
||
endDate: string;
|
||
isActive: boolean;
|
||
glassColors: ReturnType<typeof getGlassColors>;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const [countdown, setCountdown] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 });
|
||
|
||
useEffect(() => {
|
||
const endTime = new Date(endDate).getTime();
|
||
const tick = () => {
|
||
const diff = Math.max(0, endTime - Date.now());
|
||
setCountdown({
|
||
days: Math.floor(diff / 86_400_000),
|
||
hours: Math.floor((diff % 86_400_000) / 3_600_000),
|
||
minutes: Math.floor((diff % 3_600_000) / 60_000),
|
||
seconds: Math.floor((diff % 60_000) / 1_000),
|
||
});
|
||
};
|
||
tick();
|
||
const id = setInterval(tick, 1000);
|
||
return () => clearInterval(id);
|
||
}, [endDate]);
|
||
|
||
const isExpired = !isActive;
|
||
const isUrgent = countdown.days <= 3;
|
||
|
||
const formattedDate = new Date(endDate).toLocaleDateString(undefined, {
|
||
day: 'numeric',
|
||
month: 'short',
|
||
year: 'numeric',
|
||
});
|
||
|
||
return (
|
||
<div
|
||
className="min-w-0 overflow-hidden rounded-[14px] p-3.5"
|
||
style={{
|
||
background: isExpired
|
||
? 'rgba(255,59,92,0.06)'
|
||
: isUrgent
|
||
? 'rgba(255,184,0,0.06)'
|
||
: g.innerBg,
|
||
border: isExpired
|
||
? '1px solid rgba(255,59,92,0.15)'
|
||
: isUrgent
|
||
? '1px solid rgba(255,184,0,0.15)'
|
||
: `1px solid ${g.innerBorder}`,
|
||
}}
|
||
>
|
||
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
|
||
<div
|
||
className="flex h-6 w-6 items-center justify-center rounded-[7px]"
|
||
style={{
|
||
background: isExpired
|
||
? 'rgba(255,59,92,0.1)'
|
||
: isUrgent
|
||
? 'rgba(255,184,0,0.1)'
|
||
: g.hoverBg,
|
||
}}
|
||
>
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke={
|
||
isExpired
|
||
? 'rgb(var(--color-critical-500))'
|
||
: isUrgent
|
||
? 'rgb(var(--color-urgent-400))'
|
||
: g.textSecondary
|
||
}
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||
<path d="M16 2v4M8 2v4M3 10h18" />
|
||
</svg>
|
||
</div>
|
||
{t('dashboard.remaining')}
|
||
</div>
|
||
{isExpired ? (
|
||
<div
|
||
className="text-[18px] font-bold tracking-tight"
|
||
style={{ color: 'rgb(var(--color-critical-500))' }}
|
||
>
|
||
{t('subscription.expired')}
|
||
</div>
|
||
) : (
|
||
<div className="flex items-baseline justify-between">
|
||
<div className="flex items-baseline gap-1 font-mono tabular-nums">
|
||
{countdown.days > 0 && (
|
||
<>
|
||
<span
|
||
className="text-[20px] font-bold tracking-tight"
|
||
style={{ color: isUrgent ? 'rgb(var(--color-urgent-400))' : g.text }}
|
||
>
|
||
{countdown.days}
|
||
</span>
|
||
<span className="mr-1 text-[10px] font-medium text-dark-50/25">
|
||
{t('subscription.daysShort')}
|
||
</span>
|
||
</>
|
||
)}
|
||
<span
|
||
className="text-[20px] font-bold tracking-tight"
|
||
style={{ color: isUrgent ? 'rgb(var(--color-urgent-400))' : g.text }}
|
||
>
|
||
{String(countdown.hours).padStart(2, '0')}
|
||
</span>
|
||
<span
|
||
className="mx-[-1px] text-[16px] font-bold opacity-30"
|
||
style={{ color: isUrgent ? 'rgb(var(--color-urgent-400))' : g.text }}
|
||
>
|
||
:
|
||
</span>
|
||
<span
|
||
className="text-[20px] font-bold tracking-tight"
|
||
style={{ color: isUrgent ? 'rgb(var(--color-urgent-400))' : g.text }}
|
||
>
|
||
{String(countdown.minutes).padStart(2, '0')}
|
||
</span>
|
||
<span
|
||
className="mx-[-1px] text-[16px] font-bold opacity-30"
|
||
style={{ color: isUrgent ? 'rgb(var(--color-urgent-400))' : g.text }}
|
||
>
|
||
:
|
||
</span>
|
||
<span
|
||
className="text-[20px] font-bold tracking-tight"
|
||
style={{ color: isUrgent ? 'rgb(var(--color-urgent-400))' : g.text }}
|
||
>
|
||
{String(countdown.seconds).padStart(2, '0')}
|
||
</span>
|
||
</div>
|
||
<div className="text-[10px] font-medium text-dark-50/25">
|
||
{t('subscription.expiresAt')}: {formattedDate}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export default function Subscription() {
|
||
const { t } = useTranslation();
|
||
const queryClient = useQueryClient();
|
||
const { formatAmount, currencySymbol } = useCurrency();
|
||
const navigate = useNavigate();
|
||
const { subscriptionId: subIdParam } = useParams<{ subscriptionId?: string }>();
|
||
const subscriptionId = subIdParam ? parseInt(subIdParam, 10) : undefined;
|
||
const { isDark } = useTheme();
|
||
const g = getGlassColors(isDark);
|
||
const haptic = useHaptic();
|
||
const [copied, setCopied] = useState(false);
|
||
const [showDeleteSheet, setShowDeleteSheet] = useState(false);
|
||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||
const { platform } = usePlatform();
|
||
const destructiveConfirm = useDestructiveConfirm();
|
||
|
||
// Helper to format price from kopeks
|
||
const formatPrice = (kopeks: number) =>
|
||
kopeks === 0
|
||
? t('subscription.free', 'Бесплатно')
|
||
: `${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 [showTrafficTopup, setShowTrafficTopup] = useState(false);
|
||
const [selectedTrafficPackage, setSelectedTrafficPackage] = useState<number | null>(null);
|
||
const [showServerManagement, setShowServerManagement] = useState(false);
|
||
const [selectedServersToUpdate, setSelectedServersToUpdate] = useState<string[]>([]);
|
||
|
||
// Traffic refresh state
|
||
const [trafficRefreshCooldown, setTrafficRefreshCooldown] = useState(0);
|
||
|
||
// Revoke (reissue) cooldown state
|
||
const [revokeCooldown, setRevokeCooldown] = useState(0);
|
||
const [trafficData, setTrafficData] = useState<{
|
||
traffic_used_gb: number;
|
||
traffic_used_percent: number;
|
||
is_unlimited: boolean;
|
||
} | null>(null);
|
||
|
||
// Detect multi-tariff mode from cached subscriptions-list
|
||
const { data: multiSubData } = useQuery({
|
||
queryKey: ['subscriptions-list'],
|
||
queryFn: () => subscriptionApi.getSubscriptions(),
|
||
staleTime: 60_000,
|
||
});
|
||
const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false;
|
||
|
||
const { data: subscriptionResponse, isLoading } = useQuery({
|
||
queryKey: ['subscription', subscriptionId],
|
||
queryFn: () => subscriptionApi.getSubscription(subscriptionId),
|
||
retry: false,
|
||
staleTime: 0,
|
||
refetchOnMount: 'always',
|
||
});
|
||
const { data: connectionLink, isLoading: isConnectionLinkLoading } = useQuery({
|
||
queryKey: ['connection-link', subscriptionId],
|
||
queryFn: () => subscriptionApi.getConnectionLink(subscriptionId),
|
||
retry: false,
|
||
staleTime: 0,
|
||
});
|
||
|
||
// Extract subscription from response (null if no subscription)
|
||
const subscription = subscriptionResponse?.subscription ?? null;
|
||
const displayedConnectionUrl = useMemo(
|
||
() =>
|
||
resolveConnectionUrlForUi({
|
||
mode: connectionLink?.connect_mode,
|
||
happSchemeLink: connectionLink?.happ_scheme_link,
|
||
displayLink: connectionLink?.display_link,
|
||
subscriptionUrl: connectionLink?.subscription_url,
|
||
happCryptLink: connectionLink?.happ_cryptolink,
|
||
happCryptoLink: connectionLink?.happ_crypto_link,
|
||
happLink: connectionLink?.happ_link,
|
||
fallbackUrl: isConnectionLinkLoading ? null : (subscription?.subscription_url ?? null),
|
||
}),
|
||
[
|
||
connectionLink?.connect_mode,
|
||
connectionLink?.display_link,
|
||
connectionLink?.happ_cryptolink,
|
||
connectionLink?.happ_crypto_link,
|
||
connectionLink?.happ_link,
|
||
connectionLink?.happ_scheme_link,
|
||
connectionLink?.subscription_url,
|
||
isConnectionLinkLoading,
|
||
subscription?.subscription_url,
|
||
],
|
||
);
|
||
const shouldHideConnectionLink =
|
||
subscription?.hide_subscription_link || connectionLink?.hide_link;
|
||
|
||
// Traffic zone (theme-aware) — called unconditionally at top level
|
||
const usedPercent = trafficData?.traffic_used_percent ?? subscription?.traffic_used_percent ?? 0;
|
||
const zone = useTrafficZone(usedPercent);
|
||
|
||
// Purchase options (needed for balance_kopeks in device/traffic/server management)
|
||
const { data: purchaseOptions } = useQuery({
|
||
queryKey: ['purchase-options', subscriptionId],
|
||
queryFn: () => subscriptionApi.getPurchaseOptions(subscriptionId),
|
||
staleTime: 0,
|
||
refetchOnMount: 'always',
|
||
});
|
||
|
||
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs';
|
||
|
||
const autopayMutation = useMutation({
|
||
mutationFn: (enabled: boolean) =>
|
||
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||
},
|
||
});
|
||
|
||
// Devices query
|
||
const { data: devicesData, isLoading: devicesLoading } = useQuery({
|
||
queryKey: ['devices', subscriptionId],
|
||
queryFn: () => subscriptionApi.getDevices(subscriptionId),
|
||
enabled: !!subscription,
|
||
});
|
||
|
||
// Delete device mutation
|
||
const deleteDeviceMutation = useMutation({
|
||
mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid, subscriptionId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||
},
|
||
});
|
||
|
||
// Delete all devices mutation
|
||
const deleteAllDevicesMutation = useMutation({
|
||
mutationFn: () => subscriptionApi.deleteAllDevices(subscriptionId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||
},
|
||
});
|
||
|
||
// Local device alias (rename) state. Only one device can be in edit-mode
|
||
// at a time — `editingDeviceHwid` doubles as both the toggle and the
|
||
// identifier of the row being edited.
|
||
const [editingDeviceHwid, setEditingDeviceHwid] = useState<string | null>(null);
|
||
const [editingDeviceName, setEditingDeviceName] = useState('');
|
||
|
||
const renameDeviceMutation = useMutation({
|
||
mutationFn: ({ hwid, name }: { hwid: string; name: string | null }) =>
|
||
subscriptionApi.renameDevice(hwid, name, subscriptionId),
|
||
onSuccess: (_data, variables) => {
|
||
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||
// Soft success-tap, like other mutations on this page.
|
||
haptic.notification('success');
|
||
// Не сбрасываем edit-state, если пользователь уже перешёл на другой
|
||
// девайс пока шёл запрос — иначе теряем его новый input. Имя не чистим
|
||
// безусловно: оно либо принадлежит уже другому девайсу (нужно сохранить),
|
||
// либо инпут уже закрылся (значение не отображается).
|
||
setEditingDeviceHwid((current) => (current === variables.hwid ? null : current));
|
||
},
|
||
onError: () => {
|
||
haptic.notification('error');
|
||
},
|
||
});
|
||
|
||
// Pause subscription mutation
|
||
const pauseMutation = useMutation({
|
||
mutationFn: () => subscriptionApi.togglePause(subscriptionId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||
},
|
||
});
|
||
|
||
// Auto-close all modals/forms when success notification appears
|
||
const handleCloseAllModals = useCallback(() => {
|
||
setShowDeviceTopup(false);
|
||
setShowDeviceReduction(false);
|
||
setShowTrafficTopup(false);
|
||
setShowServerManagement(false);
|
||
}, []);
|
||
useCloseOnSuccessNotification(handleCloseAllModals);
|
||
|
||
// (device price + purchase moved into <DeviceTopupSheet>)
|
||
|
||
// Device reduction info query
|
||
const { data: deviceReductionInfo } = useQuery({
|
||
queryKey: ['device-reduction-info', subscriptionId],
|
||
queryFn: () => subscriptionApi.getDeviceReductionInfo(subscriptionId),
|
||
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, subscriptionId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||
queryClient.invalidateQueries({ queryKey: ['device-reduction-info', subscriptionId] });
|
||
setShowDeviceReduction(false);
|
||
},
|
||
});
|
||
|
||
// Traffic packages query
|
||
const { data: trafficPackages } = useQuery({
|
||
queryKey: ['traffic-packages', subscriptionId],
|
||
queryFn: () => subscriptionApi.getTrafficPackages(subscriptionId),
|
||
enabled: showTrafficTopup && !!subscription,
|
||
});
|
||
|
||
// Traffic purchase mutation
|
||
const trafficPurchaseMutation = useMutation({
|
||
mutationFn: (gb: number) => subscriptionApi.purchaseTraffic(gb, subscriptionId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||
queryClient.invalidateQueries({ queryKey: ['traffic-packages', subscriptionId] });
|
||
setShowTrafficTopup(false);
|
||
setSelectedTrafficPackage(null);
|
||
},
|
||
});
|
||
|
||
// Countries/servers query
|
||
const { data: countriesData, isLoading: countriesLoading } = useQuery({
|
||
queryKey: ['countries', subscriptionId],
|
||
queryFn: () => subscriptionApi.getCountries(subscriptionId),
|
||
enabled: showServerManagement && !!subscription && !subscription.is_trial,
|
||
});
|
||
|
||
// Initialize selected servers when data loads
|
||
useEffect(() => {
|
||
if (countriesData && showServerManagement) {
|
||
const connected = countriesData.countries.filter((c) => c.is_connected).map((c) => c.uuid);
|
||
setSelectedServersToUpdate(connected);
|
||
}
|
||
}, [countriesData, showServerManagement]);
|
||
|
||
// Countries update mutation
|
||
const updateCountriesMutation = useMutation({
|
||
mutationFn: (countries: string[]) => subscriptionApi.updateCountries(countries, subscriptionId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||
queryClient.invalidateQueries({ queryKey: ['countries', subscriptionId] });
|
||
setShowServerManagement(false);
|
||
},
|
||
});
|
||
|
||
// Traffic refresh mutation
|
||
const refreshTrafficMutation = useMutation({
|
||
mutationFn: () => subscriptionApi.refreshTraffic(subscriptionId),
|
||
onSuccess: (data) => {
|
||
setTrafficData({
|
||
traffic_used_gb: data.traffic_used_gb,
|
||
traffic_used_percent: data.traffic_used_percent,
|
||
is_unlimited: data.is_unlimited,
|
||
});
|
||
localStorage.setItem(
|
||
`traffic_refresh_ts_${subscriptionId ?? 'default'}`,
|
||
Date.now().toString(),
|
||
);
|
||
if (data.rate_limited && data.retry_after_seconds) {
|
||
setTrafficRefreshCooldown(data.retry_after_seconds);
|
||
} else {
|
||
setTrafficRefreshCooldown(30);
|
||
}
|
||
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||
},
|
||
onError: (error: {
|
||
response?: { status?: number; headers?: { get?: (key: string) => string } };
|
||
}) => {
|
||
if (error.response?.status === 429) {
|
||
const retryAfter = error.response.headers?.get?.('Retry-After');
|
||
setTrafficRefreshCooldown(retryAfter ? parseInt(retryAfter, 10) : 30);
|
||
}
|
||
},
|
||
});
|
||
|
||
// Track if we've already triggered auto-refresh this session
|
||
const hasAutoRefreshed = useRef(false);
|
||
|
||
// Cooldown timer for traffic refresh
|
||
useEffect(() => {
|
||
if (trafficRefreshCooldown <= 0) return;
|
||
const timer = setInterval(() => {
|
||
setTrafficRefreshCooldown((prev) => Math.max(0, prev - 1));
|
||
}, 1000);
|
||
return () => clearInterval(timer);
|
||
}, [trafficRefreshCooldown]);
|
||
|
||
// Initialize revoke cooldown from localStorage on mount
|
||
useEffect(() => {
|
||
const ts = localStorage.getItem(`revoke_ts_${subscriptionId ?? 'default'}`);
|
||
if (ts) {
|
||
const elapsed = Math.floor((Date.now() - parseInt(ts, 10)) / 1000);
|
||
const remaining = Math.max(0, 900 - elapsed);
|
||
setRevokeCooldown(remaining);
|
||
}
|
||
}, [subscriptionId]);
|
||
|
||
// Countdown timer for revoke cooldown
|
||
useEffect(() => {
|
||
if (revokeCooldown <= 0) return;
|
||
const timer = setInterval(() => {
|
||
setRevokeCooldown((prev) => Math.max(0, prev - 1));
|
||
}, 1000);
|
||
return () => clearInterval(timer);
|
||
}, [revokeCooldown]);
|
||
|
||
// Revoke (reissue) subscription mutation
|
||
const revokeMutation = useMutation({
|
||
mutationFn: () => subscriptionApi.revokeSubscription(subscriptionId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||
queryClient.invalidateQueries({ queryKey: ['connection-link', subscriptionId] });
|
||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||
// RemnaWave resets device HWIDs on revoke — make sure the cabinet
|
||
// re-reads the now-empty device list instead of showing the stale cache.
|
||
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||
haptic.notification('success');
|
||
localStorage.setItem(`revoke_ts_${subscriptionId ?? 'default'}`, Date.now().toString());
|
||
setRevokeCooldown(900);
|
||
},
|
||
onError: () => {
|
||
haptic.notification('error');
|
||
},
|
||
});
|
||
|
||
// Auto-refresh traffic on mount (with 30s caching)
|
||
useEffect(() => {
|
||
if (!subscription) return;
|
||
if (hasAutoRefreshed.current) return;
|
||
hasAutoRefreshed.current = true;
|
||
|
||
const lastRefresh = localStorage.getItem(`traffic_refresh_ts_${subscriptionId ?? 'default'}`);
|
||
const now = Date.now();
|
||
const cacheMs = 30 * 1000;
|
||
|
||
if (lastRefresh && now - parseInt(lastRefresh, 10) < cacheMs) {
|
||
const elapsed = now - parseInt(lastRefresh, 10);
|
||
const remaining = Math.ceil((cacheMs - elapsed) / 1000);
|
||
if (remaining > 0) {
|
||
setTrafficRefreshCooldown(remaining);
|
||
}
|
||
return;
|
||
}
|
||
|
||
refreshTrafficMutation.mutate();
|
||
}, [subscription, refreshTrafficMutation, subscriptionId]);
|
||
|
||
const copyUrl = () => {
|
||
if (displayedConnectionUrl) {
|
||
void copyToClipboard(displayedConnectionUrl);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
}
|
||
};
|
||
|
||
const handleRevoke = async () => {
|
||
const confirmed = await destructiveConfirm(
|
||
t('subscription.revoke.warning'),
|
||
t('subscription.revoke.confirmBtn'),
|
||
t('subscription.revoke.title'),
|
||
);
|
||
if (!confirmed) return;
|
||
revokeMutation.mutate();
|
||
};
|
||
|
||
// In multi-tariff mode without a specific subscription ID, redirect to list
|
||
if (isMultiTariff && !subscriptionId && !isLoading) {
|
||
return <Navigate to="/subscriptions" replace />;
|
||
}
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex min-h-64 items-center justify-center">
|
||
<div className="h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!subscription && subscriptionId) {
|
||
return (
|
||
<div className="mx-auto max-w-lg p-4 text-center">
|
||
<div className="mb-4 text-4xl">😕</div>
|
||
<h2 className="mb-2 text-xl font-bold text-dark-50">
|
||
{t('subscription.notFound', 'Подписка не найдена')}
|
||
</h2>
|
||
<p className="mb-4 text-sm text-dark-50/60">
|
||
{t('subscription.notFoundDesc', 'Возможно, подписка была удалена или не существует')}
|
||
</p>
|
||
<button
|
||
onClick={() => navigate('/subscriptions')}
|
||
className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-white"
|
||
>
|
||
{t('subscription.backToList', 'Мои подписки')}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Page title */}
|
||
<div className="flex items-center gap-3">
|
||
<WebBackButton to={isMultiTariff ? '/subscriptions' : '/'} />
|
||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
|
||
{isMultiTariff && subscription?.tariff_name
|
||
? subscription.tariff_name
|
||
: t('subscription.title')}
|
||
</h1>
|
||
</div>
|
||
|
||
{/* Current Subscription */}
|
||
{subscription ? (
|
||
(() => {
|
||
const usedGb = trafficData?.traffic_used_gb ?? subscription.traffic_used_gb;
|
||
const isUnlimited =
|
||
(trafficData?.is_unlimited ?? false) || subscription.traffic_limit_gb === 0;
|
||
const connectedDevices = devicesData?.total ?? 0;
|
||
const isAtDeviceLimit =
|
||
subscription.device_limit > 0 && connectedDevices >= subscription.device_limit;
|
||
|
||
return (
|
||
<div
|
||
className="relative overflow-hidden rounded-3xl backdrop-blur-xl"
|
||
style={{
|
||
background: g.cardBg,
|
||
border: subscription.is_trial
|
||
? '1px solid rgba(var(--color-accent-400), 0.15)'
|
||
: isDark
|
||
? `1px solid ${g.cardBorder}`
|
||
: `1px solid ${zone.mainHex}25`,
|
||
boxShadow: isDark
|
||
? g.shadow
|
||
: `0 2px 16px ${zone.mainHex}12, 0 0 0 1px ${zone.mainHex}08`,
|
||
padding: '28px 28px 24px',
|
||
}}
|
||
>
|
||
{/* Trial shimmer border */}
|
||
{subscription.is_trial && (
|
||
<div
|
||
className="pointer-events-none absolute inset-[-1px] animate-trial-glow rounded-3xl"
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
|
||
{/* Background glow */}
|
||
<div
|
||
className="pointer-events-none absolute"
|
||
style={{
|
||
top: -60,
|
||
right: -60,
|
||
width: 200,
|
||
height: 200,
|
||
borderRadius: '50%',
|
||
background: `radial-gradient(circle, ${zone.mainHex}${g.glowAlpha} 0%, transparent 70%)`,
|
||
transition: 'background 0.8s ease',
|
||
}}
|
||
aria-hidden="true"
|
||
/>
|
||
|
||
{/* ─── Header ─── */}
|
||
<div className="mb-6 flex items-start justify-between">
|
||
<div>
|
||
{/* Zone indicator */}
|
||
<div className="mb-1 flex items-center gap-2">
|
||
<div
|
||
className="h-2 w-2 rounded-full"
|
||
style={{
|
||
background: zone.mainHex,
|
||
boxShadow: `0 0 8px ${zone.mainHex}80`,
|
||
transition: 'all 0.6s ease',
|
||
}}
|
||
aria-hidden="true"
|
||
/>
|
||
<span
|
||
className="font-mono text-[11px] font-semibold uppercase tracking-widest"
|
||
style={{ color: zone.mainHex, transition: 'color 0.6s ease' }}
|
||
>
|
||
{isUnlimited ? t('dashboard.unlimited') : t(zone.labelKey)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Plan name */}
|
||
<h2 className="text-lg font-bold tracking-tight text-dark-50">
|
||
{subscription.tariff_name || t('subscription.currentPlan')}
|
||
</h2>
|
||
</div>
|
||
|
||
{/* Status badge */}
|
||
<span
|
||
className="max-w-[55%] shrink-0 rounded-full px-3 py-1 text-center font-mono text-[10px] font-semibold uppercase tracking-wider"
|
||
style={{
|
||
background: subscription.is_active
|
||
? `${zone.mainHex}15`
|
||
: subscription.is_limited
|
||
? 'rgba(255,184,0,0.12)'
|
||
: 'rgba(255,59,92,0.12)',
|
||
border: subscription.is_active
|
||
? `1px solid ${zone.mainHex}30`
|
||
: subscription.is_limited
|
||
? '1px solid rgba(255,184,0,0.25)'
|
||
: '1px solid rgba(255,59,92,0.25)',
|
||
color: subscription.is_active
|
||
? zone.mainHex
|
||
: subscription.is_limited
|
||
? 'rgb(var(--color-urgent-400))'
|
||
: 'rgb(var(--color-critical-500))',
|
||
}}
|
||
>
|
||
{subscription.is_active
|
||
? subscription.is_trial
|
||
? t('subscription.trialStatus')
|
||
: t('subscription.active')
|
||
: subscription.is_limited
|
||
? t('subscription.trafficLimited')
|
||
: subscription.status === 'disabled'
|
||
? t('subscription.pause.suspended')
|
||
: t('subscription.expired')}
|
||
</span>
|
||
</div>
|
||
|
||
{/* ─── Traffic Limited Banner ─── */}
|
||
{subscription.is_limited && (
|
||
<div
|
||
className="mb-6 rounded-[14px] p-4"
|
||
style={{
|
||
background:
|
||
'linear-gradient(135deg, rgba(255,184,0,0.08), rgba(255,184,0,0.03))',
|
||
border: '1px solid rgba(255,184,0,0.2)',
|
||
}}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<div
|
||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
|
||
style={{ background: 'rgba(255,184,0,0.12)' }}
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="rgb(var(--color-urgent-400))"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||
<line x1="12" y1="9" x2="12" y2="13" />
|
||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||
</svg>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p
|
||
className="text-sm font-semibold"
|
||
style={{ color: 'rgb(var(--color-urgent-400))' }}
|
||
>
|
||
{t('subscription.trafficLimitedTitle')}
|
||
</p>
|
||
<p className="mt-1 text-xs text-dark-400">
|
||
{t('subscription.trafficLimitedDescription')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ─── Trial Info Banner ─── */}
|
||
{subscription.is_trial && subscription.is_active && (
|
||
<div
|
||
className="mb-6 rounded-[14px] p-4"
|
||
style={{
|
||
background:
|
||
'linear-gradient(135deg, rgba(var(--color-accent-400), 0.08), rgba(var(--color-accent-400), 0.03))',
|
||
border: '1px solid rgba(var(--color-accent-400), 0.12)',
|
||
}}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<div
|
||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
|
||
style={{ background: 'rgba(var(--color-accent-400), 0.12)' }}
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="rgb(var(--color-accent-400))"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</div>
|
||
<div className="flex-1">
|
||
<div
|
||
className="text-sm font-semibold"
|
||
style={{ color: 'rgb(var(--color-accent-400))' }}
|
||
>
|
||
{t('subscription.trialInfo.title')}
|
||
</div>
|
||
<div className="mt-1 text-[12px] text-dark-50/40">
|
||
{t('subscription.trialInfo.description')}
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-4">
|
||
<div className="flex items-center gap-1.5">
|
||
<span
|
||
className="font-mono text-[12px] font-semibold"
|
||
style={{ color: 'rgb(var(--color-accent-400))' }}
|
||
>
|
||
{subscription.days_left > 0
|
||
? t('subscription.days', { count: subscription.days_left })
|
||
: `${subscription.hours_left}${t('subscription.hours')} ${subscription.minutes_left}${t('subscription.minutes')}`}
|
||
</span>
|
||
<span className="text-[11px] text-dark-50/30">
|
||
{t('subscription.trialInfo.remaining')}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span
|
||
className="font-mono text-[12px] font-semibold"
|
||
style={{ color: 'rgb(var(--color-accent-400))' }}
|
||
>
|
||
{subscription.traffic_limit_gb || '∞'} {t('common.units.gb')}
|
||
</span>
|
||
<span className="text-[11px] text-dark-50/30">
|
||
{t('subscription.traffic')}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span
|
||
className="font-mono text-[12px] font-semibold"
|
||
style={{ color: 'rgb(var(--color-accent-400))' }}
|
||
>
|
||
{subscription.device_limit === 0 ? '∞' : subscription.device_limit}
|
||
</span>
|
||
<span className="text-[11px] text-dark-50/30">
|
||
{t('subscription.devices')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ─── Traffic Progress ─── */}
|
||
<div className="mb-6">
|
||
<div className="mb-2.5 flex items-center justify-between">
|
||
<span className="text-[11px] font-medium uppercase tracking-wider text-dark-50/40">
|
||
{t('subscription.traffic')}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono text-[11px] text-dark-50/30">
|
||
{isUnlimited
|
||
? formatTraffic(usedGb)
|
||
: `${formatTraffic(usedGb)} / ${formatTraffic(subscription.traffic_limit_gb)}`}
|
||
</span>
|
||
<button
|
||
onClick={() => refreshTrafficMutation.mutate()}
|
||
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
||
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium text-dark-50/30 transition-colors hover:bg-dark-50/[0.05] hover:text-dark-50/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
<svg
|
||
className={`h-3 w-3 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={2}
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||
/>
|
||
</svg>
|
||
{trafficRefreshCooldown > 0
|
||
? `${trafficRefreshCooldown}s`
|
||
: t('common.refresh')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{subscription.traffic_reset_mode &&
|
||
subscription.traffic_reset_mode !== 'NO_RESET' && (
|
||
<div className="mb-2 text-[10px] text-dark-50/25">
|
||
{t(`subscription.trafficReset.${subscription.traffic_reset_mode}`)}
|
||
</div>
|
||
)}
|
||
<TrafficProgressBar
|
||
usedGb={usedGb}
|
||
limitGb={subscription.traffic_limit_gb}
|
||
percent={usedPercent}
|
||
isUnlimited={isUnlimited}
|
||
compact
|
||
/>
|
||
</div>
|
||
|
||
{/* ─── Connect Device Button ─── */}
|
||
{subscription.subscription_url && (
|
||
<HoverBorderGradient
|
||
as="button"
|
||
accentColor={zone.mainHex}
|
||
disabled={isAtDeviceLimit}
|
||
onClick={() => {
|
||
if (isAtDeviceLimit) {
|
||
haptic.notification('error');
|
||
return;
|
||
}
|
||
navigate(subscriptionId ? `/connection?sub=${subscriptionId}` : '/connection');
|
||
}}
|
||
className={`mb-5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`}
|
||
style={{ fontFamily: 'inherit' }}
|
||
>
|
||
<div
|
||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
|
||
style={{ background: `${zone.mainHex}12` }}
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke={zone.mainHex}
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||
<path d="M12 17v4M8 21h8" />
|
||
<path d="M12 8v4M10 10h4" opacity="0.7" />
|
||
</svg>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-semibold tracking-tight text-dark-50">
|
||
{t('dashboard.connectDevice')}
|
||
</div>
|
||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||
{subscription.device_limit === 0
|
||
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
|
||
: t('dashboard.devicesOfMax', {
|
||
used: connectedDevices,
|
||
max: subscription.device_limit,
|
||
})}
|
||
</div>
|
||
{isAtDeviceLimit && (
|
||
<div
|
||
className="mt-1 text-[10px] font-medium"
|
||
style={{ color: 'rgb(var(--color-warning-400))' }}
|
||
>
|
||
{t('dashboard.deviceLimitReached')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{subscription.device_limit === 0 ? (
|
||
<div
|
||
className="flex flex-shrink-0 items-center text-lg text-dark-50/40"
|
||
aria-hidden="true"
|
||
>
|
||
∞
|
||
</div>
|
||
) : subscription.device_limit <= 10 ? (
|
||
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
||
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||
<div
|
||
key={i}
|
||
className="h-[7px] w-[7px] rounded-full transition-[background-color,box-shadow] duration-300"
|
||
style={{
|
||
background: i < connectedDevices ? zone.mainHex : g.textGhost,
|
||
boxShadow: i < connectedDevices ? `0 0 6px ${zone.mainHex}50` : 'none',
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex w-16 flex-shrink-0 items-center" aria-hidden="true">
|
||
<div
|
||
className="h-[6px] w-full overflow-hidden rounded-full"
|
||
style={{ background: g.textGhost }}
|
||
>
|
||
{/* scaleX (compositor) instead of width (layout-thrash).
|
||
Track is 64px (w-16), so 0.0625 floor = 4px minimum,
|
||
preserving the prior minWidth behaviour. */}
|
||
<div
|
||
className="h-full w-full origin-left rounded-full transition-transform duration-500"
|
||
style={{
|
||
transform: `scaleX(${(() => {
|
||
const pct = connectedDevices / subscription.device_limit;
|
||
return connectedDevices > 0 ? Math.max(pct, 0.0625) : 0;
|
||
})()})`,
|
||
background: zone.mainHex,
|
||
boxShadow: `0 0 8px ${zone.mainHex}40`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</HoverBorderGradient>
|
||
)}
|
||
|
||
{/* ─── Subscription URL ─── */}
|
||
{displayedConnectionUrl && !shouldHideConnectionLink && (
|
||
<div className="mb-5 flex gap-2">
|
||
<code
|
||
className="block min-w-0 flex-1 truncate whitespace-nowrap rounded-[10px] px-3 py-2 font-mono text-[11px] text-dark-50/30"
|
||
style={{
|
||
background: g.codeBg,
|
||
border: `1px solid ${g.codeBorder}`,
|
||
}}
|
||
title={displayedConnectionUrl}
|
||
>
|
||
{displayedConnectionUrl}
|
||
</code>
|
||
<button
|
||
onClick={copyUrl}
|
||
className="flex h-auto items-center rounded-[10px] px-3 transition-colors duration-300"
|
||
style={{
|
||
background: copied ? 'rgba(var(--color-accent-400), 0.12)' : g.innerBorder,
|
||
border: copied
|
||
? '1px solid rgba(var(--color-accent-400), 0.2)'
|
||
: `1px solid ${g.trackBg}`,
|
||
color: copied ? 'rgb(var(--color-accent-400))' : g.textMuted,
|
||
}}
|
||
aria-label={t('subscription.copyLink')}
|
||
title={t('subscription.copyLink')}
|
||
>
|
||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* ─── Countdown ─── */}
|
||
<div className="mb-5">
|
||
<CountdownTimer
|
||
endDate={subscription.end_date}
|
||
isActive={subscription.is_active || subscription.is_limited}
|
||
glassColors={g}
|
||
/>
|
||
</div>
|
||
|
||
{/* ─── Locations ─── */}
|
||
{subscription.servers && subscription.servers.length > 0 && (
|
||
<div className="mb-5">
|
||
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
|
||
{t('subscription.locationsLabel')}
|
||
</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{subscription.servers.map((server) => (
|
||
<span
|
||
key={server.uuid}
|
||
className="inline-flex items-center gap-1.5 rounded-[8px] px-2.5 py-1 text-[11px] font-medium text-dark-50/50"
|
||
style={{
|
||
background: g.innerBorder,
|
||
border: `1px solid ${g.trackBg}`,
|
||
}}
|
||
>
|
||
{server.country_code && (
|
||
<span className="text-xs">{getFlagEmoji(server.country_code)}</span>
|
||
)}
|
||
<Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}>
|
||
{server.name}
|
||
</Twemoji>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ─── Purchased Traffic Packages ─── */}
|
||
{subscription.traffic_purchases && subscription.traffic_purchases.length > 0 && (
|
||
<div className="mb-5">
|
||
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
|
||
{t('subscription.purchasedTraffic')}
|
||
</div>
|
||
<div className="space-y-2">
|
||
{subscription.traffic_purchases.map((purchase) => (
|
||
<div
|
||
key={purchase.id}
|
||
className="rounded-[12px] p-3"
|
||
style={{
|
||
background: g.innerBg,
|
||
border: `1px solid ${g.innerBorder}`,
|
||
}}
|
||
>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<div
|
||
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
|
||
style={{ background: `${zone.mainHex}12` }}
|
||
>
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke={zone.mainHex}
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||
</svg>
|
||
</div>
|
||
<span className="text-sm font-semibold text-dark-50">
|
||
{purchase.traffic_gb} {t('common.units.gb')}
|
||
</span>
|
||
</div>
|
||
<div className="text-right">
|
||
<div
|
||
className="text-[11px] font-medium"
|
||
style={{
|
||
color: purchase.days_remaining === 0 ? '#FF6B35' : g.textSecondary,
|
||
}}
|
||
>
|
||
{purchase.days_remaining === 0
|
||
? t('subscription.expired')
|
||
: t('subscription.days', { count: purchase.days_remaining })}
|
||
</div>
|
||
<div className="mt-0.5 font-mono text-[9px] text-dark-50/20">
|
||
{t('subscription.trafficResetAt')}:{' '}
|
||
{new Date(purchase.expires_at).toLocaleDateString(undefined, {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="relative h-1.5 overflow-hidden rounded-full"
|
||
style={{ background: g.trackBg }}
|
||
>
|
||
<div
|
||
className="absolute inset-0 origin-left rounded-full transition-transform duration-500"
|
||
style={{
|
||
transform: `scaleX(${purchase.progress_percent / 100})`,
|
||
background: `linear-gradient(90deg, ${zone.mainHex}, ${zone.mainHex}80)`,
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="mt-1 flex justify-between font-mono text-[9px] text-dark-50/20">
|
||
<span>{new Date(purchase.created_at).toLocaleDateString()}</span>
|
||
<span>{new Date(purchase.expires_at).toLocaleDateString()}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ─── Autopay Toggle ─── */}
|
||
{!subscription.is_trial && !subscription.is_daily && (
|
||
<div
|
||
className="flex items-center justify-between rounded-[14px] p-3.5"
|
||
style={{
|
||
background: g.innerBg,
|
||
border: `1px solid ${g.innerBorder}`,
|
||
}}
|
||
>
|
||
<div>
|
||
<div className="text-sm font-semibold text-dark-50">
|
||
{t('subscription.autoRenewal')}
|
||
</div>
|
||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||
{t('subscription.daysBeforeExpiry', {
|
||
count: subscription.autopay_days_before,
|
||
})}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => autopayMutation.mutate(!subscription.autopay_enabled)}
|
||
disabled={autopayMutation.isPending}
|
||
role="switch"
|
||
aria-checked={subscription.autopay_enabled}
|
||
aria-label={t('subscription.autopay.title', 'Auto-payment')}
|
||
className="relative h-7 w-[52px] rounded-full transition-colors duration-300"
|
||
style={{
|
||
background: subscription.autopay_enabled ? zone.mainHex : g.textGhost,
|
||
}}
|
||
>
|
||
{/* translateX (compositor) instead of left (layout-thrash).
|
||
Resting position pinned at left:3px; on toggles a 23px
|
||
slide on the GPU. */}
|
||
<span
|
||
className="absolute left-[3px] top-[3px] h-[22px] w-[22px] rounded-full bg-white transition-transform duration-300"
|
||
style={{
|
||
transform: subscription.autopay_enabled
|
||
? 'translateX(23px)'
|
||
: 'translateX(0)',
|
||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||
}}
|
||
/>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})()
|
||
) : (
|
||
<div
|
||
className="relative overflow-hidden rounded-3xl py-12 text-center"
|
||
style={{
|
||
background: g.cardBg,
|
||
border: `1px solid ${g.cardBorder}`,
|
||
boxShadow: g.shadow,
|
||
}}
|
||
>
|
||
<div
|
||
className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl"
|
||
style={{ background: g.hoverBg }}
|
||
>
|
||
<svg
|
||
width="32"
|
||
height="32"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke={g.textFaint}
|
||
strokeWidth="1.5"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<div className="text-sm text-dark-50/30">{t('subscription.noSubscription')}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Daily Subscription Pause */}
|
||
{subscription && subscription.is_daily && !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',
|
||
}}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-base font-bold tracking-tight text-dark-50">
|
||
{t('subscription.pause.title')}
|
||
</h2>
|
||
<div className="mt-1 text-[12px] text-dark-50/35">
|
||
{subscription.is_limited
|
||
? t('subscription.trafficLimited')
|
||
: subscription.status === 'disabled'
|
||
? t('subscription.pause.suspended')
|
||
: subscription.is_daily_paused
|
||
? t('subscription.pause.paused')
|
||
: t('subscription.pause.active')}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => pauseMutation.mutate()}
|
||
disabled={pauseMutation.isPending}
|
||
className="rounded-[10px] px-4 py-2 text-sm font-semibold transition-colors duration-300"
|
||
style={{
|
||
background:
|
||
subscription.is_daily_paused || subscription.status === 'disabled'
|
||
? 'rgba(var(--color-accent-400), 0.12)'
|
||
: 'rgba(255,184,0,0.12)',
|
||
border:
|
||
subscription.is_daily_paused || subscription.status === 'disabled'
|
||
? '1px solid rgba(var(--color-accent-400), 0.2)'
|
||
: '1px solid rgba(255,184,0,0.2)',
|
||
color:
|
||
subscription.is_daily_paused || subscription.status === 'disabled'
|
||
? 'rgb(var(--color-accent-400))'
|
||
: 'rgb(var(--color-urgent-400))',
|
||
}}
|
||
>
|
||
{pauseMutation.isPending ? (
|
||
<span className="flex items-center gap-2">
|
||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||
</span>
|
||
) : subscription.is_daily_paused || subscription.status === 'disabled' ? (
|
||
t('subscription.pause.resumeBtn')
|
||
) : (
|
||
t('subscription.pause.pauseBtn')
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Pause mutation error */}
|
||
{pauseMutation.isError &&
|
||
(() => {
|
||
const balanceError = getInsufficientBalanceError(pauseMutation.error);
|
||
if (balanceError) {
|
||
const missingAmount = balanceError.required - balanceError.balance;
|
||
return (
|
||
<div className="mt-4">
|
||
<InsufficientBalancePrompt
|
||
missingAmountKopeks={missingAmount}
|
||
message={t('subscription.pause.insufficientBalance')}
|
||
compact
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div
|
||
className="mt-4 rounded-[10px] p-3 text-center text-sm"
|
||
style={{
|
||
background: 'rgba(255,59,92,0.08)',
|
||
border: '1px solid rgba(255,59,92,0.15)',
|
||
color: 'rgb(var(--color-critical-500))',
|
||
}}
|
||
>
|
||
{getErrorMessage(pauseMutation.error)}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Paused info or Next charge progress bar */}
|
||
{subscription.is_daily_paused ? (
|
||
<div
|
||
className="mt-4 rounded-[12px] p-4"
|
||
style={{
|
||
background: 'rgba(255,184,0,0.06)',
|
||
border: '1px solid rgba(255,184,0,0.12)',
|
||
}}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-lg" style={{ color: 'rgb(var(--color-urgent-400))' }}>
|
||
⏸️
|
||
</div>
|
||
<div>
|
||
<div
|
||
className="text-sm font-semibold"
|
||
style={{ color: 'rgb(var(--color-urgent-400))' }}
|
||
>
|
||
{t('subscription.pause.pausedInfo')}
|
||
</div>
|
||
<div className="mt-1 text-[12px] text-dark-50/35">
|
||
{t('subscription.pause.pausedDescription')}{' '}
|
||
{new Date(subscription.end_date).toLocaleDateString()} (
|
||
{t('subscription.pause.days', { count: subscription.days_left })})
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
subscription.next_daily_charge_at &&
|
||
(() => {
|
||
const now = new Date();
|
||
const nextChargeStr = subscription.next_daily_charge_at.endsWith('Z')
|
||
? subscription.next_daily_charge_at
|
||
: subscription.next_daily_charge_at + 'Z';
|
||
const nextCharge = new Date(nextChargeStr);
|
||
const totalMs = 24 * 60 * 60 * 1000;
|
||
const remainingMs = Math.max(0, nextCharge.getTime() - now.getTime());
|
||
const elapsedMs = totalMs - remainingMs;
|
||
const progress = Math.min(100, (elapsedMs / totalMs) * 100);
|
||
|
||
const hours = Math.floor(remainingMs / (1000 * 60 * 60));
|
||
const minutes = Math.floor((remainingMs % (1000 * 60 * 60)) / (1000 * 60));
|
||
|
||
return (
|
||
<div className="mt-4">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<span className="text-[11px] font-medium uppercase tracking-wider text-dark-50/35">
|
||
{t('subscription.pause.nextCharge')}
|
||
</span>
|
||
<span className="font-mono text-[12px] font-semibold text-dark-50">
|
||
{hours > 0
|
||
? `${hours}${t('subscription.pause.hours')} ${minutes}${t('subscription.pause.minutes')}`
|
||
: `${minutes}${t('subscription.pause.minutes')}`}
|
||
</span>
|
||
</div>
|
||
<div
|
||
className="relative h-2 overflow-hidden rounded-full"
|
||
style={{ background: g.trackBg }}
|
||
>
|
||
<div
|
||
className="absolute inset-0 origin-left rounded-full transition-transform duration-500"
|
||
style={{
|
||
transform: `scaleX(${progress / 100})`,
|
||
background:
|
||
'linear-gradient(90deg, rgb(var(--color-accent-500)), rgb(var(--color-accent-400)))',
|
||
}}
|
||
/>
|
||
</div>
|
||
{subscription.daily_price_kopeks && (
|
||
<div className="mt-2 text-center text-[11px] text-dark-50/25">
|
||
{t('subscription.pause.willBeCharged')}:{' '}
|
||
{formatPrice(subscription.daily_price_kopeks)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})()
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Purchase / Renewal CTA */}
|
||
<PurchaseCTAButton subscription={subscription} isMultiTariff={isMultiTariff} />
|
||
|
||
{/* Delete expired subscription */}
|
||
{isMultiTariff &&
|
||
subscription &&
|
||
!subscription.is_active &&
|
||
!subscription.is_trial &&
|
||
!subscription.is_limited && (
|
||
<div className="space-y-3">
|
||
{!showDeleteSheet ? (
|
||
<button
|
||
onClick={async () => {
|
||
if (platform === 'telegram') {
|
||
const confirmed = await destructiveConfirm(
|
||
t(
|
||
'subscription.deleteWarning',
|
||
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны.',
|
||
),
|
||
t('subscription.confirmDelete', 'Да, удалить'),
|
||
t('subscription.deleteTitle', 'Удалить подписку?'),
|
||
);
|
||
if (!confirmed) return;
|
||
setDeleteLoading(true);
|
||
try {
|
||
await subscriptionApi.deleteSubscription(subscription.id);
|
||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||
navigate('/subscriptions', { replace: true });
|
||
} catch {
|
||
setDeleteLoading(false);
|
||
}
|
||
} else {
|
||
setShowDeleteSheet(true);
|
||
}
|
||
}}
|
||
disabled={deleteLoading}
|
||
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-error-400/20 bg-error-400/5 p-3.5 text-sm font-medium text-error-400 transition-colors hover:bg-error-400/10 disabled:opacity-50"
|
||
>
|
||
<svg
|
||
className="h-4 w-4"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth={2}
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||
/>
|
||
</svg>
|
||
{t('subscription.delete', 'Удалить подписку')}
|
||
</button>
|
||
) : (
|
||
<div
|
||
className="rounded-2xl border border-error-400/20 p-4"
|
||
style={{ background: 'rgba(255,59,92,0.04)' }}
|
||
>
|
||
<div className="mb-3 text-sm font-semibold text-error-400">
|
||
{t('subscription.deleteTitle', 'Удалить подписку?')}
|
||
</div>
|
||
<div className="mb-4 text-xs" style={{ color: g.textSecondary }}>
|
||
{t(
|
||
'subscription.deleteWarning',
|
||
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.',
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={async () => {
|
||
setDeleteLoading(true);
|
||
try {
|
||
await subscriptionApi.deleteSubscription(subscription.id);
|
||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||
navigate('/subscriptions', { replace: true });
|
||
} catch {
|
||
setDeleteLoading(false);
|
||
setShowDeleteSheet(false);
|
||
}
|
||
}}
|
||
disabled={deleteLoading}
|
||
className="flex-1 rounded-xl bg-error-500 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
||
>
|
||
{deleteLoading
|
||
? t('common.processing', 'Удаление...')
|
||
: t('subscription.confirmDelete', 'Да, удалить')}
|
||
</button>
|
||
<button
|
||
onClick={() => setShowDeleteSheet(false)}
|
||
className="flex-1 rounded-xl border border-dark-700 py-2.5 text-sm font-medium transition-colors hover:bg-dark-700"
|
||
style={{ color: g.textSecondary }}
|
||
>
|
||
{t('common.cancel', 'Отмена')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Additional Options (Buy Devices) */}
|
||
{subscription &&
|
||
(subscription.is_active || subscription.is_limited) &&
|
||
!subscription.is_trial &&
|
||
subscription.device_limit !== 0 && (
|
||
<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 Devices */}
|
||
<DeviceTopupSheet
|
||
open={showDeviceTopup}
|
||
onOpen={() => setShowDeviceTopup(true)}
|
||
onClose={() => setShowDeviceTopup(false)}
|
||
subscription={subscription}
|
||
subscriptionId={subscriptionId}
|
||
devicesToAdd={devicesToAdd}
|
||
onDevicesToAddChange={setDevicesToAdd}
|
||
purchaseOptions={purchaseOptions}
|
||
isDark={isDark}
|
||
/>
|
||
|
||
{/* 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">
|
||
{!showTrafficTopup ? (
|
||
<button
|
||
onClick={() => setShowTrafficTopup(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.buyTraffic')}
|
||
</div>
|
||
<div className="mt-1 text-sm text-dark-400">
|
||
{t('subscription.additionalOptions.currentTrafficLimit', {
|
||
limit: subscription.traffic_limit_gb,
|
||
used: subscription.traffic_used_gb.toFixed(1),
|
||
})}
|
||
</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.buyTrafficTitle')}
|
||
</h3>
|
||
<button
|
||
onClick={() => {
|
||
setShowTrafficTopup(false);
|
||
setSelectedTrafficPackage(null);
|
||
}}
|
||
className="text-sm text-dark-400 hover:text-dark-200"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
className={`mb-4 rounded-lg p-2 text-xs ${isDark ? 'bg-dark-700/30 text-dark-500' : 'bg-champagne-300/40 text-champagne-600'}`}
|
||
>
|
||
⚠️ {t('subscription.additionalOptions.trafficWarning')}
|
||
</div>
|
||
|
||
{!trafficPackages || trafficPackages.length === 0 ? (
|
||
<div className="py-4 text-center text-sm text-dark-400">
|
||
{t('subscription.additionalOptions.trafficUnavailable')}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{trafficPackages.map((pkg) => (
|
||
<button
|
||
key={pkg.gb}
|
||
onClick={() => setSelectedTrafficPackage(pkg.gb)}
|
||
className={`rounded-xl border p-4 text-center transition-all ${
|
||
selectedTrafficPackage === pkg.gb
|
||
? 'border-accent-500 bg-accent-500/10'
|
||
: 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="text-lg font-semibold text-dark-100">
|
||
{pkg.is_unlimited
|
||
? '♾️ ' + t('subscription.additionalOptions.unlimited')
|
||
: `${pkg.gb} ${t('common.units.gb')}`}
|
||
</div>
|
||
{/* Discount badge */}
|
||
{pkg.discount_percent && pkg.discount_percent > 0 && (
|
||
<div className="mb-1">
|
||
<span className="inline-block rounded-full bg-success-500/20 px-2 py-0.5 text-xs font-medium text-success-400">
|
||
-{pkg.discount_percent}%
|
||
</span>
|
||
</div>
|
||
)}
|
||
{/* Price with original strikethrough if discount */}
|
||
<div className="font-medium text-accent-400">
|
||
{pkg.discount_percent &&
|
||
pkg.discount_percent > 0 &&
|
||
pkg.base_price_kopeks ? (
|
||
<>
|
||
<span className="mr-1 text-sm text-dark-500 line-through">
|
||
{formatPrice(pkg.base_price_kopeks)}
|
||
</span>
|
||
{formatPrice(pkg.price_kopeks)}
|
||
</>
|
||
) : (
|
||
formatPrice(pkg.price_kopeks)
|
||
)}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{selectedTrafficPackage !== null &&
|
||
(() => {
|
||
const selectedPkg = trafficPackages.find(
|
||
(p) => p.gb === selectedTrafficPackage,
|
||
);
|
||
const hasEnoughBalance =
|
||
!selectedPkg ||
|
||
!purchaseOptions ||
|
||
selectedPkg.price_kopeks <= purchaseOptions.balance_kopeks;
|
||
const missingAmount =
|
||
selectedPkg && purchaseOptions
|
||
? selectedPkg.price_kopeks - purchaseOptions.balance_kopeks
|
||
: 0;
|
||
|
||
return (
|
||
<>
|
||
{!hasEnoughBalance && missingAmount > 0 && (
|
||
<InsufficientBalancePrompt
|
||
missingAmountKopeks={missingAmount}
|
||
compact
|
||
className="mb-3"
|
||
onBeforeTopUp={async () => {
|
||
await subscriptionApi.saveTrafficCart(
|
||
selectedTrafficPackage,
|
||
subscriptionId,
|
||
);
|
||
}}
|
||
/>
|
||
)}
|
||
<button
|
||
onClick={() =>
|
||
trafficPurchaseMutation.mutate(selectedTrafficPackage)
|
||
}
|
||
disabled={trafficPurchaseMutation.isPending || !hasEnoughBalance}
|
||
className="btn-primary w-full py-3"
|
||
>
|
||
{trafficPurchaseMutation.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>
|
||
) : selectedPkg?.is_unlimited ? (
|
||
t('subscription.additionalOptions.buyUnlimited')
|
||
) : (
|
||
t('subscription.additionalOptions.buyTrafficGb', {
|
||
gb: selectedTrafficPackage,
|
||
})
|
||
)}
|
||
</button>
|
||
</>
|
||
);
|
||
})()}
|
||
|
||
{trafficPurchaseMutation.isError && (
|
||
<div className="text-center text-sm text-error-400">
|
||
{getErrorMessage(trafficPurchaseMutation.error)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Server Management - only in classic mode */}
|
||
{!isTariffsMode && (
|
||
<div className="mt-4">
|
||
{!showServerManagement ? (
|
||
<button
|
||
onClick={() => setShowServerManagement(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.manageServers')}
|
||
</div>
|
||
<div className="mt-1 text-sm text-dark-400">
|
||
{t('subscription.servers', { count: subscription.servers?.length || 0 })}
|
||
</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.manageServersTitle')}
|
||
</h3>
|
||
<button
|
||
onClick={() => {
|
||
setShowServerManagement(false);
|
||
setSelectedServersToUpdate([]);
|
||
}}
|
||
className="text-sm text-dark-400 hover:text-dark-200"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{countriesLoading ? (
|
||
<div className="flex items-center justify-center py-8">
|
||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||
</div>
|
||
) : countriesData && countriesData.countries.length > 0 ? (
|
||
<div className="space-y-4">
|
||
<div
|
||
className={`rounded-lg p-2 text-xs ${isDark ? 'bg-dark-700/30 text-dark-500' : 'bg-champagne-300/40 text-champagne-600'}`}
|
||
>
|
||
{t('subscription.serverManagement.statusLegend')}
|
||
</div>
|
||
|
||
{countriesData.discount_percent > 0 && (
|
||
<div className="rounded-lg border border-success-500/30 bg-success-500/10 p-2 text-xs text-success-400">
|
||
🎁{' '}
|
||
{t('subscription.serverManagement.discountBanner', {
|
||
percent: countriesData.discount_percent,
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||
{countriesData.countries
|
||
.filter((country) => country.is_available || country.is_connected)
|
||
.map((country) => {
|
||
const isCurrentlyConnected = country.is_connected;
|
||
const isSelected = selectedServersToUpdate.includes(country.uuid);
|
||
const willBeAdded = !isCurrentlyConnected && isSelected;
|
||
const willBeRemoved = isCurrentlyConnected && !isSelected;
|
||
|
||
return (
|
||
<button
|
||
key={country.uuid}
|
||
onClick={() => {
|
||
if (isSelected) {
|
||
setSelectedServersToUpdate((prev) =>
|
||
prev.filter((u) => u !== country.uuid),
|
||
);
|
||
} else {
|
||
setSelectedServersToUpdate((prev) => [...prev, country.uuid]);
|
||
}
|
||
}}
|
||
disabled={!country.is_available && !isCurrentlyConnected}
|
||
className={`flex w-full items-center justify-between rounded-xl border p-3 text-left transition-all ${
|
||
isSelected
|
||
? willBeAdded
|
||
? 'border-success-500 bg-success-500/10'
|
||
: 'border-accent-500 bg-accent-500/10'
|
||
: willBeRemoved
|
||
? 'border-error-500/50 bg-error-500/5'
|
||
: 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'
|
||
} ${!country.is_available && !isCurrentlyConnected ? 'cursor-not-allowed opacity-50' : ''}`}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-lg">
|
||
{willBeAdded
|
||
? '➕'
|
||
: willBeRemoved
|
||
? '➖'
|
||
: isSelected
|
||
? '✅'
|
||
: '⚪'}
|
||
</span>
|
||
<div>
|
||
<div className="flex items-center gap-2 font-medium text-dark-100">
|
||
{country.name}
|
||
{country.has_discount && !isCurrentlyConnected && (
|
||
<span className="rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400">
|
||
-{country.discount_percent}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
{willBeAdded && (
|
||
<div className="text-xs text-success-400">
|
||
+{formatPrice(country.price_kopeks)}{' '}
|
||
{t('subscription.serverManagement.forDays', {
|
||
days: countriesData.days_left,
|
||
})}
|
||
{country.has_discount && (
|
||
<span className="ml-1 text-dark-500 line-through">
|
||
{formatPrice(
|
||
Math.round(
|
||
(country.base_price_kopeks *
|
||
countriesData.days_left) /
|
||
30,
|
||
),
|
||
)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{!willBeAdded && !isCurrentlyConnected && (
|
||
<div className="text-xs text-dark-500">
|
||
{formatPrice(country.price_per_month_kopeks)}
|
||
{t('subscription.serverManagement.perMonth')}
|
||
{country.has_discount && (
|
||
<span className="ml-1 text-dark-600 line-through">
|
||
{formatPrice(country.base_price_kopeks)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{!country.is_available && !isCurrentlyConnected && (
|
||
<div className="text-xs text-dark-500">
|
||
{t('subscription.serverManagement.unavailable')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{country.country_code && (
|
||
<span className="text-xl">
|
||
{getFlagEmoji(country.country_code)}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{(() => {
|
||
const currentConnected = countriesData.countries
|
||
.filter((c) => c.is_connected)
|
||
.map((c) => c.uuid);
|
||
const added = selectedServersToUpdate.filter(
|
||
(u) => !currentConnected.includes(u),
|
||
);
|
||
const removed = currentConnected.filter(
|
||
(u) => !selectedServersToUpdate.includes(u),
|
||
);
|
||
const hasChanges = added.length > 0 || removed.length > 0;
|
||
|
||
// Calculate cost for added servers
|
||
const addedServers = countriesData.countries.filter((c) =>
|
||
added.includes(c.uuid),
|
||
);
|
||
const totalCost = addedServers.reduce(
|
||
(sum, s) => sum + s.price_kopeks,
|
||
0,
|
||
);
|
||
const hasEnoughBalance =
|
||
!purchaseOptions || totalCost <= purchaseOptions.balance_kopeks;
|
||
const missingAmount = purchaseOptions
|
||
? totalCost - purchaseOptions.balance_kopeks
|
||
: 0;
|
||
|
||
return hasChanges ? (
|
||
<div
|
||
className={`space-y-3 border-t pt-3 ${isDark ? 'border-dark-700/50' : 'border-champagne-300/60'}`}
|
||
>
|
||
{added.length > 0 && (
|
||
<div className="text-sm">
|
||
<span className="text-success-400">
|
||
{t('subscription.serverManagement.toAdd')}
|
||
</span>{' '}
|
||
<span className="text-dark-300">
|
||
{addedServers.map((s) => s.name).join(', ')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{removed.length > 0 && (
|
||
<div className="text-sm">
|
||
<span className="text-error-400">
|
||
{t('subscription.serverManagement.toDisconnect')}
|
||
</span>{' '}
|
||
<span className="text-dark-300">
|
||
{countriesData.countries
|
||
.filter((c) => removed.includes(c.uuid))
|
||
.map((s) => s.name)
|
||
.join(', ')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{totalCost > 0 && (
|
||
<div className="text-center">
|
||
<div className="text-sm text-dark-400">
|
||
{t('subscription.serverManagement.paymentProrated')}
|
||
</div>
|
||
<div className="text-xl font-bold text-accent-400">
|
||
{formatPrice(totalCost)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{totalCost > 0 && !hasEnoughBalance && missingAmount > 0 && (
|
||
<InsufficientBalancePrompt
|
||
missingAmountKopeks={missingAmount}
|
||
compact
|
||
/>
|
||
)}
|
||
|
||
<button
|
||
onClick={() =>
|
||
updateCountriesMutation.mutate(selectedServersToUpdate)
|
||
}
|
||
disabled={
|
||
updateCountriesMutation.isPending ||
|
||
selectedServersToUpdate.length === 0 ||
|
||
(totalCost > 0 && !hasEnoughBalance)
|
||
}
|
||
className="btn-primary w-full py-3"
|
||
>
|
||
{updateCountriesMutation.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.serverManagement.applyChanges')
|
||
)}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="py-2 text-center text-sm text-dark-500">
|
||
{t('subscription.serverManagement.selectServersHint')}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{updateCountriesMutation.isError && (
|
||
<div className="text-center text-sm text-error-400">
|
||
{getErrorMessage(updateCountriesMutation.error)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="py-4 text-center text-sm text-dark-400">
|
||
{t('subscription.serverManagement.noServersAvailable')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Reissue Subscription — standalone block, not dependent on device_limit */}
|
||
{subscription &&
|
||
(subscription.is_active || subscription.is_limited) &&
|
||
!subscription.is_trial && (
|
||
<div
|
||
className="relative overflow-hidden rounded-3xl"
|
||
style={{
|
||
background: g.cardBg,
|
||
border: `1px solid ${g.cardBorder}`,
|
||
boxShadow: g.shadow,
|
||
padding: '16px 20px',
|
||
}}
|
||
>
|
||
<button
|
||
onClick={handleRevoke}
|
||
disabled={revokeMutation.isPending || revokeCooldown > 0}
|
||
className="w-full rounded-xl border border-warning-500/30 bg-warning-500/10 p-4 text-left transition-colors hover:bg-warning-500/20 disabled:opacity-50"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="font-medium text-warning-400">
|
||
{t('subscription.revoke.button')}
|
||
</div>
|
||
<div className="mt-1 text-sm text-dark-400">
|
||
{revokeCooldown > 0
|
||
? t('subscription.revoke.cooldown', {
|
||
minutes: Math.floor(revokeCooldown / 60),
|
||
seconds: revokeCooldown % 60,
|
||
})
|
||
: t('subscription.revoke.description')}
|
||
</div>
|
||
</div>
|
||
<div className="text-warning-400">
|
||
{revokeMutation.isPending ? (
|
||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-warning-400/30 border-t-amber-400" />
|
||
) : (
|
||
<svg
|
||
className="h-5 w-5"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={1.5}
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"
|
||
/>
|
||
</svg>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
{revokeMutation.error && (
|
||
<p className="mt-2 text-sm text-error-400">{getErrorMessage(revokeMutation.error)}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* My Devices Section */}
|
||
{subscription && (
|
||
<div
|
||
className="relative overflow-hidden rounded-3xl"
|
||
style={{
|
||
background: g.cardBg,
|
||
border: `1px solid ${g.cardBorder}`,
|
||
boxShadow: g.shadow,
|
||
padding: '24px 28px',
|
||
}}
|
||
>
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h2 className="text-base font-bold tracking-tight text-dark-50">
|
||
{t('subscription.myDevices')}
|
||
</h2>
|
||
{devicesData && devicesData.devices.length > 0 && (
|
||
<button
|
||
onClick={() => {
|
||
if (confirm(t('subscription.confirmDeleteAllDevices'))) {
|
||
deleteAllDevicesMutation.mutate();
|
||
}
|
||
}}
|
||
disabled={deleteAllDevicesMutation.isPending}
|
||
className="text-[11px] font-medium transition-colors"
|
||
style={{ color: 'rgb(var(--color-critical-500))' }}
|
||
>
|
||
{t('subscription.deleteAllDevices')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{devicesLoading ? (
|
||
<div className="flex items-center justify-center py-8">
|
||
<div
|
||
className="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
|
||
style={{
|
||
borderColor: 'rgb(var(--color-accent-500))',
|
||
borderTopColor: 'transparent',
|
||
}}
|
||
/>
|
||
</div>
|
||
) : devicesData && devicesData.devices.length > 0 ? (
|
||
<div className="space-y-2">
|
||
<div className="mb-2 font-mono text-[11px] text-dark-50/30">
|
||
{devicesData.device_limit === 0
|
||
? `${devicesData.total} · ∞`
|
||
: `${devicesData.total} / ${t('subscription.devices', { count: devicesData.device_limit })}`}
|
||
</div>
|
||
{devicesData.devices.map((device) => {
|
||
const isEditing = editingDeviceHwid === device.hwid;
|
||
// Display priority: user alias → device model → platform.
|
||
const displayName =
|
||
(device.local_name && device.local_name.trim()) ||
|
||
device.device_model ||
|
||
device.platform;
|
||
|
||
return (
|
||
<div
|
||
key={device.hwid}
|
||
className="flex items-center justify-between rounded-[12px] p-3.5"
|
||
style={{
|
||
background: g.innerBg,
|
||
border: `1px solid ${g.innerBorder}`,
|
||
}}
|
||
>
|
||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||
<div
|
||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
|
||
style={{ background: g.trackBg }}
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke={g.textSecondary}
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||
</svg>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
{isEditing ? (
|
||
<input
|
||
type="text"
|
||
autoFocus
|
||
value={editingDeviceName}
|
||
maxLength={DEVICE_ALIAS_MAX_LENGTH}
|
||
placeholder={device.device_model || device.platform}
|
||
onChange={(e) => setEditingDeviceName(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const trimmed = editingDeviceName.trim();
|
||
renameDeviceMutation.mutate({
|
||
hwid: device.hwid,
|
||
name: trimmed || null,
|
||
});
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
setEditingDeviceHwid(null);
|
||
setEditingDeviceName('');
|
||
}
|
||
}}
|
||
className="w-full rounded-md border-none bg-transparent px-2 py-1 text-sm font-semibold text-dark-50 outline-none focus:ring-1"
|
||
style={{
|
||
background: g.trackBg,
|
||
boxShadow: `inset 0 0 0 1px ${g.innerBorder}`,
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="truncate text-sm font-semibold text-dark-50">
|
||
{displayName}
|
||
</div>
|
||
)}
|
||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-dark-50/30">
|
||
<span className="truncate">{device.platform}</span>
|
||
<span className="font-mono text-dark-50/20">
|
||
{device.hwid.slice(0, 8).toUpperCase()}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-shrink-0 items-center gap-1">
|
||
{isEditing ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const trimmed = editingDeviceName.trim();
|
||
renameDeviceMutation.mutate({
|
||
hwid: device.hwid,
|
||
name: trimmed || null,
|
||
});
|
||
}}
|
||
disabled={renameDeviceMutation.isPending}
|
||
className="p-2 transition-colors"
|
||
style={{ color: g.textSecondary }}
|
||
title={t('subscription.renameDeviceSave', 'Сохранить')}
|
||
aria-label={t('subscription.renameDeviceSave', 'Сохранить')}
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setEditingDeviceHwid(null);
|
||
setEditingDeviceName('');
|
||
}}
|
||
disabled={renameDeviceMutation.isPending}
|
||
className="p-2 transition-colors"
|
||
style={{ color: g.textFaint }}
|
||
title={t('subscription.renameDeviceCancel', 'Отмена')}
|
||
aria-label={t('subscription.renameDeviceCancel', 'Отмена')}
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setEditingDeviceHwid(device.hwid);
|
||
setEditingDeviceName(device.local_name || '');
|
||
}}
|
||
className="p-2 transition-colors"
|
||
style={{ color: g.textFaint }}
|
||
title={t('subscription.renameDevice', 'Переименовать')}
|
||
aria-label={t('subscription.renameDevice', 'Переименовать')}
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (confirm(t('subscription.confirmDeleteDevice'))) {
|
||
deleteDeviceMutation.mutate(device.hwid);
|
||
}
|
||
}}
|
||
disabled={deleteDeviceMutation.isPending}
|
||
className="p-2 transition-colors"
|
||
style={{ color: g.textFaint }}
|
||
title={t('subscription.deleteDevice')}
|
||
aria-label={t('subscription.deleteDevice')}
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||
</svg>
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="py-8 text-center text-[12px] text-dark-50/25">
|
||
{t('subscription.noDevices')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|