mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
refactor: rewrite dashboard components to match prototype design
- Rewrite TrafficProgressBar with multi-segment gradient fill, flex-based zone tints, shimmer + highlight overlays, radial glow at fill edge - Rewrite SubscriptionCardActive with zone header, big percentage on right, connect device card with device dots, tariff/days-left two-column row, sparkline placeholder, traffic refresh controls - Rewrite SubscriptionCardExpired with red glow, grid pattern, gradient renew button, outline tariffs button - Rewrite TrialOfferCard with animated glow background, grid pattern, icon glow animation, price tag with old price, gradient CTA buttons - Rewrite StatsGrid with 2x2 card layout, icon+label+chevron header, big value numbers, zone-colored balance/earnings cards - Update Sparkline with color prop and last-point dot indicator - Update useAnimatedNumber to use easeOutExpo matching prototype - Add formatTraffic utility for MB/GB/TB unit formatting - Add mainHex to trafficZone for inline style support - Update animations to match prototype timing - Add new i18n keys for redesigned components
This commit is contained in:
@@ -4,36 +4,33 @@ interface SparklineProps {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
color: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Sparkline({
|
||||
data,
|
||||
width = 120,
|
||||
height = 32,
|
||||
width = 200,
|
||||
height = 40,
|
||||
color,
|
||||
className = '',
|
||||
}: SparklineProps) {
|
||||
const gradientId = useId();
|
||||
|
||||
if (data.length < 2) return null;
|
||||
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = max - min || 1;
|
||||
const padding = 2;
|
||||
const innerW = width - padding * 2;
|
||||
const innerH = height - padding * 2;
|
||||
|
||||
const max = Math.max(...data, 1);
|
||||
const points = data.map((v, i) => {
|
||||
const x = padding + (i / (data.length - 1)) * innerW;
|
||||
const y = padding + innerH - ((v - min) / range) * innerH;
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
const y = height - (v / max) * height * 0.85 - 2;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const polyline = points.join(' ');
|
||||
const lastX = padding + innerW;
|
||||
const bottomY = padding + innerH;
|
||||
const areaPath = `M${points[0]} ${points.slice(1).join(' ')} L${lastX},${bottomY} L${padding},${bottomY} Z`;
|
||||
const lastPoint = points[points.length - 1].split(',');
|
||||
const lastX = parseFloat(lastPoint[0]);
|
||||
const lastY = parseFloat(lastPoint[1]);
|
||||
const areaPath = `M${points[0]} ${points.slice(1).join(' ')} L${width},${height} L0,${height} Z`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -41,23 +38,32 @@ export default function Sparkline({
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
style={{ overflow: 'visible' }}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgb(var(--color-accent-400))" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="rgb(var(--color-accent-400))" stopOpacity="0" />
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={areaPath} fill={`url(#${gradientId})`} />
|
||||
<polyline
|
||||
points={polyline}
|
||||
stroke="rgb(var(--color-accent-400))"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Last point dot */}
|
||||
<circle
|
||||
cx={lastX}
|
||||
cy={lastY}
|
||||
r="3"
|
||||
fill={color}
|
||||
style={{ filter: `drop-shadow(0 0 4px ${color})` }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router';
|
||||
import type { Subscription } from '../../types';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { getTrafficZone } from '../../utils/trafficZone';
|
||||
|
||||
interface StatsGridProps {
|
||||
balanceRubles: number;
|
||||
@@ -12,83 +14,21 @@ interface StatsGridProps {
|
||||
refLoading: boolean;
|
||||
}
|
||||
|
||||
const ArrowRightIcon = () => (
|
||||
const ChevronIcon = () => (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WalletIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
style={{ opacity: 0.25, flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6 4l4 4-4 4"
|
||||
stroke="#fff"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 110-6h5.25A2.25 2.25 0 0121 6v6zm0 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18V6a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CalendarIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CoinIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -104,110 +44,166 @@ export default function StatsGrid({
|
||||
const { t } = useTranslation();
|
||||
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
|
||||
|
||||
const zone = useMemo(
|
||||
() => getTrafficZone(subscription?.traffic_used_percent ?? 0),
|
||||
[subscription?.traffic_used_percent],
|
||||
);
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: t('dashboard.stats.balance'),
|
||||
value: `${formatAmount(balanceRubles)} ${currencySymbol}`,
|
||||
valueColor: zone.mainHex,
|
||||
to: '/balance',
|
||||
icon: (color: string) => (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="6" width="20" height="14" rx="2" />
|
||||
<path d="M2 10h20" />
|
||||
<path d="M6 14h.01M10 14h.01" />
|
||||
</svg>
|
||||
),
|
||||
iconBg: `${zone.mainHex}12`,
|
||||
iconColor: zone.mainHex,
|
||||
loading: false,
|
||||
onboarding: 'balance',
|
||||
},
|
||||
{
|
||||
label: t('dashboard.stats.subscription'),
|
||||
value: subscription ? `${subscription.days_left}` : '—',
|
||||
valueSuffix: subscription ? ` ${t('subscription.daysShort')}` : '',
|
||||
valueColor: '#fff',
|
||||
to: '/subscription',
|
||||
icon: (color: string) => (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" />
|
||||
<circle cx="7" cy="7" r="1" />
|
||||
</svg>
|
||||
),
|
||||
iconBg: 'rgba(255,255,255,0.06)',
|
||||
iconColor: 'rgba(255,255,255,0.45)',
|
||||
loading: subLoading,
|
||||
onboarding: 'subscription-status',
|
||||
},
|
||||
{
|
||||
label: t('dashboard.stats.referrals'),
|
||||
value: `${referralCount}`,
|
||||
valueColor: '#fff',
|
||||
to: '/referral',
|
||||
icon: (color: string) => (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
|
||||
</svg>
|
||||
),
|
||||
iconBg: 'rgba(255,255,255,0.06)',
|
||||
iconColor: 'rgba(255,255,255,0.45)',
|
||||
loading: refLoading,
|
||||
},
|
||||
{
|
||||
label: t('dashboard.stats.earnings'),
|
||||
value: formatPositive(earningsRubles),
|
||||
valueColor: zone.mainHex,
|
||||
to: '/referral',
|
||||
icon: (color: string) => (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v12M8.5 9.5c0-1.38 1.57-2.5 3.5-2.5s3.5 1.12 3.5 2.5-1.57 2.5-3.5 2.5-3.5 1.12-3.5 2.5 1.57 2.5 3.5 2.5 3.5-1.12 3.5-2.5" />
|
||||
</svg>
|
||||
),
|
||||
iconBg: `${zone.mainHex}12`,
|
||||
iconColor: zone.mainHex,
|
||||
loading: refLoading,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent-500/15 text-accent-400">
|
||||
<WalletIcon />
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{cards.map((card, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
to={card.to}
|
||||
className="group relative overflow-hidden rounded-[18px] transition-all duration-200"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(145deg, rgba(255,255,255,0.045) 0%, rgba(255,255,255,0.02) 100%)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
padding: '18px 20px 20px',
|
||||
}}
|
||||
data-onboarding={card.onboarding}
|
||||
>
|
||||
{/* Top row: icon + label + arrow */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-[9px] transition-colors duration-500"
|
||||
style={{ background: card.iconBg }}
|
||||
>
|
||||
{card.icon(card.iconColor)}
|
||||
</div>
|
||||
<span className="text-[13px] font-medium text-white/45">{card.label}</span>
|
||||
</div>
|
||||
<ChevronIcon />
|
||||
</div>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs uppercase tracking-wider text-dark-500">
|
||||
{t('dashboard.stats.balance')}
|
||||
</div>
|
||||
<div className="mt-1 font-display text-2xl font-bold text-accent-400">
|
||||
{formatAmount(balanceRubles)}
|
||||
<span className="ml-1 text-base text-dark-500">{currencySymbol}</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Subscription Days */}
|
||||
<Link
|
||||
to="/subscription"
|
||||
className="bento-card-hover group"
|
||||
data-onboarding="subscription-status"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent-500/15 text-accent-400">
|
||||
<CalendarIcon />
|
||||
</div>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs uppercase tracking-wider text-dark-500">
|
||||
{t('dashboard.stats.subscription')}
|
||||
</div>
|
||||
{subLoading ? (
|
||||
<div className="skeleton mt-1 h-8 w-16" />
|
||||
) : subscription ? (
|
||||
<div className="mt-1 font-display text-2xl font-bold text-dark-50">
|
||||
{subscription.days_left > 0 ? (
|
||||
<>
|
||||
{subscription.days_left}
|
||||
<span className="ml-1 text-base text-dark-500">{t('subscription.days')}</span>
|
||||
</>
|
||||
) : subscription.hours_left > 0 ? (
|
||||
<>
|
||||
{subscription.hours_left}
|
||||
<span className="ml-1 text-base text-dark-500">{t('subscription.hours')}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-error-400">{t('subscription.expired')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 font-display text-lg font-bold 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">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent-500/15 text-accent-400">
|
||||
<UsersIcon />
|
||||
</div>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs uppercase tracking-wider text-dark-500">
|
||||
{t('dashboard.stats.referrals')}
|
||||
</div>
|
||||
{refLoading ? (
|
||||
<div className="skeleton mt-1 h-8 w-12" />
|
||||
) : (
|
||||
<div className="mt-1 font-display text-2xl font-bold text-dark-50">{referralCount}</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Earnings */}
|
||||
<Link to="/referral" className="bento-card-hover group">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-success-500/15 text-success-400">
|
||||
<CoinIcon />
|
||||
</div>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs uppercase tracking-wider text-dark-500">
|
||||
{t('dashboard.stats.earnings')}
|
||||
</div>
|
||||
{refLoading ? (
|
||||
<div className="skeleton mt-1 h-8 w-16" />
|
||||
) : (
|
||||
<div className="mt-1 font-display text-2xl font-bold text-success-400">
|
||||
{formatPositive(earningsRubles)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
{/* Value */}
|
||||
{card.loading ? (
|
||||
<div className="skeleton h-8 w-20" />
|
||||
) : (
|
||||
<div
|
||||
className="text-[28px] font-bold leading-tight tracking-tight transition-colors duration-500"
|
||||
style={{ color: card.valueColor }}
|
||||
>
|
||||
{card.value}
|
||||
{card.valueSuffix && (
|
||||
<span className="ml-0.5 text-base font-medium text-white/35">
|
||||
{card.valueSuffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import { UseMutationResult } from '@tanstack/react-query';
|
||||
import { HoverBorderGradient } from '../ui/hover-border-gradient';
|
||||
import TrafficProgressBar from './TrafficProgressBar';
|
||||
import Sparkline from './Sparkline';
|
||||
import { useAnimatedNumber } from '../../hooks/useAnimatedNumber';
|
||||
import { getTrafficZone } from '../../utils/trafficZone';
|
||||
import { formatTraffic } from '../../utils/formatTraffic';
|
||||
import type { Subscription } from '../../types';
|
||||
|
||||
interface SubscriptionCardActiveProps {
|
||||
@@ -35,23 +38,6 @@ const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DeviceIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
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>
|
||||
);
|
||||
|
||||
export default function SubscriptionCardActive({
|
||||
subscription,
|
||||
trafficData,
|
||||
@@ -64,128 +50,318 @@ export default function SubscriptionCardActive({
|
||||
const usedPercent = trafficData?.traffic_used_percent ?? subscription.traffic_used_percent;
|
||||
const usedGb = trafficData?.traffic_used_gb ?? subscription.traffic_used_gb;
|
||||
const isUnlimited = trafficData?.is_unlimited ?? subscription.traffic_limit_gb === 0;
|
||||
const zone = getTrafficZone(usedPercent);
|
||||
const zone = useMemo(() => getTrafficZone(usedPercent), [usedPercent]);
|
||||
const animatedPercent = useAnimatedNumber(usedPercent);
|
||||
|
||||
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
|
||||
const daysLeft = subscription.days_left;
|
||||
|
||||
// Sparkline placeholder data (hidden until API provides daily usage)
|
||||
const dailyUsage: number[] = [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bento-card ${subscription.is_trial ? 'animate-trial-glow border-warning-500/30 bg-gradient-to-br from-warning-500/5 to-transparent' : ''}`}
|
||||
className="relative overflow-hidden rounded-3xl backdrop-blur-xl"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(145deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%)',
|
||||
border: subscription.is_trial
|
||||
? '1px solid rgba(62,219,176,0.15)'
|
||||
: '1px solid rgba(255,255,255,0.07)',
|
||||
padding: '28px 28px 24px',
|
||||
}}
|
||||
>
|
||||
{/* Top row: zone indicator + tariff info */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Animated zone dot */}
|
||||
<span className="relative flex h-2.5 w-2.5" aria-hidden="true">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${zone.dotClass}`}
|
||||
{/* Trial shimmer border */}
|
||||
{subscription.is_trial && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-[-1px] animate-trial-glow rounded-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Background glow */}
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
top: -60,
|
||||
right: -60,
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${zone.mainHex}15 0%, transparent 70%)`,
|
||||
transition: 'background 0.8s ease',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* ─── Header ─── */}
|
||||
<div className="mb-7 flex items-start justify-between">
|
||||
<div>
|
||||
{/* Zone indicator */}
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{
|
||||
background: zone.mainHex,
|
||||
boxShadow: `0 0 8px ${zone.mainHex}80`,
|
||||
transition: 'all 0.6s ease',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${zone.dotClass}`} />
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${zone.textClass}`}>
|
||||
{isUnlimited ? t('dashboard.unlimited') : t(zone.labelKey)}
|
||||
<span
|
||||
className="font-mono text-[11px] font-semibold uppercase tracking-widest"
|
||||
style={{ color: zone.mainHex, transition: 'color 0.6s ease' }}
|
||||
>
|
||||
{isUnlimited ? t('dashboard.unlimited') : t(zone.labelKey)}
|
||||
</span>
|
||||
{subscription.is_trial && (
|
||||
<span
|
||||
className="inline-flex animate-trial-glow items-center gap-1 rounded-md px-2 py-0.5 font-mono text-[9px] font-bold uppercase tracking-widest"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(62,219,176,0.15), rgba(62,219,176,0.06))',
|
||||
border: '1px solid rgba(62,219,176,0.2)',
|
||||
color: '#3EDBB0',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
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"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{t('subscription.trialStatus')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-lg font-bold tracking-tight text-white">
|
||||
{t('dashboard.trafficUsageTitle')}
|
||||
</h2>
|
||||
|
||||
{/* Tariff info line */}
|
||||
<span className="text-xs text-white/35">
|
||||
{subscription.tariff_name && (
|
||||
<>
|
||||
{t('dashboard.tariff')} {subscription.tariff_name} ·{' '}
|
||||
</>
|
||||
)}
|
||||
{t('dashboard.validUntil', { date: formattedDate })} · {subscription.device_limit}{' '}
|
||||
{t('dashboard.devicesShort')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={
|
||||
subscription.is_trial
|
||||
? 'badge-warning'
|
||||
: subscription.is_active
|
||||
? 'badge-success'
|
||||
: 'badge-error'
|
||||
}
|
||||
{/* Big percentage / infinity */}
|
||||
<div className="text-right">
|
||||
{isUnlimited ? (
|
||||
<>
|
||||
<div
|
||||
className="font-display text-[28px] font-extrabold leading-none tracking-tight"
|
||||
style={{ color: zone.mainHex }}
|
||||
>
|
||||
∞
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/30">
|
||||
{formatTraffic(usedGb)} {t('dashboard.usedSuffix')}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-display text-[38px] font-extrabold leading-none tracking-tight text-white">
|
||||
{animatedPercent.toFixed(0)}
|
||||
<span className="ml-px text-lg font-medium text-white/35">%</span>
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-[11px] text-white/30">
|
||||
{formatTraffic(usedGb)} / {formatTraffic(subscription.traffic_limit_gb)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Progress Bar ─── */}
|
||||
<div className="mb-6">
|
||||
<TrafficProgressBar
|
||||
usedGb={usedGb}
|
||||
limitGb={subscription.traffic_limit_gb}
|
||||
percent={usedPercent}
|
||||
isUnlimited={isUnlimited}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ─── Connect Device Button ─── */}
|
||||
{subscription.subscription_url && (
|
||||
<button
|
||||
onClick={() => navigate('/connection')}
|
||||
className="mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] border border-white/[0.04] bg-white/[0.03] p-3.5 text-left transition-all duration-300 hover:border-white/[0.08] hover:bg-white/[0.05]"
|
||||
data-onboarding="connect-devices"
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
>
|
||||
{subscription.is_trial
|
||||
? t('subscription.trialStatus')
|
||||
: subscription.is_active
|
||||
? t('subscription.active')
|
||||
: t('subscription.expired')}
|
||||
</span>
|
||||
{/* Monitor icon */}
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
|
||||
style={{ background: `${zone.mainHex}12` }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={zone.mainHex}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M12 17v4M8 21h8" />
|
||||
<path d="M12 8v4M10 10h4" opacity="0.7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold tracking-tight text-white">
|
||||
{t('dashboard.connectDevice')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-white/30">
|
||||
{t('dashboard.devicesOfMax', {
|
||||
used: subscription.device_limit,
|
||||
max: subscription.device_limit,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device dots */}
|
||||
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
||||
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[7px] w-[7px] rounded-full transition-all duration-300"
|
||||
style={{
|
||||
background:
|
||||
i < subscription.device_limit ? zone.mainHex : 'rgba(255,255,255,0.08)',
|
||||
boxShadow: i < subscription.device_limit ? `0 0 6px ${zone.mainHex}50` : 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ─── Stats row: Tariff + Days Left ─── */}
|
||||
<div className="mb-5 flex gap-2.5">
|
||||
{/* Tariff badge */}
|
||||
<div
|
||||
className="flex-1 rounded-[14px] p-3.5 transition-all duration-500"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${zone.mainHex}12, ${zone.mainHex}06)`,
|
||||
border: `1px solid ${zone.mainHex}18`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider opacity-70 transition-colors duration-500"
|
||||
style={{ color: zone.mainHex }}
|
||||
>
|
||||
{t('dashboard.tariff')}
|
||||
</div>
|
||||
<div className="text-base font-bold leading-tight tracking-tight text-white">
|
||||
{subscription.tariff_name || t('subscription.currentPlan')}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-[10px] text-white/30">
|
||||
{t('dashboard.validUntil', { date: formattedDate })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Days remaining */}
|
||||
<div
|
||||
className="flex-1 rounded-[14px] bg-white/[0.03] p-3.5 transition-colors duration-300"
|
||||
style={{
|
||||
border:
|
||||
daysLeft <= 3 ? '1px solid rgba(255,184,0,0.2)' : '1px solid rgba(255,255,255,0.04)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-white/35">
|
||||
<div
|
||||
className="flex h-6 w-6 items-center justify-center rounded-[7px] transition-colors duration-300"
|
||||
style={{
|
||||
background: daysLeft <= 3 ? 'rgba(255,184,0,0.1)' : 'rgba(255,255,255,0.05)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={daysLeft <= 3 ? '#FFB800' : 'rgba(255,255,255,0.4)'}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||
</svg>
|
||||
</div>
|
||||
{t('dashboard.remaining')}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className="text-[22px] font-bold tracking-tight transition-colors duration-300"
|
||||
style={{ color: daysLeft <= 3 ? '#FFB800' : '#fff' }}
|
||||
>
|
||||
{daysLeft}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-white/25">{t('subscription.daysShort')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tariff info line */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-dark-400">
|
||||
{subscription.tariff_name && (
|
||||
<>
|
||||
<span className="text-accent-400">{subscription.tariff_name}</span>
|
||||
<span className="text-dark-600">·</span>
|
||||
</>
|
||||
)}
|
||||
<span>{t('dashboard.validUntil', { date: formattedDate })}</span>
|
||||
<span className="text-dark-600">·</span>
|
||||
<span>
|
||||
{subscription.device_limit} {t('subscription.devices')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Big percentage or infinity */}
|
||||
<div className="mb-1 flex items-end gap-3">
|
||||
{isUnlimited ? (
|
||||
<span className="font-display text-4xl font-bold text-accent-400">∞</span>
|
||||
) : (
|
||||
<span className={`font-display text-4xl font-bold ${zone.textClass}`}>
|
||||
{animatedPercent.toFixed(1)}
|
||||
<span className="text-2xl text-dark-500">%</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Traffic used line with refresh */}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-dark-400">
|
||||
{isUnlimited
|
||||
? `${usedGb.toFixed(1)} ${t('common.units.gb')}`
|
||||
: `${usedGb.toFixed(1)} / ${subscription.traffic_limit_gb} ${t('common.units.gb')}`}
|
||||
</span>
|
||||
{/* ─── Traffic Refresh ─── */}
|
||||
<div className="mb-5 flex items-center justify-between px-0.5">
|
||||
<button
|
||||
onClick={() => refreshTrafficMutation.mutate()}
|
||||
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
||||
className="rounded-full p-1 text-dark-500 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium text-white/35 transition-colors hover:bg-white/[0.05] hover:text-white/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={t('common.refresh')}
|
||||
title={trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')}
|
||||
>
|
||||
<RefreshIcon
|
||||
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||||
className={`h-3 w-3 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<TrafficProgressBar
|
||||
usedGb={usedGb}
|
||||
limitGb={subscription.traffic_limit_gb}
|
||||
percent={usedPercent}
|
||||
isUnlimited={isUnlimited}
|
||||
showScale={!isUnlimited && subscription.traffic_limit_gb > 0}
|
||||
showThresholds={!isUnlimited}
|
||||
/>
|
||||
|
||||
{/* Connect device button */}
|
||||
{subscription.subscription_url && (
|
||||
<div className="mt-5">
|
||||
<HoverBorderGradient
|
||||
onClick={() => navigate('/connection')}
|
||||
containerClassName="w-full"
|
||||
className="flex w-full items-center justify-center gap-3 py-3"
|
||||
data-onboarding="connect-devices"
|
||||
>
|
||||
<DeviceIcon />
|
||||
<span>{t('dashboard.connectDevice')}</span>
|
||||
</HoverBorderGradient>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom link */}
|
||||
<div className="mt-5 flex items-center justify-end">
|
||||
<Link
|
||||
to="/subscription"
|
||||
className="text-sm font-medium text-accent-400 transition-colors hover:text-accent-300"
|
||||
className="text-[11px] font-medium text-white/25 transition-colors hover:text-white/40"
|
||||
>
|
||||
{t('dashboard.viewSubscription')} →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ─── Sparkline ─── */}
|
||||
{dailyUsage.length >= 2 && (
|
||||
<div className="rounded-[14px] border border-white/[0.04] bg-white/[0.02] p-3.5 pb-3">
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-white/40">
|
||||
{t('dashboard.usageLast14Days')}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-white/25">
|
||||
{t('dashboard.maxUsage', { amount: formatTraffic(Math.max(...dailyUsage)) })}
|
||||
</span>
|
||||
</div>
|
||||
<Sparkline data={dailyUsage} width={440} height={44} color={zone.mainHex} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,79 +6,152 @@ interface SubscriptionCardExpiredProps {
|
||||
subscription: Subscription;
|
||||
}
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg
|
||||
className="h-6 w-6 text-error-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function SubscriptionCardExpired({ subscription }: SubscriptionCardExpiredProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
|
||||
|
||||
return (
|
||||
<div className="bento-card border-error-500/30 bg-gradient-to-br from-error-500/5 to-transparent">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-3xl"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(145deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%)',
|
||||
border: '1px solid rgba(255,70,70,0.12)',
|
||||
padding: '28px 28px 24px',
|
||||
}}
|
||||
>
|
||||
{/* Red glow */}
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
top: -60,
|
||||
right: -60,
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(255,59,92,0.08) 0%, transparent 70%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Grid pattern */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.02]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||
backgroundSize: '40px 40px',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/15">
|
||||
<ClockIcon />
|
||||
<div className="mb-5 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Clock icon */}
|
||||
<div
|
||||
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[14px]"
|
||||
style={{
|
||||
background: 'rgba(255,59,92,0.1)',
|
||||
border: '1px solid rgba(255,59,92,0.15)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#FF3B5C"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold tracking-tight text-white">
|
||||
{t('dashboard.expired.title')}
|
||||
</h2>
|
||||
<span className="text-xs text-white/35">
|
||||
{subscription.is_trial
|
||||
? t('dashboard.expired.trialSubtitle')
|
||||
: t('dashboard.expired.paidSubtitle')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{subscription.is_trial
|
||||
? t('dashboard.expired.trialTitle')
|
||||
: t('dashboard.expired.title')}
|
||||
</h3>
|
||||
|
||||
{/* Badge */}
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-full px-3 py-1 text-[11px] font-semibold"
|
||||
style={{
|
||||
background: 'rgba(255,59,92,0.1)',
|
||||
border: '1px solid rgba(255,59,92,0.2)',
|
||||
color: '#FF3B5C',
|
||||
}}
|
||||
>
|
||||
{subscription.is_trial && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
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"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{subscription.is_trial ? t('subscription.trialStatus') : t('subscription.expired')}
|
||||
</div>
|
||||
<span className="badge-error">{t('subscription.expired')}</span>
|
||||
</div>
|
||||
|
||||
{/* 3-column info */}
|
||||
<div className="mb-5 grid grid-cols-3 gap-4 rounded-xl bg-dark-800/40 p-4">
|
||||
<div className="text-center">
|
||||
<div className="font-display text-2xl font-bold text-dark-300">0</div>
|
||||
<div className="mt-0.5 font-mono text-xs uppercase tracking-wider text-dark-500">
|
||||
{t('dashboard.expired.traffic')}
|
||||
{/* Expired info grid */}
|
||||
<div
|
||||
className="mb-5 flex justify-around rounded-[14px] text-center"
|
||||
style={{
|
||||
background: 'rgba(255,59,92,0.04)',
|
||||
border: '1px solid rgba(255,59,92,0.08)',
|
||||
padding: '16px 18px',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: t('dashboard.expired.traffic'), value: '0 GB' },
|
||||
{ label: t('dashboard.expired.devices'), value: '0' },
|
||||
{ label: t('dashboard.expired.expiredDate'), value: formattedDate },
|
||||
].map((item, i) => (
|
||||
<div key={i}>
|
||||
<div className="mb-1 font-mono text-[10px] font-medium uppercase tracking-wider text-white/30">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-base font-bold tracking-tight text-white/50">{item.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-display text-2xl font-bold text-dark-300">0</div>
|
||||
<div className="mt-0.5 font-mono text-xs uppercase tracking-wider text-dark-500">
|
||||
{t('dashboard.expired.devices')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-display text-sm font-bold text-dark-300">{formattedDate}</div>
|
||||
<div className="mt-0.5 font-mono text-xs uppercase tracking-wider text-dark-500">
|
||||
{t('dashboard.expired.expiredDate')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex gap-2.5">
|
||||
<Link
|
||||
to="/subscription"
|
||||
state={{ scrollToExtend: true }}
|
||||
className="flex items-center justify-center rounded-xl bg-gradient-to-r from-error-600 to-error-500 py-2.5 text-center text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
className="flex flex-1 items-center justify-center rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
|
||||
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
|
||||
}}
|
||||
>
|
||||
{t('dashboard.expired.renew')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/subscription"
|
||||
className="flex items-center justify-center rounded-xl border border-dark-600 py-2.5 text-center text-sm font-medium text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-800/50"
|
||||
className="flex items-center justify-center rounded-[14px] border border-white/[0.08] bg-white/[0.03] px-5 py-3.5 text-[15px] font-semibold tracking-tight text-white/50 transition-colors duration-200 hover:bg-white/[0.05]"
|
||||
>
|
||||
{t('dashboard.expired.tariffs')}
|
||||
</Link>
|
||||
|
||||
@@ -1,48 +1,98 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTrafficZone } from '../../utils/trafficZone';
|
||||
import { formatTraffic } from '../../utils/formatTraffic';
|
||||
|
||||
interface TrafficProgressBarProps {
|
||||
usedGb: number;
|
||||
limitGb: number;
|
||||
percent: number;
|
||||
isUnlimited: boolean;
|
||||
showScale?: boolean;
|
||||
showThresholds?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const THRESHOLDS = [50, 75, 90];
|
||||
|
||||
export default function TrafficProgressBar({
|
||||
usedGb,
|
||||
limitGb,
|
||||
percent,
|
||||
isUnlimited,
|
||||
showScale = false,
|
||||
showThresholds = false,
|
||||
compact = false,
|
||||
}: TrafficProgressBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const zone = useMemo(() => getTrafficZone(percent), [percent]);
|
||||
const clampedPercent = Math.min(percent, 100);
|
||||
const barHeight = compact ? 'h-2' : 'h-3';
|
||||
const barHeight = compact ? 8 : 14;
|
||||
|
||||
// Multi-segment gradient matching prototype
|
||||
const fillGradient = useMemo(() => {
|
||||
if (percent < 50) return `linear-gradient(90deg, #00C987, ${zone.mainHex})`;
|
||||
if (percent < 75) return `linear-gradient(90deg, #00C987, #FFB800, ${zone.mainHex})`;
|
||||
if (percent < 90) return `linear-gradient(90deg, #00C987, #FFB800, #FF6B35, ${zone.mainHex})`;
|
||||
return 'linear-gradient(90deg, #00C987, #FFB800, #FF6B35, #FF3B5C)';
|
||||
}, [percent, zone.mainHex]);
|
||||
|
||||
if (isUnlimited) {
|
||||
return (
|
||||
<div className="relative" role="progressbar" aria-label={t('dashboard.unlimited')}>
|
||||
<div className={`${barHeight} w-full overflow-hidden rounded-full bg-dark-800`}>
|
||||
<div role="progressbar" aria-label={t('dashboard.unlimited')}>
|
||||
{/* Unlimited flowing bar */}
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
height: barHeight,
|
||||
borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: `1px solid ${zone.mainHex}20`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full w-full animate-unlimited-flow rounded-full"
|
||||
className="absolute inset-0 animate-unlimited-flow"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, rgb(var(--color-accent-400)), rgb(var(--color-accent-600)), rgb(var(--color-accent-400)))',
|
||||
background: `linear-gradient(90deg, ${zone.mainHex}50, ${zone.mainHex}, ${zone.mainHex}50)`,
|
||||
backgroundSize: '200% 100%',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 animate-traffic-shimmer"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.25) 50%, transparent 100%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Top highlight */}
|
||||
<div
|
||||
className="absolute left-0 right-0 top-0"
|
||||
style={{
|
||||
height: '50%',
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 100%)',
|
||||
borderRadius: '10px 10px 0 0',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Below bar: label + usage */}
|
||||
{!compact && (
|
||||
<div className="absolute -right-1 top-1/2 -translate-y-1/2" aria-hidden="true">
|
||||
<div className="h-2.5 w-2.5 animate-unlimited-pulse rounded-full bg-accent-400" />
|
||||
<div className="mt-2 flex items-center justify-between px-0.5">
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold"
|
||||
style={{ color: zone.mainHex }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-1.5 w-1.5 animate-unlimited-pulse rounded-full"
|
||||
style={{
|
||||
background: zone.mainHex,
|
||||
boxShadow: `0 0 8px ${zone.mainHex}`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('dashboard.unlimitedTraffic')}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-white/30">
|
||||
{t('dashboard.usedTraffic', { amount: formatTraffic(usedGb) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -51,105 +101,110 @@ export default function TrafficProgressBar({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(clampedPercent)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${t('subscription.traffic')}: ${clampedPercent.toFixed(1)}%`}
|
||||
>
|
||||
{/* Track with zone tint backgrounds */}
|
||||
<div className={`relative ${barHeight} w-full overflow-hidden rounded-full bg-dark-800`}>
|
||||
{/* Zone tint backgrounds */}
|
||||
{showThresholds && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-y-0 left-1/2 opacity-[0.06]"
|
||||
style={{
|
||||
right: '25%',
|
||||
background: 'rgb(var(--color-warning-500))',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 opacity-[0.06]"
|
||||
style={{
|
||||
left: '75%',
|
||||
right: '10%',
|
||||
background: 'rgb(var(--color-warning-600))',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 opacity-[0.06]"
|
||||
style={{
|
||||
left: '90%',
|
||||
background: 'rgb(var(--color-error-500))',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Track */}
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
height: barHeight,
|
||||
borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(255,255,255,0.04)',
|
||||
}}
|
||||
>
|
||||
{/* Warning zone tint backgrounds */}
|
||||
<div className="absolute inset-0 flex" aria-hidden="true">
|
||||
<div style={{ flex: '50 0 0', background: 'transparent' }} />
|
||||
<div style={{ flex: '25 0 0', background: 'rgba(255,184,0,0.03)' }} />
|
||||
<div style={{ flex: '15 0 0', background: 'rgba(255,107,53,0.04)' }} />
|
||||
<div style={{ flex: '10 0 0', background: 'rgba(255,59,92,0.05)' }} />
|
||||
</div>
|
||||
|
||||
{/* Fill bar */}
|
||||
<div
|
||||
className="relative h-full rounded-full transition-all duration-700 ease-smooth"
|
||||
className="absolute bottom-0 left-0 top-0 overflow-hidden"
|
||||
style={{
|
||||
width: `${clampedPercent}%`,
|
||||
background: `linear-gradient(90deg, ${zone.gradientFrom}, ${zone.gradientTo})`,
|
||||
borderRadius: 10,
|
||||
transition: 'width 1.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
{/* Gradient fill */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: fillGradient,
|
||||
transition: 'background 0.8s ease',
|
||||
}}
|
||||
/>
|
||||
{/* Shimmer overlay */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-full" aria-hidden="true">
|
||||
<div
|
||||
className="absolute inset-y-0 w-1/2 animate-traffic-shimmer"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-0 animate-traffic-shimmer"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.2) 50%, transparent 100%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Top highlight */}
|
||||
<div
|
||||
className="absolute left-0 right-0 top-0"
|
||||
style={{
|
||||
height: '50%',
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)',
|
||||
borderRadius: '10px 10px 0 0',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Glow dot at fill edge */}
|
||||
{clampedPercent > 2 && !compact && (
|
||||
{/* Threshold markers */}
|
||||
{THRESHOLDS.map((threshold) => (
|
||||
<div
|
||||
className="absolute top-1/2 transition-all duration-700"
|
||||
style={{ left: `${clampedPercent}%`, transform: 'translate(-50%, -50%)' }}
|
||||
key={threshold}
|
||||
className="absolute bottom-0 top-0"
|
||||
style={{
|
||||
left: `${threshold}%`,
|
||||
width: 1,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{
|
||||
background: zone.gradientTo,
|
||||
boxShadow: `0 0 8px ${zone.glowColor}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Threshold marker lines */}
|
||||
{showThresholds &&
|
||||
THRESHOLDS.map((threshold) => (
|
||||
<div
|
||||
key={threshold}
|
||||
className="absolute inset-y-0 w-px bg-dark-500/40"
|
||||
style={{ left: `${threshold}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
{/* Glow at fill edge */}
|
||||
{clampedPercent > 2 && (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
top: -4,
|
||||
bottom: -4,
|
||||
left: `calc(${clampedPercent}% - 8px)`,
|
||||
width: 16,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${zone.mainHex}60, transparent)`,
|
||||
filter: 'blur(4px)',
|
||||
transition: 'left 1.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scale labels */}
|
||||
{showScale && limitGb > 0 && (
|
||||
{!compact && limitGb > 0 && (
|
||||
<div
|
||||
className="mt-1.5 flex justify-between font-mono text-2xs text-dark-500"
|
||||
className="mt-1.5 flex justify-between px-0.5 font-mono text-[9px] font-medium text-white/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>0</span>
|
||||
<span>{(limitGb * 0.25).toFixed(0)}</span>
|
||||
<span>{(limitGb * 0.5).toFixed(0)}</span>
|
||||
<span>{(limitGb * 0.75).toFixed(0)}</span>
|
||||
<span>
|
||||
{limitGb} {t('common.units.gb')}
|
||||
</span>
|
||||
{[0, 25, 50, 75, 100].map((v) => (
|
||||
<span key={v}>{formatTraffic((limitGb * v) / 100)}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router';
|
||||
import { UseMutationResult } from '@tanstack/react-query';
|
||||
import { HoverBorderGradient } from '../ui/hover-border-gradient';
|
||||
import type { TrialInfo } from '../../types';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
|
||||
@@ -13,40 +12,6 @@ interface TrialOfferCardProps {
|
||||
trialError: string | null;
|
||||
}
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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 BoltIcon = () => (
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function TrialOfferCard({
|
||||
trialInfo,
|
||||
balanceKopeks,
|
||||
@@ -60,62 +25,151 @@ export default function TrialOfferCard({
|
||||
const canAfford = balanceKopeks >= trialInfo.price_kopeks;
|
||||
|
||||
return (
|
||||
<div className="bento-card animate-trial-glow border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent">
|
||||
{/* Icon + Title */}
|
||||
<div className="mb-4 flex flex-col items-center text-center">
|
||||
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-accent-500/15 text-accent-400">
|
||||
{isFree ? <SparklesIcon /> : <BoltIcon />}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{isFree ? t('dashboard.trialOffer.freeTitle') : t('dashboard.trialOffer.paidTitle')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-dark-400">
|
||||
{isFree ? t('dashboard.trialOffer.freeDesc') : t('dashboard.trialOffer.paidDesc')}
|
||||
</p>
|
||||
<div
|
||||
className="relative overflow-hidden rounded-3xl text-center"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(145deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%)',
|
||||
border: '1px solid rgba(255,255,255,0.07)',
|
||||
padding: '32px 28px 28px',
|
||||
}}
|
||||
>
|
||||
{/* Animated glow background */}
|
||||
<div
|
||||
className="pointer-events-none absolute left-1/2 -translate-x-1/2"
|
||||
style={{
|
||||
top: -100,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: '50%',
|
||||
background: isFree
|
||||
? 'radial-gradient(circle, rgba(62,219,176,0.08) 0%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,184,0,0.07) 0%, transparent 70%)',
|
||||
transition: 'background 0.5s ease',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Grid pattern */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.025]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||
backgroundSize: '40px 40px',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="relative mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl"
|
||||
style={{
|
||||
background: isFree
|
||||
? 'linear-gradient(135deg, #1a3a30, #142824)'
|
||||
: 'linear-gradient(135deg, #3a3020, #282418)',
|
||||
border: isFree ? '1px solid rgba(62,219,176,0.2)' : '1px solid rgba(255,184,0,0.2)',
|
||||
transition: 'all 0.5s ease',
|
||||
}}
|
||||
>
|
||||
{isFree ? (
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#3EDBB0"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
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"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#FFB800"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{/* Glow effect */}
|
||||
<div
|
||||
className="absolute inset-[-1px] animate-trial-glow rounded-2xl"
|
||||
style={{
|
||||
boxShadow: isFree ? '0 0 20px rgba(62,219,176,0.15)' : '0 0 20px rgba(255,184,0,0.12)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="mb-1.5 text-[22px] font-bold tracking-tight text-white">
|
||||
{isFree ? t('dashboard.trialOffer.freeTitle') : t('dashboard.trialOffer.paidTitle')}
|
||||
</h2>
|
||||
<p className="mb-5 text-sm text-white/40">
|
||||
{isFree ? t('dashboard.trialOffer.freeDesc') : t('dashboard.trialOffer.paidDesc')}
|
||||
</p>
|
||||
|
||||
{/* Price tag for paid trial */}
|
||||
{!isFree && trialInfo.price_rubles > 0 && (
|
||||
<div className="mb-4 flex items-center justify-center gap-2">
|
||||
<span className="font-display text-2xl font-bold text-accent-400">
|
||||
{trialInfo.price_rubles.toFixed(0)} {currencySymbol}
|
||||
<div
|
||||
className="mb-5 inline-flex items-baseline gap-1 rounded-xl px-5 py-2"
|
||||
style={{
|
||||
background: 'rgba(255,184,0,0.08)',
|
||||
border: '1px solid rgba(255,184,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[32px] font-extrabold leading-none tracking-tight"
|
||||
style={{ color: '#FFB800' }}
|
||||
>
|
||||
{trialInfo.price_rubles.toFixed(0)}
|
||||
</span>
|
||||
<span className="text-base font-semibold opacity-70" style={{ color: '#FFB800' }}>
|
||||
{currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3-column stats */}
|
||||
<div className="mb-5 grid grid-cols-3 gap-4 rounded-xl bg-dark-800/40 p-4">
|
||||
<div className="text-center">
|
||||
<div className="font-display text-2xl font-bold text-accent-400">
|
||||
{trialInfo.duration_days}
|
||||
{/* Trial stats */}
|
||||
<div className="mb-7 flex justify-center gap-8">
|
||||
{[
|
||||
{ value: String(trialInfo.duration_days), label: t('subscription.trial.days') },
|
||||
{
|
||||
value: trialInfo.traffic_limit_gb === 0 ? '∞' : String(trialInfo.traffic_limit_gb),
|
||||
label: t('common.units.gb'),
|
||||
},
|
||||
{ value: String(trialInfo.device_limit), label: t('subscription.trial.devices') },
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div className="text-4xl font-extrabold leading-none tracking-tight text-white">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-medium text-white/30">{stat.label}</div>
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-xs uppercase tracking-wider text-dark-500">
|
||||
{t('subscription.trial.days')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-display text-2xl font-bold text-accent-400">
|
||||
{trialInfo.traffic_limit_gb || '∞'}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-xs uppercase tracking-wider text-dark-500">
|
||||
{t('common.units.gb')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-display text-2xl font-bold text-accent-400">
|
||||
{trialInfo.device_limit}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-xs uppercase tracking-wider text-dark-500">
|
||||
{t('subscription.trial.devices')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Balance info for paid trial */}
|
||||
{!isFree && trialInfo.price_rubles > 0 && (
|
||||
<div className="mb-4 space-y-2 rounded-xl bg-dark-800/50 p-4">
|
||||
<div
|
||||
className="mb-4 space-y-2 rounded-xl bg-white/[0.03] p-4 text-left"
|
||||
style={{ border: '1px solid rgba(255,255,255,0.04)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('balance.currentBalance')}</span>
|
||||
<span className="text-sm text-white/40">{t('balance.currentBalance')}</span>
|
||||
<span
|
||||
className={`font-display text-sm font-semibold ${canAfford ? 'text-success-400' : 'text-warning-400'}`}
|
||||
>
|
||||
@@ -140,30 +194,46 @@ export default function TrialOfferCard({
|
||||
{/* CTA Button */}
|
||||
{!isFree && trialInfo.price_kopeks > 0 ? (
|
||||
canAfford ? (
|
||||
<HoverBorderGradient
|
||||
<button
|
||||
onClick={() => !activateTrialMutation.isPending && activateTrialMutation.mutate()}
|
||||
containerClassName={`w-full ${activateTrialMutation.isPending ? 'opacity-50' : ''}`}
|
||||
className="flex w-full items-center justify-center"
|
||||
aria-disabled={activateTrialMutation.isPending}
|
||||
disabled={activateTrialMutation.isPending}
|
||||
className="w-full rounded-[14px] py-4 text-base font-bold tracking-tight transition-all duration-300 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #FFB800, #FF8C42)',
|
||||
color: '#1a1200',
|
||||
boxShadow: '0 4px 20px rgba(255,184,0,0.2)',
|
||||
}}
|
||||
>
|
||||
{activateTrialMutation.isPending
|
||||
? t('common.loading')
|
||||
: t('subscription.trial.payAndActivate')}
|
||||
</HoverBorderGradient>
|
||||
</button>
|
||||
) : (
|
||||
<Link to="/balance" className="btn-primary block w-full text-center">
|
||||
<Link
|
||||
to="/balance"
|
||||
className="block w-full rounded-[14px] py-4 text-center text-base font-bold tracking-tight transition-all duration-300"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #FFB800, #FF8C42)',
|
||||
color: '#1a1200',
|
||||
boxShadow: '0 4px 20px rgba(255,184,0,0.2)',
|
||||
}}
|
||||
>
|
||||
{t('subscription.trial.topUpToActivate')}
|
||||
</Link>
|
||||
)
|
||||
) : (
|
||||
<HoverBorderGradient
|
||||
<button
|
||||
onClick={() => !activateTrialMutation.isPending && activateTrialMutation.mutate()}
|
||||
containerClassName={`w-full ${activateTrialMutation.isPending ? 'opacity-50' : ''}`}
|
||||
className="flex w-full items-center justify-center"
|
||||
aria-disabled={activateTrialMutation.isPending}
|
||||
disabled={activateTrialMutation.isPending}
|
||||
className="w-full rounded-[14px] py-4 text-base font-bold tracking-tight text-white transition-all duration-300 disabled:opacity-50"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(62,219,176,0.12) 0%, rgba(62,219,176,0.04) 100%)',
|
||||
border: '1px solid rgba(62,219,176,0.25)',
|
||||
}}
|
||||
>
|
||||
{activateTrialMutation.isPending ? t('common.loading') : t('subscription.trial.activate')}
|
||||
</HoverBorderGradient>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function useAnimatedNumber(target: number, duration = 600): number {
|
||||
const [current, setCurrent] = useState(target);
|
||||
/**
|
||||
* Animates a number from its current value to a new target.
|
||||
* Uses easeOutExpo easing matching the prototype.
|
||||
*/
|
||||
export function useAnimatedNumber(target: number, duration = 1200): number {
|
||||
const [value, setValue] = useState(0);
|
||||
const ref = useRef({ start: 0, startTime: 0, target: 0 });
|
||||
const rafRef = useRef<number>(0);
|
||||
const startRef = useRef<number>(0);
|
||||
const fromRef = useRef<number>(target);
|
||||
const currentRef = useRef<number>(target);
|
||||
const prevTargetRef = useRef<number>(target);
|
||||
|
||||
// Keep currentRef in sync with state
|
||||
currentRef.current = current;
|
||||
|
||||
useEffect(() => {
|
||||
if (prevTargetRef.current === target) return;
|
||||
prevTargetRef.current = target;
|
||||
fromRef.current = currentRef.current;
|
||||
startRef.current = performance.now();
|
||||
ref.current.start = value;
|
||||
ref.current.target = target;
|
||||
ref.current.startTime = performance.now();
|
||||
|
||||
const animate = (now: number) => {
|
||||
const elapsed = now - startRef.current;
|
||||
const elapsed = now - ref.current.startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const value = fromRef.current + (target - fromRef.current) * eased;
|
||||
setCurrent(value);
|
||||
currentRef.current = value;
|
||||
|
||||
// easeOutExpo
|
||||
const ease = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
|
||||
const current = ref.current.start + (ref.current.target - ref.current.start) * ease;
|
||||
setValue(current);
|
||||
if (progress < 1) {
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
@@ -34,5 +30,5 @@ export function useAnimatedNumber(target: number, duration = 600): number {
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [target, duration]);
|
||||
|
||||
return current;
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -234,16 +234,27 @@
|
||||
},
|
||||
"connectDevice": "Connect Device",
|
||||
"devicesConnected": "{{count}} connected",
|
||||
"devicesOfMax": "{{used}} of {{max}} connected",
|
||||
"devicesShort": "dev.",
|
||||
"trafficUsage": "{{used}} / {{limit}} GB",
|
||||
"trafficUsageTitle": "Traffic Usage",
|
||||
"usedTraffic": "used {{amount}}",
|
||||
"usedSuffix": "used",
|
||||
"unlimited": "Unlimited",
|
||||
"unlimitedTraffic": "Unlimited traffic",
|
||||
"tariff": "Tariff",
|
||||
"validUntil": "until {{date}}",
|
||||
"remaining": "Remaining",
|
||||
"daysRemaining": "Days left",
|
||||
"usageLast14Days": "Usage last 14 days",
|
||||
"maxUsage": "max {{amount}}",
|
||||
"expired": {
|
||||
"title": "Subscription Expired",
|
||||
"trialTitle": "Trial Expired",
|
||||
"renew": "Renew",
|
||||
"tariffs": "View Tariffs",
|
||||
"trialSubtitle": "Trial period ended",
|
||||
"paidSubtitle": "Subscription has expired",
|
||||
"renew": "Renew Subscription",
|
||||
"tariffs": "Tariffs",
|
||||
"traffic": "Traffic",
|
||||
"devices": "Devices",
|
||||
"expiredDate": "Expired"
|
||||
@@ -295,6 +306,7 @@
|
||||
"buyDevices": "Buy Devices",
|
||||
"renewalOptions": "Renewal Options",
|
||||
"days": "days",
|
||||
"daysShort": "d.",
|
||||
"days_one": "{{count}} day",
|
||||
"days_other": "{{count}} days",
|
||||
"hours": "h",
|
||||
|
||||
@@ -246,15 +246,26 @@
|
||||
},
|
||||
"connectDevice": "Подключить устройство",
|
||||
"devicesConnected": "{{count}} подключено",
|
||||
"devicesOfMax": "{{used}} из {{max}} подключено",
|
||||
"devicesShort": "устр.",
|
||||
"trafficUsage": "{{used}} / {{limit}} ГБ",
|
||||
"trafficUsageTitle": "Расход трафика",
|
||||
"usedTraffic": "использовано {{amount}}",
|
||||
"usedSuffix": "израсходовано",
|
||||
"unlimited": "Безлимит",
|
||||
"unlimitedTraffic": "Безлимитный трафик",
|
||||
"tariff": "Тариф",
|
||||
"validUntil": "до {{date}}",
|
||||
"remaining": "Осталось",
|
||||
"daysRemaining": "Дней осталось",
|
||||
"usageLast14Days": "Расход за 14 дней",
|
||||
"maxUsage": "макс {{amount}}",
|
||||
"expired": {
|
||||
"title": "Подписка истекла",
|
||||
"trialTitle": "Пробный период истёк",
|
||||
"renew": "Продлить",
|
||||
"trialSubtitle": "Пробный период завершён",
|
||||
"paidSubtitle": "Срок действия закончился",
|
||||
"renew": "Продлить подписку",
|
||||
"tariffs": "Тарифы",
|
||||
"traffic": "Трафик",
|
||||
"devices": "Устройства",
|
||||
@@ -310,6 +321,7 @@
|
||||
"buyDevices": "Докупить устройства",
|
||||
"renewalOptions": "Варианты продления",
|
||||
"days": "дней",
|
||||
"daysShort": "дн.",
|
||||
"days_one": "{{count}} день",
|
||||
"days_few": "{{count}} дня",
|
||||
"days_many": "{{count}} дней",
|
||||
|
||||
9
src/utils/formatTraffic.ts
Normal file
9
src/utils/formatTraffic.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Format traffic amount with appropriate unit (MB/GB/TB).
|
||||
* Matches the prototype's formatBytes logic.
|
||||
*/
|
||||
export function formatTraffic(gb: number): string {
|
||||
if (gb >= 1000) return `${(gb / 1000).toFixed(1)} TB`;
|
||||
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
||||
return `${(gb * 1024).toFixed(0)} MB`;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ interface TrafficZoneResult {
|
||||
labelKey: string;
|
||||
gradientFrom: string;
|
||||
gradientTo: string;
|
||||
/** Hex color for inline styles */
|
||||
mainHex: string;
|
||||
}
|
||||
|
||||
const ZONES: Record<TrafficZone, Omit<TrafficZoneResult, 'zone'>> = {
|
||||
@@ -18,6 +20,7 @@ const ZONES: Record<TrafficZone, Omit<TrafficZoneResult, 'zone'>> = {
|
||||
labelKey: 'dashboard.zone.normal',
|
||||
gradientFrom: 'rgb(var(--color-success-500))',
|
||||
gradientTo: 'rgb(var(--color-success-400))',
|
||||
mainHex: '#00E5A0',
|
||||
},
|
||||
warning: {
|
||||
textClass: 'text-warning-400',
|
||||
@@ -26,6 +29,7 @@ const ZONES: Record<TrafficZone, Omit<TrafficZoneResult, 'zone'>> = {
|
||||
labelKey: 'dashboard.zone.warning',
|
||||
gradientFrom: 'rgb(var(--color-warning-500))',
|
||||
gradientTo: 'rgb(var(--color-warning-400))',
|
||||
mainHex: '#FFB800',
|
||||
},
|
||||
danger: {
|
||||
textClass: 'text-warning-300',
|
||||
@@ -34,6 +38,7 @@ const ZONES: Record<TrafficZone, Omit<TrafficZoneResult, 'zone'>> = {
|
||||
labelKey: 'dashboard.zone.danger',
|
||||
gradientFrom: 'rgb(var(--color-warning-600))',
|
||||
gradientTo: 'rgb(var(--color-warning-400))',
|
||||
mainHex: '#FF6B35',
|
||||
},
|
||||
critical: {
|
||||
textClass: 'text-error-400',
|
||||
@@ -42,6 +47,7 @@ const ZONES: Record<TrafficZone, Omit<TrafficZoneResult, 'zone'>> = {
|
||||
labelKey: 'dashboard.zone.critical',
|
||||
gradientFrom: 'rgb(var(--color-error-500))',
|
||||
gradientTo: 'rgb(var(--color-error-400))',
|
||||
mainHex: '#FF3B5C',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user