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:
Fringg
2026-02-25 10:22:50 +03:00
parent 78fe3c48eb
commit 6b688ad451
12 changed files with 967 additions and 555 deletions

View File

@@ -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>
);
}