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;
const all = [
...filtersData.filters,
...filtersData.tariff_filters,
...filtersData.custom_filters,
];
return all.find((f) => f.key === telegramTarget) ?? null;
}, [telegramTarget, filtersData]);
// Selected filter info const selectedEmailFilter = useMemo(() => {
const selectedFilter = useMemo(() => { if (!emailTarget || !emailFiltersData) return null;
if (!target) return null; return emailFiltersData.filters.find((f) => f.key === emailTarget) ?? null;
}, [emailTarget, emailFiltersData]);
if (channel === 'telegram' && filtersData) { // Handle toggling channels
const all = [ const handleToggleTelegram = () => {
...filtersData.filters, setTelegramEnabled((prev) => !prev);
...filtersData.tariff_filters, setTelegramTarget('');
...filtersData.custom_filters, telegramPreviewMutation.reset();
]; };
return all.find((f) => f.key === target);
}
if (channel === 'email' && emailFiltersData) { const handleToggleEmail = () => {
return emailFiltersData.filters.find((f) => f.key === target); setEmailEnabled((prev) => !prev);
} setEmailTarget('');
return null;
}, [target, channel, filtersData, emailFiltersData]);
// Handle channel change
const handleChannelChange = (newChannel: BroadcastChannel) => {
setChannel(newChannel);
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,53 +311,163 @@ 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;
const data: CombinedBroadcastCreateRequest = { // Single channel — use existing createMutation with navigation to detail
channel, if (telegramEnabled && !emailEnabled) {
target, const data: CombinedBroadcastCreateRequest = {
}; channel: 'telegram',
target: telegramTarget,
if (channel === 'telegram') { message_text: messageText,
data.message_text = messageText; selected_buttons: selectedButtons,
data.selected_buttons = selectedButtons; };
if (uploadedFileId) { if (uploadedFileId) {
data.media = { data.media = { type: mediaType, file_id: uploadedFileId };
type: mediaType,
file_id: uploadedFileId,
};
} }
createMutation.mutate(data);
return;
} }
if (channel === 'email') { if (emailEnabled && !telegramEnabled) {
data.email_subject = emailSubject; const data: CombinedBroadcastCreateRequest = {
data.email_html_content = emailContent; channel: 'email',
target: emailTarget,
email_subject: emailSubject,
email_html_content: emailContent,
};
createMutation.mutate(data);
return;
} }
createMutation.mutate(data); // 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,
};
await adminBroadcastsApi.createCombined(telegramData);
await adminBroadcastsApi.createCombined(emailData);
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
navigate('/admin/broadcasts');
} finally {
setIsSubmitting(false);
}
}; };
const recipientsCount = // Recipients counts per channel
channel === 'telegram' const telegramRecipientsCount = telegramEnabled
? (previewMutation.data?.count ?? selectedFilter?.count ?? null) ? (telegramPreviewMutation.data?.count ?? selectedTelegramFilter?.count ?? null)
: (emailPreviewMutation.data?.count ?? selectedFilter?.count ?? null); : null;
const emailRecipientsCount = emailEnabled
? (emailPreviewMutation.data?.count ?? selectedEmailFilter?.count ?? null)
: null;
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">
{channelType === 'telegram'
? t('admin.broadcasts.selectFilter')
: t('admin.broadcasts.selectEmailFilter')}
</label>
<div className="relative">
<button
onClick={() => setShowFilters(!showFilters)}
className="flex w-full items-center justify-between rounded-lg border border-dark-700 bg-dark-800 p-3 text-left transition-colors hover:border-dark-600"
>
<div className="flex items-center gap-2">
<UsersIcon />
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
{selectedFilter
? selectedFilter.label
: channelType === 'telegram'
? t('admin.broadcasts.selectFilterPlaceholder')
: t('admin.broadcasts.selectEmailFilterPlaceholder')}
</span>
{recipientsCount !== null && (
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
{recipientsCount} {t('admin.broadcasts.recipients')}
</span>
)}
</div>
<ChevronDownIcon />
</button>
{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">
{isLoading ? (
<div className="p-4 text-center text-dark-400">{t('common.loading')}</div>
) : (
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}
</div>
{filters.map((filter) => (
<button
key={filter.key}
onClick={() => handleFilterSelect(filter.key)}
className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-700 ${
target === filter.key ? 'bg-accent-500/20' : ''
}`}
>
<span className="text-dark-100">{filter.label}</span>
{filter.count !== null && filter.count !== undefined && (
<span className="text-xs text-dark-400">{filter.count}</span>
)}
</button>
))}
</div>
))
)}
</div>
)}
</div>
</div>
);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -372,273 +485,250 @@ export default function AdminBroadcastCreate() {
</div> </div>
</div> </div>
{/* Channel selection */} {/* Channel toggles */}
<div className="card"> <div className="card">
<label className="mb-3 block text-sm font-medium text-dark-300"> <label className="mb-3 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.selectChannel')} {t('admin.broadcasts.selectChannel')}
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={() => handleChannelChange('telegram')} onClick={handleToggleTelegram}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${ className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
channel === 'telegram' telegramEnabled
? 'border-accent-500 bg-accent-500/10 text-accent-400' ? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600' : 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
}`} }`}
> >
<TelegramIcon /> <TelegramIcon />
<span className="font-medium">{t('admin.broadcasts.channel.telegram')}</span> <span className="font-medium">{t('admin.broadcasts.enableTelegram')}</span>
</button> </button>
<button <button
onClick={() => handleChannelChange('email')} onClick={handleToggleEmail}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${ className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
channel === 'email' emailEnabled
? 'border-accent-500 bg-accent-500/10 text-accent-400' ? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600' : 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
}`} }`}
> >
<EmailIcon /> <EmailIcon />
<span className="font-medium">{t('admin.broadcasts.channel.email')}</span> <span className="font-medium">{t('admin.broadcasts.enableEmail')}</span>
</button> </button>
</div> </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> </div>
{/* Content */} {/* Telegram section */}
<div className="card space-y-6"> {telegramEnabled && (
{/* Section title */} <div className="card space-y-6">
<h2 className="text-lg font-semibold text-dark-100"> <h2 className="text-lg font-semibold text-dark-100">
{channel === 'telegram' {t('admin.broadcasts.telegramSection')}
? t('admin.broadcasts.telegramSection') </h2>
: t('admin.broadcasts.emailSection')}
</h2>
{/* Filter selection */} {/* Telegram filter selection */}
<div> {renderFilterDropdown(
<label className="mb-2 block text-sm font-medium text-dark-300"> 'telegram',
{channel === 'telegram' telegramTarget,
? t('admin.broadcasts.selectFilter') selectedTelegramFilter,
: t('admin.broadcasts.selectEmailFilter')} telegramRecipientsCount,
</label> showTelegramFilters,
<div className="relative"> setShowTelegramFilters,
<button handleTelegramFilterSelect,
onClick={() => setShowFilters(!showFilters)} groupedTelegramFilters,
className="flex w-full items-center justify-between rounded-lg border border-dark-700 bg-dark-800 p-3 text-left transition-colors hover:border-dark-600" filtersLoading,
> )}
<div className="flex items-center gap-2">
<UsersIcon /> {/* Message text */}
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}> <div>
{selectedFilter <label className="mb-2 block text-sm font-medium text-dark-300">
? selectedFilter.label {t('admin.broadcasts.messageText')}
: channel === 'telegram' </label>
? t('admin.broadcasts.selectFilterPlaceholder') <textarea
: t('admin.broadcasts.selectEmailFilterPlaceholder')} value={messageText}
</span> onChange={(e) => setMessageText(e.target.value)}
{recipientsCount !== null && ( placeholder={t('admin.broadcasts.messageTextPlaceholder')}
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400"> rows={6}
{recipientsCount} {t('admin.broadcasts.recipients')} maxLength={4000}
</span> className="input min-h-[150px] resize-y"
/>
<div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
</div>
{/* Media upload */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.media')}
</label>
{mediaFile ? (
<div className="rounded-lg border border-dark-700 bg-dark-800 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{mediaType === 'photo' && <PhotoIcon />}
{mediaType === 'video' && <VideoIcon />}
{mediaType === 'document' && <DocumentIcon />}
<div>
<p className="text-sm text-dark-100">{mediaFile.name}</p>
<p className="text-xs text-dark-400">
{(mediaFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<button
onClick={handleRemoveMedia}
className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400"
disabled={isUploading}
>
<XIcon />
</button>
</div>
{mediaPreview && (
<img
src={mediaPreview}
alt="Preview"
className="mt-3 max-h-48 rounded-lg object-cover"
/>
)}
{isUploading && (
<div className="mt-2 flex items-center gap-2 text-sm text-accent-400">
<RefreshIcon />
{t('admin.broadcasts.uploading')}
</div>
)} )}
</div> </div>
<ChevronDownIcon /> ) : (
</button> <div>
<input
{showFilters && ( ref={fileInputRef}
<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"> type="file"
{isFiltersLoading ? ( accept="image/*,video/*,application/*"
<div className="p-4 text-center text-dark-400">{t('common.loading')}</div> onChange={handleFileSelect}
) : ( className="hidden"
Object.entries(currentFilters).map(([group, filters]) => ( />
<div key={group}> <button
<div className="sticky top-0 bg-dark-900 px-3 py-2 text-xs font-medium text-dark-400"> onClick={() => fileInputRef.current?.click()}
{FILTER_GROUP_LABEL_KEYS[group] ? t(FILTER_GROUP_LABEL_KEYS[group]) : group} className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-dark-600 bg-dark-800/50 p-6 text-dark-400 transition-colors hover:border-dark-500 hover:bg-dark-800 hover:text-dark-300"
</div> >
{filters.map((filter) => ( <PhotoIcon />
<button <span>{t('admin.broadcasts.addMedia')}</span>
key={filter.key} </button>
onClick={() => handleFilterSelect(filter.key)}
className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-700 ${
target === filter.key ? 'bg-accent-500/20' : ''
}`}
>
<span className="text-dark-100">{filter.label}</span>
{filter.count !== null && filter.count !== undefined && (
<span className="text-xs text-dark-400">{filter.count}</span>
)}
</button>
))}
</div>
))
)}
</div> </div>
)} )}
</div> </div>
{/* Buttons selection */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.buttons')}
</label>
<div className="flex flex-wrap gap-2">
{buttonsData?.buttons.map((button) => (
<button
key={button.key}
onClick={() => toggleButton(button.key)}
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
selectedButtons.includes(button.key)
? 'bg-accent-500 text-white'
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{button.label}
</button>
))}
</div>
</div>
</div> </div>
)}
{/* Telegram-specific fields */} {/* Email section */}
{channel === 'telegram' && ( {emailEnabled && (
<> <div className="card space-y-6">
{/* Message text */} <h2 className="text-lg font-semibold text-dark-100">
<div> {t('admin.broadcasts.emailSection')}
<label className="mb-2 block text-sm font-medium text-dark-300"> </h2>
{t('admin.broadcasts.messageText')}
</label>
<textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
rows={6}
maxLength={4000}
className="input min-h-[150px] resize-y"
/>
<div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
</div>
{/* Media upload */} {/* Email filter selection */}
<div> {renderFilterDropdown(
<label className="mb-2 block text-sm font-medium text-dark-300"> 'email',
{t('admin.broadcasts.media')} emailTarget,
</label> selectedEmailFilter,
{mediaFile ? ( emailRecipientsCount,
<div className="rounded-lg border border-dark-700 bg-dark-800 p-4"> showEmailFilters,
<div className="flex items-center justify-between"> setShowEmailFilters,
<div className="flex items-center gap-3"> handleEmailFilterSelect,
{mediaType === 'photo' && <PhotoIcon />} groupedEmailFilters,
{mediaType === 'video' && <VideoIcon />} emailFiltersLoading,
{mediaType === 'document' && <DocumentIcon />} )}
<div>
<p className="text-sm text-dark-100">{mediaFile.name}</p>
<p className="text-xs text-dark-400">
{(mediaFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<button
onClick={handleRemoveMedia}
className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400"
disabled={isUploading}
>
<XIcon />
</button>
</div>
{mediaPreview && (
<img
src={mediaPreview}
alt="Preview"
className="mt-3 max-h-48 rounded-lg object-cover"
/>
)}
{isUploading && (
<div className="mt-2 flex items-center gap-2 text-sm text-accent-400">
<RefreshIcon />
{t('admin.broadcasts.uploading')}
</div>
)}
</div>
) : (
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,application/*"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-dark-600 bg-dark-800/50 p-6 text-dark-400 transition-colors hover:border-dark-500 hover:bg-dark-800 hover:text-dark-300"
>
<PhotoIcon />
<span>{t('admin.broadcasts.addMedia')}</span>
</button>
</div>
)}
</div>
{/* Buttons selection */} {/* 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">
{t('admin.broadcasts.buttons')} {t('admin.broadcasts.emailSubject')}
</label> </label>
<div className="flex flex-wrap gap-2"> <input
{buttonsData?.buttons.map((button) => ( type="text"
<button value={emailSubject}
key={button.key} onChange={(e) => setEmailSubject(e.target.value)}
onClick={() => toggleButton(button.key)} placeholder={t('admin.broadcasts.emailSubjectPlaceholder')}
className={`rounded-lg px-3 py-2 text-sm transition-colors ${ className="input"
selectedButtons.includes(button.key) maxLength={200}
? 'bg-accent-500 text-white' />
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700' </div>
}`}
>
{button.label}
</button>
))}
</div>
</div>
</>
)}
{/* Email-specific fields */} {/* Email content */}
{channel === 'email' && ( <div>
<> <label className="mb-2 block text-sm font-medium text-dark-300">
{/* Email subject */} {t('admin.broadcasts.emailContent')}
<div> </label>
<label className="mb-2 block text-sm font-medium text-dark-300"> <p className="mb-2 text-xs text-dark-400">{t('admin.broadcasts.emailContentHint')}</p>
{t('admin.broadcasts.emailSubject')} <textarea
</label> value={emailContent}
<input onChange={(e) => setEmailContent(e.target.value)}
type="text" placeholder={t('admin.broadcasts.emailContentPlaceholder')}
value={emailSubject} rows={10}
onChange={(e) => setEmailSubject(e.target.value)} className="input min-h-[200px] resize-y font-mono text-sm"
placeholder={t('admin.broadcasts.emailSubjectPlaceholder')} />
className="input" </div>
maxLength={200}
/>
</div>
{/* Email content */} {/* Email variables hint */}
<div> <div className="rounded-lg border border-dark-700 bg-dark-800/50 p-4">
<label className="mb-2 block text-sm font-medium text-dark-300"> <p className="mb-2 text-sm font-medium text-dark-300">
{t('admin.broadcasts.emailContent')} {t('admin.broadcasts.emailVariables')}
</label> </p>
<p className="mb-2 text-xs text-dark-400">{t('admin.broadcasts.emailContentHint')}</p> <div className="flex flex-wrap gap-2">
<textarea {['{{user_name}}', '{{email}}', '{{user_id}}'].map((variable) => (
value={emailContent} <code
onChange={(e) => setEmailContent(e.target.value)} key={variable}
placeholder={t('admin.broadcasts.emailContentPlaceholder')} className="rounded bg-dark-700 px-2 py-1 text-xs text-accent-400"
rows={10} >
className="input min-h-[200px] resize-y font-mono text-sm" {variable}
/> </code>
))}
</div> </div>
</div>
{/* Email variables hint */} </div>
<div className="rounded-lg border border-dark-700 bg-dark-800/50 p-4"> )}
<p className="mb-2 text-sm font-medium text-dark-300">
{t('admin.broadcasts.emailVariables')}
</p>
<div className="flex flex-wrap gap-2">
{['{{user_name}}', '{{email}}', '{{user_id}}'].map((variable) => (
<code
key={variable}
className="rounded bg-dark-700 px-2 py-1 text-xs text-accent-400"
>
{variable}
</code>
))}
</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>