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:
Fringg
2026-03-02 06:10:20 +03:00
parent 60f16e64e8
commit c7d05c4809
21 changed files with 1577 additions and 158 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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;
}