Adiciona widget de chat flutuante global
- Widget no canto inferior direito em todas as paginas - Mostra sessoes de chat ativas do agente - Suporta multiplas sessoes com seletor - Badge com contador de mensagens nao lidas - Pode minimizar ou fechar - Query listAgentSessions para buscar sessoes ativas 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2a78d14a74
commit
8c465008bf
4 changed files with 360 additions and 1 deletions
13
src/components/chat/chat-widget-provider.tsx
Normal file
13
src/components/chat/chat-widget-provider.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
|
||||
// Importacao dinamica para evitar problemas de SSR
|
||||
const ChatWidget = dynamic(
|
||||
() => import("./chat-widget").then((mod) => ({ default: mod.ChatWidget })),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
export function ChatWidgetProvider() {
|
||||
return <ChatWidget />
|
||||
}
|
||||
302
src/components/chat/chat-widget.tsx
Normal file
302
src/components/chat/chat-widget.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { MessageCircle, Send, X, Minimize2, User, Headphones, ChevronDown } from "lucide-react"
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 4000
|
||||
|
||||
function formatTime(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleTimeString("pt-BR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
type ChatSession = {
|
||||
ticketId: string
|
||||
ticketRef: number
|
||||
ticketSubject: string
|
||||
sessionId: string
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export function ChatWidget() {
|
||||
const { convexUserId } = useAuth()
|
||||
const viewerId = convexUserId ?? null
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const [activeTicketId, setActiveTicketId] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState("")
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Buscar sessoes de chat ativas do agente
|
||||
const activeSessions = useQuery(
|
||||
api.liveChat.listAgentSessions,
|
||||
viewerId ? { agentId: viewerId as Id<"users"> } : "skip"
|
||||
) as ChatSession[] | undefined
|
||||
|
||||
// Buscar mensagens do chat ativo
|
||||
const chat = useQuery(
|
||||
api.tickets.listChatMessages,
|
||||
viewerId && activeTicketId
|
||||
? { ticketId: activeTicketId as Id<"tickets">, viewerId: viewerId as Id<"users"> }
|
||||
: "skip"
|
||||
)
|
||||
|
||||
const postChatMessage = useMutation(api.tickets.postChatMessage)
|
||||
const markChatRead = useMutation(api.tickets.markChatRead)
|
||||
|
||||
const messages = chat?.messages ?? []
|
||||
const totalUnread = activeSessions?.reduce((sum, s) => sum + s.unreadCount, 0) ?? 0
|
||||
|
||||
// Auto-selecionar primeira sessao se nenhuma selecionada
|
||||
useEffect(() => {
|
||||
if (!activeTicketId && activeSessions && activeSessions.length > 0) {
|
||||
setActiveTicketId(activeSessions[0].ticketId)
|
||||
}
|
||||
}, [activeTicketId, activeSessions])
|
||||
|
||||
// Scroll para ultima mensagem
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current && isOpen && !isMinimized) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
}, [messages.length, isOpen, isMinimized])
|
||||
|
||||
// Marcar mensagens como lidas
|
||||
useEffect(() => {
|
||||
if (!viewerId || !chat || !activeTicketId || !isOpen || isMinimized) return
|
||||
const unreadIds = chat.messages
|
||||
?.filter((msg) => !msg.readBy?.some((r) => r.userId === viewerId))
|
||||
.map((msg) => msg.id) ?? []
|
||||
if (unreadIds.length === 0) return
|
||||
markChatRead({
|
||||
ticketId: activeTicketId as Id<"tickets">,
|
||||
actorId: viewerId as Id<"users">,
|
||||
messageIds: unreadIds,
|
||||
}).catch(console.error)
|
||||
}, [viewerId, chat, activeTicketId, isOpen, isMinimized, markChatRead])
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!viewerId || !activeTicketId || !draft.trim()) return
|
||||
if (draft.length > MAX_MESSAGE_LENGTH) {
|
||||
toast.error(`Mensagem muito longa (max. ${MAX_MESSAGE_LENGTH} caracteres).`)
|
||||
return
|
||||
}
|
||||
setIsSending(true)
|
||||
try {
|
||||
await postChatMessage({
|
||||
ticketId: activeTicketId as Id<"tickets">,
|
||||
actorId: viewerId as Id<"users">,
|
||||
body: draft.trim(),
|
||||
})
|
||||
setDraft("")
|
||||
inputRef.current?.focus()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Nao foi possivel enviar a mensagem.")
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Nao mostrar se nao logado ou sem sessoes
|
||||
if (!viewerId) return null
|
||||
if (!activeSessions || activeSessions.length === 0) return null
|
||||
|
||||
const activeSession = activeSessions.find((s) => s.ticketId === activeTicketId)
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2">
|
||||
{/* Widget aberto */}
|
||||
{isOpen && !isMinimized && (
|
||||
<div className="flex h-[500px] w-[380px] flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-slate-100 bg-black px-4 py-3 text-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-white/20">
|
||||
<MessageCircle className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Chat Ativo</p>
|
||||
{activeSession && (
|
||||
<p className="text-xs text-white/70">#{activeSession.ticketRef}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="rounded p-1.5 hover:bg-white/10"
|
||||
>
|
||||
<Minimize2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="rounded p-1.5 hover:bg-white/10"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seletor de sessoes (se mais de uma) */}
|
||||
{activeSessions.length > 1 && (
|
||||
<div className="border-b border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<select
|
||||
value={activeTicketId ?? ""}
|
||||
onChange={(e) => setActiveTicketId(e.target.value)}
|
||||
className="w-full rounded border border-slate-200 bg-white px-2 py-1 text-sm"
|
||||
>
|
||||
{activeSessions.map((session) => (
|
||||
<option key={session.ticketId} value={session.ticketId}>
|
||||
#{session.ticketRef} - {session.ticketSubject.slice(0, 30)}
|
||||
{session.unreadCount > 0 ? ` (${session.unreadCount})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mensagens */}
|
||||
<div className="flex-1 overflow-y-auto bg-slate-50/50 p-4">
|
||||
{!chat ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner className="size-6 text-slate-400" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-slate-100">
|
||||
<MessageCircle className="size-6 text-slate-400" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-slate-600">Nenhuma mensagem</p>
|
||||
<p className="mt-1 text-xs text-slate-400">Envie uma mensagem para o cliente</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((msg) => {
|
||||
const isOwn = String(msg.authorId) === String(viewerId)
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn("flex gap-2", isOwn ? "flex-row-reverse" : "flex-row")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full",
|
||||
isOwn ? "bg-black text-white" : "bg-slate-200 text-slate-600"
|
||||
)}
|
||||
>
|
||||
{isOwn ? <Headphones className="size-3.5" /> : <User className="size-3.5" />}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[70%] rounded-2xl px-3 py-2",
|
||||
isOwn
|
||||
? "rounded-br-md bg-black text-white"
|
||||
: "rounded-bl-md border border-slate-100 bg-white text-slate-900 shadow-sm"
|
||||
)}
|
||||
>
|
||||
{!isOwn && (
|
||||
<p className="mb-0.5 text-xs font-medium text-slate-500">
|
||||
{msg.authorName ?? "Cliente"}
|
||||
</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
|
||||
<p className={cn("mt-1 text-right text-xs", isOwn ? "text-white/60" : "text-slate-400")}>
|
||||
{formatTime(msg.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-slate-200 bg-white p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Digite sua mensagem..."
|
||||
className="max-h-20 min-h-[36px] flex-1 resize-none rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-1 focus:ring-black"
|
||||
rows={1}
|
||||
disabled={isSending}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={!draft.trim() || isSending}
|
||||
className="flex size-9 items-center justify-center rounded-lg bg-black text-white hover:bg-black/90"
|
||||
size="icon"
|
||||
>
|
||||
{isSending ? <Spinner className="size-4" /> : <Send className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Widget minimizado */}
|
||||
{isOpen && isMinimized && (
|
||||
<button
|
||||
onClick={() => setIsMinimized(false)}
|
||||
className="flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
<span className="text-sm font-medium">
|
||||
Chat #{activeSession?.ticketRef}
|
||||
</span>
|
||||
{totalUnread > 0 && (
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
|
||||
{totalUnread}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Botao flutuante */}
|
||||
{!isOpen && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(true)
|
||||
setIsMinimized(false)
|
||||
}}
|
||||
className="relative flex size-14 items-center justify-center rounded-full bg-black text-white shadow-lg transition-transform hover:scale-105 hover:bg-black/90"
|
||||
>
|
||||
<MessageCircle className="size-6" />
|
||||
{totalUnread > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
|
||||
{totalUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue