mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-15 01:03:08 +00:00
feat: add recharts analytics to admin campaign stats page
- Move shared chart components (DailyChart, PeriodComparison, StatCard) to src/components/stats/ with configurable label props - Create shared types for chart data decoupled from partner API - Add campaign chart data API client with deposits/spending split - Enhance AdminCampaignStats with daily trends chart, period comparison, top registrations, and deposit/spending stat cards - Add useChartColors hook with SSR guard and theme-aware CSS variable parsing - Add cross-platform clipboard utility with execCommand fallback for Telegram WebView - Add i18n keys for chart analytics across all 4 locales (en, ru, zh, fa) - Responsive fixes: truncation for long names/values, responsive text sizing, touch-friendly copy buttons
This commit is contained in:
@@ -149,6 +149,49 @@ export interface CampaignsOverview {
|
||||
total_tariff_issued: number;
|
||||
}
|
||||
|
||||
export interface AdminDailyStatItem {
|
||||
date: string;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface AdminPeriodStats {
|
||||
days: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface AdminPeriodChange {
|
||||
absolute: number;
|
||||
percent: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
export interface AdminPeriodComparison {
|
||||
current: AdminPeriodStats;
|
||||
previous: AdminPeriodStats;
|
||||
referrals_change: AdminPeriodChange;
|
||||
earnings_change: AdminPeriodChange;
|
||||
}
|
||||
|
||||
export interface AdminTopRegistrationItem {
|
||||
id: number;
|
||||
full_name: string;
|
||||
created_at: string;
|
||||
has_paid: boolean;
|
||||
is_active: boolean;
|
||||
total_earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface AdminCampaignChartData {
|
||||
campaign_id: number;
|
||||
total_deposits_kopeks: number;
|
||||
total_spending_kopeks: number;
|
||||
daily_stats: AdminDailyStatItem[];
|
||||
period_comparison: AdminPeriodComparison;
|
||||
top_registrations: AdminTopRegistrationItem[];
|
||||
}
|
||||
|
||||
export interface ServerSquadInfo {
|
||||
id: number;
|
||||
squad_uuid: string;
|
||||
@@ -258,4 +301,10 @@ export const campaignsApi = {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-partners');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get campaign chart data for analytics
|
||||
getChartData: async (campaignId: number): Promise<AdminCampaignChartData> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/chart-data`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface PartnerCampaignInfo {
|
||||
subscription_traffic_gb: number | null;
|
||||
deep_link: string | null;
|
||||
web_link: string | null;
|
||||
// Per-campaign statistics
|
||||
registrations_count: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PartnerStatusResponse {
|
||||
@@ -45,6 +49,57 @@ export interface PartnerApplicationRequest {
|
||||
desired_commission_percent?: number;
|
||||
}
|
||||
|
||||
// ==================== Campaign detailed stats types ====================
|
||||
|
||||
export interface DailyStatItem {
|
||||
date: string;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PeriodStats {
|
||||
days: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PeriodChange {
|
||||
absolute: number;
|
||||
percent: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
export interface PeriodComparison {
|
||||
current: PeriodStats;
|
||||
previous: PeriodStats;
|
||||
referrals_change: PeriodChange;
|
||||
earnings_change: PeriodChange;
|
||||
}
|
||||
|
||||
export interface CampaignReferralItem {
|
||||
id: number;
|
||||
full_name: string;
|
||||
created_at: string;
|
||||
has_paid: boolean;
|
||||
is_active: boolean;
|
||||
total_earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PartnerCampaignDetailedStats {
|
||||
campaign_id: number;
|
||||
campaign_name: string;
|
||||
registrations_count: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
conversion_rate: number;
|
||||
earnings_today: number;
|
||||
earnings_week: number;
|
||||
earnings_month: number;
|
||||
daily_stats: DailyStatItem[];
|
||||
period_comparison: PeriodComparison;
|
||||
top_referrals: CampaignReferralItem[];
|
||||
}
|
||||
|
||||
// ==================== Admin-facing types ====================
|
||||
|
||||
export interface AdminPartnerApplicationItem {
|
||||
@@ -159,6 +214,13 @@ export const partnerApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getCampaignStats: async (campaignId: number): Promise<PartnerCampaignDetailedStats> => {
|
||||
const response = await apiClient.get<PartnerCampaignDetailedStats>(
|
||||
`/cabinet/referral/partner/campaigns/${campaignId}/stats`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin endpoints
|
||||
getStats: async (): Promise<PartnerStats> => {
|
||||
const response = await apiClient.get<PartnerStats>('/cabinet/admin/partners/stats');
|
||||
|
||||
197
src/components/partner/CampaignCard.tsx
Normal file
197
src/components/partner/CampaignCard.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PartnerCampaignInfo } from '../../api/partners';
|
||||
import { PARTNER_STATS } from '../../constants/partner';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { useHaptic } from '../../platform';
|
||||
import { copyToClipboard } from '../../utils/clipboard';
|
||||
import { CampaignDetailStats } from './CampaignDetailStats';
|
||||
import { StatCard } from '../stats/StatCard';
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||
<svg
|
||||
className={`h-5 w-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface CampaignCardProps {
|
||||
campaign: PartnerCampaignInfo;
|
||||
}
|
||||
|
||||
export function CampaignCard({ campaign }: CampaignCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency, formatPositive } = useCurrency();
|
||||
const haptic = useHaptic();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (url: string, key: string) => {
|
||||
try {
|
||||
await copyToClipboard(url);
|
||||
haptic.notification('success');
|
||||
} catch {
|
||||
haptic.notification('error');
|
||||
}
|
||||
setCopiedLink(key);
|
||||
clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => setCopiedLink(null), PARTNER_STATS.COPY_FEEDBACK_MS);
|
||||
},
|
||||
[haptic],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
haptic.impact('light');
|
||||
setExpanded((prev) => !prev);
|
||||
}, [haptic]);
|
||||
|
||||
const botKey = `${campaign.id}-bot`;
|
||||
const webKey = `${campaign.id}-web`;
|
||||
|
||||
return (
|
||||
<div className="bento-card space-y-4">
|
||||
{/* Campaign header */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="min-w-0 truncate text-base font-semibold text-dark-100">{campaign.name}</h3>
|
||||
<button
|
||||
onClick={handleToggleExpand}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`campaign-detail-${campaign.id}`}
|
||||
className="flex shrink-0 items-center gap-1 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
<span>
|
||||
{expanded
|
||||
? t('referral.partner.stats.hideDetails')
|
||||
: t('referral.partner.stats.showDetails')}
|
||||
</span>
|
||||
<ChevronIcon expanded={expanded} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Basic stats -- always visible */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.registrations')}
|
||||
value={campaign.registrations_count}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.referrals')}
|
||||
value={campaign.referrals_count}
|
||||
valueClassName="text-accent-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.earnings')}
|
||||
value={formatPositive(campaign.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bonus info */}
|
||||
{campaign.bonus_type !== 'none' && (
|
||||
<div className="rounded-lg bg-success-500/10 p-3">
|
||||
<div className="mb-1 text-xs font-medium text-success-500">
|
||||
{t('referral.partner.campaignBonus.title')}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-success-400">
|
||||
{campaign.bonus_type === 'balance' &&
|
||||
t('referral.partner.campaignBonus.balanceDesc', {
|
||||
amount: formatWithCurrency(
|
||||
campaign.balance_bonus_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
0,
|
||||
),
|
||||
})}
|
||||
{campaign.bonus_type === 'subscription' &&
|
||||
t('referral.partner.campaignBonus.subscriptionDesc', {
|
||||
days: campaign.subscription_duration_days ?? 0,
|
||||
...(campaign.subscription_traffic_gb
|
||||
? { traffic: campaign.subscription_traffic_gb }
|
||||
: {}),
|
||||
})}
|
||||
{campaign.bonus_type === 'tariff' && t('referral.partner.campaignBonus.tariffDesc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bot link */}
|
||||
{campaign.deep_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.bot')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.deep_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleCopy(campaign.deep_link!, botKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2.5 ${
|
||||
copiedLink === botKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === botKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Web link */}
|
||||
{campaign.web_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.web')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.web_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleCopy(campaign.web_link!, webKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2.5 ${
|
||||
copiedLink === webKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === webKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded detail stats */}
|
||||
<div id={`campaign-detail-${campaign.id}`}>
|
||||
{expanded && <CampaignDetailStats campaignId={campaign.id} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/components/partner/CampaignDetailStats.tsx
Normal file
100
src/components/partner/CampaignDetailStats.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { partnerApi } from '../../api/partners';
|
||||
import { PARTNER_STATS } from '../../constants/partner';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { DailyChart } from '../stats/DailyChart';
|
||||
import { PeriodComparison } from '../stats/PeriodComparison';
|
||||
import { StatCard } from '../stats/StatCard';
|
||||
import { TopReferrals } from './TopReferrals';
|
||||
|
||||
interface CampaignDetailStatsProps {
|
||||
campaignId: number;
|
||||
}
|
||||
|
||||
export function CampaignDetailStats({ campaignId }: CampaignDetailStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['partner-campaign-stats', campaignId],
|
||||
queryFn: () => partnerApi.getCampaignStats(campaignId),
|
||||
staleTime: PARTNER_STATS.STATS_STALE_TIME,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Skeleton loader */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{Array.from({ length: PARTNER_STATS.SKELETON_COUNT }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-52 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="pt-2 text-center">
|
||||
<div className="text-sm text-error-400">{t('referral.partner.stats.noData')}</div>
|
||||
<button onClick={() => refetch()} className="btn-secondary mt-2 px-4 py-1 text-xs">
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Period earnings */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.today')}
|
||||
value={formatWithCurrency(data.earnings_today / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.week')}
|
||||
value={formatWithCurrency(data.earnings_week / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.month')}
|
||||
value={formatWithCurrency(data.earnings_month / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conversion rate */}
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-dark-500">
|
||||
{t('referral.partner.stats.conversionRate')}
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-accent-400">{data.conversion_rate}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-500 transition-all"
|
||||
style={{
|
||||
width: `${Math.min(data.conversion_rate, PARTNER_STATS.MAX_CONVERSION_RATE)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily chart */}
|
||||
<DailyChart data={data.daily_stats} chartId={campaignId} />
|
||||
|
||||
{/* Period comparison */}
|
||||
<PeriodComparison data={data.period_comparison} />
|
||||
|
||||
{/* Top referrals */}
|
||||
<TopReferrals referrals={data.top_referrals} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/partner/TopReferrals.tsx
Normal file
70
src/components/partner/TopReferrals.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { CampaignReferralItem } from '../../api/partners';
|
||||
import { PARTNER_STATS } from '../../constants/partner';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
|
||||
interface TopReferralsProps {
|
||||
referrals: CampaignReferralItem[];
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
hasPaid: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function StatusBadge({ hasPaid, isActive }: StatusBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isActive) {
|
||||
return <span className="badge-success">{t('referral.partner.stats.active')}</span>;
|
||||
}
|
||||
if (hasPaid) {
|
||||
return <span className="badge-info">{t('referral.partner.stats.paid')}</span>;
|
||||
}
|
||||
return <span className="badge-neutral">{t('referral.partner.stats.pending')}</span>;
|
||||
}
|
||||
|
||||
export function TopReferrals({ referrals }: TopReferralsProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
if (referrals.length === 0) {
|
||||
return (
|
||||
<div className="bento-card py-6 text-center">
|
||||
<div className="text-sm text-dark-400">{t('referral.partner.stats.noReferrals')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||
{t('referral.partner.stats.topReferrals')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{referrals.map((ref) => (
|
||||
<div
|
||||
key={ref.id}
|
||||
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-medium text-dark-100">
|
||||
{ref.full_name}
|
||||
</span>
|
||||
<StatusBadge hasPaid={ref.has_paid} isActive={ref.is_active} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{new Date(ref.created_at).toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-success-400">
|
||||
{formatWithCurrency(ref.total_earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
src/components/stats/DailyChart.tsx
Normal file
144
src/components/stats/DailyChart.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import type { DailyStatItem } from './types';
|
||||
import { PARTNER_STATS } from '../../constants/partner';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { useChartColors } from '../../hooks/useChartColors';
|
||||
|
||||
interface DailyChartProps {
|
||||
data: DailyStatItem[];
|
||||
chartId: string | number;
|
||||
title?: string;
|
||||
earningsLabel?: string;
|
||||
countLabel?: string;
|
||||
}
|
||||
|
||||
interface ChartDataItem extends DailyStatItem {
|
||||
earnings_display: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function DailyChart({ data, chartId, title, earningsLabel, countLabel }: DailyChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const colors = useChartColors();
|
||||
|
||||
const resolvedTitle = title ?? t('referral.partner.stats.dailyChart');
|
||||
const resolvedEarningsLabel = earningsLabel ?? t('referral.partner.stats.earnings');
|
||||
const resolvedCountLabel = countLabel ?? t('referral.partner.stats.referrals');
|
||||
|
||||
const chartData: ChartDataItem[] = useMemo(
|
||||
() =>
|
||||
data.map((item) => ({
|
||||
...item,
|
||||
earnings_display: item.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
label: new Date(item.date).toLocaleDateString(i18n.language, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
})),
|
||||
[data, i18n.language],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{resolvedTitle}</h4>
|
||||
<ResponsiveContainer width="100%" height={PARTNER_STATS.CHART.HEIGHT}>
|
||||
<AreaChart data={chartData} margin={PARTNER_STATS.CHART.MARGIN}>
|
||||
<defs>
|
||||
<linearGradient id={`earningsGradient-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset={PARTNER_STATS.GRADIENT.START_OFFSET}
|
||||
stopColor={colors.earnings}
|
||||
stopOpacity={PARTNER_STATS.GRADIENT.START_OPACITY}
|
||||
/>
|
||||
<stop
|
||||
offset={PARTNER_STATS.GRADIENT.END_OFFSET}
|
||||
stopColor={colors.earnings}
|
||||
stopOpacity={PARTNER_STATS.GRADIENT.END_OPACITY}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id={`referralsGradient-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset={PARTNER_STATS.GRADIENT.START_OFFSET}
|
||||
stopColor={colors.referrals}
|
||||
stopOpacity={PARTNER_STATS.GRADIENT.START_OPACITY}
|
||||
/>
|
||||
<stop
|
||||
offset={PARTNER_STATS.GRADIENT.END_OFFSET}
|
||||
stopColor={colors.referrals}
|
||||
stopOpacity={PARTNER_STATS.GRADIENT.END_OPACITY}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray={PARTNER_STATS.GRID_DASH} stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: colors.tick, fontSize: PARTNER_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="earnings"
|
||||
orientation="right"
|
||||
tick={{ fill: colors.earnings, fontSize: PARTNER_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={PARTNER_STATS.AXIS.EARNINGS_WIDTH}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="referrals"
|
||||
orientation="left"
|
||||
tick={{ fill: colors.referrals, fontSize: PARTNER_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={PARTNER_STATS.AXIS.REFERRALS_WIDTH}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: PARTNER_STATS.TOOLTIP.BORDER_RADIUS,
|
||||
fontSize: PARTNER_STATS.TOOLTIP.FONT_SIZE,
|
||||
}}
|
||||
labelStyle={{ color: colors.label }}
|
||||
formatter={(value: number | undefined, name: string | undefined) => {
|
||||
const displayValue = value ?? 0;
|
||||
if (name === 'earnings_display') {
|
||||
return [formatWithCurrency(displayValue), resolvedEarningsLabel];
|
||||
}
|
||||
return [displayValue, resolvedCountLabel];
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="earnings"
|
||||
type="monotone"
|
||||
dataKey="earnings_display"
|
||||
stroke={colors.earnings}
|
||||
fill={`url(#earningsGradient-${chartId})`}
|
||||
strokeWidth={PARTNER_STATS.STROKE_WIDTH}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="referrals"
|
||||
type="monotone"
|
||||
dataKey="referrals_count"
|
||||
stroke={colors.referrals}
|
||||
fill={`url(#referralsGradient-${chartId})`}
|
||||
strokeWidth={PARTNER_STATS.STROKE_WIDTH}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/components/stats/PeriodComparison.tsx
Normal file
87
src/components/stats/PeriodComparison.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PeriodComparison as PeriodComparisonType } from './types';
|
||||
import { PARTNER_STATS } from '../../constants/partner';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
|
||||
interface PeriodComparisonProps {
|
||||
data: PeriodComparisonType;
|
||||
title?: string;
|
||||
countLabel?: string;
|
||||
earningsLabel?: 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 {
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
percent: number;
|
||||
}
|
||||
|
||||
function TrendBadge({ trend, percent }: TrendBadgeProps) {
|
||||
const style = TREND_STYLES[trend];
|
||||
return (
|
||||
<span className={`text-sm font-medium ${style.className}`}>
|
||||
{style.arrow} {Math.abs(percent)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeriodComparison({
|
||||
data,
|
||||
title,
|
||||
countLabel,
|
||||
earningsLabel,
|
||||
comparisonLabel,
|
||||
}: PeriodComparisonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const resolvedTitle = title ?? t('referral.partner.stats.periodComparison');
|
||||
const resolvedCountLabel = countLabel ?? t('referral.partner.stats.referralsCount');
|
||||
const resolvedEarningsLabel = earningsLabel ?? t('referral.partner.stats.earningsAmount');
|
||||
const resolvedComparisonLabel = comparisonLabel ?? t('referral.partner.stats.vsLastWeek');
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{resolvedTitle}</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Count comparison */}
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-xs text-dark-500">{resolvedCountLabel}</div>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-base font-semibold text-dark-100 sm:text-lg">
|
||||
{data.current.referrals_count}
|
||||
</span>
|
||||
<TrendBadge
|
||||
trend={data.referrals_change.trend}
|
||||
percent={data.referrals_change.percent}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">{resolvedComparisonLabel}</div>
|
||||
</div>
|
||||
|
||||
{/* Earnings comparison */}
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-xs text-dark-500">{resolvedEarningsLabel}</div>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-base font-semibold text-success-400 sm:text-lg">
|
||||
{formatWithCurrency(data.current.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</span>
|
||||
<TrendBadge trend={data.earnings_change.trend} percent={data.earnings_change.percent} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">{resolvedComparisonLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/stats/StatCard.tsx
Normal file
27
src/components/stats/StatCard.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
const DEFAULT_VALUE_CLASS = 'text-dark-100';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: ReactNode;
|
||||
valueClassName?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
valueClassName = DEFAULT_VALUE_CLASS,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="flex items-center gap-1.5 text-sm text-dark-500">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className={`mt-1 text-base font-semibold sm:text-lg ${valueClassName}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/components/stats/index.ts
Normal file
9
src/components/stats/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { DailyChart } from './DailyChart';
|
||||
export { PeriodComparison } from './PeriodComparison';
|
||||
export { StatCard } from './StatCard';
|
||||
export type {
|
||||
DailyStatItem,
|
||||
PeriodStats,
|
||||
PeriodChange,
|
||||
PeriodComparison as PeriodComparisonType,
|
||||
} from './types';
|
||||
24
src/components/stats/types.ts
Normal file
24
src/components/stats/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface DailyStatItem {
|
||||
date: string;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PeriodStats {
|
||||
days: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PeriodChange {
|
||||
absolute: number;
|
||||
percent: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
export interface PeriodComparison {
|
||||
current: PeriodStats;
|
||||
previous: PeriodStats;
|
||||
referrals_change: PeriodChange;
|
||||
earnings_change: PeriodChange;
|
||||
}
|
||||
39
src/constants/partner.ts
Normal file
39
src/constants/partner.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const PARTNER_STATS = {
|
||||
/** Chart visual configuration */
|
||||
CHART: {
|
||||
HEIGHT: 200,
|
||||
MARGIN: { top: 5, right: 10, left: 0, bottom: 5 },
|
||||
},
|
||||
/** Kopeks-to-currency divisor */
|
||||
KOPEKS_DIVISOR: 100,
|
||||
/** 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,
|
||||
},
|
||||
/** Axis tick styling */
|
||||
AXIS: {
|
||||
TICK_FONT_SIZE: 11,
|
||||
EARNINGS_WIDTH: 45,
|
||||
REFERRALS_WIDTH: 30,
|
||||
},
|
||||
/** Tooltip styling */
|
||||
TOOLTIP: {
|
||||
BORDER_RADIUS: '8px',
|
||||
FONT_SIZE: '12px',
|
||||
},
|
||||
/** Area stroke width */
|
||||
STROKE_WIDTH: 2,
|
||||
/** Grid dash array */
|
||||
GRID_DASH: '3 3',
|
||||
/** 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,
|
||||
} as const;
|
||||
63
src/hooks/useChartColors.ts
Normal file
63
src/hooks/useChartColors.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { rgbToHex } from '../utils/colorConversion';
|
||||
|
||||
/** CSS variable names for chart theme colors */
|
||||
const CSS_VARS = {
|
||||
earnings: '--color-success-400',
|
||||
referrals: '--color-accent-400',
|
||||
grid: '--color-dark-700',
|
||||
tooltipBg: '--color-dark-800',
|
||||
tooltipBorder: '--color-dark-600',
|
||||
tick: '--color-dark-400',
|
||||
label: '--color-dark-300',
|
||||
} as const;
|
||||
|
||||
/** Fallback hex colors (default dark theme) */
|
||||
const FALLBACK: Record<keyof typeof CSS_VARS, string> = {
|
||||
earnings: '#34d399',
|
||||
referrals: '#818cf8',
|
||||
grid: '#374151',
|
||||
tooltipBg: '#1f2937',
|
||||
tooltipBorder: '#4b5563',
|
||||
tick: '#9ca3af',
|
||||
label: '#d1d5db',
|
||||
};
|
||||
|
||||
function cssVarToHex(varName: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback;
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
if (!raw) return fallback;
|
||||
|
||||
const parts = raw.split(',').map((s) => parseInt(s.trim(), 10));
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return fallback;
|
||||
|
||||
return rgbToHex(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
export interface ChartColors {
|
||||
earnings: string;
|
||||
referrals: string;
|
||||
grid: string;
|
||||
tooltipBg: string;
|
||||
tooltipBorder: string;
|
||||
tick: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads theme-aware colors from CSS custom properties for use in Recharts.
|
||||
* 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.
|
||||
*/
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -750,6 +750,28 @@
|
||||
"tariff": "Bonus tariff",
|
||||
"tariffDesc": "Free tariff plan"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "Registrations",
|
||||
"referrals": "Referrals",
|
||||
"earnings": "Earnings",
|
||||
"conversionRate": "Conversion",
|
||||
"today": "Today",
|
||||
"week": "Week",
|
||||
"month": "Month",
|
||||
"showDetails": "Details",
|
||||
"hideDetails": "Collapse",
|
||||
"vsLastWeek": "vs last week",
|
||||
"topReferrals": "Top Referrals",
|
||||
"noData": "No data",
|
||||
"dailyChart": "30-day trends",
|
||||
"periodComparison": "Period comparison",
|
||||
"noReferrals": "No referrals yet",
|
||||
"paid": "Paid",
|
||||
"pending": "Pending",
|
||||
"active": "Active",
|
||||
"referralsCount": "Referrals",
|
||||
"earningsAmount": "Earned"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "Company Name",
|
||||
"companyNamePlaceholder": "Your company or brand name",
|
||||
@@ -1991,7 +2013,17 @@
|
||||
"users": "Users",
|
||||
"noUsers": "No registered users",
|
||||
"paid": "Paid",
|
||||
"hasSub": "Has sub"
|
||||
"hasSub": "Has sub",
|
||||
"dailyChart": "30-day trends",
|
||||
"chartRegistrations": "Registrations",
|
||||
"chartRevenue": "Revenue",
|
||||
"totalDeposits": "Total Deposits",
|
||||
"totalSpending": "Subscription Spending",
|
||||
"periodComparison": "Period comparison",
|
||||
"vsLastWeek": "vs last week",
|
||||
"topRegistrations": "Top registrations",
|
||||
"noRegistrations": "No registrations yet",
|
||||
"chartLoading": "Loading chart data..."
|
||||
},
|
||||
"users": {
|
||||
"title": "Campaign Users",
|
||||
|
||||
@@ -584,6 +584,28 @@
|
||||
"tariff": "تعرفه جایزه",
|
||||
"tariffDesc": "طرح تعرفه رایگان"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "ثبتنام",
|
||||
"referrals": "معرفیشدهها",
|
||||
"earnings": "درآمد",
|
||||
"conversionRate": "نرخ تبدیل",
|
||||
"today": "امروز",
|
||||
"week": "هفته",
|
||||
"month": "ماه",
|
||||
"showDetails": "جزئیات",
|
||||
"hideDetails": "بستن",
|
||||
"vsLastWeek": "مقایسه با هفته قبل",
|
||||
"topReferrals": "برترین معرفیشدهها",
|
||||
"noData": "دادهای نیست",
|
||||
"dailyChart": "روند ۳۰ روزه",
|
||||
"periodComparison": "مقایسه دورهها",
|
||||
"noReferrals": "هنوز معرفیشدهای نیست",
|
||||
"paid": "پرداخت شده",
|
||||
"pending": "در انتظار",
|
||||
"active": "فعال",
|
||||
"referralsCount": "تعداد معرفی",
|
||||
"earningsAmount": "کسب شده"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "نام شرکت",
|
||||
"companyNamePlaceholder": "نام شرکت یا برند شما",
|
||||
@@ -1670,7 +1692,17 @@
|
||||
"users": "کاربران",
|
||||
"noUsers": "کاربر ثبتنام شدهای وجود ندارد",
|
||||
"paid": "پرداخت کرده",
|
||||
"hasSub": "دارای اشتراک"
|
||||
"hasSub": "دارای اشتراک",
|
||||
"dailyChart": "روند ۳۰ روزه",
|
||||
"chartRegistrations": "ثبتنامها",
|
||||
"chartRevenue": "درآمد",
|
||||
"totalDeposits": "کل واریزیها",
|
||||
"totalSpending": "هزینه اشتراک",
|
||||
"periodComparison": "مقایسه دورهها",
|
||||
"vsLastWeek": "نسبت به هفته قبل",
|
||||
"topRegistrations": "بالاترین ثبتنامها",
|
||||
"noRegistrations": "هنوز ثبتنامی وجود ندارد",
|
||||
"chartLoading": "در حال بارگذاری..."
|
||||
},
|
||||
"users": {
|
||||
"title": "کاربران کمپین",
|
||||
|
||||
@@ -778,6 +778,28 @@
|
||||
"tariff": "Бонусный тариф",
|
||||
"tariffDesc": "Бесплатный тарифный план"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "Регистрации",
|
||||
"referrals": "Рефералы",
|
||||
"earnings": "Заработок",
|
||||
"conversionRate": "Конверсия",
|
||||
"today": "Сегодня",
|
||||
"week": "Неделя",
|
||||
"month": "Месяц",
|
||||
"showDetails": "Подробнее",
|
||||
"hideDetails": "Свернуть",
|
||||
"vsLastWeek": "vs прошлая неделя",
|
||||
"topReferrals": "Топ рефералы",
|
||||
"noData": "Нет данных",
|
||||
"dailyChart": "Динамика за 30 дней",
|
||||
"periodComparison": "Сравнение периодов",
|
||||
"noReferrals": "Пока нет рефералов",
|
||||
"paid": "Оплатил",
|
||||
"pending": "Ожидание",
|
||||
"active": "Активен",
|
||||
"referralsCount": "Рефералов",
|
||||
"earningsAmount": "Заработано"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "Название компании",
|
||||
"companyNamePlaceholder": "Название вашей компании или бренда",
|
||||
@@ -2512,7 +2534,17 @@
|
||||
"users": "Пользователи",
|
||||
"noUsers": "Нет зарегистрированных пользователей",
|
||||
"paid": "Платил",
|
||||
"hasSub": "Подписка"
|
||||
"hasSub": "Подписка",
|
||||
"dailyChart": "Динамика за 30 дней",
|
||||
"chartRegistrations": "Регистрации",
|
||||
"chartRevenue": "Доход",
|
||||
"totalDeposits": "Всего пополнений",
|
||||
"totalSpending": "Расход на подписки",
|
||||
"periodComparison": "Сравнение периодов",
|
||||
"vsLastWeek": "vs прошлая неделя",
|
||||
"topRegistrations": "Топ регистрации",
|
||||
"noRegistrations": "Пока нет регистраций",
|
||||
"chartLoading": "Загрузка данных..."
|
||||
},
|
||||
"users": {
|
||||
"title": "Пользователи кампании",
|
||||
|
||||
@@ -584,6 +584,28 @@
|
||||
"tariff": "奖励套餐",
|
||||
"tariffDesc": "免费套餐方案"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "注册",
|
||||
"referrals": "推荐",
|
||||
"earnings": "收入",
|
||||
"conversionRate": "转化率",
|
||||
"today": "今天",
|
||||
"week": "本周",
|
||||
"month": "本月",
|
||||
"showDetails": "详情",
|
||||
"hideDetails": "收起",
|
||||
"vsLastWeek": "对比上周",
|
||||
"topReferrals": "热门推荐",
|
||||
"noData": "暂无数据",
|
||||
"dailyChart": "30天趋势",
|
||||
"periodComparison": "周期对比",
|
||||
"noReferrals": "暂无推荐",
|
||||
"paid": "已付款",
|
||||
"pending": "待处理",
|
||||
"active": "活跃",
|
||||
"referralsCount": "推荐数",
|
||||
"earningsAmount": "收入"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "公司名称",
|
||||
"companyNamePlaceholder": "您的公司或品牌名称",
|
||||
@@ -1669,7 +1691,17 @@
|
||||
"users": "用户",
|
||||
"noUsers": "没有注册用户",
|
||||
"paid": "已付费",
|
||||
"hasSub": "有订阅"
|
||||
"hasSub": "有订阅",
|
||||
"dailyChart": "30天趋势",
|
||||
"chartRegistrations": "注册",
|
||||
"chartRevenue": "收入",
|
||||
"totalDeposits": "总充值",
|
||||
"totalSpending": "订阅支出",
|
||||
"periodComparison": "期间比较",
|
||||
"vsLastWeek": "与上周对比",
|
||||
"topRegistrations": "热门注册",
|
||||
"noRegistrations": "暂无注册",
|
||||
"chartLoading": "加载图表数据..."
|
||||
},
|
||||
"users": {
|
||||
"title": "活动用户",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
|
||||
import type { AdminCampaignChartData } from '../api/campaigns';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { DailyChart, PeriodComparison, StatCard } from '../components/stats';
|
||||
import { PARTNER_STATS } from '../constants/partner';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
import { useHaptic } from '../platform';
|
||||
|
||||
// Icons
|
||||
const CopyIcon = () => (
|
||||
@@ -74,31 +79,12 @@ const bonusTypeConfig: Record<
|
||||
},
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
|
||||
const formatRubles = (kopeks: number): string => {
|
||||
const rubles = kopeks / 100;
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return `${rubles.toLocaleString(locale)} ₽`;
|
||||
};
|
||||
|
||||
const formatDate = (date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export default function AdminCampaignStats() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const haptic = useHaptic();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const [copiedBot, setCopiedBot] = useState(false);
|
||||
const [copiedWeb, setCopiedWeb] = useState(false);
|
||||
const [showUsers, setShowUsers] = useState(false);
|
||||
@@ -130,31 +116,54 @@ export default function AdminCampaignStats() {
|
||||
enabled: !!id && showUsers,
|
||||
});
|
||||
|
||||
const copyBotLink = async () => {
|
||||
if (stats?.deep_link) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(stats.deep_link);
|
||||
setCopiedBot(true);
|
||||
clearTimeout(copyBotTimer.current);
|
||||
copyBotTimer.current = setTimeout(() => setCopiedBot(false), 2000);
|
||||
} catch {
|
||||
// Clipboard not available
|
||||
}
|
||||
}
|
||||
};
|
||||
// Fetch chart data
|
||||
const { data: chartData, isLoading: chartLoading } = useQuery<AdminCampaignChartData>({
|
||||
queryKey: ['campaign-chart-data', id],
|
||||
queryFn: () => campaignsApi.getChartData(Number(id)),
|
||||
enabled: !!id,
|
||||
staleTime: PARTNER_STATS.STATS_STALE_TIME,
|
||||
});
|
||||
|
||||
const copyWebLink = async () => {
|
||||
if (stats?.web_link) {
|
||||
const handleCopy = useCallback(
|
||||
async (url: string, type: 'bot' | 'web') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(stats.web_link);
|
||||
setCopiedWeb(true);
|
||||
clearTimeout(copyWebTimer.current);
|
||||
copyWebTimer.current = setTimeout(() => setCopiedWeb(false), 2000);
|
||||
await copyToClipboard(url);
|
||||
haptic.notification('success');
|
||||
if (type === 'bot') {
|
||||
setCopiedBot(true);
|
||||
clearTimeout(copyBotTimer.current);
|
||||
copyBotTimer.current = setTimeout(
|
||||
() => setCopiedBot(false),
|
||||
PARTNER_STATS.COPY_FEEDBACK_MS,
|
||||
);
|
||||
} else {
|
||||
setCopiedWeb(true);
|
||||
clearTimeout(copyWebTimer.current);
|
||||
copyWebTimer.current = setTimeout(
|
||||
() => setCopiedWeb(false),
|
||||
PARTNER_STATS.COPY_FEEDBACK_MS,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Clipboard not available
|
||||
haptic.notification('error');
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[haptic],
|
||||
);
|
||||
|
||||
const formatDate = useCallback(
|
||||
(date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString(i18n.language, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
},
|
||||
[i18n.language],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -195,8 +204,8 @@ export default function AdminCampaignStats() {
|
||||
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||
<ChartIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{stats.name}</h1>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold text-dark-100">{stats.name}</h1>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${bonusTypeConfig[stats.bonus_type].bgColor} ${bonusTypeConfig[stats.bonus_type].color}`}
|
||||
@@ -232,8 +241,8 @@ export default function AdminCampaignStats() {
|
||||
<span className="truncate text-sm text-dark-300">{stats.deep_link}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyBotLink}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
onClick={() => handleCopy(stats.deep_link!, 'bot')}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="text-sm">
|
||||
@@ -256,8 +265,8 @@ export default function AdminCampaignStats() {
|
||||
<span className="truncate text-sm text-dark-300">{stats.web_link}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyWebLink}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
onClick={() => handleCopy(stats.web_link!, 'web')}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="text-sm">
|
||||
@@ -275,21 +284,25 @@ export default function AdminCampaignStats() {
|
||||
{/* Main Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-dark-100">{stats.registrations}</div>
|
||||
<div className="text-xl font-bold text-dark-100 sm:text-2xl">{stats.registrations}</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.registrations')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{formatRubles(stats.total_revenue_kopeks)}
|
||||
<div className="truncate text-xl font-bold text-success-400 sm:text-2xl">
|
||||
{formatWithCurrency(stats.total_revenue_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.revenue')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.paid_users_count}</div>
|
||||
<div className="text-xl font-bold text-accent-400 sm:text-2xl">
|
||||
{stats.paid_users_count}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.paidUsers')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.conversion_rate}%</div>
|
||||
<div className="text-xl font-bold text-accent-400 sm:text-2xl">
|
||||
{stats.conversion_rate}%
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.conversion')}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,7 +319,7 @@ export default function AdminCampaignStats() {
|
||||
</div>
|
||||
{stats.bonus_type === 'balance' && (
|
||||
<div className="text-lg font-medium text-success-400">
|
||||
{formatRubles(stats.balance_issued_kopeks)}
|
||||
{formatWithCurrency(stats.balance_issued_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
)}
|
||||
{stats.bonus_type === 'subscription' && (
|
||||
@@ -330,7 +343,9 @@ export default function AdminCampaignStats() {
|
||||
{t('admin.campaigns.stats.avgRevenuePerUser')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatRubles(stats.avg_revenue_per_user_kopeks)}
|
||||
{formatWithCurrency(
|
||||
stats.avg_revenue_per_user_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
@@ -338,7 +353,7 @@ export default function AdminCampaignStats() {
|
||||
{t('admin.campaigns.stats.avgFirstPayment')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatRubles(stats.avg_first_payment_kopeks)}
|
||||
{formatWithCurrency(stats.avg_first_payment_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
@@ -371,6 +386,94 @@ export default function AdminCampaignStats() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Charts */}
|
||||
<div className="space-y-4">
|
||||
{chartLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-52 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="h-24 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
<div className="h-24 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
</div>
|
||||
</div>
|
||||
) : chartData ? (
|
||||
<>
|
||||
{/* Deposits vs Spending */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
label={t('admin.campaigns.stats.totalDeposits')}
|
||||
value={formatWithCurrency(
|
||||
chartData.total_deposits_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.campaigns.stats.totalSpending')}
|
||||
value={formatWithCurrency(
|
||||
chartData.total_spending_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
valueClassName="text-accent-400"
|
||||
/>
|
||||
</div>
|
||||
<DailyChart
|
||||
data={chartData.daily_stats}
|
||||
chartId={`admin-${id}`}
|
||||
title={t('admin.campaigns.stats.dailyChart')}
|
||||
earningsLabel={t('admin.campaigns.stats.chartRevenue')}
|
||||
countLabel={t('admin.campaigns.stats.chartRegistrations')}
|
||||
/>
|
||||
<PeriodComparison
|
||||
data={chartData.period_comparison}
|
||||
title={t('admin.campaigns.stats.periodComparison')}
|
||||
countLabel={t('admin.campaigns.stats.chartRegistrations')}
|
||||
earningsLabel={t('admin.campaigns.stats.chartRevenue')}
|
||||
comparisonLabel={t('admin.campaigns.stats.vsLastWeek')}
|
||||
/>
|
||||
{/* Top Registrations */}
|
||||
{chartData.top_registrations.length > 0 && (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||
{t('admin.campaigns.stats.topRegistrations')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{chartData.top_registrations.map((reg) => (
|
||||
<Link
|
||||
key={reg.id}
|
||||
to={`/admin/users/${reg.id}`}
|
||||
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-3 transition-colors hover:bg-dark-700/50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-medium text-dark-100">
|
||||
{reg.full_name}
|
||||
</span>
|
||||
{reg.is_active && (
|
||||
<span className="badge-success">
|
||||
{t('admin.campaigns.stats.active')}
|
||||
</span>
|
||||
)}
|
||||
{reg.has_paid && !reg.is_active && (
|
||||
<span className="badge-info">{t('admin.campaigns.stats.paid')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{new Date(reg.created_at).toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-success-400">
|
||||
{formatWithCurrency(
|
||||
reg.total_earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Users Section */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800">
|
||||
<button
|
||||
|
||||
@@ -6,6 +6,7 @@ import { referralApi } from '../api/referral';
|
||||
import { brandingApi } from '../api/branding';
|
||||
import { partnerApi } from '../api/partners';
|
||||
import { withdrawalApi } from '../api/withdrawals';
|
||||
import { CampaignCard } from '../components/partner/CampaignCard';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
const LinkIcon = () => (
|
||||
@@ -93,7 +94,6 @@ export default function Referral() {
|
||||
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
|
||||
const queryClient = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
|
||||
const { data: info, isLoading } = useQuery({
|
||||
queryKey: ['referral-info'],
|
||||
@@ -535,99 +535,9 @@ export default function Referral() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{partnerStatus.campaigns.map((campaign) => {
|
||||
const copyLink = (url: string, key: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopiedLink(key);
|
||||
setTimeout(() => setCopiedLink(null), 2000);
|
||||
};
|
||||
|
||||
const botKey = `${campaign.id}-bot`;
|
||||
const webKey = `${campaign.id}-web`;
|
||||
|
||||
return (
|
||||
<div key={campaign.id} className="bento-card space-y-4">
|
||||
{/* Campaign header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-dark-100">{campaign.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* Bonus info */}
|
||||
{campaign.bonus_type !== 'none' && (
|
||||
<div className="rounded-lg bg-success-500/10 p-3">
|
||||
<div className="mb-1 text-xs font-medium text-success-500">
|
||||
{t('referral.partner.campaignBonus.title')}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-success-400">
|
||||
{campaign.bonus_type === 'balance' &&
|
||||
t('referral.partner.campaignBonus.balanceDesc', {
|
||||
amount: formatWithCurrency(campaign.balance_bonus_kopeks / 100, 0),
|
||||
})}
|
||||
{campaign.bonus_type === 'subscription' &&
|
||||
t('referral.partner.campaignBonus.subscriptionDesc', {
|
||||
days: campaign.subscription_duration_days ?? 0,
|
||||
...(campaign.subscription_traffic_gb
|
||||
? { traffic: campaign.subscription_traffic_gb }
|
||||
: {}),
|
||||
})}
|
||||
{campaign.bonus_type === 'tariff' &&
|
||||
t('referral.partner.campaignBonus.tariffDesc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bot link */}
|
||||
{campaign.deep_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.bot')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.deep_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyLink(campaign.deep_link!, botKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2 ${
|
||||
copiedLink === botKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === botKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Web link */}
|
||||
{campaign.web_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.web')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.web_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyLink(campaign.web_link!, webKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2 ${
|
||||
copiedLink === webKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === webKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{partnerStatus.campaigns.map((campaign) => (
|
||||
<CampaignCard key={campaign.id} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
24
src/utils/clipboard.ts
Normal file
24
src/utils/clipboard.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Cross-platform clipboard utility with fallback for Telegram WebView.
|
||||
* navigator.clipboard.writeText() is unavailable in some WebViews
|
||||
* (Android System WebView, unfocused tabs, insecure contexts).
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
try {
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
if (!ok) throw new Error('execCommand copy failed');
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user