import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' import { campaignsApi, CampaignListItem, CampaignDetail, CampaignStatistics, CampaignCreateRequest, CampaignUpdateRequest, CampaignBonusType, ServerSquadInfo, TariffListItem, CampaignRegistrationItem, } from '../api/campaigns' // Icons const BackIcon = () => ( ) const PlusIcon = () => ( ) const EditIcon = () => ( ) const TrashIcon = () => ( ) const CheckIcon = () => ( ) const XIcon = () => ( ) const ChartIcon = () => ( ) const UsersIcon = () => ( ) const LinkIcon = () => ( ) const CopyIcon = () => ( ) // Bonus type labels and colors const bonusTypeConfig: Record = { balance: { label: 'Баланс', color: 'text-emerald-400', bgColor: 'bg-emerald-500/20' }, subscription: { label: 'Подписка', color: 'text-blue-400', bgColor: 'bg-blue-500/20' }, tariff: { label: 'Тариф', color: 'text-purple-400', bgColor: 'bg-purple-500/20' }, none: { label: 'Только ссылка', color: 'text-gray-400', bgColor: 'bg-gray-500/20' }, } // Format number as rubles const formatRubles = (kopeks: number) => (kopeks / 100).toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 }) + ' ₽' // Format date const formatDate = (dateStr: string | null) => { if (!dateStr) return '-' return new Date(dateStr).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) } // Campaign Modal interface CampaignModalProps { campaign?: CampaignDetail | null servers: ServerSquadInfo[] tariffs: TariffListItem[] onSave: (data: CampaignCreateRequest | CampaignUpdateRequest) => void onClose: () => void isLoading?: boolean } function CampaignModal({ campaign, servers, tariffs, onSave, onClose, isLoading }: CampaignModalProps) { const isEdit = !!campaign const [name, setName] = useState(campaign?.name || '') const [startParameter, setStartParameter] = useState(campaign?.start_parameter || '') const [bonusType, setBonusType] = useState(campaign?.bonus_type || 'balance') const [isActive, setIsActive] = useState(campaign?.is_active ?? true) // Balance bonus const [balanceBonusRubles, setBalanceBonusRubles] = useState((campaign?.balance_bonus_kopeks || 0) / 100) // Subscription bonus const [subscriptionDays, setSubscriptionDays] = useState(campaign?.subscription_duration_days || 7) const [subscriptionTraffic, setSubscriptionTraffic] = useState(campaign?.subscription_traffic_gb || 10) const [subscriptionDevices, setSubscriptionDevices] = useState(campaign?.subscription_device_limit || 1) const [selectedSquads, setSelectedSquads] = useState(campaign?.subscription_squads || []) // Tariff bonus const [tariffId, setTariffId] = useState(campaign?.tariff_id || null) const [tariffDays, setTariffDays] = useState(campaign?.tariff_duration_days || 30) const handleSubmit = () => { const data: CampaignCreateRequest | CampaignUpdateRequest = { name, start_parameter: startParameter, bonus_type: bonusType, is_active: isActive, } if (bonusType === 'balance') { data.balance_bonus_kopeks = Math.round(balanceBonusRubles * 100) } else if (bonusType === 'subscription') { data.subscription_duration_days = subscriptionDays data.subscription_traffic_gb = subscriptionTraffic data.subscription_device_limit = subscriptionDevices data.subscription_squads = selectedSquads } else if (bonusType === 'tariff') { data.tariff_id = tariffId || undefined data.tariff_duration_days = tariffDays } onSave(data) } const toggleServer = (uuid: string) => { setSelectedSquads(prev => prev.includes(uuid) ? prev.filter(s => s !== uuid) : [...prev, uuid] ) } const isValid = name.trim() && startParameter.trim() && /^[a-zA-Z0-9_-]+$/.test(startParameter) return (
{/* Header */}

{isEdit ? 'Редактирование кампании' : 'Новая кампания'}

{/* Content */}
{/* Name */}
setName(e.target.value)} className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" placeholder="Например: Instagram Реклама" />
{/* Start Parameter */}
setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))} className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" placeholder="instagram_jan2024" />

