refactor(cabinet): unify all statistics onto the shared StatCard

Bring every stat strip across the admin panel and user cabinet onto the
canonical StatCard (icon chip + label-on-top + tone-coloured value),
matching the sales/Remnawave style the rest of the app already used.

- Convert ~20 hand-rolled stat strips across 21 pages/components
- Remove 4 divergent local StatCard components (Dashboard, Users,
  BanSystem, Payments) and migrate Remnawave's local StatCard (33 cards)
- Extend StatCard with optional subValue, loading skeleton, trailing slot,
  and icon-size normalisation (all backward-compatible)
- StatsGrid nav tiles now use StatCard while keeping link/chevron/loading
- Preserve all data, i18n keys, conditionals and interactive filters;
  fix the dead 'info' colour by mapping it to accent
This commit is contained in:
c0mrade
2026-06-06 21:27:57 +03:00
parent 9fc681b56d
commit 3b48abbb7b
23 changed files with 863 additions and 973 deletions

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { SendIcon } from '@/components/icons';
import { GiftIcon, SendIcon } from '@/components/icons';
import { StatCard } from '@/components/stats';
import { useCurrency } from '../../../hooks/useCurrency';
import type { AdminUserGiftItem, AdminUserGiftsResponse } from '../../../api/adminUsers';
@@ -208,18 +209,18 @@ export function GiftsTab({ giftsLoading, giftsData, locale, onNavigateToUser }:
<div className="space-y-6">
{/* Summary counters */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.gifts.totalSent')}
</div>
<div className="text-2xl font-bold text-accent-400">{giftsData.sent_total}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.gifts.totalReceived')}
</div>
<div className="text-2xl font-bold text-success-400">{giftsData.received_total}</div>
</div>
<StatCard
label={t('admin.users.detail.gifts.totalSent')}
value={giftsData.sent_total}
icon={<GiftIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.users.detail.gifts.totalReceived')}
value={giftsData.received_total}
icon={<GiftIcon className="h-5 w-5" />}
tone="success"
/>
</div>
{/* Sent Gifts */}

View File

@@ -5,7 +5,8 @@ import { useNavigate } from 'react-router';
import { useCurrency } from '../../../hooks/useCurrency';
import { useNotify } from '../../../platform/hooks/useNotify';
import { adminUsersApi, type UserDetailResponse, type UserListItem } from '../../../api/adminUsers';
import { XIcon } from '@/components/icons';
import { StatCard } from '@/components/stats';
import { BanknotesIcon, PercentIcon, TagIcon, UsersIcon, XIcon } from '@/components/icons';
// ──────────────────────────────────────────────────────────────────
// Referrals tab — top-of-graph referrer + stats + referrals list,
@@ -327,40 +328,35 @@ export function ReferralsTab({ user, userId, onUserRefresh }: ReferralsTabProps)
{/* Section 2: Referral stats */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.totalReferrals')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{user.referral.referrals_count}
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.totalEarnings')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.commission')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{user.referral.commission_percent != null
<StatCard
label={t('admin.users.detail.referrals.totalReferrals')}
value={user.referral.referrals_count}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.users.detail.referrals.totalEarnings')}
value={formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.users.detail.referrals.commission')}
value={
user.referral.commission_percent != null
? `${user.referral.commission_percent}%`
: t('admin.users.detail.referrals.default')}
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.referralCode')}
</div>
<div className="mt-1 truncate font-mono text-sm text-dark-100">
{user.referral.referral_code}
</div>
</div>
: t('admin.users.detail.referrals.default')
}
icon={<PercentIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.users.detail.referrals.referralCode')}
value={user.referral.referral_code}
icon={<TagIcon className="h-5 w-5" />}
tone="neutral"
valueClassName="font-mono"
/>
</div>
{/* Section 3: Referrals list */}

View File

@@ -1,9 +1,8 @@
import { PiCaretRight } from 'react-icons/pi';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import { useCurrency } from '../../hooks/useCurrency';
import { useTheme } from '../../hooks/useTheme';
import { getGlassColors } from '../../utils/glassTheme';
import { StatCard } from '@/components/stats';
import { CardIcon, ChevronRightIcon, UsersIcon } from '@/components/icons';
interface StatsGridProps {
balanceRubles: number;
@@ -12,10 +11,6 @@ interface StatsGridProps {
refLoading: boolean;
}
const ChevronIcon = ({ color }: { color: string }) => (
<PiCaretRight width={16} height={16} style={{ flexShrink: 0, color }} aria-hidden="true" />
);
export default function StatsGrid({
balanceRubles,
referralCount,
@@ -24,122 +19,31 @@ export default function StatsGrid({
}: StatsGridProps) {
const { t } = useTranslation();
const { formatAmount, currencySymbol } = useCurrency();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const accentColor = 'rgb(var(--color-accent-400))';
const accentBg = 'rgba(var(--color-accent-400), 0.07)';
const cards = [
{
label: t('dashboard.stats.balance'),
value: `${formatAmount(balanceRubles)} ${currencySymbol}`,
valueColor: accentColor,
to: '/balance',
icon: (color: string) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2" y="6" width="20" height="14" rx="2" />
<path d="M2 10h20" />
<path d="M6 14h.01M10 14h.01" />
</svg>
),
iconBg: accentBg,
iconColor: accentColor,
loading: false,
onboarding: 'balance',
},
{
label: t('dashboard.stats.referrals'),
value: `${referralCount}`,
valueColor: g.text,
subtitle: `+${formatAmount(earningsRubles)} ${currencySymbol}`,
subtitleColor: accentColor,
to: '/referral',
icon: (color: string) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
),
iconBg: g.trackBg,
iconColor: g.textSecondary,
loading: refLoading,
},
];
const chevron = <ChevronRightIcon className="h-4 w-4 shrink-0 text-dark-500" />;
return (
<div className="grid grid-cols-2 gap-2.5">
{cards.map((card, i) => (
<Link
key={i}
to={card.to}
className="group relative overflow-hidden rounded-[18px] transition-all duration-200"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '18px 20px 20px',
}}
data-onboarding={card.onboarding}
>
{/* Top row: icon + label + arrow */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-[9px] transition-colors duration-500"
style={{ background: card.iconBg }}
>
{card.icon(card.iconColor)}
</div>
<span className="text-[13px] font-medium text-dark-50/45">{card.label}</span>
</div>
<ChevronIcon color={g.textFaint} />
</div>
{/* Value */}
{card.loading ? (
<div className="skeleton h-8 w-20" />
) : (
<>
<div
className="text-[28px] font-bold leading-tight tracking-tight transition-colors duration-500"
style={{ color: card.valueColor }}
>
{card.value}
</div>
{card.subtitle && (
<div
className="mt-0.5 text-[13px] font-semibold"
style={{ color: card.subtitleColor }}
>
{card.subtitle}
</div>
)}
</>
)}
</Link>
))}
<Link to="/balance" className="block" data-onboarding="balance">
<StatCard
label={t('dashboard.stats.balance')}
value={`${formatAmount(balanceRubles)} ${currencySymbol}`}
icon={<CardIcon className="h-5 w-5" />}
tone="accent"
trailing={chevron}
/>
</Link>
<Link to="/referral" className="block">
<StatCard
label={t('dashboard.stats.referrals')}
value={`${referralCount}`}
subValue={`+${formatAmount(earningsRubles)} ${currencySymbol}`}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
loading={refLoading}
trailing={chevron}
/>
</Link>
</div>
);
}

View File

@@ -24,6 +24,12 @@ interface StatCardProps {
/** Tints the icon chip and (unless valueClassName is set) the value colour. */
tone?: keyof typeof TONE;
valueClassName?: string;
/** Optional secondary line shown under the value (e.g. a subtitle or context). */
subValue?: string;
/** When true, shows a skeleton placeholder instead of the value. */
loading?: boolean;
/** Optional node rendered at the right edge of the label row (e.g. a chevron for nav cards). */
trailing?: ReactNode;
/** Optional period-over-period change shown under the value. */
delta?: StatCardDelta | null;
}
@@ -34,6 +40,9 @@ export function StatCard({
icon,
tone = 'neutral',
valueClassName,
subValue,
loading,
trailing,
delta,
}: StatCardProps) {
const toneStyle = TONE[tone];
@@ -42,19 +51,32 @@ export function StatCard({
return (
<div className="rounded-xl bg-dark-800/30 p-3 transition-colors hover:bg-dark-800/50">
<div className="truncate text-xs text-dark-500 sm:text-sm">{label}</div>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs text-dark-500 sm:text-sm">{label}</span>
{trailing}
</div>
{/* Chip is centred against the value line only (delta sits below the whole
row), so the icon lands in the same spot on every card. */}
row), so the icon lands in the same spot on every card. The forced svg
size normalises every icon regardless of what the call site passes. */}
<div className="mt-1.5 flex items-center gap-2.5">
{icon && (
<span
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${toneStyle.chip}`}
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg [&>svg]:h-5 [&>svg]:w-5 ${toneStyle.chip}`}
>
{icon}
</span>
)}
<div className={`min-w-0 flex-1 truncate text-lg font-semibold sm:text-xl ${valueClass}`}>
{value}
<div className="min-w-0 flex-1">
{loading ? (
<div className="skeleton h-7 w-20 rounded" />
) : (
<>
<div className={`truncate text-lg font-semibold sm:text-xl ${valueClass}`}>
{value}
</div>
{subValue && <div className="truncate text-xs text-dark-500">{subValue}</div>}
</>
)}
</div>
</div>
{trendStyle && (