import { useState, useEffect, useRef, useCallback } from 'react' import { MessageSquare, X, ChevronLeft, Send, Users, Loader2, Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, Pin, } from 'lucide-react' import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatReaction } from '../../lib/api' import type { User } from '../../types' import { useAuth } from '../../contexts/AuthContext' import { cn } from '../../lib/utils' // ── Helpers ──────────────────────────────────────────────────────────────── function avatarColor(name: string) { const colors = ['#4F46E5','#059669','#2563EB','#7C3AED','#DC2626','#D97706','#DB2777','#0891B2'] let h = 0 for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % colors.length return colors[h] } function initials(name: string) { return name.split(' ').map(p => p[0]).join('').toUpperCase().slice(0, 2) } function fmtTime(iso: string) { const d = new Date(iso), now = new Date() if (now.toDateString() === d.toDateString()) return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } function Avatar({ name, size = 28, online = false }: { name: string; size?: number; online?: boolean }) { return (
{initials(name)}
{online && ( )}
) } function SystemAvatar({ size = 28 }: { size?: number }) { return (
) } // ── Sound ────────────────────────────────────────────────────────────────── function playNotifSound() { try { const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext const ctx = new AudioCtx() const osc = ctx.createOscillator(), gain = ctx.createGain() osc.connect(gain); gain.connect(ctx.destination) osc.frequency.value = 880 gain.gain.setValueAtTime(0.18, ctx.currentTime) gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25) osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.25) setTimeout(() => ctx.close(), 600) } catch { /* ignore */ } } // ── Persist ──────────────────────────────────────────────────────────────── interface ChatSettings { notifVisible: boolean; soundEnabled: boolean } const SETTINGS_KEY = 'hotelsync-chat-settings' const PINS_KEY = 'hotelsync-chat-pins' function loadSettings(): ChatSettings { try { const s = localStorage.getItem(SETTINGS_KEY) if (s) return { notifVisible: true, soundEnabled: false, ...JSON.parse(s) as Partial } } catch { /**/ } return { notifVisible: true, soundEnabled: false } } function saveSettings(s: ChatSettings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) } function loadPins(): string[] { try { return JSON.parse(localStorage.getItem(PINS_KEY) ?? '[]') as string[] } catch { return [] } } function savePins(ids: string[]) { localStorage.setItem(PINS_KEY, JSON.stringify(ids)) } // ── Context menu ─────────────────────────────────────────────────────────── type CtxMenu = | { type: 'message'; x: number; y: number; msg: ChatMessage } | { type: 'room'; x: number; y: number; room: ChatRoom } const EMOJIS = ['👍','❤️','😂','😮','😢','🔥','👏','✅'] // ── Main ─────────────────────────────────────────────────────────────────── type View = 'rooms' | 'messages' | 'search' | 'settings' export function ChatWidget() { const { user } = useAuth() const slug = user?.hotelSlug ?? '' const [open, setOpen] = useState(false) const [view, setView] = useState('rooms') const [rooms, setRooms] = useState([]) const [activeRoom, setActiveRoom] = useState(null) const [messages, setMessages] = useState([]) const [text, setText] = useState('') const [loadingRooms, setLoadingRooms] = useState(false) const [loadingMsgs, setLoadingMsgs] = useState(false) const [sending, setSending] = useState(false) const [attachment, setAttachment] = useState(null) const [attachPreview, setAttachPreview] = useState(null) // Edit const [editingMsgId, setEditingMsgId] = useState(null) const [editText, setEditText] = useState('') // Context menu const [ctxMenu, setCtxMenu] = useState(null) // Typing & presence const [typingNames, setTypingNames] = useState([]) const [onlineIds, setOnlineIds] = useState([]) // Search const [searchQuery, setSearchQuery] = useState('') const [searchUsers, setSearchUsers] = useState([]) const [searchResults, setSearchResults] = useState([]) const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people') const [loadingSearch, setLoadingSearch] = useState(false) const [allUsers, setAllUsers] = useState([]) // Settings + pins const [settings, setSettings] = useState(loadSettings) const [pinnedIds, setPinnedIds] = useState(loadPins) const messagesEndRef = useRef(null) const pollRef = useRef | null>(null) const searchInputRef = useRef(null) const fileInputRef = useRef(null) const prevUnreadRef = useRef(0) const prevMsgCountRef = useRef(0) const typingTimerRef = useRef | null>(null) const isTypingRef = useRef(false) const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) const visibleRooms = [...rooms] .filter(r => !(r.type === 'notifications' && !settings.notifVisible)) .sort((a, b) => (pinnedIds.includes(a.id) ? 0 : 1) - (pinnedIds.includes(b.id) ? 0 : 1)) // ── Effects ─────────────────────────────────────────────────────────────── const loadRooms = useCallback(async () => { if (!slug) return try { const data = await api.chat.listRooms(slug) setRooms(data) if (settings.soundEnabled && !open) { const n = data.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) if (n > prevUnreadRef.current) playNotifSound() prevUnreadRef.current = n } } catch { /**/ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [slug, settings.soundEnabled, open]) // Poll rooms useEffect(() => { if (!open || !slug) return setLoadingRooms(true) loadRooms().finally(() => setLoadingRooms(false)) pollRef.current = setInterval(loadRooms, 5000) return () => { if (pollRef.current) clearInterval(pollRef.current) } }, [open, slug, loadRooms]) // Presence heartbeat useEffect(() => { if (!open || !slug) return api.chat.setPresence(slug).catch(() => {/**/}) api.chat.getPresence(slug).then(setOnlineIds).catch(() => {/**/}) const t = setInterval(async () => { api.chat.setPresence(slug).catch(() => {/**/}) api.chat.getPresence(slug).then(setOnlineIds).catch(() => {/**/}) }, 30_000) return () => clearInterval(t) }, [open, slug]) // Auto-scroll useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) // Poll messages + typing useEffect(() => { if (view !== 'messages' || !activeRoom) return const interval = setInterval(async () => { try { const [msgs, typing] = await Promise.all([ api.chat.getMessages(slug, activeRoom.id), api.chat.getTyping(slug, activeRoom.id), ]) if (settings.soundEnabled && msgs.length > prevMsgCountRef.current && prevMsgCountRef.current > 0) { const last = msgs[msgs.length - 1] if (last.senderId !== user?.id) playNotifSound() } prevMsgCountRef.current = msgs.length setMessages(msgs) setTypingNames(typing) } catch { /**/ } }, 2000) return () => clearInterval(interval) }, [view, activeRoom, slug, settings.soundEnabled, user?.id]) // Clear typing names when leaving room useEffect(() => { if (view !== 'messages') setTypingNames([]) }, [view]) // Search users useEffect(() => { if (view !== 'search' || !slug) return api.users.list(slug).then(setAllUsers).catch(() => {/**/}) }, [view, slug]) // Search effect useEffect(() => { if (view !== 'search') return const q = searchQuery.trim() if (!q) { setSearchUsers([]); setSearchResults([]); return } const ql = q.toLowerCase() setSearchUsers(allUsers.filter(u => u.id !== user?.id && (u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql)))) const timer = setTimeout(async () => { if (searchTab !== 'messages') return setLoadingSearch(true) try { setSearchResults(await api.chat.search(slug, q)) } catch { /**/ } finally { setLoadingSearch(false) } }, 400) return () => clearTimeout(timer) }, [searchQuery, view, allUsers, user?.id, slug, searchTab]) useEffect(() => { if (view === 'search') setTimeout(() => searchInputRef.current?.focus(), 50) }, [view]) // Close ctx menu on outside click useEffect(() => { if (!ctxMenu) return const h = () => setCtxMenu(null) window.addEventListener('click', h) return () => window.removeEventListener('click', h) }, [ctxMenu]) // ── Typing helpers ──────────────────────────────────────────────────────── const sendTyping = useCallback((typing: boolean) => { if (!activeRoom || !slug) return if (typing === isTypingRef.current) return isTypingRef.current = typing api.chat.setTyping(slug, activeRoom.id, typing).catch(() => {/**/}) }, [activeRoom, slug]) const handleTextChange = (val: string) => { setText(val) if (!val.trim()) { sendTyping(false); return } sendTyping(true) if (typingTimerRef.current) clearTimeout(typingTimerRef.current) typingTimerRef.current = setTimeout(() => sendTyping(false), 4000) } // ── Actions ─────────────────────────────────────────────────────────────── const openRoom = async (room: ChatRoom) => { setActiveRoom(room); setView('messages'); setLoadingMsgs(true) prevMsgCountRef.current = 0; isTypingRef.current = false try { const msgs = await api.chat.getMessages(slug, room.id) prevMsgCountRef.current = msgs.length setMessages(msgs) await api.chat.markRead(slug, room.id) setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r)) } catch { /**/ } finally { setLoadingMsgs(false) } } const openDirect = async (targetUser: User) => { try { const { roomId } = await api.chat.openDirect(slug, targetUser.id) await loadRooms() await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserId: targetUser.id, otherUserLastRead: null }) setSearchQuery('') } catch { /**/ } } const openSearchResult = async (result: ChatSearchResult) => { const room = rooms.find(r => r.id === result.roomId) if (room) { setSearchQuery(''); setView('rooms'); await openRoom(room) } } const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return setAttachment(file) const reader = new FileReader() reader.onload = ev => setAttachPreview(ev.target?.result as string) reader.readAsDataURL(file); e.target.value = '' } const sendMessage = async () => { if ((!text.trim() && !attachment) || !activeRoom || sending) return const t = text.trim(), file = attachment setText(''); setAttachment(null); setAttachPreview(null); setSending(true) sendTyping(false) try { let url: string | undefined if (file) { url = (await api.chat.uploadImage(file)).url } const msg = await api.chat.sendMessage(slug, activeRoom.id, t, url) setMessages(prev => { prevMsgCountRef.current = prev.length + 1; return [...prev, msg] }) } catch { setText(t) if (file) { setAttachment(file); setAttachPreview(attachPreview) } } finally { setSending(false) } } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (text.trim() || attachment) void sendMessage() } } const startEdit = (msg: ChatMessage) => { setEditingMsgId(msg.id); setEditText(msg.text); setCtxMenu(null) } const saveEdit = async (msg: ChatMessage) => { if (!editText.trim() || !activeRoom) return try { const updated = await api.chat.editMessage(slug, activeRoom.id, msg.id, editText) setMessages(prev => prev.map(m => m.id === msg.id ? updated : m)) } catch { /**/ } setEditingMsgId(null) } const deleteMsg = async (msg: ChatMessage) => { if (!activeRoom) return; setCtxMenu(null) try { await api.chat.deleteMessage(slug, activeRoom.id, msg.id) setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, deletedAt: new Date().toISOString(), text: '', attachmentUrl: null } : m)) } catch { /**/ } } const toggleReaction = async (msg: ChatMessage, emoji: string) => { if (!activeRoom) return; setCtxMenu(null) try { const updated = await api.chat.toggleReaction(slug, activeRoom.id, msg.id, emoji) setMessages(prev => prev.map(m => m.id === msg.id ? updated : m)) } catch { /**/ } } const togglePin = (roomId: string) => { setCtxMenu(null) const next = pinnedIds.includes(roomId) ? pinnedIds.filter(id => id !== roomId) : [...pinnedIds, roomId] setPinnedIds(next); savePins(next) } const updateSettings = (patch: Partial) => { const next = { ...settings, ...patch }; setSettings(next); saveSettings(next) } const goBack = () => { if (view === 'messages') { sendTyping(false) setView('rooms'); setActiveRoom(null); setEditingMsgId(null); setTypingNames([]) } else if (view === 'search') { setView('rooms'); setSearchQuery('') } else if (view === 'settings') setView('rooms') } const roomDisplayName = (room: ChatRoom) => room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName ?? 'Чат' const isOtherOnline = (room: ChatRoom) => room.type === 'direct' && room.otherUserId ? onlineIds.includes(room.otherUserId) : false // Read receipt: is message read by other side? const isRead = (msg: ChatMessage) => { if (!activeRoom || activeRoom.type !== 'direct' || !activeRoom.otherUserLastRead) return false return new Date(msg.createdAt) <= new Date(activeRoom.otherUserLastRead) } if (!slug) return null // ── Render ──────────────────────────────────────────────────────────────── return ( <> {/* Float button */} {/* Panel */} {open && (
{/* Header */}
{view !== 'rooms' && ( )}

