feat(sales-stats): душевные карточки с иконками + фикс скачка при смене периода

- StatCard: иконка в мягком цветном чипе (tone success/accent/warning/…) в духе
  статистики Remnawave; дельта под значением. Обратно совместимо (valueClassName).
- Карточки сводки получили осмысленные иконки (доход/подписки/триалы/конверсия/
  продления/допы/пополнения) и цветовые тоны.
- Скачок при переключении периодов убран: на все запросы статистики добавлен
  placeholderData: keepPreviousData — старые данные остаются на экране, пока
  грузятся новые, без вспышки скелетона и прыжка вёрстки.
This commit is contained in:
c0mrade
2026-06-02 15:32:53 +03:00
parent a687cc3d18
commit dfc7768a40
7 changed files with 72 additions and 30 deletions

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats'; import type { SalesStatsParams } from '../../api/adminSalesStats';
@@ -23,6 +23,7 @@ export function AddonsTab({ params }: AddonsTabProps) {
queryKey: ['sales-stats', 'addons', params], queryKey: ['sales-stats', 'addons', params],
queryFn: () => salesStatsApi.getAddons(params), queryFn: () => salesStatsApi.getAddons(params),
staleTime: SALES_STATS.STALE_TIME, staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
}); });
const dailyChartData = useMemo(() => { const dailyChartData = useMemo(() => {

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats'; import type { SalesStatsParams } from '../../api/adminSalesStats';
@@ -27,6 +27,7 @@ export function DepositsTab({ params }: DepositsTabProps) {
queryKey: ['sales-stats', 'deposits', params], queryKey: ['sales-stats', 'deposits', params],
queryFn: () => salesStatsApi.getDeposits(params), queryFn: () => salesStatsApi.getDeposits(params),
staleTime: SALES_STATS.STALE_TIME, staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
}); });
const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]); const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]);

View File

