feat: add admin sales statistics dashboard with 5 analytics tabs

- Add sales stats API client with typed interfaces for all endpoints
- Implement AdminSalesStats page with summary cards and period selector
- Add TrialsTab with provider breakdown donut chart and daily area chart
- Add SalesTab with tariff/period breakdown and daily revenue chart
- Add RenewalsTab with period comparison and trend indicators
- Add AddonsTab with package breakdown bar chart
- Add DepositsTab with payment method breakdown bar chart
- Add SimpleBarChart, SimpleAreaChart, DonutChart components
- Add PeriodSelector with preset periods and custom date range
- Full responsive design (mobile/tablet/desktop/TG Mini App)
- ARIA accessibility (tablist, tab, tabpanel, aria-selected)
- Error handling with isError on all queries
- Empty data guards on all chart components
- i18n support across all 4 locales (en, ru, zh, fa)
This commit is contained in:
Fringg
2026-03-02 20:34:39 +03:00
parent 673de08dd4
commit a47c222310
13 changed files with 1226 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
import { salesStatsApi } from '../../api/adminSalesStats';
import { SALES_STATS } from '../../constants/salesStats';
import { useCurrency } from '../../hooks/useCurrency';
import { StatCard } from '../stats';
import { SimpleAreaChart } from './SimpleAreaChart';
import { SimpleBarChart } from './SimpleBarChart';
interface AddonsTabProps {
params: SalesStatsParams;
}
export function AddonsTab({ params }: AddonsTabProps) {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const { data, isLoading, isError } = useQuery({
queryKey: ['sales-stats', 'addons', params],
queryFn: () => salesStatsApi.getAddons(params),
staleTime: SALES_STATS.STALE_TIME,
});
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
))}
</div>
);
}
if (isError || !data) {
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
}
const packageBarData = data.by_package.map((item) => ({
name: `${item.traffic_gb} GB`,
value: item.count,
}));
const dailyData = data.daily.map((item) => ({
date: item.date,
value: item.count,
}));
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label={t('admin.salesStats.addons.totalPurchases')}
value={data.total_purchases}
/>
<StatCard
label={t('admin.salesStats.addons.totalGb')}
value={`${data.total_gb_purchased} GB`}
/>
<StatCard
label={t('admin.salesStats.addons.revenue')}
value={formatWithCurrency(data.addon_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<SimpleBarChart data={packageBarData} title={t('admin.salesStats.addons.byPackage')} />
<SimpleAreaChart
data={dailyData}
title={t('admin.salesStats.addons.dailyChart')}
chartId="addons-daily"
valueLabel={t('admin.salesStats.addons.purchases')}
color={SALES_STATS.BAR_COLORS[5]}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
import { salesStatsApi } from '../../api/adminSalesStats';
import { SALES_STATS } from '../../constants/salesStats';
import { useCurrency } from '../../hooks/useCurrency';
import { StatCard } from '../stats';
import { SimpleAreaChart } from './SimpleAreaChart';
import { SimpleBarChart } from './SimpleBarChart';
interface DepositsTabProps {
params: SalesStatsParams;
}
const METHOD_LABELS: Record<string, string> = {
telegram_stars: 'Telegram Stars',
tribute: 'Tribute',
yookassa: 'YooKassa',
cryptobot: 'CryptoBot',
heleket: 'Heleket',
mulenpay: 'Mulenpay',
pal24: 'Pal24',
wata: 'Wata',
platega: 'Platega',
cloudpayments: 'CloudPayments',
freekassa: 'FreeKassa',
kassa_ai: 'Kassa AI',
};
export function DepositsTab({ params }: DepositsTabProps) {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const { data, isLoading, isError } = useQuery({
queryKey: ['sales-stats', 'deposits', params],
queryFn: () => salesStatsApi.getDeposits(params),
staleTime: SALES_STATS.STALE_TIME,
});
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
))}
</div>
);
}
if (isError || !data) {
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
}
const methodBarData = data.by_method.map((item) => ({
name: METHOD_LABELS[item.method] || item.method,
value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
}));
const dailyData = data.daily.map((item) => ({
date: item.date,
value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
}));
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label={t('admin.salesStats.deposits.totalDeposits')}
value={data.total_deposits}
/>
<StatCard
label={t('admin.salesStats.deposits.totalAmount')}
value={formatWithCurrency(data.total_amount_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
/>
<StatCard
label={t('admin.salesStats.deposits.avgDeposit')}
value={formatWithCurrency(data.avg_deposit_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
/>
</div>
<SimpleBarChart
data={methodBarData}
title={t('admin.salesStats.deposits.byMethod')}
valueFormatter={(v) => formatWithCurrency(v)}
/>
<SimpleAreaChart
data={dailyData}
title={t('admin.salesStats.deposits.dailyChart')}
chartId="deposits-daily"
valueLabel={t('admin.salesStats.deposits.revenue')}
/>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useTranslation } from 'react-i18next';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { SALES_STATS } from '../../constants/salesStats';
import { useChartColors } from '../../hooks/useChartColors';
interface DonutChartProps {
data: { name: string; value: number; color?: string }[];
title: string;
height?: number;
}
export function DonutChart({
data,
title,
height = SALES_STATS.CHART.PIE_HEIGHT,
}: DonutChartProps) {
const { t } = useTranslation();
const colors = useChartColors();
if (data.length === 0) {
return (
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
{t('common.noData')}
</div>
</div>
);
}
return (
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
<ResponsiveContainer width="100%" height={height}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={80}
paddingAngle={2}
dataKey="value"
nameKey="name"
>
{data.map((entry, index) => (
<Cell
key={entry.name}
fill={entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
}}
labelStyle={{ color: colors.label }}
/>
</PieChart>
</ResponsiveContainer>
<div className="mt-2 flex flex-wrap justify-center gap-3">
{data.map((entry, index) => (
<div key={entry.name} className="flex items-center gap-1.5 text-xs text-dark-400">
<div
className="h-2.5 w-2.5 rounded-full"
style={{
backgroundColor:
entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length],
}}
/>
<span>
{entry.name}: {entry.value}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SALES_STATS } from '../../constants/salesStats';
interface PeriodSelectorProps {
value: { days?: number; startDate?: string; endDate?: string };
onChange: (period: { days?: number; startDate?: string; endDate?: string }) => void;
}
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
const { t } = useTranslation();
const [showCustom, setShowCustom] = useState(false);
const presetLabels: Record<number, string> = {
7: t('admin.salesStats.period.week'),
30: t('admin.salesStats.period.month'),
90: t('admin.salesStats.period.quarter'),
0: t('admin.salesStats.period.all'),
};
const handlePreset = (days: number) => {
setShowCustom(false);
onChange({ days });
};
const handleCustomToggle = () => {
setShowCustom((prev) => !prev);
};
const handleDateChange = (field: 'startDate' | 'endDate', dateStr: string) => {
onChange({
...value,
days: undefined,
[field]: dateStr,
});
};
const isPresetActive = (days: number) => !showCustom && value.days === days;
return (
<div className="flex flex-wrap items-center gap-2">
{SALES_STATS.PERIOD_PRESETS.map((days) => (
<button
key={days}
type="button"
onClick={() => handlePreset(days)}
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
isPresetActive(days)
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
}`}
>
{presetLabels[days]}
</button>
))}
<button
type="button"
onClick={handleCustomToggle}
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
showCustom
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
}`}
>
{t('admin.salesStats.period.custom')}
</button>
{showCustom && (
<div className="flex items-center gap-2">
<input
type="date"
value={value.startDate || ''}
onChange={(e) => handleDateChange('startDate', e.target.value)}
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
/>
<span className="text-dark-500">{'\u2014'}</span>
<input
type="date"
value={value.endDate || ''}
onChange={(e) => handleDateChange('endDate', e.target.value)}
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
import { salesStatsApi } from '../../api/adminSalesStats';
import { SALES_STATS } from '../../constants/salesStats';
import { useCurrency } from '../../hooks/useCurrency';
import { StatCard } from '../stats';
import { TREND_STYLES } from '../stats/constants';
import { SimpleAreaChart } from './SimpleAreaChart';
interface RenewalsTabProps {
params: SalesStatsParams;
}
export function RenewalsTab({ params }: RenewalsTabProps) {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const { data, isLoading, isError } = useQuery({
queryKey: ['sales-stats', 'renewals', params],
queryFn: () => salesStatsApi.getRenewals(params),
staleTime: SALES_STATS.STALE_TIME,
});
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
))}
</div>
);
}
if (isError || !data) {
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
}
const dailyData = data.daily.map((item) => ({
date: item.date,
value: item.count,
}));
const trendStyle = TREND_STYLES[data.change.trend] ?? TREND_STYLES.stable;
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard label={t('admin.salesStats.renewals.total')} value={data.total_renewals} />
<StatCard
label={t('admin.salesStats.renewals.rate')}
value={`${data.renewal_rate}%`}
valueClassName="text-success-400"
/>
<StatCard
label={t('admin.salesStats.renewals.revenue')}
value={formatWithCurrency(data.total_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
/>
</div>
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">
{t('admin.salesStats.renewals.comparison')}
</h4>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-xs text-dark-500">
{t('admin.salesStats.renewals.currentPeriod')}
</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-base font-semibold text-dark-100 sm:text-lg">
{data.current_period.count}
</span>
<span className={`text-sm font-medium ${trendStyle.className}`}>
{trendStyle.arrow} {Math.abs(data.change.percent)}%
</span>
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-xs text-dark-500">
{t('admin.salesStats.renewals.previousPeriod')}
</div>
<div className="mt-1 text-base font-semibold text-dark-400 sm:text-lg">
{data.previous_period.count}
</div>
</div>
</div>
</div>
<SimpleAreaChart
data={dailyData}
title={t('admin.salesStats.renewals.dailyChart')}
chartId="renewals-daily"
valueLabel={t('admin.salesStats.renewals.renewals')}
color={SALES_STATS.BAR_COLORS[2]}
/>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
import { salesStatsApi } from '../../api/adminSalesStats';
import { SALES_STATS } from '../../constants/salesStats';
import { useCurrency } from '../../hooks/useCurrency';
import { StatCard } from '../stats';
import { DonutChart } from './DonutChart';
import { SimpleAreaChart } from './SimpleAreaChart';
import { SimpleBarChart } from './SimpleBarChart';
interface SalesTabProps {
params: SalesStatsParams;
}
export function SalesTab({ params }: SalesTabProps) {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const { data, isLoading, isError } = useQuery({
queryKey: ['sales-stats', 'sales', params],
queryFn: () => salesStatsApi.getSales(params),
staleTime: SALES_STATS.STALE_TIME,
});
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
))}
</div>
);
}
if (isError || !data) {
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
}
const tariffBarData = data.by_tariff.map((item) => ({
name: item.tariff_name,
value: item.count,
}));
const periodPieData = data.by_period.map((item) => ({
name: `${item.period_days} ${t('admin.trafficUsage.days')}`,
value: item.count,
}));
const dailyData = data.daily.map((item) => ({
date: item.date,
value: item.revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR,
}));
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard label={t('admin.salesStats.sales.totalSales')} value={data.total_sales} />
<StatCard
label={t('admin.salesStats.sales.avgOrder')}
value={formatWithCurrency(data.avg_order_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
/>
<StatCard label={t('admin.salesStats.sales.topTariff')} value={data.top_tariff_name} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<SimpleBarChart data={tariffBarData} title={t('admin.salesStats.sales.byTariff')} />
<DonutChart data={periodPieData} title={t('admin.salesStats.sales.byPeriod')} />
</div>
<SimpleAreaChart
data={dailyData}
title={t('admin.salesStats.sales.dailyChart')}
chartId="sales-daily"
valueLabel={t('admin.salesStats.summary.revenue')}
/>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { SALES_STATS } from '../../constants/salesStats';
import { useChartColors } from '../../hooks/useChartColors';
interface SimpleAreaChartProps {
data: { date: string; value: number }[];
title: string;
color?: string;
chartId: string;
valueLabel?: string;
height?: number;
}
export function SimpleAreaChart({
data,
title,
color,
chartId,
valueLabel,
height = SALES_STATS.CHART.HEIGHT,
}: SimpleAreaChartProps) {
const { t, i18n } = useTranslation();
const colors = useChartColors();
const strokeColor = color || colors.earnings;
const chartData = useMemo(
() =>
data.map((item) => ({
...item,
label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
month: 'short',
day: 'numeric',
}),
})),
[data, i18n.language],
);
if (data.length === 0) {
return (
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
{t('common.noData')}
</div>
</div>
);
}
return (
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={chartData} margin={SALES_STATS.CHART.MARGIN}>
<defs>
<linearGradient id={`gradient-${chartId}`} x1="0" y1="0" x2="0" y2="1">
<stop
offset={SALES_STATS.GRADIENT.START_OFFSET}
stopColor={strokeColor}
stopOpacity={SALES_STATS.GRADIENT.START_OPACITY}
/>
<stop
offset={SALES_STATS.GRADIENT.END_OFFSET}
stopColor={strokeColor}
stopOpacity={SALES_STATS.GRADIENT.END_OPACITY}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
<XAxis
dataKey="label"
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
tickLine={false}
axisLine={false}
width={SALES_STATS.AXIS.WIDTH}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
}}
labelStyle={{ color: colors.label }}
formatter={(value: number | undefined) => [value ?? 0, valueLabel || '']}
/>
<Area
type="monotone"
dataKey="value"
stroke={strokeColor}
fill={`url(#gradient-${chartId})`}
strokeWidth={SALES_STATS.STROKE_WIDTH}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { SALES_STATS } from '../../constants/salesStats';
import { useChartColors } from '../../hooks/useChartColors';
interface SimpleBarChartProps {
data: { name: string; value: number; color?: string }[];
title: string;
height?: number;
valueFormatter?: (value: number) => string;
}
const BAR_CHART_MARGIN = {
...SALES_STATS.CHART.MARGIN,
bottom: 40,
};
export function SimpleBarChart({
data,
title,
height = SALES_STATS.CHART.HEIGHT,
valueFormatter,
}: SimpleBarChartProps) {
const { t } = useTranslation();
const colors = useChartColors();
const adjustedHeight = useMemo(() => height + 35, [height]);
if (data.length === 0) {
return (
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
{t('common.noData')}
</div>
</div>
);
}
return (
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
<ResponsiveContainer width="100%" height={adjustedHeight}>
<BarChart data={data} margin={BAR_CHART_MARGIN}>
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
<XAxis
dataKey="name"
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
tickLine={false}
angle={-30}
textAnchor="end"
height={60}
interval={0}
/>
<YAxis
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
tickLine={false}
axisLine={false}
width={SALES_STATS.AXIS.WIDTH}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
}}
labelStyle={{ color: colors.label }}
formatter={(value: number | undefined) => [
valueFormatter ? valueFormatter(value ?? 0) : (value ?? 0),
]}
/>
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{data.map((entry, index) => (
<Cell
key={entry.name}
fill={entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
import { salesStatsApi } from '../../api/adminSalesStats';
import { SALES_STATS } from '../../constants/salesStats';
import { StatCard } from '../stats';
import { DonutChart } from './DonutChart';
import { SimpleAreaChart } from './SimpleAreaChart';
interface TrialsTabProps {
params: SalesStatsParams;
}
const PROVIDER_LABELS: Record<string, string> = {
telegram: 'Telegram',
email: 'Email',
vk: 'VK',
yandex: 'Yandex',
google: 'Google',
discord: 'Discord',
};
export function TrialsTab({ params }: TrialsTabProps) {
const { t } = useTranslation();
const { data, isLoading, isError } = useQuery({
queryKey: ['sales-stats', 'trials', params],
queryFn: () => salesStatsApi.getTrials(params),
staleTime: SALES_STATS.STALE_TIME,
});
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
))}
</div>
);
}
if (isError || !data) {
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
}
const pieData = data.by_provider.map((item) => ({
name: PROVIDER_LABELS[item.provider] || item.provider,
value: item.count,
color: SALES_STATS.PROVIDER_COLORS[item.provider as keyof typeof SALES_STATS.PROVIDER_COLORS],
}));
const areaData = data.daily.map((item) => ({
date: item.date,
value: item.count,
}));
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard label={t('admin.salesStats.trials.total')} value={data.total_trials} />
<StatCard
label={t('admin.salesStats.trials.conversion')}
value={`${data.conversion_rate}%`}
valueClassName="text-success-400"
/>
<StatCard
label={t('admin.salesStats.trials.avgDuration')}
value={`${data.avg_trial_duration_days} ${t('admin.trafficUsage.days')}`}
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<DonutChart data={pieData} title={t('admin.salesStats.trials.byProvider')} />
<SimpleAreaChart
data={areaData}
title={t('admin.salesStats.trials.dailyChart')}
chartId="trials-daily"
valueLabel={t('admin.salesStats.trials.registrations')}
color={SALES_STATS.PROVIDER_COLORS.telegram}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export { AddonsTab } from './AddonsTab';
export { DepositsTab } from './DepositsTab';
export { DonutChart } from './DonutChart';
export { PeriodSelector } from './PeriodSelector';
export { RenewalsTab } from './RenewalsTab';
export { SalesTab } from './SalesTab';
export { SimpleAreaChart } from './SimpleAreaChart';
export { SimpleBarChart } from './SimpleBarChart';
export { TrialsTab } from './TrialsTab';