mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Fixed issue where users had to click the Telegram Mini App back button twice in many admin pages. The replace flag was causing conflicts between Telegram's BackButton internal history and React Router's history management. Updated all 32 admin pages to use simple navigate() calls without replace flag.
665 lines
25 KiB
TypeScript
665 lines
25 KiB
TypeScript
import { useState, useRef, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
adminBroadcastsApi,
|
|
BroadcastFilter,
|
|
TariffFilter,
|
|
BroadcastChannel,
|
|
CombinedBroadcastCreateRequest,
|
|
} from '../api/adminBroadcasts';
|
|
import { AdminBackButton } from '../components/admin';
|
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
|
|
|
// Icons
|
|
const BroadcastIcon = () => (
|
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const XIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
);
|
|
|
|
const RefreshIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const PhotoIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const VideoIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const DocumentIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const UsersIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const ChevronDownIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
);
|
|
|
|
const TelegramIcon = () => (
|
|
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
|
</svg>
|
|
);
|
|
|
|
const EmailIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
// Filter labels
|
|
const FILTER_GROUP_LABEL_KEYS: Record<string, string> = {
|
|
basic: 'admin.broadcasts.filterGroups.basic',
|
|
subscription: 'admin.broadcasts.filterGroups.subscription',
|
|
traffic: 'admin.broadcasts.filterGroups.traffic',
|
|
registration: 'admin.broadcasts.filterGroups.registration',
|
|
activity: 'admin.broadcasts.filterGroups.activity',
|
|
source: 'admin.broadcasts.filterGroups.source',
|
|
tariff: 'admin.broadcasts.filterGroups.tariff',
|
|
email: 'admin.broadcasts.filterGroups.email',
|
|
};
|
|
|
|
export default function AdminBroadcastCreate() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useBackButton(() => navigate('/admin/broadcasts'));
|
|
|
|
// Channel selection
|
|
const [channel, setChannel] = useState<BroadcastChannel>('telegram');
|
|
|
|
// Common state
|
|
const [target, setTarget] = useState('');
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
|
|
// Telegram-specific state
|
|
const [messageText, setMessageText] = useState('');
|
|
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
|
|
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
|
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
|
|
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
|
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
// Email-specific state
|
|
const [emailSubject, setEmailSubject] = useState('');
|
|
const [emailContent, setEmailContent] = useState('');
|
|
|
|
// Fetch Telegram filters
|
|
const { data: filtersData, isLoading: filtersLoading } = useQuery({
|
|
queryKey: ['admin', 'broadcasts', 'filters'],
|
|
queryFn: adminBroadcastsApi.getFilters,
|
|
enabled: channel === 'telegram',
|
|
});
|
|
|
|
// Fetch Email filters
|
|
const { data: emailFiltersData, isLoading: emailFiltersLoading } = useQuery({
|
|
queryKey: ['admin', 'broadcasts', 'email-filters'],
|
|
queryFn: adminBroadcastsApi.getEmailFilters,
|
|
enabled: channel === 'email',
|
|
});
|
|
|
|
// Fetch buttons
|
|
const { data: buttonsData } = useQuery({
|
|
queryKey: ['admin', 'broadcasts', 'buttons'],
|
|
queryFn: adminBroadcastsApi.getButtons,
|
|
enabled: channel === 'telegram',
|
|
});
|
|
|
|
// Preview mutations
|
|
const previewMutation = useMutation({
|
|
mutationFn: adminBroadcastsApi.preview,
|
|
});
|
|
|
|
const emailPreviewMutation = useMutation({
|
|
mutationFn: adminBroadcastsApi.previewEmail,
|
|
});
|
|
|
|
// Create mutation
|
|
const createMutation = useMutation({
|
|
mutationFn: adminBroadcastsApi.createCombined,
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
|
|
navigate(`/admin/broadcasts/${data.id}`);
|
|
},
|
|
});
|
|
|
|
// Group Telegram filters
|
|
const groupedFilters = useMemo(() => {
|
|
if (!filtersData) return {};
|
|
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {};
|
|
|
|
filtersData.filters.forEach((f) => {
|
|
const group = f.group || 'basic';
|
|
if (!groups[group]) groups[group] = [];
|
|
groups[group].push(f);
|
|
});
|
|
|
|
if (filtersData.tariff_filters.length > 0) {
|
|
groups['tariff'] = filtersData.tariff_filters;
|
|
}
|
|
|
|
filtersData.custom_filters.forEach((f) => {
|
|
const group = f.group || 'custom';
|
|
if (!groups[group]) groups[group] = [];
|
|
groups[group].push(f);
|
|
});
|
|
|
|
return groups;
|
|
}, [filtersData]);
|
|
|
|
// Group Email filters
|
|
const groupedEmailFilters = useMemo(() => {
|
|
if (!emailFiltersData) return {};
|
|
const groups: Record<string, BroadcastFilter[]> = {};
|
|
|
|
emailFiltersData.filters.forEach((f) => {
|
|
const group = f.group || 'email';
|
|
if (!groups[group]) groups[group] = [];
|
|
groups[group].push(f);
|
|
});
|
|
|
|
return groups;
|
|
}, [emailFiltersData]);
|
|
|
|
// Current filters based on channel
|
|
const currentFilters = channel === 'telegram' ? groupedFilters : groupedEmailFilters;
|
|
const isFiltersLoading = channel === 'telegram' ? filtersLoading : emailFiltersLoading;
|
|
|
|
// Selected filter info
|
|
const selectedFilter = useMemo(() => {
|
|
if (!target) return null;
|
|
|
|
if (channel === 'telegram' && filtersData) {
|
|
const all = [
|
|
...filtersData.filters,
|
|
...filtersData.tariff_filters,
|
|
...filtersData.custom_filters,
|
|
];
|
|
return all.find((f) => f.key === target);
|
|
}
|
|
|
|
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();
|
|
emailPreviewMutation.reset();
|
|
};
|
|
|
|
// Handle filter selection
|
|
const handleFilterSelect = (filterKey: string) => {
|
|
setTarget(filterKey);
|
|
setShowFilters(false);
|
|
|
|
if (channel === 'telegram') {
|
|
previewMutation.mutate(filterKey);
|
|
} else {
|
|
emailPreviewMutation.mutate(filterKey);
|
|
}
|
|
};
|
|
|
|
// Handle file selection
|
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setMediaFile(file);
|
|
|
|
if (file.type.startsWith('image/')) {
|
|
setMediaType('photo');
|
|
setMediaPreview(URL.createObjectURL(file));
|
|
} else if (file.type.startsWith('video/')) {
|
|
setMediaType('video');
|
|
setMediaPreview(null);
|
|
} else {
|
|
setMediaType('document');
|
|
setMediaPreview(null);
|
|
}
|
|
|
|
setIsUploading(true);
|
|
try {
|
|
const result = await adminBroadcastsApi.uploadMedia(file, mediaType);
|
|
setUploadedFileId(result.file_id);
|
|
} catch (err) {
|
|
console.error('Upload failed:', err);
|
|
setMediaFile(null);
|
|
setMediaPreview(null);
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
};
|
|
|
|
// Remove media
|
|
const handleRemoveMedia = () => {
|
|
setMediaFile(null);
|
|
setMediaPreview(null);
|
|
setUploadedFileId(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
// Toggle button
|
|
const toggleButton = (key: string) => {
|
|
setSelectedButtons((prev) =>
|
|
prev.includes(key) ? prev.filter((b) => b !== key) : [...prev, key],
|
|
);
|
|
};
|
|
|
|
// Validate form
|
|
const isValid = useMemo(() => {
|
|
if (!target) return false;
|
|
|
|
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]);
|
|
|
|
// Submit
|
|
const handleSubmit = () => {
|
|
if (!isValid) return;
|
|
|
|
const data: CombinedBroadcastCreateRequest = {
|
|
channel,
|
|
target,
|
|
};
|
|
|
|
if (channel === 'telegram') {
|
|
data.message_text = messageText;
|
|
data.selected_buttons = selectedButtons;
|
|
|
|
if (uploadedFileId) {
|
|
data.media = {
|
|
type: mediaType,
|
|
file_id: uploadedFileId,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (channel === 'email') {
|
|
data.email_subject = emailSubject;
|
|
data.email_html_content = emailContent;
|
|
}
|
|
|
|
createMutation.mutate(data);
|
|
};
|
|
|
|
const recipientsCount =
|
|
channel === 'telegram'
|
|
? (previewMutation.data?.count ?? selectedFilter?.count ?? null)
|
|
: (emailPreviewMutation.data?.count ?? selectedFilter?.count ?? null);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3">
|
|
<AdminBackButton />
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
|
<BroadcastIcon />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-dark-100">{t('admin.broadcasts.create')}</h1>
|
|
<p className="text-sm text-dark-400">{t('admin.broadcasts.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Channel selection */}
|
|
<div className="card">
|
|
<label className="mb-3 block text-sm font-medium text-dark-300">
|
|
{t('admin.broadcasts.selectChannel')}
|
|
</label>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => handleChannelChange('telegram')}
|
|
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
|
|
channel === 'telegram'
|
|
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
|
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
|
|
}`}
|
|
>
|
|
<TelegramIcon />
|
|
<span className="font-medium">{t('admin.broadcasts.channel.telegram')}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => handleChannelChange('email')}
|
|
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
|
|
channel === 'email'
|
|
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
|
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
|
|
}`}
|
|
>
|
|
<EmailIcon />
|
|
<span className="font-medium">{t('admin.broadcasts.channel.email')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
)}
|
|
</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>
|
|
)}
|
|
</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>
|
|
|
|
{/* 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>
|
|
|
|
{/* 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-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 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>
|
|
|
|
{/* Footer */}
|
|
<div className="card flex items-center justify-between">
|
|
<div className="text-sm text-dark-400">
|
|
{recipientsCount !== null && (
|
|
<span>
|
|
{t('admin.broadcasts.willBeSent')}:{' '}
|
|
<strong className="text-accent-400">{recipientsCount}</strong>{' '}
|
|
{t('admin.broadcasts.users')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button onClick={() => navigate('/admin/broadcasts')} className="btn-secondary">
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={!isValid || createMutation.isPending || isUploading}
|
|
className="btn-primary flex items-center gap-2"
|
|
>
|
|
{createMutation.isPending ? <RefreshIcon /> : <BroadcastIcon />}
|
|
{t('admin.broadcasts.send')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|