diff --git a/package-lock.json b/package-lock.json index c6a8e3e..eb0ae1c 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 1e3ccef..ef20618 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/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(() => { diff --git a/src/api/adminSalesStats.ts b/src/api/adminSalesStats.ts index 7b5f261..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; @@ -165,6 +167,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 +214,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/DateField.tsx b/src/components/DateField.tsx new file mode 100644 index 0000000..585560f --- /dev/null +++ b/src/components/DateField.tsx @@ -0,0 +1,113 @@ +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; + // 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: '2-digit', + 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/admin/trafficUsage/filters/PeriodSelector.tsx b/src/components/admin/trafficUsage/filters/PeriodSelector.tsx index da00ca9..52d5973 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 @@ -41,25 +42,21 @@ export function PeriodSelector({ if (dateMode) { return ( -
- +
+ {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} /> + {SALES_STATS.PERIOD_PRESETS.map((days) => ( ))} - {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')} /> - {'\u2014'} - {'—'} + 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/components/sales-stats/RenewalsTab.tsx b/src/components/sales-stats/RenewalsTab.tsx index 185c798..ddffdb1 100644 --- a/src/components/sales-stats/RenewalsTab.tsx +++ b/src/components/sales-stats/RenewalsTab.tsx @@ -1,10 +1,11 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; 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'; @@ -22,6 +23,7 @@ export function RenewalsTab({ params }: RenewalsTabProps) { queryKey: ['sales-stats', 'renewals', params], queryFn: () => salesStatsApi.getRenewals(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); if (isLoading) { @@ -48,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 5b7bc44..d1cd433 100644 --- a/src/components/sales-stats/SalesTab.tsx +++ b/src/components/sales-stats/SalesTab.tsx @@ -1,17 +1,18 @@ 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'; 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'; import { DonutChart } from './DonutChart'; -import { MultiSeriesAreaChart } from './MultiSeriesAreaChart'; import { SimpleAreaChart } from './SimpleAreaChart'; -import { SimpleBarChart } from './SimpleBarChart'; +import { StackedBarChart } from './StackedBarChart'; interface SalesTabProps { params: SalesStatsParams; @@ -25,6 +26,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( @@ -48,8 +50,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,18 +68,35 @@ export function SalesTab({ params }: SalesTabProps) { return (
-
- +
+ } + tone="accent" + /> + } + tone="success" + /> } + tone="success" + /> + } + tone="warning" /> -
- +
@@ -87,11 +107,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/components/sales-stats/TrialsTab.tsx b/src/components/sales-stats/TrialsTab.tsx index 1cd09df..a074907 100644 --- a/src/components/sales-stats/TrialsTab.tsx +++ b/src/components/sales-stats/TrialsTab.tsx @@ -1,9 +1,10 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import type { SalesStatsParams } from '../../api/adminSalesStats'; import { salesStatsApi } from '../../api/adminSalesStats'; import { SALES_STATS } from '../../constants/salesStats'; +import { GiftIcon, PercentIcon, UserPlusIcon } from '../../components/icons'; import { StatCard } from '../stats'; import { DonutChart } from './DonutChart'; @@ -29,6 +30,7 @@ export function TrialsTab({ params }: TrialsTabProps) { queryKey: ['sales-stats', 'trials', params], queryFn: () => salesStatsApi.getTrials(params), staleTime: SALES_STATS.STALE_TIME, + placeholderData: keepPreviousData, }); if (isLoading) { @@ -59,20 +61,24 @@ export function TrialsTab({ params }: TrialsTabProps) { return (
-
+
} + tone="accent" + /> + } + tone="neutral" /> - - } + tone="success" />
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/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx index c632fee..79f2600 100644 --- a/src/components/stats/StatCard.tsx +++ b/src/components/stats/StatCard.tsx @@ -1,29 +1,67 @@ import { type ReactNode } from 'react'; -const DEFAULT_VALUE_CLASS = 'text-dark-100'; +import { TREND_STYLES } from './constants'; + +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 under the value. */ + delta?: StatCardDelta | null; } 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} +
+
{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 && ( + + {icon} + + )} +
+ {value} +
+ {trendStyle && ( +
+ {trendStyle.arrow} {Math.abs(delta!.percent)}% +
+ )}
); } 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', diff --git a/src/locales/en.json b/src/locales/en.json index 0976dba..41c7c93 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1282,6 +1282,9 @@ "title": "Sales Statistics", "subtitle": "Revenue, trials, subscriptions, and renewals", "period": { + "thisMonth": "This month", + "from": "From", + "to": "To", "week": "7 days", "month": "30 days", "quarter": "90 days", @@ -1293,13 +1296,15 @@ "sales": "Sales", "renewals": "Renewals", "addons": "Add-ons", - "deposits": "Deposits" + "deposits": "Deposits", + "payment": "Payments" }, "summary": { "revenue": "Revenue", "activeSubs": "Active Subs", "activeTrials": "Active Trials", "newTrials": "New Trials", + "newPaid": "New Paid Subscriptions", "conversion": "Conversion", "renewals": "Renewals", "addonRevenue": "Add-on Revenue", @@ -1310,13 +1315,13 @@ "totalRegistrations": "Registrations", "trialsIssued": "Trials Issued", "conversion": "Conversion Rate", - "avgDuration": "Avg Duration", "byProvider": "By Registration Source", "dailyChart": "Daily Registrations & Trials", "registrations": "Registrations" }, "sales": { "totalSales": "Total Sales", + "totalRevenue": "Subscription turnover", "avgOrder": "Avg Order", "topTariff": "Top Tariff", "byTariff": "Sales by Tariff", @@ -1356,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 7c4e281..00ef8c1 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1155,6 +1155,9 @@ "title": "آمار فروش", "subtitle": "درآمد، آزمایشی، اشتراک و تمدید", "period": { + "thisMonth": "این ماه", + "from": "از", + "to": "تا", "week": "۷ روز", "month": "۳۰ روز", "quarter": "۹۰ روز", @@ -1166,13 +1169,15 @@ "sales": "فروش", "renewals": "تمدید", "addons": "خدمات اضافی", - "deposits": "شارژ" + "deposits": "شارژ", + "payment": "پرداخت‌ها" }, "summary": { "revenue": "درآمد", "activeSubs": "اشتراک فعال", "activeTrials": "آزمایشی فعال", "newTrials": "آزمایشی جدید", + "newPaid": "اشتراک‌های پولی جدید", "conversion": "تبدیل", "renewals": "تمدید", "addonRevenue": "درآمد خدمات اضافی", @@ -1183,13 +1188,13 @@ "totalRegistrations": "ثبت‌نام‌ها", "trialsIssued": "آزمایشی صادرشده", "conversion": "نرخ تبدیل", - "avgDuration": "میانگین مدت", "byProvider": "بر اساس منبع", "dailyChart": "ثبت‌نام و آزمایشی روزانه", "registrations": "ثبت‌نام" }, "sales": { "totalSales": "کل فروش", + "totalRevenue": "گردش اشتراک", "avgOrder": "میانگین سفارش", "topTariff": "تعرفه برتر", "byTariff": "بر اساس تعرفه", @@ -1229,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 bf8081d..d7bd867 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1304,6 +1304,9 @@ "title": "Статистика продаж", "subtitle": "Доход, триалы, подписки и продления", "period": { + "thisMonth": "Этот месяц", + "from": "Начало", + "to": "Конец", "week": "7 дней", "month": "30 дней", "quarter": "90 дней", @@ -1315,13 +1318,15 @@ "sales": "Продажи", "renewals": "Продления", "addons": "Доп. услуги", - "deposits": "Пополнения" + "deposits": "Пополнения", + "payment": "Оплаты" }, "summary": { "revenue": "Доход", "activeSubs": "Активные подписки", "activeTrials": "Активные триалы", "newTrials": "Новые триалы", + "newPaid": "Новые платные подписки", "conversion": "Конверсия", "renewals": "Продления", "addonRevenue": "Доп. услуги", @@ -1332,13 +1337,13 @@ "totalRegistrations": "Регистрации", "trialsIssued": "Выданные триалы", "conversion": "Конверсия", - "avgDuration": "Средняя длит.", "byProvider": "По источнику регистрации", "dailyChart": "Регистрации и триалы по дням", "registrations": "Регистрации" }, "sales": { "totalSales": "Всего продаж", + "totalRevenue": "Оборот по подпискам", "avgOrder": "Средний чек", "topTariff": "Топ тариф", "byTariff": "Продажи по тарифам", @@ -1378,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 bcc6376..9143769 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1155,6 +1155,9 @@ "title": "销售统计", "subtitle": "收入、试用、订阅和续订", "period": { + "thisMonth": "本月", + "from": "从", + "to": "到", "week": "7天", "month": "30天", "quarter": "90天", @@ -1166,13 +1169,15 @@ "sales": "销售", "renewals": "续订", "addons": "附加服务", - "deposits": "充值" + "deposits": "充值", + "payment": "支付" }, "summary": { "revenue": "收入", "activeSubs": "活跃订阅", "activeTrials": "活跃试用", "newTrials": "新试用", + "newPaid": "新增付费订阅", "conversion": "转化率", "renewals": "续订", "addonRevenue": "附加服务收入", @@ -1183,13 +1188,13 @@ "totalRegistrations": "注册数", "trialsIssued": "已发放试用", "conversion": "转化率", - "avgDuration": "平均时长", "byProvider": "按注册来源", "dailyChart": "每日注册与试用", "registrations": "注册" }, "sales": { "totalSales": "总销售", + "totalRevenue": "订阅流水", "avgOrder": "平均订单", "topTariff": "热门套餐", "byTariff": "按套餐", @@ -1229,6 +1234,12 @@ "dailyByMethod": "每日按支付方式充值", "revenue": "收入" }, + "payments": { + "successRate": "支付成功率", + "attempts": "支付尝试", + "failedPurchases": "失败购买", + "byGateway": "各网关成功率" + }, "loadError": "加载统计失败" }, "referralNetwork": { diff --git a/src/pages/AdminAuditLog.tsx b/src/pages/AdminAuditLog.tsx index e90bbc8..0a3f533 100644 --- a/src/pages/AdminAuditLog.tsx +++ b/src/pages/AdminAuditLog.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { rbacApi, AuditLogEntry, AuditLogFilters } from '@/api/rbac'; import { PermissionGate } from '@/components/auth/PermissionGate'; +import { DateField } from '@/components/DateField'; import { usePlatform } from '@/platform/hooks/usePlatform'; import { BackIcon, @@ -704,12 +705,11 @@ export default function AdminAuditLog() { > {t('admin.auditLog.filters.dateFrom')} - setFilters((prev) => ({ ...prev, dateFrom: 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" + max={filters.dateTo} + onChange={(v) => setFilters((prev) => ({ ...prev, dateFrom: 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" />
@@ -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" />
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}
}
diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index 5fb400e..ce59c47 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -1,23 +1,70 @@ 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 { getMonthToDateRange } from '../utils/period'; import { AdminBackButton } from '../components/admin/AdminBackButton'; +import { + BanknotesIcon, + GiftIcon, + PercentIcon, + PlusIcon, + RepeatIcon, + RocketIcon, + SparklesIcon, + TicketIcon, + WalletIcon, +} from '../components/icons'; 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' }; + +/** 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(); @@ -28,7 +75,7 @@ export default function AdminSalesStats() { days?: number; startDate?: string; endDate?: string; - }>({ days: SALES_STATS.DEFAULT_PERIOD }); + }>(() => getMonthToDateRange()); const params: SalesStatsParams = useMemo( () => ({ @@ -50,14 +97,50 @@ export default function AdminSalesStats() { queryFn: () => salesStatsApi.getSummary(params), staleTime: SALES_STATS.STALE_TIME, enabled: isValidPeriod, + placeholderData: keepPreviousData, }); + 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, + placeholderData: keepPreviousData, + }); + + 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), + 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') }, { 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 ( @@ -82,7 +165,7 @@ export default function AdminSalesStats() { {t('admin.salesStats.loadError')}
)} -
+
} + tone="success" + delta={deltas?.revenue} /> } + tone="accent" + delta={activeDelta} + /> + } + tone="success" + delta={deltas?.newPaid} /> } + tone="neutral" /> } + tone="accent" + delta={deltas?.newTrials} /> } + tone="warning" /> } + tone="success" + delta={deltas?.renewals} /> } + tone="accent" + delta={deltas?.addonRevenue} /> } + tone="warning" + delta={deltas?.manualTopup} />
@@ -173,6 +283,7 @@ export default function AdminSalesStats() { {activeTab === 'renewals' && } {activeTab === 'addons' && } {activeTab === 'deposits' && } + {activeTab === 'payment' && }
)}
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' }, diff --git a/src/styles/globals.css b/src/styles/globals.css index 849ca51..4e783fe 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1380,6 +1380,89 @@ 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; +} + +/* 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.4; + --rdp-disabled-opacity: 0.4; + --rdp-selected-border: none; + --rdp-weekday-opacity: 1; +} +.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-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: 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)); background-color: rgb(var(--color-champagne-100)); 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; +}