import { useState, useRef, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminBroadcastsApi, BroadcastFilter, TariffFilter, CombinedBroadcastCreateRequest, CustomBroadcastButton, } from '../api/adminBroadcasts'; import { AdminBackButton } from '../components/admin'; import { TelegramPreview, EmailPreview } from '../components/broadcasts/BroadcastPreview'; import { BroadcastIcon, ChevronDownIcon, DocumentIcon, EmailIcon, PhotoIcon, RefreshIcon, TelegramIcon, UsersIcon, VideoIcon, XIcon, } from '@/components/icons'; // Filter labels const FILTER_GROUP_LABEL_KEYS: Record = { basic: 'admin.broadcasts.filterGroups.basic', subscription: 'admin.broadcasts.filterGroups.subscription', traffic: 'admin.broadcasts.filterGroups.traffic', registration: 'admin.broadcasts.filterGroups.registration', activity: 'admin.broadcasts.filterGroups.activity', source: 'admin.broadcasts.filterGroups.source', tariff: 'admin.broadcasts.filterGroups.tariff', email: 'admin.broadcasts.filterGroups.email', }; export default function AdminBroadcastCreate() { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const fileInputRef = useRef(null); // Channel toggles (both can be enabled) const [telegramEnabled, setTelegramEnabled] = useState(true); const [emailEnabled, setEmailEnabled] = useState(false); // Separate targets per channel const [telegramTarget, setTelegramTarget] = useState(''); const [emailTarget, setEmailTarget] = useState(''); const [showTelegramFilters, setShowTelegramFilters] = useState(false); const [showEmailFilters, setShowEmailFilters] = useState(false); // Broadcast category (system/news/promo) const [category, setCategory] = useState<'system' | 'news' | 'promo'>('system'); // Telegram-specific state const [messageText, setMessageText] = useState(''); const [selectedButtons, setSelectedButtons] = useState(['home']); const [customButtons, setCustomButtons] = useState([]); const [isAddingCustomButton, setIsAddingCustomButton] = useState(false); const [newButtonLabel, setNewButtonLabel] = useState(''); const [newButtonActionType, setNewButtonActionType] = useState<'callback' | 'url'>('callback'); const [newButtonActionValue, setNewButtonActionValue] = useState(''); const [mediaFile, setMediaFile] = useState(null); const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo'); const [mediaPreview, setMediaPreview] = useState(null); const [uploadedFileId, setUploadedFileId] = useState(null); const [isUploading, setIsUploading] = useState(false); const mediaPreviewRef = useRef(null); // Revoke blob URLs on unmount to prevent memory leaks useEffect(() => { return () => { if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); }; }, []); // Email-specific state const [emailSubject, setEmailSubject] = useState(''); const [emailContent, setEmailContent] = useState(''); // Submitting state for dual send const [isSubmitting, setIsSubmitting] = useState(false); // Preview modals const [showTelegramPreview, setShowTelegramPreview] = useState(false); const [showEmailPreview, setShowEmailPreview] = useState(false); const previewMediaTypeForModal: 'photo' | 'video' | null = mediaType === 'photo' || mediaType === 'video' ? mediaType : null; const previewButtonRows = useMemo(() => { const rows: { text: string; url?: string; callback_data?: string }[][] = []; if (selectedButtons.length > 0) { const presetLabels: Record = { balance: t('admin.broadcasts.btnBalance', 'Пополнить баланс'), // Бот отдаёт ключ кнопки как 'referrals' (см. BROADCAST_BUTTONS в admin.py), // раньше тут был 'partners' — из-за рассинхрона кнопка показывалась сырым // ключом 'referrals' вместо «Партнёрка» (Telegram-баг #602989). referrals: t('admin.broadcasts.btnPartners', 'Партнёрка'), promocode: t('admin.broadcasts.btnPromocode', 'Промокод'), connect: t('admin.broadcasts.btnConnect', 'Подключиться'), subscription: t('admin.broadcasts.btnSubscription', 'Подписка'), support: t('admin.broadcasts.btnSupport', 'Техподдержка'), home: t('admin.broadcasts.btnHome', 'На главную'), }; for (const id of selectedButtons) { rows.push([{ text: presetLabels[id] || id, callback_data: id }]); } } for (const cb of customButtons) { rows.push([ { text: cb.label, ...(cb.action_type === 'url' ? { url: cb.action_value } : { callback_data: cb.action_value }), }, ]); } return rows; }, [selectedButtons, customButtons, t]); // Fetch Telegram filters const { data: filtersData, isLoading: filtersLoading } = useQuery({ queryKey: ['admin', 'broadcasts', 'filters'], queryFn: adminBroadcastsApi.getFilters, enabled: telegramEnabled, }); // Fetch Email filters const { data: emailFiltersData, isLoading: emailFiltersLoading } = useQuery({ queryKey: ['admin', 'broadcasts', 'email-filters'], queryFn: adminBroadcastsApi.getEmailFilters, enabled: emailEnabled, }); // Fetch buttons const { data: buttonsData } = useQuery({ queryKey: ['admin', 'broadcasts', 'buttons'], queryFn: adminBroadcastsApi.getButtons, enabled: telegramEnabled, }); // Preview mutations — separate for each channel const telegramPreviewMutation = useMutation({ mutationFn: adminBroadcastsApi.preview, }); const emailPreviewMutation = useMutation({ mutationFn: adminBroadcastsApi.previewEmail, }); // Create mutation (used for single-channel sends) const createMutation = useMutation({ mutationFn: adminBroadcastsApi.createCombined, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }); navigate(`/admin/broadcasts/${data.id}`); }, }); // Group Telegram filters const groupedTelegramFilters = useMemo(() => { if (!filtersData) return {}; const groups: Record = {}; filtersData.filters.forEach((f) => { const group = f.group || 'basic'; if (!groups[group]) groups[group] = []; groups[group].push(f); }); if (filtersData.tariff_filters.length > 0) { groups['tariff'] = filtersData.tariff_filters; } filtersData.custom_filters.forEach((f) => { const group = f.group || 'custom'; if (!groups[group]) groups[group] = []; groups[group].push(f); }); return groups; }, [filtersData]); // Group Email filters const groupedEmailFilters = useMemo(() => { if (!emailFiltersData) return {}; const groups: Record = {}; emailFiltersData.filters.forEach((f) => { const group = f.group || 'email'; if (!groups[group]) groups[group] = []; groups[group].push(f); }); return groups; }, [emailFiltersData]); // Selected filter info for each channel const selectedTelegramFilter = useMemo(() => { if (!telegramTarget || !filtersData) return null; const all = [ ...filtersData.filters, ...filtersData.tariff_filters, ...filtersData.custom_filters, ]; return all.find((f) => f.key === telegramTarget) ?? null; }, [telegramTarget, filtersData]); const selectedEmailFilter = useMemo(() => { if (!emailTarget || !emailFiltersData) return null; return emailFiltersData.filters.find((f) => f.key === emailTarget) ?? null; }, [emailTarget, emailFiltersData]); // Handle toggling channels const handleToggleTelegram = () => { setTelegramEnabled((prev) => !prev); setTelegramTarget(''); telegramPreviewMutation.reset(); }; const handleToggleEmail = () => { setEmailEnabled((prev) => !prev); setEmailTarget(''); emailPreviewMutation.reset(); }; // Handle filter selection per channel const handleTelegramFilterSelect = (filterKey: string) => { setTelegramTarget(filterKey); setShowTelegramFilters(false); telegramPreviewMutation.mutate(filterKey); }; const handleEmailFilterSelect = (filterKey: string) => { setEmailTarget(filterKey); setShowEmailFilters(false); emailPreviewMutation.mutate(filterKey); }; // Handle file selection const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; // Determine type locally to avoid stale state in async call let detectedType: 'photo' | 'video' | 'document'; if (file.type.startsWith('image/')) { detectedType = 'photo'; } else if (file.type.startsWith('video/')) { detectedType = 'video'; } else { detectedType = 'document'; } setMediaFile(file); setMediaType(detectedType); if (detectedType === 'photo') { if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); const url = URL.createObjectURL(file); mediaPreviewRef.current = url; setMediaPreview(url); } else { setMediaPreview(null); } setIsUploading(true); try { const result = await adminBroadcastsApi.uploadMedia(file, detectedType); setUploadedFileId(result.file_id); } catch { setMediaFile(null); setMediaPreview(null); } finally { setIsUploading(false); } }; // Remove media const handleRemoveMedia = () => { if (mediaPreviewRef.current) { URL.revokeObjectURL(mediaPreviewRef.current); mediaPreviewRef.current = null; } setMediaFile(null); setMediaPreview(null); setUploadedFileId(null); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; // Toggle button const toggleButton = (key: string) => { setSelectedButtons((prev) => prev.includes(key) ? prev.filter((b) => b !== key) : [...prev, key], ); }; // Custom button validation const isNewButtonValid = useMemo(() => { if (!newButtonLabel.trim() || !newButtonActionValue.trim()) return false; if (newButtonActionType === 'url') { return /^https:\/\/|^tg:\/\//.test(newButtonActionValue.trim()); } if (newButtonActionType === 'callback') { return new TextEncoder().encode(newButtonActionValue.trim()).length <= 64; } return true; }, [newButtonLabel, newButtonActionType, newButtonActionValue]); // Custom button handlers const addCustomButton = () => { if (!isNewButtonValid) return; setCustomButtons((prev) => [ ...prev, { label: newButtonLabel.trim(), action_type: newButtonActionType, action_value: newButtonActionValue.trim(), }, ]); setNewButtonLabel(''); setNewButtonActionValue(''); setNewButtonActionType('callback'); setIsAddingCustomButton(false); }; const removeCustomButton = (index: number) => { setCustomButtons((prev) => prev.filter((_, i) => i !== index)); }; // Validate form const isTelegramValid = telegramEnabled && telegramTarget && messageText.trim().length > 0; const isEmailValid = emailEnabled && emailTarget && emailSubject.trim().length > 0 && emailContent.trim().length > 0; const isValid = useMemo(() => { if (!telegramEnabled && !emailEnabled) return false; if (telegramEnabled && !isTelegramValid) return false; if (emailEnabled && !isEmailValid) return false; return true; }, [telegramEnabled, emailEnabled, isTelegramValid, isEmailValid]); const bothChannels = telegramEnabled && emailEnabled; // Submit const handleSubmit = async () => { if (!isValid) return; // Single channel — use existing createMutation with navigation to detail if (telegramEnabled && !emailEnabled) { const data: CombinedBroadcastCreateRequest = { channel: 'telegram', target: telegramTarget, message_text: messageText, selected_buttons: selectedButtons, custom_buttons: customButtons.length > 0 ? customButtons : undefined, category, }; if (uploadedFileId) { data.media = { type: mediaType, file_id: uploadedFileId }; } createMutation.mutate(data); return; } if (emailEnabled && !telegramEnabled) { const data: CombinedBroadcastCreateRequest = { channel: 'email', target: emailTarget, email_subject: emailSubject, email_html_content: emailContent, category, }; createMutation.mutate(data); return; } // Both channels — two sequential requests, navigate to list setIsSubmitting(true); try { const telegramData: CombinedBroadcastCreateRequest = { channel: 'telegram', target: telegramTarget, message_text: messageText, selected_buttons: selectedButtons, custom_buttons: customButtons.length > 0 ? customButtons : undefined, category, }; if (uploadedFileId) { telegramData.media = { type: mediaType, file_id: uploadedFileId }; } const emailData: CombinedBroadcastCreateRequest = { channel: 'email', target: emailTarget, email_subject: emailSubject, email_html_content: emailContent, category, }; await adminBroadcastsApi.createCombined(telegramData); await adminBroadcastsApi.createCombined(emailData); queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }); navigate('/admin/broadcasts'); } finally { setIsSubmitting(false); } }; // Recipients counts per channel const telegramRecipientsCount = telegramEnabled ? (telegramPreviewMutation.data?.count ?? selectedTelegramFilter?.count ?? null) : null; const emailRecipientsCount = emailEnabled ? (emailPreviewMutation.data?.count ?? selectedEmailFilter?.count ?? null) : null; const isPending = createMutation.isPending || isSubmitting; // Render filter dropdown const renderFilterDropdown = ( channelType: 'telegram' | 'email', target: string, selectedFilter: BroadcastFilter | TariffFilter | null, recipientsCount: number | null, showFilters: boolean, setShowFilters: (v: boolean) => void, handleFilterSelect: (key: string) => void, groupedFilters: Record, isLoading: boolean, ) => (
{showFilters && (
{isLoading ? (
{t('common.loading')}
) : ( Object.entries(groupedFilters).map(([group, filters]) => (
{FILTER_GROUP_LABEL_KEYS[group] ? t(FILTER_GROUP_LABEL_KEYS[group]) : group}
{filters.map((filter) => ( ))}
)) )}
)}
); return (
{/* Header */}

{t('admin.broadcasts.create')}

{t('admin.broadcasts.subtitle')}

{/* Channel toggles */}
{!telegramEnabled && !emailEnabled && (

{t('admin.broadcasts.atLeastOneChannel')}

)} {bothChannels && (

{t('admin.broadcasts.sendingBoth')}

)}
{/* Broadcast category */}

{t( 'admin.broadcasts.categoryDesc', 'Пользователи могут отключить получение новостей и промо в настройках профиля. Системные рассылки доставляются всем.', )}

{(['system', 'news', 'promo'] as const).map((cat) => ( ))}
{/* Telegram section */} {telegramEnabled && (

{t('admin.broadcasts.telegramSection')}

{/* Telegram filter selection */} {renderFilterDropdown( 'telegram', telegramTarget, selectedTelegramFilter, telegramRecipientsCount, showTelegramFilters, setShowTelegramFilters, handleTelegramFilterSelect, groupedTelegramFilters, filtersLoading, )} {/* Message text */}