From dfc7768a403c9d8ff91a79cea8df9207b1855d8c Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 15:32:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(sales-stats):=20=D0=B4=D1=83=D1=88=D0=B5?= =?UTF-8?q?=D0=B2=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=81=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=20+=20=D1=84=D0=B8=D0=BA=D1=81=20=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D1=87=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatCard: иконка в мягком цветном чипе (tone success/accent/warning/…) в духе статистики Remnawave; дельта под значением. Обратно совместимо (valueClassName). - Карточки сводки получили осмысленные иконки (доход/подписки/триалы/конверсия/ продления/допы/пополнения) и цветовые тоны. - Скачок при переключении периодов убран: на все запросы статистики добавлен placeholderData: keepPreviousData — старые данные остаются на экране, пока грузятся новые, без вспышки скелетона и прыжка вёрстки. --- src/components/sales-stats/AddonsTab.tsx | 3 +- src/components/sales-stats/DepositsTab.tsx | 3 +- src/components/sales-stats/RenewalsTab.tsx | 3 +- src/components/sales-stats/SalesTab.tsx | 3 +- src/components/sales-stats/TrialsTab.tsx | 3 +- src/components/stats/StatCard.tsx | 50 ++++++++++++++-------- src/pages/AdminSalesStats.tsx | 37 ++++++++++++---- 7 files changed, 72 insertions(+), 30 deletions(-) diff --git a/src/components/sales-stats/AddonsTab.tsx b/src/components/sales-stats/AddonsTab.tsx index 0e6fbec..7decffb 100644 --- a/src/components/sales-stats/AddonsTab.tsx +++ b/src/components/sales-stats/AddonsTab.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; @@ -23,6 +23,7 @@ export function AddonsTab({ params }: AddonsTabProps) { queryKey: ['sales-stats', 'addons', params], queryFn: () => salesStatsApi.getAddons(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); const dailyChartData = useMemo(() => { diff --git a/src/components/sales-stats/DepositsTab.tsx b/src/components/sales-stats/DepositsTab.tsx index 8aa3003..60d544d 100644 --- a/src/components/sales-stats/DepositsTab.tsx +++ b/src/components/sales-stats/DepositsTab.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; @@ -27,6 +27,7 @@ export function DepositsTab({ params }: DepositsTabProps) { queryKey: ['sales-stats', 'deposits', params], queryFn: () => salesStatsApi.getDeposits(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]); diff --git a/src/components/sales-stats/RenewalsTab.tsx b/src/components/sales-stats/RenewalsTab.tsx index 185c798..d145ba0 100644 --- a/src/components/sales-stats/RenewalsTab.tsx +++ b/src/components/sales-stats/RenewalsTab.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; @@ -22,6 +22,7 @@ export function RenewalsTab({ params }: RenewalsTabProps) { queryKey: ['sales-stats', 'renewals', params], queryFn: () => salesStatsApi.getRenewals(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); if (isLoading) { diff --git a/src/components/sales-stats/SalesTab.tsx b/src/components/sales-stats/SalesTab.tsx index 58dc043..723b8f0 100644 --- a/src/components/sales-stats/SalesTab.tsx +++ b/src/components/sales-stats/SalesTab.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; @@ -25,6 +25,7 @@ export function SalesTab({ params }: SalesTabProps) { queryKey: ['sales-stats', 'sales', params], queryFn: () => salesStatsApi.getSales(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); const dailyByTariffData = useMemo( diff --git a/src/components/sales-stats/TrialsTab.tsx b/src/components/sales-stats/TrialsTab.tsx index 1cd09df..fc113da 100644 --- a/src/components/sales-stats/TrialsTab.tsx +++ b/src/components/sales-stats/TrialsTab.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; @@ -29,6 +29,7 @@ export function TrialsTab({ params }: TrialsTabProps) { queryKey: ['sales-stats', 'trials', params], queryFn: () => salesStatsApi.getTrials(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); if (isLoading) { diff --git a/src/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx index 2e9e4ce..2cf3359 100644 --- a/src/components/stats/StatCard.tsx +++ b/src/components/stats/StatCard.tsx @@ -2,20 +2,29 @@ import { type ReactNode } from 'react'; import { TREND_STYLES } from './constants'; -const DEFAULT_VALUE_CLASS = 'text-dark-100'; - export interface StatCardDelta { /** Signed percent change vs the comparison period. */ percent: number; trend: 'up' | 'down' | 'stable'; } +/** Soft tinted chip + matching value colour, in the spirit of the Remnawave stats. */ +const TONE = { + neutral: { chip: 'bg-dark-700/60 text-dark-300', value: 'text-dark-100' }, + success: { chip: 'bg-success-500/15 text-success-400', value: 'text-success-400' }, + accent: { chip: 'bg-accent-500/15 text-accent-400', value: 'text-accent-400' }, + warning: { chip: 'bg-warning-500/15 text-warning-400', value: 'text-warning-400' }, + error: { chip: 'bg-error-500/15 text-error-400', value: 'text-error-400' }, +} as const; + interface StatCardProps { label: string; value: string | number; icon?: ReactNode; + /** Tints the icon chip and (unless valueClassName is set) the value colour. */ + tone?: keyof typeof TONE; valueClassName?: string; - /** Optional period-over-period change shown next to the value. */ + /** Optional period-over-period change shown under the value. */ delta?: StatCardDelta | null; } @@ -23,26 +32,33 @@ export function StatCard({ label, value, icon, - valueClassName = DEFAULT_VALUE_CLASS, + tone = 'neutral', + valueClassName, delta, }: StatCardProps) { + const toneStyle = TONE[tone]; + const valueClass = valueClassName ?? toneStyle.value; const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null; return ( -
-
- {icon} - {label} -
-
-
- {value} -
- {trendStyle && ( -
- {trendStyle.arrow} {Math.abs(delta!.percent)}% -
+
+
+ {icon && ( + + {icon} + )} +
+
{label}
+
{value}
+ {trendStyle && ( +
+ {trendStyle.arrow} {Math.abs(delta!.percent)}% +
+ )} +
); diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index 9bbfaa1..ed30235 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -1,12 +1,22 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import type { SalesStatsParams } from '../api/adminSalesStats'; import { salesStatsApi } from '../api/adminSalesStats'; import { SALES_STATS } from '../constants/salesStats'; import { useCurrency } from '../hooks/useCurrency'; import { AdminBackButton } from '../components/admin/AdminBackButton'; +import { + BanknotesIcon, + GiftIcon, + PercentIcon, + RepeatIcon, + RocketIcon, + SparklesIcon, + SubscriptionIcon, + WalletIcon, +} from '../components/icons'; import { StatCard } from '../components/stats'; import { AddonsTab, @@ -84,6 +94,7 @@ export default function AdminSalesStats() { queryFn: () => salesStatsApi.getSummary(params), staleTime: SALES_STATS.STALE_TIME, enabled: isValidPeriod, + placeholderData: keepPreviousData, }); const prevParams = useMemo(() => getPreviousPeriodParams(period), [period]); @@ -92,6 +103,7 @@ export default function AdminSalesStats() { queryFn: () => salesStatsApi.getSummary(prevParams as SalesStatsParams), staleTime: SALES_STATS.STALE_TIME, enabled: isValidPeriod && prevParams !== null, + placeholderData: keepPreviousData, }); const deltas = useMemo(() => { @@ -146,33 +158,40 @@ export default function AdminSalesStats() { 0, ) } - valueClassName="text-success-400" + icon={} + tone="success" delta={deltas?.revenue} /> } + tone="accent" /> } + tone="neutral" /> } + tone="accent" delta={deltas?.newTrials} /> } + tone="warning" /> } + tone="success" delta={deltas?.renewals} /> } + tone="accent" delta={deltas?.addonRevenue} /> } + tone="warning" delta={deltas?.manualTopup} />