mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
Merge pull request #167 from BEDOLAGA-DEV/feat/dual-channel-broadcasts
feat: dual-channel broadcasts (Telegram + Email)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -803,7 +803,12 @@
|
|||||||
"activity": "بر اساس فعالیت",
|
"activity": "بر اساس فعالیت",
|
||||||
"source": "بر اساس منبع",
|
"source": "بر اساس منبع",
|
||||||
"tariff": "بر اساس تعرفه"
|
"tariff": "بر اساس تعرفه"
|
||||||
}
|
},
|
||||||
|
"enableTelegram": "Telegram",
|
||||||
|
"enableEmail": "Email",
|
||||||
|
"sendingBoth": "۲ پیامرسانی ایجاد خواهد شد",
|
||||||
|
"bothCreated": "پیامرسانیها ایجاد شدند",
|
||||||
|
"atLeastOneChannel": "حداقل یک کانال را انتخاب کنید"
|
||||||
},
|
},
|
||||||
"payments": {
|
"payments": {
|
||||||
"title": "تأیید پرداخت",
|
"title": "تأیید پرداخت",
|
||||||
|
|||||||
@@ -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": "Настройки системы",
|
||||||
|
|||||||
@@ -855,7 +855,12 @@
|
|||||||
"activity": "按活动",
|
"activity": "按活动",
|
||||||
"source": "按来源",
|
"source": "按来源",
|
||||||
"tariff": "按套餐"
|
"tariff": "按套餐"
|
||||||
}
|
},
|
||||||
|
"enableTelegram": "Telegram",
|
||||||
|
"enableEmail": "Email",
|
||||||
|
"sendingBoth": "将创建2个群发",
|
||||||
|
"bothCreated": "群发已创建",
|
||||||
|
"atLeastOneChannel": "请至少选择一个渠道"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "系统设置",
|
"title": "系统设置",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user