mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
- Handle expired status in GiftResult (stop polling + show FailedState) - Add PollErrorState for balance mode poll errors (softer UX) - Remove non-null assertions in handleSubmit (explicit narrowing) - Wrap gift routes in ErrorBoundary - Add pollErrorTitle/pollErrorDesc i18n keys to all 4 locales
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Link } from 'react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useAuthStore } from '../store/auth';
|
|
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 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 PendingGiftCard from '../components/dashboard/PendingGiftCard';
|
|
import { API } from '../config/constants';
|
|
|
|
const ChevronRightIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
</svg>
|
|
);
|
|
|
|
export default function Dashboard() {
|
|
const { t } = useTranslation();
|
|
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',
|
|
});
|
|
|
|
const { data: subscriptionResponse, isLoading: subLoading } = useQuery({
|
|
queryKey: ['subscription'],
|
|
queryFn: subscriptionApi.getSubscription,
|
|
retry: false,
|
|
staleTime: API.BALANCE_STALE_TIME_MS,
|
|
refetchOnMount: 'always',
|
|
});
|
|
|
|
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,
|
|
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 activateTrialMutation = useMutation({
|
|
mutationFn: subscriptionApi.activateTrial,
|
|
onSuccess: () => {
|
|
setTrialError(null);
|
|
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
|
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,
|
|
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', Date.now().toString());
|
|
if (data.rate_limited && data.retry_after_seconds) {
|
|
setTrafficRefreshCooldown(data.retry_after_seconds);
|
|
} else {
|
|
setTrafficRefreshCooldown(30);
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
|
},
|
|
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');
|
|
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]);
|
|
|
|
const hasNoSubscription = subscriptionResponse?.has_subscription === false && !subLoading;
|
|
|
|
// 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 = () => {
|
|
setShowOnboarding(false);
|
|
completeOnboarding();
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div data-onboarding="welcome">
|
|
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
|
|
{t('dashboard.welcome', { name: user?.first_name || user?.username || '' })}
|
|
</h1>
|
|
<p className="mt-1 text-dark-400">{t('dashboard.yourSubscription')}</p>
|
|
</div>
|
|
|
|
{/* Pending Gift Activations */}
|
|
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
|
|
|
|
{/* Subscription Status Card */}
|
|
{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' ? (
|
|
<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}
|
|
|
|
{/* Trial Activation */}
|
|
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
|
|
<TrialOfferCard
|
|
trialInfo={trialInfo}
|
|
balanceKopeks={balanceData?.balance_kopeks || 0}
|
|
balanceRubles={balanceData?.balance_rubles || 0}
|
|
activateTrialMutation={activateTrialMutation}
|
|
trialError={trialError}
|
|
/>
|
|
)}
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* Onboarding Tutorial */}
|
|
{showOnboarding && (
|
|
<Onboarding
|
|
steps={onboardingSteps}
|
|
onComplete={handleOnboardingComplete}
|
|
onSkip={handleOnboardingComplete}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|