191 lines
7.4 KiB
TypeScript
191 lines
7.4 KiB
TypeScript
|
|
import { useState, useEffect } from 'react'
|
|||
|
|
import { adminApi } from '@/api'
|
|||
|
|
import type { AdminMarathon } from '@/types'
|
|||
|
|
import { useToast } from '@/store/toast'
|
|||
|
|
import { NeonButton } from '@/components/ui'
|
|||
|
|
import { Send, Users, Trophy, AlertTriangle } from 'lucide-react'
|
|||
|
|
|
|||
|
|
export function AdminBroadcastPage() {
|
|||
|
|
const [message, setMessage] = useState('')
|
|||
|
|
const [targetType, setTargetType] = useState<'all' | 'marathon'>('all')
|
|||
|
|
const [marathonId, setMarathonId] = useState<number | null>(null)
|
|||
|
|
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
|||
|
|
const [sending, setSending] = useState(false)
|
|||
|
|
const [loadingMarathons, setLoadingMarathons] = useState(false)
|
|||
|
|
|
|||
|
|
const toast = useToast()
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (targetType === 'marathon') {
|
|||
|
|
loadMarathons()
|
|||
|
|
}
|
|||
|
|
}, [targetType])
|
|||
|
|
|
|||
|
|
const loadMarathons = async () => {
|
|||
|
|
setLoadingMarathons(true)
|
|||
|
|
try {
|
|||
|
|
const data = await adminApi.listMarathons(0, 100)
|
|||
|
|
setMarathons(data.filter(m => m.status === 'active'))
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Failed to load marathons:', err)
|
|||
|
|
} finally {
|
|||
|
|
setLoadingMarathons(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleSend = async () => {
|
|||
|
|
if (!message.trim()) {
|
|||
|
|
toast.error('Введите сообщение')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (targetType === 'marathon' && !marathonId) {
|
|||
|
|
toast.error('Выберите марафон')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setSending(true)
|
|||
|
|
try {
|
|||
|
|
let result
|
|||
|
|
if (targetType === 'all') {
|
|||
|
|
result = await adminApi.broadcastToAll(message)
|
|||
|
|
} else {
|
|||
|
|
result = await adminApi.broadcastToMarathon(marathonId!, message)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
|
|||
|
|
setMessage('')
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Failed to send broadcast:', err)
|
|||
|
|
toast.error('Ошибка отправки')
|
|||
|
|
} finally {
|
|||
|
|
setSending(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
{/* Header */}
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<div className="p-2 rounded-lg bg-pink-500/20 border border-pink-500/30">
|
|||
|
|
<Send className="w-6 h-6 text-pink-400" />
|
|||
|
|
</div>
|
|||
|
|
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="max-w-2xl space-y-6">
|
|||
|
|
{/* Target Selection */}
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<label className="block text-sm font-medium text-gray-300">
|
|||
|
|
Кому отправить
|
|||
|
|
</label>
|
|||
|
|
<div className="flex gap-4">
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
setTargetType('all')
|
|||
|
|
setMarathonId(null)
|
|||
|
|
}}
|
|||
|
|
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
|||
|
|
targetType === 'all'
|
|||
|
|
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
|||
|
|
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<Users className="w-5 h-5" />
|
|||
|
|
<span className="font-medium">Всем пользователям</span>
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setTargetType('marathon')}
|
|||
|
|
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
|||
|
|
targetType === 'marathon'
|
|||
|
|
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
|||
|
|
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<Trophy className="w-5 h-5" />
|
|||
|
|
<span className="font-medium">Участникам марафона</span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Marathon Selection */}
|
|||
|
|
{targetType === 'marathon' && (
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<label className="block text-sm font-medium text-gray-300">
|
|||
|
|
Выберите марафон
|
|||
|
|
</label>
|
|||
|
|
{loadingMarathons ? (
|
|||
|
|
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" />
|
|||
|
|
) : (
|
|||
|
|
<select
|
|||
|
|
value={marathonId || ''}
|
|||
|
|
onChange={(e) => setMarathonId(Number(e.target.value) || null)}
|
|||
|
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
|||
|
|
>
|
|||
|
|
<option value="">Выберите марафон...</option>
|
|||
|
|
{marathons.map((m) => (
|
|||
|
|
<option key={m.id} value={m.id}>
|
|||
|
|
{m.title} ({m.participants_count} участников)
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
)}
|
|||
|
|
{marathons.length === 0 && !loadingMarathons && (
|
|||
|
|
<p className="text-sm text-gray-500">Нет активных марафонов</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Message */}
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<label className="block text-sm font-medium text-gray-300">
|
|||
|
|
Сообщение
|
|||
|
|
</label>
|
|||
|
|
<textarea
|
|||
|
|
value={message}
|
|||
|
|
onChange={(e) => setMessage(e.target.value)}
|
|||
|
|
rows={6}
|
|||
|
|
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)"
|
|||
|
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
|
|||
|
|
/>
|
|||
|
|
<div className="flex items-center justify-between text-xs">
|
|||
|
|
<p className="text-gray-500">
|
|||
|
|
Поддерживается HTML: <b>, <i>, <code>, <a href>
|
|||
|
|
</p>
|
|||
|
|
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
|
|||
|
|
{message.length} / 2000
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Send Button */}
|
|||
|
|
<NeonButton
|
|||
|
|
size="lg"
|
|||
|
|
color="purple"
|
|||
|
|
onClick={handleSend}
|
|||
|
|
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
|
|||
|
|
isLoading={sending}
|
|||
|
|
icon={<Send className="w-5 h-5" />}
|
|||
|
|
className="w-full"
|
|||
|
|
>
|
|||
|
|
{sending ? 'Отправка...' : 'Отправить рассылку'}
|
|||
|
|
</NeonButton>
|
|||
|
|
|
|||
|
|
{/* Warning */}
|
|||
|
|
<div className="glass rounded-xl p-4 border border-amber-500/20">
|
|||
|
|
<div className="flex items-start gap-3">
|
|||
|
|
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
|||
|
|
<div>
|
|||
|
|
<p className="text-sm text-amber-400 font-medium mb-1">Обратите внимание</p>
|
|||
|
|
<p className="text-sm text-gray-400">
|
|||
|
|
Сообщение будет отправлено только пользователям с привязанным Telegram.
|
|||
|
|
Рассылка ограничена: 1 сообщение всем в минуту, 3 сообщения марафону в минуту.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|