mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
432 lines
17 KiB
TypeScript
432 lines
17 KiB
TypeScript
import { useState, useEffect, useMemo, useRef } from 'react';
|
||
import { 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 { 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,
|
||
});
|
||
|
||
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>
|
||
{multiSubData.subscriptions.slice(0, 3).map((sub) => (
|
||
<SubscriptionListCard
|
||
key={sub.id}
|
||
subscription={sub}
|
||
onClick={() => navigate(`/subscriptions/${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}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|