Только латиница, цифры, _ и -

{/* Bonus Type */}
{(Object.keys(bonusTypeConfig) as CampaignBonusType[]).map(type => ( ))}
{/* Bonus Settings */} {bonusType === 'balance' && (
setBalanceBonusRubles(Math.max(0, parseFloat(e.target.value) || 0))} className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-emerald-500" min={0} step={1} />
)} {bonusType === 'subscription' && (
setSubscriptionDays(Math.max(1, parseInt(e.target.value) || 1))} className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-blue-500" min={1} />
setSubscriptionTraffic(Math.max(0, parseInt(e.target.value) || 0))} className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-blue-500" min={0} />
setSubscriptionDevices(Math.max(1, parseInt(e.target.value) || 1))} className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-blue-500" min={1} />
{servers.length > 0 && (
{servers.map(server => ( ))}
)}
)} {bonusType === 'tariff' && (
setTariffDays(Math.max(1, parseInt(e.target.value) || 1))} className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-purple-500" min={1} />
)} {bonusType === 'none' && (

Кампания без бонусов - только для отслеживания переходов и регистраций.

)} {/* Active toggle */}
Активна
{/* Footer */}
) } // Statistics Modal interface StatsModalProps { stats: CampaignStatistics onClose: () => void onViewUsers: () => void } function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) { const [copied, setCopied] = useState(false) const copyLink = () => { if (stats.deep_link) { navigator.clipboard.writeText(stats.deep_link) setCopied(true) setTimeout(() => setCopied(false), 2000) } } return (
{/* Header */}

{stats.name}

{bonusTypeConfig[stats.bonus_type].label} {stats.is_active ? ( Активна ) : ( Неактивна )}
{/* Content */}
{/* Deep Link */} {stats.deep_link && (
{stats.deep_link}
)} {/* Main Stats */}
{stats.registrations}
Регистраций
{formatRubles(stats.total_revenue_kopeks)}
Доход
{stats.paid_users_count}
Оплатили
{stats.conversion_rate}%
Конверсия
{/* Detailed Stats */}
Бонусы выданы
{stats.bonus_type === 'balance' && (
{formatRubles(stats.balance_issued_kopeks)}
)} {stats.bonus_type === 'subscription' && (
{stats.subscription_issued} подписок
)} {stats.bonus_type === 'tariff' && (
{stats.subscription_issued} тарифов
)} {stats.bonus_type === 'none' && (
-
)}
Средний доход с пользователя
{formatRubles(stats.avg_revenue_per_user_kopeks)}
Средний первый платёж
{formatRubles(stats.avg_first_payment_kopeks)}
Пробных подписок
{stats.trial_users_count} (активных: {stats.active_trials_count})
Конверсия из триала
{stats.trial_conversion_rate}%
Последняя регистрация
{formatDate(stats.last_registration)}
{/* Footer */}
) } // Users Modal interface UsersModalProps { campaignId: number campaignName: string onClose: () => void } function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) { const [page, setPage] = useState(1) const { data, isLoading } = useQuery({ queryKey: ['campaign-registrations', campaignId, page], queryFn: () => campaignsApi.getCampaignRegistrations(campaignId, page, 20), }) const registrations = data?.registrations || [] const total = data?.total || 0 const totalPages = Math.ceil(total / 20) return (
{/* Header */}

Пользователи кампании

{campaignName} - {total} регистраций