{view === 'rooms' && 'Чат сотрудников'} {view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')} {view === 'search' && 'Поиск'} {view === 'settings' && 'Настройки чата'}

{/* Online dot in messages header */} {view === 'messages' && activeRoom && isOtherOnline(activeRoom) && ( )}
{view === 'rooms' && visibleRooms.length > 0 &&

{visibleRooms.length} чатов

} {view === 'messages' && activeRoom?.type === 'direct' && isOtherOnline(activeRoom) && (

в сети

)} {view === 'messages' && activeRoom?.type === 'notifications' && (

Только чтение

)}
{view === 'rooms' && (
)}
{/* ── Rooms ── */} {view === 'rooms' && (
{loadingRooms && visibleRooms.length === 0 ? ( <> } bg="bg-brand-100 dark:bg-brand-900/30" label="Общий чат" /> {settings.notifVisible && } bg="bg-amber-100 dark:bg-amber-900/30" label="Уведомления" />} ) : ( visibleRooms.map(room => ( openRoom(room)} onMenuClick={e => { e.stopPropagation(); setCtxMenu({ type: 'room', x: e.clientX, y: e.clientY, room }) }} /> )) )}
)} {/* ── Search ── */} {view === 'search' && (
setSearchQuery(e.target.value)} placeholder="Люди или сообщения..." className="flex-1 bg-transparent text-sm text-slate-800 dark:text-slate-200 placeholder-slate-400 outline-none" /> {searchQuery && }
{(['people', 'messages'] as const).map(tab => ( ))}
{searchTab === 'people' && ( !searchQuery ? allUsers.length === 0 ?
Загрузка...
: allUsers.filter(u => u.id !== user?.id).map(u => ( openDirect(u)} /> )) : searchUsers.length === 0 ?
Никого не найдено
: searchUsers.map(u => openDirect(u)} />) )} {searchTab === 'messages' && ( !searchQuery ?
Введите запрос для поиска
: loadingSearch ?
: searchResults.length === 0 ?
Ничего не найдено
: searchResults.map(r => ( )) )}
)} {/* ── Settings ── */} {view === 'settings' && (
: } label="Показывать канал уведомлений" checked={settings.notifVisible} onChange={v => updateSettings({ notifVisible: v })} /> : } label="Звуковое уведомление" checked={settings.soundEnabled} onChange={v => updateSettings({ soundEnabled: v })} />

Правый клик на сообщении — реакции, редактирование, удаление.
Кнопка ⋮ на чате — закрепить / открепить сверху.

)} {/* ── Messages ── */} {view === 'messages' && ( <>
{loadingMsgs ? (
) : messages.length === 0 ? (
{activeRoom?.type === 'notifications' ? 'Уведомлений пока нет' : 'Нет сообщений. Напишите первым!'}
) : ( messages.map(msg => ( { if (msg.deletedAt || msg.isSystem || activeRoom?.type === 'notifications') return e.preventDefault() setCtxMenu({ type: 'message', x: e.clientX, y: e.clientY, msg }) }} onEditTextChange={setEditText} onSaveEdit={() => saveEdit(msg)} onCancelEdit={() => setEditingMsgId(null)} onReaction={emoji => toggleReaction(msg, emoji)} /> )) )} {/* Typing indicator */} {typingNames.length > 0 && (
{typingNames.length === 1 ? `${typingNames[0].split(' ')[0]} печатает...` : `${typingNames.map(n => n.split(' ')[0]).join(', ')} печатают...`}
)}
{activeRoom?.type !== 'notifications' && (
{attachPreview && (
превью
)}