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:
Fringg
2026-03-02 20:34:24 +03:00
parent c7d05c4809
commit 673de08dd4
16 changed files with 449 additions and 84 deletions

View File

@@ -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<AdminCampaignChartData>({
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() {
>
<div>
<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 className="text-xs text-dark-500">{reg.telegram_id}</div>
</div>

View File

@@ -414,6 +414,13 @@ export default function AdminPanel() {
description: t('admin.panel.trafficUsageDesc'),
permission: 'traffic:read',
},
{
to: '/admin/sales-stats',
icon: <ChartBarIcon />,
title: t('admin.nav.salesStats'),
description: t('admin.panel.salesStatsDesc'),
permission: 'stats:read',
},
],
},
{

View File

@@ -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<ReturnType<typeof setTimeout> | 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() {
>
<div>
<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 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>
{ref.has_paid ? (
@@ -384,11 +396,13 @@ export default function Referral() {
>
<div>
<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 className="mt-0.5 text-xs text-dark-500">
{t(`referral.reasons.${earning.reason}`, earning.reason)} {' '}
{new Date(earning.created_at).toLocaleDateString()}
{new Date(earning.created_at).toLocaleDateString(i18n.language)}
</div>
</div>
<div className="font-semibold text-success-400">
@@ -442,9 +456,9 @@ export default function Referral() {
{partnerStatus?.latest_application?.created_at && (
<p className="mt-2 text-xs text-dark-500">
{t('referral.partner.submittedAt', {
date: new Date(
partnerStatus.latest_application.created_at,
).toLocaleDateString(),
date: new Date(partnerStatus.latest_application.created_at).toLocaleDateString(
i18n.language,
),
})}
</p>
)}
@@ -641,7 +655,7 @@ export default function Referral() {
</span>
</div>
<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 && (
<span className="ml-1">
&bull;{' '}