mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
Onboarding tutorial was appearing on top of the channel subscription screen because Dashboard still mounts behind BlockingOverlay. Added blockingType check to prevent onboarding from triggering.
650 lines
26 KiB
TypeScript
650 lines
26 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 { 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 { useCurrency } from '../hooks/useCurrency';
|
|
import { API } from '../config/constants';
|
|
|
|
// Icons
|
|
const ArrowRightIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
|
</svg>
|
|
);
|
|
|
|
const SparklesIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
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>
|
|
);
|
|
|
|
const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
export default function Dashboard() {
|
|
const { t } = useTranslation();
|
|
const { user, refreshUser } = useAuthStore();
|
|
const queryClient = useQueryClient();
|
|
const navigate = useNavigate();
|
|
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
|
|
const [trialError, setTrialError] = useState<string | null>(null);
|
|
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
|
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
|
const { blockingType } = useBlockingStore();
|
|
|
|
// 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',
|
|
});
|
|
|
|
// Extract subscription from response (null if no subscription)
|
|
const subscription = subscriptionResponse?.subscription ?? null;
|
|
|
|
const { data: trialInfo, isLoading: trialLoading } = useQuery({
|
|
queryKey: ['trial-info'],
|
|
queryFn: subscriptionApi.getTrialInfo,
|
|
enabled: !subscription && !subLoading,
|
|
});
|
|
|
|
const { data: referralInfo, isLoading: refLoading } = useQuery({
|
|
queryKey: ['referral-info'],
|
|
queryFn: referralApi.getReferralInfo,
|
|
});
|
|
|
|
// Fetch wheel config to show banner if enabled
|
|
const { data: wheelConfig } = useQuery({
|
|
queryKey: ['wheel-config'],
|
|
queryFn: wheelApi.getConfig,
|
|
staleTime: 60000, // 1 minute
|
|
retry: false,
|
|
});
|
|
|
|
const activateTrialMutation = useMutation({
|
|
mutationFn: subscriptionApi.activateTrial,
|
|
onSuccess: () => {
|
|
setTrialError(null);
|
|
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
|
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
|
|
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
|
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,
|
|
});
|
|
// Save last refresh timestamp to localStorage
|
|
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]);
|
|
|
|
// Track if we've already triggered auto-refresh this session
|
|
const hasAutoRefreshed = useRef(false);
|
|
|
|
// Auto-refresh traffic on mount (with 30s caching)
|
|
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]);
|
|
|
|
// User has no subscription if API returns has_subscription: false
|
|
const hasNoSubscription = subscriptionResponse?.has_subscription === false && !subLoading;
|
|
|
|
// Show onboarding for new users after data loads
|
|
// Skip if a blocking screen is active (channel subscription, maintenance, etc.)
|
|
useEffect(() => {
|
|
if (!isOnboardingCompleted && !subLoading && !refLoading && !blockingType) {
|
|
// Small delay to ensure DOM is ready
|
|
const timer = setTimeout(() => setShowOnboarding(true), 500);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [isOnboardingCompleted, subLoading, refLoading, blockingType]);
|
|
|
|
// Define onboarding steps based on available data
|
|
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',
|
|
},
|
|
{
|
|
target: 'subscription-status',
|
|
title: t('onboarding.steps.subscription.title'),
|
|
description: t('onboarding.steps.subscription.description'),
|
|
placement: 'bottom',
|
|
},
|
|
];
|
|
|
|
// Add connect devices step only if subscription exists
|
|
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();
|
|
};
|
|
|
|
// Calculate traffic percentage color
|
|
const getTrafficColor = (percent: number) => {
|
|
if (percent > API.TRAFFIC_CRITICAL_PERCENT) return 'bg-error-500';
|
|
if (percent > API.TRAFFIC_WARN_PERCENT) return 'bg-warning-500';
|
|
return 'bg-success-500';
|
|
};
|
|
|
|
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>
|
|
|
|
{/* Subscription Status - Main Card */}
|
|
{subLoading ? (
|
|
<div className="bento-card">
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div className="skeleton h-6 w-24" />
|
|
<div className="skeleton h-6 w-16 rounded-full" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i}>
|
|
<div className="skeleton mb-2 h-4 w-20" />
|
|
<div className="skeleton h-5 w-24" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-6">
|
|
<div className="skeleton h-2 w-full rounded-full" />
|
|
</div>
|
|
<div className="mt-6 grid grid-cols-2 gap-3">
|
|
<div className="skeleton h-10 w-full rounded-xl" />
|
|
<div className="skeleton h-10 w-full rounded-xl" />
|
|
</div>
|
|
</div>
|
|
) : subscription ? (
|
|
<div
|
|
className={`bento-card ${subscription.is_trial ? 'border-warning-500/30 bg-gradient-to-br from-warning-500/5 to-transparent' : ''}`}
|
|
>
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.status')}</h2>
|
|
{subscription.tariff_name && (
|
|
<div className="mt-1 text-sm text-accent-400">{subscription.tariff_name}</div>
|
|
)}
|
|
</div>
|
|
<span
|
|
className={
|
|
subscription.is_trial
|
|
? 'badge-warning'
|
|
: subscription.is_active
|
|
? 'badge-success'
|
|
: 'badge-error'
|
|
}
|
|
>
|
|
{subscription.is_trial
|
|
? t('subscription.trialStatus')
|
|
: subscription.is_active
|
|
? t('subscription.active')
|
|
: t('subscription.expired')}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Trial Info Banner */}
|
|
{subscription.is_trial && subscription.is_active && (
|
|
<div className="mb-6 rounded-xl border border-warning-500/30 bg-warning-500/10 p-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-warning-500/20 text-xl">
|
|
<SparklesIcon />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="font-medium text-warning-300">
|
|
{t('subscription.trialBanner.title')}
|
|
</div>
|
|
<div className="mt-1 text-sm text-dark-400">
|
|
{t('subscription.trialBanner.description', { days: subscription.days_left })}
|
|
</div>
|
|
<Link
|
|
to="/subscription"
|
|
state={{ scrollToExtend: true }}
|
|
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-warning-400 transition-colors hover:text-warning-300"
|
|
>
|
|
{t('subscription.trialBanner.upgrade')}
|
|
<ArrowRightIcon />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
|
<div>
|
|
<div className="mb-1 text-sm text-dark-500">{t('subscription.expiresAt')}</div>
|
|
<div className="font-medium text-dark-100">
|
|
{new Date(subscription.end_date).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="mb-1 flex items-center gap-2">
|
|
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
|
|
<button
|
|
onClick={() => refreshTrafficMutation.mutate()}
|
|
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
|
className="rounded-full p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
|
|
title={
|
|
trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')
|
|
}
|
|
>
|
|
<RefreshIcon
|
|
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
<div className="font-medium text-dark-100">
|
|
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} /{' '}
|
|
{subscription.traffic_limit_gb || '∞'} GB
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
|
|
<div className="font-medium text-dark-100">{subscription.device_limit}</div>
|
|
</div>
|
|
<div>
|
|
<div className="mb-1 text-sm text-dark-500">{t('subscription.timeLeft')}</div>
|
|
<div className="font-medium text-dark-100">
|
|
{subscription.days_left > 0
|
|
? t('subscription.days', { count: subscription.days_left })
|
|
: `${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Traffic Progress */}
|
|
{subscription.traffic_limit_gb > 0 && (
|
|
<div className="mt-6">
|
|
<div className="mb-2 flex justify-between text-sm">
|
|
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
|
|
<span className="text-dark-300">
|
|
{(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent).toFixed(
|
|
1,
|
|
)}
|
|
%
|
|
</span>
|
|
</div>
|
|
<div className="progress-bar">
|
|
<div
|
|
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
|
|
style={{
|
|
width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={`mt-6 grid gap-3 ${subscription.subscription_url ? 'grid-cols-2' : 'grid-cols-1'}`}
|
|
>
|
|
<Link to="/subscription" className="btn-primary py-2.5 text-center text-sm">
|
|
{t('dashboard.viewSubscription')}
|
|
</Link>
|
|
{subscription.subscription_url && (
|
|
<button
|
|
onClick={() => navigate('/connection')}
|
|
className="btn-secondary py-2.5 text-sm"
|
|
data-onboarding="connect-devices"
|
|
>
|
|
{t('subscription.getConfig')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Promo Offers - показываем сразу после карточки подписки */}
|
|
<PromoOffersSection />
|
|
|
|
{/* Stats Grid */}
|
|
<div className="bento-grid">
|
|
{/* Balance */}
|
|
<Link to="/balance" className="bento-card-hover group" data-onboarding="balance">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<span className="text-sm text-dark-400">{t('balance.currentBalance')}</span>
|
|
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
|
<ArrowRightIcon />
|
|
</span>
|
|
</div>
|
|
<div className="stat-value text-accent-400">
|
|
{formatAmount(balanceData?.balance_rubles || 0)}
|
|
<span className="ml-1 text-lg text-dark-400">{currencySymbol}</span>
|
|
</div>
|
|
</Link>
|
|
|
|
{/* Subscription */}
|
|
<Link
|
|
to="/subscription"
|
|
className="bento-card-hover group"
|
|
data-onboarding="subscription-status"
|
|
>
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<span className="text-sm text-dark-400">{t('subscription.title')}</span>
|
|
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
|
<ArrowRightIcon />
|
|
</span>
|
|
</div>
|
|
{subLoading ? (
|
|
<div className="skeleton h-8 w-24" />
|
|
) : subscription ? (
|
|
<div className="stat-value">
|
|
{subscription.days_left > 0 ? (
|
|
<>
|
|
{subscription.days_left}
|
|
<span className="ml-1 text-lg text-dark-400">{t('subscription.days')}</span>
|
|
</>
|
|
) : subscription.hours_left > 0 ? (
|
|
<>
|
|
{subscription.hours_left}
|
|
<span className="ml-1 text-lg text-dark-400">{t('subscription.hours')}</span>
|
|
</>
|
|
) : subscription.minutes_left > 0 ? (
|
|
<>
|
|
{subscription.minutes_left}
|
|
<span className="ml-1 text-lg text-dark-400">{t('subscription.minutes')}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-error-400">{t('subscription.expired')}</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="stat-value text-error-400">{t('subscription.inactive')}</div>
|
|
)}
|
|
</Link>
|
|
|
|
{/* Referrals */}
|
|
<Link to="/referral" className="bento-card-hover group">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<span className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</span>
|
|
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
|
<ArrowRightIcon />
|
|
</span>
|
|
</div>
|
|
{refLoading ? (
|
|
<div className="skeleton h-8 w-16" />
|
|
) : (
|
|
<div className="stat-value">{referralInfo?.total_referrals || 0}</div>
|
|
)}
|
|
</Link>
|
|
|
|
{/* Earnings */}
|
|
<Link to="/referral" className="bento-card-hover group">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<span className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</span>
|
|
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
|
<ArrowRightIcon />
|
|
</span>
|
|
</div>
|
|
{refLoading ? (
|
|
<div className="skeleton h-8 w-20" />
|
|
) : (
|
|
<div className="stat-value text-success-400">
|
|
{formatPositive(referralInfo?.total_earnings_rubles || 0)}
|
|
</div>
|
|
)}
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Trial Activation */}
|
|
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
|
|
<div className="bento-card-glow border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent">
|
|
<div className="flex items-start gap-4">
|
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-accent-500/20">
|
|
<SparklesIcon />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
|
{trialInfo.requires_payment
|
|
? t('subscription.trial.titlePaid', 'Trial Subscription')
|
|
: t('subscription.trial.title', 'Free Trial')}
|
|
</h3>
|
|
<p className="mb-4 text-sm text-dark-400">
|
|
{t('subscription.trial.description', 'Try our VPN service for free!')}
|
|
</p>
|
|
|
|
<div className="mb-6 flex gap-6">
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-accent-400">
|
|
{trialInfo.duration_days}
|
|
</div>
|
|
<div className="text-xs text-dark-500">{t('subscription.trial.days')}</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-accent-400">
|
|
{trialInfo.traffic_limit_gb || '∞'}
|
|
</div>
|
|
<div className="text-xs text-dark-500">GB</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-accent-400">{trialInfo.device_limit}</div>
|
|
<div className="text-xs text-dark-500">{t('subscription.trial.devices')}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{trialInfo.requires_payment && trialInfo.price_rubles > 0 && (
|
|
<div className="mb-4 space-y-2 rounded-xl bg-dark-800/50 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-dark-400">
|
|
{t('subscription.trial.price', 'Activation price')}:
|
|
</span>
|
|
<span className="text-lg font-semibold text-accent-400">
|
|
{trialInfo.price_rubles.toFixed(2)} {currencySymbol}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-dark-400">
|
|
{t('balance.currentBalance', 'Your balance')}:
|
|
</span>
|
|
<span
|
|
className={`text-lg font-semibold ${(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? 'text-success-400' : 'text-warning-400'}`}
|
|
>
|
|
{formatAmount(balanceData?.balance_rubles || 0)} {currencySymbol}
|
|
</span>
|
|
</div>
|
|
{(balanceData?.balance_kopeks || 0) < trialInfo.price_kopeks && (
|
|
<div className="pt-1 text-xs text-warning-400">
|
|
{t(
|
|
'subscription.trial.insufficientBalance',
|
|
'Top up your balance to activate',
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{trialError && (
|
|
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
|
{trialError}
|
|
</div>
|
|
)}
|
|
|
|
{trialInfo.requires_payment && trialInfo.price_kopeks > 0 ? (
|
|
(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? (
|
|
<button
|
|
onClick={() => activateTrialMutation.mutate()}
|
|
disabled={activateTrialMutation.isPending}
|
|
className="btn-primary w-full"
|
|
>
|
|
{activateTrialMutation.isPending
|
|
? t('common.loading', 'Loading...')
|
|
: t('subscription.trial.payAndActivate', 'Pay from Balance & Activate')}
|
|
</button>
|
|
) : (
|
|
<Link to="/balance" className="btn-primary block w-full text-center">
|
|
{t('subscription.trial.topUpToActivate', 'Top Up Balance')}
|
|
</Link>
|
|
)
|
|
) : (
|
|
<button
|
|
onClick={() => activateTrialMutation.mutate()}
|
|
disabled={activateTrialMutation.isPending}
|
|
className="btn-primary w-full"
|
|
>
|
|
{activateTrialMutation.isPending
|
|
? t('common.loading', 'Loading...')
|
|
: t('subscription.trial.activate', 'Activate Free Trial')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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">
|
|
{/* Emoji */}
|
|
<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>
|
|
);
|
|
}
|