@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats'; import type { SalesStatsParams } from '../../api/adminSalesStats';
@@ -22,6 +22,7 @@ export function RenewalsTab({ params }: RenewalsTabProps) {
queryKey: ['sales-stats', 'renewals', params], queryKey: ['sales-stats', 'renewals', params],
queryFn: () => salesStatsApi.getRenewals(params), queryFn: () => salesStatsApi.getRenewals(params),
staleTime: SALES_STATS.STALE_TIME, staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
}); });
if (isLoading) { if (isLoading) {

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats'; import type { SalesStatsParams } from '../../api/adminSalesStats';
@@ -25,6 +25,7 @@ export function SalesTab({ params }: SalesTabProps) {
queryKey: ['sales-stats', 'sales', params], queryKey: ['sales-stats', 'sales', params],
queryFn: () => salesStatsApi.getSales(params), queryFn: () => salesStatsApi.getSales(params),
staleTime: SALES_STATS.STALE_TIME, staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
}); });
const dailyByTariffData = useMemo( const dailyByTariffData = useMemo(

View File

@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats'; import type { SalesStatsParams } from '../../api/adminSalesStats';
@@ -29,6 +29,7 @@ export function TrialsTab({ params }: TrialsTabProps) {
queryKey: ['sales-stats', 'trials', params], queryKey: ['sales-stats', 'trials', params],
queryFn: () => salesStatsApi.getTrials(params), queryFn: () => salesStatsApi.getTrials(params),
staleTime: SALES_STATS.STALE_TIME, staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
}); });
if (isLoading) { if (isLoading) {

View File

@@ -2,20 +2,29 @@ import { type ReactNode } from 'react';
import { TREND_STYLES } from './constants'; import { TREND_STYLES } from './constants';
const DEFAULT_VALUE_CLASS = 'text-dark-100';
export interface StatCardDelta { export interface StatCardDelta {
/** Signed percent change vs the comparison period. */ /** Signed percent change vs the comparison period. */
percent: number; percent: number;
trend: 'up' | 'down' | 'stable'; 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 { interface StatCardProps {
label: string; label: string;
value: string | number; value: string | number;
icon?: ReactNode; icon?: ReactNode;
/** Tints the icon chip and (unless valueClassName is set) the value colour. */
tone?: keyof typeof TONE;
valueClassName?: string; valueClassName?: string;
/** Optional period-over-period change shown next to the value. */ /** Optional period-over-period change shown under the value. */
delta?: StatCardDelta | null; delta?: StatCardDelta | null;
} }
@@ -23,21 +32,27 @@ export function StatCard({
label, label,
value, value,
icon, icon,
valueClassName = DEFAULT_VALUE_CLASS, tone = 'neutral',
valueClassName,
delta, delta,
}: StatCardProps) { }: StatCardProps) {
const toneStyle = TONE[tone];
const valueClass = valueClassName ?? toneStyle.value;
const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null; const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null;
return ( return (
<div className="rounded-xl bg-dark-800/30 p-3"> <div className="rounded-xl bg-dark-800/30 p-3 transition-colors hover:bg-dark-800/50">
<div className="flex items-center gap-1.5 text-sm text-dark-500"> <div className="flex items-center gap-2.5">
{icon && (
<span
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${toneStyle.chip}`}
>
{icon} {icon}
<span>{label}</span> </span>
</div> )}
<div className="mt-1"> <div className="min-w-0 flex-1">
<div className={`truncate text-base font-semibold sm:text-lg ${valueClassName}`}> <div className="truncate text-xs text-dark-500 sm:text-sm">{label}</div>
{value} <div className={`truncate text-base font-semibold sm:text-lg ${valueClass}`}>{value}</div>
</div>
{trendStyle && ( {trendStyle && (
<div className={`mt-0.5 text-xs font-medium ${trendStyle.className}`}> <div className={`mt-0.5 text-xs font-medium ${trendStyle.className}`}>
{trendStyle.arrow} {Math.abs(delta!.percent)}% {trendStyle.arrow} {Math.abs(delta!.percent)}%
@@ -45,5 +60,6 @@ export function StatCard({
)} )}
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -1,12 +1,22 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 type { SalesStatsParams } from '../api/adminSalesStats';
import { salesStatsApi } from '../api/adminSalesStats'; import { salesStatsApi } from '../api/adminSalesStats';
import { SALES_STATS } from '../constants/salesStats'; import { SALES_STATS } from '../constants/salesStats';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { AdminBackButton } from '../components/admin/AdminBackButton'; import { AdminBackButton } from '../components/admin/AdminBackButton';
import {
BanknotesIcon,
GiftIcon,
PercentIcon,
RepeatIcon,
RocketIcon,
SparklesIcon,
SubscriptionIcon,
WalletIcon,
} from '../components/icons';
import { StatCard } from '../components/stats'; import { StatCard } from '../components/stats';
import { import {
AddonsTab, AddonsTab,
@@ -84,6 +94,7 @@ export default function AdminSalesStats() {
queryFn: () => salesStatsApi.getSummary(params), queryFn: () => salesStatsApi.getSummary(params),
staleTime: SALES_STATS.STALE_TIME, staleTime: SALES_STATS.STALE_TIME,
enabled: isValidPeriod, enabled: isValidPeriod,
placeholderData: keepPreviousData,
}); });
const prevParams = useMemo(() => getPreviousPeriodParams(period), [period]); const prevParams = useMemo(() => getPreviousPeriodParams(period), [period]);
@@ -92,6 +103,7 @@ export default function AdminSalesStats() {
queryFn: () => salesStatsApi.getSummary(prevParams as SalesStatsParams), queryFn: () => salesStatsApi.getSummary(prevParams as SalesStatsParams),
staleTime: SALES_STATS.STALE_TIME, staleTime: SALES_STATS.STALE_TIME,
enabled: isValidPeriod && prevParams !== null, enabled: isValidPeriod && prevParams !== null,
placeholderData: keepPreviousData,
}); });
const deltas = useMemo(() => { const deltas = useMemo(() => {
@@ -146,33 +158,40 @@ export default function AdminSalesStats() {
0, 0,
) )
} }
valueClassName="text-success-400" icon={<BanknotesIcon className="h-5 w-5" />}
tone="success"
delta={deltas?.revenue} delta={deltas?.revenue}
/> />
<StatCard <StatCard
label={t('admin.salesStats.summary.activeSubs')} label={t('admin.salesStats.summary.activeSubs')}
value={summaryLoading ? '...' : (summary?.active_subscriptions ?? 0)} value={summaryLoading ? '...' : (summary?.active_subscriptions ?? 0)}
valueClassName="text-accent-400" icon={<SubscriptionIcon className="h-5 w-5" />}
tone="accent"
/> />
<StatCard <StatCard
label={t('admin.salesStats.summary.activeTrials')} label={t('admin.salesStats.summary.activeTrials')}
value={summaryLoading ? '...' : (summary?.active_trials ?? 0)} value={summaryLoading ? '...' : (summary?.active_trials ?? 0)}
icon={<GiftIcon className="h-5 w-5" />}
tone="neutral"
/> />
<StatCard <StatCard
label={t('admin.salesStats.summary.newTrials')} label={t('admin.salesStats.summary.newTrials')}
value={summaryLoading ? '...' : (summary?.new_trials ?? 0)} value={summaryLoading ? '...' : (summary?.new_trials ?? 0)}
valueClassName="text-accent-400" icon={<SparklesIcon className="h-5 w-5" />}
tone="accent"
delta={deltas?.newTrials} delta={deltas?.newTrials}
/> />
<StatCard <StatCard
label={t('admin.salesStats.summary.conversion')} label={t('admin.salesStats.summary.conversion')}
value={summaryLoading ? '...' : `${summary?.trial_to_paid_conversion ?? 0}%`} value={summaryLoading ? '...' : `${summary?.trial_to_paid_conversion ?? 0}%`}
valueClassName="text-warning-400" icon={<PercentIcon className="h-5 w-5" />}
tone="warning"
/> />
<StatCard <StatCard
label={t('admin.salesStats.summary.renewals')} label={t('admin.salesStats.summary.renewals')}
value={summaryLoading ? '...' : (summary?.renewals_count ?? 0)} value={summaryLoading ? '...' : (summary?.renewals_count ?? 0)}
valueClassName="text-success-400" icon={<RepeatIcon className="h-5 w-5" />}
tone="success"
delta={deltas?.renewals} delta={deltas?.renewals}
/> />
<StatCard <StatCard
@@ -185,7 +204,8 @@ export default function AdminSalesStats() {
0, 0,
) )
} }
valueClassName="text-accent-400" icon={<RocketIcon className="h-5 w-5" />}
tone="accent"
delta={deltas?.addonRevenue} delta={deltas?.addonRevenue}
/> />
<StatCard <StatCard
@@ -198,7 +218,8 @@ export default function AdminSalesStats() {
0, 0,
) )
} }
valueClassName="text-warning-400" icon={<WalletIcon className="h-5 w-5" />}
tone="warning"
delta={deltas?.manualTopup} delta={deltas?.manualTopup}
/> />
</div> </div>