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",
"emailContentHint": "HTML supported",
"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": {
"title": "System Settings",

View File

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

View File

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

View File

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

View File

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