// SupportChat — root-level floating chat widget. // Listens for `support-chat-send` custom events from other pages so any component // can pre-send a message and open the panel (used by OrderDetail "Связаться по заказу"). // Dedup strategy: optimistic messages carry _optimistic; on every poll, drop optimistic // rows whose (direction, text) matches an incoming real row. const { useState: useSCState, useEffect: useSCEffect, useRef: useSCRef } = React; function nowHHMM() { return new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); } function SupportChat() { const [chatOpen, setChatOpen] = useSCState(false); const [messages, setMessages] = useSCState([]); const [input, setInput] = useSCState(''); const [unread, setUnread] = useSCState(0); const [sending, setSending] = useSCState(false); const msgEndRef = useSCRef(null); const lastIdRef = useSCRef(0); const lastReadIdRef = useSCRef(parseInt(localStorage.getItem('support_last_read_id') || '0', 10)); const pollRef = useSCRef(null); const chatOpenRef = useSCRef(false); chatOpenRef.current = chatOpen; const mergeIncoming = (msgs) => { setMessages(prev => { const incomingIds = new Set(msgs.map(m => m.id)); const filtered = prev.filter(m => { // Drop real messages already present in incoming batch (race-safe dedupe by id). if (!m._optimistic && incomingIds.has(m.id)) return false; // Drop optimistic messages whose (direction, text) matches an incoming real message. if (m._optimistic && msgs.some(r => r.direction === m.direction && r.text === m.text)) return false; return true; }); return [...filtered, ...msgs]; }); }; const loadMessages = async (since = 0) => { try { const msgs = await api.get('/api/support/messages?since_id=' + since); if (msgs.__unauthorized) return; if (msgs.length > 0) { mergeIncoming(msgs); lastIdRef.current = msgs[msgs.length - 1].id; if (!chatOpenRef.current) { const adminCount = msgs.filter(m => m.direction === 'admin' && m.id > lastReadIdRef.current).length; if (adminCount > 0) setUnread(u => u + adminCount); } } } catch (_) {} }; useSCEffect(() => { loadMessages(0); pollRef.current = setInterval(() => loadMessages(lastIdRef.current), 3000); return () => clearInterval(pollRef.current); }, []); useSCEffect(() => { if (chatOpen) { setUnread(0); lastReadIdRef.current = lastIdRef.current; localStorage.setItem('support_last_read_id', String(lastIdRef.current)); } }, [chatOpen]); useSCEffect(() => { if (msgEndRef.current) { msgEndRef.current.scrollTop = msgEndRef.current.scrollHeight; } }, [messages, chatOpen]); const sendMessageWithText = async (text) => { if (!text || sending) return; setSending(true); const optMsg = { id: `opt-${Date.now()}-${Math.random()}`, direction: 'user', text, created_at: nowHHMM(), _optimistic: true, }; setMessages(m => [...m, optMsg]); try { await api.post('/api/support/messages', { text }); const msgs = await api.get('/api/support/messages?since_id=' + lastIdRef.current); if (msgs && msgs.length > 0) { mergeIncoming(msgs); lastIdRef.current = msgs[msgs.length - 1].id; } } catch (_) { setMessages(m => m.filter(x => x.id !== optMsg.id)); } finally { setSending(false); } }; const sendMessage = () => { const text = input.trim(); if (!text) return; setInput(''); sendMessageWithText(text); }; // External trigger: window.dispatchEvent(new CustomEvent('support-chat-send', { detail: { text } })) useSCEffect(() => { const handler = (e) => { const text = e?.detail?.text; if (!text) return; setChatOpen(true); sendMessageWithText(text); }; window.addEventListener('support-chat-send', handler); return () => window.removeEventListener('support-chat-send', handler); }, []); return (
{chatOpen && (
ТП
Поддержка
● Онлайн
{messages.length === 0 && (
Напишите ваш вопрос — поддержка ответит в Telegram
)} {messages.map(m => (
{m.text}
{formatTime(m.created_at)}
))}
setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && sendMessage()} />
)} {!chatOpen && ( )}
); } Object.assign(window, { SupportChat });