From 049397c3729ebf7de6fbb52cfd2ccc9f34997c6e Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 31 Jan 2026 18:02:02 +0300 Subject: [PATCH 1/5] Update package-lock.json From 8f72d9f83176596cca1d19908bcec73d2f74819a Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 31 Jan 2026 18:02:39 +0300 Subject: [PATCH 2/5] Update adminBroadcasts.ts --- src/api/adminBroadcasts.ts | 63 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 2 deletions(-) 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', { From f1976ca7ffe6445f439c1e0033de1ac857fe93a1 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 31 Jan 2026 18:02:58 +0300 Subject: [PATCH 3/5] Update AdminBroadcasts.tsx --- src/pages/AdminBroadcasts.tsx | 702 +++++++++++++++++++++++++--------- 1 file changed, 523 insertions(+), 179 deletions(-) diff --git a/src/pages/AdminBroadcasts.tsx b/src/pages/AdminBroadcasts.tsx index 3928233..cbb54b3 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 */} -
- -