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

@@ -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;
},
};

View File

@@ -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');

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

39
src/constants/partner.ts Normal file
View 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;

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

View File

@@ -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",

View File

@@ -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": "کاربران کمپین",

View File

@@ -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": "Пользователи кампании",

View File

@@ -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": "活动用户",

View File

@@ -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

View File

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