feat(dashboard): подключение устройства прикреплено к карточке подписки

На главной подключение жило отдельной плиткой под карточкой, и показывалось
только когда подписка одна: при нескольких «выбор устройства неоднозначен».
Получалось три плитки-ровни подряд — подписка, действие над этой подпиской и
покупка новой, — из которых вторая ровней не была. При нескольких подписках
подключение с главной пропадало вовсе.

Теперь это подвал самой карточки: тело ведёт в подписку, подвал — в
подключение. Одна карточка на подписку при любом их числе, отдельных плиток
нет. Карточка снаружи перестала быть `<button>` — вложенные кнопки невалидны
и ведут себя в браузерах непредсказуемо, поэтому это обёртка с двумя зонами
нажатия.

Подвал включается пропом `connect` и задан только на главной: список «Все
подписки» остаётся перечнем без действий. Где подвал есть, из строки
статистики убран счётчик лимита устройств — он дублировал бы счётчик подвала.

Акцентом подсвечивается только подписка без единого устройства: там действие
и нужно. У остальных подвал приглушён и не спорит с карточкой.

Исчерпанный лимит не блокирует кнопку, а ведёт в разбор: сколько слотов даёт
тариф, какие устройства их занимают, отключить лишнее либо добавить слоты.
Докупка скрыта на тестовой подписке и при безлимите — те же условия, что у
блока докупки на странице подписки, — и тогда текст не обещает того, чего
сделать нельзя.

Пока счётчик устройств не пришёл, подвал показывает скелетон, а не догадку о
состоянии. Его высоту задаёт невидимый спан с той же типографикой: подбор
числом даёт дробные пиксели строчного бокса, и карточка дёргается. Проверено
замером — сдвиг 0px на всех состояниях.

ResponsiveSheet: на телефоне нижний шит, с 640px обычное центрированное окно.
Шит на десктопе — плашка у нижнего края с ручкой перетаскивания, бессмысленной
для мыши. Компонент общий, остальные шиты кабинета смогут его переиспользовать.

ConnectDeviceTile не тронут и продолжает жить в карточке подписки одиночного
тарифа вместе с якорем онбординга.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
c0mrade
2026-08-19 16:55:27 +03:00
parent b4e1de6152
commit 28e25a9292
11 changed files with 650 additions and 108 deletions

View File

