From ec346d38d8e74a8efade19ac234c2f2261819747 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 20:29:03 +0300 Subject: [PATCH 01/25] =?UTF-8?q?fix(navigation):=20single-tariff=20subscr?= =?UTF-8?q?iption=20detail=20=D0=B2=D1=81=D0=B5=D0=B3=D0=B4=D0=B0=20Close,?= =?UTF-8?q?=20=D0=BD=D0=B5=20Back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Регрессия после a62d689: Back прятался только при deep-link входе (depthRef === 0). При внутреннем переходе на /subscriptions/:id (depth > 0) показывался Back, а в single-tariff «назад» уходил в /subscriptions, который тут же редиректит обратно на деталь (Subscriptions.tsx) — петля, и юзер видел Back вместо Close. Решение depth-независимое: если список-редирект всё равно отбрасывает на эту же страницу (single-tariff, ≤1 подписки = listRedirectsToDetail, инверсия listIsSafe из обработчика), деталь — это верхний экран. Прячем Back всегда, Telegram показывает нативный Close. Петля невозможна по построению: без кнопки Back обработчик navigate(-1) в редиректящий список просто не вызвать. Multi-tariff и >1 подписки сохраняют реальный Back к осмысленному списку. --- src/AppWithNavigator.tsx | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index ba3a78b..c89d016 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -28,11 +28,11 @@ const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as /** Pages reachable from bottom nav — treat as top-level (no back button). */ const BOTTOM_NAV_PATHS = ['/', '/subscriptions', '/balance', '/referral', '/support', '/wheel']; -/** Matches /subscriptions/:numericId. Single-tariff users land here straight - * from bot deep-links, and their /subscriptions list auto-redirects right back - * to this page (Subscriptions.tsx). So on a genuine deep-link entry (in-app - * navigation depth 0) we hide the back button and let Telegram surface its - * native Close (X); when there IS in-app history we show it and navigate back. */ +/** Matches /subscriptions/:numericId. When the user has a single tariff and at + * most one subscription, the /subscriptions list auto-redirects straight back + * here (Subscriptions.tsx), so this page is effectively top-level: we hide the + * back button and let Telegram surface its native Close (X). Multi-tariff users + * (or anyone with >1 subscription) keep a real Back to their meaningful list. */ const SUBSCRIPTION_DETAIL_RE = /^\/subscriptions\/\d+\/?$/; function TelegramBackButton() { @@ -71,27 +71,39 @@ function TelegramBackButton() { enabled: isInTelegramWebApp(), }); const isMultiTariff = subData?.multi_tariff_enabled ?? false; + const subsCount = subData?.subscriptions?.length ?? 0; + // The /subscriptions list silently redirects straight back to the open detail + // page when there is a single tariff and at most one subscription + // (Subscriptions.tsx). In that mode the detail page IS the top-level screen — + // there is no meaningful "back" target. Inverse of the handler's `listIsSafe`. + // Defaults to true on a cold cache (isMultiTariff=false, subsCount=0): hiding + // Back is fail-closed — far better than briefly arming the looping Back. + const listRedirectsToDetail = !isMultiTariff && subsCount <= 1; // Refs so the stable back handler (memoised with []) reads fresh values // without re-subscribing — re-subscription lets a component's local handler // overwrite ours via Telegram's singleton onBackButtonClick (issue #436). const isMultiTariffRef = useRef(isMultiTariff); isMultiTariffRef.current = isMultiTariff; - const subsCountRef = useRef(subData?.subscriptions?.length ?? 0); - subsCountRef.current = subData?.subscriptions?.length ?? 0; + const subsCountRef = useRef(subsCount); + subsCountRef.current = subsCount; useEffect(() => { const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname); - const isSingleTariffDetailDeepLink = - !isMultiTariff && SUBSCRIPTION_DETAIL_RE.test(location.pathname) && depthRef.current === 0; + // Depth-independent on purpose: whether the user deep-linked in or navigated + // here in-app, a single-tariff detail whose list just bounces back has no + // real "back" target. Showing Back here is exactly what looped through the + // redirecting /subscriptions list (#436); hiding it always surfaces Close. + const isRedirectingSubscriptionDetail = + listRedirectsToDetail && SUBSCRIPTION_DETAIL_RE.test(location.pathname); try { - if (isTopLevel || isSingleTariffDetailDeepLink) { + if (isTopLevel || isRedirectingSubscriptionDetail) { hideBackButton(); } else { showBackButton(); } } catch {} - }, [location, isMultiTariff]); + }, [location, listRedirectsToDetail]); // Stable handler — ref prevents re-subscription on every render const handler = useCallback(() => { From 33f8ddac9a17047766ff924cefddf0e83e689cbf Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 14:45:43 +0300 Subject: [PATCH 02/25] =?UTF-8?q?fix(payment-labels):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BB=D0=B5=D0=B9=D0=B1=D0=BB?= =?UTF-8?q?=20OverPay=20(=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D1=81=D1=8F=20=D1=81=D1=8B=D1=80=D1=8B=D0=BC=20=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=BE=D0=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit METHOD_LABELS не содержал overpay, хотя метод есть в боте — в статистике/истории он отображался как сырой ключ «overpay». Добавил человекочитаемое название. --- src/constants/paymentMethods.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/constants/paymentMethods.ts b/src/constants/paymentMethods.ts index 3f07f1a..8154c4f 100644 --- a/src/constants/paymentMethods.ts +++ b/src/constants/paymentMethods.ts @@ -18,6 +18,7 @@ export const METHOD_LABELS: Record = { paypear: 'PayPear', rollypay: 'RollyPay', aurapay: 'AuraPay', + overpay: 'OverPay', etoplatezhi: 'Etoplatezhi', antilopay: 'Antilopay', jupiter: 'Jupiter', From 5e837621ad5649b8c6d78d74218cbee2258d0f2b Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 14:57:40 +0300 Subject: [PATCH 03/25] =?UTF-8?q?fix(stats-charts):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20focus-outline=20=D0=B0=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D1=84=D0=B0=D0=BA=D1=82=20=D0=BF=D1=80=D0=B8=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B8=D0=BA=D0=B5=20=D0=BF=D0=BE=20=D0=B3=D1=80=D0=B0=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В recharts столбцы/секторы/точки становятся фокусируемыми, и при клике браузер рисует свою обводку — артефакт на read-only графиках. Добавил гашение outline для .recharts-wrapper/.surface/.sector/.rectangle. --- src/styles/globals.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/styles/globals.css b/src/styles/globals.css index 849ca51..1ed7da8 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1380,6 +1380,18 @@ input[type='checkbox']:hover:not(:checked) { background-color: rgb(var(--color-dark-600)); } +/* Recharts: bars, pie sectors and dots become focusable, so clicking one draws + the browser's focus outline — a visual artifact on read-only charts. Kill it. */ +.recharts-wrapper:focus, +.recharts-wrapper:focus-visible, +.recharts-wrapper :focus, +.recharts-wrapper :focus-visible, +.recharts-surface, +.recharts-sector, +.recharts-rectangle { + outline: none; +} + .light input[type='checkbox'] { border-color: rgb(var(--color-champagne-400)); background-color: rgb(var(--color-champagne-100)); From a75f42d056afe7183cf070cf0d9e9cb88630f94a Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 15:10:06 +0300 Subject: [PATCH 04/25] =?UTF-8?q?feat(sales-stats):=20=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D0=B5=D0=BC=D1=8B=D0=B5=20=D1=80=D0=B0=D0=B7=D0=B1=D0=B8?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D0=B8=20stacked-=D0=B1=D0=B0=D1=80=D1=8B,?= =?UTF-8?q?=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B8=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=91=D0=B6=D0=B5=D0=BA,=20=D0=B2=D1=8B=D1=80=D1=83?= =?UTF-8?q?=D1=87=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полировка статистики продаж (читаемее/информативнее): - BreakdownList — категорийные разбивки списком (иконка/цвет + значение + доля % + пропорциональный мини-бар) вместо bar-графика. В «Депозиты → по методам» теперь с иконками платёжек (PaymentMethodIcon), в «Продажи → по тарифам» — с долями. - StackedBarChart — «по дням разбивкой» теперь stacked-бар вместо наслаивающихся полупрозрачных area (читалось плохо); тултип по дню показывает все серии. - Продажи: добавлена карточка «Выручка» (total_revenue бэк отдавал, но не показывался). --- src/components/sales-stats/BreakdownList.tsx | 80 +++++++++++ src/components/sales-stats/DepositsTab.tsx | 20 +-- src/components/sales-stats/SalesTab.tsx | 23 +-- .../sales-stats/StackedBarChart.tsx | 135 ++++++++++++++++++ src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + 8 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 src/components/sales-stats/BreakdownList.tsx create mode 100644 src/components/sales-stats/StackedBarChart.tsx diff --git a/src/components/sales-stats/BreakdownList.tsx b/src/components/sales-stats/BreakdownList.tsx new file mode 100644 index 0000000..a3c7065 --- /dev/null +++ b/src/components/sales-stats/BreakdownList.tsx @@ -0,0 +1,80 @@ +import { type ReactNode, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { SALES_STATS } from '../../constants/salesStats'; + +export interface BreakdownItem { + key: string; + label: string; + value: number; + color?: string; + icon?: ReactNode; +} + +interface BreakdownListProps { + title: string; + items: BreakdownItem[]; + /** Formats the value shown on the right (e.g. money). Defaults to the raw number. */ + valueFormatter?: (value: number) => string; +} + +/** + * Categorical breakdown rendered as a ranked list instead of a bar chart: + * icon/colour dot + label + value + share-% + a proportional mini bar. Far more + * readable than a recharts bar for "by method / by tariff / by package" data. + */ +export function BreakdownList({ title, items, valueFormatter }: BreakdownListProps) { + const { t } = useTranslation(); + + const { sorted, total, max } = useMemo(() => { + const sortedItems = [...items].sort((a, b) => b.value - a.value); + const totalValue = sortedItems.reduce((sum, item) => sum + item.value, 0); + const maxValue = sortedItems.length > 0 ? sortedItems[0].value : 0; + return { sorted: sortedItems, total: totalValue, max: maxValue }; + }, [items]); + + return ( +
+

{title}

