Add Telegram bot notifications for managers in settings
In Settings → Notifications, new «Telegram-уведомления» section: - Bot token input (from @BotFather) - Step-by-step instructions: manager sends /start to bot, gets Chat ID, admin adds it - Copyable bot invite link - Event checkboxes: new booking, cancellation, check-in, check-out, channel error, payment - Manager list with avatar initials, name, Chat ID, delete button - Add manager form: name + Chat ID input - Test message button (shown when at least one manager is added) - Full section only visible after token is entered Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare, Users, Plus, X as XIcon } from 'lucide-react'
|
import { Save, Building2, Bell, Shield, Globe, CreditCard, BedDouble, Mail, MessageSquare, Users, Plus, X as XIcon, Send, CheckCircle2, Copy } from 'lucide-react'
|
||||||
import { MOCK_HOTELS } from '../data/mockData'
|
import { MOCK_HOTELS } from '../data/mockData'
|
||||||
import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils'
|
import { cn, PLAN_COLORS, PLAN_LABELS } from '../lib/utils'
|
||||||
import { Badge } from '../components/ui/Badge'
|
import { Badge } from '../components/ui/Badge'
|
||||||
@@ -80,6 +80,42 @@ export function SettingsPage() {
|
|||||||
const [smsApiKey, setSmsApiKey] = useState('')
|
const [smsApiKey, setSmsApiKey] = useState('')
|
||||||
const [smsSender, setSmsSender] = useState('')
|
const [smsSender, setSmsSender] = useState('')
|
||||||
|
|
||||||
|
// Telegram settings
|
||||||
|
const [tgBotToken, setTgBotToken] = useState('')
|
||||||
|
const [tgManagers, setTgManagers] = useState<{ id: string; name: string; chatId: string }[]>([])
|
||||||
|
const [newTgName, setNewTgName] = useState('')
|
||||||
|
const [newTgChatId, setNewTgChatId] = useState('')
|
||||||
|
const [tgEvents, setTgEvents] = useState({
|
||||||
|
newBooking: true,
|
||||||
|
cancellation: true,
|
||||||
|
checkIn: true,
|
||||||
|
checkOut: false,
|
||||||
|
channelError: true,
|
||||||
|
payment: false,
|
||||||
|
})
|
||||||
|
const [tgTestSent, setTgTestSent] = useState(false)
|
||||||
|
const [copiedToken, setCopiedToken] = useState(false)
|
||||||
|
|
||||||
|
const tgBotConfigured = tgBotToken.length > 10
|
||||||
|
|
||||||
|
const addTgManager = () => {
|
||||||
|
if (!newTgName.trim() || !newTgChatId.trim()) return
|
||||||
|
setTgManagers(prev => [...prev, { id: `tg-${Date.now()}`, name: newTgName.trim(), chatId: newTgChatId.trim() }])
|
||||||
|
setNewTgName('')
|
||||||
|
setNewTgChatId('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendTgTest = () => {
|
||||||
|
setTgTestSent(true)
|
||||||
|
setTimeout(() => setTgTestSent(false), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyBotLink = () => {
|
||||||
|
navigator.clipboard.writeText('https://t.me/HotelSyncBot?start=connect')
|
||||||
|
setCopiedToken(true)
|
||||||
|
setTimeout(() => setCopiedToken(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
@@ -436,6 +472,150 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Telegram */}
|
||||||
|
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Send size={15} className="text-sky-500" />
|
||||||
|
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Telegram-уведомления для менеджеров</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mb-3">
|
||||||
|
Менеджеры получают уведомления о бронированиях прямо в Telegram. Каждый менеджер должен написать боту команду <code className="bg-slate-100 dark:bg-slate-700 px-1 rounded">/start</code> и сообщить свой Chat ID.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* How to connect — step instructions */}
|
||||||
|
<div className="rounded-xl bg-sky-50 dark:bg-sky-900/20 border border-sky-200 dark:border-sky-800 p-3.5 mb-3 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-sky-700 dark:text-sky-300">Как подключить менеджера:</p>
|
||||||
|
<ol className="text-xs text-sky-700 dark:text-sky-300 space-y-1 list-decimal list-inside">
|
||||||
|
<li>Менеджер открывает Telegram и переходит в бота</li>
|
||||||
|
<li>Пишет <code className="bg-sky-100 dark:bg-sky-900 px-1 rounded">/start</code> — бот ответит его Chat ID</li>
|
||||||
|
<li>Менеджер сообщает Chat ID администратору — тот добавляет его ниже</li>
|
||||||
|
</ol>
|
||||||
|
<button
|
||||||
|
onClick={copyBotLink}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium text-sky-600 dark:text-sky-400 hover:underline"
|
||||||
|
>
|
||||||
|
{copiedToken ? <CheckCircle2 size={13} className="text-emerald-500" /> : <Copy size={13} />}
|
||||||
|
{copiedToken ? 'Скопировано!' : 'Скопировать ссылку на бота'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bot token */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||||
|
Токен бота
|
||||||
|
<span className="ml-1 text-slate-400 font-normal">(получить у @BotFather в Telegram)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input text-sm font-mono"
|
||||||
|
placeholder="123456789:AAExx..."
|
||||||
|
value={tgBotToken}
|
||||||
|
onChange={e => setTgBotToken(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events to notify */}
|
||||||
|
{tgBotConfigured && (
|
||||||
|
<>
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2">Уведомлять о событиях:</p>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{([
|
||||||
|
{ key: 'newBooking' as const, label: 'Новое бронирование' },
|
||||||
|
{ key: 'cancellation' as const, label: 'Отмена брони' },
|
||||||
|
{ key: 'checkIn' as const, label: 'Заезд гостя' },
|
||||||
|
{ key: 'checkOut' as const, label: 'Выезд гостя' },
|
||||||
|
{ key: 'channelError' as const, label: 'Ошибка канала' },
|
||||||
|
{ key: 'payment' as const, label: 'Платёж / оплата' },
|
||||||
|
]).map(ev => (
|
||||||
|
<label key={ev.key} className="flex items-center gap-2 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-brand-600"
|
||||||
|
checked={tgEvents[ev.key]}
|
||||||
|
onChange={() => setTgEvents(p => ({ ...p, [ev.key]: !p[ev.key] }))}
|
||||||
|
/>
|
||||||
|
<span className="text-slate-700 dark:text-slate-300">{ev.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manager list */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2">
|
||||||
|
Менеджеры — получатели уведомлений
|
||||||
|
{tgManagers.length > 0 && <span className="ml-1 text-slate-400">({tgManagers.length})</span>}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5 mb-2">
|
||||||
|
{tgManagers.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-400 py-2 text-center rounded-lg bg-slate-50 dark:bg-slate-700/30">
|
||||||
|
Менеджеры ещё не добавлены
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{tgManagers.map(m => (
|
||||||
|
<div key={m.id} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40 text-sm">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-sky-100 dark:bg-sky-900/50 flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-xs font-bold text-sky-600 dark:text-sky-400">{m.name[0]?.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{m.name}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 font-mono">Chat ID: {m.chatId}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setTgManagers(prev => prev.filter(x => x.id !== m.id))}
|
||||||
|
className="p-1 rounded text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||||
|
>
|
||||||
|
<XIcon size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add manager */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input text-sm flex-1"
|
||||||
|
placeholder="Имя менеджера"
|
||||||
|
value={newTgName}
|
||||||
|
onChange={e => setNewTgName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input text-sm w-32 font-mono"
|
||||||
|
placeholder="Chat ID"
|
||||||
|
value={newTgChatId}
|
||||||
|
onChange={e => setNewTgChatId(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') addTgManager() }}
|
||||||
|
/>
|
||||||
|
<button onClick={addTgManager} className="btn-secondary px-3">
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test button */}
|
||||||
|
{tgManagers.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={sendTgTest}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 text-sm font-medium px-3 py-2 rounded-lg border transition-colors',
|
||||||
|
tgTestSent
|
||||||
|
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-400'
|
||||||
|
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-sky-400 hover:text-sky-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tgTestSent
|
||||||
|
? <><CheckCircle2 size={14} /> Тестовое сообщение отправлено!</>
|
||||||
|
: <><Send size={14} /> Отправить тестовое сообщение</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* SMS */}
|
{/* SMS */}
|
||||||
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
|
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user