Files
bedolaga-cabinet/src/components/subscription/DeviceLimitSheet.tsx
c0mrade 28e25a9292 feat(dashboard): подключение устройства прикреплено к карточке подписки
На главной подключение жило отдельной плиткой под карточкой, и показывалось
только когда подписка одна: при нескольких «выбор устройства неоднозначен».
Получалось три плитки-ровни подряд — подписка, действие над этой подпиской и
покупка новой, — из которых вторая ровней не была. При нескольких подписках
подключение с главной пропадало вовсе.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:55:27 +03:00

140 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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