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", "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>