@@ -0,0 +1,139 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { subscriptionApi } from '@/api/subscription';
import { DevicesIcon, PlusIcon, TrashIcon, WarningIcon } from '@/components/icons';
import { ResponsiveSheet } from '@/components/ui/ResponsiveSheet';
import { getApiErrorMessage } from '@/utils/api-error';
interface DeviceLimitSheetProps {
isOpen: boolean;
onClose: () => void;
subscriptionId: number;
subscriptionName: string;
deviceLimit: number;
isTrial: boolean;
devices: Array<{
hwid: string;
platform: string;
device_model: string;
local_name?: string | null;
}>;
/** Уводит на страницу подписки, где живёт докупка слотов. */
onOpenSubscription: () => void;
}
/**
* Почему нельзя подключить ещё одно устройство и что с этим делать.
*
* Открывается из подвала карточки, когда слоты кончились. Заблокировать
* кнопку было бы проще, но человек остался бы без выхода: реальных действий
* тут два — освободить слот или добавить ещё.
*/
export function DeviceLimitSheet({
isOpen,
onClose,
subscriptionId,
subscriptionName,
deviceLimit,
isTrial,
devices,
onOpenSubscription,
}: DeviceLimitSheetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
// Докупка слотов недоступна на тестовой подписке и при безлимите — ровно
// те же условия, что у блока докупки на странице подписки.
const canAddSlots = !isTrial && deviceLimit !== 0;
const disconnect = useMutation({
mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid, subscriptionId),
onSuccess: () => {
setError(null);
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
onClose();
},
onError: (err) =>
setError(getApiErrorMessage(err, t('common.error', 'Не удалось выполнить действие'))),
});
return (
<ResponsiveSheet
isOpen={isOpen}
onClose={onClose}
title={t('subscription.connectFooter.full', 'Все слоты заняты')}
>
<div className="space-y-4 px-4 pb-6 sm:px-5">
<div className="flex items-start gap-3 rounded-2xl bg-warning-400/10 p-3.5">
<WarningIcon className="mt-0.5 h-5 w-5 shrink-0 text-warning-400" />
<p className="text-[13px] leading-snug text-dark-200">
{canAddSlots
? t('subscription.connectFooter.limitExplained', {
defaultValue:
'Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите слот или добавьте ещё.',
name: subscriptionName,
count: deviceLimit,
})
: t('subscription.connectFooter.limitExplainedNoTopup', {
defaultValue:
'Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите занятый слот.',
name: subscriptionName,
count: deviceLimit,
})}
</p>
</div>
<div>
<div className="mb-2 text-[11px] uppercase tracking-wider text-dark-500">
{t('subscription.connectFooter.connectedDevices', 'Подключённые устройства')}
</div>
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.hwid}
className="flex items-center gap-3 rounded-xl bg-dark-800/60 px-3 py-2.5"
>
<DevicesIcon className="h-4 w-4 shrink-0 opacity-40" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-dark-100">
{device.local_name || device.device_model || device.platform}
</div>
<div className="text-[11px] text-dark-500">{device.platform}</div>
</div>
<button
type="button"
disabled={disconnect.isPending}
onClick={() => disconnect.mutate(device.hwid)}
className="flex min-h-[36px] shrink-0 items-center gap-1.5 rounded-lg bg-error-500/10 px-2.5 text-[12px] font-medium text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50"
>
<TrashIcon className="h-3.5 w-3.5" />
{t('subscription.connectFooter.disconnect', 'Отключить')}
</button>
</div>
))}
</div>
</div>
{error && (
<p className="rounded-xl bg-error-500/10 px-3 py-2 text-[12px] text-error-400">{error}</p>
)}
{canAddSlots ? (
<button type="button" onClick={onOpenSubscription} className="btn-primary w-full">
<PlusIcon className="h-4 w-4" />
{t('subscription.connectFooter.addSlots', 'Добавить слоты')}
</button>
) : (
<p className="rounded-xl bg-dark-800/60 px-3 py-2.5 text-[12px] leading-snug text-dark-400">
{t(
'subscription.connectFooter.noTopupHint',
'На тестовой подписке слоты докупить нельзя — освободите занятый или оформите платный тариф.',
)}
</p>
)}
</div>
</ResponsiveSheet>
);
}

View File

