diff --git a/src/components/dashboard/Sparkline.tsx b/src/components/dashboard/Sparkline.tsx
index e85e948..54da5b7 100644
--- a/src/components/dashboard/Sparkline.tsx
+++ b/src/components/dashboard/Sparkline.tsx
@@ -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 (
);
}
diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx
index 5e81413..d176595 100644
--- a/src/components/dashboard/StatsGrid.tsx
+++ b/src/components/dashboard/StatsGrid.tsx
@@ -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 = () => (
-);
-
-const WalletIcon = () => (
-
-);
-
-const CalendarIcon = () => (
-
-);
-
-const UsersIcon = () => (
-
-);
-
-const CoinIcon = () => (
-
);
@@ -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) => (
+
+ ),
+ 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) => (
+
+ ),
+ 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) => (
+
+ ),
+ 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) => (
+
+ ),
+ iconBg: `${zone.mainHex}12`,
+ iconColor: zone.mainHex,
+ loading: refLoading,
+ },
+ ];
+
return (
-
- {/* Balance */}
-
-
-
-
+
+ {cards.map((card, i) => (
+
+ {/* Top row: icon + label + arrow */}
+
+
+
+ {card.icon(card.iconColor)}
+
+
{card.label}
+
+
-
-
-
-
-
- {t('dashboard.stats.balance')}
-
-
- {formatAmount(balanceRubles)}
- {currencySymbol}
-
-
- {/* Subscription Days */}
-
-
-
- {t('dashboard.stats.subscription')}
-
- {subLoading ? (
-
- ) : subscription ? (
-
- {subscription.days_left > 0 ? (
- <>
- {subscription.days_left}
- {t('subscription.days')}
- >
- ) : subscription.hours_left > 0 ? (
- <>
- {subscription.hours_left}
- {t('subscription.hours')}
- >
- ) : (
- {t('subscription.expired')}
- )}
-
- ) : (
-
- {t('subscription.inactive')}
-
- )}
-
-
- {/* Referrals */}
-
-
-
- {t('dashboard.stats.referrals')}
-
- {refLoading ? (
-
- ) : (
-
{referralCount}
- )}
-
-
- {/* Earnings */}
-
-
-
- {t('dashboard.stats.earnings')}
-
- {refLoading ? (
-
- ) : (
-
- {formatPositive(earningsRubles)}
-
- )}
-
+ {/* Value */}
+ {card.loading ? (
+
+ ) : (
+
+ {card.value}
+ {card.valueSuffix && (
+
+ {card.valueSuffix}
+
+ )}
+
+ )}
+
+ ))}
);
}
diff --git a/src/components/dashboard/SubscriptionCardActive.tsx b/src/components/dashboard/SubscriptionCardActive.tsx
index 0e02eb9..b306621 100644
--- a/src/components/dashboard/SubscriptionCardActive.tsx
+++ b/src/components/dashboard/SubscriptionCardActive.tsx
@@ -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 }) => (
);
-const DeviceIcon = () => (
-
-);
-
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 (
- {/* Top row: zone indicator + tariff info */}
-
-
- {/* Animated zone dot */}
-
-
+ )}
+
+ {/* Background glow */}
+
+
+ {/* ─── Header ─── */}
+
+
+ {/* Zone indicator */}
+
+
-
-
-
- {isUnlimited ? t('dashboard.unlimited') : t(zone.labelKey)}
+
+ {isUnlimited ? t('dashboard.unlimited') : t(zone.labelKey)}
+
+ {subscription.is_trial && (
+
+
+ {t('subscription.trialStatus')}
+
+ )}
+
+
+ {/* Title */}
+
+ {t('dashboard.trafficUsageTitle')}
+
+
+ {/* Tariff info line */}
+
+ {subscription.tariff_name && (
+ <>
+ {t('dashboard.tariff')} {subscription.tariff_name} ·{' '}
+ >
+ )}
+ {t('dashboard.validUntil', { date: formattedDate })} · {subscription.device_limit}{' '}
+ {t('dashboard.devicesShort')}
-
+ {isUnlimited ? (
+ <>
+
+ ∞
+
+
+ {formatTraffic(usedGb)} {t('dashboard.usedSuffix')}
+
+ >
+ ) : (
+ <>
+
+ {animatedPercent.toFixed(0)}
+ %
+
+
+ {formatTraffic(usedGb)} / {formatTraffic(subscription.traffic_limit_gb)}
+
+ >
+ )}
+
+
+
+ {/* ─── Progress Bar ─── */}
+
+
+
+
+ {/* ─── Connect Device Button ─── */}
+ {subscription.subscription_url && (
+
+ )}
+
+ {/* ─── Stats row: Tariff + Days Left ─── */}
+
+ {/* Tariff badge */}
+
+
+ {t('dashboard.tariff')}
+
+
+ {subscription.tariff_name || t('subscription.currentPlan')}
+
+
+ {t('dashboard.validUntil', { date: formattedDate })}
+
+
+
+ {/* Days remaining */}
+
+
+
+ {t('dashboard.remaining')}
+
+
+
+ {daysLeft}
+
+ {t('subscription.daysShort')}
+
+
- {/* Tariff info line */}
-
- {subscription.tariff_name && (
- <>
- {subscription.tariff_name}
- ·
- >
- )}
- {t('dashboard.validUntil', { date: formattedDate })}
- ·
-
- {subscription.device_limit} {t('subscription.devices')}
-
-
-
- {/* Big percentage or infinity */}
-
- {isUnlimited ? (
- ∞
- ) : (
-
- {animatedPercent.toFixed(1)}
- %
-
- )}
-
-
- {/* Traffic used line with refresh */}
-
-
- {isUnlimited
- ? `${usedGb.toFixed(1)} ${t('common.units.gb')}`
- : `${usedGb.toFixed(1)} / ${subscription.traffic_limit_gb} ${t('common.units.gb')}`}
-
+ {/* ─── Traffic Refresh ─── */}
+
-
-
- {/* Progress bar */}
-
0}
- showThresholds={!isUnlimited}
- />
-
- {/* Connect device button */}
- {subscription.subscription_url && (
-
- navigate('/connection')}
- containerClassName="w-full"
- className="flex w-full items-center justify-center gap-3 py-3"
- data-onboarding="connect-devices"
- >
-
- {t('dashboard.connectDevice')}
-
-
- )}
-
- {/* Bottom link */}
-
{t('dashboard.viewSubscription')} →
+
+ {/* ─── Sparkline ─── */}
+ {dailyUsage.length >= 2 && (
+
+
+
+ {t('dashboard.usageLast14Days')}
+
+
+ {t('dashboard.maxUsage', { amount: formatTraffic(Math.max(...dailyUsage)) })}
+
+
+
+
+ )}
);
}
diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx
index c5cdad7..ec1bc8b 100644
--- a/src/components/dashboard/SubscriptionCardExpired.tsx
+++ b/src/components/dashboard/SubscriptionCardExpired.tsx
@@ -6,79 +6,152 @@ interface SubscriptionCardExpiredProps {
subscription: Subscription;
}
-const ClockIcon = () => (
-
-);
-
export default function SubscriptionCardExpired({ subscription }: SubscriptionCardExpiredProps) {
const { t } = useTranslation();
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
return (
-
+
+ {/* Red glow */}
+
+ {/* Grid pattern */}
+
+
{/* Header */}
-
-
-
+
+
+ {/* Clock icon */}
+
+
+
+ {t('dashboard.expired.title')}
+
+
+ {subscription.is_trial
+ ? t('dashboard.expired.trialSubtitle')
+ : t('dashboard.expired.paidSubtitle')}
+
+
-
-
- {subscription.is_trial
- ? t('dashboard.expired.trialTitle')
- : t('dashboard.expired.title')}
-
+
+ {/* Badge */}
+
+ {subscription.is_trial && (
+
+ )}
+ {subscription.is_trial ? t('subscription.trialStatus') : t('subscription.expired')}
-
{t('subscription.expired')}
- {/* 3-column info */}
-
-
-
0
-
- {t('dashboard.expired.traffic')}
+ {/* Expired info grid */}
+
+ {[
+ { 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) => (
+
+
+ {item.label}
+
+
{item.value}
-
-
-
0
-
- {t('dashboard.expired.devices')}
-
-
-
-
{formattedDate}
-
- {t('dashboard.expired.expiredDate')}
-
-
+ ))}
{/* Action buttons */}
-
+
{t('dashboard.expired.renew')}
{t('dashboard.expired.tariffs')}
diff --git a/src/components/dashboard/TrafficProgressBar.tsx b/src/components/dashboard/TrafficProgressBar.tsx
index 073dafc..b291f32 100644
--- a/src/components/dashboard/TrafficProgressBar.tsx
+++ b/src/components/dashboard/TrafficProgressBar.tsx
@@ -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 (
-
-
+
+ {/* Unlimited flowing bar */}
+
+
+ {/* Top highlight */}
+
+
+ {/* Below bar: label + usage */}
{!compact && (
-
-
+
+
+
+ {t('dashboard.unlimitedTraffic')}
+
+
+ {t('dashboard.usedTraffic', { amount: formatTraffic(usedGb) })}
+
)}
@@ -51,105 +101,110 @@ export default function TrafficProgressBar({
return (
- {/* Track with zone tint backgrounds */}
-
- {/* Zone tint backgrounds */}
- {showThresholds && (
- <>
-
-
-
- >
- )}
+ {/* Track */}
+
+ {/* Warning zone tint backgrounds */}
+
{/* Fill bar */}
+ {/* Gradient fill */}
+
{/* Shimmer overlay */}
-
+
+ {/* Top highlight */}
+
- {/* Glow dot at fill edge */}
- {clampedPercent > 2 && !compact && (
+ {/* Threshold markers */}
+ {THRESHOLDS.map((threshold) => (
- )}
+ />
+ ))}
- {/* Threshold marker lines */}
- {showThresholds &&
- THRESHOLDS.map((threshold) => (
-
- ))}
+ {/* Glow at fill edge */}
+ {clampedPercent > 2 && (
+
+ )}
{/* Scale labels */}
- {showScale && limitGb > 0 && (
+ {!compact && limitGb > 0 && (
- 0
- {(limitGb * 0.25).toFixed(0)}
- {(limitGb * 0.5).toFixed(0)}
- {(limitGb * 0.75).toFixed(0)}
-
- {limitGb} {t('common.units.gb')}
-
+ {[0, 25, 50, 75, 100].map((v) => (
+ {formatTraffic((limitGb * v) / 100)}
+ ))}
)}
diff --git a/src/components/dashboard/TrialOfferCard.tsx b/src/components/dashboard/TrialOfferCard.tsx
index 519fc63..995bd09 100644
--- a/src/components/dashboard/TrialOfferCard.tsx
+++ b/src/components/dashboard/TrialOfferCard.tsx
@@ -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 = () => (
-
-);
-
-const BoltIcon = () => (
-
-);
-
export default function TrialOfferCard({
trialInfo,
balanceKopeks,
@@ -60,62 +25,151 @@ export default function TrialOfferCard({
const canAfford = balanceKopeks >= trialInfo.price_kopeks;
return (
-
- {/* Icon + Title */}
-
-
- {isFree ? : }
-
-
- {isFree ? t('dashboard.trialOffer.freeTitle') : t('dashboard.trialOffer.paidTitle')}
-
-
- {isFree ? t('dashboard.trialOffer.freeDesc') : t('dashboard.trialOffer.paidDesc')}
-
+
+ {/* Animated glow background */}
+
+ {/* Grid pattern */}
+
+
+ {/* Icon */}
+
+ {isFree ? (
+
+ ) : (
+
+ )}
+ {/* Glow effect */}
+
+ {/* Title */}
+
+ {isFree ? t('dashboard.trialOffer.freeTitle') : t('dashboard.trialOffer.paidTitle')}
+
+
+ {isFree ? t('dashboard.trialOffer.freeDesc') : t('dashboard.trialOffer.paidDesc')}
+
+
{/* Price tag for paid trial */}
{!isFree && trialInfo.price_rubles > 0 && (
-
-
- {trialInfo.price_rubles.toFixed(0)} {currencySymbol}
+
+
+ {trialInfo.price_rubles.toFixed(0)}
+
+
+ {currencySymbol}
)}
- {/* 3-column stats */}
-
-
-
- {trialInfo.duration_days}
+ {/* Trial stats */}
+
+ {[
+ { 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) => (
+
+
+ {stat.value}
+
+
{stat.label}
-
- {t('subscription.trial.days')}
-
-
-
-
- {trialInfo.traffic_limit_gb || '∞'}
-
-
- {t('common.units.gb')}
-
-
-
-
- {trialInfo.device_limit}
-
-
- {t('subscription.trial.devices')}
-
-
+ ))}
{/* Balance info for paid trial */}
{!isFree && trialInfo.price_rubles > 0 && (
-
+
- {t('balance.currentBalance')}
+ {t('balance.currentBalance')}
@@ -140,30 +194,46 @@ export default function TrialOfferCard({
{/* CTA Button */}
{!isFree && trialInfo.price_kopeks > 0 ? (
canAfford ? (
- !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')}
-
+
) : (
-
+
{t('subscription.trial.topUpToActivate')}
)
) : (
- !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')}
-
+
)}
);
diff --git a/src/hooks/useAnimatedNumber.ts b/src/hooks/useAnimatedNumber.ts
index fba5aba..2797787 100644
--- a/src/hooks/useAnimatedNumber.ts
+++ b/src/hooks/useAnimatedNumber.ts
@@ -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
(0);
- const startRef = useRef(0);
- const fromRef = useRef(target);
- const currentRef = useRef(target);
- const prevTargetRef = useRef(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;
}
diff --git a/src/locales/en.json b/src/locales/en.json
index c4fb3cc..8c78448 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -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",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 1526c70..4a1bbc9 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -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}} дней",
diff --git a/src/utils/formatTraffic.ts b/src/utils/formatTraffic.ts
new file mode 100644
index 0000000..34fd6a4
--- /dev/null
+++ b/src/utils/formatTraffic.ts
@@ -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`;
+}
diff --git a/src/utils/trafficZone.ts b/src/utils/trafficZone.ts
index fc8c27b..d1855d6 100644
--- a/src/utils/trafficZone.ts
+++ b/src/utils/trafficZone.ts
@@ -8,6 +8,8 @@ interface TrafficZoneResult {
labelKey: string;
gradientFrom: string;
gradientTo: string;
+ /** Hex color for inline styles */
+ mainHex: string;
}
const ZONES: Record> = {
@@ -18,6 +20,7 @@ const ZONES: Record> = {
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> = {
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> = {
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> = {
labelKey: 'dashboard.zone.critical',
gradientFrom: 'rgb(var(--color-error-500))',
gradientTo: 'rgb(var(--color-error-400))',
+ mainHex: '#FF3B5C',
},
};
diff --git a/tailwind.config.js b/tailwind.config.js
index 027d948..834c809 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -177,9 +177,9 @@ export default {
'spotlight-ace': 'spotlightAce 2s ease 0.75s 1 forwards',
// Dashboard traffic animations
'traffic-shimmer': 'trafficShimmer 2s ease-in-out infinite',
- 'unlimited-flow': 'unlimitedFlow 3s linear infinite',
+ 'unlimited-flow': 'unlimitedFlow 3s ease-in-out infinite',
'unlimited-pulse': 'unlimitedPulse 2s ease-in-out infinite',
- 'trial-glow': 'trialGlow 2s ease-in-out infinite',
+ 'trial-glow': 'trialGlow 3s ease-in-out infinite',
},
keyframes: {
fadeIn: {
@@ -265,15 +265,16 @@ export default {
},
unlimitedFlow: {
'0%': { backgroundPosition: '0% 50%' },
- '100%': { backgroundPosition: '200% 50%' },
+ '50%': { backgroundPosition: '100% 50%' },
+ '100%': { backgroundPosition: '0% 50%' },
},
unlimitedPulse: {
- '0%, 100%': { opacity: '0.6', transform: 'scale(1)' },
- '50%': { opacity: '1', transform: 'scale(1.3)' },
+ '0%, 100%': { opacity: '1', transform: 'scale(1)' },
+ '50%': { opacity: '0.5', transform: 'scale(0.7)' },
},
trialGlow: {
- '0%, 100%': { boxShadow: '0 0 15px rgba(var(--color-warning-500), 0.2)' },
- '50%': { boxShadow: '0 0 30px rgba(var(--color-warning-500), 0.4)' },
+ '0%, 100%': { boxShadow: '0 0 15px rgba(62, 219, 176, 0.06)' },
+ '50%': { boxShadow: '0 0 30px rgba(62, 219, 176, 0.12)' },
},
},
transitionTimingFunction: {