mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(landing-stats): rebuild charts on shared components + add funnel & breakdowns
The landing stats page rendered raw recharts by hand plus a bespoke progress-bar list, looking nothing like the Sales/Remnawave stats. Rebuild it on the shared chart components and add the visualizations the data warrants: - Daily: MultiSeriesAreaChart (created/paid) + SimpleAreaChart (revenue), replacing the hand-rolled area chart and the custom progress-bar list. - Tariff & traffic sources: BreakdownList; payment methods & gift composition: DonutChart — all the same components Sales stats uses. - New conversion Funnel (created → paid) and a Gift activation card (claimed/sent + rate), backed by the new backend breakdowns. - Containers use the bento-card surface; i18n added in ru/en/zh/fa.
This commit is contained in:
@@ -326,10 +326,22 @@ export interface LandingTariffStat {
|
|||||||
revenue_kopeks: number;
|
revenue_kopeks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LandingPaymentMethodStat {
|
||||||
|
method: string;
|
||||||
|
purchases: number;
|
||||||
|
revenue_kopeks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandingSourceStat {
|
||||||
|
source: string;
|
||||||
|
purchases: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LandingStatsResponse {
|
export interface LandingStatsResponse {
|
||||||
total_purchases: number;
|
total_purchases: number;
|
||||||
total_revenue_kopeks: number;
|
total_revenue_kopeks: number;
|
||||||
total_gifts: number;
|
total_gifts: number;
|
||||||
|
total_gifts_claimed: number;
|
||||||
total_regular: number;
|
total_regular: number;
|
||||||
avg_purchase_kopeks: number;
|
avg_purchase_kopeks: number;
|
||||||
total_created: number;
|
total_created: number;
|
||||||
@@ -337,6 +349,8 @@ export interface LandingStatsResponse {
|
|||||||
conversion_rate: number;
|
conversion_rate: number;
|
||||||
daily_stats: LandingDailyStat[];
|
daily_stats: LandingDailyStat[];
|
||||||
tariff_stats: LandingTariffStat[];
|
tariff_stats: LandingTariffStat[];
|
||||||
|
payment_method_stats: LandingPaymentMethodStat[];
|
||||||
|
source_stats: LandingSourceStat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PurchaseItemStatus =
|
export type PurchaseItemStatus =
|
||||||
|
|||||||
@@ -3978,6 +3978,12 @@
|
|||||||
"dailyChart": "Purchases & Revenue by Day",
|
"dailyChart": "Purchases & Revenue by Day",
|
||||||
"tariffChart": "Tariff Distribution",
|
"tariffChart": "Tariff Distribution",
|
||||||
"giftBreakdown": "Gifts vs Regular",
|
"giftBreakdown": "Gifts vs Regular",
|
||||||
|
"funnelTitle": "Conversion funnel",
|
||||||
|
"giftClaimTitle": "Gift activation",
|
||||||
|
"giftClaimLabel": "claimed / sent",
|
||||||
|
"dailyRevenue": "Daily revenue",
|
||||||
|
"byPaymentMethod": "By payment method",
|
||||||
|
"bySource": "Traffic sources",
|
||||||
"purchases": "Purchases",
|
"purchases": "Purchases",
|
||||||
"revenueLabel": "Revenue",
|
"revenueLabel": "Revenue",
|
||||||
"gifts": "Gifts",
|
"gifts": "Gifts",
|
||||||
|
|||||||
@@ -3694,6 +3694,12 @@
|
|||||||
"dailyChart": "خرید و درآمد روزانه",
|
"dailyChart": "خرید و درآمد روزانه",
|
||||||
"tariffChart": "توزیع طرحها",
|
"tariffChart": "توزیع طرحها",
|
||||||
"giftBreakdown": "هدایا در مقابل عادی",
|
"giftBreakdown": "هدایا در مقابل عادی",
|
||||||
|
"funnelTitle": "قیف تبدیل",
|
||||||
|
"giftClaimTitle": "فعالسازی هدیه",
|
||||||
|
"giftClaimLabel": "دریافتشده / ارسالشده",
|
||||||
|
"dailyRevenue": "درآمد روزانه",
|
||||||
|
"byPaymentMethod": "بر اساس روش پرداخت",
|
||||||
|
"bySource": "منابع ترافیک",
|
||||||
"purchases": "خریدها",
|
"purchases": "خریدها",
|
||||||
"revenueLabel": "درآمد",
|
"revenueLabel": "درآمد",
|
||||||
"gifts": "هدایا",
|
"gifts": "هدایا",
|
||||||
|
|||||||
@@ -4523,6 +4523,12 @@
|
|||||||
"dailyChart": "Покупки и доход по дням",
|
"dailyChart": "Покупки и доход по дням",
|
||||||
"tariffChart": "Распределение по тарифам",
|
"tariffChart": "Распределение по тарифам",
|
||||||
"giftBreakdown": "Подарки vs обычные",
|
"giftBreakdown": "Подарки vs обычные",
|
||||||
|
"funnelTitle": "Воронка конверсии",
|
||||||
|
"giftClaimTitle": "Активация подарков",
|
||||||
|
"giftClaimLabel": "забрано / отправлено",
|
||||||
|
"dailyRevenue": "Выручка по дням",
|
||||||
|
"byPaymentMethod": "По способам оплаты",
|
||||||
|
"bySource": "Источники трафика",
|
||||||
"purchases": "Покупки",
|
"purchases": "Покупки",
|
||||||
"revenueLabel": "Доход",
|
"revenueLabel": "Доход",
|
||||||
"gifts": "Подарки",
|
"gifts": "Подарки",
|
||||||
|
|||||||
@@ -3693,6 +3693,12 @@
|
|||||||
"dailyChart": "每日购买与收入",
|
"dailyChart": "每日购买与收入",
|
||||||
"tariffChart": "套餐分布",
|
"tariffChart": "套餐分布",
|
||||||
"giftBreakdown": "礼物 vs 普通",
|
"giftBreakdown": "礼物 vs 普通",
|
||||||
|
"funnelTitle": "转化漏斗",
|
||||||
|
"giftClaimTitle": "礼物激活",
|
||||||
|
"giftClaimLabel": "已领取 / 已发送",
|
||||||
|
"dailyRevenue": "每日收入",
|
||||||
|
"byPaymentMethod": "按支付方式",
|
||||||
|
"bySource": "流量来源",
|
||||||
"purchases": "购买",
|
"purchases": "购买",
|
||||||
"revenueLabel": "收入",
|
"revenueLabel": "收入",
|
||||||
"gifts": "礼物",
|
"gifts": "礼物",
|
||||||
|
|||||||
@@ -2,20 +2,7 @@ import { useState, useMemo } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer } from 'recharts';
|
||||||
Area,
|
|
||||||
AreaChart,
|
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
CartesianGrid,
|
|
||||||
Cell,
|
|
||||||
Pie,
|
|
||||||
PieChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from 'recharts';
|
|
||||||
import {
|
import {
|
||||||
adminLandingsApi,
|
adminLandingsApi,
|
||||||
resolveLocaleDisplay,
|
resolveLocaleDisplay,
|
||||||
@@ -23,10 +10,13 @@ import {
|
|||||||
type LandingPurchaseItem,
|
type LandingPurchaseItem,
|
||||||
} from '../api/landings';
|
} from '../api/landings';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { useChartColors } from '../hooks/useChartColors';
|
|
||||||
import { CHART_COMMON } from '../constants/charts';
|
import { CHART_COMMON } from '../constants/charts';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
import { StatCard } from '../components/stats';
|
import { StatCard } from '../components/stats';
|
||||||
|
import { BreakdownList } from '../components/sales-stats/BreakdownList';
|
||||||
|
import { DonutChart } from '../components/sales-stats/DonutChart';
|
||||||
|
import { SimpleAreaChart } from '../components/sales-stats/SimpleAreaChart';
|
||||||
|
import { MultiSeriesAreaChart } from '../components/sales-stats/MultiSeriesAreaChart';
|
||||||
import {
|
import {
|
||||||
ChartIcon,
|
ChartIcon,
|
||||||
EmailIcon,
|
EmailIcon,
|
||||||
@@ -44,8 +34,8 @@ import {
|
|||||||
ChevronRightIcon as ChevronRightSmall,
|
ChevronRightIcon as ChevronRightSmall,
|
||||||
} from '@/components/icons';
|
} from '@/components/icons';
|
||||||
|
|
||||||
const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6'];
|
const FUNNEL_COLORS = ['#f59e0b', '#34d399'];
|
||||||
const GIFT_COLOR = '#a855f7';
|
const GIFT_DONUT = { regular: '#818cf8', gift: '#a855f7' };
|
||||||
|
|
||||||
const PURCHASE_STATUS_STYLES: Record<string, string> = {
|
const PURCHASE_STATUS_STYLES: Record<string, string> = {
|
||||||
pending: 'bg-warning-500/20 text-warning-400',
|
pending: 'bg-warning-500/20 text-warning-400',
|
||||||
@@ -189,7 +179,8 @@ export default function AdminLandingStats() {
|
|||||||
const isValidId = !isNaN(numericId);
|
const isValidId = !isNaN(numericId);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { formatWithCurrency } = useCurrency();
|
const { formatWithCurrency } = useCurrency();
|
||||||
const colors = useChartColors();
|
const divisor = CHART_COMMON.KOPEKS_DIVISOR;
|
||||||
|
const money = (kopeks: number) => formatWithCurrency(kopeks / divisor);
|
||||||
|
|
||||||
// Purchases list state
|
// Purchases list state
|
||||||
const [purchaseOffset, setPurchaseOffset] = useState(0);
|
const [purchaseOffset, setPurchaseOffset] = useState(0);
|
||||||
@@ -242,49 +233,59 @@ export default function AdminLandingStats() {
|
|||||||
const purchaseTotalPages = Math.ceil(purchaseTotal / PURCHASES_PAGE_SIZE);
|
const purchaseTotalPages = Math.ceil(purchaseTotal / PURCHASES_PAGE_SIZE);
|
||||||
const purchaseCurrentPage = Math.floor(purchaseOffset / PURCHASES_PAGE_SIZE) + 1;
|
const purchaseCurrentPage = Math.floor(purchaseOffset / PURCHASES_PAGE_SIZE) + 1;
|
||||||
|
|
||||||
// Prepare daily chart data
|
// Daily counts (created / paid) — flat data for MultiSeriesAreaChart
|
||||||
const dailyData = useMemo(() => {
|
const dailyCounts = useMemo(() => {
|
||||||
if (!stats) return [];
|
if (!stats) return [];
|
||||||
return stats.daily_stats.map((item) => ({
|
const createdLabel = t('admin.landings.stats.created', 'Created');
|
||||||
label: (() => {
|
const paidLabel = t('admin.landings.stats.paid', 'Paid');
|
||||||
const d = new Date(item.date + 'T00:00:00');
|
return stats.daily_stats.flatMap((d) => [
|
||||||
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`;
|
{ date: d.date, key: createdLabel, value: d.created },
|
||||||
})(),
|
{ date: d.date, key: paidLabel, value: d.purchases },
|
||||||
created: item.created,
|
]);
|
||||||
purchases: item.purchases,
|
}, [stats, t]);
|
||||||
revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
|
|
||||||
gifts: item.gifts,
|
|
||||||
}));
|
|
||||||
}, [stats]);
|
|
||||||
|
|
||||||
// Prepare tariff chart data
|
// Daily revenue — for SimpleAreaChart
|
||||||
const tariffData = useMemo(() => {
|
const dailyRevenue = useMemo(() => {
|
||||||
if (!stats) return [];
|
if (!stats) return [];
|
||||||
return stats.tariff_stats.map((item) => ({
|
return stats.daily_stats.map((d) => ({ date: d.date, value: d.revenue_kopeks / divisor }));
|
||||||
name: item.tariff_name,
|
}, [stats, divisor]);
|
||||||
purchases: item.purchases,
|
|
||||||
revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
|
|
||||||
}));
|
|
||||||
}, [stats]);
|
|
||||||
|
|
||||||
// Donut data for gift vs regular
|
// Tariff breakdown (by revenue)
|
||||||
const donutData = useMemo(() => {
|
const tariffItems = useMemo(
|
||||||
|
() =>
|
||||||
|
(stats?.tariff_stats ?? []).map((t2) => ({
|
||||||
|
key: String(t2.tariff_id ?? t2.tariff_name),
|
||||||
|
label: t2.tariff_name,
|
||||||
|
value: t2.revenue_kopeks / divisor,
|
||||||
|
})),
|
||||||
|
[stats, divisor],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Payment method breakdown (by purchases)
|
||||||
|
const paymentDonut = useMemo(
|
||||||
|
() => (stats?.payment_method_stats ?? []).map((p) => ({ name: p.method, value: p.purchases })),
|
||||||
|
[stats],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Traffic source breakdown (by purchases)
|
||||||
|
const sourceItems = useMemo(
|
||||||
|
() =>
|
||||||
|
(stats?.source_stats ?? []).map((s) => ({
|
||||||
|
key: s.source,
|
||||||
|
label: s.source,
|
||||||
|
value: s.purchases,
|
||||||
|
})),
|
||||||
|
[stats],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Funnel: created -> paid
|
||||||
|
const funnelData = useMemo(() => {
|
||||||
if (!stats) return [];
|
if (!stats) return [];
|
||||||
return [
|
return [
|
||||||
{
|
{ name: t('admin.landings.stats.created', 'Created'), value: stats.total_created },
|
||||||
name: t('admin.landings.stats.regular'),
|
{ name: t('admin.landings.stats.paid', 'Paid'), value: stats.total_successful },
|
||||||
value: stats.total_regular,
|
|
||||||
color: colors.referrals,
|
|
||||||
},
|
|
||||||
{ name: t('admin.landings.stats.gifts'), value: stats.total_gifts, color: GIFT_COLOR },
|
|
||||||
];
|
];
|
||||||
}, [stats, t, colors.referrals]);
|
}, [stats, t]);
|
||||||
|
|
||||||
// Bar chart height based on tariff count
|
|
||||||
const barChartHeight = useMemo(() => {
|
|
||||||
const count = tariffData.length;
|
|
||||||
return Math.max(220, count * 45 + 40);
|
|
||||||
}, [tariffData.length]);
|
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -317,6 +318,8 @@ export default function AdminLandingStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const landingTitle = landing ? resolveLocaleDisplay(landing.title) : `#${numericId}`;
|
const landingTitle = landing ? resolveLocaleDisplay(landing.title) : `#${numericId}`;
|
||||||
|
const giftClaimRate =
|
||||||
|
stats.total_gifts > 0 ? Math.round((stats.total_gifts_claimed / stats.total_gifts) * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
@@ -344,7 +347,7 @@ export default function AdminLandingStats() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
@@ -354,14 +357,14 @@ export default function AdminLandingStats() {
|
|||||||
tone="warning"
|
tone="warning"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.landings.stats.paid', 'paid')}
|
label={t('admin.landings.stats.paid', 'Paid')}
|
||||||
value={stats.total_successful}
|
value={stats.total_successful}
|
||||||
icon={<CheckCircleIcon className="h-5 w-5" />}
|
icon={<CheckCircleIcon className="h-5 w-5" />}
|
||||||
tone="success"
|
tone="success"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.landings.stats.revenue')}
|
label={t('admin.landings.stats.revenue')}
|
||||||
value={formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
|
value={money(stats.total_revenue_kopeks)}
|
||||||
icon={<BanknotesIcon className="h-5 w-5" />}
|
icon={<BanknotesIcon className="h-5 w-5" />}
|
||||||
tone="success"
|
tone="success"
|
||||||
/>
|
/>
|
||||||
@@ -394,317 +397,132 @@ export default function AdminLandingStats() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.landings.stats.avgPurchase')}
|
label={t('admin.landings.stats.avgPurchase')}
|
||||||
value={formatWithCurrency(stats.avg_purchase_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
|
value={money(stats.avg_purchase_kopeks)}
|
||||||
icon={<WalletIcon className="h-5 w-5" />}
|
icon={<WalletIcon className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Funnel + gift activation */}
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
{/* Daily Purchases & Revenue */}
|
|
||||||
<div className="bento-card">
|
<div className="bento-card">
|
||||||
<h3 className="mb-4 font-medium text-dark-200">
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||||
{t('admin.landings.stats.dailyChart')}
|
{t('admin.landings.stats.funnelTitle', 'Conversion funnel')}
|
||||||
</h3>
|
</h4>
|
||||||
{dailyData.length === 0 ? (
|
{stats.total_created === 0 ? (
|
||||||
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
|
<div className="flex h-[180px] items-center justify-center text-sm text-dark-500">
|
||||||
{t('admin.landings.stats.noPurchases')}
|
{t('admin.landings.stats.noPurchases')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<>
|
||||||
<AreaChart data={dailyData} margin={CHART_COMMON.CHART.MARGIN}>
|
<ResponsiveContainer width="100%" height={170}>
|
||||||
<defs>
|
<FunnelChart>
|
||||||
<linearGradient
|
<Funnel dataKey="value" data={funnelData} isAnimationActive>
|
||||||
id={`landingPurchaseGrad-${numericId}`}
|
{/* Center value labels (dark text reads on the light segments)
|
||||||
x1="0"
|
— left/right labels can clip in the half-width column. */}
|
||||||
y1="0"
|
<LabelList
|
||||||
x2="0"
|
position="center"
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset={CHART_COMMON.GRADIENT.START_OFFSET}
|
|
||||||
stopColor={colors.referrals}
|
|
||||||
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset={CHART_COMMON.GRADIENT.END_OFFSET}
|
|
||||||
stopColor={colors.referrals}
|
|
||||||
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient
|
|
||||||
id={`landingRevenueGrad-${numericId}`}
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset={CHART_COMMON.GRADIENT.START_OFFSET}
|
|
||||||
stopColor={colors.earnings}
|
|
||||||
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset={CHART_COMMON.GRADIENT.END_OFFSET}
|
|
||||||
stopColor={colors.earnings}
|
|
||||||
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient
|
|
||||||
id={`landingCreatedGrad-${numericId}`}
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset={CHART_COMMON.GRADIENT.START_OFFSET}
|
|
||||||
stopColor="#f59e0b"
|
|
||||||
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset={CHART_COMMON.GRADIENT.END_OFFSET}
|
|
||||||
stopColor="#f59e0b"
|
|
||||||
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="label"
|
|
||||||
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
|
|
||||||
stroke={colors.grid}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="left"
|
|
||||||
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
|
|
||||||
stroke={colors.grid}
|
|
||||||
allowDecimals={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="right"
|
|
||||||
orientation="right"
|
|
||||||
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
|
|
||||||
stroke={colors.grid}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: colors.tooltipBg,
|
|
||||||
border: `1px solid ${colors.tooltipBorder}`,
|
|
||||||
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
|
|
||||||
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
|
|
||||||
color: colors.label,
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: colors.label }}
|
|
||||||
itemStyle={{ color: colors.label }}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="created"
|
|
||||||
name={t('admin.landings.stats.created', 'Created')}
|
|
||||||
stroke="#f59e0b"
|
|
||||||
fill={`url(#landingCreatedGrad-${numericId})`}
|
|
||||||
strokeWidth={CHART_COMMON.STROKE_WIDTH}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="purchases"
|
|
||||||
name={t('admin.landings.stats.purchases')}
|
|
||||||
stroke={colors.referrals}
|
|
||||||
fill={`url(#landingPurchaseGrad-${numericId})`}
|
|
||||||
strokeWidth={CHART_COMMON.STROKE_WIDTH}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
yAxisId="right"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="revenue"
|
|
||||||
name={t('admin.landings.stats.revenueLabel')}
|
|
||||||
stroke={colors.earnings}
|
|
||||||
fill={`url(#landingRevenueGrad-${numericId})`}
|
|
||||||
strokeWidth={CHART_COMMON.STROKE_WIDTH}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Daily Purchases Bar Chart */}
|
|
||||||
<div className="bento-card">
|
|
||||||
<h3 className="mb-4 font-medium text-dark-200">
|
|
||||||
{t('admin.landings.stats.dailyPurchases', 'Daily purchases')}
|
|
||||||
</h3>
|
|
||||||
{dailyData.length === 0 ? (
|
|
||||||
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
|
|
||||||
{t('admin.landings.stats.noPurchases')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[...dailyData]
|
|
||||||
.slice(-7)
|
|
||||||
.reverse()
|
|
||||||
.map((day, i) => {
|
|
||||||
const purchasedPct =
|
|
||||||
(day.created || 0) > 0
|
|
||||||
? ((day.purchases || 0) / (day.created || 1)) * 100
|
|
||||||
: 0;
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex items-center gap-2">
|
|
||||||
<span className="w-10 shrink-0 text-right text-xs text-dark-500">
|
|
||||||
{day.label}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="group relative h-5 flex-1 overflow-hidden rounded-full bg-warning-500/80"
|
|
||||||
title={`${t('admin.landings.stats.created', 'Created')}: ${day.created || 0}\n${t('admin.landings.stats.paid', 'paid')}: ${day.purchases || 0}\n${t('admin.landings.stats.revenueLabel', 'Revenue')}: ${day.revenue?.toFixed(0) || 0} ${t('common.currency', '\u20BD')}\nCR: ${Math.round(purchasedPct)}%`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-y-0 left-0 rounded-full bg-accent-500"
|
|
||||||
style={{ width: `${purchasedPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="w-12 shrink-0 text-xs text-dark-400">
|
|
||||||
<span className="text-warning-400">{day.created || 0}</span>
|
|
||||||
<span className="text-dark-600">/</span>
|
|
||||||
<span className="text-accent-400">{day.purchases || 0}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="mt-2 flex items-center gap-4 text-xs text-dark-500">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-warning-500/80" />
|
|
||||||
<span>{t('admin.landings.stats.created', 'Created')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-accent-500" />
|
|
||||||
<span>{t('admin.landings.stats.paid', 'paid')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tariff Distribution -- full width */}
|
|
||||||
<div className="bento-card">
|
|
||||||
<h3 className="mb-4 font-medium text-dark-200">
|
|
||||||
{t('admin.landings.stats.tariffChart')}
|
|
||||||
</h3>
|
|
||||||
{tariffData.length === 0 ? (
|
|
||||||
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
|
|
||||||
{t('admin.landings.stats.noPurchases')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height={barChartHeight}>
|
|
||||||
<BarChart
|
|
||||||
data={tariffData}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ ...CHART_COMMON.CHART.MARGIN, left: 10 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
|
|
||||||
<XAxis
|
|
||||||
type="number"
|
|
||||||
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
|
|
||||||
stroke={colors.grid}
|
|
||||||
allowDecimals={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="name"
|
|
||||||
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
|
|
||||||
stroke={colors.grid}
|
|
||||||
width={100}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: colors.tooltipBg,
|
|
||||||
border: `1px solid ${colors.tooltipBorder}`,
|
|
||||||
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
|
|
||||||
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
|
|
||||||
color: colors.label,
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: colors.label }}
|
|
||||||
itemStyle={{ color: colors.label }}
|
|
||||||
formatter={(value: number | undefined) => {
|
|
||||||
return [value ?? 0, t('admin.landings.stats.purchases')];
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="purchases"
|
|
||||||
name={t('admin.landings.stats.purchases')}
|
|
||||||
radius={[0, 4, 4, 0]}
|
|
||||||
>
|
|
||||||
{tariffData.map((_, index) => (
|
|
||||||
<Cell key={index} fill={TARIFF_PALETTE[index % TARIFF_PALETTE.length]} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gift vs Regular Donut */}
|
|
||||||
{stats.total_purchases > 0 && (
|
|
||||||
<div className="bento-card">
|
|
||||||
<h3 className="mb-4 font-medium text-dark-200">
|
|
||||||
{t('admin.landings.stats.giftBreakdown')}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center justify-center gap-8">
|
|
||||||
<div className="relative">
|
|
||||||
<ResponsiveContainer width={160} height={160}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={donutData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={50}
|
|
||||||
outerRadius={70}
|
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
strokeWidth={0}
|
fill="#0a0f1a"
|
||||||
>
|
stroke="none"
|
||||||
{donutData.map((entry, index) => (
|
fontSize={14}
|
||||||
<Cell key={index} fill={entry.color} />
|
/>
|
||||||
|
{funnelData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={FUNNEL_COLORS[i % FUNNEL_COLORS.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Funnel>
|
||||||
<Tooltip
|
</FunnelChart>
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: colors.tooltipBg,
|
|
||||||
border: `1px solid ${colors.tooltipBorder}`,
|
|
||||||
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
|
|
||||||
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
|
|
||||||
color: colors.label,
|
|
||||||
}}
|
|
||||||
itemStyle={{ color: colors.label }}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
{/* Center text */}
|
<div className="mt-2 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-dark-400">
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
{funnelData.map((s, i) => (
|
||||||
<span className="text-lg font-bold text-dark-100">{stats.total_purchases}</span>
|
<span key={i} className="flex items-center gap-1.5">
|
||||||
</div>
|
<span
|
||||||
</div>
|
className="h-2 w-2 rounded-full"
|
||||||
<div className="space-y-3">
|
style={{ backgroundColor: FUNNEL_COLORS[i % FUNNEL_COLORS.length] }}
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="h-3 w-3 rounded-full"
|
|
||||||
style={{ backgroundColor: colors.referrals }}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-dark-300">
|
{s.name}: <span className="text-dark-200">{s.value}</span>
|
||||||
{t('admin.landings.stats.regular')}: {stats.total_regular}
|
|
||||||
</span>
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="text-accent-400">{stats.conversion_rate}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</>
|
||||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: GIFT_COLOR }} />
|
|
||||||
<span className="text-sm text-dark-300">
|
|
||||||
{t('admin.landings.stats.gifts')}: {stats.total_gifts}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||||
|
{t('admin.landings.stats.giftClaimTitle', 'Gift activation')}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold text-dark-100">
|
||||||
|
{stats.total_gifts_claimed}
|
||||||
|
<span className="text-base text-dark-500"> / {stats.total_gifts}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-dark-500">
|
||||||
|
{t('admin.landings.stats.giftClaimLabel', 'claimed / sent')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold text-accent-400">{giftClaimRate}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2 overflow-hidden rounded-full bg-dark-800/60">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-accent-500 transition-all"
|
||||||
|
style={{ width: `${giftClaimRate}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily charts */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<MultiSeriesAreaChart
|
||||||
|
data={dailyCounts}
|
||||||
|
title={t('admin.landings.stats.dailyPurchases', 'Daily purchases')}
|
||||||
|
chartId={`landing-counts-${numericId}`}
|
||||||
|
/>
|
||||||
|
<SimpleAreaChart
|
||||||
|
data={dailyRevenue}
|
||||||
|
title={t('admin.landings.stats.dailyRevenue', 'Daily revenue')}
|
||||||
|
chartId={`landing-revenue-${numericId}`}
|
||||||
|
valueLabel={t('admin.landings.stats.revenueLabel', 'Revenue')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tariff + payment method */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<BreakdownList
|
||||||
|
title={t('admin.landings.stats.tariffChart')}
|
||||||
|
items={tariffItems}
|
||||||
|
valueFormatter={(v) => formatWithCurrency(v)}
|
||||||
|
/>
|
||||||
|
<DonutChart
|
||||||
|
data={paymentDonut}
|
||||||
|
title={t('admin.landings.stats.byPaymentMethod', 'By payment method')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source + gift composition */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<BreakdownList
|
||||||
|
title={t('admin.landings.stats.bySource', 'Traffic sources')}
|
||||||
|
items={sourceItems}
|
||||||
|
/>
|
||||||
|
<DonutChart
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
name: t('admin.landings.stats.regular'),
|
||||||
|
value: stats.total_regular,
|
||||||
|
color: GIFT_DONUT.regular,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t('admin.landings.stats.gifts'),
|
||||||
|
value: stats.total_gifts,
|
||||||
|
color: GIFT_DONUT.gift,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
title={t('admin.landings.stats.giftBreakdown')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Purchases List */}
|
{/* Purchases List */}
|
||||||
<div className="bento-card">
|
<div className="bento-card">
|
||||||
@@ -746,9 +564,7 @@ export default function AdminLandingStats() {
|
|||||||
<PurchaseCard
|
<PurchaseCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
formatPrice={(kopeks) =>
|
formatPrice={(kopeks) => money(kopeks)}
|
||||||
formatWithCurrency(kopeks / CHART_COMMON.KOPEKS_DIVISOR)
|
|
||||||
}
|
|
||||||
lang={i18n.language}
|
lang={i18n.language}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user