{/* Content */}
{isLoading ? (
) : registrations.length === 0 ? (
Нет зарегистрированных пользователей
) : ( {registrations.map((reg: CampaignRegistrationItem) => ( ))}
Пользователь Бонус Баланс Статус Дата
{reg.first_name || 'Без имени'} {reg.username && @{reg.username}}
{reg.telegram_id}
{bonusTypeConfig[reg.bonus_type as CampaignBonusType]?.label || reg.bonus_type} {formatRubles(reg.user_balance_kopeks)}
{reg.has_subscription && ( VPN )} {reg.has_paid && ( Платил )}
{formatDate(reg.created_at)}
)}
{/* Pagination */} {totalPages > 1 && (
{page} из {totalPages}
)}
) } // Main Component export default function AdminCampaigns() { const queryClient = useQueryClient() const [showModal, setShowModal] = useState(false) const [editingCampaign, setEditingCampaign] = useState(null) const [showStats, setShowStats] = useState(null) const [showUsers, setShowUsers] = useState<{ id: number; name: string } | null>(null) const [deleteConfirm, setDeleteConfirm] = useState(null) // Queries const { data: campaignsData, isLoading } = useQuery({ queryKey: ['admin-campaigns'], queryFn: () => campaignsApi.getCampaigns(true), }) const { data: overview } = useQuery({ queryKey: ['admin-campaigns-overview'], queryFn: () => campaignsApi.getOverview(), }) const { data: servers = [] } = useQuery({ queryKey: ['admin-campaigns-servers'], queryFn: () => campaignsApi.getAvailableServers(), }) const { data: tariffs = [] } = useQuery({ queryKey: ['admin-campaigns-tariffs'], queryFn: () => campaignsApi.getAvailableTariffs(), }) // Mutations const createMutation = useMutation({ mutationFn: campaignsApi.createCampaign, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] }) queryClient.invalidateQueries({ queryKey: ['admin-campaigns-overview'] }) setShowModal(false) }, }) const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: CampaignUpdateRequest }) => campaignsApi.updateCampaign(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] }) setShowModal(false) setEditingCampaign(null) }, }) const deleteMutation = useMutation({ mutationFn: campaignsApi.deleteCampaign, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] }) queryClient.invalidateQueries({ queryKey: ['admin-campaigns-overview'] }) setDeleteConfirm(null) }, }) const toggleMutation = useMutation({ mutationFn: campaignsApi.toggleCampaign, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] }) }, }) const handleEdit = async (campaignId: number) => { try { const detail = await campaignsApi.getCampaign(campaignId) setEditingCampaign(detail) setShowModal(true) } catch (error) { console.error('Failed to load campaign:', error) } } const handleViewStats = async (campaignId: number) => { try { const stats = await campaignsApi.getCampaignStats(campaignId) setShowStats(stats) } catch (error) { console.error('Failed to load stats:', error) } } const handleSave = (data: CampaignCreateRequest | CampaignUpdateRequest) => { if (editingCampaign) { updateMutation.mutate({ id: editingCampaign.id, data }) } else { createMutation.mutate(data as CampaignCreateRequest) } } const handleCloseModal = () => { setShowModal(false) setEditingCampaign(null) } const campaigns = campaignsData?.campaigns || [] return (
{/* Header */}

Рекламные кампании

Управление рекламными ссылками

{/* Overview */} {overview && (
{overview.total}
Всего кампаний
{overview.active}
Активных
{overview.total_registrations}
Регистраций
{formatRubles(overview.total_balance_issued_kopeks)}
Выдано бонусов
)} {/* Campaigns List */} {isLoading ? (
) : campaigns.length === 0 ? (

Нет рекламных кампаний

) : (
{campaigns.map((campaign: CampaignListItem) => (

{campaign.name}

{bonusTypeConfig[campaign.bonus_type].label} {!campaign.is_active && ( Неактивна )}
?start={campaign.start_parameter} {campaign.registrations_count} регистраций {formatRubles(campaign.total_revenue_kopeks)} доход {campaign.conversion_rate}% конверсия
{/* Stats */} {/* Toggle Active */} {/* Edit */} {/* Delete */}
))}
)} {/* Create/Edit Modal */} {showModal && ( )} {/* Stats Modal */} {showStats && ( setShowStats(null)} onViewUsers={() => { setShowUsers({ id: showStats.id, name: showStats.name }) setShowStats(null) }} /> )} {/* Users Modal */} {showUsers && ( setShowUsers(null)} /> )} {/* Delete Confirmation */} {deleteConfirm !== null && (

Удалить кампанию?

Это действие нельзя отменить. Кампании с регистрациями удалить нельзя.

)}
) }