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

175
src/api/adminSalesStats.ts Normal file
View File

@@ -0,0 +1,175 @@
import apiClient from './client';
// ============ Period Params ============
export interface SalesStatsParams {
days?: number;
start_date?: string;
end_date?: string;
}
// ============ Summary ============
export interface SalesSummary {
total_revenue_kopeks: number;
active_subscriptions: number;
active_trials: number;
new_trials: number;
trial_to_paid_conversion: number;
renewals_count: number;
addon_revenue_kopeks: number;
}
// ============ Trials ============
export interface ProviderBreakdownItem {
provider: string;
count: number;
}
export interface DailyTrialItem {
date: string;
count: number;
}
export interface TrialsStats {
total_trials: number;
conversion_rate: number;
avg_trial_duration_days: number;
by_provider: ProviderBreakdownItem[];
daily: DailyTrialItem[];
}
// ============ Sales ============
export interface SalesByTariffItem {
tariff_id: number;
tariff_name: string;
count: number;
}
export interface SalesByPeriodItem {
period_days: number;
count: number;
}
export interface DailySalesItem {
date: string;
count: number;
revenue_kopeks: number;
}
export interface SalesStats {
total_sales: number;
total_revenue_kopeks: number;
avg_order_kopeks: number;
top_tariff_name: string;
by_tariff: SalesByTariffItem[];
by_period: SalesByPeriodItem[];
daily: DailySalesItem[];
}
// ============ Renewals ============
export interface RenewalPeriodStats {
count: number;
revenue_kopeks: number;
}
export interface RenewalChange {
absolute: number;
percent: number;
trend: 'up' | 'down' | 'stable';
}
export interface DailyRenewalItem {
date: string;
count: number;
}
export interface RenewalsStats {
total_renewals: number;
total_revenue_kopeks: number;
renewal_rate: number;
current_period: RenewalPeriodStats;
previous_period: RenewalPeriodStats;
change: RenewalChange;
daily: DailyRenewalItem[];
}
// ============ Add-ons ============
export interface AddonByPackageItem {
traffic_gb: number;
count: number;
}
export interface DailyAddonItem {
date: string;
count: number;
total_gb: number;
}
export interface AddonsStats {
total_purchases: number;
total_gb_purchased: number;
addon_revenue_kopeks: number;
by_package: AddonByPackageItem[];
daily: DailyAddonItem[];
}
// ============ Deposits ============
export interface DepositByMethodItem {
method: string;
count: number;
amount_kopeks: number;
}
export interface DailyDepositItem {
date: string;
count: number;
amount_kopeks: number;
}
export interface DepositsStats {
total_deposits: number;
total_amount_kopeks: number;
avg_deposit_kopeks: number;
by_method: DepositByMethodItem[];
daily: DailyDepositItem[];
}
// ============ API ============
export const salesStatsApi = {
getSummary: async (params: SalesStatsParams = {}): Promise<SalesSummary> => {
const response = await apiClient.get('/cabinet/admin/stats/sales/summary', { params });
return response.data;
},
getTrials: async (params: SalesStatsParams = {}): Promise<TrialsStats> => {
const response = await apiClient.get('/cabinet/admin/stats/sales/trials', { params });
return response.data;
},
getSales: async (params: SalesStatsParams = {}): Promise<SalesStats> => {
const response = await apiClient.get('/cabinet/admin/stats/sales/subscriptions', { params });
return response.data;
},
getRenewals: async (params: SalesStatsParams = {}): Promise<RenewalsStats> => {
const response = await apiClient.get('/cabinet/admin/stats/sales/renewals', { params });
return response.data;
},
getAddons: async (params: SalesStatsParams = {}): Promise<AddonsStats> => {
const response = await apiClient.get('/cabinet/admin/stats/sales/addons', { params });
return response.data;
},
getDeposits: async (params: SalesStatsParams = {}): Promise<DepositsStats> => {
const response = await apiClient.get('/cabinet/admin/stats/sales/deposits', { params });
return response.data;
},
};

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';

View File

@@ -0,0 +1,39 @@
import { CHART_COMMON } from './charts';
export const SALES_STATS = {
CHART: {
...CHART_COMMON.CHART,
HEIGHT: 220,
PIE_HEIGHT: 250,
},
KOPEKS_DIVISOR: CHART_COMMON.KOPEKS_DIVISOR,
GRADIENT: CHART_COMMON.GRADIENT,
AXIS: {
TICK_FONT_SIZE: CHART_COMMON.AXIS.TICK_FONT_SIZE,
WIDTH: 40,
},
TOOLTIP: CHART_COMMON.TOOLTIP,
STROKE_WIDTH: CHART_COMMON.STROKE_WIDTH,
GRID_DASH: CHART_COMMON.GRID_DASH,
STALE_TIME: CHART_COMMON.STALE_TIME,
DEFAULT_PERIOD: 30,
PERIOD_PRESETS: [7, 30, 90, 0] as const,
PROVIDER_COLORS: {
telegram: '#229ED9',
email: '#34d399',
vk: '#4C75A3',
yandex: '#FC3F1D',
google: '#4285F4',
discord: '#5865F2',
} as const,
BAR_COLORS: [
'#34d399',
'#818cf8',
'#f59e0b',
'#ec4899',
'#06b6d4',
'#8b5cf6',
'#f97316',
'#14b8a6',
] as const,
} as const;

