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