@@ -0,0 +1,93 @@
import { useTranslation } from 'react-i18next';
import { ChevronRightIcon, DevicesIcon, WarningIcon } from '@/components/icons';
import { cn } from '@/lib/utils';
import type { ConnectFooterState } from './connectFooterState';
interface SubscriptionConnectFooterProps {
state: ConnectFooterState;
/** Цвет разделителя берём у карточки, чтобы подвал читался её частью. */
borderColor: string;
mutedColor: string;
onConnect: () => void;
onManage: () => void;
}
/**
* Подвал карточки подписки: подключить устройство либо разобраться с лимитом.
*
* Отдельная зона нажатия внутри карточки, поэтому карточка снаружи — не
* `<button>`, а обёртка: вложенные кнопки невалидны и ломаются в браузере.
*/
export function SubscriptionConnectFooter({
state,
borderColor,
mutedColor,
onConnect,
onManage,
}: SubscriptionConnectFooterProps) {
const { t } = useTranslation();
if (state.kind === 'hidden') return null;
if (state.kind === 'loading') {
return (
<div
className="flex items-center gap-2.5 border-t px-4 py-2.5"
style={{ borderColor }}
aria-busy="true"
>
{/* Высоту задаёт невидимый текст с той же типографикой, что у готового
подвала. Подбирать её числом нельзя: строчный бокс даёт дробные
пиксели, и карточка дёргается при появлении данных. */}
<div className="flex w-full items-center gap-2.5">
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-dark-700/60" />
<div className="h-3.5 w-40 animate-pulse rounded bg-dark-700/60" />
<div className="ml-auto h-3 w-10 animate-pulse rounded bg-dark-700/60" />
<span aria-hidden className="w-0 overflow-hidden text-[13px] font-medium">
&nbsp;
</span>
</div>
</div>
);
}
const isFull = state.kind === 'full';
const highlight = state.kind === 'connect' && state.highlight;
const counter =
state.kind === 'connect' && state.unlimited
? `${state.used} · ∞`
: `${state.used} / ${state.limit}`;
return (
<button
type="button"
onClick={isFull ? onManage : onConnect}
className="flex w-full items-center gap-2.5 border-t px-4 py-2.5 text-left transition-colors active:bg-accent-500/10"
style={{ borderColor }}
>
{isFull ? (
<WarningIcon className="h-4 w-4 shrink-0 text-warning-400/80" />
) : (
<DevicesIcon
className={cn('h-4 w-4 shrink-0', highlight ? 'text-accent-400' : 'opacity-40')}
/>
)}
<span
className={cn('text-[13px] font-medium', highlight && 'text-accent-400')}
style={highlight ? undefined : { color: mutedColor }}
>
{isFull
? t('subscription.connectFooter.full', 'Все слоты заняты')
: t('subscription.connectFooter.connect', 'Подключить устройство')}
</span>
<span
className={cn('ml-auto text-[11px] tabular-nums', isFull && 'text-warning-400/80')}
style={isFull ? undefined : { color: mutedColor }}
>
{counter}
</span>
<ChevronRightIcon className="h-3.5 w-3.5 shrink-0 opacity-30" />
</button>
);
}

View File

