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.
This commit is contained in:
Fringg
2026-02-25 10:07:57 +03:00
parent eb1f788033
commit 909374d369
4 changed files with 662 additions and 0 deletions

View File

@@ -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 = () => (
<svg
className="h-4 w-4"
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}
aria-hidden="true"
>
<path
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>
);
export default function StatsGrid({
balanceRubles,
subscription,
subLoading,
referralCount,
earningsRubles,
refLoading,
}: StatsGridProps) {
const { t } = useTranslation();
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
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>
<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>
</div>
);
}

View File

@@ -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<unknown, unknown, void, unknown>;
trafficRefreshCooldown: number;
}
const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const DeviceIcon = () => (
<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="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
);
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 (
<div
className={`bento-card ${subscription.is_trial ? 'animate-trial-glow border-warning-500/30 bg-gradient-to-br from-warning-500/5 to-transparent' : ''}`}
>
{/* Top row: zone indicator + tariff info */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Animated zone dot */}
<span className="relative flex h-2.5 w-2.5" aria-hidden="true">
<span
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${zone.dotClass}`}
/>
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${zone.dotClass}`} />
</span>
<span className={`text-sm font-medium ${zone.textClass}`}>
{isUnlimited ? t('dashboard.unlimited') : t(zone.labelKey)}
</span>
</div>
<span
className={
subscription.is_trial
? 'badge-warning'
: subscription.is_active
? 'badge-success'
: 'badge-error'
}
>
{subscription.is_trial
? t('subscription.trialStatus')
: subscription.is_active
? t('subscription.active')
: t('subscription.expired')}
</span>
</div>
{/* Tariff info line */}
<div className="mb-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-dark-400">
{subscription.tariff_name && (
<>
<span className="text-accent-400">{subscription.tariff_name}</span>
<span className="text-dark-600">&middot;</span>
</>
)}
<span>{t('dashboard.validUntil', { date: formattedDate })}</span>
<span className="text-dark-600">&middot;</span>
<span>
{subscription.device_limit} {t('subscription.devices')}
</span>
</div>
{/* Big percentage or infinity */}
<div className="mb-1 flex items-end gap-3">
{isUnlimited ? (
<span className="font-display text-4xl font-bold text-accent-400">&#8734;</span>
) : (
<span className={`font-display text-4xl font-bold ${zone.textClass}`}>
{animatedPercent.toFixed(1)}
<span className="text-2xl text-dark-500">%</span>
</span>
)}
</div>
{/* Traffic used line with refresh */}
<div className="mb-3 flex items-center gap-2">
<span className="font-mono text-sm text-dark-400">
{isUnlimited
? `${usedGb.toFixed(1)} ${t('common.units.gb')}`
: `${usedGb.toFixed(1)} / ${subscription.traffic_limit_gb} ${t('common.units.gb')}`}
</span>
<button
onClick={() => refreshTrafficMutation.mutate()}
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
className="rounded-full p-1 text-dark-500 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t('common.refresh')}
title={trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')}
>
<RefreshIcon
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
/>
</button>
</div>
{/* Progress bar */}
<TrafficProgressBar
usedGb={usedGb}
limitGb={subscription.traffic_limit_gb}
percent={usedPercent}
isUnlimited={isUnlimited}
showScale={!isUnlimited && subscription.traffic_limit_gb > 0}
showThresholds={!isUnlimited}
/>
{/* Connect device button */}
{subscription.subscription_url && (
<div className="mt-5">
<HoverBorderGradient
onClick={() => navigate('/connection')}
containerClassName="w-full"
className="flex w-full items-center justify-center gap-3 py-3"
data-onboarding="connect-devices"
>
<DeviceIcon />
<span>{t('dashboard.connectDevice')}</span>
</HoverBorderGradient>
</div>
)}
{/* Bottom link */}
<div className="mt-5 flex items-center justify-end">
<Link
to="/subscription"
className="text-sm font-medium text-accent-400 transition-colors hover:text-accent-300"
>
{t('dashboard.viewSubscription')} &rarr;
</Link>
</div>
</div>
);
}

View File

@@ -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 = () => (
<svg
className="h-6 w-6 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
export default function SubscriptionCardExpired({ subscription }: SubscriptionCardExpiredProps) {
const { t } = useTranslation();
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
return (
<div className="bento-card border-error-500/30 bg-gradient-to-br from-error-500/5 to-transparent">
{/* Header */}
<div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/15">
<ClockIcon />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-dark-100">
{subscription.is_trial
? t('dashboard.expired.trialTitle')
: t('dashboard.expired.title')}
</h3>
</div>
<span className="badge-error">{t('subscription.expired')}</span>
</div>
{/* 3-column info */}
<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-dark-300">0</div>
<div className="mt-0.5 font-mono text-xs uppercase tracking-wider text-dark-500">
{t('dashboard.expired.traffic')}
</div>
</div>
<div className="text-center">
<div className="font-display text-2xl font-bold text-dark-300">0</div>
<div className="mt-0.5 font-mono text-xs uppercase tracking-wider text-dark-500">
{t('dashboard.expired.devices')}
</div>
</div>
<div className="text-center">
<div className="font-display text-sm font-bold text-dark-300">{formattedDate}</div>
<div className="mt-0.5 font-mono text-xs uppercase tracking-wider text-dark-500">
{t('dashboard.expired.expiredDate')}
</div>
</div>
</div>
{/* Action buttons */}
<div className="grid grid-cols-2 gap-3">
<Link
to="/subscription"
state={{ scrollToExtend: true }}
className="flex items-center justify-center rounded-xl bg-gradient-to-r from-error-600 to-error-500 py-2.5 text-center text-sm font-medium text-white transition-opacity hover:opacity-90"
>
{t('dashboard.expired.renew')}
</Link>
<Link
to="/subscription"
className="flex items-center justify-center rounded-xl border border-dark-600 py-2.5 text-center text-sm font-medium text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-800/50"
>
{t('dashboard.expired.tariffs')}
</Link>
</div>
</div>
);
}

View File

@@ -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<unknown, unknown, void, unknown>;
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,
balanceRubles,
activateTrialMutation,
trialError,
}: TrialOfferCardProps) {
const { t } = useTranslation();
const { formatAmount, currencySymbol } = useCurrency();
const isFree = !trialInfo.requires_payment;
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>
{/* 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}
</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}
</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="flex items-center justify-between">
<span className="text-sm text-dark-400">{t('balance.currentBalance')}</span>
<span
className={`font-display text-sm font-semibold ${canAfford ? 'text-success-400' : 'text-warning-400'}`}
>
{formatAmount(balanceRubles)} {currencySymbol}
</span>
</div>
{!canAfford && (
<div className="text-xs text-warning-400">
{t('subscription.trial.insufficientBalance')}
</div>
)}
</div>
)}
{/* Error */}
{trialError && (
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-center text-sm text-error-400">
{trialError}
</div>
)}
{/* CTA Button */}
{!isFree && trialInfo.price_kopeks > 0 ? (
canAfford ? (
<HoverBorderGradient
onClick={() => !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')}
</HoverBorderGradient>
) : (
<Link to="/balance" className="btn-primary block w-full text-center">
{t('subscription.trial.topUpToActivate')}
</Link>
)
) : (
<HoverBorderGradient
onClick={() => !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')}
</HoverBorderGradient>
)}
</div>
);
}