From 909374d369589474623ee006779586fadddd485b Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 10:07:57 +0300 Subject: [PATCH] feat: add dashboard sub-components for subscription cards and stats grid SubscriptionCardActive with animated zone dot, traffic bar, connect button. SubscriptionCardExpired with red theme and renewal actions. TrialOfferCard with stats grid and free/paid activation. StatsGrid 2x2 with font-display values and font-mono labels. --- src/components/dashboard/StatsGrid.tsx | 213 ++++++++++++++++++ .../dashboard/SubscriptionCardActive.tsx | 191 ++++++++++++++++ .../dashboard/SubscriptionCardExpired.tsx | 88 ++++++++ src/components/dashboard/TrialOfferCard.tsx | 170 ++++++++++++++ 4 files changed, 662 insertions(+) create mode 100644 src/components/dashboard/StatsGrid.tsx create mode 100644 src/components/dashboard/SubscriptionCardActive.tsx create mode 100644 src/components/dashboard/SubscriptionCardExpired.tsx create mode 100644 src/components/dashboard/TrialOfferCard.tsx diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx new file mode 100644 index 0000000..5e81413 --- /dev/null +++ b/src/components/dashboard/StatsGrid.tsx @@ -0,0 +1,213 @@ +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router'; +import type { Subscription } from '../../types'; +import { useCurrency } from '../../hooks/useCurrency'; + +interface StatsGridProps { + balanceRubles: number; + subscription: Subscription | null; + subLoading: boolean; + referralCount: number; + earningsRubles: number; + refLoading: boolean; +} + +const ArrowRightIcon = () => ( + +); + +const WalletIcon = () => ( + +); + +const CalendarIcon = () => ( + +); + +const UsersIcon = () => ( + +); + +const CoinIcon = () => ( + +); + +export default function StatsGrid({ + balanceRubles, + subscription, + subLoading, + referralCount, + earningsRubles, + refLoading, +}: StatsGridProps) { + const { t } = useTranslation(); + const { formatAmount, currencySymbol, formatPositive } = useCurrency(); + + return ( +
+ {/* Balance */} + +
+
+ +
+ + + +
+
+ {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)} +
+ )} + +
+ ); +} diff --git a/src/components/dashboard/SubscriptionCardActive.tsx b/src/components/dashboard/SubscriptionCardActive.tsx new file mode 100644 index 0000000..0e02eb9 --- /dev/null +++ b/src/components/dashboard/SubscriptionCardActive.tsx @@ -0,0 +1,191 @@ +import { useTranslation } from 'react-i18next'; +import { Link, useNavigate } from 'react-router'; +import { UseMutationResult } from '@tanstack/react-query'; +import { HoverBorderGradient } from '../ui/hover-border-gradient'; +import TrafficProgressBar from './TrafficProgressBar'; +import { useAnimatedNumber } from '../../hooks/useAnimatedNumber'; +import { getTrafficZone } from '../../utils/trafficZone'; +import type { Subscription } from '../../types'; + +interface SubscriptionCardActiveProps { + subscription: Subscription; + trafficData: { + traffic_used_gb: number; + traffic_used_percent: number; + is_unlimited: boolean; + } | null; + refreshTrafficMutation: UseMutationResult; + trafficRefreshCooldown: number; +} + +const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => ( + +); + +const DeviceIcon = () => ( + +); + +export default function SubscriptionCardActive({ + subscription, + trafficData, + refreshTrafficMutation, + trafficRefreshCooldown, +}: SubscriptionCardActiveProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + 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 animatedPercent = useAnimatedNumber(usedPercent); + + const formattedDate = new Date(subscription.end_date).toLocaleDateString(); + + return ( +
+ {/* Top row: zone indicator + tariff info */} +
+
+ {/* Animated zone dot */} +
+ + + {subscription.is_trial + ? t('subscription.trialStatus') + : subscription.is_active + ? t('subscription.active') + : t('subscription.expired')} + +
+ + {/* 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')}`} + + +
+ + {/* 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')} → + +
+
+ ); +} diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx new file mode 100644 index 0000000..c5cdad7 --- /dev/null +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -0,0 +1,88 @@ +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router'; +import type { Subscription } from '../../types'; + +interface SubscriptionCardExpiredProps { + subscription: Subscription; +} + +const ClockIcon = () => ( + +); + +export default function SubscriptionCardExpired({ subscription }: SubscriptionCardExpiredProps) { + const { t } = useTranslation(); + + const formattedDate = new Date(subscription.end_date).toLocaleDateString(); + + return ( +
+ {/* Header */} +
+
+ +
+
+

+ {subscription.is_trial + ? t('dashboard.expired.trialTitle') + : t('dashboard.expired.title')} +

+
+ {t('subscription.expired')} +
+ + {/* 3-column info */} +
+
+
0
+
+ {t('dashboard.expired.traffic')} +
+
+
+
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/TrialOfferCard.tsx b/src/components/dashboard/TrialOfferCard.tsx new file mode 100644 index 0000000..519fc63 --- /dev/null +++ b/src/components/dashboard/TrialOfferCard.tsx @@ -0,0 +1,170 @@ +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'; + +interface TrialOfferCardProps { + trialInfo: TrialInfo; + balanceKopeks: number; + balanceRubles: number; + activateTrialMutation: UseMutationResult; + trialError: string | null; +} + +const SparklesIcon = () => ( + +); + +const BoltIcon = () => ( + +); + +export default function TrialOfferCard({ + trialInfo, + balanceKopeks, + balanceRubles, + activateTrialMutation, + trialError, +}: TrialOfferCardProps) { + const { t } = useTranslation(); + const { formatAmount, currencySymbol } = useCurrency(); + const isFree = !trialInfo.requires_payment; + 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')} +

+
+ + {/* Price tag for paid trial */} + {!isFree && trialInfo.price_rubles > 0 && ( +
+ + {trialInfo.price_rubles.toFixed(0)} {currencySymbol} + +
+ )} + + {/* 3-column stats */} +
+
+
+ {trialInfo.duration_days} +
+
+ {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')} + + {formatAmount(balanceRubles)} {currencySymbol} + +
+ {!canAfford && ( +
+ {t('subscription.trial.insufficientBalance')} +
+ )} +
+ )} + + {/* Error */} + {trialError && ( +
+ {trialError} +
+ )} + + {/* 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} + > + {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} + > + {activateTrialMutation.isPending ? t('common.loading') : t('subscription.trial.activate')} + + )} +
+ ); +}