diff --git a/src/api/landings.ts b/src/api/landings.ts index 3e7594b..8b18f9d 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -326,10 +326,22 @@ export interface LandingTariffStat { revenue_kopeks: number; } +export interface LandingPaymentMethodStat { + method: string; + purchases: number; + revenue_kopeks: number; +} + +export interface LandingSourceStat { + source: string; + purchases: number; +} + export interface LandingStatsResponse { total_purchases: number; total_revenue_kopeks: number; total_gifts: number; + total_gifts_claimed: number; total_regular: number; avg_purchase_kopeks: number; total_created: number; @@ -337,6 +349,8 @@ export interface LandingStatsResponse { conversion_rate: number; daily_stats: LandingDailyStat[]; tariff_stats: LandingTariffStat[]; + payment_method_stats: LandingPaymentMethodStat[]; + source_stats: LandingSourceStat[]; } export type PurchaseItemStatus = diff --git a/src/locales/en.json b/src/locales/en.json index a6b4092..42b174e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3978,6 +3978,12 @@ "dailyChart": "Purchases & Revenue by Day", "tariffChart": "Tariff Distribution", "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", "revenueLabel": "Revenue", "gifts": "Gifts", diff --git a/src/locales/fa.json b/src/locales/fa.json index e457de3..13d917d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3694,6 +3694,12 @@ "dailyChart": "خرید و درآمد روزانه", "tariffChart": "توزیع طرح‌ها", "giftBreakdown": "هدایا در مقابل عادی", + "funnelTitle": "قیف تبدیل", + "giftClaimTitle": "فعال‌سازی هدیه", + "giftClaimLabel": "دریافت‌شده / ارسال‌شده", + "dailyRevenue": "درآمد روزانه", + "byPaymentMethod": "بر اساس روش پرداخت", + "bySource": "منابع ترافیک", "purchases": "خریدها", "revenueLabel": "درآمد", "gifts": "هدایا", diff --git a/src/locales/ru.json b/src/locales/ru.json index 586eeb8..f4ef089 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4523,6 +4523,12 @@ "dailyChart": "Покупки и доход по дням", "tariffChart": "Распределение по тарифам", "giftBreakdown": "Подарки vs обычные", + "funnelTitle": "Воронка конверсии", + "giftClaimTitle": "Активация подарков", + "giftClaimLabel": "забрано / отправлено", + "dailyRevenue": "Выручка по дням", + "byPaymentMethod": "По способам оплаты", + "bySource": "Источники трафика", "purchases": "Покупки", "revenueLabel": "Доход", "gifts": "Подарки", diff --git a/src/locales/zh.json b/src/locales/zh.json index da7f73f..d695273 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3693,6 +3693,12 @@ "dailyChart": "每日购买与收入", "tariffChart": "套餐分布", "giftBreakdown": "礼物 vs 普通", + "funnelTitle": "转化漏斗", + "giftClaimTitle": "礼物激活", + "giftClaimLabel": "已领取 / 已发送", + "dailyRevenue": "每日收入", + "byPaymentMethod": "按支付方式", + "bySource": "流量来源", "purchases": "购买", "revenueLabel": "收入", "gifts": "礼物", diff --git a/src/pages/AdminLandingStats.tsx b/src/pages/AdminLandingStats.tsx index 0c725af..fc092e7 100644 --- a/src/pages/AdminLandingStats.tsx +++ b/src/pages/AdminLandingStats.tsx @@ -2,20 +2,7 @@ import { useState, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { - Area, - AreaChart, - Bar, - BarChart, - CartesianGrid, - Cell, - Pie, - PieChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; +import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer } from 'recharts'; import { adminLandingsApi, resolveLocaleDisplay, @@ -23,10 +10,13 @@ import { type LandingPurchaseItem, } from '../api/landings'; import { useCurrency } from '../hooks/useCurrency'; -import { useChartColors } from '../hooks/useChartColors'; import { CHART_COMMON } from '../constants/charts'; import { AdminBackButton } from '../components/admin'; 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 { ChartIcon, EmailIcon, @@ -44,8 +34,8 @@ import { ChevronRightIcon as ChevronRightSmall, } from '@/components/icons'; -const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6']; -const GIFT_COLOR = '#a855f7'; +const FUNNEL_COLORS = ['#f59e0b', '#34d399']; +const GIFT_DONUT = { regular: '#818cf8', gift: '#a855f7' }; const PURCHASE_STATUS_STYLES: Record = { pending: 'bg-warning-500/20 text-warning-400', @@ -189,7 +179,8 @@ export default function AdminLandingStats() { const isValidId = !isNaN(numericId); const navigate = useNavigate(); const { formatWithCurrency } = useCurrency(); - const colors = useChartColors(); + const divisor = CHART_COMMON.KOPEKS_DIVISOR; + const money = (kopeks: number) => formatWithCurrency(kopeks / divisor); // Purchases list state const [purchaseOffset, setPurchaseOffset] = useState(0); @@ -242,49 +233,59 @@ export default function AdminLandingStats() { const purchaseTotalPages = Math.ceil(purchaseTotal / PURCHASES_PAGE_SIZE); const purchaseCurrentPage = Math.floor(purchaseOffset / PURCHASES_PAGE_SIZE) + 1; - // Prepare daily chart data - const dailyData = useMemo(() => { + // Daily counts (created / paid) — flat data for MultiSeriesAreaChart + const dailyCounts = useMemo(() => { if (!stats) return []; - return stats.daily_stats.map((item) => ({ - label: (() => { - const d = new Date(item.date + 'T00:00:00'); - return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`; - })(), - created: item.created, - purchases: item.purchases, - revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR, - gifts: item.gifts, - })); - }, [stats]); + const createdLabel = t('admin.landings.stats.created', 'Created'); + const paidLabel = t('admin.landings.stats.paid', 'Paid'); + return stats.daily_stats.flatMap((d) => [ + { date: d.date, key: createdLabel, value: d.created }, + { date: d.date, key: paidLabel, value: d.purchases }, + ]); + }, [stats, t]); - // Prepare tariff chart data - const tariffData = useMemo(() => { + // Daily revenue — for SimpleAreaChart + const dailyRevenue = useMemo(() => { if (!stats) return []; - return stats.tariff_stats.map((item) => ({ - name: item.tariff_name, - purchases: item.purchases, - revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR, - })); - }, [stats]); + return stats.daily_stats.map((d) => ({ date: d.date, value: d.revenue_kopeks / divisor })); + }, [stats, divisor]); - // Donut data for gift vs regular - const donutData = useMemo(() => { + // Tariff breakdown (by revenue) + 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 []; return [ - { - name: t('admin.landings.stats.regular'), - value: stats.total_regular, - color: colors.referrals, - }, - { name: t('admin.landings.stats.gifts'), value: stats.total_gifts, color: GIFT_COLOR }, + { name: t('admin.landings.stats.created', 'Created'), value: stats.total_created }, + { name: t('admin.landings.stats.paid', 'Paid'), value: stats.total_successful }, ]; - }, [stats, t, colors.referrals]); - - // Bar chart height based on tariff count - const barChartHeight = useMemo(() => { - const count = tariffData.length; - return Math.max(220, count * 45 + 40); - }, [tariffData.length]); + }, [stats, t]); // Loading state if (isLoading) { @@ -317,6 +318,8 @@ export default function AdminLandingStats() { } 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 (
@@ -344,7 +347,7 @@ export default function AdminLandingStats() {
-
+
{/* Summary Cards */}
} tone="success" /> } tone="success" /> @@ -394,317 +397,132 @@ export default function AdminLandingStats() { /> } />
- {/* Charts */} + {/* Funnel + gift activation */}
- {/* Daily Purchases & Revenue */}
-

- {t('admin.landings.stats.dailyChart')} -

- {dailyData.length === 0 ? ( -
+

+ {t('admin.landings.stats.funnelTitle', 'Conversion funnel')} +

+ {stats.total_created === 0 ? ( +
{t('admin.landings.stats.noPurchases')}
) : ( - - - - - + + + + {/* Center value labels (dark text reads on the light segments) + — left/right labels can clip in the half-width column. */} + - - - - - - - - - - - - - - - - - - - - - - )} -
- - {/* Daily Purchases Bar Chart */} -
-

- {t('admin.landings.stats.dailyPurchases', 'Daily purchases')} -

- {dailyData.length === 0 ? ( -
- {t('admin.landings.stats.noPurchases')} -
- ) : ( -
- {[...dailyData] - .slice(-7) - .reverse() - .map((day, i) => { - const purchasedPct = - (day.created || 0) > 0 - ? ((day.purchases || 0) / (day.created || 1)) * 100 - : 0; - return ( -
- - {day.label} - -
-
-
- - {day.created || 0} - / - {day.purchases || 0} - -
- ); - })} -
-
-
- {t('admin.landings.stats.created', 'Created')} -
-
-
- {t('admin.landings.stats.paid', 'paid')} -
-
-
- )} -
-
- - {/* Tariff Distribution -- full width */} -
-

- {t('admin.landings.stats.tariffChart')} -

- {tariffData.length === 0 ? ( -
- {t('admin.landings.stats.noPurchases')} -
- ) : ( - - - - - - { - return [value ?? 0, t('admin.landings.stats.purchases')]; - }} - /> - - {tariffData.map((_, index) => ( - - ))} - - - - )} -
- - {/* Gift vs Regular Donut */} - {stats.total_purchases > 0 && ( -
-

- {t('admin.landings.stats.giftBreakdown')} -

-
-
- - - - {donutData.map((entry, index) => ( - + {funnelData.map((_, i) => ( + ))} - - - + + - {/* Center text */} -
- {stats.total_purchases} -
-
-
-
-
- - {t('admin.landings.stats.regular')}: {stats.total_regular} - -
-
-
- - {t('admin.landings.stats.gifts')}: {stats.total_gifts} - +
+ {funnelData.map((s, i) => ( + + + {s.name}: {s.value} + + ))} + {stats.conversion_rate}% +
+ + )} +
+ +
+

+ {t('admin.landings.stats.giftClaimTitle', 'Gift activation')} +

+
+
+
+ {stats.total_gifts_claimed} + / {stats.total_gifts} +
+
+ {t('admin.landings.stats.giftClaimLabel', 'claimed / sent')}
+
{giftClaimRate}%
+
+
+
- )} +
+ + {/* Daily charts */} +
+ + +
+ + {/* Tariff + payment method */} +
+ formatWithCurrency(v)} + /> + +
+ + {/* Source + gift composition */} +
+ + +
{/* Purchases List */}
@@ -746,9 +564,7 @@ export default function AdminLandingStats() { - formatWithCurrency(kopeks / CHART_COMMON.KOPEKS_DIVISOR) - } + formatPrice={(kopeks) => money(kopeks)} lang={i18n.language} t={t} />