Merge pull request #167 from BEDOLAGA-DEV/feat/dual-channel-broadcasts

feat: dual-channel broadcasts (Telegram + Email)
This commit is contained in:
Egor
2026-02-07 05:03:57 +03:00
committed by GitHub
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>