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

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