mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
fix: improve campaign stats, shared chart components, and i18n coverage
- Extract shared chart constants to constants/charts.ts - Add TREND_STYLES to stats/constants.ts for reuse - Fix useChartColors with MutationObserver for theme changes - Fix DailyChart date parsing to prevent timezone shift - Add truncate to StatCard value for overflow safety - Fix CampaignCard copy feedback inside try block - Add NaN validation and staleTime to AdminCampaignStats - Replace hardcoded strings with i18n keys in Referral page - Add missing i18n keys across all 4 locales (en, ru, zh, fa) - Add sales stats route to AdminPanel and App router
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -87,6 +87,7 @@ const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
|||||||
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
|
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
|
||||||
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
||||||
const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage'));
|
const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage'));
|
||||||
|
const AdminSalesStats = lazy(() => import('./pages/AdminSalesStats'));
|
||||||
const AdminUpdates = lazy(() => import('./pages/AdminUpdates'));
|
const AdminUpdates = lazy(() => import('./pages/AdminUpdates'));
|
||||||
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||||
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
||||||
@@ -744,6 +745,16 @@ function App() {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/sales-stats"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="stats:read">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminSalesStats />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/payment-methods"
|
path="/admin/payment-methods"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -56,12 +56,15 @@ export function CampaignCard({ campaign }: CampaignCardProps) {
|
|||||||
try {
|
try {
|
||||||
await copyToClipboard(url);
|
await copyToClipboard(url);
|
||||||
haptic.notification('success');
|
haptic.notification('success');
|
||||||
|
setCopiedLink(key);
|
||||||
|
clearTimeout(copyTimerRef.current);
|
||||||
|
copyTimerRef.current = setTimeout(
|
||||||
|
() => setCopiedLink(null),
|
||||||
|
PARTNER_STATS.COPY_FEEDBACK_MS,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
haptic.notification('error');
|
haptic.notification('error');
|
||||||
}
|
}
|
||||||
setCopiedLink(key);
|
|
||||||
clearTimeout(copyTimerRef.current);
|
|
||||||
copyTimerRef.current = setTimeout(() => setCopiedLink(null), PARTNER_STATS.COPY_FEEDBACK_MS);
|
|
||||||
},
|
},
|
||||||
[haptic],
|
[haptic],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function DailyChart({ data, chartId, title, earningsLabel, countLabel }:
|
|||||||
data.map((item) => ({
|
data.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
earnings_display: item.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
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',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { PeriodComparison as PeriodComparisonType } from './types';
|
import type { PeriodComparison as PeriodComparisonType } from './types';
|
||||||
|
import { TREND_STYLES } from './constants';
|
||||||
import { PARTNER_STATS } from '../../constants/partner';
|
import { PARTNER_STATS } from '../../constants/partner';
|
||||||
import { useCurrency } from '../../hooks/useCurrency';
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
|
||||||
@@ -12,16 +13,6 @@ interface PeriodComparisonProps {
|
|||||||
comparisonLabel?: string;
|
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 {
|
interface TrendBadgeProps {
|
||||||
trend: 'up' | 'down' | 'stable';
|
trend: 'up' | 'down' | 'stable';
|
||||||
percent: number;
|
percent: number;
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export function StatCard({
|
|||||||
{icon}
|
{icon}
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-1 text-base font-semibold sm:text-lg ${valueClassName}`}>{value}</div>
|
<div className={`mt-1 truncate text-base font-semibold sm:text-lg ${valueClassName}`}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/components/stats/constants.ts
Normal file
5
src/components/stats/constants.ts
Normal file
@@ -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;
|
||||||
22
src/constants/charts.ts
Normal file
22
src/constants/charts.ts
Normal file
@@ -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;
|
||||||
@@ -1,39 +1,33 @@
|
|||||||
|
import { CHART_COMMON } from './charts';
|
||||||
|
|
||||||
export const PARTNER_STATS = {
|
export const PARTNER_STATS = {
|
||||||
/** Chart visual configuration */
|
/** Chart visual configuration */
|
||||||
CHART: {
|
CHART: {
|
||||||
|
...CHART_COMMON.CHART,
|
||||||
HEIGHT: 200,
|
HEIGHT: 200,
|
||||||
MARGIN: { top: 5, right: 10, left: 0, bottom: 5 },
|
|
||||||
},
|
},
|
||||||
/** Kopeks-to-currency divisor */
|
/** Kopeks-to-currency divisor */
|
||||||
KOPEKS_DIVISOR: 100,
|
KOPEKS_DIVISOR: CHART_COMMON.KOPEKS_DIVISOR,
|
||||||
/** Copy feedback duration in ms */
|
/** Copy feedback duration in ms */
|
||||||
COPY_FEEDBACK_MS: 2000,
|
COPY_FEEDBACK_MS: 2000,
|
||||||
/** Gradient stop offsets */
|
/** Gradient stop offsets */
|
||||||
GRADIENT: {
|
GRADIENT: CHART_COMMON.GRADIENT,
|
||||||
START_OFFSET: '5%',
|
|
||||||
END_OFFSET: '95%',
|
|
||||||
START_OPACITY: 0.3,
|
|
||||||
END_OPACITY: 0,
|
|
||||||
},
|
|
||||||
/** Axis tick styling */
|
/** Axis tick styling */
|
||||||
AXIS: {
|
AXIS: {
|
||||||
TICK_FONT_SIZE: 11,
|
TICK_FONT_SIZE: CHART_COMMON.AXIS.TICK_FONT_SIZE,
|
||||||
EARNINGS_WIDTH: 45,
|
EARNINGS_WIDTH: 45,
|
||||||
REFERRALS_WIDTH: 30,
|
REFERRALS_WIDTH: 30,
|
||||||
},
|
},
|
||||||
/** Tooltip styling */
|
/** Tooltip styling */
|
||||||
TOOLTIP: {
|
TOOLTIP: CHART_COMMON.TOOLTIP,
|
||||||
BORDER_RADIUS: '8px',
|
|
||||||
FONT_SIZE: '12px',
|
|
||||||
},
|
|
||||||
/** Area stroke width */
|
/** Area stroke width */
|
||||||
STROKE_WIDTH: 2,
|
STROKE_WIDTH: CHART_COMMON.STROKE_WIDTH,
|
||||||
/** Grid dash array */
|
/** Grid dash array */
|
||||||
GRID_DASH: '3 3',
|
GRID_DASH: CHART_COMMON.GRID_DASH,
|
||||||
/** Skeleton count for loading state */
|
/** Skeleton count for loading state */
|
||||||
SKELETON_COUNT: 3,
|
SKELETON_COUNT: 3,
|
||||||
/** Maximum conversion rate for progress bar */
|
/** Maximum conversion rate for progress bar */
|
||||||
MAX_CONVERSION_RATE: 100,
|
MAX_CONVERSION_RATE: 100,
|
||||||
/** Stale time for campaign stats query (ms) */
|
/** Stale time for campaign stats query (ms) */
|
||||||
STATS_STALE_TIME: 60_000,
|
STATS_STALE_TIME: CHART_COMMON.STALE_TIME,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { rgbToHex } from '../utils/colorConversion';
|
import { rgbToHex } from '../utils/colorConversion';
|
||||||
|
|
||||||
/** CSS variable names for chart theme colors */
|
/** 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,
|
* Returns hex values derived from the current theme's CSS variables,
|
||||||
* adapting to light/dark mode and admin-customized accent colors.
|
* 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 {
|
export function useChartColors(): ChartColors {
|
||||||
return {
|
const [themeKey, setThemeKey] = useState(() =>
|
||||||
earnings: cssVarToHex(CSS_VARS.earnings, FALLBACK.earnings),
|
typeof document !== 'undefined' ? document.documentElement.className : '',
|
||||||
referrals: cssVarToHex(CSS_VARS.referrals, FALLBACK.referrals),
|
);
|
||||||
grid: cssVarToHex(CSS_VARS.grid, FALLBACK.grid),
|
|
||||||
tooltipBg: cssVarToHex(CSS_VARS.tooltipBg, FALLBACK.tooltipBg),
|
useEffect(() => {
|
||||||
tooltipBorder: cssVarToHex(CSS_VARS.tooltipBorder, FALLBACK.tooltipBorder),
|
const observer = new MutationObserver(() => {
|
||||||
tick: cssVarToHex(CSS_VARS.tick, FALLBACK.tick),
|
setThemeKey(document.documentElement.className);
|
||||||
label: cssVarToHex(CSS_VARS.label, FALLBACK.label),
|
});
|
||||||
};
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colors = useMemo<ChartColors>(
|
||||||
|
() => ({
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -702,6 +702,8 @@
|
|||||||
},
|
},
|
||||||
"yourReferrals": "Your Referrals",
|
"yourReferrals": "Your Referrals",
|
||||||
"noReferrals": "No referrals yet. Share your link to invite friends!",
|
"noReferrals": "No referrals yet. Share your link to invite friends!",
|
||||||
|
"anonymousUser": "User #{{id}}",
|
||||||
|
"anonymousReferral": "Referral",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"joined": "Joined",
|
"joined": "Joined",
|
||||||
"hasPaid": "Has Paid",
|
"hasPaid": "Has Paid",
|
||||||
@@ -1040,7 +1042,8 @@
|
|||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
"roleAssign": "Role Assignment",
|
"roleAssign": "Role Assignment",
|
||||||
"policies": "Access Policies",
|
"policies": "Access Policies",
|
||||||
"auditLog": "Audit Log"
|
"auditLog": "Audit Log",
|
||||||
|
"salesStats": "Sales Statistics"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
@@ -1072,7 +1075,78 @@
|
|||||||
"rolesDesc": "Manage admin roles and permissions",
|
"rolesDesc": "Manage admin roles and permissions",
|
||||||
"roleAssignDesc": "Assign and revoke user roles",
|
"roleAssignDesc": "Assign and revoke user roles",
|
||||||
"policiesDesc": "Configure ABAC access policies",
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "Traffic Usage",
|
"title": "Traffic Usage",
|
||||||
@@ -2021,9 +2095,7 @@
|
|||||||
"totalSpending": "Subscription Spending",
|
"totalSpending": "Subscription Spending",
|
||||||
"periodComparison": "Period comparison",
|
"periodComparison": "Period comparison",
|
||||||
"vsLastWeek": "vs last week",
|
"vsLastWeek": "vs last week",
|
||||||
"topRegistrations": "Top registrations",
|
"topRegistrations": "Top registrations"
|
||||||
"noRegistrations": "No registrations yet",
|
|
||||||
"chartLoading": "Loading chart data..."
|
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "Campaign Users",
|
"title": "Campaign Users",
|
||||||
|
|||||||
@@ -536,6 +536,8 @@
|
|||||||
},
|
},
|
||||||
"yourReferrals": "معرفیهای شما",
|
"yourReferrals": "معرفیهای شما",
|
||||||
"noReferrals": "هنوز معرفی ندارید. لینک را به اشتراک بگذارید!",
|
"noReferrals": "هنوز معرفی ندارید. لینک را به اشتراک بگذارید!",
|
||||||
|
"anonymousUser": "کاربر #{{id}}",
|
||||||
|
"anonymousReferral": "معرفیشده",
|
||||||
"user": "کاربر",
|
"user": "کاربر",
|
||||||
"joined": "تاریخ عضویت",
|
"joined": "تاریخ عضویت",
|
||||||
"hasPaid": "پرداخت کرده",
|
"hasPaid": "پرداخت کرده",
|
||||||
@@ -863,7 +865,8 @@
|
|||||||
"roles": "نقشها",
|
"roles": "نقشها",
|
||||||
"roleAssign": "تخصیص نقش",
|
"roleAssign": "تخصیص نقش",
|
||||||
"policies": "سیاستهای دسترسی",
|
"policies": "سیاستهای دسترسی",
|
||||||
"auditLog": "گزارش بازرسی"
|
"auditLog": "گزارش بازرسی",
|
||||||
|
"salesStats": "آمار فروش"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "پنل مدیریت",
|
"title": "پنل مدیریت",
|
||||||
@@ -895,7 +898,78 @@
|
|||||||
"rolesDesc": "مدیریت نقشها و مجوزهای مدیران",
|
"rolesDesc": "مدیریت نقشها و مجوزهای مدیران",
|
||||||
"roleAssignDesc": "تخصیص و لغو نقشهای کاربران",
|
"roleAssignDesc": "تخصیص و لغو نقشهای کاربران",
|
||||||
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "مصرف ترافیک",
|
"title": "مصرف ترافیک",
|
||||||
@@ -1700,9 +1774,7 @@
|
|||||||
"totalSpending": "هزینه اشتراک",
|
"totalSpending": "هزینه اشتراک",
|
||||||
"periodComparison": "مقایسه دورهها",
|
"periodComparison": "مقایسه دورهها",
|
||||||
"vsLastWeek": "نسبت به هفته قبل",
|
"vsLastWeek": "نسبت به هفته قبل",
|
||||||
"topRegistrations": "بالاترین ثبتنامها",
|
"topRegistrations": "بالاترین ثبتنامها"
|
||||||
"noRegistrations": "هنوز ثبتنامی وجود ندارد",
|
|
||||||
"chartLoading": "در حال بارگذاری..."
|
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "کاربران کمپین",
|
"title": "کاربران کمپین",
|
||||||
@@ -1752,7 +1824,8 @@
|
|||||||
"approved": "تأیید شده",
|
"approved": "تأیید شده",
|
||||||
"rejected": "رد شده",
|
"rejected": "رد شده",
|
||||||
"completed": "تکمیل شده",
|
"completed": "تکمیل شده",
|
||||||
"cancelled": "لغو شده"
|
"cancelled": "لغو شده",
|
||||||
|
"unknown": "ناشناخته"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "جزئیات برداشت",
|
"title": "جزئیات برداشت",
|
||||||
|
|||||||
@@ -730,6 +730,8 @@
|
|||||||
},
|
},
|
||||||
"yourReferrals": "Ваши рефералы",
|
"yourReferrals": "Ваши рефералы",
|
||||||
"noReferrals": "Пока нет рефералов. Поделитесь ссылкой, чтобы пригласить друзей!",
|
"noReferrals": "Пока нет рефералов. Поделитесь ссылкой, чтобы пригласить друзей!",
|
||||||
|
"anonymousUser": "Пользователь #{{id}}",
|
||||||
|
"anonymousReferral": "Реферал",
|
||||||
"user": "Пользователь",
|
"user": "Пользователь",
|
||||||
"joined": "Присоединился",
|
"joined": "Присоединился",
|
||||||
"hasPaid": "Оплатил",
|
"hasPaid": "Оплатил",
|
||||||
@@ -1061,7 +1063,8 @@
|
|||||||
"roles": "Роли",
|
"roles": "Роли",
|
||||||
"roleAssign": "Назначение ролей",
|
"roleAssign": "Назначение ролей",
|
||||||
"policies": "Политики доступа",
|
"policies": "Политики доступа",
|
||||||
"auditLog": "Журнал аудита"
|
"auditLog": "Журнал аудита",
|
||||||
|
"salesStats": "Статистика продаж"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
@@ -1093,7 +1096,78 @@
|
|||||||
"rolesDesc": "Управление ролями и правами администраторов",
|
"rolesDesc": "Управление ролями и правами администраторов",
|
||||||
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
|
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
|
||||||
"policiesDesc": "Настройка политик контроля доступа ABAC",
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "Расход трафика",
|
"title": "Расход трафика",
|
||||||
@@ -2542,9 +2616,7 @@
|
|||||||
"totalSpending": "Расход на подписки",
|
"totalSpending": "Расход на подписки",
|
||||||
"periodComparison": "Сравнение периодов",
|
"periodComparison": "Сравнение периодов",
|
||||||
"vsLastWeek": "vs прошлая неделя",
|
"vsLastWeek": "vs прошлая неделя",
|
||||||
"topRegistrations": "Топ регистрации",
|
"topRegistrations": "Топ регистрации"
|
||||||
"noRegistrations": "Пока нет регистраций",
|
|
||||||
"chartLoading": "Загрузка данных..."
|
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "Пользователи кампании",
|
"title": "Пользователи кампании",
|
||||||
|
|||||||
@@ -536,6 +536,8 @@
|
|||||||
},
|
},
|
||||||
"yourReferrals": "您的推荐",
|
"yourReferrals": "您的推荐",
|
||||||
"noReferrals": "暂无推荐。分享链接邀请好友!",
|
"noReferrals": "暂无推荐。分享链接邀请好友!",
|
||||||
|
"anonymousUser": "用户 #{{id}}",
|
||||||
|
"anonymousReferral": "推荐人",
|
||||||
"user": "用户",
|
"user": "用户",
|
||||||
"joined": "加入时间",
|
"joined": "加入时间",
|
||||||
"hasPaid": "已付款",
|
"hasPaid": "已付款",
|
||||||
@@ -863,7 +865,8 @@
|
|||||||
"roles": "角色",
|
"roles": "角色",
|
||||||
"roleAssign": "角色分配",
|
"roleAssign": "角色分配",
|
||||||
"policies": "访问策略",
|
"policies": "访问策略",
|
||||||
"auditLog": "审计日志"
|
"auditLog": "审计日志",
|
||||||
|
"salesStats": "销售统计"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "管理面板",
|
"title": "管理面板",
|
||||||
@@ -895,7 +898,78 @@
|
|||||||
"rolesDesc": "管理管理员角色和权限",
|
"rolesDesc": "管理管理员角色和权限",
|
||||||
"roleAssignDesc": "分配和撤销用户角色",
|
"roleAssignDesc": "分配和撤销用户角色",
|
||||||
"policiesDesc": "配置ABAC访问控制策略",
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "流量使用",
|
"title": "流量使用",
|
||||||
@@ -1699,9 +1773,7 @@
|
|||||||
"totalSpending": "订阅支出",
|
"totalSpending": "订阅支出",
|
||||||
"periodComparison": "期间比较",
|
"periodComparison": "期间比较",
|
||||||
"vsLastWeek": "与上周对比",
|
"vsLastWeek": "与上周对比",
|
||||||
"topRegistrations": "热门注册",
|
"topRegistrations": "热门注册"
|
||||||
"noRegistrations": "暂无注册",
|
|
||||||
"chartLoading": "加载图表数据..."
|
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "活动用户",
|
"title": "活动用户",
|
||||||
@@ -1751,7 +1823,8 @@
|
|||||||
"approved": "已批准",
|
"approved": "已批准",
|
||||||
"rejected": "已拒绝",
|
"rejected": "已拒绝",
|
||||||
"completed": "已完成",
|
"completed": "已完成",
|
||||||
"cancelled": "已取消"
|
"cancelled": "已取消",
|
||||||
|
"unknown": "未知"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "提现详情",
|
"title": "提现详情",
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ const bonusTypeConfig: Record<
|
|||||||
export default function AdminCampaignStats() {
|
export default function AdminCampaignStats() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const numericId = id ? Number(id) : null;
|
||||||
|
const isValidId = numericId !== null && !isNaN(numericId);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const haptic = useHaptic();
|
const haptic = useHaptic();
|
||||||
const { formatWithCurrency } = useCurrency();
|
const { formatWithCurrency } = useCurrency();
|
||||||
@@ -105,22 +107,23 @@ export default function AdminCampaignStats() {
|
|||||||
error,
|
error,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['campaign-stats', id],
|
queryKey: ['campaign-stats', id],
|
||||||
queryFn: () => campaignsApi.getCampaignStats(Number(id)),
|
queryFn: () => campaignsApi.getCampaignStats(numericId!),
|
||||||
enabled: !!id,
|
enabled: isValidId,
|
||||||
|
staleTime: PARTNER_STATS.STATS_STALE_TIME,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch registrations when users section is open
|
// Fetch registrations when users section is open
|
||||||
const { data: registrationsData, isLoading: usersLoading } = useQuery({
|
const { data: registrationsData, isLoading: usersLoading } = useQuery({
|
||||||
queryKey: ['campaign-registrations', id],
|
queryKey: ['campaign-registrations', id],
|
||||||
queryFn: () => campaignsApi.getCampaignRegistrations(Number(id), 1, 50),
|
queryFn: () => campaignsApi.getCampaignRegistrations(numericId!, 1, 50),
|
||||||
enabled: !!id && showUsers,
|
enabled: isValidId && showUsers,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch chart data
|
// Fetch chart data
|
||||||
const { data: chartData, isLoading: chartLoading } = useQuery<AdminCampaignChartData>({
|
const { data: chartData, isLoading: chartLoading } = useQuery<AdminCampaignChartData>({
|
||||||
queryKey: ['campaign-chart-data', id],
|
queryKey: ['campaign-chart-data', id],
|
||||||
queryFn: () => campaignsApi.getChartData(Number(id)),
|
queryFn: () => campaignsApi.getChartData(numericId!),
|
||||||
enabled: !!id,
|
enabled: isValidId,
|
||||||
staleTime: PARTNER_STATS.STATS_STALE_TIME,
|
staleTime: PARTNER_STATS.STATS_STALE_TIME,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -517,7 +520,9 @@ export default function AdminCampaignStats() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-dark-100">
|
<div className="font-medium text-dark-100">
|
||||||
{reg.first_name || reg.username || `User #${reg.user_id}`}
|
{reg.first_name ||
|
||||||
|
reg.username ||
|
||||||
|
`${t('admin.campaigns.stats.users')} #${reg.user_id}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-dark-500">{reg.telegram_id}</div>
|
<div className="text-xs text-dark-500">{reg.telegram_id}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -414,6 +414,13 @@ export default function AdminPanel() {
|
|||||||
description: t('admin.panel.trafficUsageDesc'),
|
description: t('admin.panel.trafficUsageDesc'),
|
||||||
permission: 'traffic:read',
|
permission: 'traffic:read',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/sales-stats',
|
||||||
|
icon: <ChartBarIcon />,
|
||||||
|
title: t('admin.nav.salesStats'),
|
||||||
|
description: t('admin.panel.salesStatsDesc'),
|
||||||
|
permission: 'stats:read',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { referralApi } from '../api/referral';
|
import { referralApi } from '../api/referral';
|
||||||
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
import { brandingApi } from '../api/branding';
|
import { brandingApi } from '../api/branding';
|
||||||
import { partnerApi } from '../api/partners';
|
import { partnerApi } from '../api/partners';
|
||||||
import { withdrawalApi } from '../api/withdrawals';
|
import { withdrawalApi } from '../api/withdrawals';
|
||||||
@@ -89,11 +90,18 @@ function getWithdrawalStatusBadge(status: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Referral() {
|
export default function Referral() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
|
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: info, isLoading } = useQuery({
|
const { data: info, isLoading } = useQuery({
|
||||||
queryKey: ['referral-info'],
|
queryKey: ['referral-info'],
|
||||||
@@ -156,11 +164,15 @@ export default function Referral() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyLink = () => {
|
const copyLink = async () => {
|
||||||
if (referralLink) {
|
if (!referralLink) return;
|
||||||
navigator.clipboard.writeText(referralLink);
|
try {
|
||||||
|
await copyToClipboard(referralLink);
|
||||||
setCopied(true);
|
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() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-dark-100">
|
<div className="font-medium text-dark-100">
|
||||||
{ref.first_name || ref.username || `User #${ref.id}`}
|
{ref.first_name || ref.username || t('referral.anonymousUser', { id: ref.id })}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-xs text-dark-500">
|
<div className="mt-0.5 text-xs text-dark-500">
|
||||||
{new Date(ref.created_at).toLocaleDateString()}
|
{new Date(ref.created_at).toLocaleDateString(i18n.language)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ref.has_paid ? (
|
{ref.has_paid ? (
|
||||||
@@ -384,11 +396,13 @@ export default function Referral() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-dark-100">
|
<div className="text-dark-100">
|
||||||
{earning.referral_first_name || earning.referral_username || 'Referral'}
|
{earning.referral_first_name ||
|
||||||
|
earning.referral_username ||
|
||||||
|
t('referral.anonymousReferral')}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-xs text-dark-500">
|
<div className="mt-0.5 text-xs text-dark-500">
|
||||||
{t(`referral.reasons.${earning.reason}`, earning.reason)} •{' '}
|
{t(`referral.reasons.${earning.reason}`, earning.reason)} •{' '}
|
||||||
{new Date(earning.created_at).toLocaleDateString()}
|
{new Date(earning.created_at).toLocaleDateString(i18n.language)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-semibold text-success-400">
|
<div className="font-semibold text-success-400">
|
||||||
@@ -442,9 +456,9 @@ export default function Referral() {
|
|||||||
{partnerStatus?.latest_application?.created_at && (
|
{partnerStatus?.latest_application?.created_at && (
|
||||||
<p className="mt-2 text-xs text-dark-500">
|
<p className="mt-2 text-xs text-dark-500">
|
||||||
{t('referral.partner.submittedAt', {
|
{t('referral.partner.submittedAt', {
|
||||||
date: new Date(
|
date: new Date(partnerStatus.latest_application.created_at).toLocaleDateString(
|
||||||
partnerStatus.latest_application.created_at,
|
i18n.language,
|
||||||
).toLocaleDateString(),
|
),
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -641,7 +655,7 @@ export default function Referral() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-xs text-dark-500">
|
<div className="mt-0.5 text-xs text-dark-500">
|
||||||
{new Date(item.created_at).toLocaleDateString()}
|
{new Date(item.created_at).toLocaleDateString(i18n.language)}
|
||||||
{item.payment_details && (
|
{item.payment_details && (
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
•{' '}
|
•{' '}
|
||||||
|
|||||||
Reference in New Issue
Block a user