+ {sorted.length === 0 ? ( +
{t('common.noData')}
+ ) : ( +
+ {sorted.map((item, index) => { + const color = + item.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]; + const share = total > 0 ? (item.value / total) * 100 : 0; + const barWidth = max > 0 ? (item.value / max) * 100 : 0; + return ( +
+
+ {item.icon ?? ( + + )} + + {item.label} + + + {valueFormatter ? valueFormatter(item.value) : item.value} + + + {share.toFixed(1)}% + +
+
+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/sales-stats/DepositsTab.tsx b/src/components/sales-stats/DepositsTab.tsx index e62599c..8aa3003 100644 --- a/src/components/sales-stats/DepositsTab.tsx +++ b/src/components/sales-stats/DepositsTab.tsx @@ -9,9 +9,11 @@ import { SALES_STATS } from '../../constants/salesStats'; import { useCurrency } from '../../hooks/useCurrency'; import { StatCard } from '../stats'; -import { MultiSeriesAreaChart } from './MultiSeriesAreaChart'; +import PaymentMethodIcon from '../PaymentMethodIcon'; + +import { BreakdownList } from './BreakdownList'; import { SimpleAreaChart } from './SimpleAreaChart'; -import { SimpleBarChart } from './SimpleBarChart'; +import { StackedBarChart } from './StackedBarChart'; interface DepositsTabProps { params: SalesStatsParams; @@ -29,11 +31,13 @@ export function DepositsTab({ params }: DepositsTabProps) { const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]); - const methodBarData = useMemo( + const methodBreakdown = useMemo( () => data?.by_method.map((item) => ({ - name: METHOD_LABELS[item.method] || item.method, + key: item.method, + label: METHOD_LABELS[item.method] || item.method, value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR, + icon: , })) ?? [], [data?.by_method], ); @@ -89,9 +93,9 @@ export function DepositsTab({ params }: DepositsTabProps) { />
- @@ -102,11 +106,9 @@ export function DepositsTab({ params }: DepositsTabProps) { valueLabel={t('admin.salesStats.deposits.revenue')} /> - diff --git a/src/components/sales-stats/SalesTab.tsx b/src/components/sales-stats/SalesTab.tsx index 5b7bc44..58dc043 100644 --- a/src/components/sales-stats/SalesTab.tsx +++ b/src/components/sales-stats/SalesTab.tsx @@ -8,10 +8,10 @@ import { SALES_STATS } from '../../constants/salesStats'; import { useCurrency } from '../../hooks/useCurrency'; import { StatCard } from '../stats'; +import { BreakdownList } from './BreakdownList'; import { DonutChart } from './DonutChart'; -import { MultiSeriesAreaChart } from './MultiSeriesAreaChart'; import { SimpleAreaChart } from './SimpleAreaChart'; -import { SimpleBarChart } from './SimpleBarChart'; +import { StackedBarChart } from './StackedBarChart'; interface SalesTabProps { params: SalesStatsParams; @@ -48,8 +48,9 @@ export function SalesTab({ params }: SalesTabProps) { return
{t('admin.salesStats.loadError')}
; } - const tariffBarData = data.by_tariff.map((item) => ({ - name: item.tariff_name, + const tariffBreakdown = data.by_tariff.map((item) => ({ + key: String(item.tariff_id), + label: item.tariff_name, value: item.count, })); @@ -65,8 +66,13 @@ export function SalesTab({ params }: SalesTabProps) { return (
-
+
+
- +
@@ -87,11 +93,10 @@ export function SalesTab({ params }: SalesTabProps) { valueLabel={t('admin.salesStats.summary.revenue')} /> - String(v)} />
); diff --git a/src/components/sales-stats/StackedBarChart.tsx b/src/components/sales-stats/StackedBarChart.tsx new file mode 100644 index 0000000..8a13edb --- /dev/null +++ b/src/components/sales-stats/StackedBarChart.tsx @@ -0,0 +1,135 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { SALES_STATS } from '../../constants/salesStats'; +import { useChartColors } from '../../hooks/useChartColors'; + +interface StackedBarChartProps { + data: { date: string; key: string; value: number }[]; + title: string; + valueFormatter?: (value: number) => string; + height?: number; +} + +/** + * Composition-over-time as a STACKED bar chart. Replaces the overlapping + * multi-series area chart for "daily by method / by tariff" — stacked bars read + * far cleaner (no layered translucent areas hiding each other) and the tooltip + * shows every series for the hovered day plus the day total. + */ +export function StackedBarChart({ + data, + title, + valueFormatter, + height = SALES_STATS.CHART.HEIGHT, +}: StackedBarChartProps) { + const { t, i18n } = useTranslation(); + const colors = useChartColors(); + + const { chartData, seriesKeys } = useMemo(() => { + const dateMap = new Map>(); + const keySet = new Set(); + + for (const item of data) { + keySet.add(item.key); + const existing = dateMap.get(item.date) || {}; + existing[item.key] = (existing[item.key] || 0) + item.value; + dateMap.set(item.date, existing); + } + + const keys = Array.from(keySet).sort(); + const sortedDates = Array.from(dateMap.keys()).sort(); + const pivoted = sortedDates.map((date) => { + const row: Record = { + date, + label: new Date(date + 'T00:00:00').toLocaleDateString(i18n.language, { + month: 'short', + day: 'numeric', + }), + }; + const values = dateMap.get(date) || {}; + for (const key of keys) { + row[key] = values[key] || 0; + } + return row; + }); + + return { chartData: pivoted, seriesKeys: keys }; + }, [data, i18n.language]); + + if (data.length === 0) { + return ( +
+

{title}

+
+ {t('common.noData')} +
+
+ ); + } + + return ( +
+

{title}

+ + + + + + [ + valueFormatter ? valueFormatter(value ?? 0) : (value ?? 0), + name || '', + ]} + /> + + {seriesKeys.map((key, index) => ( + + ))} + + +
+ ); +} diff --git a/src/locales/en.json b/src/locales/en.json index 0976dba..d29d7fa 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1317,6 +1317,7 @@ }, "sales": { "totalSales": "Total Sales", + "totalRevenue": "Revenue", "avgOrder": "Avg Order", "topTariff": "Top Tariff", "byTariff": "Sales by Tariff", diff --git a/src/locales/fa.json b/src/locales/fa.json index 7c4e281..ac32a24 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1190,6 +1190,7 @@ }, "sales": { "totalSales": "کل فروش", + "totalRevenue": "درآمد", "avgOrder": "میانگین سفارش", "topTariff": "تعرفه برتر", "byTariff": "بر اساس تعرفه", diff --git a/src/locales/ru.json b/src/locales/ru.json index bf8081d..5e95ba5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1339,6 +1339,7 @@ }, "sales": { "totalSales": "Всего продаж", + "totalRevenue": "Выручка", "avgOrder": "Средний чек", "topTariff": "Топ тариф", "byTariff": "Продажи по тарифам", diff --git a/src/locales/zh.json b/src/locales/zh.json index bcc6376..69ffe95 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1190,6 +1190,7 @@ }, "sales": { "totalSales": "总销售", + "totalRevenue": "营收", "avgOrder": "平均订单", "topTariff": "热门套餐", "byTariff": "按套餐", From a685e542aa1e3863b25cac52af6e12edebc6e3c7 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 15:15:34 +0300 Subject: [PATCH 05/25] =?UTF-8?q?feat(sales-stats):=20=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4-?= =?UTF-8?q?=D0=BA-=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D1=83=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=D1=85?= =?UTF-8?q?=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StatCard получил опциональную дельту (стрелка + %), а страница статистики тянет сводку ещё и за предыдущий равный период и считает рост/падение для метрик периода: выручка, новые триалы, продления, доход с допов, ручные пополнения. Для «всего времени» сравнение не показывается (не с чем сравнивать). --- src/components/stats/StatCard.tsx | 24 +++++++++++-- src/pages/AdminSalesStats.tsx | 58 +++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx index c632fee..a983a6b 100644 --- a/src/components/stats/StatCard.tsx +++ b/src/components/stats/StatCard.tsx @@ -1,12 +1,22 @@ import { type ReactNode } from 'react'; +import { TREND_STYLES } from './constants'; + const DEFAULT_VALUE_CLASS = 'text-dark-100'; +export interface StatCardDelta { + /** Signed percent change vs the comparison period. */ + percent: number; + trend: 'up' | 'down' | 'stable'; +} + interface StatCardProps { label: string; value: string | number; icon?: ReactNode; valueClassName?: string; + /** Optional period-over-period change shown next to the value. */ + delta?: StatCardDelta | null; } export function StatCard({ @@ -14,15 +24,25 @@ export function StatCard({ value, icon, valueClassName = DEFAULT_VALUE_CLASS, + delta, }: StatCardProps) { + const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null; + return (
{icon} {label}
-
- {value} +
+ + {value} + + {trendStyle && ( + + {trendStyle.arrow} {Math.abs(delta!.percent)}% + + )}
); diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index 5fb400e..db40db5 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -19,6 +19,40 @@ import { type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits'; +type Delta = { percent: number; trend: 'up' | 'down' | 'stable' }; + +/** Same-length window immediately before the selected one, for period-over-period + * deltas. Returns null for "all time" — nothing meaningful to compare against. */ +function getPreviousPeriodParams(period: { + days?: number; + startDate?: string; + endDate?: string; +}): SalesStatsParams | null { + if (period.startDate && period.endDate) { + const start = new Date(period.startDate).getTime(); + const length = new Date(period.endDate).getTime() - start; + return { + start_date: new Date(start - length).toISOString(), + end_date: new Date(start).toISOString(), + }; + } + if (period.days !== undefined && period.days > 0) { + const dayMs = 86_400_000; + const now = Date.now(); + return { + start_date: new Date(now - 2 * period.days * dayMs).toISOString(), + end_date: new Date(now - period.days * dayMs).toISOString(), + }; + } + return null; +} + +function computeDelta(current: number, previous: number): Delta | null { + if (previous === 0) return current === 0 ? null : { percent: 100, trend: 'up' }; + const percent = Math.round(((current - previous) / previous) * 1000) / 10; + return { percent, trend: percent > 0 ? 'up' : percent < 0 ? 'down' : 'stable' }; +} + export default function AdminSalesStats() { const { t } = useTranslation(); const { formatWithCurrency } = useCurrency(); @@ -52,6 +86,25 @@ export default function AdminSalesStats() { enabled: isValidPeriod, }); + const prevParams = useMemo(() => getPreviousPeriodParams(period), [period]); + const { data: prevSummary } = useQuery({ + queryKey: ['sales-stats', 'summary', prevParams], + queryFn: () => salesStatsApi.getSummary(prevParams as SalesStatsParams), + staleTime: SALES_STATS.STALE_TIME, + enabled: isValidPeriod && prevParams !== null, + }); + + const deltas = useMemo(() => { + if (!summary || !prevSummary) return null; + return { + revenue: computeDelta(summary.total_revenue_kopeks, prevSummary.total_revenue_kopeks), + newTrials: computeDelta(summary.new_trials, prevSummary.new_trials), + renewals: computeDelta(summary.renewals_count, prevSummary.renewals_count), + addonRevenue: computeDelta(summary.addon_revenue_kopeks, prevSummary.addon_revenue_kopeks), + manualTopup: computeDelta(summary.manual_topup_kopeks, prevSummary.manual_topup_kopeks), + }; + }, [summary, prevSummary]); + const tabs: { id: TabId; label: string }[] = [ { id: 'trials', label: t('admin.salesStats.tabs.trials') }, { id: 'sales', label: t('admin.salesStats.tabs.sales') }, @@ -93,6 +146,7 @@ export default function AdminSalesStats() { ) } valueClassName="text-success-400" + delta={deltas?.revenue} />
From bb97ee8430c6073ea872b08dea7733c0f9a46f27 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 15:17:34 +0300 Subject: [PATCH 06/25] =?UTF-8?q?feat(sales-stats):=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B1=D0=B8=D0=B2=D0=BA=D0=B0=20=D0=B4=D0=BE=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=D0=BC=20?= =?UTF-8?q?=D1=82=D0=BE=D0=B6=D0=B5=20=D0=BD=D0=B0=20BreakdownList?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для консистентности: «Допы → по пакетам» теперь список с долями % и мини-баром, как остальные категорийные разбивки, вместо отдельного bar-графика. --- src/components/sales-stats/AddonsTab.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/sales-stats/AddonsTab.tsx b/src/components/sales-stats/AddonsTab.tsx index 0f13c8d..0e6fbec 100644 --- a/src/components/sales-stats/AddonsTab.tsx +++ b/src/components/sales-stats/AddonsTab.tsx @@ -8,8 +8,8 @@ import { SALES_STATS } from '../../constants/salesStats'; import { useCurrency } from '../../hooks/useCurrency'; import { StatCard } from '../stats'; +import { BreakdownList } from './BreakdownList'; import { DualAreaChart } from './DualAreaChart'; -import { SimpleBarChart } from './SimpleBarChart'; interface AddonsTabProps { params: SalesStatsParams; @@ -51,8 +51,9 @@ export function AddonsTab({ params }: AddonsTabProps) { return
{t('admin.salesStats.loadError')}
; } - const packageBarData = data.by_package.map((item) => ({ - name: `${item.traffic_gb} GB`, + const packageBreakdown = data.by_package.map((item) => ({ + key: String(item.traffic_gb), + label: `${item.traffic_gb} GB`, value: item.count, })); @@ -84,7 +85,7 @@ export function AddonsTab({ params }: AddonsTabProps) {
- + Date: Tue, 2 Jun 2026 15:22:33 +0300 Subject: [PATCH 07/25] =?UTF-8?q?fix(sales-stats):=20=D0=BD=D0=B5=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B0=D1=82=D1=8C=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=D1=85=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BA=D0=B8?= =?UTF-8?q?=20(=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B8=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточки сводки резали суммы («252…», «100.0…»): на десктопе их было 8 в ряд (слишком узко), а дельта стояла в той же строке и доедала место. - Дельта переехала ПОД значение — значение получает всю ширину карточки. - Сетка сводки: убрал xl:grid-cols-8, максимум 4 в ряд (карточки шире). --- src/components/stats/StatCard.tsx | 10 +++++----- src/pages/AdminSalesStats.tsx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx index a983a6b..2e9e4ce 100644 --- a/src/components/stats/StatCard.tsx +++ b/src/components/stats/StatCard.tsx @@ -34,14 +34,14 @@ export function StatCard({ {icon} {label}
-
- +
+
{value} - +
{trendStyle && ( - +
{trendStyle.arrow} {Math.abs(delta!.percent)}% - +
)}
diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index db40db5..91b54a5 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -135,7 +135,7 @@ export default function AdminSalesStats() { {t('admin.salesStats.loadError')}
)} -
+
Date: Tue, 2 Jun 2026 15:26:12 +0300 Subject: [PATCH 08/25] =?UTF-8?q?fix(sales-stats):=20=D0=BA=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BD=D1=8B=D0=B5=20=D1=81=D1=83=D0=BC=D0=BC=D1=8B=20?= =?UTF-8?q?=D1=81=D0=B2=D0=BE=D0=B4=D0=BA=D0=B8=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B5=D0=B5=D0=BA=20(=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D1=87=D0=B5,=20=D0=BD=D0=B5=20=D1=80=D0=B5=D0=B6=D0=B5?= =?UTF-8?q?=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatWithCurrency по умолчанию даёт 2 знака без разделителей разрядов («2521000.00 ₽») — длинно и режется. Для итоговых сумм сводки (доход, доход с допов, ручные пополнения) показываем целые рубли. --- src/pages/AdminSalesStats.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index 91b54a5..9bbfaa1 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -143,6 +143,7 @@ export default function AdminSalesStats() { ? '...' : formatWithCurrency( (summary?.total_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR, + 0, ) } valueClassName="text-success-400" @@ -181,6 +182,7 @@ export default function AdminSalesStats() { ? '...' : formatWithCurrency( (summary?.addon_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR, + 0, ) } valueClassName="text-accent-400" @@ -191,7 +193,10 @@ export default function AdminSalesStats() { value={ summaryLoading ? '...' - : formatWithCurrency((summary?.manual_topup_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR) + : formatWithCurrency( + (summary?.manual_topup_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR, + 0, + ) } valueClassName="text-warning-400" delta={deltas?.manualTopup} From dfc7768a403c9d8ff91a79cea8df9207b1855d8c Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 15:32:53 +0300 Subject: [PATCH 09/25] =?UTF-8?q?feat(sales-stats):=20=D0=B4=D1=83=D1=88?= =?UTF-8?q?=D0=B5=D0=B2=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8=20=D1=81=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=20+=20=D1=84=D0=B8=D0=BA=D1=81=20=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=87=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatCard: иконка в мягком цветном чипе (tone success/accent/warning/…) в духе статистики Remnawave; дельта под значением. Обратно совместимо (valueClassName). - Карточки сводки получили осмысленные иконки (доход/подписки/триалы/конверсия/ продления/допы/пополнения) и цветовые тоны. - Скачок при переключении периодов убран: на все запросы статистики добавлен placeholderData: keepPreviousData — старые данные остаются на экране, пока грузятся новые, без вспышки скелетона и прыжка вёрстки. --- src/components/sales-stats/AddonsTab.tsx | 3 +- src/components/sales-stats/DepositsTab.tsx | 3 +- src/components/sales-stats/RenewalsTab.tsx | 3 +- src/components/sales-stats/SalesTab.tsx | 3 +- src/components/sales-stats/TrialsTab.tsx | 3 +- src/components/stats/StatCard.tsx | 50 ++++++++++++++-------- src/pages/AdminSalesStats.tsx | 37 ++++++++++++---- 7 files changed, 72 insertions(+), 30 deletions(-) diff --git a/src/components/sales-stats/AddonsTab.tsx b/src/components/sales-stats/AddonsTab.tsx index 0e6fbec..7decffb 100644 --- a/src/components/sales-stats/AddonsTab.tsx +++ b/src/components/sales-stats/AddonsTab.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; @@ -23,6 +23,7 @@ export function AddonsTab({ params }: AddonsTabProps) { queryKey: ['sales-stats', 'addons', params], queryFn: () => salesStatsApi.getAddons(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); const dailyChartData = useMemo(() => { diff --git a/src/components/sales-stats/DepositsTab.tsx b/src/components/sales-stats/DepositsTab.tsx index 8aa3003..60d544d 100644 --- a/src/components/sales-stats/DepositsTab.tsx +++ b/src/components/sales-stats/DepositsTab.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; @@ -27,6 +27,7 @@ export function DepositsTab({ params }: DepositsTabProps) { queryKey: ['sales-stats', 'deposits', params], queryFn: () => salesStatsApi.getDeposits(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]); diff --git a/src/components/sales-stats/RenewalsTab.tsx b/src/components/sales-stats/RenewalsTab.tsx index 185c798..d145ba0 100644 --- a/src/components/sales-stats/RenewalsTab.tsx +++ b/src/components/sales-stats/RenewalsTab.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; @@ -22,6 +22,7 @@ export function RenewalsTab({ params }: RenewalsTabProps) { queryKey: ['sales-stats', 'renewals', params], queryFn: () => salesStatsApi.getRenewals(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); if (isLoading) { diff --git a/src/components/sales-stats/SalesTab.tsx b/src/components/sales-stats/SalesTab.tsx index 58dc043..723b8f0 100644 --- a/src/components/sales-stats/SalesTab.tsx +++ b/src/components/sales-stats/SalesTab.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; @@ -25,6 +25,7 @@ export function SalesTab({ params }: SalesTabProps) { queryKey: ['sales-stats', 'sales', params], queryFn: () => salesStatsApi.getSales(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); const dailyByTariffData = useMemo( diff --git a/src/components/sales-stats/TrialsTab.tsx b/src/components/sales-stats/TrialsTab.tsx index 1cd09df..fc113da 100644 --- a/src/components/sales-stats/TrialsTab.tsx +++ b/src/components/sales-stats/TrialsTab.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; @@ -29,6 +29,7 @@ export function TrialsTab({ params }: TrialsTabProps) { queryKey: ['sales-stats', 'trials', params], queryFn: () => salesStatsApi.getTrials(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); if (isLoading) { diff --git a/src/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx index 2e9e4ce..2cf3359 100644 --- a/src/components/stats/StatCard.tsx +++ b/src/components/stats/StatCard.tsx @@ -2,20 +2,29 @@ import { type ReactNode } from 'react'; import { TREND_STYLES } from './constants'; -const DEFAULT_VALUE_CLASS = 'text-dark-100'; - export interface StatCardDelta { /** Signed percent change vs the comparison period. */ percent: number; trend: 'up' | 'down' | 'stable'; } +/** Soft tinted chip + matching value colour, in the spirit of the Remnawave stats. */ +const TONE = { + neutral: { chip: 'bg-dark-700/60 text-dark-300', value: 'text-dark-100' }, + success: { chip: 'bg-success-500/15 text-success-400', value: 'text-success-400' }, + accent: { chip: 'bg-accent-500/15 text-accent-400', value: 'text-accent-400' }, + warning: { chip: 'bg-warning-500/15 text-warning-400', value: 'text-warning-400' }, + error: { chip: 'bg-error-500/15 text-error-400', value: 'text-error-400' }, +} as const; + interface StatCardProps { label: string; value: string | number; icon?: ReactNode; + /** Tints the icon chip and (unless valueClassName is set) the value colour. */ + tone?: keyof typeof TONE; valueClassName?: string; - /** Optional period-over-period change shown next to the value. */ + /** Optional period-over-period change shown under the value. */ delta?: StatCardDelta | null; } @@ -23,26 +32,33 @@ export function StatCard({ label, value, icon, - valueClassName = DEFAULT_VALUE_CLASS, + tone = 'neutral', + valueClassName, delta, }: StatCardProps) { + const toneStyle = TONE[tone]; + const valueClass = valueClassName ?? toneStyle.value; const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null; return ( -
-
- {icon} - {label} -
-
-
- {value} -
- {trendStyle && ( -
- {trendStyle.arrow} {Math.abs(delta!.percent)}% -
+
+
+ {icon && ( + + {icon} + )} +
+
{label}
+
{value}
+ {trendStyle && ( +
+ {trendStyle.arrow} {Math.abs(delta!.percent)}% +
+ )} +
); diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index 9bbfaa1..ed30235 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -1,12 +1,22 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, 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 { + BanknotesIcon, + GiftIcon, + PercentIcon, + RepeatIcon, + RocketIcon, + SparklesIcon, + SubscriptionIcon, + WalletIcon, +} from '../components/icons'; import { StatCard } from '../components/stats'; import { AddonsTab, @@ -84,6 +94,7 @@ export default function AdminSalesStats() { queryFn: () => salesStatsApi.getSummary(params), staleTime: SALES_STATS.STALE_TIME, enabled: isValidPeriod, + placeholderData: keepPreviousData, }); const prevParams = useMemo(() => getPreviousPeriodParams(period), [period]); @@ -92,6 +103,7 @@ export default function AdminSalesStats() { queryFn: () => salesStatsApi.getSummary(prevParams as SalesStatsParams), staleTime: SALES_STATS.STALE_TIME, enabled: isValidPeriod && prevParams !== null, + placeholderData: keepPreviousData, }); const deltas = useMemo(() => { @@ -146,33 +158,40 @@ export default function AdminSalesStats() { 0, ) } - valueClassName="text-success-400" + icon={} + tone="success" delta={deltas?.revenue} /> } + tone="accent" /> } + tone="neutral" /> } + tone="accent" delta={deltas?.newTrials} /> } + tone="warning" /> } + tone="success" delta={deltas?.renewals} /> } + tone="accent" delta={deltas?.addonRevenue} /> } + tone="warning" delta={deltas?.manualTopup} />
From 622ec318da6eac1cef92c95f244159825345a5e1 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 15:39:25 +0300 Subject: [PATCH 10/25] =?UTF-8?q?fix(sales-stats):=20=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=D1=85?= =?UTF-8?q?=20+=20=D0=BD=D0=B5=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatCard: лейбл сверху, чип+значение в одной строке (чип центрируется ровно по значению, дельта ушла под строку) — иконки больше не «плавают» по вертикали из-за разной высоты карточек. - «Активные подписки»: TicketIcon вместо SubscriptionIcon (та визуально как искра и дублировала «Новые триалы»). --- src/components/stats/StatCard.tsx | 20 +++++++++++--------- src/pages/AdminSalesStats.tsx | 4 ++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx index 2cf3359..79f2600 100644 --- a/src/components/stats/StatCard.tsx +++ b/src/components/stats/StatCard.tsx @@ -42,7 +42,10 @@ export function StatCard({ return (
-
+
{label}
+ {/* Chip is centred against the value line only (delta sits below the whole + row), so the icon lands in the same spot on every card. */} +
{icon && ( )} -
-
{label}
-
{value}
- {trendStyle && ( -
- {trendStyle.arrow} {Math.abs(delta!.percent)}% -
- )} +
+ {value}
+ {trendStyle && ( +
+ {trendStyle.arrow} {Math.abs(delta!.percent)}% +
+ )}
); } diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index ed30235..89af97a 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -14,7 +14,7 @@ import { RepeatIcon, RocketIcon, SparklesIcon, - SubscriptionIcon, + TicketIcon, WalletIcon, } from '../components/icons'; import { StatCard } from '../components/stats'; @@ -165,7 +165,7 @@ export default function AdminSalesStats() { } + icon={} tone="accent" /> Date: Tue, 2 Jun 2026 15:45:40 +0300 Subject: [PATCH 11/25] =?UTF-8?q?feat(sales-stats):=20=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B0=D1=85=20=D0=B2=D0=BE=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D1=85=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Доделал душевный вид: карточки внутри вкладок (Триалы/Продажи/Продления/Допы/ Депозиты) тоже получили иконки в цветных чипах с тонами — единый стиль со сводкой (регистрации/триалы/конверсия/длительность, продажи/выручка/чек/топ-тариф, продления/%, допы/GB/устройства, пополнения/сумма/средний). Крупные суммы — без копеек. --- src/components/sales-stats/AddonsTab.tsx | 23 ++++++++++++++++++---- src/components/sales-stats/DepositsTab.tsx | 10 ++++++++-- src/components/sales-stats/RenewalsTab.tsx | 16 +++++++++++---- src/components/sales-stats/SalesTab.tsx | 23 +++++++++++++++++----- src/components/sales-stats/TrialsTab.tsx | 15 ++++++++++++-- 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/components/sales-stats/AddonsTab.tsx b/src/components/sales-stats/AddonsTab.tsx index 7decffb..c0f961d 100644 --- a/src/components/sales-stats/AddonsTab.tsx +++ b/src/components/sales-stats/AddonsTab.tsx @@ -6,6 +6,13 @@ import type { SalesStatsParams } from '../../api/adminSalesStats'; import { salesStatsApi } from '../../api/adminSalesStats'; import { SALES_STATS } from '../../constants/salesStats'; import { useCurrency } from '../../hooks/useCurrency'; +import { + BanknotesIcon, + CardIcon, + DevicesIcon, + PlusIcon, + TrafficIcon, +} from '../../components/icons'; import { StatCard } from '../stats'; import { BreakdownList } from './BreakdownList'; @@ -64,24 +71,32 @@ export function AddonsTab({ params }: AddonsTabProps) { } + tone="accent" /> } + tone="neutral" /> } + tone="success" /> } + tone="accent" /> } + tone="success" />
diff --git a/src/components/sales-stats/DepositsTab.tsx b/src/components/sales-stats/DepositsTab.tsx index 60d544d..c73c8b2 100644 --- a/src/components/sales-stats/DepositsTab.tsx +++ b/src/components/sales-stats/DepositsTab.tsx @@ -7,6 +7,7 @@ import { salesStatsApi } from '../../api/adminSalesStats'; import { METHOD_LABELS } from '../../constants/paymentMethods'; import { SALES_STATS } from '../../constants/salesStats'; import { useCurrency } from '../../hooks/useCurrency'; +import { BanknotesIcon, CardIcon, WalletIcon } from '../../components/icons'; import { StatCard } from '../stats'; import PaymentMethodIcon from '../PaymentMethodIcon'; @@ -82,15 +83,20 @@ export function DepositsTab({ params }: DepositsTabProps) { } + tone="accent" /> } + tone="success" /> } + tone="neutral" />
diff --git a/src/components/sales-stats/RenewalsTab.tsx b/src/components/sales-stats/RenewalsTab.tsx index d145ba0..ddffdb1 100644 --- a/src/components/sales-stats/RenewalsTab.tsx +++ b/src/components/sales-stats/RenewalsTab.tsx @@ -5,6 +5,7 @@ import type { SalesStatsParams } from '../../api/adminSalesStats'; import { salesStatsApi } from '../../api/adminSalesStats'; import { SALES_STATS } from '../../constants/salesStats'; import { useCurrency } from '../../hooks/useCurrency'; +import { BanknotesIcon, PercentIcon, RepeatIcon } from '../../components/icons'; import { StatCard } from '../stats'; import { TREND_STYLES } from '../stats/constants'; @@ -49,16 +50,23 @@ export function RenewalsTab({ params }: RenewalsTabProps) { return (
- + } + tone="success" + /> } + tone="accent" /> } + tone="success" />
diff --git a/src/components/sales-stats/SalesTab.tsx b/src/components/sales-stats/SalesTab.tsx index 723b8f0..d1cd433 100644 --- a/src/components/sales-stats/SalesTab.tsx +++ b/src/components/sales-stats/SalesTab.tsx @@ -6,6 +6,7 @@ import type { SalesStatsParams } from '../../api/adminSalesStats'; import { salesStatsApi } from '../../api/adminSalesStats'; import { SALES_STATS } from '../../constants/salesStats'; import { useCurrency } from '../../hooks/useCurrency'; +import { BanknotesIcon, CardIcon, TicketIcon, TrophyIcon } from '../../components/icons'; import { StatCard } from '../stats'; import { BreakdownList } from './BreakdownList'; @@ -68,18 +69,30 @@ export function SalesTab({ params }: SalesTabProps) { return (
- + } + tone="accent" + /> } + tone="success" /> } + tone="success" + /> + } + tone="warning" /> -
diff --git a/src/components/sales-stats/TrialsTab.tsx b/src/components/sales-stats/TrialsTab.tsx index fc113da..f041105 100644 --- a/src/components/sales-stats/TrialsTab.tsx +++ b/src/components/sales-stats/TrialsTab.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; import { salesStatsApi } from '../../api/adminSalesStats'; import { SALES_STATS } from '../../constants/salesStats'; +import { ClockIcon, GiftIcon, PercentIcon, UserPlusIcon } from '../../components/icons'; import { StatCard } from '../stats'; import { DonutChart } from './DonutChart'; @@ -64,16 +65,26 @@ export function TrialsTab({ params }: TrialsTabProps) { } + tone="accent" + /> + } + tone="neutral" /> - } + tone="success" /> } + tone="neutral" />
From 9a05f23a04358affca5faa0acb5cd81911372136 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 15:55:10 +0300 Subject: [PATCH 12/25] =?UTF-8?q?feat(sales-stats):=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D0=B5=D1=82=20=C2=AB=D0=AD=D1=82=D0=BE=D1=82=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=8F=D1=86=C2=BB=20(=D1=81=201-=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D0=BB=D0=B0)=20=D0=B8=20=D0=BE=D0=BD=20?= =?UTF-8?q?=D0=B6=D0=B5=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Статистика по умолчанию открывается за текущий месяц с 1-го числа до сегодня (month-to-date), а не за фиксированные 30 дней. Добавлена кнопка «Этот месяц» в селектор периода; util getMonthToDateRange/isMonthToDate. --- src/components/sales-stats/PeriodSelector.tsx | 45 +++++++++---------- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/AdminSalesStats.tsx | 3 +- src/utils/period.ts | 22 +++++++++ 7 files changed, 50 insertions(+), 24 deletions(-) create mode 100644 src/utils/period.ts diff --git a/src/components/sales-stats/PeriodSelector.tsx b/src/components/sales-stats/PeriodSelector.tsx index 35eff18..c55874a 100644 --- a/src/components/sales-stats/PeriodSelector.tsx +++ b/src/components/sales-stats/PeriodSelector.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SALES_STATS } from '../../constants/salesStats'; +import { getMonthToDateRange, isMonthToDate } from '../../utils/period'; interface PeriodSelectorProps { value: { days?: number; startDate?: string; endDate?: string }; @@ -24,46 +25,44 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { onChange({ days }); }; - const handleCustomToggle = () => { - setShowCustom((prev) => !prev); + const handleThisMonth = () => { + setShowCustom(false); + onChange({ days: undefined, ...getMonthToDateRange() }); }; + const handleCustomToggle = () => setShowCustom((prev) => !prev); + const handleDateChange = (field: 'startDate' | 'endDate', dateStr: string) => { - onChange({ - ...value, - days: undefined, - [field]: dateStr, - }); + onChange({ ...value, days: undefined, [field]: dateStr }); }; - const isPresetActive = (days: number) => !showCustom && value.days === days; + const buttonClass = (active: boolean) => + `rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${ + active + ? 'bg-accent-500/20 text-accent-400' + : 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300' + }`; + + const mtdActive = !showCustom && isMonthToDate(value); return (
+ + {SALES_STATS.PERIOD_PRESETS.map((days) => ( ))} - @@ -75,7 +74,7 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { 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" /> - {'\u2014'} + {'—'} ({ days: SALES_STATS.DEFAULT_PERIOD }); + }>(() => getMonthToDateRange()); const params: SalesStatsParams = useMemo( () => ({ diff --git a/src/utils/period.ts b/src/utils/period.ts new file mode 100644 index 0000000..7032556 --- /dev/null +++ b/src/utils/period.ts @@ -0,0 +1,22 @@ +/** First day of the current month → today, as local YYYY-MM-DD strings. + * Used as the default sales-stats window ("this month so far"). */ +export function getMonthToDateRange(): { startDate: string; endDate: string } { + const now = new Date(); + const toLocalISODate = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + return { + startDate: toLocalISODate(new Date(now.getFullYear(), now.getMonth(), 1)), + endDate: toLocalISODate(now), + }; +} + +/** True when the period is exactly "from the 1st of the current month to today". */ +export function isMonthToDate(period: { + days?: number; + startDate?: string; + endDate?: string; +}): boolean { + if (period.days !== undefined) return false; + const mtd = getMonthToDateRange(); + return period.startDate === mtd.startDate && period.endDate === mtd.endDate; +} From edd2d25024989a0b2d7888b82fa2f13dce60b63e Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 16:10:48 +0300 Subject: [PATCH 13/25] =?UTF-8?q?feat(ui):=20=D1=82=D1=91=D0=BC=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20DateField=20=D0=BD=D0=B0=20react-day-picker=20?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BD=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Нативный выглядел чужеродно (браузерный светлый календарь). Новый общий компонент DateField: react-day-picker внутри Radix Popover, тёмная тема через CSS-переменные .rdp-dark, навигация — наши иконки из барреля, локализация месяцев/дней по языку кабинета (date-fns). Принимает/отдаёт 'YYYY-MM-DD' — drop-in замена нативного инпута. Подключён в селекторе периода статистики (свой период). Остальные места — следующими шагами. --- package-lock.json | 43 +++++++ package.json | 1 + src/components/DateField.tsx | 106 ++++++++++++++++++ src/components/sales-stats/PeriodSelector.tsx | 17 +-- src/locales/en.json | 2 + src/locales/fa.json | 2 + src/locales/ru.json | 2 + src/locales/zh.json | 2 + src/styles/globals.css | 38 +++++++ 9 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 src/components/DateField.tsx diff --git a/package-lock.json b/package-lock.json index 867b7de..111c915 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "jsencrypt": "^3.5.4", "qrcode.react": "^4.2.0", "react": "^19.2.4", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", "react-icons": "^5.6.0", @@ -392,6 +393,12 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -4564,6 +4571,16 @@ "node": ">=12" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6870,6 +6887,32 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", + "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", diff --git a/package.json b/package.json index c3c0f2a..f9298a5 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "jsencrypt": "^3.5.4", "qrcode.react": "^4.2.0", "react": "^19.2.4", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", "react-icons": "^5.6.0", diff --git a/src/components/DateField.tsx b/src/components/DateField.tsx new file mode 100644 index 0000000..d634c2d --- /dev/null +++ b/src/components/DateField.tsx @@ -0,0 +1,106 @@ +import * as Popover from '@radix-ui/react-popover'; +import { enUS, faIR, ru, zhCN } from 'date-fns/locale'; +import { useState } from 'react'; +import { DayPicker, type Matcher } from 'react-day-picker'; +import { useTranslation } from 'react-i18next'; + +import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from './icons'; + +import 'react-day-picker/style.css'; + +interface DateFieldProps { + /** Selected date as 'YYYY-MM-DD', or '' when empty. */ + value: string; + onChange: (value: string) => void; + placeholder?: string; + /** Inclusive bounds as 'YYYY-MM-DD'. */ + min?: string; + max?: string; + /** Override the trigger button classes. */ + className?: string; +} + +const LOCALES = { ru, en: enUS, zh: zhCN, fa: faIR } as const; + +const pad = (n: number) => String(n).padStart(2, '0'); +const toISO = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +const parseISO = (s: string): Date | undefined => { + if (!s) return undefined; + const [y, m, d] = s.split('-').map(Number); + if (!y || !m || !d) return undefined; + return new Date(y, m - 1, d); +}; + +function NavChevron({ orientation }: { orientation?: 'left' | 'right' | 'up' | 'down' }) { + return orientation === 'right' ? ( + + ) : ( + + ); +} + +/** + * Dark-themed date field — Radix Popover trigger + react-day-picker calendar. + * Drop-in replacement for ``: takes/returns 'YYYY-MM-DD'. + * Month/weekday names follow the UI language (date-fns locale), navigation uses + * the shared barrel chevrons, theming via the .rdp-dark variable overrides. + */ +export function DateField({ value, onChange, placeholder, min, max, className }: DateFieldProps) { + const { i18n } = useTranslation(); + const [open, setOpen] = useState(false); + + const selected = parseISO(value); + const locale = LOCALES[i18n.language as keyof typeof LOCALES] ?? enUS; + const display = selected + ? selected.toLocaleDateString(i18n.language, { + day: '2-digit', + month: 'short', + year: 'numeric', + }) + : ''; + + const disabled: Matcher[] = []; + const minDate = parseISO(min ?? ''); + const maxDate = parseISO(max ?? ''); + if (minDate) disabled.push({ before: minDate }); + if (maxDate) disabled.push({ after: maxDate }); + + return ( + + + + + + + { + if (d) { + onChange(toISO(d)); + setOpen(false); + } + }} + /> + + + + ); +} diff --git a/src/components/sales-stats/PeriodSelector.tsx b/src/components/sales-stats/PeriodSelector.tsx index c55874a..19c465b 100644 --- a/src/components/sales-stats/PeriodSelector.tsx +++ b/src/components/sales-stats/PeriodSelector.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { SALES_STATS } from '../../constants/salesStats'; import { getMonthToDateRange, isMonthToDate } from '../../utils/period'; +import { DateField } from '../DateField'; interface PeriodSelectorProps { value: { days?: number; startDate?: string; endDate?: string }; @@ -68,18 +69,18 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { {showCustom && (
- handleDateChange('startDate', e.target.value)} - className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200" + onChange={(v) => handleDateChange('startDate', v)} + max={value.endDate} + placeholder={t('admin.salesStats.period.from')} /> {'—'} - handleDateChange('endDate', e.target.value)} - className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200" + onChange={(v) => handleDateChange('endDate', v)} + min={value.startDate} + placeholder={t('admin.salesStats.period.to')} />
)} diff --git a/src/locales/en.json b/src/locales/en.json index c14df7d..d229697 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1283,6 +1283,8 @@ "subtitle": "Revenue, trials, subscriptions, and renewals", "period": { "thisMonth": "This month", + "from": "From", + "to": "To", "week": "7 days", "month": "30 days", "quarter": "90 days", diff --git a/src/locales/fa.json b/src/locales/fa.json index f84cde8..7006b10 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1156,6 +1156,8 @@ "subtitle": "درآمد، آزمایشی، اشتراک و تمدید", "period": { "thisMonth": "این ماه", + "from": "از", + "to": "تا", "week": "۷ روز", "month": "۳۰ روز", "quarter": "۹۰ روز", diff --git a/src/locales/ru.json b/src/locales/ru.json index 8c18e60..b0db7dd 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1305,6 +1305,8 @@ "subtitle": "Доход, триалы, подписки и продления", "period": { "thisMonth": "Этот месяц", + "from": "Начало", + "to": "Конец", "week": "7 дней", "month": "30 дней", "quarter": "90 дней", diff --git a/src/locales/zh.json b/src/locales/zh.json index 68c6184..7778363 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1156,6 +1156,8 @@ "subtitle": "收入、试用、订阅和续订", "period": { "thisMonth": "本月", + "from": "从", + "to": "到", "week": "7天", "month": "30天", "quarter": "90天", diff --git a/src/styles/globals.css b/src/styles/globals.css index 1ed7da8..2561198 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1392,6 +1392,44 @@ input[type='checkbox']:hover:not(:checked) { outline: none; } +/* react-day-picker (DateField) — dark theme via the library's CSS variables. */ +.rdp-dark { + --rdp-accent-color: rgb(var(--color-accent-500)); + --rdp-accent-background-color: rgb(var(--color-accent-500) / 0.18); + --rdp-today-color: rgb(var(--color-accent-400)); + --rdp-day-height: 2.25rem; + --rdp-day-width: 2.25rem; + --rdp-outside-opacity: 0.35; + --rdp-disabled-opacity: 0.3; + --rdp-selected-border: none; + color: rgb(var(--color-dark-200)); + font-size: 0.85rem; +} +.rdp-dark .rdp-month_caption, +.rdp-dark .rdp-caption_label { + color: rgb(var(--color-dark-100)); + font-weight: 600; + text-transform: capitalize; +} +.rdp-dark .rdp-weekday { + color: rgb(var(--color-dark-500)); + font-weight: 500; + text-transform: capitalize; +} +.rdp-dark .rdp-day_button { + border-radius: 0.5rem; +} +.rdp-dark .rdp-day_button:hover:not([disabled]) { + background-color: rgb(var(--color-dark-700)); +} +.rdp-dark .rdp-selected .rdp-day_button { + color: #fff; + font-weight: 600; +} +.rdp-dark .rdp-chevron { + fill: currentColor; +} + .light input[type='checkbox'] { border-color: rgb(var(--color-champagne-400)); background-color: rgb(var(--color-champagne-100)); From 016da9f638ca4a7ecec35e0d77c0e851bc3b8808 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 16:17:10 +0300 Subject: [PATCH 14/25] =?UTF-8?q?feat(ui):=20=D0=B7=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B2=D1=81=D0=B5=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D0=B5=20date-=D0=B8=D0=BD=D0=BF?= =?UTF-8?q?=D1=83=D1=82=D1=8B=20=D0=BD=D0=B0=20DateField?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тёмный DateField теперь везде, где был браузерный : фильтр периода в Трафике, диапазон дат в Платежах и Аудит-логе, срок действия промокода. Единый вид во всём кабинете. (datetime-local не трогал — там нужно ещё и время.) --- .../trafficUsage/filters/PeriodSelector.tsx | 13 +++++-------- src/pages/AdminAuditLog.tsx | 19 +++++++++---------- src/pages/AdminPayments.tsx | 17 +++++++++-------- src/pages/AdminPromocodeCreate.tsx | 9 ++++----- 4 files changed, 27 insertions(+), 31 deletions(-) diff --git a/src/components/admin/trafficUsage/filters/PeriodSelector.tsx b/src/components/admin/trafficUsage/filters/PeriodSelector.tsx index da00ca9..9d020fe 100644 --- a/src/components/admin/trafficUsage/filters/PeriodSelector.tsx +++ b/src/components/admin/trafficUsage/filters/PeriodSelector.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import { CalendarIcon, XIcon } from '../TrafficIcons'; +import { DateField } from '../../../DateField'; // ────────────────────────────────────────────────────────────────── // PeriodSelector — switches between fixed period tabs (1/3/7/14/30 @@ -44,22 +45,18 @@ export function PeriodSelector({
{t('admin.trafficUsage.dateFrom')} - onCustomStartChange(e.target.value)} - className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none" + onChange={onCustomStartChange} /> {t('admin.trafficUsage.dateTo')} - onCustomEndChange(e.target.value)} - className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none" + onChange={onCustomEndChange} />
@@ -721,12 +721,11 @@ export default function AdminAuditLog() { > {t('admin.auditLog.filters.dateTo')} - setFilters((prev) => ({ ...prev, dateTo: e.target.value }))} - className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500" + min={filters.dateFrom} + onChange={(v) => setFilters((prev) => ({ ...prev, dateTo: v }))} + className="flex w-full items-center gap-2 rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 transition-colors hover:border-accent-500" />
diff --git a/src/pages/AdminPayments.tsx b/src/pages/AdminPayments.tsx index 76daaab..e35f6c2 100644 --- a/src/pages/AdminPayments.tsx +++ b/src/pages/AdminPayments.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminPaymentsApi, type SearchStats } from '../api/adminPayments'; +import { DateField } from '../components/DateField'; import { useCurrency } from '../hooks/useCurrency'; import type { PendingPayment, PaginatedResponse } from '../types'; import { usePlatform } from '../platform/hooks/usePlatform'; @@ -327,20 +328,20 @@ export default function AdminPayments() { - setDateFrom(e.target.value)} - className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none" + max={dateTo} + onChange={setDateFrom} + className="flex w-full items-center gap-2 rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 transition-colors hover:border-accent-500" />
- setDateTo(e.target.value)} - className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none" + min={dateFrom} + onChange={setDateTo} + className="flex w-full items-center gap-2 rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 transition-colors hover:border-accent-500" />
From a8213ce5d5fccd24ee192196a52accbf65954984 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 16:32:24 +0300 Subject: [PATCH 15/25] =?UTF-8?q?fix(datepicker):=20=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D0=B5=D0=BC=D0=BE=D1=81=D1=82=D1=8C=20=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D1=8F=20=D0=B2=20=D0=BB=D1=8E?= =?UTF-8?q?=D0=B1=D0=BE=D0=B9=20=D1=82=D0=B5=D0=BC=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Переменные --rdp-* библиотека объявляет на самом .rdp-root, поэтому оверрайды на родителе (.rdp-dark) шадовились — отсюда синий акцент, синяя рамка вместо заливки и тусклые дни. Теперь целимся в `.rdp-dark .rdp-root` (специфичность выше базовой). Выбранный день — залит акцентом, числа — яркие, сегодня — акцентная подсветка, все цвета через токены темы (dark/accent), так что инвертируются для светлых тем (champagne) и остаются читаемыми везде. --- src/styles/globals.css | 47 +++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/styles/globals.css b/src/styles/globals.css index 2561198..4e783fe 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1392,18 +1392,26 @@ input[type='checkbox']:hover:not(:checked) { outline: none; } -/* react-day-picker (DateField) — dark theme via the library's CSS variables. */ +/* react-day-picker (DateField) — themed via the library's CSS variables. + The library declares its rdp variables ON .rdp-root, so overriding them on a + parent (.rdp-dark) gets shadowed. We must target `.rdp-dark .rdp-root` to win. + All colors go through the app's theme tokens (the dark and accent scales), + which flip for light themes (e.g. champagne) — so the picker stays legible in + every theme without bespoke per-theme rules. */ .rdp-dark { + color: rgb(var(--color-dark-100)); + font-size: 0.85rem; +} +.rdp-dark .rdp-root { --rdp-accent-color: rgb(var(--color-accent-500)); --rdp-accent-background-color: rgb(var(--color-accent-500) / 0.18); --rdp-today-color: rgb(var(--color-accent-400)); --rdp-day-height: 2.25rem; --rdp-day-width: 2.25rem; - --rdp-outside-opacity: 0.35; - --rdp-disabled-opacity: 0.3; + --rdp-outside-opacity: 0.4; + --rdp-disabled-opacity: 0.4; --rdp-selected-border: none; - color: rgb(var(--color-dark-200)); - font-size: 0.85rem; + --rdp-weekday-opacity: 1; } .rdp-dark .rdp-month_caption, .rdp-dark .rdp-caption_label { @@ -1412,23 +1420,48 @@ input[type='checkbox']:hover:not(:checked) { text-transform: capitalize; } .rdp-dark .rdp-weekday { - color: rgb(var(--color-dark-500)); + color: rgb(var(--color-dark-400)); font-weight: 500; text-transform: capitalize; } .rdp-dark .rdp-day_button { + color: rgb(var(--color-dark-100)); + border: none; border-radius: 0.5rem; } .rdp-dark .rdp-day_button:hover:not([disabled]) { background-color: rgb(var(--color-dark-700)); } +.rdp-dark .rdp-day_button:focus-visible { + outline: 2px solid rgb(var(--color-accent-500)); + outline-offset: 1px; +} +/* Today: accent-tinted text + subtle ring (loses to the filled "selected" look). */ +.rdp-dark .rdp-today:not(.rdp-outside) .rdp-day_button { + color: rgb(var(--color-accent-400)); + font-weight: 700; + box-shadow: inset 0 0 0 1px rgb(var(--color-accent-500) / 0.45); +} .rdp-dark .rdp-selected .rdp-day_button { + background-color: rgb(var(--color-accent-500)); color: #fff; - font-weight: 600; + font-weight: 700; + border: none; + box-shadow: none; } .rdp-dark .rdp-chevron { fill: currentColor; } +.rdp-dark .rdp-button_previous, +.rdp-dark .rdp-button_next { + color: rgb(var(--color-dark-300)); + border-radius: 0.5rem; +} +.rdp-dark .rdp-button_previous:hover:not([disabled]), +.rdp-dark .rdp-button_next:hover:not([disabled]) { + color: rgb(var(--color-dark-100)); + background-color: rgb(var(--color-dark-700)); +} .light input[type='checkbox'] { border-color: rgb(var(--color-champagne-400)); From 54998c819566b749c282a5f20ab383469f2615ba Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 16:32:25 +0300 Subject: [PATCH 16/25] =?UTF-8?q?fix(sales-stats):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BF=D1=83=D1=81=D1=82=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B8=20=D0=B2=20=D1=81=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D1=85=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сводка (8 карточек) была 2/3/4 колонки — на 3-колоночном брейкпоинте последний ряд оставлял пустую ячейку. Перевёл на 2/4 (8 делится ровно). Допы (5 карточек) тянули сироту на 2- и 3-колоночном — теперь 2/5 с orphan-fill: последняя нечётная карточка занимает всю ширину на мобайле (паттерн как в новостях/подписках). --- src/components/sales-stats/AddonsTab.tsx | 2 +- src/pages/AdminSalesStats.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/sales-stats/AddonsTab.tsx b/src/components/sales-stats/AddonsTab.tsx index c0f961d..ec24e76 100644 --- a/src/components/sales-stats/AddonsTab.tsx +++ b/src/components/sales-stats/AddonsTab.tsx @@ -67,7 +67,7 @@ export function AddonsTab({ params }: AddonsTabProps) { return (
-
+
)} -
+
Date: Tue, 2 Jun 2026 16:38:56 +0300 Subject: [PATCH 17/25] =?UTF-8?q?fix(datepicker):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=81=D0=BA=D0=B0=D1=87=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B8=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Числовой формат даты (01.06.2026) — постоянная ширина в любой локали, раньше короткое имя месяца («мая» vs «сент.») меняло ширину кнопки. - min-w + justify-start + truncate на триггере: плейсхолдер и выбранная дата дают одинаковую ширину — поле не прыгает по горизонтали. - fixedWeeks + showOutsideDays: всегда 6 недель, высота календаря не скачет при листании месяцев (5↔6 рядов). - collisionPadding + max-w-[calc(100vw-1rem)]: поповер не вылезает за край экрана на мобайле. - Контейнеры диапазона дат (продажи, трафик) переведены на flex-wrap / full-width на мобайле — два поля больше не вылезают за ширину. --- src/components/DateField.tsx | 15 +++++++++++---- .../admin/trafficUsage/filters/PeriodSelector.tsx | 4 ++-- src/components/sales-stats/PeriodSelector.tsx | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/DateField.tsx b/src/components/DateField.tsx index d634c2d..585560f 100644 --- a/src/components/DateField.tsx +++ b/src/components/DateField.tsx @@ -51,10 +51,12 @@ export function DateField({ value, onChange, placeholder, min, max, className }: const selected = parseISO(value); const locale = LOCALES[i18n.language as keyof typeof LOCALES] ?? enUS; + // Numeric format keeps a constant button width across dates and locales + // (a "short" month name varies in length and makes the trigger jitter). const display = selected ? selected.toLocaleDateString(i18n.language, { day: '2-digit', - month: 'short', + month: '2-digit', year: 'numeric', }) : ''; @@ -72,18 +74,21 @@ export function DateField({ value, onChange, placeholder, min, max, className }: type="button" className={ className ?? - 'flex items-center gap-2 rounded-lg border border-dark-600 bg-dark-800 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:border-dark-500' + 'flex min-w-[8rem] items-center justify-start gap-2 whitespace-nowrap rounded-lg border border-dark-600 bg-dark-800 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:border-dark-500' } > - {display || placeholder} + + {display || placeholder} + { if (d) { diff --git a/src/components/admin/trafficUsage/filters/PeriodSelector.tsx b/src/components/admin/trafficUsage/filters/PeriodSelector.tsx index 9d020fe..52d5973 100644 --- a/src/components/admin/trafficUsage/filters/PeriodSelector.tsx +++ b/src/components/admin/trafficUsage/filters/PeriodSelector.tsx @@ -42,8 +42,8 @@ export function PeriodSelector({ if (dateMode) { return ( -
- +
+ {t('admin.trafficUsage.dateFrom')} {showCustom && ( -
+
handleDateChange('startDate', v)} From 40c684cc8887df8ec6e2287939141a975e4aacbc Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 16:45:40 +0300 Subject: [PATCH 18/25] =?UTF-8?q?fix(sales-stats):=20=C2=AB=D0=92=D1=8B?= =?UTF-8?q?=D1=80=D1=83=D1=87=D0=BA=D0=B0=C2=BB=20=E2=86=92=20=C2=AB=D0=9E?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=BE=D1=82=20=D0=BF=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=D0=BC=C2=BB=20=D0=B2=D0=BE?= =?UTF-8?q?=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B5=20=D0=9F=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=B6=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поле суммирует все подписочные платежи (новые + продления + докупки), поэтому пересекалось по смыслу с вкладками Продления/Допы. Честное имя «Оборот по подпискам» убирает путаницу: видно, что это итог по всем подписочным платежам, а не только новые продажи. Все 4 локали. --- src/locales/en.json | 2 +- src/locales/fa.json | 2 +- src/locales/ru.json | 2 +- src/locales/zh.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index d229697..6a482c4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1320,7 +1320,7 @@ }, "sales": { "totalSales": "Total Sales", - "totalRevenue": "Revenue", + "totalRevenue": "Subscription turnover", "avgOrder": "Avg Order", "topTariff": "Top Tariff", "byTariff": "Sales by Tariff", diff --git a/src/locales/fa.json b/src/locales/fa.json index 7006b10..26cde24 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1193,7 +1193,7 @@ }, "sales": { "totalSales": "کل فروش", - "totalRevenue": "درآمد", + "totalRevenue": "گردش اشتراک", "avgOrder": "میانگین سفارش", "topTariff": "تعرفه برتر", "byTariff": "بر اساس تعرفه", diff --git a/src/locales/ru.json b/src/locales/ru.json index b0db7dd..872bd7c 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1342,7 +1342,7 @@ }, "sales": { "totalSales": "Всего продаж", - "totalRevenue": "Выручка", + "totalRevenue": "Оборот по подпискам", "avgOrder": "Средний чек", "topTariff": "Топ тариф", "byTariff": "Продажи по тарифам", diff --git a/src/locales/zh.json b/src/locales/zh.json index 7778363..dca06ba 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1193,7 +1193,7 @@ }, "sales": { "totalSales": "总销售", - "totalRevenue": "营收", + "totalRevenue": "订阅流水", "avgOrder": "平均订单", "topTariff": "热门套餐", "byTariff": "按套餐", From bd12113294c92379fc7fee6d4c25f1d73e3de856 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 16:53:30 +0300 Subject: [PATCH 19/25] =?UTF-8?q?feat(sales-stats):=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D0=B2=20=D0=B2=20=D1=81=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Две карточки — количество и сумма возвратов за период (красный тон). Сетку сводки перевёл на 2/5 (10 карточек делятся ровно, без пустых ячеек). StatCard получил invertDelta: для «меньше — лучше» метрик рост красится в красный, падение в зелёный (стрелка по факту). 4 локали. --- src/api/adminSalesStats.ts | 2 ++ src/components/stats/StatCard.tsx | 13 ++++++++++++- src/locales/en.json | 4 +++- src/locales/fa.json | 4 +++- src/locales/ru.json | 4 +++- src/locales/zh.json | 4 +++- src/pages/AdminSalesStats.tsx | 22 +++++++++++++++++++++- 7 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/api/adminSalesStats.ts b/src/api/adminSalesStats.ts index 7b5f261..8faabc8 100644 --- a/src/api/adminSalesStats.ts +++ b/src/api/adminSalesStats.ts @@ -19,6 +19,8 @@ export interface SalesSummary { trial_to_paid_conversion: number; renewals_count: number; addon_revenue_kopeks: number; + refunds_count: number; + refunds_kopeks: number; } // ============ Trials ============ diff --git a/src/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx index 79f2600..92189ae 100644 --- a/src/components/stats/StatCard.tsx +++ b/src/components/stats/StatCard.tsx @@ -26,6 +26,9 @@ interface StatCardProps { valueClassName?: string; /** Optional period-over-period change shown under the value. */ delta?: StatCardDelta | null; + /** For "lower is better" metrics (e.g. refunds): keep the real arrow but flip + * the colour, so a rise reads as bad (red) and a drop as good (green). */ + invertDelta?: boolean; } export function StatCard({ @@ -35,10 +38,18 @@ export function StatCard({ tone = 'neutral', valueClassName, delta, + invertDelta = false, }: StatCardProps) { const toneStyle = TONE[tone]; const valueClass = valueClassName ?? toneStyle.value; - const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null; + const baseTrend = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null; + const trendStyle = + baseTrend && invertDelta && delta && delta.trend !== 'stable' + ? { + arrow: baseTrend.arrow, + className: TREND_STYLES[delta.trend === 'up' ? 'down' : 'up'].className, + } + : baseTrend; return (
diff --git a/src/locales/en.json b/src/locales/en.json index 6a482c4..68bfaac 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1306,7 +1306,9 @@ "conversion": "Conversion", "renewals": "Renewals", "addonRevenue": "Add-on Revenue", - "manualTopup": "Manual Top-ups" + "manualTopup": "Manual Top-ups", + "refundsCount": "Refunds", + "refundsAmount": "Refunded amount" }, "trials": { "total": "Total Trials", diff --git a/src/locales/fa.json b/src/locales/fa.json index 26cde24..2c744e2 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1179,7 +1179,9 @@ "conversion": "تبدیل", "renewals": "تمدید", "addonRevenue": "درآمد خدمات اضافی", - "manualTopup": "شارژ دستی" + "manualTopup": "شارژ دستی", + "refundsCount": "بازپرداخت‌ها", + "refundsAmount": "مبلغ بازپرداخت" }, "trials": { "total": "کل آزمایشی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 872bd7c..b77dbec 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1328,7 +1328,9 @@ "conversion": "Конверсия", "renewals": "Продления", "addonRevenue": "Доп. услуги", - "manualTopup": "Ручные пополнения" + "manualTopup": "Ручные пополнения", + "refundsCount": "Возвраты", + "refundsAmount": "Сумма возвратов" }, "trials": { "total": "Всего триалов", diff --git a/src/locales/zh.json b/src/locales/zh.json index dca06ba..6a9d6a1 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1179,7 +1179,9 @@ "conversion": "转化率", "renewals": "续订", "addonRevenue": "附加服务收入", - "manualTopup": "手动充值" + "manualTopup": "手动充值", + "refundsCount": "退款", + "refundsAmount": "退款金额" }, "trials": { "total": "试用总数", diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index 55cdb8d..bd044db 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -13,6 +13,7 @@ import { GiftIcon, PercentIcon, RepeatIcon, + ResetIcon, RocketIcon, SparklesIcon, TicketIcon, @@ -115,6 +116,7 @@ export default function AdminSalesStats() { renewals: computeDelta(summary.renewals_count, prevSummary.renewals_count), addonRevenue: computeDelta(summary.addon_revenue_kopeks, prevSummary.addon_revenue_kopeks), manualTopup: computeDelta(summary.manual_topup_kopeks, prevSummary.manual_topup_kopeks), + refunds: computeDelta(summary.refunds_kopeks, prevSummary.refunds_kopeks), }; }, [summary, prevSummary]); @@ -148,7 +150,7 @@ export default function AdminSalesStats() { {t('admin.salesStats.loadError')}
)} -
+
+ } + tone="error" + /> + } + tone="error" + delta={deltas?.refunds} + invertDelta + />
{/* Tabs */} From 56d8e1ffa760558c0196cf1069a5696fe03dd0c9 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 17:15:18 +0300 Subject: [PATCH 20/25] =?UTF-8?q?fix(sales-stats):=20=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D0=BF=D1=82=D0=B8=D0=B2=20=D1=81=D0=B5=D1=82=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=94=D0=BE=D0=BF=D0=BE=D0=B2=20=D0=B8=20=D1=81=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Допы (5 карточек) рисовали 4+1: orphan-fill из двух arbitrary-вариантов конфликтовал по специфичности — правило :last-child:nth-child(odd) бьёт ресет :last-child, поэтому 5-я карточка тянулась на 2 колонки и на десктопе. Заменил на один mobile-вариант max-lg:, как в AdminRemnawave: десктоп — 5 в ряд, планшет/мобайл — 2 колонки + последняя на всю ширину. Сводку тоже перевёл на lg:grid-cols-5 (было sm) — на планшете 5 колонок давали ~115px и резали суммы; теперь до десктопа 2 колонки. --- src/components/sales-stats/AddonsTab.tsx | 2 +- src/pages/AdminSalesStats.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/sales-stats/AddonsTab.tsx b/src/components/sales-stats/AddonsTab.tsx index ec24e76..e1b9552 100644 --- a/src/components/sales-stats/AddonsTab.tsx +++ b/src/components/sales-stats/AddonsTab.tsx @@ -67,7 +67,7 @@ export function AddonsTab({ params }: AddonsTabProps) { return (
-
+
)} -
+
Date: Tue, 2 Jun 2026 18:01:33 +0300 Subject: [PATCH 21/25] =?UTF-8?q?revert(sales-stats):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Возвраты для оператора всегда 0 (реальные возвраты только Apple/Tribute, которых нет) — постоянно пустые карточки только путают. Сводка вернулась к 8 карточкам (grid 2/4). Убрал также неиспользуемый invertDelta из StatCard и ключи refunds из локалей. --- src/api/adminSalesStats.ts | 2 -- src/components/stats/StatCard.tsx | 13 +------------ src/locales/en.json | 4 +--- src/locales/fa.json | 4 +--- src/locales/ru.json | 4 +--- src/locales/zh.json | 4 +--- src/pages/AdminSalesStats.tsx | 22 +--------------------- 7 files changed, 6 insertions(+), 47 deletions(-) diff --git a/src/api/adminSalesStats.ts b/src/api/adminSalesStats.ts index 8faabc8..7b5f261 100644 --- a/src/api/adminSalesStats.ts +++ b/src/api/adminSalesStats.ts @@ -19,8 +19,6 @@ export interface SalesSummary { trial_to_paid_conversion: number; renewals_count: number; addon_revenue_kopeks: number; - refunds_count: number; - refunds_kopeks: number; } // ============ Trials ============ diff --git a/src/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx index 92189ae..79f2600 100644 --- a/src/components/stats/StatCard.tsx +++ b/src/components/stats/StatCard.tsx @@ -26,9 +26,6 @@ interface StatCardProps { valueClassName?: string; /** Optional period-over-period change shown under the value. */ delta?: StatCardDelta | null; - /** For "lower is better" metrics (e.g. refunds): keep the real arrow but flip - * the colour, so a rise reads as bad (red) and a drop as good (green). */ - invertDelta?: boolean; } export function StatCard({ @@ -38,18 +35,10 @@ export function StatCard({ tone = 'neutral', valueClassName, delta, - invertDelta = false, }: StatCardProps) { const toneStyle = TONE[tone]; const valueClass = valueClassName ?? toneStyle.value; - const baseTrend = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null; - const trendStyle = - baseTrend && invertDelta && delta && delta.trend !== 'stable' - ? { - arrow: baseTrend.arrow, - className: TREND_STYLES[delta.trend === 'up' ? 'down' : 'up'].className, - } - : baseTrend; + const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null; return (
diff --git a/src/locales/en.json b/src/locales/en.json index 68bfaac..6a482c4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1306,9 +1306,7 @@ "conversion": "Conversion", "renewals": "Renewals", "addonRevenue": "Add-on Revenue", - "manualTopup": "Manual Top-ups", - "refundsCount": "Refunds", - "refundsAmount": "Refunded amount" + "manualTopup": "Manual Top-ups" }, "trials": { "total": "Total Trials", diff --git a/src/locales/fa.json b/src/locales/fa.json index 2c744e2..26cde24 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1179,9 +1179,7 @@ "conversion": "تبدیل", "renewals": "تمدید", "addonRevenue": "درآمد خدمات اضافی", - "manualTopup": "شارژ دستی", - "refundsCount": "بازپرداخت‌ها", - "refundsAmount": "مبلغ بازپرداخت" + "manualTopup": "شارژ دستی" }, "trials": { "total": "کل آزمایشی", diff --git a/src/locales/ru.json b/src/locales/ru.json index b77dbec..872bd7c 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1328,9 +1328,7 @@ "conversion": "Конверсия", "renewals": "Продления", "addonRevenue": "Доп. услуги", - "manualTopup": "Ручные пополнения", - "refundsCount": "Возвраты", - "refundsAmount": "Сумма возвратов" + "manualTopup": "Ручные пополнения" }, "trials": { "total": "Всего триалов", diff --git a/src/locales/zh.json b/src/locales/zh.json index 6a9d6a1..dca06ba 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1179,9 +1179,7 @@ "conversion": "转化率", "renewals": "续订", "addonRevenue": "附加服务收入", - "manualTopup": "手动充值", - "refundsCount": "退款", - "refundsAmount": "退款金额" + "manualTopup": "手动充值" }, "trials": { "total": "试用总数", diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index 0be2563..55cdb8d 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -13,7 +13,6 @@ import { GiftIcon, PercentIcon, RepeatIcon, - ResetIcon, RocketIcon, SparklesIcon, TicketIcon, @@ -116,7 +115,6 @@ export default function AdminSalesStats() { renewals: computeDelta(summary.renewals_count, prevSummary.renewals_count), addonRevenue: computeDelta(summary.addon_revenue_kopeks, prevSummary.addon_revenue_kopeks), manualTopup: computeDelta(summary.manual_topup_kopeks, prevSummary.manual_topup_kopeks), - refunds: computeDelta(summary.refunds_kopeks, prevSummary.refunds_kopeks), }; }, [summary, prevSummary]); @@ -150,7 +148,7 @@ export default function AdminSalesStats() { {t('admin.salesStats.loadError')}
)} -
+
- } - tone="error" - /> - } - tone="error" - delta={deltas?.refunds} - invertDelta - />
{/* Tabs */} From 436d9f860c89a4012c57a9689b11abdff1572332 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 18:46:42 +0300 Subject: [PATCH 22/25] =?UTF-8?q?feat(sales-stats):=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=20=C2=AB=D0=9E=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=8B=C2=BB=20=E2=80=94=20success-rate=20=D0=B8=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=83=D0=B4=D0=B0=D1=87=D0=BD=D1=8B=D0=B5=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BF=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая вкладка в статистике продаж: - success-rate платежей (оплачено/создано) — общий + по каждому шлюзу с цветными барами (зелёный ≥90%, жёлтый ≥70%, красный ниже); - кол-во попыток оплат; - «Неудачные покупки» — внутренние откаты при ошибке покупки (сигнал надёжности, не возвраты). Данные из нового эндпоинта /payment-health. Шлюзы без активности не показываются. Карточки 1/3 (без сирот), адаптив. 4 локали. --- src/api/adminSalesStats.ts | 20 +++ .../sales-stats/PaymentHealthTab.tsx | 115 ++++++++++++++++++ src/components/sales-stats/index.ts | 1 + src/locales/en.json | 9 +- src/locales/fa.json | 9 +- src/locales/ru.json | 9 +- src/locales/zh.json | 9 +- src/pages/AdminSalesStats.tsx | 5 +- 8 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 src/components/sales-stats/PaymentHealthTab.tsx diff --git a/src/api/adminSalesStats.ts b/src/api/adminSalesStats.ts index 7b5f261..5017520 100644 --- a/src/api/adminSalesStats.ts +++ b/src/api/adminSalesStats.ts @@ -165,6 +165,21 @@ export interface DepositsStats { daily_by_method: DailyDepositByMethodItem[]; } +export interface GatewaySuccessItem { + method: string; + total: number; + paid: number; + success_rate: number; +} + +export interface PaymentHealth { + total_attempts: number; + total_paid: number; + success_rate: number; + failed_purchases: number; + by_gateway: GatewaySuccessItem[]; +} + // ============ API ============ export const salesStatsApi = { @@ -197,4 +212,9 @@ export const salesStatsApi = { const response = await apiClient.get('/cabinet/admin/stats/sales/deposits', { params }); return response.data; }, + + getPaymentHealth: async (params: SalesStatsParams = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/sales/payment-health', { params }); + return response.data; + }, }; diff --git a/src/components/sales-stats/PaymentHealthTab.tsx b/src/components/sales-stats/PaymentHealthTab.tsx new file mode 100644 index 0000000..78c7326 --- /dev/null +++ b/src/components/sales-stats/PaymentHealthTab.tsx @@ -0,0 +1,115 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import type { SalesStatsParams } from '../../api/adminSalesStats'; +import { salesStatsApi } from '../../api/adminSalesStats'; +import { METHOD_LABELS } from '../../constants/paymentMethods'; +import { SALES_STATS } from '../../constants/salesStats'; +import { CardIcon, PercentIcon, WarningIcon } from '../../components/icons'; +import PaymentMethodIcon from '../PaymentMethodIcon'; +import { StatCard } from '../stats'; + +interface PaymentHealthTabProps { + params: SalesStatsParams; +} + +/** Green when healthy, amber when shaky, red when most attempts fail. */ +function rateColor(rate: number): string { + if (rate >= 90) return 'text-success-400'; + if (rate >= 70) return 'text-warning-400'; + return 'text-error-400'; +} + +function barColor(rate: number): string { + if (rate >= 90) return 'bg-success-500'; + if (rate >= 70) return 'bg-warning-500'; + return 'bg-error-500'; +} + +export function PaymentHealthTab({ params }: PaymentHealthTabProps) { + const { t } = useTranslation(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['sales-stats', 'payment-health', params], + queryFn: () => salesStatsApi.getPaymentHealth(params), + staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, + }); + + if (isLoading) { + return ( +
+ {Array.from({ length: 3 }, (_, i) => ( +
+ ))} +
+ ); + } + + if (isError || !data) { + return
{t('admin.salesStats.loadError')}
; + } + + return ( +
+
+ } + tone={data.success_rate >= 90 ? 'success' : data.success_rate >= 70 ? 'warning' : 'error'} + /> + } + tone="accent" + /> + } + tone="warning" + /> +
+ +
+

+ {t('admin.salesStats.payments.byGateway')} +

+ {data.by_gateway.length === 0 ? ( +
{t('common.noData')}
+ ) : ( +
+ {data.by_gateway.map((g) => ( +
+ +
+
+ + {METHOD_LABELS[g.method] || g.method} + + + {g.paid}/{g.total} + +
+
+
+
+
+ + {g.success_rate}% + +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/components/sales-stats/index.ts b/src/components/sales-stats/index.ts index f9eba7d..bda2294 100644 --- a/src/components/sales-stats/index.ts +++ b/src/components/sales-stats/index.ts @@ -3,6 +3,7 @@ export { DepositsTab } from './DepositsTab'; export { DonutChart } from './DonutChart'; export { DualAreaChart } from './DualAreaChart'; export { MultiSeriesAreaChart } from './MultiSeriesAreaChart'; +export { PaymentHealthTab } from './PaymentHealthTab'; export { PeriodSelector } from './PeriodSelector'; export { RenewalsTab } from './RenewalsTab'; export { SalesTab } from './SalesTab'; diff --git a/src/locales/en.json b/src/locales/en.json index 6a482c4..eef7a36 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1296,7 +1296,8 @@ "sales": "Sales", "renewals": "Renewals", "addons": "Add-ons", - "deposits": "Deposits" + "deposits": "Deposits", + "payment": "Payments" }, "summary": { "revenue": "Revenue", @@ -1360,6 +1361,12 @@ "dailyByMethod": "Daily Deposits by Payment Method", "revenue": "Revenue" }, + "payments": { + "successRate": "Payment success rate", + "attempts": "Payment attempts", + "failedPurchases": "Failed purchases", + "byGateway": "Success rate by gateway" + }, "loadError": "Failed to load statistics" }, "referralNetwork": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 26cde24..377d506 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1169,7 +1169,8 @@ "sales": "فروش", "renewals": "تمدید", "addons": "خدمات اضافی", - "deposits": "شارژ" + "deposits": "شارژ", + "payment": "پرداخت‌ها" }, "summary": { "revenue": "درآمد", @@ -1233,6 +1234,12 @@ "dailyByMethod": "شارژ روزانه بر اساس روش پرداخت", "revenue": "درآمد" }, + "payments": { + "successRate": "نرخ موفقیت پرداخت", + "attempts": "تلاش‌های پرداخت", + "failedPurchases": "خریدهای ناموفق", + "byGateway": "نرخ موفقیت بر اساس درگاه" + }, "loadError": "بارگذاری آمار ناموفق بود" }, "referralNetwork": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 872bd7c..3ad0fe5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1318,7 +1318,8 @@ "sales": "Продажи", "renewals": "Продления", "addons": "Доп. услуги", - "deposits": "Пополнения" + "deposits": "Пополнения", + "payment": "Оплаты" }, "summary": { "revenue": "Доход", @@ -1382,6 +1383,12 @@ "dailyByMethod": "Пополнения по дням по платёжкам", "revenue": "Доход" }, + "payments": { + "successRate": "Успешность оплат", + "attempts": "Попыток оплат", + "failedPurchases": "Неудачные покупки", + "byGateway": "Успешность по шлюзам" + }, "loadError": "Не удалось загрузить статистику" }, "referralNetwork": { diff --git a/src/locales/zh.json b/src/locales/zh.json index dca06ba..9a188d9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1169,7 +1169,8 @@ "sales": "销售", "renewals": "续订", "addons": "附加服务", - "deposits": "充值" + "deposits": "充值", + "payment": "支付" }, "summary": { "revenue": "收入", @@ -1233,6 +1234,12 @@ "dailyByMethod": "每日按支付方式充值", "revenue": "收入" }, + "payments": { + "successRate": "支付成功率", + "attempts": "支付尝试", + "failedPurchases": "失败购买", + "byGateway": "各网关成功率" + }, "loadError": "加载统计失败" }, "referralNetwork": { diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index 55cdb8d..78a366e 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -22,13 +22,14 @@ import { StatCard } from '../components/stats'; import { AddonsTab, DepositsTab, + PaymentHealthTab, PeriodSelector, RenewalsTab, SalesTab, TrialsTab, } from '../components/sales-stats'; -type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits'; +type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits' | 'payment'; type Delta = { percent: number; trend: 'up' | 'down' | 'stable' }; @@ -124,6 +125,7 @@ export default function AdminSalesStats() { { id: 'renewals', label: t('admin.salesStats.tabs.renewals') }, { id: 'addons', label: t('admin.salesStats.tabs.addons') }, { id: 'deposits', label: t('admin.salesStats.tabs.deposits') }, + { id: 'payment', label: t('admin.salesStats.tabs.payment') }, ]; return ( @@ -258,6 +260,7 @@ export default function AdminSalesStats() { {activeTab === 'renewals' && } {activeTab === 'addons' && } {activeTab === 'deposits' && } + {activeTab === 'payment' && }
)}
From 3adda6fa032e3d105b8b7836b478f68f4c5e6d9f Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 19:38:38 +0300 Subject: [PATCH 23/25] =?UTF-8?q?feat(sales-stats):=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D1=80=D0=B5=D1=82=D0=B8=D0=BA=D0=B0=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - «Активные подписки» теперь с % (нетто-изменение за период: новые платные − истёкшие; обычная дельта снимка была бы всегда 0). - Новая карточка «Новые платные подписки» (с дельтой период-к-периоду). - «Доход» больше не включает ручные пополнения (бэк отвязал). - Убрал «Средняя длит.» из триалов — была вечный 0 и непонятна. - Сводка 9 карточек: сетка 2/3 с orphan-fill на мобайле, без пустот. --- src/api/adminSalesStats.ts | 2 ++ src/components/sales-stats/TrialsTab.tsx | 10 ++------- src/locales/en.json | 2 +- src/locales/fa.json | 2 +- src/locales/ru.json | 2 +- src/locales/zh.json | 2 +- src/pages/AdminSalesStats.tsx | 27 ++++++++++++++++++++++-- 7 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/api/adminSalesStats.ts b/src/api/adminSalesStats.ts index 5017520..bcd1eea 100644 --- a/src/api/adminSalesStats.ts +++ b/src/api/adminSalesStats.ts @@ -16,6 +16,8 @@ export interface SalesSummary { active_subscriptions: number; active_trials: number; new_trials: number; + new_paid_subscriptions: number; + expired_subscriptions: number; trial_to_paid_conversion: number; renewals_count: number; addon_revenue_kopeks: number; diff --git a/src/components/sales-stats/TrialsTab.tsx b/src/components/sales-stats/TrialsTab.tsx index f041105..a074907 100644 --- a/src/components/sales-stats/TrialsTab.tsx +++ b/src/components/sales-stats/TrialsTab.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; import { salesStatsApi } from '../../api/adminSalesStats'; import { SALES_STATS } from '../../constants/salesStats'; -import { ClockIcon, GiftIcon, PercentIcon, UserPlusIcon } from '../../components/icons'; +import { GiftIcon, PercentIcon, UserPlusIcon } from '../../components/icons'; import { StatCard } from '../stats'; import { DonutChart } from './DonutChart'; @@ -61,7 +61,7 @@ export function TrialsTab({ params }: TrialsTabProps) { return (
-
+
} tone="success" /> - } - tone="neutral" - />
diff --git a/src/locales/en.json b/src/locales/en.json index eef7a36..41c7c93 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1304,6 +1304,7 @@ "activeSubs": "Active Subs", "activeTrials": "Active Trials", "newTrials": "New Trials", + "newPaid": "New Paid Subscriptions", "conversion": "Conversion", "renewals": "Renewals", "addonRevenue": "Add-on Revenue", @@ -1314,7 +1315,6 @@ "totalRegistrations": "Registrations", "trialsIssued": "Trials Issued", "conversion": "Conversion Rate", - "avgDuration": "Avg Duration", "byProvider": "By Registration Source", "dailyChart": "Daily Registrations & Trials", "registrations": "Registrations" diff --git a/src/locales/fa.json b/src/locales/fa.json index 377d506..00ef8c1 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1177,6 +1177,7 @@ "activeSubs": "اشتراک فعال", "activeTrials": "آزمایشی فعال", "newTrials": "آزمایشی جدید", + "newPaid": "اشتراک‌های پولی جدید", "conversion": "تبدیل", "renewals": "تمدید", "addonRevenue": "درآمد خدمات اضافی", @@ -1187,7 +1188,6 @@ "totalRegistrations": "ثبت‌نام‌ها", "trialsIssued": "آزمایشی صادرشده", "conversion": "نرخ تبدیل", - "avgDuration": "میانگین مدت", "byProvider": "بر اساس منبع", "dailyChart": "ثبت‌نام و آزمایشی روزانه", "registrations": "ثبت‌نام" diff --git a/src/locales/ru.json b/src/locales/ru.json index 3ad0fe5..d7bd867 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1326,6 +1326,7 @@ "activeSubs": "Активные подписки", "activeTrials": "Активные триалы", "newTrials": "Новые триалы", + "newPaid": "Новые платные подписки", "conversion": "Конверсия", "renewals": "Продления", "addonRevenue": "Доп. услуги", @@ -1336,7 +1337,6 @@ "totalRegistrations": "Регистрации", "trialsIssued": "Выданные триалы", "conversion": "Конверсия", - "avgDuration": "Средняя длит.", "byProvider": "По источнику регистрации", "dailyChart": "Регистрации и триалы по дням", "registrations": "Регистрации" diff --git a/src/locales/zh.json b/src/locales/zh.json index 9a188d9..9143769 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1177,6 +1177,7 @@ "activeSubs": "活跃订阅", "activeTrials": "活跃试用", "newTrials": "新试用", + "newPaid": "新增付费订阅", "conversion": "转化率", "renewals": "续订", "addonRevenue": "附加服务收入", @@ -1187,7 +1188,6 @@ "totalRegistrations": "注册数", "trialsIssued": "已发放试用", "conversion": "转化率", - "avgDuration": "平均时长", "byProvider": "按注册来源", "dailyChart": "每日注册与试用", "registrations": "注册" diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index 78a366e..ce59c47 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -12,6 +12,7 @@ import { BanknotesIcon, GiftIcon, PercentIcon, + PlusIcon, RepeatIcon, RocketIcon, SparklesIcon, @@ -113,12 +114,26 @@ export default function AdminSalesStats() { return { revenue: computeDelta(summary.total_revenue_kopeks, prevSummary.total_revenue_kopeks), newTrials: computeDelta(summary.new_trials, prevSummary.new_trials), + newPaid: computeDelta(summary.new_paid_subscriptions, prevSummary.new_paid_subscriptions), renewals: computeDelta(summary.renewals_count, prevSummary.renewals_count), addonRevenue: computeDelta(summary.addon_revenue_kopeks, prevSummary.addon_revenue_kopeks), manualTopup: computeDelta(summary.manual_topup_kopeks, prevSummary.manual_topup_kopeks), }; }, [summary, prevSummary]); + // Active-subscriptions trend = net change over the period (new paid − expired), + // shown relative to the count at the start of the period. The active count + // itself is a "right now" snapshot, so a plain period-over-period delta would + // always be zero — this shows real growth/shrinkage instead. + const activeDelta = useMemo(() => { + if (!summary) return null; + const net = summary.new_paid_subscriptions - summary.expired_subscriptions; + if (net === 0) return null; + const base = summary.active_subscriptions - net; + const percent = base > 0 ? Math.round((Math.abs(net) / base) * 1000) / 10 : 100; + return { percent, trend: net > 0 ? 'up' : 'down' }; + }, [summary]); + const tabs: { id: TabId; label: string }[] = [ { id: 'trials', label: t('admin.salesStats.tabs.trials') }, { id: 'sales', label: t('admin.salesStats.tabs.sales') }, @@ -150,7 +165,7 @@ export default function AdminSalesStats() { {t('admin.salesStats.loadError')}
)} -
+
} tone="accent" + delta={activeDelta} + /> + } + tone="success" + delta={deltas?.newPaid} /> } + icon={} tone="accent" delta={deltas?.addonRevenue} /> From 488503b360806def859e7792919b68ab29ae44e1 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 19:54:34 +0300 Subject: [PATCH 24/25] =?UTF-8?q?fix(deeplink):=20=D0=BF=D0=BE=D0=B4=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B0=D1=82=D1=8C=20=D1=81=D1=85=D0=B5=D0=BC?= =?UTF-8?q?=D1=83=20incy://=20=D0=B2=20=D1=80=D0=B5=D0=B4=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B5=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новое приложение INCY (incy://import/{url}) не открывалось на роутах /add и /connect: DeepLinkRedirect держит хардкод-allowlist разрешённых схем (happ, hiddify, v2rayng, streisand…), и isValidDeepLink резал incy:// как неизвестную схему. Добавил incy:// в список. Бэкенд не трогаю: _create_deep_link / _get_url_scheme_for_app собирают deep-link из urlScheme Remnawave-конфига полностью обобщённо (без списка имён), incy:// он строит сам. Mini-app путь (redirect.html) тоже generic. --- src/pages/DeepLinkRedirect.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/DeepLinkRedirect.tsx b/src/pages/DeepLinkRedirect.tsx index 7422e35..4479b28 100644 --- a/src/pages/DeepLinkRedirect.tsx +++ b/src/pages/DeepLinkRedirect.tsx @@ -17,6 +17,7 @@ type Status = 'countdown' | 'fallback' | 'error'; // App schemes configuration - same as miniapp const appSchemes = [ { scheme: 'happ://', icon: 'H', name: 'Happ' }, + { scheme: 'incy://', icon: 'I', name: 'INCY' }, { scheme: 'flclash://', icon: 'F', name: 'FlClash' }, { scheme: 'clash://', icon: 'C', name: 'Clash Meta' }, { scheme: 'sing-box://', icon: 'S', name: 'sing-box' }, From 7efe069be077a35e3538908d24bd68d11407d513 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 21:15:33 +0300 Subject: [PATCH 25/25] =?UTF-8?q?fix(admin-remnawave):=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=B0=D1=82=D1=8C=20=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B8=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=D1=85=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Локальный StatCard держал иконку в чипе p-2, который подстраивался под размер иконки (а часть иконок передавалась без className) — отсюда разные по размеру чипы и «кривые» иконки. Привёл к виду карточек статистики продаж: лейбл сверху, фиксированный чип h-9 w-9 с принудительным размером иконки ([&>svg]:h-5 w-5 нормализует все иконки разом), значение в тон чипу. Рамку и subValue сохранил. --- src/pages/AdminRemnawave.tsx | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index 3c7c592..4658771 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -116,24 +116,33 @@ interface StatCardProps { subValue?: string; } +// Soulful chip + matching value colour, in the spirit of the sales-stats cards. +// Fixed-size chip + forced icon size normalises every icon regardless of what the +// call site passes (some omit a className) — that was the source of the misalignment. +const STAT_TONE: Record = { + accent: { chip: 'bg-accent-500/15 text-accent-400', value: 'text-accent-400' }, + green: { chip: 'bg-success-500/15 text-success-400', value: 'text-success-400' }, + blue: { chip: 'bg-accent-500/15 text-accent-400', value: 'text-accent-400' }, + orange: { chip: 'bg-warning-500/15 text-warning-400', value: 'text-warning-400' }, + red: { chip: 'bg-error-500/15 text-error-400', value: 'text-error-400' }, + purple: { chip: 'bg-accent-500/15 text-accent-400', value: 'text-accent-400' }, +}; + function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardProps) { - const colorClasses: Record = { - accent: 'bg-accent-500/20 text-accent-400', - green: 'bg-success-500/20 text-success-400', - blue: 'bg-accent-500/20 text-accent-400', - orange: 'bg-warning-500/20 text-warning-400', - red: 'bg-error-500/20 text-error-400', - purple: 'bg-accent-500/20 text-accent-400', - }; + const tone = STAT_TONE[color] ?? STAT_TONE.accent; return (
-
-
{icon}
+

{label}

+
+ svg]:h-5 [&>svg]:w-5 ${tone.chip}`} + > + {icon} +
-

{label}

-

{value}

- {subValue &&

{subValue}

} +
{value}
+ {subValue &&
{subValue}
}