diff --git a/src/api/adminBroadcasts.ts b/src/api/adminBroadcasts.ts index 6f60fc0..26760c1 100644 --- a/src/api/adminBroadcasts.ts +++ b/src/api/adminBroadcasts.ts @@ -1,6 +1,8 @@ import apiClient from './client'; // Types +export type BroadcastChannel = 'telegram' | 'email' | 'both'; + export interface BroadcastFilter { key: string; label: string; @@ -21,6 +23,10 @@ export interface BroadcastFiltersResponse { custom_filters: BroadcastFilter[]; } +export interface EmailFiltersResponse { + filters: BroadcastFilter[]; +} + export interface TariffForBroadcast { id: number; name: string; @@ -55,6 +61,24 @@ export interface BroadcastCreateRequest { media?: BroadcastMedia; } +export interface EmailBroadcastCreateRequest { + target: string; + subject: string; + html_content: string; +} + +export interface CombinedBroadcastCreateRequest { + channel: BroadcastChannel; + target: string; + // Telegram fields + message_text?: string; + selected_buttons?: string[]; + media?: BroadcastMedia; + // Email fields + email_subject?: string; + email_html_content?: string; +} + export interface Broadcast { id: number; target_type: string; @@ -79,6 +103,10 @@ export interface Broadcast { created_at: string; completed_at: string | null; progress_percent: number; + // New fields for channel support + channel?: BroadcastChannel; + email_subject?: string | null; + email_html_content?: string | null; } export interface BroadcastListResponse { @@ -105,7 +133,7 @@ export interface MediaUploadResponse { } export const adminBroadcastsApi = { - // Get all available filters with counts + // Get all available filters with counts (for Telegram) getFilters: async (): Promise => { const response = await apiClient.get( '/cabinet/admin/broadcasts/filters', @@ -113,6 +141,14 @@ export const adminBroadcastsApi = { return response.data; }, + // Get email filters with counts + getEmailFilters: async (): Promise => { + const response = await apiClient.get( + '/cabinet/admin/broadcasts/email-filters', + ); + return response.data; + }, + // Get tariffs for filtering getTariffs: async (): Promise => { const response = await apiClient.get( @@ -140,12 +176,35 @@ export const adminBroadcastsApi = { return response.data; }, - // Create and start broadcast + // Preview email broadcast (get recipients count) + previewEmail: async (target: string): Promise => { + const response = await apiClient.post( + '/cabinet/admin/broadcasts/email-preview', + { + target, + }, + ); + return response.data; + }, + + // Create and start broadcast (Telegram only - legacy) create: async (data: BroadcastCreateRequest): Promise => { const response = await apiClient.post('/cabinet/admin/broadcasts', data); return response.data; }, + // Create email broadcast + createEmail: async (data: EmailBroadcastCreateRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/broadcasts/email', data); + return response.data; + }, + + // Create combined broadcast (Telegram, Email, or Both) + createCombined: async (data: CombinedBroadcastCreateRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/broadcasts/send', data); + return response.data; + }, + // Get list of broadcasts list: async (limit = 20, offset = 0): Promise => { const response = await apiClient.get('/cabinet/admin/broadcasts', { diff --git a/src/locales/en.json b/src/locales/en.json index 0b8ccbe..0495966 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -971,8 +971,25 @@ "registration": "By registration", "activity": "By activity", "source": "By source", - "tariff": "By tariff" - } + "tariff": "By tariff", + "email": "Email users" + }, + "channel": { + "telegram": "Telegram", + "email": "Email", + "both": "Both" + }, + "selectChannel": "Broadcast channel", + "telegramSection": "Telegram broadcast", + "emailSection": "Email broadcast", + "selectEmailFilter": "Select email audience", + "selectEmailFilterPlaceholder": "Select filter...", + "emailSubject": "Email subject", + "emailSubjectPlaceholder": "Enter email subject...", + "emailContent": "Email content", + "emailContentHint": "HTML supported", + "emailContentPlaceholder": "

Hello, {{user_name}}!

\n

Your message here...