@@ -4,6 +4,8 @@ import { getGlassColors } from '../../utils/glassTheme';
import { useHaptic } from '../../platform';
import { CalendarIcon, CheckIcon, ChevronRightIcon, DevicesIcon } from '@/components/icons';
import type { SubscriptionListItem } from '../../types';
import { connectFooterState } from './connectFooterState';
import { SubscriptionConnectFooter } from './SubscriptionConnectFooter';
function formatDate(iso: string | null, locale?: string): string {
if (!iso) return '—';
@@ -68,9 +70,20 @@ function StatusBadge({
export default function SubscriptionListCard({
subscription,
onClick,
connect,
}: {
subscription: SubscriptionListItem;
onClick: () => void;
/**
* Подключение устройства прямо из карточки. Задаётся только на главной:
* список «Все подписки» остаётся простым перечнем без действий.
*/
connect?: {
/** `undefined`, пока число устройств не загрузилось. */
connectedDevices: number | undefined;
onConnect: () => void;
onManage: () => void;
};
}) {
const { t, i18n } = useTranslation();
const { isDark } = useTheme();
@@ -123,89 +136,113 @@ export default function SubscriptionListCard({
: 'rgba(255,59,92,0.03)'
: g.cardBg;
const footer = connect
? connectFooterState({
status: subscription.status,
subscriptionUrl: subscription.subscription_url,
deviceLimit: subscription.device_limit,
connected: connect.connectedDevices,
})
: { kind: 'hidden' as const };
// Подвал — своя зона нажатия, поэтому карточка снаружи не `<button>`:
// вложенные кнопки невалидны и ведут себя в браузерах непредсказуемо.
return (
<button
onClick={handleClick}
className="w-full rounded-2xl border p-4 text-left transition-all duration-200 hover:scale-[1.01] active:scale-[0.99]"
<div
className="overflow-hidden rounded-2xl border transition-all duration-200 hover:scale-[1.01] active:scale-[0.99]"
style={{ background: bgColor, borderColor }}
>
{/* Header: tariff name + status badge + chevron */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-base font-semibold" style={{ color: g.text }}>
{subscription.tariff_name || t('subscription.defaultName', 'Подписка')}
</span>
<StatusBadge status={subscription.status} isTrial={isTrial} t={t} />
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 opacity-30" />
</div>
{/* Traffic mini progress bar */}
{isActive && (
<div className="mt-3">
<div className="mb-1 flex items-baseline justify-between">
<span className="text-[11px] font-medium" style={{ color: g.textSecondary }}>
{t('subscription.traffic', 'Трафик')}
</span>
<span className="text-[11px] tabular-nums" style={{ color: g.textSecondary }}>
{isUnlimited
? '∞'
: `${trafficUsed.toFixed(1)} / ${trafficLimit} ${t('common.units.gb', 'ГБ')}`}
<button onClick={handleClick} className="w-full p-4 text-left">
{/* Header: tariff name + status badge + chevron */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-base font-semibold" style={{ color: g.text }}>
{subscription.tariff_name || t('subscription.defaultName', 'Подписка')}
</span>
<StatusBadge status={subscription.status} isTrial={isTrial} t={t} />
</div>
{!isUnlimited && (
<div className="h-1.5 overflow-hidden rounded-full" style={{ background: g.innerBg }}>
<div
className={`h-full rounded-full transition-all ${trafficColor}`}
style={{ width: `${Math.max(1, trafficPercent)}%` }}
/>
</div>
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 opacity-30" />
</div>
)}
{/* Stats row */}
<div
className="mt-2.5 flex items-center gap-4 text-[12px]"
style={{ color: g.textSecondary }}
>
<span className="flex items-center gap-1">
<DevicesIcon className="h-3.5 w-3.5 opacity-50" />
{subscription.device_limit}
</span>
<span className="flex items-center gap-1">
<CalendarIcon className="h-3.5 w-3.5 opacity-50" />
{formatDate(subscription.end_date, i18n.language)}
</span>
{!isTrial &&
(() => {
const isDaily = subscription.is_daily;
const enabled = isDaily ? !subscription.is_daily_paused : subscription.autopay_enabled;
const label = isDaily
? t('subscription.dailyAutoCharge', 'Автосписание')
: t('subscription.autopay', 'Автопродление');
return (
<span
className={`flex items-center gap-1 ${enabled ? 'text-success-400/70' : 'text-error-400/50'}`}
>
{enabled ? (
<CheckIcon className="h-3 w-3" />
) : (
<svg
className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{label}
{/* Traffic mini progress bar */}
{isActive && (
<div className="mt-3">
<div className="mb-1 flex items-baseline justify-between">
<span className="text-[11px] font-medium" style={{ color: g.textSecondary }}>
{t('subscription.traffic', 'Трафик')}
</span>
);
})()}
</div>
</button>
<span className="text-[11px] tabular-nums" style={{ color: g.textSecondary }}>
{isUnlimited
? '∞'
: `${trafficUsed.toFixed(1)} / ${trafficLimit} ${t('common.units.gb', 'ГБ')}`}
</span>
</div>
{!isUnlimited && (
<div className="h-1.5 overflow-hidden rounded-full" style={{ background: g.innerBg }}>
<div
className={`h-full rounded-full transition-all ${trafficColor}`}
style={{ width: `${Math.max(1, trafficPercent)}%` }}
/>
</div>
)}
</div>
)}
{/* Stats row */}
<div
className="mt-2.5 flex items-center gap-4 text-[12px]"
style={{ color: g.textSecondary }}
>
{footer.kind === 'hidden' && (
<span className="flex items-center gap-1">
<DevicesIcon className="h-3.5 w-3.5 opacity-50" />
{subscription.device_limit}
</span>
)}
<span className="flex items-center gap-1">
<CalendarIcon className="h-3.5 w-3.5 opacity-50" />
{formatDate(subscription.end_date, i18n.language)}
</span>
{!isTrial &&
(() => {
const isDaily = subscription.is_daily;
const enabled = isDaily
? !subscription.is_daily_paused
: subscription.autopay_enabled;
const label = isDaily
? t('subscription.dailyAutoCharge', 'Автосписание')
: t('subscription.autopay', 'Автопродление');
return (
<span
className={`flex items-center gap-1 ${enabled ? 'text-success-400/70' : 'text-error-400/50'}`}
>
{enabled ? (
<CheckIcon className="h-3 w-3" />
) : (
<svg
className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{label}
</span>
);
})()}
</div>
</button>
<SubscriptionConnectFooter
state={footer}
borderColor={borderColor}
mutedColor={g.textSecondary}
onConnect={() => connect?.onConnect()}
onManage={() => connect?.onManage()}
/>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { connectFooterState } from './connectFooterState';
const base = {
status: 'active',
subscriptionUrl: 'https://example.invalid/sub',
deviceLimit: 5,
connected: 2,
};
describe('connectFooterState', () => {
it('обычная подписка со свободными слотами зовёт подключить', () => {
expect(connectFooterState(base)).toEqual({
kind: 'connect',
used: 2,
limit: 5,
unlimited: false,
highlight: false,
});
});
it('подсвечивает подписку без единого устройства — только там действие и нужно', () => {
const state = connectFooterState({ ...base, connected: 0 });
expect(state).toMatchObject({ kind: 'connect', highlight: true });
});
it('исчерпанный лимит — это не «нельзя», а повод управлять устройствами', () => {
expect(connectFooterState({ ...base, connected: 5 })).toEqual({
kind: 'full',
used: 5,
limit: 5,
});
});
it('устройств больше лимита (лимит понизили) — тоже полный', () => {
expect(connectFooterState({ ...base, connected: 7 })).toMatchObject({ kind: 'full' });
});
it('нулевой лимит означает безлимит, а не запрет', () => {
expect(connectFooterState({ ...base, deviceLimit: 0, connected: 12 })).toEqual({
kind: 'connect',
used: 12,
limit: 0,
unlimited: true,
highlight: false,
});
});
it('пока счётчик не пришёл — загрузка, а не догадка о состоянии', () => {
expect(connectFooterState({ ...base, connected: undefined })).toEqual({ kind: 'loading' });
});
it.each(['expired', 'disabled'])('у подписки со статусом %s подключать нечего', (status) => {
expect(connectFooterState({ ...base, status })).toEqual({ kind: 'hidden' });
});
it.each(['active', 'trial', 'limited'])('статус %s подвал показывает', (status) => {
expect(connectFooterState({ ...base, status }).kind).toBe('connect');
});
it.each([null, undefined, ''])('без ссылки на подписку (%s) подвала нет', (subscriptionUrl) => {
expect(connectFooterState({ ...base, subscriptionUrl })).toEqual({ kind: 'hidden' });
});
it('отсутствие ссылки важнее незагруженного счётчика', () => {
expect(connectFooterState({ ...base, subscriptionUrl: null, connected: undefined })).toEqual({
kind: 'hidden',
});
});
});

View File

@@ -0,0 +1,61 @@
/**
* Состояние подвала карточки подписки — «подключить устройство».
*
* Вынесено из компонента отдельно, потому что правил тут больше, чем кажется:
* безлимит и исчерпанный лимит выражаются одним и тем же полем `device_limit`,
* а незагруженный счётчик нельзя путать с нулём подключённых устройств.
*/
export type ConnectFooterState =
/** Подключать нечего: подписка истекла или у неё нет ссылки. */
| { kind: 'hidden' }
/** Счётчик устройств ещё не пришёл — показываем скелетон, а не догадку. */
| { kind: 'loading' }
/** Есть куда подключаться. `highlight` — ни одного устройства, зовём заметнее. */
| { kind: 'connect'; used: number; limit: number; unlimited: boolean; highlight: boolean }
/** Слоты кончились — ведём разбираться, а не блокируем. */
| { kind: 'full'; used: number; limit: number };
/** Статусы, при которых доступ ещё работает и устройство есть смысл подключать. */
const CONNECTABLE_STATUSES = new Set(['active', 'trial', 'limited']);
export interface ConnectFooterInput {
status: string;
/** Ссылка на подписку из панели: без неё подключать не к чему. */
subscriptionUrl: string | null | undefined;
/** 0 означает «без лимита устройств», а не «нельзя ни одного». */
deviceLimit: number;
/** `undefined`, пока запрос числа устройств не завершился. */
connected: number | undefined;
}
export function connectFooterState({
status,
subscriptionUrl,
deviceLimit,
connected,
}: ConnectFooterInput): ConnectFooterState {
if (!subscriptionUrl || !CONNECTABLE_STATUSES.has(status)) {
return { kind: 'hidden' };
}
if (connected === undefined) {
return { kind: 'loading' };
}
const unlimited = deviceLimit === 0;
// `>=`, а не `===`: лимит можно понизить ниже числа уже подключённых
// устройств, и тогда слотов «минус один» — состояние всё равно полное.
if (!unlimited && connected >= deviceLimit) {
return { kind: 'full', used: connected, limit: deviceLimit };
}
return {
kind: 'connect',
used: connected,
limit: deviceLimit,
unlimited,
highlight: connected === 0,
};
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { XCloseIcon } from '@/components/icons';
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { Sheet } from './Sheet';
/** Ширина, с которой нижний шит перестаёт быть уместным. */
const DESKTOP_QUERY = '(min-width: 640px)';
function useIsDesktop(): boolean {
const [isDesktop, setIsDesktop] = useState(
() => typeof window !== 'undefined' && window.matchMedia(DESKTOP_QUERY).matches,
);
useEffect(() => {
const query = window.matchMedia(DESKTOP_QUERY);
const onChange = (event: MediaQueryListEvent) => setIsDesktop(event.matches);
query.addEventListener('change', onChange);
setIsDesktop(query.matches);
return () => query.removeEventListener('change', onChange);
}, []);
return isDesktop;
}
interface ResponsiveSheetProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
/**
* Нижний шит на телефоне, обычное окно на десктопе.
*
* Шит — мобильная идиома: ручка перетаскивания мышью бессмысленна, а
* прижатая к нижнему краю плашка на широком экране оставляет центр пустым
* и обрезает нижние скругления. На десктопе то же содержимое показывается
* центрированным окном с ловушкой фокуса и закрытием по Escape.
*/
export function ResponsiveSheet({ isOpen, onClose, title, children }: ResponsiveSheetProps) {
const { t } = useTranslation();
const isDesktop = useIsDesktop();
// Ловушка нужна только своей ветке: у Sheet она уже своя.
const dialogRef = useFocusTrap<HTMLDivElement>(isOpen && isDesktop, { onEscape: onClose });
if (!isOpen) return null;
if (!isDesktop) {
return (
<Sheet isOpen onClose={onClose} title={title}>
{children}
</Sheet>
);
}
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-dark-950/70 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className="relative flex max-h-[calc(100dvh-2rem)] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-dark-700 bg-dark-900 shadow-2xl"
>
<div className="flex items-center justify-between gap-3 px-5 pb-3 pt-5">
<h3 className="text-lg font-semibold text-dark-50">{title}</h3>
<button
type="button"
onClick={onClose}
aria-label={t('common.close', 'Закрыть')}
className="-mr-1.5 flex min-h-[44px] min-w-[44px] shrink-0 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
>
<XCloseIcon />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
</div>
</div>,
document.body,
);
}

View File

@@ -739,6 +739,16 @@
"confirmDelete": "Delete subscription?",
"dailyAutoCharge": "Daily auto-charge",
"defaultName": "Subscription",
"connectFooter": {
"connect": "Connect a device",
"full": "All slots are in use",
"connectedDevices": "Connected devices",
"disconnect": "Disconnect",
"addSlots": "Add slots",
"limitExplained": "The «{{name}}» plan gives {{count}} slots and all are in use. Free one up or add more.",
"limitExplainedNoTopup": "The «{{name}}» plan gives {{count}} slots and all are in use. Free up a slot to continue.",
"noTopupHint": "Slots cannot be bought on a trial subscription — free one up or switch to a paid plan."
},
"delete": "Delete",
"deleteTitle": "Delete subscription",
"get_config": "Get config",

View File

@@ -644,6 +644,16 @@
},
"dailyAutoCharge": "برداشت خودکار روزانه",
"defaultName": "اشتراک",
"connectFooter": {
"connect": "اتصال دستگاه",
"full": "همه ظرفیت‌ها پر است",
"connectedDevices": "دستگاه‌های متصل",
"disconnect": "قطع اتصال",
"addSlots": "افزودن ظرفیت",
"limitExplained": "طرح «{{name}}» {{count}} ظرفیت دارد و همه پر است. یکی را آزاد کنید یا ظرفیت اضافه کنید.",
"limitExplainedNoTopup": "طرح «{{name}}» {{count}} ظرفیت دارد و همه پر است. یک ظرفیت را آزاد کنید.",
"noTopupHint": "روی اشتراک آزمایشی نمی‌توان ظرفیت خرید — یکی را آزاد کنید یا طرح پولی بگیرید."
},
"delete": "حذف",
"deleteTitle": "حذف اشتراک",
"get_config": "دریافت پیکربندی",

View File

@@ -758,6 +758,16 @@
"confirmDelete": "Удалить подписку?",
"dailyAutoCharge": "Ежедневное списание",
"defaultName": "Подписка",
"connectFooter": {
"connect": "Подключить устройство",
"full": "Все слоты заняты",
"connectedDevices": "Подключённые устройства",
"disconnect": "Отключить",
"addSlots": "Добавить слоты",
"limitExplained": "Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите слот или добавьте ещё.",
"limitExplainedNoTopup": "Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите занятый слот.",
"noTopupHint": "На тестовой подписке слоты докупить нельзя — освободите занятый или оформите платный тариф."
},
"delete": "Удалить",
"deleteTitle": "Удаление подписки",
"get_config": "Получить конфиг",

View File

@@ -644,6 +644,16 @@
},
"dailyAutoCharge": "每日自动扣费",
"defaultName": "订阅",
"connectFooter": {
"connect": "连接设备",
"full": "所有名额已占满",
"connectedDevices": "已连接设备",
"disconnect": "断开",
"addSlots": "增加名额",
"limitExplained": "「{{name}}」套餐提供 {{count}} 个名额,且已全部占用。请释放一个名额或增加名额。",
"limitExplainedNoTopup": "「{{name}}」套餐提供 {{count}} 个名额,且已全部占用。请先释放一个名额。",
"noTopupHint": "试用订阅无法购买名额 — 请释放已占用的名额或改用付费套餐。"
},
"delete": "删除",
"deleteTitle": "删除订阅",
"get_config": "获取配置",

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQueries, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
@@ -14,13 +14,13 @@ import PromoOffersSection from '../components/PromoOffersSection';
import NewsSection from '../components/news/NewsSection';
import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActive';
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
import ConnectDeviceTile from '../components/dashboard/ConnectDeviceTile';
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
import StatsGrid from '../components/dashboard/StatsGrid';
import { giftApi } from '../api/gift';
import { promoApi } from '../api/promo';
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
import { DeviceLimitSheet } from '../components/subscription/DeviceLimitSheet';
import { API } from '../config/constants';
import { ChevronRightIcon, StarIcon } from '@/components/icons';
@@ -86,18 +86,29 @@ export default function Dashboard() {
// показывал бы «0 из N», а лимит устройств не срабатывал бы никогда — то
// есть ровно то, ради чего плитку и добавили, не работало бы.
// Ключ ['devices', id] — тот же, что на странице подписки, так что кэш общий.
const homeSingleSub =
isMultiTariff && multiSubData?.subscriptions?.length === 1
? multiSubData.subscriptions[0]
: null;
// Карточки подписок на главной показывают, сколько устройств подключено, и
// дают подключить ещё. Число устройств живёт в панели, поэтому запрос идёт
// на каждую показанную подписку; ключ ['devices', id] тот же, что на
// странице подписки, так что кэш общий и переход туда не стоит сети.
const visibleSubscriptions = useMemo(
() => multiSubData?.subscriptions?.slice(0, 3) ?? [],
[multiSubData],
);
const { data: homeSingleSubDevices } = useQuery({
queryKey: ['devices', homeSingleSub?.id],
queryFn: () => subscriptionApi.getDevices(homeSingleSub?.id),
enabled: !!homeSingleSub,
staleTime: API.BALANCE_STALE_TIME_MS,
const deviceQueries = useQueries({
queries: visibleSubscriptions.map((sub) => ({
queryKey: ['devices', sub.id],
queryFn: () => subscriptionApi.getDevices(sub.id),
staleTime: API.BALANCE_STALE_TIME_MS,
})),
});
// Подписка, у которой разбираем исчерпанный лимит устройств.
const [deviceLimitSubId, setDeviceLimitSubId] = useState<number | null>(null);
const deviceLimitSub = visibleSubscriptions.find((s) => s.id === deviceLimitSubId) ?? null;
const deviceLimitDevices =
deviceQueries[visibleSubscriptions.findIndex((s) => s.id === deviceLimitSubId)]?.data;
const { data: referralInfo, isLoading: refLoading } = useQuery({
queryKey: ['referral-info'],
queryFn: referralApi.getReferralInfo,
@@ -313,33 +324,18 @@ export default function Dashboard() {
{t('dashboard.manageAll', 'Управление')}
</Link>
</div>
{multiSubData.subscriptions.slice(0, 3).map((sub) => (
{visibleSubscriptions.map((sub, index) => (
<SubscriptionListCard
key={sub.id}
subscription={sub}
onClick={() => navigate(`/subscriptions/${sub.id}`)}
connect={{
connectedDevices: deviceQueries[index]?.data?.total,
onConnect: () => navigate(`/connection?sub=${sub.id}`),
onManage: () => setDeviceLimitSubId(sub.id),
}}
/>
))}
{/* Подписку мог выдать бонус рекламной кампании — она создаётся сама,
и человек попадает на главную с готовым доступом. Пока подписка
одна, показываем здесь же, как подключить устройство: иначе за
этим нужно уходить на отдельную страницу, о чём он не догадается. */}
{homeSingleSub && (
<ConnectDeviceTile
subscription={homeSingleSub}
connectedDevices={homeSingleSubDevices?.total ?? 0}
usedPercent={
homeSingleSub.traffic_limit_gb > 0
? Math.min(
100,
Math.round(
(homeSingleSub.traffic_used_gb / homeSingleSub.traffic_limit_gb) * 100,
),
)
: 0
}
/>
)}
{multiSubData.subscriptions.length > 3 && (
<Link
to="/subscriptions"
@@ -465,6 +461,22 @@ export default function Dashboard() {
onSkip={handleOnboardingComplete}
/>
)}
{deviceLimitSub && (
<DeviceLimitSheet
isOpen
onClose={() => setDeviceLimitSubId(null)}
subscriptionId={deviceLimitSub.id}
subscriptionName={deviceLimitSub.tariff_name || t('subscription.defaultName', 'Подписка')}
deviceLimit={deviceLimitSub.device_limit}
isTrial={deviceLimitSub.is_trial}
devices={deviceLimitDevices?.devices ?? []}
onOpenSubscription={() => {
setDeviceLimitSubId(null);
navigate(`/subscriptions/${deviceLimitSub.id}`);
}}
/>
)}
</div>
);
}