mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user