View File

@@ -0,0 +1,171 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { 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 { StatCard } from '../components/stats';
import {
AddonsTab,
DepositsTab,
PeriodSelector,
RenewalsTab,
SalesTab,
TrialsTab,
} from '../components/sales-stats';
type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits';
export default function AdminSalesStats() {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const [activeTab, setActiveTab] = useState<TabId>('trials');
const [period, setPeriod] = useState<{
days?: number;
startDate?: string;
endDate?: string;
}>({ days: SALES_STATS.DEFAULT_PERIOD });
const params: SalesStatsParams = useMemo(
() => ({
days: period.days,
start_date: period.startDate,
end_date: period.endDate,
}),
[period.days, period.startDate, period.endDate],
);
const isValidPeriod = period.days !== undefined || (!!period.startDate && !!period.endDate);
const {
data: summary,
isLoading: summaryLoading,
isError: summaryError,
} = useQuery({
queryKey: ['sales-stats', 'summary', params],
queryFn: () => salesStatsApi.getSummary(params),
staleTime: SALES_STATS.STALE_TIME,
enabled: isValidPeriod,
});
const tabs: { id: TabId; label: string }[] = [
{ id: 'trials', label: t('admin.salesStats.tabs.trials') },
{ id: 'sales', label: t('admin.salesStats.tabs.sales') },
{ id: 'renewals', label: t('admin.salesStats.tabs.renewals') },
{ id: 'addons', label: t('admin.salesStats.tabs.addons') },
{ id: 'deposits', label: t('admin.salesStats.tabs.deposits') },
];
return (
<div className="animate-fade-in space-y-4 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3">
<AdminBackButton />
<div>
<h1 className="text-xl font-bold text-dark-100 sm:text-2xl">
{t('admin.salesStats.title')}
</h1>
<p className="text-sm text-dark-400">{t('admin.salesStats.subtitle')}</p>
</div>
</div>
{/* Period selector */}
<PeriodSelector value={period} onChange={setPeriod} />
{/* Summary cards */}
{summaryError && (
<div className="rounded-xl bg-error-500/10 px-4 py-3 text-sm text-error-400">
{t('admin.salesStats.loadError')}
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7">
<StatCard
label={t('admin.salesStats.summary.revenue')}
value={
summaryLoading
? '...'
: formatWithCurrency(
(summary?.total_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR,
)
}
valueClassName="text-success-400"
/>
<StatCard
label={t('admin.salesStats.summary.activeSubs')}
value={summaryLoading ? '...' : (summary?.active_subscriptions ?? 0)}
valueClassName="text-accent-400"
/>
<StatCard
label={t('admin.salesStats.summary.activeTrials')}
value={summaryLoading ? '...' : (summary?.active_trials ?? 0)}
/>
<StatCard
label={t('admin.salesStats.summary.newTrials')}
value={summaryLoading ? '...' : (summary?.new_trials ?? 0)}
valueClassName="text-blue-400"
/>
<StatCard
label={t('admin.salesStats.summary.conversion')}
value={summaryLoading ? '...' : `${summary?.trial_to_paid_conversion ?? 0}%`}
valueClassName="text-warning-400"
/>
<StatCard
label={t('admin.salesStats.summary.renewals')}
value={summaryLoading ? '...' : (summary?.renewals_count ?? 0)}
valueClassName="text-success-400"
/>
<StatCard
label={t('admin.salesStats.summary.addonRevenue')}
value={
summaryLoading
? '...'
: formatWithCurrency(
(summary?.addon_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR,
)
}
valueClassName="text-accent-400"
/>
</div>
{/* Tabs */}
<div
className="scrollbar-hide flex gap-1 overflow-x-auto rounded-xl bg-dark-800/30 p-1"
role="tablist"
>
{tabs.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`shrink-0 whitespace-nowrap rounded-lg px-3 py-2.5 text-xs font-medium transition-colors sm:text-sm ${
activeTab === tab.id
? 'bg-dark-700/60 text-dark-100'
: 'text-dark-400 hover:text-dark-300'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
{isValidPeriod && (
<div role="tabpanel" id={`panel-${activeTab}`} aria-labelledby={`tab-${activeTab}`}>
{activeTab === 'trials' && <TrialsTab params={params} />}
{activeTab === 'sales' && <SalesTab params={params} />}
{activeTab === 'renewals' && <RenewalsTab params={params} />}
{activeTab === 'addons' && <AddonsTab params={params} />}
{activeTab === 'deposits' && <DepositsTab params={params} />}
</div>
)}
</div>
);
}