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:
c0mrade
2026-06-03 15:11:47 +03:00
parent 966c2bf03a
commit 78c633ff54
6 changed files with 209 additions and 355 deletions

View File

@@ -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 =

View File

@@ -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",

View File

@@ -3694,6 +3694,12 @@
"dailyChart": "خرید و درآمد روزانه", "dailyChart": "خرید و درآمد روزانه",
"tariffChart": "توزیع طرح‌ها", "tariffChart": "توزیع طرح‌ها",
"giftBreakdown": "هدایا در مقابل عادی", "giftBreakdown": "هدایا در مقابل عادی",
"funnelTitle": "قیف تبدیل",
"giftClaimTitle": "فعال‌سازی هدیه",
"giftClaimLabel": "دریافت‌شده / ارسال‌شده",
"dailyRevenue": "درآمد روزانه",
"byPaymentMethod": "بر اساس روش پرداخت",
"bySource": "منابع ترافیک",
"purchases": "خریدها", "purchases": "خریدها",
"revenueLabel": "درآمد", "revenueLabel": "درآمد",
"gifts": "هدایا", "gifts": "هدایا",

View File

@@ -4523,6 +4523,12 @@
"dailyChart": "Покупки и доход по дням", "dailyChart": "Покупки и доход по дням",
"tariffChart": "Распределение по тарифам", "tariffChart": "Распределение по тарифам",
"giftBreakdown": "Подарки vs обычные", "giftBreakdown": "Подарки vs обычные",
"funnelTitle": "Воронка конверсии",
"giftClaimTitle": "Активация подарков",
"giftClaimLabel": "забрано / отправлено",
"dailyRevenue": "Выручка по дням",
"byPaymentMethod": "По способам оплаты",
"bySource": "Источники трафика",
"purchases": "Покупки", "purchases": "Покупки",
"revenueLabel": "Доход", "revenueLabel": "Доход",
"gifts": "Подарки", "gifts": "Подарки",

View File

@@ -3693,6 +3693,12 @@
"dailyChart": "每日购买与收入", "dailyChart": "每日购买与收入",
"tariffChart": "套餐分布", "tariffChart": "套餐分布",
"giftBreakdown": "礼物 vs 普通", "giftBreakdown": "礼物 vs 普通",
"funnelTitle": "转化漏斗",
"giftClaimTitle": "礼物激活",
"giftClaimLabel": "已领取 / 已发送",
"dailyRevenue": "每日收入",
"byPaymentMethod": "按支付方式",
"bySource": "流量来源",
"purchases": "购买", "purchases": "购买",
"revenueLabel": "收入", "revenueLabel": "收入",
"gifts": "礼物", "gifts": "礼物",

View File

@@ -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}
/> />