From 30190199ed88cde6aea575eed44a2f7d4361dbdc Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Mar 2026 09:35:47 +0300 Subject: [PATCH] feat: add landing page statistics page with recharts Add AdminLandingStats page with: - Summary cards (purchases, revenue, gifts, conversion) - AreaChart with dual Y-axis (daily purchases + revenue) - BarChart (tariff distribution) - PieChart donut (gift vs regular breakdown) - Responsive layout for desktop and mobile - Stats button on landing cards list Includes API types, route, translations (ru/en). --- src/App.tsx | 11 + src/api/landings.ts | 36 +++ src/locales/en.json | 24 +- src/locales/ru.json | 24 +- src/pages/AdminLandingStats.tsx | 445 ++++++++++++++++++++++++++++++++ src/pages/AdminLandings.tsx | 27 ++ 6 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 src/pages/AdminLandingStats.tsx diff --git a/src/App.tsx b/src/App.tsx index f7ace2c..46e0ea8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -110,6 +110,7 @@ const AdminPolicyEdit = lazy(() => import('./pages/AdminPolicyEdit')); const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog')); const AdminLandings = lazy(() => import('./pages/AdminLandings')); const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor')); +const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats')); function ProtectedRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); @@ -542,6 +543,16 @@ function App() { } /> + + + + + + } + /> => { await apiClient.put('/cabinet/admin/landings/order', { landing_ids: landingIds }); }, + + getStats: async (id: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/landings/${id}/stats`); + return response.data; + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index 5f7347f..b523618 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3227,7 +3227,29 @@ "discountOverrides": "Per-tariff overrides", "discountOverridesHint": "Leave empty to use global discount", "discountPreview": "Preview", - "discountActive": "Discount" + "discountActive": "Discount", + "statistics": "Statistics", + "stats": { + "title": "Landing Statistics", + "totalPurchases": "Purchases", + "revenue": "Revenue", + "giftPurchases": "Gifts", + "regularPurchases": "Regular", + "conversionRate": "Conversion", + "avgPurchase": "Avg. Check", + "dailyChart": "Purchases & Revenue by Day", + "tariffChart": "Tariff Distribution", + "giftBreakdown": "Gifts vs Regular", + "purchases": "Purchases", + "revenueLabel": "Revenue", + "gifts": "Gifts", + "regular": "Regular", + "created": "Created", + "successful": "Successful", + "funnel": "Funnel", + "loadError": "Failed to load statistics", + "noPurchases": "No purchases" + } } }, "adminUpdates": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 341951f..c2175bb 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3778,7 +3778,29 @@ "discountOverrides": "Индивидуальные скидки по тарифам", "discountOverridesHint": "Оставьте пустым для использования общей скидки", "discountPreview": "Предпросмотр", - "discountActive": "Скидка" + "discountActive": "Скидка", + "statistics": "Статистика", + "stats": { + "title": "Статистика лендинга", + "totalPurchases": "Покупки", + "revenue": "Доход", + "giftPurchases": "Подарки", + "regularPurchases": "Обычные", + "conversionRate": "Конверсия", + "avgPurchase": "Средний чек", + "dailyChart": "Покупки и доход по дням", + "tariffChart": "Распределение по тарифам", + "giftBreakdown": "Подарки vs обычные", + "purchases": "Покупки", + "revenueLabel": "Доход", + "gifts": "Подарки", + "regular": "Обычные", + "created": "Создано", + "successful": "Успешных", + "funnel": "Воронка", + "loadError": "Не удалось загрузить статистику", + "noPurchases": "Нет покупок" + } } }, "adminUpdates": { diff --git a/src/pages/AdminLandingStats.tsx b/src/pages/AdminLandingStats.tsx new file mode 100644 index 0000000..a041cce --- /dev/null +++ b/src/pages/AdminLandingStats.tsx @@ -0,0 +1,445 @@ +import { 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 { adminLandingsApi, resolveLocaleDisplay } from '../api/landings'; +import { useCurrency } from '../hooks/useCurrency'; +import { useChartColors } from '../hooks/useChartColors'; +import { CHART_COMMON } from '../constants/charts'; +import { AdminBackButton } from '../components/admin'; + +// Icons +const ChartIcon = () => ( + + + +); + +const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6']; +const GIFT_COLOR = '#a855f7'; + +export default function AdminLandingStats() { + const { t, i18n } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const numericId = id ? Number(id) : NaN; + const isValidId = !isNaN(numericId); + const navigate = useNavigate(); + const { formatWithCurrency } = useCurrency(); + const colors = useChartColors(); + + // Fetch stats + const { + data: stats, + isLoading, + error, + } = useQuery({ + queryKey: ['landing-stats', numericId], + queryFn: () => adminLandingsApi.getStats(numericId), + enabled: isValidId, + staleTime: CHART_COMMON.STALE_TIME, + }); + + // Fetch landing detail for header + const { data: landing } = useQuery({ + queryKey: ['admin-landing', numericId], + queryFn: () => adminLandingsApi.get(numericId), + enabled: isValidId, + staleTime: CHART_COMMON.STALE_TIME, + }); + + // Prepare daily chart data + const dailyData = useMemo(() => { + if (!stats) return []; + return stats.daily_stats.map((item) => ({ + label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, { + month: 'short', + day: 'numeric', + }), + purchases: item.purchases, + revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR, + gifts: item.gifts, + })); + }, [stats, i18n.language]); + + // Prepare tariff chart data + const tariffData = 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]); + + // Donut data for gift vs regular + const donutData = 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 }, + ]; + }, [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]); + + // Loading state + if (isLoading) { + return ( +
+
+
+ ); + } + + // Error state + if (error || !stats) { + return ( +
+
+ +

