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,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>
);