diff --git a/src/pages/AdminCampaigns.tsx b/src/pages/AdminCampaigns.tsx new file mode 100644 index 0000000..d3ab946 --- /dev/null +++ b/src/pages/AdminCampaigns.tsx @@ -0,0 +1,928 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + campaignsApi, + CampaignListItem, + CampaignDetail, + CampaignStatistics, + CampaignCreateRequest, + CampaignUpdateRequest, + CampaignBonusType, + ServerSquadInfo, + TariffListItem, + CampaignRegistrationItem, +} from '../api/campaigns' + +// Icons +const CampaignIcon = () => ( + + + +) + +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 && ( +
+
+

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

+

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

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 6ce47cc..f623d42 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -63,6 +63,12 @@ const PromocodeIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( ) +const CampaignIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + + + +) + const ChevronRightIcon = () => ( @@ -212,6 +218,16 @@ export default function AdminPanel() { bgColor: 'bg-violet-500/20', textColor: 'text-violet-400' }, + { + to: '/admin/campaigns', + icon: , + mobileIcon: , + title: t('admin.nav.campaigns', 'Кампании'), + description: t('admin.panel.campaignsDesc', 'Рекламные кампании'), + color: 'orange', + bgColor: 'bg-orange-500/20', + textColor: 'text-orange-400' + }, ] return (