feat: wire up loyalty + chat — register routes, API types, connect LoyaltyPage, add ChatWidget to layout
This commit is contained in:
294
src/components/chat/ChatWidget.tsx
Normal file
294
src/components/chat/ChatWidget.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { MessageSquare, X, ChevronLeft, Send, Users, Loader2 } from 'lucide-react'
|
||||
import { api, type ChatRoom, type ChatMessage } from '../../lib/api'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
// Color for avatar based on name
|
||||
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) {
|
||||
return new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function Avatar({ name, size = 28 }: { name: string; size?: number }) {
|
||||
return (
|
||||
<div style={{ width: size, height: size, background: avatarColor(name), fontSize: size * 0.38 }}
|
||||
className="rounded-full flex items-center justify-center text-white font-semibold shrink-0">
|
||||
{initials(name)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatWidget() {
|
||||
const { user } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<'rooms' | 'messages'>('rooms')
|
||||
const [rooms, setRooms] = useState<ChatRoom[]>([])
|
||||
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [text, setText] = useState('')
|
||||
const [loadingRooms, setLoadingRooms] = useState(false)
|
||||
const [loadingMsgs, setLoadingMsgs] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
|
||||
|
||||
const loadRooms = useCallback(async () => {
|
||||
if (!slug) return
|
||||
try {
|
||||
const data = await api.chat.listRooms(slug)
|
||||
setRooms(data)
|
||||
} catch { /* ignore */ }
|
||||
}, [slug])
|
||||
|
||||
// Poll for new messages every 5s when open
|
||||
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])
|
||||
|
||||
const openRoom = async (room: ChatRoom) => {
|
||||
setActiveRoom(room)
|
||||
setView('messages')
|
||||
setLoadingMsgs(true)
|
||||
try {
|
||||
const msgs = await api.chat.getMessages(slug, room.id)
|
||||
setMessages(msgs)
|
||||
await api.chat.markRead(slug, room.id)
|
||||
setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r))
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingMsgs(false) }
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Poll messages when in messages view
|
||||
useEffect(() => {
|
||||
if (view !== 'messages' || !activeRoom) return
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const msgs = await api.chat.getMessages(slug, activeRoom.id)
|
||||
setMessages(msgs)
|
||||
} catch { /* ignore */ }
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [view, activeRoom, slug])
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!text.trim() || !activeRoom || sending) return
|
||||
const t = text.trim()
|
||||
setText('')
|
||||
setSending(true)
|
||||
try {
|
||||
const msg = await api.chat.sendMessage(slug, activeRoom.id, t)
|
||||
setMessages(prev => [...prev, msg])
|
||||
} catch {
|
||||
setText(t)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
if (!slug) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating button */}
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className={cn(
|
||||
'fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-lg',
|
||||
'bg-brand-600 hover:bg-brand-700 text-white transition-all',
|
||||
'flex items-center justify-center',
|
||||
open && 'scale-90',
|
||||
)}
|
||||
>
|
||||
{open ? <X size={22} /> : <MessageSquare size={22} />}
|
||||
{!open && totalUnread > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{totalUnread > 9 ? '9+' : totalUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Chat panel */}
|
||||
{open && (
|
||||
<div className={cn(
|
||||
'fixed bottom-24 right-6 z-50',
|
||||
'w-80 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl',
|
||||
'border border-slate-200 dark:border-slate-700',
|
||||
'flex flex-col overflow-hidden',
|
||||
'transition-all',
|
||||
)} style={{ height: 480 }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-brand-600 text-white rounded-t-2xl shrink-0">
|
||||
{view === 'messages' && (
|
||||
<button onClick={() => { setView('rooms'); setActiveRoom(null) }} className="p-1 hover:bg-white/20 rounded-lg transition-colors">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm truncate">
|
||||
{view === 'rooms' ? 'Чат сотрудников' : (activeRoom?.type === 'general' ? 'Общий чат' : activeRoom?.otherUserName ?? 'Чат')}
|
||||
</p>
|
||||
{view === 'rooms' && (
|
||||
<p className="text-xs text-white/70">{rooms.length} чатов</p>
|
||||
)}
|
||||
</div>
|
||||
<Users size={16} className="opacity-70" />
|
||||
</div>
|
||||
|
||||
{/* Rooms list */}
|
||||
{view === 'rooms' && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loadingRooms ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 size={20} className="animate-spin text-slate-400" />
|
||||
</div>
|
||||
) : rooms.length === 0 ? (
|
||||
<div className="text-center py-10 text-sm text-slate-400 px-4">
|
||||
Нет чатов. Начните общение!
|
||||
</div>
|
||||
) : (
|
||||
rooms.map(room => (
|
||||
<button
|
||||
key={room.id}
|
||||
onClick={() => openRoom(room)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50 text-left"
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
{room.type === 'general' ? (
|
||||
<div className="w-9 h-9 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<Users size={16} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar name={room.otherUserName ?? '?'} size={36} />
|
||||
)}
|
||||
{Number(room.unreadCount) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{room.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className={cn('text-sm font-medium truncate', Number(room.unreadCount) > 0 ? 'text-slate-900 dark:text-slate-100' : 'text-slate-700 dark:text-slate-300')}>
|
||||
{room.type === 'general' ? 'Общий чат' : room.otherUserName}
|
||||
</p>
|
||||
{room.lastMessageAt && (
|
||||
<span className="text-[10px] text-slate-400 shrink-0 ml-1">
|
||||
{fmtTime(room.lastMessageAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{room.lastMessage && (
|
||||
<p className={cn('text-xs truncate mt-0.5', Number(room.unreadCount) > 0 ? 'text-slate-600 dark:text-slate-300 font-medium' : 'text-slate-400 dark:text-slate-500')}>
|
||||
{room.lastSender && room.type === 'general' ? `${room.lastSender.split(' ')[0]}: ` : ''}{room.lastMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages view */}
|
||||
{view === 'messages' && (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
|
||||
{loadingMsgs ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 size={20} className="animate-spin text-slate-400" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center py-10 text-sm text-slate-400">
|
||||
Нет сообщений. Напишите первым!
|
||||
</div>
|
||||
) : (
|
||||
messages.map(msg => {
|
||||
const isOwn = msg.senderId === user?.id
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex gap-2', isOwn && 'flex-row-reverse')}>
|
||||
{!isOwn && <Avatar name={msg.senderName} size={24} />}
|
||||
<div className={cn('max-w-[75%]', isOwn && 'items-end flex flex-col')}>
|
||||
{!isOwn && (
|
||||
<p className="text-[10px] text-slate-400 mb-0.5 ml-1">{msg.senderName.split(' ')[0]}</p>
|
||||
)}
|
||||
<div className={cn(
|
||||
'px-3 py-2 rounded-2xl text-sm',
|
||||
isOwn
|
||||
? 'bg-brand-600 text-white rounded-tr-sm'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 rounded-tl-sm',
|
||||
)}>
|
||||
{msg.text}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 mx-1">{fmtTime(msg.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Сообщение..."
|
||||
rows={1}
|
||||
className={cn(
|
||||
'flex-1 resize-none rounded-xl px-3 py-2 text-sm',
|
||||
'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100',
|
||||
'placeholder-slate-400 outline-none',
|
||||
'max-h-24 overflow-y-auto',
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void sendMessage()}
|
||||
disabled={!text.trim() || sending}
|
||||
className="p-2.5 rounded-xl bg-brand-600 hover:bg-brand-700 disabled:opacity-40 text-white transition-colors shrink-0"
|
||||
>
|
||||
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1.5">Enter — отправить, Shift+Enter — перенос</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user