feat: dual-channel broadcast form (Telegram + Email simultaneously)

Replace single channel radio with independent toggles allowing both
channels to be enabled at once with separate audiences and content.
When both enabled, sends two sequential API requests.
This commit is contained in:
Fringg
2026-02-07 04:59:02 +03:00
parent f8b74f3cfb
commit 772dcf7236
5 changed files with 421 additions and 311 deletions

View File

@@ -1099,7 +1099,12 @@
"emailContent": "Email content", "emailContent": "Email content",
"emailContentHint": "HTML supported", "emailContentHint": "HTML supported",
"emailContentPlaceholder": "<p>Hello, {{user_name}}!</p>\n<p>Your message here...</p>", "emailContentPlaceholder": "<p>Hello, {{user_name}}!</p>\n<p>Your message here...</p>",
"emailVariables": "Available variables" "emailVariables": "Available variables",
"enableTelegram": "Telegram",
"enableEmail": "Email",
"sendingBoth": "2 broadcasts will be created",
"bothCreated": "Broadcasts created",
"atLeastOneChannel": "Select at least one channel"
}, },
"settings": { "settings": {
"title": "System Settings", "title": "System Settings",

View File

@@ -803,7 +803,12 @@
"activity": "بر اساس فعالیت", "activity": "بر اساس فعالیت",
"source": "بر اساس منبع", "source": "بر اساس منبع",
"tariff": "بر اساس تعرفه" "tariff": "بر اساس تعرفه"
} },
"enableTelegram": "Telegram",
"enableEmail": "Email",
"sendingBoth": "۲ پیام‌رسانی ایجاد خواهد شد",
"bothCreated": "پیام‌رسانی‌ها ایجاد شدند",
"atLeastOneChannel": "حداقل یک کانال را انتخاب کنید"
}, },
"payments": { "payments": {
"title": "تأیید پرداخت", "title": "تأیید پرداخت",

View File

@@ -1124,7 +1124,12 @@
"emailContent": "Содержимое письма", "emailContent": "Содержимое письма",
"emailContentHint": "Поддерживается HTML", "emailContentHint": "Поддерживается HTML",
"emailContentPlaceholder": "<p>Здравствуйте, {{user_name}}!</p>\n<p>Текст вашего письма...</p>", "emailContentPlaceholder": "<p>Здравствуйте, {{user_name}}!</p>\n<p>Текст вашего письма...</p>",
"emailVariables": "Доступные переменные" "emailVariables": "Доступные переменные",
"enableTelegram": "Telegram",
"enableEmail": "Email",
"sendingBoth": "Будет создано 2 рассылки",
"bothCreated": "Рассылки созданы",
"atLeastOneChannel": "Выберите хотя бы один канал"
}, },
"settings": { "settings": {
"title": "Настройки системы", "title": "Настройки системы",

View File

@@ -855,7 +855,12 @@
"activity": "按活动", "activity": "按活动",
"source": "按来源", "source": "按来源",
"tariff": "按套餐" "tariff": "按套餐"
} },
"enableTelegram": "Telegram",
"enableEmail": "Email",
"sendingBoth": "将创建2个群发",
"bothCreated": "群发已创建",
"atLeastOneChannel": "请至少选择一个渠道"
}, },
"settings": { "settings": {
"title": "系统设置", "title": "系统设置",

View File

@@ -6,7 +6,6 @@ import {
adminBroadcastsApi, adminBroadcastsApi,
BroadcastFilter, BroadcastFilter,
TariffFilter, TariffFilter,
BroadcastChannel,
CombinedBroadcastCreateRequest, CombinedBroadcastCreateRequest,
} from '../api/adminBroadcasts'; } from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
@@ -118,12 +117,15 @@ export default function AdminBroadcastCreate() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Channel selection // Channel toggles (both can be enabled)
const [channel, setChannel] = useState<BroadcastChannel>('telegram'); const [telegramEnabled, setTelegramEnabled] = useState(true);
const [emailEnabled, setEmailEnabled] = useState(false);
// Common state // Separate targets per channel
const [target, setTarget] = useState(''); const [telegramTarget, setTelegramTarget] = useState('');
const [showFilters, setShowFilters] = useState(false); const [emailTarget, setEmailTarget] = useState('');
const [showTelegramFilters, setShowTelegramFilters] = useState(false);
const [showEmailFilters, setShowEmailFilters] = useState(false);
// Telegram-specific state // Telegram-specific state
const [messageText, setMessageText] = useState(''); const [messageText, setMessageText] = useState('');
@@ -138,29 +140,32 @@ export default function AdminBroadcastCreate() {
const [emailSubject, setEmailSubject] = useState(''); const [emailSubject, setEmailSubject] = useState('');
const [emailContent, setEmailContent] = useState(''); const [emailContent, setEmailContent] = useState('');
// Submitting state for dual send
const [isSubmitting, setIsSubmitting] = useState(false);
// Fetch Telegram filters // Fetch Telegram filters
const { data: filtersData, isLoading: filtersLoading } = useQuery({ const { data: filtersData, isLoading: filtersLoading } = useQuery({
queryKey: ['admin', 'broadcasts', 'filters'], queryKey: ['admin', 'broadcasts', 'filters'],
queryFn: adminBroadcastsApi.getFilters, queryFn: adminBroadcastsApi.getFilters,
enabled: channel === 'telegram', enabled: telegramEnabled,
}); });
// Fetch Email filters // Fetch Email filters
const { data: emailFiltersData, isLoading: emailFiltersLoading } = useQuery({ const { data: emailFiltersData, isLoading: emailFiltersLoading } = useQuery({
queryKey: ['admin', 'broadcasts', 'email-filters'], queryKey: ['admin', 'broadcasts', 'email-filters'],
queryFn: adminBroadcastsApi.getEmailFilters, queryFn: adminBroadcastsApi.getEmailFilters,
enabled: channel === 'email', enabled: emailEnabled,
}); });
// Fetch buttons // Fetch buttons
const { data: buttonsData } = useQuery({ const { data: buttonsData } = useQuery({
queryKey: ['admin', 'broadcasts', 'buttons'], queryKey: ['admin', 'broadcasts', 'buttons'],
queryFn: adminBroadcastsApi.getButtons, queryFn: adminBroadcastsApi.getButtons,
enabled: channel === 'telegram', enabled: telegramEnabled,
}); });
// Preview mutations // Preview mutations — separate for each channel
const previewMutation = useMutation({ const telegramPreviewMutation = useMutation({
mutationFn: adminBroadcastsApi.preview, mutationFn: adminBroadcastsApi.preview,
}); });
@@ -168,7 +173,7 @@ export default function AdminBroadcastCreate() {
mutationFn: adminBroadcastsApi.previewEmail, mutationFn: adminBroadcastsApi.previewEmail,
}); });
// Create mutation // Create mutation (used for single-channel sends)
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: adminBroadcastsApi.createCombined, mutationFn: adminBroadcastsApi.createCombined,
onSuccess: (data) => { onSuccess: (data) => {
@@ -178,7 +183,7 @@ export default function AdminBroadcastCreate() {
}); });
// Group Telegram filters // Group Telegram filters
const groupedFilters = useMemo(() => { const groupedTelegramFilters = useMemo(() => {
if (!filtersData) return {}; if (!filtersData) return {};
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {}; const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {};
@@ -215,48 +220,46 @@ export default function AdminBroadcastCreate() {
return groups; return groups;
}, [emailFiltersData]); }, [emailFiltersData]);
// Current filters based on channel // Selected filter info for each channel
const currentFilters = channel === 'telegram' ? groupedFilters : groupedEmailFilters; const selectedTelegramFilter = useMemo(() => {
const isFiltersLoading = channel === 'telegram' ? filtersLoading : emailFiltersLoading; if (!telegramTarget || !filtersData) return null;
// Selected filter info
const selectedFilter = useMemo(() => {
if (!target) return null;
if (channel === 'telegram' && filtersData) {
const all = [ const all = [
...filtersData.filters, ...filtersData.filters,
...filtersData.tariff_filters, ...filtersData.tariff_filters,
...filtersData.custom_filters, ...filtersData.custom_filters,
]; ];
return all.find((f) => f.key === target); return all.find((f) => f.key === telegramTarget) ?? null;
} }, [telegramTarget, filtersData]);
if (channel === 'email' && emailFiltersData) { const selectedEmailFilter = useMemo(() => {
return emailFiltersData.filters.find((f) => f.key === target); if (!emailTarget || !emailFiltersData) return null;
} return emailFiltersData.filters.find((f) => f.key === emailTarget) ?? null;
}, [emailTarget, emailFiltersData]);
return null; // Handle toggling channels
}, [target, channel, filtersData, emailFiltersData]); const handleToggleTelegram = () => {
setTelegramEnabled((prev) => !prev);
setTelegramTarget('');
telegramPreviewMutation.reset();
};
// Handle channel change const handleToggleEmail = () => {
const handleChannelChange = (newChannel: BroadcastChannel) => { setEmailEnabled((prev) => !prev);
setChannel(newChannel); setEmailTarget('');
setTarget('');
previewMutation.reset();
emailPreviewMutation.reset(); emailPreviewMutation.reset();
}; };
// Handle filter selection // Handle filter selection per channel
const handleFilterSelect = (filterKey: string) => { const handleTelegramFilterSelect = (filterKey: string) => {
setTarget(filterKey); setTelegramTarget(filterKey);
setShowFilters(false); setShowTelegramFilters(false);
telegramPreviewMutation.mutate(filterKey);
};
if (channel === 'telegram') { const handleEmailFilterSelect = (filterKey: string) => {
previewMutation.mutate(filterKey); setEmailTarget(filterKey);
} else { setShowEmailFilters(false);
emailPreviewMutation.mutate(filterKey); emailPreviewMutation.mutate(filterKey);
}
}; };
// Handle file selection // Handle file selection
@@ -308,114 +311,105 @@ export default function AdminBroadcastCreate() {
}; };
// Validate form // 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(() => { const isValid = useMemo(() => {
if (!target) return false; if (!telegramEnabled && !emailEnabled) return false;
if (telegramEnabled && !isTelegramValid) return false;
if (emailEnabled && !isEmailValid) return false;
return true;
}, [telegramEnabled, emailEnabled, isTelegramValid, isEmailValid]);
if (channel === 'telegram') { const bothChannels = telegramEnabled && emailEnabled;
return messageText.trim().length > 0;
}
if (channel === 'email') {
return emailSubject.trim().length > 0 && emailContent.trim().length > 0;
}
return false;
}, [target, channel, messageText, emailSubject, emailContent]);
// Submit // Submit
const handleSubmit = () => { const handleSubmit = async () => {
if (!isValid) return; if (!isValid) return;
// Single channel — use existing createMutation with navigation to detail
if (telegramEnabled && !emailEnabled) {
const data: CombinedBroadcastCreateRequest = { const data: CombinedBroadcastCreateRequest = {
channel, channel: 'telegram',
target, target: telegramTarget,
message_text: messageText,
selected_buttons: selectedButtons,
}; };
if (channel === 'telegram') {
data.message_text = messageText;
data.selected_buttons = selectedButtons;
if (uploadedFileId) { if (uploadedFileId) {
data.media = { data.media = { type: mediaType, file_id: uploadedFileId };
type: mediaType,
file_id: uploadedFileId,
};
} }
}
if (channel === 'email') {
data.email_subject = emailSubject;
data.email_html_content = emailContent;
}
createMutation.mutate(data); createMutation.mutate(data);
return;
}
if (emailEnabled && !telegramEnabled) {
const data: CombinedBroadcastCreateRequest = {
channel: 'email',
target: emailTarget,
email_subject: emailSubject,
email_html_content: emailContent,
};
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,
};
if (uploadedFileId) {
telegramData.media = { type: mediaType, file_id: uploadedFileId };
}
const emailData: CombinedBroadcastCreateRequest = {
channel: 'email',
target: emailTarget,
email_subject: emailSubject,
email_html_content: emailContent,
}; };
const recipientsCount = await adminBroadcastsApi.createCombined(telegramData);
channel === 'telegram' await adminBroadcastsApi.createCombined(emailData);
? (previewMutation.data?.count ?? selectedFilter?.count ?? null)
: (emailPreviewMutation.data?.count ?? selectedFilter?.count ?? null);
return ( queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
<div className="space-y-6"> navigate('/admin/broadcasts');
{/* Header */} } finally {
<div className="flex items-center gap-3"> setIsSubmitting(false);
<AdminBackButton /> }
<div className="flex items-center gap-3"> };
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<BroadcastIcon />
</div>
<div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.broadcasts.create')}</h1>
<p className="text-sm text-dark-400">{t('admin.broadcasts.subtitle')}</p>
</div>
</div>
</div>
{/* Channel selection */} // Recipients counts per channel
<div className="card"> const telegramRecipientsCount = telegramEnabled
<label className="mb-3 block text-sm font-medium text-dark-300"> ? (telegramPreviewMutation.data?.count ?? selectedTelegramFilter?.count ?? null)
{t('admin.broadcasts.selectChannel')} : null;
</label>
<div className="flex gap-3">
<button
onClick={() => handleChannelChange('telegram')}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
channel === 'telegram'
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
}`}
>
<TelegramIcon />
<span className="font-medium">{t('admin.broadcasts.channel.telegram')}</span>
</button>
<button
onClick={() => handleChannelChange('email')}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
channel === 'email'
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
}`}
>
<EmailIcon />
<span className="font-medium">{t('admin.broadcasts.channel.email')}</span>
</button>
</div>
</div>
{/* Content */} const emailRecipientsCount = emailEnabled
<div className="card space-y-6"> ? (emailPreviewMutation.data?.count ?? selectedEmailFilter?.count ?? null)
{/* Section title */} : null;
<h2 className="text-lg font-semibold text-dark-100">
{channel === 'telegram'
? t('admin.broadcasts.telegramSection')
: t('admin.broadcasts.emailSection')}
</h2>
{/* Filter selection */} 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<string, (BroadcastFilter | TariffFilter)[]>,
isLoading: boolean,
) => (
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{channel === 'telegram' {channelType === 'telegram'
? t('admin.broadcasts.selectFilter') ? t('admin.broadcasts.selectFilter')
: t('admin.broadcasts.selectEmailFilter')} : t('admin.broadcasts.selectEmailFilter')}
</label> </label>
@@ -429,7 +423,7 @@ export default function AdminBroadcastCreate() {
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}> <span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
{selectedFilter {selectedFilter
? selectedFilter.label ? selectedFilter.label
: channel === 'telegram' : channelType === 'telegram'
? t('admin.broadcasts.selectFilterPlaceholder') ? t('admin.broadcasts.selectFilterPlaceholder')
: t('admin.broadcasts.selectEmailFilterPlaceholder')} : t('admin.broadcasts.selectEmailFilterPlaceholder')}
</span> </span>
@@ -444,10 +438,10 @@ export default function AdminBroadcastCreate() {
{showFilters && ( {showFilters && (
<div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg border border-dark-700 bg-dark-800 shadow-xl"> <div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg border border-dark-700 bg-dark-800 shadow-xl">
{isFiltersLoading ? ( {isLoading ? (
<div className="p-4 text-center text-dark-400">{t('common.loading')}</div> <div className="p-4 text-center text-dark-400">{t('common.loading')}</div>
) : ( ) : (
Object.entries(currentFilters).map(([group, filters]) => ( Object.entries(groupedFilters).map(([group, filters]) => (
<div key={group}> <div key={group}>
<div className="sticky top-0 bg-dark-900 px-3 py-2 text-xs font-medium text-dark-400"> <div className="sticky top-0 bg-dark-900 px-3 py-2 text-xs font-medium text-dark-400">
{FILTER_GROUP_LABEL_KEYS[group] ? t(FILTER_GROUP_LABEL_KEYS[group]) : group} {FILTER_GROUP_LABEL_KEYS[group] ? t(FILTER_GROUP_LABEL_KEYS[group]) : group}
@@ -473,10 +467,81 @@ export default function AdminBroadcastCreate() {
)} )}
</div> </div>
</div> </div>
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<AdminBackButton />
<div className="flex items-center gap-3">
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<BroadcastIcon />
</div>
<div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.broadcasts.create')}</h1>
<p className="text-sm text-dark-400">{t('admin.broadcasts.subtitle')}</p>
</div>
</div>
</div>
{/* Channel toggles */}
<div className="card">
<label className="mb-3 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.selectChannel')}
</label>
<div className="flex gap-3">
<button
onClick={handleToggleTelegram}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
telegramEnabled
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
}`}
>
<TelegramIcon />
<span className="font-medium">{t('admin.broadcasts.enableTelegram')}</span>
</button>
<button
onClick={handleToggleEmail}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
emailEnabled
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
}`}
>
<EmailIcon />
<span className="font-medium">{t('admin.broadcasts.enableEmail')}</span>
</button>
</div>
{!telegramEnabled && !emailEnabled && (
<p className="mt-2 text-sm text-error-400">{t('admin.broadcasts.atLeastOneChannel')}</p>
)}
{bothChannels && (
<p className="mt-2 text-sm text-accent-400">{t('admin.broadcasts.sendingBoth')}</p>
)}
</div>
{/* Telegram section */}
{telegramEnabled && (
<div className="card space-y-6">
<h2 className="text-lg font-semibold text-dark-100">
{t('admin.broadcasts.telegramSection')}
</h2>
{/* Telegram filter selection */}
{renderFilterDropdown(
'telegram',
telegramTarget,
selectedTelegramFilter,
telegramRecipientsCount,
showTelegramFilters,
setShowTelegramFilters,
handleTelegramFilterSelect,
groupedTelegramFilters,
filtersLoading,
)}
{/* Telegram-specific fields */}
{channel === 'telegram' && (
<>
{/* Message text */} {/* Message text */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
@@ -575,12 +640,29 @@ export default function AdminBroadcastCreate() {
))} ))}
</div> </div>
</div> </div>
</> </div>
)}
{/* Email section */}
{emailEnabled && (
<div className="card space-y-6">
<h2 className="text-lg font-semibold text-dark-100">
{t('admin.broadcasts.emailSection')}
</h2>
{/* Email filter selection */}
{renderFilterDropdown(
'email',
emailTarget,
selectedEmailFilter,
emailRecipientsCount,
showEmailFilters,
setShowEmailFilters,
handleEmailFilterSelect,
groupedEmailFilters,
emailFiltersLoading,
)} )}
{/* Email-specific fields */}
{channel === 'email' && (
<>
{/* Email subject */} {/* Email subject */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
@@ -627,18 +709,26 @@ export default function AdminBroadcastCreate() {
))} ))}
</div> </div>
</div> </div>
</>
)}
</div> </div>
)}
{/* Footer */} {/* Footer */}
<div className="card flex items-center justify-between"> <div className="card flex items-center justify-between">
<div className="text-sm text-dark-400"> <div className="text-sm text-dark-400">
{recipientsCount !== null && ( {(telegramRecipientsCount !== null || emailRecipientsCount !== null) && (
<span> <span>
{t('admin.broadcasts.willBeSent')}:{' '} {t('admin.broadcasts.willBeSent')}:{' '}
<strong className="text-accent-400">{recipientsCount}</strong>{' '} {telegramRecipientsCount !== null && (
{t('admin.broadcasts.users')} <>
<strong className="text-accent-400">{telegramRecipientsCount}</strong> (TG)
</>
)}
{telegramRecipientsCount !== null && emailRecipientsCount !== null && ' + '}
{emailRecipientsCount !== null && (
<>
<strong className="text-accent-400">{emailRecipientsCount}</strong> (Email)
</>
)}
</span> </span>
)} )}
</div> </div>
@@ -648,10 +738,10 @@ export default function AdminBroadcastCreate() {
</button> </button>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isValid || createMutation.isPending || isUploading} disabled={!isValid || isPending || isUploading}
className="btn-primary flex items-center gap-2" className="btn-primary flex items-center gap-2"
> >
{createMutation.isPending ? <RefreshIcon /> : <BroadcastIcon />} {isPending ? <RefreshIcon /> : <BroadcastIcon />}
{t('admin.broadcasts.send')} {t('admin.broadcasts.send')}
</button> </button>
</div> </div>