{t('admin.landings.stats.title')}

+
+
+

{t('admin.landings.stats.loadError')}

+ +
+
+ ); + } + + const landingTitle = landing ? resolveLocaleDisplay(landing.title) : `#${numericId}`; + + return ( +
+ {/* Header */} +
+
+ +
+ +
+
+

{landingTitle}

+
+ {landing?.is_active ? ( + + {t('admin.landings.active')} + + ) : ( + + {t('admin.landings.inactive')} + + )} +
+
+
+
+ +
+ {/* Summary Cards */} +
+
+
+ {stats.total_purchases} +
+
{t('admin.landings.stats.totalPurchases')}
+
+
+
+ {formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)} +
+
{t('admin.landings.stats.revenue')}
+
+
+
{stats.total_gifts}
+
{t('admin.landings.stats.giftPurchases')}
+
+
+
+ {stats.conversion_rate}% +
+
{t('admin.landings.stats.conversionRate')}
+
+
+ + {/* Charts */} +
+ {/* Daily Purchases & Revenue */} +
+

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

+ {dailyData.length === 0 ? ( +
+ {t('admin.landings.stats.noPurchases')} +
+ ) : ( + + + + + + + + + + + + + + + + + + + + + + )} +
+ + {/* Tariff Distribution */} +
+

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

+ {tariffData.length === 0 ? ( +
+ {t('admin.landings.stats.noPurchases')} +
+ ) : ( + + + + + + { + return [value ?? 0, t('admin.landings.stats.purchases')]; + }} + /> + + {tariffData.map((_, index) => ( + + ))} + + + + )} +
+
+ + {/* Additional Stats Row */} +
+
+
+ {t('admin.landings.stats.avgPurchase')} +
+
+ {formatWithCurrency(stats.avg_purchase_kopeks / CHART_COMMON.KOPEKS_DIVISOR)} +
+
+
+
+ {t('admin.landings.stats.regularPurchases')} +
+
{stats.total_regular}
+
+
+
{t('admin.landings.stats.funnel')}
+
+ {stats.total_created}{' '} + {t('admin.landings.stats.created')} + {' / '} + {stats.total_successful}{' '} + {t('admin.landings.stats.successful')} +
+
+
+ + {/* Gift vs Regular Donut */} + {stats.total_purchases > 0 && ( +
+

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

+
+
+ + + + {donutData.map((entry, index) => ( + + ))} + + + + + {/* Center text */} +
+ {stats.total_purchases} +
+
+
+
+
+ + {t('admin.landings.stats.regular')}: {stats.total_regular} + +
+
+
+ + {t('admin.landings.stats.gifts')}: {stats.total_gifts} + +
+
+
+
+ )} +
+
+ ); +} diff --git a/src/pages/AdminLandings.tsx b/src/pages/AdminLandings.tsx index 96fd103..578398d 100644 --- a/src/pages/AdminLandings.tsx +++ b/src/pages/AdminLandings.tsx @@ -79,11 +79,22 @@ const CopyIcon = () => ( ); +const StatsChartIcon = () => ( + + + +); + // ============ Sortable Landing Card ============ interface SortableLandingCardProps { landing: LandingListItem; onEdit: () => void; + onStats: () => void; onDelete: () => void; onToggle: () => void; onCopyUrl: () => void; @@ -93,6 +104,7 @@ interface SortableLandingCardProps { function SortableLandingCard({ landing, onEdit, + onStats, onDelete, onToggle, onCopyUrl, @@ -182,6 +194,13 @@ function SortableLandingCard({ > + +