feat: wire up loyalty + chat — register routes, API types, connect LoyaltyPage, add ChatWidget to layout

This commit is contained in:
2026-03-26 10:15:29 +03:00
parent 8ede920e5d
commit 113971f854
9 changed files with 964 additions and 63 deletions

View 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>
)}
</>
)
}