mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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:
@@ -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 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 === telegramTarget) ?? null;
|
||||
}, [telegramTarget, filtersData]);
|
||||
|
||||
// Selected filter info
|
||||
const selectedFilter = useMemo(() => {
|
||||
if (!target) return null;
|
||||
const selectedEmailFilter = useMemo(() => {
|
||||
if (!emailTarget || !emailFiltersData) return null;
|
||||
return emailFiltersData.filters.find((f) => f.key === emailTarget) ?? null;
|
||||
}, [emailTarget, emailFiltersData]);
|
||||
|
||||
if (channel === 'telegram' && filtersData) {
|
||||
const all = [
|
||||
...filtersData.filters,
|
||||
...filtersData.tariff_filters,
|
||||
...filtersData.custom_filters,
|
||||
];
|
||||
return all.find((f) => f.key === target);
|
||||
}
|
||||
// Handle toggling channels
|
||||
const handleToggleTelegram = () => {
|
||||
setTelegramEnabled((prev) => !prev);
|
||||
setTelegramTarget('');
|
||||
telegramPreviewMutation.reset();
|
||||
};
|
||||
|
||||
if (channel === 'email' && emailFiltersData) {
|
||||
return emailFiltersData.filters.find((f) => f.key === target);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [target, channel, filtersData, emailFiltersData]);
|
||||
|
||||
// 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 {
|
||||
emailPreviewMutation.mutate(filterKey);
|
||||
}
|
||||
const handleEmailFilterSelect = (filterKey: string) => {
|
||||
setEmailTarget(filterKey);
|
||||
setShowEmailFilters(false);
|
||||
emailPreviewMutation.mutate(filterKey);
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
@@ -308,53 +311,163 @@ 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;
|
||||
|
||||
const data: CombinedBroadcastCreateRequest = {
|
||||
channel,
|
||||
target,
|
||||
};
|
||||
|
||||
if (channel === 'telegram') {
|
||||
data.message_text = messageText;
|
||||
data.selected_buttons = selectedButtons;
|
||||
|
||||
// Single channel — use existing createMutation with navigation to detail
|
||||
if (telegramEnabled && !emailEnabled) {
|
||||
const data: CombinedBroadcastCreateRequest = {
|
||||
channel: 'telegram',
|
||||
target: telegramTarget,
|
||||
message_text: messageText,
|
||||
selected_buttons: selectedButtons,
|
||||
};
|
||||
if (uploadedFileId) {
|
||||
data.media = {
|
||||
type: mediaType,
|
||||
file_id: uploadedFileId,
|
||||
};
|
||||
data.media = { type: mediaType, file_id: uploadedFileId };
|
||||
}
|
||||
createMutation.mutate(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel === 'email') {
|
||||
data.email_subject = emailSubject;
|
||||
data.email_html_content = emailContent;
|
||||
if (emailEnabled && !telegramEnabled) {
|
||||
const data: CombinedBroadcastCreateRequest = {
|
||||
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 =
|
||||
channel === 'telegram'
|
||||
? (previewMutation.data?.count ?? selectedFilter?.count ?? null)
|
||||
: (emailPreviewMutation.data?.count ?? selectedFilter?.count ?? null);
|
||||
// Recipients counts per channel
|
||||
const telegramRecipientsCount = telegramEnabled
|
||||
? (telegramPreviewMutation.data?.count ?? selectedTelegramFilter?.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 (
|
||||
<div className="space-y-6">
|
||||
@@ -372,273 +485,250 @@ export default function AdminBroadcastCreate() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel selection */}
|
||||
{/* 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={() => handleChannelChange('telegram')}
|
||||
onClick={handleToggleTelegram}
|
||||
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-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
|
||||
}`}
|
||||
>
|
||||
<TelegramIcon />
|
||||
<span className="font-medium">{t('admin.broadcasts.channel.telegram')}</span>
|
||||
<span className="font-medium">{t('admin.broadcasts.enableTelegram')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleChannelChange('email')}
|
||||
onClick={handleToggleEmail}
|
||||
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-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
|
||||
}`}
|
||||
>
|
||||
<EmailIcon />
|
||||
<span className="font-medium">{t('admin.broadcasts.channel.email')}</span>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
{/* Telegram section */}
|
||||
{telegramEnabled && (
|
||||
<div className="card space-y-6">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.broadcasts.telegramSection')}
|
||||
</h2>
|
||||
|
||||
{/* Filter selection */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{channel === '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
|
||||
: channel === '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>
|
||||
{/* Telegram filter selection */}
|
||||
{renderFilterDropdown(
|
||||
'telegram',
|
||||
telegramTarget,
|
||||
selectedTelegramFilter,
|
||||
telegramRecipientsCount,
|
||||
showTelegramFilters,
|
||||
setShowTelegramFilters,
|
||||
handleTelegramFilterSelect,
|
||||
groupedTelegramFilters,
|
||||
filtersLoading,
|
||||
)}
|
||||
|
||||
{/* Message text */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{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 */}
|
||||
<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>
|
||||
<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">
|
||||
{isFiltersLoading ? (
|
||||
<div className="p-4 text-center text-dark-400">{t('common.loading')}</div>
|
||||
) : (
|
||||
Object.entries(currentFilters).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>
|
||||
<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 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Telegram-specific fields */}
|
||||
{channel === 'telegram' && (
|
||||
<>
|
||||
{/* Message text */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{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>
|
||||
{/* Email section */}
|
||||
{emailEnabled && (
|
||||
<div className="card space-y-6">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.broadcasts.emailSection')}
|
||||
</h2>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
{/* Email filter selection */}
|
||||
{renderFilterDropdown(
|
||||
'email',
|
||||
emailTarget,
|
||||
selectedEmailFilter,
|
||||
emailRecipientsCount,
|
||||
showEmailFilters,
|
||||
setShowEmailFilters,
|
||||
handleEmailFilterSelect,
|
||||
groupedEmailFilters,
|
||||
emailFiltersLoading,
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
)}
|
||||
{/* Email subject */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.emailSubject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={emailSubject}
|
||||
onChange={(e) => setEmailSubject(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.emailSubjectPlaceholder')}
|
||||
className="input"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email-specific fields */}
|
||||
{channel === 'email' && (
|
||||
<>
|
||||
{/* Email subject */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.emailSubject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={emailSubject}
|
||||
onChange={(e) => setEmailSubject(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.emailSubjectPlaceholder')}
|
||||
className="input"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
{/* Email content */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.emailContent')}
|
||||
</label>
|
||||
<p className="mb-2 text-xs text-dark-400">{t('admin.broadcasts.emailContentHint')}</p>
|
||||
<textarea
|
||||
value={emailContent}
|
||||
onChange={(e) => setEmailContent(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.emailContentPlaceholder')}
|
||||
rows={10}
|
||||
className="input min-h-[200px] resize-y font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email content */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.emailContent')}
|
||||
</label>
|
||||
<p className="mb-2 text-xs text-dark-400">{t('admin.broadcasts.emailContentHint')}</p>
|
||||
<textarea
|
||||
value={emailContent}
|
||||
onChange={(e) => setEmailContent(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.emailContentPlaceholder')}
|
||||
rows={10}
|
||||
className="input min-h-[200px] resize-y font-mono text-sm"
|
||||
/>
|
||||
{/* Email variables hint */}
|
||||
<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>
|
||||
|
||||
{/* Email variables hint */}
|
||||
<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>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user