Files
bedolaga-cabinet/src/pages/Dashboard.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

483 lines
20 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, useEffect, useMemo, useRef } from 'react';
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';
import { displayName } from '../utils/displayName';
import { useBlockingStore } from '../store/blocking';
import { subscriptionApi } from '../api/subscription';
import { referralApi } from '../api/referral';
import { balanceApi } from '../api/balance';
import { wheelApi } from '../api/wheel';
import Onboarding, { useOnboarding } from '../components/Onboarding';
import PromoOffersSection from '../components/PromoOffersSection';
import NewsSection from '../components/news/NewsSection';
import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActive';
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
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';
export default function Dashboard() {
const { t } = useTranslation();
const navigate = useNavigate();
const user = useAuthStore((state) => state.user);
const refreshUser = useAuthStore((state) => state.refreshUser);
const queryClient = useQueryClient();
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
const [showOnboarding, setShowOnboarding] = useState(false);
const blockingType = useBlockingStore((state) => state.blockingType);
const [trialError, setTrialError] = useState<string | null>(null);
// Refresh user data on mount
useEffect(() => {
refreshUser();
}, [refreshUser]);
// Fetch balance from API
const { data: balanceData } = useQuery({
queryKey: ['balance'],
queryFn: balanceApi.getBalance,
staleTime: API.BALANCE_STALE_TIME_MS,
refetchOnMount: 'always',
});
// Multi-tariff: check if user has multiple subscriptions
const { data: multiSubData } = useQuery({
queryKey: ['subscriptions-list'],
queryFn: () => subscriptionApi.getSubscriptions(),
staleTime: 60_000,
});
const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false;
const { data: subscriptionResponse, isLoading: subLoading } = useQuery({
queryKey: ['subscription'],
queryFn: () => subscriptionApi.getSubscription(),
retry: false,
staleTime: API.BALANCE_STALE_TIME_MS,
refetchOnMount: 'always',
enabled: !isMultiTariff,
});
const subscription = subscriptionResponse?.subscription ?? null;
const { data: trialInfo, isLoading: trialLoading } = useQuery({
queryKey: ['trial-info'],
queryFn: () => subscriptionApi.getTrialInfo(),
enabled: !subscription && !subLoading,
});
const { data: devicesData } = useQuery({
queryKey: ['devices'],
queryFn: () => subscriptionApi.getDevices(),
enabled: !!subscription && !isMultiTariff,
staleTime: API.BALANCE_STALE_TIME_MS,
});
// Плитка «Подключить устройство» на главной живёт в МУЛЬТИТАРИФНОЙ ветке, а
// запрос выше там выключен: он привязан к одиночной подписке (`subscription`
// в мультитарифе всегда null). Без отдельного запроса счётчик плитки всегда
// показывал бы «0 из N», а лимит устройств не срабатывал бы никогда — то
// есть ровно то, ради чего плитку и добавили, не работало бы.
// Ключ ['devices', id] — тот же, что на странице подписки, так что кэш общий.
// Карточки подписок на главной показывают, сколько устройств подключено, и
// дают подключить ещё. Число устройств живёт в панели, поэтому запрос идёт
// на каждую показанную подписку; ключ ['devices', id] тот же, что на
// странице подписки, так что кэш общий и переход туда не стоит сети.
const visibleSubscriptions = useMemo(
() => multiSubData?.subscriptions?.slice(0, 3) ?? [],
[multiSubData],
);
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,
});
const { data: wheelConfig } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
staleTime: 60000,
retry: false,
});
const { data: pendingGifts } = useQuery({
queryKey: ['pending-gifts'],
queryFn: giftApi.getPendingGifts,
staleTime: 30_000,
retry: false,
});
const { data: promoGroupData } = useQuery({
queryKey: ['promo-group-discounts'],
queryFn: promoApi.getGroupDiscounts,
staleTime: 60_000,
retry: false,
});
const activateTrialMutation = useMutation({
mutationFn: () => subscriptionApi.activateTrial(),
onSuccess: () => {
setTrialError(null);
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
},
onError: (error: { response?: { data?: { detail?: string } } }) => {
setTrialError(error.response?.data?.detail || t('common.error'));
},
});
// Traffic refresh state and mutation
const [trafficRefreshCooldown, setTrafficRefreshCooldown] = useState(0);
const [trafficData, setTrafficData] = useState<{
traffic_used_gb: number;
traffic_used_percent: number;
is_unlimited: boolean;
} | null>(null);
const refreshTrafficMutation = useMutation({
mutationFn: () => subscriptionApi.refreshTraffic(subscription?.id),
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_${subscription?.id ?? 'default'}`,
Date.now().toString(),
);
if (data.rate_limited && data.retry_after_seconds) {
setTrafficRefreshCooldown(data.retry_after_seconds);
} else {
setTrafficRefreshCooldown(30);
}
queryClient.invalidateQueries({ queryKey: ['subscription', subscription?.id] });
},
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);
}
},
});
// Cooldown timer
useEffect(() => {
if (trafficRefreshCooldown <= 0) return;
const timer = setInterval(() => {
setTrafficRefreshCooldown((prev) => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [trafficRefreshCooldown]);
// Auto-refresh traffic on mount (with 30s caching)
const hasAutoRefreshed = useRef(false);
useEffect(() => {
if (!subscription) return;
if (hasAutoRefreshed.current) return;
hasAutoRefreshed.current = true;
const lastRefresh = localStorage.getItem(`traffic_refresh_ts_${subscription?.id ?? 'default'}`);
const now = Date.now();
const cacheMs = API.TRAFFIC_CACHE_MS;
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]);
// В multi-tariff /cabinet/subscription отключён, поэтому subscriptionResponse=undefined.
// Используем список из /cabinet/subscriptions/list — пустой массив означает «нет подписок»,
// и тогда показываем TrialOfferCard. Без этой ветки multi-tariff юзер никогда не видел триал.
const hasNoSubscription = isMultiTariff
? multiSubData !== undefined && (multiSubData.subscriptions?.length ?? 0) === 0
: subscriptionResponse?.has_subscription === false && !subLoading;
// Есть ли НАСТОЯЩАЯ (платная, не триал) живая подписка — от этого зависит CTA:
// «+ Купить ещё» только при наличии платной; иначе явная «Посмотреть тарифы».
const hasActivePaid = (multiSubData?.subscriptions ?? []).some(
(s) => !s.is_trial && (s.status === 'active' || s.status === 'limited'),
);
// Show onboarding for new users after data loads
useEffect(() => {
if (!isOnboardingCompleted && !subLoading && !refLoading && !blockingType) {
const timer = setTimeout(() => setShowOnboarding(true), 500);
return () => clearTimeout(timer);
}
}, [isOnboardingCompleted, subLoading, refLoading, blockingType]);
const onboardingSteps = useMemo(() => {
type Placement = 'top' | 'bottom' | 'left' | 'right';
const steps: Array<{
target: string;
title: string;
description: string;
placement: Placement;
}> = [
{
target: 'welcome',
title: t('onboarding.steps.welcome.title'),
description: t('onboarding.steps.welcome.description'),
placement: 'bottom',
},
{
target: 'balance',
title: t('onboarding.steps.balance.title'),
description: t('onboarding.steps.balance.description'),
placement: 'bottom',
},
];
if (subscription?.subscription_url) {
steps.splice(1, 0, {
target: 'connect-devices',
title: t('onboarding.steps.connectDevices.title'),
description: t('onboarding.steps.connectDevices.description'),
placement: 'bottom',
});
}
return steps;
}, [t, subscription]);
const handleOnboardingComplete = () => {
completeOnboarding();
setShowOnboarding(false);
};
const userName = displayName(user);
return (
<div className="space-y-6">
{/* Header */}
<div data-onboarding="welcome">
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
{userName ? t('dashboard.welcome', { name: userName }) : t('dashboard.welcomeNoName')}
</h1>
<div className="mt-1 flex flex-wrap items-center gap-2">
<p className="text-dark-400">{t('dashboard.yourSubscription')}</p>
{promoGroupData?.group_name && (
<span
className="inline-flex max-w-[160px] items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-semibold"
style={{
background: 'rgba(var(--color-accent-400), 0.1)',
border: '1px solid rgba(var(--color-accent-400), 0.2)',
color: 'rgb(var(--color-accent-400))',
}}
>
<StarIcon filled className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">{promoGroupData.group_name}</span>
</span>
)}
</div>
</div>
{/* Pending Gift Activations */}
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
{/* Multi-tariff: show subscription cards (max 3) — только когда подписки
реально есть. Пустой случай (нет подписок) ведёт блок ниже (триал/покупка),
иначе кнопка покупки дублировалась. */}
{isMultiTariff && multiSubData?.subscriptions && multiSubData.subscriptions.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<span className="text-sm font-medium opacity-60">
{t('dashboard.subscriptions', 'Подписки')}
</span>
<Link to="/subscriptions" className="text-xs text-accent-400 hover:underline">
{t('dashboard.manageAll', 'Управление')}
</Link>
</div>
{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),
}}
/>
))}
{multiSubData.subscriptions.length > 3 && (
<Link
to="/subscriptions"
className="flex w-full items-center justify-center rounded-2xl border border-dashed border-white/15 p-3 text-xs opacity-50 transition-opacity hover:opacity-80"
>
{t('dashboard.showAll', 'Показать все')} ({multiSubData.subscriptions.length})
</Link>
)}
{hasActivePaid ? (
<Link
to="/subscription/purchase"
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-500/15 p-3.5 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/25"
>
<span className="text-base">+</span>{' '}
{t('subscriptions.buyAnother', 'Купить ещё тариф')}
</Link>
) : (
<Link
to="/subscription/purchase"
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-500 p-3.5 text-sm font-semibold text-on-accent transition-colors hover:bg-accent-600"
>
<span className="text-base">+</span>{' '}
{t('subscriptions.browsePlans', 'Посмотреть тарифы и купить подписку')}
</Link>
)}
</div>
)}
{/* Subscription Status Card — hidden in multi-tariff (managed via /subscriptions) */}
{!isMultiTariff &&
(subLoading ? (
<div className="bento-card">
<div className="mb-4 flex items-center justify-between">
<div className="skeleton h-5 w-20" />
<div className="skeleton h-6 w-16 rounded-full" />
</div>
<div className="skeleton mb-3 h-10 w-32" />
<div className="skeleton mb-3 h-4 w-40" />
<div className="skeleton h-3 w-full rounded-full" />
<div className="mt-5">
<div className="skeleton h-12 w-full rounded-xl" />
</div>
</div>
) : subscription?.is_expired ||
subscription?.status === 'disabled' ||
subscription?.is_limited ? (
<SubscriptionCardExpired
subscription={subscription}
balanceKopeks={balanceData?.balance_kopeks ?? 0}
balanceRubles={balanceData?.balance_rubles ?? 0}
/>
) : subscription ? (
<SubscriptionCardActive
subscription={subscription}
trafficData={trafficData}
refreshTrafficMutation={refreshTrafficMutation}
trafficRefreshCooldown={trafficRefreshCooldown}
connectedDevices={devicesData?.total ?? 0}
/>
) : null)}
{/* Нет подписок: показываем триал (если доступен) и ВСЕГДА одну явную
кнопку покупки. Триал не обязателен, чтобы попасть в витрину — раньше
при доступном триале это был единственный экран без кнопки покупки
(Telegram-баг #605056/#605063). Единственная кнопка тут (вместо дубля
с мульти-тариф блоком). */}
{hasNoSubscription && !trialLoading && (
<div className="space-y-3">
{trialInfo?.is_available && (
<TrialOfferCard
trialInfo={trialInfo}
balanceKopeks={balanceData?.balance_kopeks || 0}
balanceRubles={balanceData?.balance_rubles || 0}
activateTrialMutation={activateTrialMutation}
trialError={trialError}
/>
)}
<Link
to="/subscription/purchase"
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-500 p-3.5 text-sm font-semibold text-on-accent transition-colors hover:bg-accent-600"
>
<span className="text-base">+</span>{' '}
{t('subscriptions.browsePlans', 'Посмотреть тарифы и купить подписку')}
</Link>
</div>
)}
{/* Promo Offers */}
<PromoOffersSection />
{/* Stats Grid */}
<StatsGrid
balanceRubles={balanceData?.balance_rubles || 0}
referralCount={referralInfo?.total_referrals || 0}
earningsRubles={referralInfo?.available_balance_rubles || 0}
refLoading={refLoading}
/>
{/* Fortune Wheel Banner */}
{wheelConfig?.is_enabled && (
<Link to="/wheel" className="bento-card-hover group flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-3xl">🎰</span>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-dark-100">{t('wheel.banner.title')}</h3>
<p className="text-sm text-dark-400">{t('wheel.banner.description')}</p>
</div>
</div>
<div className="flex-shrink-0 text-dark-500 transition-all duration-300 group-hover:translate-x-1 group-hover:text-accent-400">
<ChevronRightIcon />
</div>
</Link>
)}
{/* News Section */}
<NewsSection />
{/* Onboarding Tutorial */}
{showOnboarding && (
<Onboarding
steps={onboardingSteps}
onComplete={handleOnboardingComplete}
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>
);
}