mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
refactor: redesign Dashboard and Subscription pages with new components
Decompose monolithic Dashboard into SubscriptionCardActive, SubscriptionCardExpired, TrialOfferCard, and StatsGrid sub-components. Replace Subscription page progress bar with TrafficProgressBar compact. Add i18n keys for traffic zones, stats labels, expired/trial states.
This commit is contained in:
@@ -225,7 +225,41 @@
|
||||
"noActiveSubscription": "No active subscription",
|
||||
"devicesUsed": "Devices: {{used}} of {{total}}",
|
||||
"trafficUsed": "Traffic: {{used}} of {{total}} GB",
|
||||
"unlimitedTraffic": "Unlimited traffic"
|
||||
"unlimitedTraffic": "Unlimited traffic",
|
||||
"zone": {
|
||||
"normal": "Normal",
|
||||
"warning": "Moderate",
|
||||
"danger": "High",
|
||||
"critical": "Critical"
|
||||
},
|
||||
"connectDevice": "Connect Device",
|
||||
"devicesConnected": "{{count}} connected",
|
||||
"trafficUsage": "{{used}} / {{limit}} GB",
|
||||
"unlimited": "Unlimited",
|
||||
"tariff": "Tariff",
|
||||
"validUntil": "until {{date}}",
|
||||
"daysRemaining": "Days left",
|
||||
"expired": {
|
||||
"title": "Subscription Expired",
|
||||
"trialTitle": "Trial Expired",
|
||||
"renew": "Renew",
|
||||
"tariffs": "View Tariffs",
|
||||
"traffic": "Traffic",
|
||||
"devices": "Devices",
|
||||
"expiredDate": "Expired"
|
||||
},
|
||||
"trialOffer": {
|
||||
"freeTitle": "Free Trial Available",
|
||||
"paidTitle": "Trial Subscription",
|
||||
"freeDesc": "Try our VPN for free — no commitment required",
|
||||
"paidDesc": "Get started with a trial subscription"
|
||||
},
|
||||
"stats": {
|
||||
"balance": "Balance",
|
||||
"subscription": "Subscription",
|
||||
"referrals": "Referrals",
|
||||
"earnings": "Earnings"
|
||||
}
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Subscription",
|
||||
|
||||
@@ -237,7 +237,41 @@
|
||||
"noActiveSubscription": "Нет активной подписки",
|
||||
"devicesUsed": "Устройств: {{used}} из {{total}}",
|
||||
"trafficUsed": "Трафик: {{used}} из {{total}} ГБ",
|
||||
"unlimitedTraffic": "Безлимитный трафик"
|
||||
"unlimitedTraffic": "Безлимитный трафик",
|
||||
"zone": {
|
||||
"normal": "Норма",
|
||||
"warning": "Умеренный",
|
||||
"danger": "Высокий",
|
||||
"critical": "Критический"
|
||||
},
|
||||
"connectDevice": "Подключить устройство",
|
||||
"devicesConnected": "{{count}} подключено",
|
||||
"trafficUsage": "{{used}} / {{limit}} ГБ",
|
||||
"unlimited": "Безлимит",
|
||||
"tariff": "Тариф",
|
||||
"validUntil": "до {{date}}",
|
||||
"daysRemaining": "Дней осталось",
|
||||
"expired": {
|
||||
"title": "Подписка истекла",
|
||||
"trialTitle": "Пробный период истёк",
|
||||
"renew": "Продлить",
|
||||
"tariffs": "Тарифы",
|
||||
"traffic": "Трафик",
|
||||
"devices": "Устройства",
|
||||
"expiredDate": "Истекла"
|
||||
},
|
||||
"trialOffer": {
|
||||
"freeTitle": "Бесплатный пробный период",
|
||||
"paidTitle": "Пробная подписка",
|
||||
"freeDesc": "Попробуйте наш VPN бесплатно — без обязательств",
|
||||
"paidDesc": "Начните с пробной подписки"
|
||||
},
|
||||
"stats": {
|
||||
"balance": "Баланс",
|
||||
"subscription": "Подписка",
|
||||
"referrals": "Рефералы",
|
||||
"earnings": "Заработок"
|
||||
}
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Подписка",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useBlockingStore } from '../store/blocking';
|
||||
@@ -10,54 +10,27 @@ import { balanceApi } from '../api/balance';
|
||||
import { wheelApi } from '../api/wheel';
|
||||
import Onboarding, { useOnboarding } from '../components/Onboarding';
|
||||
import PromoOffersSection from '../components/PromoOffersSection';
|
||||
import { HoverBorderGradient } from '../components/ui/hover-border-gradient';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
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 { 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 = useAuthStore((state) => state.user);
|
||||
const refreshUser = useAuthStore((state) => state.refreshUser);
|
||||
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((state) => state.blockingType);
|
||||
const [trialError, setTrialError] = useState<string | null>(null);
|
||||
|
||||
// Refresh user data on mount
|
||||
useEffect(() => {
|
||||
@@ -80,7 +53,6 @@ export default function Dashboard() {
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
|
||||
// Extract subscription from response (null if no subscription)
|
||||
const subscription = subscriptionResponse?.subscription ?? null;
|
||||
|
||||
const { data: trialInfo, isLoading: trialLoading } = useQuery({
|
||||
@@ -94,11 +66,10 @@ export default function Dashboard() {
|
||||
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
|
||||
staleTime: 60000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -132,7 +103,6 @@ export default function Dashboard() {
|
||||
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);
|
||||
@@ -160,10 +130,9 @@ export default function Dashboard() {
|
||||
return () => clearInterval(timer);
|
||||
}, [trafficRefreshCooldown]);
|
||||
|
||||
// Track if we've already triggered auto-refresh this session
|
||||
// Auto-refresh traffic on mount (with 30s caching)
|
||||
const hasAutoRefreshed = useRef(false);
|
||||
|
||||
// Auto-refresh traffic on mount (with 30s caching)
|
||||
useEffect(() => {
|
||||
if (!subscription) return;
|
||||
if (hasAutoRefreshed.current) return;
|
||||
@@ -185,20 +154,16 @@ export default function Dashboard() {
|
||||
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<{
|
||||
@@ -227,7 +192,6 @@ export default function Dashboard() {
|
||||
},
|
||||
];
|
||||
|
||||
// Add connect devices step only if subscription exists
|
||||
if (subscription?.subscription_url) {
|
||||
steps.splice(1, 0, {
|
||||
target: 'connect-devices',
|
||||
@@ -245,13 +209,6 @@ export default function Dashboard() {
|
||||
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 */}
|
||||
@@ -262,371 +219,59 @@ export default function Dashboard() {
|
||||
<p className="mt-1 text-dark-400">{t('dashboard.yourSubscription')}</p>
|
||||
</div>
|
||||
|
||||
{/* Subscription Status - Main Card */}
|
||||
{/* Subscription Status Card */}
|
||||
{subLoading ? (
|
||||
<div className="bento-card">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="skeleton h-6 w-24" />
|
||||
<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="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 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 ? (
|
||||
<SubscriptionCardExpired subscription={subscription} />
|
||||
) : 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 && (
|
||||
<HoverBorderGradient
|
||||
onClick={() => navigate('/connection')}
|
||||
containerClassName="w-full"
|
||||
className="flex w-full items-center justify-center py-2.5 text-sm"
|
||||
data-onboarding="connect-devices"
|
||||
>
|
||||
{t('subscription.getConfig')}
|
||||
</HoverBorderGradient>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SubscriptionCardActive
|
||||
subscription={subscription}
|
||||
trafficData={trafficData}
|
||||
refreshTrafficMutation={refreshTrafficMutation}
|
||||
trafficRefreshCooldown={trafficRefreshCooldown}
|
||||
/>
|
||||
) : 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 ? (
|
||||
<HoverBorderGradient
|
||||
onClick={() => activateTrialMutation.mutate()}
|
||||
containerClassName={`w-full ${activateTrialMutation.isPending ? 'pointer-events-none opacity-50' : ''}`}
|
||||
className="flex w-full items-center justify-center"
|
||||
>
|
||||
{activateTrialMutation.isPending
|
||||
? t('common.loading', 'Loading...')
|
||||
: t('subscription.trial.payAndActivate', 'Pay from Balance & Activate')}
|
||||
</HoverBorderGradient>
|
||||
) : (
|
||||
<Link to="/balance" className="btn-primary block w-full text-center">
|
||||
{t('subscription.trial.topUpToActivate', 'Top Up Balance')}
|
||||
</Link>
|
||||
)
|
||||
) : (
|
||||
<HoverBorderGradient
|
||||
onClick={() => activateTrialMutation.mutate()}
|
||||
containerClassName={`w-full ${activateTrialMutation.isPending ? 'pointer-events-none opacity-50' : ''}`}
|
||||
className="flex w-full items-center justify-center"
|
||||
>
|
||||
{activateTrialMutation.isPending
|
||||
? t('common.loading', 'Loading...')
|
||||
: t('subscription.trial.activate', 'Activate Free Trial')}
|
||||
</HoverBorderGradient>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
subscription={subscription}
|
||||
subLoading={subLoading}
|
||||
referralCount={referralInfo?.total_referrals || 0}
|
||||
earningsRubles={referralInfo?.total_earnings_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">
|
||||
{/* 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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AxiosError } from 'axios';
|
||||
import { subscriptionApi } from '../api/subscription';
|
||||
import { promoApi } from '../api/promo';
|
||||
import { HoverBorderGradient } from '../components/ui/hover-border-gradient';
|
||||
import TrafficProgressBar from '../components/dashboard/TrafficProgressBar';
|
||||
import type {
|
||||
PurchaseSelection,
|
||||
PeriodOption,
|
||||
@@ -605,12 +606,6 @@ export default function Subscription() {
|
||||
}
|
||||
};
|
||||
|
||||
const getTrafficColor = (percent: number) => {
|
||||
if (percent > 90) return 'bg-error-500';
|
||||
if (percent > 70) return 'bg-warning-500';
|
||||
return 'bg-success-500';
|
||||
};
|
||||
|
||||
const toggleServer = (uuid: string) => {
|
||||
if (selectedServers.includes(uuid)) {
|
||||
if (selectedServers.length > 1) {
|
||||
@@ -756,11 +751,11 @@ export default function Subscription() {
|
||||
{t('subscription.connectionInfo')}
|
||||
</div>
|
||||
|
||||
{/* Get Config Button */}
|
||||
{/* Get Config Button with device indicator */}
|
||||
<HoverBorderGradient
|
||||
onClick={() => navigate('/connection')}
|
||||
containerClassName="mb-3 w-full"
|
||||
className="flex w-full items-center justify-center gap-2 py-3"
|
||||
className="flex w-full items-center justify-center gap-3 py-3"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
@@ -772,12 +767,30 @@ export default function Subscription() {
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
|
||||
/>
|
||||
</svg>
|
||||
{t('subscription.getConfig')}
|
||||
<span>{t('subscription.getConfig')}</span>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: Math.min(subscription.device_limit, 5) }).map((_, i) => (
|
||||
<div key={i} className="h-1.5 w-1.5 rounded-full bg-accent-400/60" />
|
||||
))}
|
||||
</div>
|
||||
</HoverBorderGradient>
|
||||
|
||||
{/* Compact traffic progress bar */}
|
||||
<div className="mb-3">
|
||||
<TrafficProgressBar
|
||||
usedGb={trafficData?.traffic_used_gb ?? subscription.traffic_used_gb}
|
||||
limitGb={subscription.traffic_limit_gb}
|
||||
percent={trafficData?.traffic_used_percent ?? subscription.traffic_used_percent}
|
||||
isUnlimited={
|
||||
(trafficData?.is_unlimited ?? false) || subscription.traffic_limit_gb === 0
|
||||
}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subscription URL - hidden when hide_subscription_link is true */}
|
||||
{!subscription.hide_subscription_link && (
|
||||
<div className="flex gap-2">
|
||||
@@ -885,21 +898,20 @@ export default function Subscription() {
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
|
||||
<span className="text-dark-300">
|
||||
<span className="font-mono 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>
|
||||
<TrafficProgressBar
|
||||
usedGb={trafficData?.traffic_used_gb ?? subscription.traffic_used_gb}
|
||||
limitGb={subscription.traffic_limit_gb}
|
||||
percent={trafficData?.traffic_used_percent ?? subscription.traffic_used_percent}
|
||||
isUnlimited={false}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user