diff --git a/src/App.tsx b/src/App.tsx index 04adf9e..e03ee71 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -87,6 +87,7 @@ const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')); const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail')); const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')); const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage')); +const AdminSalesStats = lazy(() => import('./pages/AdminSalesStats')); const AdminUpdates = lazy(() => import('./pages/AdminUpdates')); const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail')); @@ -744,6 +745,16 @@ function App() { } /> + + + + + + } + /> setCopiedLink(null), + PARTNER_STATS.COPY_FEEDBACK_MS, + ); } catch { haptic.notification('error'); } - setCopiedLink(key); - clearTimeout(copyTimerRef.current); - copyTimerRef.current = setTimeout(() => setCopiedLink(null), PARTNER_STATS.COPY_FEEDBACK_MS); }, [haptic], ); diff --git a/src/components/stats/DailyChart.tsx b/src/components/stats/DailyChart.tsx index ed6ef48..d1d06c0 100644 --- a/src/components/stats/DailyChart.tsx +++ b/src/components/stats/DailyChart.tsx @@ -42,7 +42,7 @@ export function DailyChart({ data, chartId, title, earningsLabel, countLabel }: data.map((item) => ({ ...item, earnings_display: item.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR, - label: new Date(item.date).toLocaleDateString(i18n.language, { + label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, { month: 'short', day: 'numeric', }), diff --git a/src/components/stats/PeriodComparison.tsx b/src/components/stats/PeriodComparison.tsx index 7e03772..33582d6 100644 --- a/src/components/stats/PeriodComparison.tsx +++ b/src/components/stats/PeriodComparison.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import type { PeriodComparison as PeriodComparisonType } from './types'; +import { TREND_STYLES } from './constants'; import { PARTNER_STATS } from '../../constants/partner'; import { useCurrency } from '../../hooks/useCurrency'; @@ -12,16 +13,6 @@ interface PeriodComparisonProps { comparisonLabel?: string; } -const TREND_ARROW_UP = '\u2191'; -const TREND_ARROW_DOWN = '\u2193'; -const TREND_ARROW_STABLE = '\u2192'; - -const TREND_STYLES = { - up: { arrow: TREND_ARROW_UP, className: 'text-success-400' }, - down: { arrow: TREND_ARROW_DOWN, className: 'text-error-400' }, - stable: { arrow: TREND_ARROW_STABLE, className: 'text-dark-400' }, -} as const; - interface TrendBadgeProps { trend: 'up' | 'down' | 'stable'; percent: number; diff --git a/src/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx index 55ead22..c632fee 100644 --- a/src/components/stats/StatCard.tsx +++ b/src/components/stats/StatCard.tsx @@ -21,7 +21,9 @@ export function StatCard({ {icon} {label} -
{value}
+
+ {value} +
); } diff --git a/src/components/stats/constants.ts b/src/components/stats/constants.ts new file mode 100644 index 0000000..5d37429 --- /dev/null +++ b/src/components/stats/constants.ts @@ -0,0 +1,5 @@ +export const TREND_STYLES = { + up: { arrow: '\u2191', className: 'text-success-400' }, + down: { arrow: '\u2193', className: 'text-error-400' }, + stable: { arrow: '\u2192', className: 'text-dark-400' }, +} as const; diff --git a/src/constants/charts.ts b/src/constants/charts.ts new file mode 100644 index 0000000..006c4a2 --- /dev/null +++ b/src/constants/charts.ts @@ -0,0 +1,22 @@ +export const CHART_COMMON = { + KOPEKS_DIVISOR: 100, + GRADIENT: { + START_OFFSET: '5%', + END_OFFSET: '95%', + START_OPACITY: 0.3, + END_OPACITY: 0, + }, + AXIS: { + TICK_FONT_SIZE: 11, + }, + TOOLTIP: { + BORDER_RADIUS: '8px', + FONT_SIZE: '12px', + }, + STROKE_WIDTH: 2, + GRID_DASH: '3 3', + STALE_TIME: 60_000, + CHART: { + MARGIN: { top: 5, right: 10, left: 0, bottom: 5 }, + }, +} as const; diff --git a/src/constants/partner.ts b/src/constants/partner.ts index 282d1e2..4eb7000 100644 --- a/src/constants/partner.ts +++ b/src/constants/partner.ts @@ -1,39 +1,33 @@ +import { CHART_COMMON } from './charts'; + export const PARTNER_STATS = { /** Chart visual configuration */ CHART: { + ...CHART_COMMON.CHART, HEIGHT: 200, - MARGIN: { top: 5, right: 10, left: 0, bottom: 5 }, }, /** Kopeks-to-currency divisor */ - KOPEKS_DIVISOR: 100, + KOPEKS_DIVISOR: CHART_COMMON.KOPEKS_DIVISOR, /** Copy feedback duration in ms */ COPY_FEEDBACK_MS: 2000, /** Gradient stop offsets */ - GRADIENT: { - START_OFFSET: '5%', - END_OFFSET: '95%', - START_OPACITY: 0.3, - END_OPACITY: 0, - }, + GRADIENT: CHART_COMMON.GRADIENT, /** Axis tick styling */ AXIS: { - TICK_FONT_SIZE: 11, + TICK_FONT_SIZE: CHART_COMMON.AXIS.TICK_FONT_SIZE, EARNINGS_WIDTH: 45, REFERRALS_WIDTH: 30, }, /** Tooltip styling */ - TOOLTIP: { - BORDER_RADIUS: '8px', - FONT_SIZE: '12px', - }, + TOOLTIP: CHART_COMMON.TOOLTIP, /** Area stroke width */ - STROKE_WIDTH: 2, + STROKE_WIDTH: CHART_COMMON.STROKE_WIDTH, /** Grid dash array */ - GRID_DASH: '3 3', + GRID_DASH: CHART_COMMON.GRID_DASH, /** Skeleton count for loading state */ SKELETON_COUNT: 3, /** Maximum conversion rate for progress bar */ MAX_CONVERSION_RATE: 100, /** Stale time for campaign stats query (ms) */ - STATS_STALE_TIME: 60_000, + STATS_STALE_TIME: CHART_COMMON.STALE_TIME, } as const; diff --git a/src/hooks/useChartColors.ts b/src/hooks/useChartColors.ts index 3f1c8ec..ce85b9d 100644 --- a/src/hooks/useChartColors.ts +++ b/src/hooks/useChartColors.ts @@ -1,3 +1,5 @@ +import { useEffect, useMemo, useState } from 'react'; + import { rgbToHex } from '../utils/colorConversion'; /** CSS variable names for chart theme colors */ @@ -48,16 +50,35 @@ export interface ChartColors { * Returns hex values derived from the current theme's CSS variables, * adapting to light/dark mode and admin-customized accent colors. * - * No memoization — getComputedStyle is cheap and themes can change at any time. + * Colors are memoized and only recomputed when the document element's + * class attribute changes (i.e. theme switch). */ export function useChartColors(): ChartColors { - return { - earnings: cssVarToHex(CSS_VARS.earnings, FALLBACK.earnings), - referrals: cssVarToHex(CSS_VARS.referrals, FALLBACK.referrals), - grid: cssVarToHex(CSS_VARS.grid, FALLBACK.grid), - tooltipBg: cssVarToHex(CSS_VARS.tooltipBg, FALLBACK.tooltipBg), - tooltipBorder: cssVarToHex(CSS_VARS.tooltipBorder, FALLBACK.tooltipBorder), - tick: cssVarToHex(CSS_VARS.tick, FALLBACK.tick), - label: cssVarToHex(CSS_VARS.label, FALLBACK.label), - }; + const [themeKey, setThemeKey] = useState(() => + typeof document !== 'undefined' ? document.documentElement.className : '', + ); + + useEffect(() => { + const observer = new MutationObserver(() => { + setThemeKey(document.documentElement.className); + }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); + + const colors = useMemo( + () => ({ + earnings: cssVarToHex(CSS_VARS.earnings, FALLBACK.earnings), + referrals: cssVarToHex(CSS_VARS.referrals, FALLBACK.referrals), + grid: cssVarToHex(CSS_VARS.grid, FALLBACK.grid), + tooltipBg: cssVarToHex(CSS_VARS.tooltipBg, FALLBACK.tooltipBg), + tooltipBorder: cssVarToHex(CSS_VARS.tooltipBorder, FALLBACK.tooltipBorder), + tick: cssVarToHex(CSS_VARS.tick, FALLBACK.tick), + label: cssVarToHex(CSS_VARS.label, FALLBACK.label), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [themeKey], + ); + + return colors; } diff --git a/src/locales/en.json b/src/locales/en.json index 2ea4903..f31fffc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -702,6 +702,8 @@ }, "yourReferrals": "Your Referrals", "noReferrals": "No referrals yet. Share your link to invite friends!", + "anonymousUser": "User #{{id}}", + "anonymousReferral": "Referral", "user": "User", "joined": "Joined", "hasPaid": "Has Paid", @@ -1040,7 +1042,8 @@ "roles": "Roles", "roleAssign": "Role Assignment", "policies": "Access Policies", - "auditLog": "Audit Log" + "auditLog": "Audit Log", + "salesStats": "Sales Statistics" }, "panel": { "title": "Admin Panel", @@ -1072,7 +1075,78 @@ "rolesDesc": "Manage admin roles and permissions", "roleAssignDesc": "Assign and revoke user roles", "policiesDesc": "Configure ABAC access policies", - "auditLogDesc": "Review system activity and access history" + "auditLogDesc": "Review system activity and access history", + "salesStatsDesc": "Sales analytics and trends" + }, + "salesStats": { + "title": "Sales Statistics", + "subtitle": "Revenue, trials, subscriptions, and renewals", + "period": { + "week": "7 days", + "month": "30 days", + "quarter": "90 days", + "all": "All time", + "custom": "Custom" + }, + "tabs": { + "trials": "Trials", + "sales": "Sales", + "renewals": "Renewals", + "addons": "Add-ons", + "deposits": "Deposits" + }, + "summary": { + "revenue": "Revenue", + "activeSubs": "Active Subs", + "activeTrials": "Active Trials", + "newTrials": "New Trials", + "conversion": "Conversion", + "renewals": "Renewals", + "addonRevenue": "Add-on Revenue" + }, + "trials": { + "total": "Total Trials", + "conversion": "Conversion Rate", + "avgDuration": "Avg Duration", + "byProvider": "By Registration Source", + "dailyChart": "Daily Registrations", + "registrations": "Registrations" + }, + "sales": { + "totalSales": "Total Sales", + "avgOrder": "Avg Order", + "topTariff": "Top Tariff", + "byTariff": "Sales by Tariff", + "byPeriod": "By Subscription Period", + "dailyChart": "Daily Sales" + }, + "renewals": { + "total": "Total Renewals", + "rate": "Renewal Rate", + "revenue": "Revenue", + "comparison": "Period Comparison", + "currentPeriod": "Current Period", + "previousPeriod": "Previous Period", + "dailyChart": "Daily Renewals", + "renewals": "Renewals" + }, + "addons": { + "totalPurchases": "Total Purchases", + "totalGb": "Total Traffic", + "revenue": "Revenue", + "byPackage": "By Package Size", + "dailyChart": "Daily Purchases", + "purchases": "Purchases" + }, + "deposits": { + "totalDeposits": "Total Deposits", + "totalAmount": "Total Amount", + "avgDeposit": "Avg Deposit", + "byMethod": "By Payment Method", + "dailyChart": "Daily Deposits", + "revenue": "Revenue" + }, + "loadError": "Failed to load statistics" }, "trafficUsage": { "title": "Traffic Usage", @@ -2021,9 +2095,7 @@ "totalSpending": "Subscription Spending", "periodComparison": "Period comparison", "vsLastWeek": "vs last week", - "topRegistrations": "Top registrations", - "noRegistrations": "No registrations yet", - "chartLoading": "Loading chart data..." + "topRegistrations": "Top registrations" }, "users": { "title": "Campaign Users", diff --git a/src/locales/fa.json b/src/locales/fa.json index 997a409..362ca7a 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -536,6 +536,8 @@ }, "yourReferrals": "معرفی‌های شما", "noReferrals": "هنوز معرفی ندارید. لینک را به اشتراک بگذارید!", + "anonymousUser": "کاربر #{{id}}", + "anonymousReferral": "معرفی‌شده", "user": "کاربر", "joined": "تاریخ عضویت", "hasPaid": "پرداخت کرده", @@ -863,7 +865,8 @@ "roles": "نقش‌ها", "roleAssign": "تخصیص نقش", "policies": "سیاست‌های دسترسی", - "auditLog": "گزارش بازرسی" + "auditLog": "گزارش بازرسی", + "salesStats": "آمار فروش" }, "panel": { "title": "پنل مدیریت", @@ -895,7 +898,78 @@ "rolesDesc": "مدیریت نقش‌ها و مجوزهای مدیران", "roleAssignDesc": "تخصیص و لغو نقش‌های کاربران", "policiesDesc": "تنظیم سیاست‌های کنترل دسترسی ABAC", - "auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی" + "auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی", + "salesStatsDesc": "تحلیل فروش و روندها" + }, + "salesStats": { + "title": "آمار فروش", + "subtitle": "درآمد، آزمایشی، اشتراک و تمدید", + "period": { + "week": "۷ روز", + "month": "۳۰ روز", + "quarter": "۹۰ روز", + "all": "همه", + "custom": "سفارشی" + }, + "tabs": { + "trials": "آزمایشی", + "sales": "فروش", + "renewals": "تمدید", + "addons": "خدمات اضافی", + "deposits": "شارژ" + }, + "summary": { + "revenue": "درآمد", + "activeSubs": "اشتراک فعال", + "activeTrials": "آزمایشی فعال", + "newTrials": "آزمایشی جدید", + "conversion": "تبدیل", + "renewals": "تمدید", + "addonRevenue": "درآمد خدمات اضافی" + }, + "trials": { + "total": "کل آزمایشی", + "conversion": "نرخ تبدیل", + "avgDuration": "میانگین مدت", + "byProvider": "بر اساس منبع", + "dailyChart": "ثبت‌نام روزانه", + "registrations": "ثبت‌نام" + }, + "sales": { + "totalSales": "کل فروش", + "avgOrder": "میانگین سفارش", + "topTariff": "تعرفه برتر", + "byTariff": "بر اساس تعرفه", + "byPeriod": "بر اساس دوره", + "dailyChart": "فروش روزانه" + }, + "renewals": { + "total": "کل تمدیدها", + "rate": "نرخ تمدید", + "revenue": "درآمد", + "comparison": "مقایسه دوره", + "currentPeriod": "دوره فعلی", + "previousPeriod": "دوره قبلی", + "dailyChart": "تمدید روزانه", + "renewals": "تمدید" + }, + "addons": { + "totalPurchases": "کل خرید", + "totalGb": "کل ترافیک", + "revenue": "درآمد", + "byPackage": "بر اساس بسته", + "dailyChart": "خرید روزانه", + "purchases": "خرید" + }, + "deposits": { + "totalDeposits": "کل شارژ", + "totalAmount": "مبلغ کل", + "avgDeposit": "میانگین شارژ", + "byMethod": "بر اساس روش پرداخت", + "dailyChart": "شارژ روزانه", + "revenue": "درآمد" + }, + "loadError": "بارگذاری آمار ناموفق بود" }, "trafficUsage": { "title": "مصرف ترافیک", @@ -1700,9 +1774,7 @@ "totalSpending": "هزینه اشتراک", "periodComparison": "مقایسه دوره‌ها", "vsLastWeek": "نسبت به هفته قبل", - "topRegistrations": "بالاترین ثبت‌نام‌ها", - "noRegistrations": "هنوز ثبت‌نامی وجود ندارد", - "chartLoading": "در حال بارگذاری..." + "topRegistrations": "بالاترین ثبت‌نام‌ها" }, "users": { "title": "کاربران کمپین", @@ -1752,7 +1824,8 @@ "approved": "تأیید شده", "rejected": "رد شده", "completed": "تکمیل شده", - "cancelled": "لغو شده" + "cancelled": "لغو شده", + "unknown": "ناشناخته" }, "detail": { "title": "جزئیات برداشت", diff --git a/src/locales/ru.json b/src/locales/ru.json index 372995b..cb5cd27 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -730,6 +730,8 @@ }, "yourReferrals": "Ваши рефералы", "noReferrals": "Пока нет рефералов. Поделитесь ссылкой, чтобы пригласить друзей!", + "anonymousUser": "Пользователь #{{id}}", + "anonymousReferral": "Реферал", "user": "Пользователь", "joined": "Присоединился", "hasPaid": "Оплатил", @@ -1061,7 +1063,8 @@ "roles": "Роли", "roleAssign": "Назначение ролей", "policies": "Политики доступа", - "auditLog": "Журнал аудита" + "auditLog": "Журнал аудита", + "salesStats": "Статистика продаж" }, "panel": { "title": "Панель администратора", @@ -1093,7 +1096,78 @@ "rolesDesc": "Управление ролями и правами администраторов", "roleAssignDesc": "Назначение и отзыв ролей пользователей", "policiesDesc": "Настройка политик контроля доступа ABAC", - "auditLogDesc": "Просмотр активности системы и истории доступа" + "auditLogDesc": "Просмотр активности системы и истории доступа", + "salesStatsDesc": "Аналитика продаж и тренды" + }, + "salesStats": { + "title": "Статистика продаж", + "subtitle": "Доход, триалы, подписки и продления", + "period": { + "week": "7 дней", + "month": "30 дней", + "quarter": "90 дней", + "all": "Всё время", + "custom": "Свой период" + }, + "tabs": { + "trials": "Триалы", + "sales": "Продажи", + "renewals": "Продления", + "addons": "Доп. услуги", + "deposits": "Пополнения" + }, + "summary": { + "revenue": "Доход", + "activeSubs": "Активные подписки", + "activeTrials": "Активные триалы", + "newTrials": "Новые триалы", + "conversion": "Конверсия", + "renewals": "Продления", + "addonRevenue": "Доп. услуги" + }, + "trials": { + "total": "Всего триалов", + "conversion": "Конверсия", + "avgDuration": "Средняя длит.", + "byProvider": "По источнику регистрации", + "dailyChart": "Регистрации по дням", + "registrations": "Регистрации" + }, + "sales": { + "totalSales": "Всего продаж", + "avgOrder": "Средний чек", + "topTariff": "Топ тариф", + "byTariff": "Продажи по тарифам", + "byPeriod": "По периоду подписки", + "dailyChart": "Продажи по дням" + }, + "renewals": { + "total": "Всего продлений", + "rate": "Доля продлений", + "revenue": "Доход", + "comparison": "Сравнение периодов", + "currentPeriod": "Текущий период", + "previousPeriod": "Предыдущий период", + "dailyChart": "Продления по дням", + "renewals": "Продления" + }, + "addons": { + "totalPurchases": "Всего покупок", + "totalGb": "Всего трафика", + "revenue": "Доход", + "byPackage": "По размеру пакета", + "dailyChart": "Покупки по дням", + "purchases": "Покупки" + }, + "deposits": { + "totalDeposits": "Всего пополнений", + "totalAmount": "Общая сумма", + "avgDeposit": "Средний чек", + "byMethod": "По способу оплаты", + "dailyChart": "Пополнения по дням", + "revenue": "Доход" + }, + "loadError": "Не удалось загрузить статистику" }, "trafficUsage": { "title": "Расход трафика", @@ -2542,9 +2616,7 @@ "totalSpending": "Расход на подписки", "periodComparison": "Сравнение периодов", "vsLastWeek": "vs прошлая неделя", - "topRegistrations": "Топ регистрации", - "noRegistrations": "Пока нет регистраций", - "chartLoading": "Загрузка данных..." + "topRegistrations": "Топ регистрации" }, "users": { "title": "Пользователи кампании", diff --git a/src/locales/zh.json b/src/locales/zh.json index d3ea2f3..aebde75 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -536,6 +536,8 @@ }, "yourReferrals": "您的推荐", "noReferrals": "暂无推荐。分享链接邀请好友!", + "anonymousUser": "用户 #{{id}}", + "anonymousReferral": "推荐人", "user": "用户", "joined": "加入时间", "hasPaid": "已付款", @@ -863,7 +865,8 @@ "roles": "角色", "roleAssign": "角色分配", "policies": "访问策略", - "auditLog": "审计日志" + "auditLog": "审计日志", + "salesStats": "销售统计" }, "panel": { "title": "管理面板", @@ -895,7 +898,78 @@ "rolesDesc": "管理管理员角色和权限", "roleAssignDesc": "分配和撤销用户角色", "policiesDesc": "配置ABAC访问控制策略", - "auditLogDesc": "查看系统活动和访问历史" + "auditLogDesc": "查看系统活动和访问历史", + "salesStatsDesc": "销售分析与趋势" + }, + "salesStats": { + "title": "销售统计", + "subtitle": "收入、试用、订阅和续订", + "period": { + "week": "7天", + "month": "30天", + "quarter": "90天", + "all": "全部", + "custom": "自定义" + }, + "tabs": { + "trials": "试用", + "sales": "销售", + "renewals": "续订", + "addons": "附加服务", + "deposits": "充值" + }, + "summary": { + "revenue": "收入", + "activeSubs": "活跃订阅", + "activeTrials": "活跃试用", + "newTrials": "新试用", + "conversion": "转化率", + "renewals": "续订", + "addonRevenue": "附加服务收入" + }, + "trials": { + "total": "试用总数", + "conversion": "转化率", + "avgDuration": "平均时长", + "byProvider": "按注册来源", + "dailyChart": "每日注册", + "registrations": "注册" + }, + "sales": { + "totalSales": "总销售", + "avgOrder": "平均订单", + "topTariff": "热门套餐", + "byTariff": "按套餐", + "byPeriod": "按订阅周期", + "dailyChart": "每日销售" + }, + "renewals": { + "total": "续订总数", + "rate": "续订率", + "revenue": "收入", + "comparison": "期间对比", + "currentPeriod": "当前期间", + "previousPeriod": "上一期间", + "dailyChart": "每日续订", + "renewals": "续订" + }, + "addons": { + "totalPurchases": "购买总数", + "totalGb": "总流量", + "revenue": "收入", + "byPackage": "按套餐大小", + "dailyChart": "每日购买", + "purchases": "购买" + }, + "deposits": { + "totalDeposits": "充值总数", + "totalAmount": "总金额", + "avgDeposit": "平均充值", + "byMethod": "按支付方式", + "dailyChart": "每日充值", + "revenue": "收入" + }, + "loadError": "加载统计失败" }, "trafficUsage": { "title": "流量使用", @@ -1699,9 +1773,7 @@ "totalSpending": "订阅支出", "periodComparison": "期间比较", "vsLastWeek": "与上周对比", - "topRegistrations": "热门注册", - "noRegistrations": "暂无注册", - "chartLoading": "加载图表数据..." + "topRegistrations": "热门注册" }, "users": { "title": "活动用户", @@ -1751,7 +1823,8 @@ "approved": "已批准", "rejected": "已拒绝", "completed": "已完成", - "cancelled": "已取消" + "cancelled": "已取消", + "unknown": "未知" }, "detail": { "title": "提现详情", diff --git a/src/pages/AdminCampaignStats.tsx b/src/pages/AdminCampaignStats.tsx index 8209496..3aa00df 100644 --- a/src/pages/AdminCampaignStats.tsx +++ b/src/pages/AdminCampaignStats.tsx @@ -82,6 +82,8 @@ const bonusTypeConfig: Record< export default function AdminCampaignStats() { const { t, i18n } = useTranslation(); const { id } = useParams<{ id: string }>(); + const numericId = id ? Number(id) : null; + const isValidId = numericId !== null && !isNaN(numericId); const navigate = useNavigate(); const haptic = useHaptic(); const { formatWithCurrency } = useCurrency(); @@ -105,22 +107,23 @@ export default function AdminCampaignStats() { error, } = useQuery({ queryKey: ['campaign-stats', id], - queryFn: () => campaignsApi.getCampaignStats(Number(id)), - enabled: !!id, + queryFn: () => campaignsApi.getCampaignStats(numericId!), + enabled: isValidId, + staleTime: PARTNER_STATS.STATS_STALE_TIME, }); // Fetch registrations when users section is open const { data: registrationsData, isLoading: usersLoading } = useQuery({ queryKey: ['campaign-registrations', id], - queryFn: () => campaignsApi.getCampaignRegistrations(Number(id), 1, 50), - enabled: !!id && showUsers, + queryFn: () => campaignsApi.getCampaignRegistrations(numericId!, 1, 50), + enabled: isValidId && showUsers, }); // Fetch chart data const { data: chartData, isLoading: chartLoading } = useQuery({ queryKey: ['campaign-chart-data', id], - queryFn: () => campaignsApi.getChartData(Number(id)), - enabled: !!id, + queryFn: () => campaignsApi.getChartData(numericId!), + enabled: isValidId, staleTime: PARTNER_STATS.STATS_STALE_TIME, }); @@ -517,7 +520,9 @@ export default function AdminCampaignStats() { >
- {reg.first_name || reg.username || `User #${reg.user_id}`} + {reg.first_name || + reg.username || + `${t('admin.campaigns.stats.users')} #${reg.user_id}`}
{reg.telegram_id}
diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 21577a7..1bcacaf 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -414,6 +414,13 @@ export default function AdminPanel() { description: t('admin.panel.trafficUsageDesc'), permission: 'traffic:read', }, + { + to: '/admin/sales-stats', + icon: , + title: t('admin.nav.salesStats'), + description: t('admin.panel.salesStatsDesc'), + permission: 'stats:read', + }, ], }, { diff --git a/src/pages/Referral.tsx b/src/pages/Referral.tsx index a30bedc..24ca797 100644 --- a/src/pages/Referral.tsx +++ b/src/pages/Referral.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { referralApi } from '../api/referral'; +import { copyToClipboard } from '../utils/clipboard'; import { brandingApi } from '../api/branding'; import { partnerApi } from '../api/partners'; import { withdrawalApi } from '../api/withdrawals'; @@ -89,11 +90,18 @@ function getWithdrawalStatusBadge(status: string): string { } export default function Referral() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const navigate = useNavigate(); const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency(); const queryClient = useQueryClient(); const [copied, setCopied] = useState(false); + const copyTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }; + }, []); const { data: info, isLoading } = useQuery({ queryKey: ['referral-info'], @@ -156,11 +164,15 @@ export default function Referral() { }, }); - const copyLink = () => { - if (referralLink) { - navigator.clipboard.writeText(referralLink); + const copyLink = async () => { + if (!referralLink) return; + try { + await copyToClipboard(referralLink); setCopied(true); - setTimeout(() => setCopied(false), 2000); + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard write failed silently } }; @@ -334,10 +346,10 @@ export default function Referral() { >
- {ref.first_name || ref.username || `User #${ref.id}`} + {ref.first_name || ref.username || t('referral.anonymousUser', { id: ref.id })}
- {new Date(ref.created_at).toLocaleDateString()} + {new Date(ref.created_at).toLocaleDateString(i18n.language)}
{ref.has_paid ? ( @@ -384,11 +396,13 @@ export default function Referral() { >
- {earning.referral_first_name || earning.referral_username || 'Referral'} + {earning.referral_first_name || + earning.referral_username || + t('referral.anonymousReferral')}
{t(`referral.reasons.${earning.reason}`, earning.reason)} •{' '} - {new Date(earning.created_at).toLocaleDateString()} + {new Date(earning.created_at).toLocaleDateString(i18n.language)}
@@ -442,9 +456,9 @@ export default function Referral() { {partnerStatus?.latest_application?.created_at && (

{t('referral.partner.submittedAt', { - date: new Date( - partnerStatus.latest_application.created_at, - ).toLocaleDateString(), + date: new Date(partnerStatus.latest_application.created_at).toLocaleDateString( + i18n.language, + ), })}

)} @@ -641,7 +655,7 @@ export default function Referral() {
- {new Date(item.created_at).toLocaleDateString()} + {new Date(item.created_at).toLocaleDateString(i18n.language)} {item.payment_details && ( •{' '}