", + "emailVariables": "Available variables" }, "settings": { "title": "System Settings", diff --git a/src/locales/ru.json b/src/locales/ru.json index 07ccdea..169f826 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -988,8 +988,25 @@ "registration": "По регистрации", "activity": "По активности", "source": "По источнику", - "tariff": "По тарифу" - } + "tariff": "По тарифу", + "email": "Email-пользователи" + }, + "channel": { + "telegram": "Telegram", + "email": "Email", + "both": "Оба" + }, + "selectChannel": "Канал рассылки", + "telegramSection": "Telegram-рассылка", + "emailSection": "Email-рассылка", + "selectEmailFilter": "Выберите email-аудиторию", + "selectEmailFilterPlaceholder": "Выберите фильтр...", + "emailSubject": "Тема письма", + "emailSubjectPlaceholder": "Введите тему письма...", + "emailContent": "Содержимое письма", + "emailContentHint": "Поддерживается HTML", + "emailContentPlaceholder": "

Здравствуйте, {{user_name}}!

\n

Текст вашего письма...

", + "emailVariables": "Доступные переменные" }, "settings": { "title": "Настройки системы", diff --git a/src/pages/AdminBroadcasts.tsx b/src/pages/AdminBroadcasts.tsx index 3928233..a8edc8f 100644 --- a/src/pages/AdminBroadcasts.tsx +++ b/src/pages/AdminBroadcasts.tsx @@ -7,7 +7,8 @@ import { Broadcast, BroadcastFilter, TariffFilter, - BroadcastCreateRequest, + BroadcastChannel, + CombinedBroadcastCreateRequest, } from '../api/adminBroadcasts'; // Icons @@ -111,6 +112,22 @@ const ChevronDownIcon = () => ( ); +const TelegramIcon = () => ( + + + +); + +const EmailIcon = () => ( + + + +); + // Status badge component const statusConfig: Record = { queued: { @@ -156,6 +173,38 @@ function StatusBadge({ status }: { status: string }) { ); } +// Channel badge component +function ChannelBadge({ channel }: { channel?: BroadcastChannel }) { + const { t } = useTranslation(); + + if (!channel || channel === 'telegram') { + return ( + + + Telegram + + ); + } + + if (channel === 'email') { + return ( + + + Email + + ); + } + + return ( + + + + + + {t('admin.broadcasts.channel.both')} + + ); +} + // Filter labels (labelKey pattern: store i18n keys, resolve at render time with t()) const FILTER_GROUP_LABEL_KEYS: Record = { basic: 'admin.broadcasts.filterGroups.basic', @@ -165,6 +214,7 @@ const FILTER_GROUP_LABEL_KEYS: Record = { activity: 'admin.broadcasts.filterGroups.activity', source: 'admin.broadcasts.filterGroups.source', tariff: 'admin.broadcasts.filterGroups.tariff', + email: 'admin.broadcasts.filterGroups.email', }; // Create broadcast modal @@ -178,7 +228,14 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { const queryClient = useQueryClient(); const fileInputRef = useRef(null); + // Channel selection + const [channel, setChannel] = useState('telegram'); + + // Common fields const [target, setTarget] = useState(''); + const [emailTarget, setEmailTarget] = useState(''); + + // Telegram fields const [messageText, setMessageText] = useState(''); const [selectedButtons, setSelectedButtons] = useState(['home']); const [mediaFile, setMediaFile] = useState(null); @@ -186,28 +243,48 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { const [mediaPreview, setMediaPreview] = useState(null); const [uploadedFileId, setUploadedFileId] = useState(null); const [isUploading, setIsUploading] = useState(false); - const [showFilters, setShowFilters] = useState(false); - // Fetch filters + // Email fields + const [emailSubject, setEmailSubject] = useState(''); + const [emailHtmlContent, setEmailHtmlContent] = useState(''); + + // UI state + const [showFilters, setShowFilters] = useState(false); + const [showEmailFilters, setShowEmailFilters] = useState(false); + + // Fetch Telegram filters const { data: filtersData, isLoading: filtersLoading } = useQuery({ queryKey: ['admin', 'broadcasts', 'filters'], queryFn: adminBroadcastsApi.getFilters, + enabled: channel === 'telegram' || channel === 'both', + }); + + // Fetch Email filters + const { data: emailFiltersData, isLoading: emailFiltersLoading } = useQuery({ + queryKey: ['admin', 'broadcasts', 'email-filters'], + queryFn: adminBroadcastsApi.getEmailFilters, + enabled: channel === 'email' || channel === 'both', }); // Fetch buttons const { data: buttonsData } = useQuery({ queryKey: ['admin', 'broadcasts', 'buttons'], queryFn: adminBroadcastsApi.getButtons, + enabled: channel === 'telegram' || channel === 'both', }); - // Preview mutation + // Preview mutations const previewMutation = useMutation({ mutationFn: adminBroadcastsApi.preview, }); + const previewEmailMutation = useMutation({ + mutationFn: adminBroadcastsApi.previewEmail, + }); + // Create mutation const createMutation = useMutation({ - mutationFn: adminBroadcastsApi.create, + mutationFn: adminBroadcastsApi.createCombined, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }); onSuccess(); @@ -242,6 +319,20 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { 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 const selectedFilter = useMemo(() => { if (!target || !filtersData) return null; @@ -253,6 +344,11 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { return all.find((f) => f.key === target); }, [target, filtersData]); + const selectedEmailFilter = useMemo(() => { + if (!emailTarget || !emailFiltersData) return null; + return emailFiltersData.filters.find((f) => f.key === emailTarget); + }, [emailTarget, emailFiltersData]); + // Handle file selection const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -303,30 +399,77 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { ); }; + // Validation + const canSubmit = useMemo(() => { + if (createMutation.isPending || isUploading) return false; + + if (channel === 'telegram') { + return !!target && !!messageText.trim(); + } + + if (channel === 'email') { + return !!emailTarget && !!emailSubject.trim() && !!emailHtmlContent.trim(); + } + + // Both + return ( + !!target && + !!messageText.trim() && + !!emailTarget && + !!emailSubject.trim() && + !!emailHtmlContent.trim() + ); + }, [ + channel, + target, + messageText, + emailTarget, + emailSubject, + emailHtmlContent, + createMutation.isPending, + isUploading, + ]); + // Submit const handleSubmit = () => { - if (!target || !messageText.trim()) return; + if (!canSubmit) return; - const data: BroadcastCreateRequest = { - target, - message_text: messageText, - selected_buttons: selectedButtons, + const data: CombinedBroadcastCreateRequest = { + channel, + target: channel === 'email' ? emailTarget : target, }; - if (uploadedFileId) { - data.media = { - type: mediaType, - file_id: uploadedFileId, - }; + // Telegram data + if (channel === 'telegram' || channel === 'both') { + data.message_text = messageText; + data.selected_buttons = selectedButtons; + + if (uploadedFileId) { + data.media = { + type: mediaType, + file_id: uploadedFileId, + }; + } + } + + // Email data + if (channel === 'email' || channel === 'both') { + data.email_subject = emailSubject; + data.email_html_content = emailHtmlContent; + if (channel === 'both') { + data.target = target; // Use Telegram target as primary + } } createMutation.mutate(data); }; - const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null; + const telegramRecipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null; + const emailRecipientsCount = + previewEmailMutation.data?.count ?? selectedEmailFilter?.count ?? null; return ( -
+
{/* Header */}
@@ -343,177 +486,346 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { {/* Content */}
- {/* Filter selection */} + {/* Channel selection */}
-
+
+ + +
+
- {showFilters && ( -
- {filtersLoading ? ( -
Loading...
- ) : ( - Object.entries(groupedFilters).map(([group, filters]) => ( -
-
- {FILTER_GROUP_LABEL_KEYS[group] - ? t(FILTER_GROUP_LABEL_KEYS[group]) - : group} -
- {filters.map((filter) => ( - - ))} -
- )) + {/* Telegram Section */} + {(channel === 'telegram' || channel === 'both') && ( +
+
+ + {t('admin.broadcasts.telegramSection')} +
+ + {/* Filter selection */} +
+ +
+ + + {showFilters && ( +
+ {filtersLoading ? ( +
Loading...
+ ) : ( + Object.entries(groupedFilters).map(([group, filters]) => ( +
+
+ {FILTER_GROUP_LABEL_KEYS[group] + ? t(FILTER_GROUP_LABEL_KEYS[group]) + : group} +
+ {filters.map((filter) => ( + + ))} +
+ )) + )} +
)}
- )} -
-
- - {/* Message text */} -
- -