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
|
|
@ -510,3 +510,45 @@ export const getTicketSession = query({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Listar sessoes ativas de um agente (para widget flutuante)
|
||||||
|
export const listAgentSessions = query({
|
||||||
|
args: {
|
||||||
|
agentId: v.id("users"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { agentId }) => {
|
||||||
|
const agent = await ctx.db.get(agentId)
|
||||||
|
if (!agent) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar todas as sessoes ativas do tenant do agente
|
||||||
|
const sessions = await ctx.db
|
||||||
|
.query("liveChatSessions")
|
||||||
|
.withIndex("by_tenant_status", (q) =>
|
||||||
|
q.eq("tenantId", agent.tenantId).eq("status", "ACTIVE")
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
// Buscar detalhes dos tickets
|
||||||
|
const result = await Promise.all(
|
||||||
|
sessions.map(async (session) => {
|
||||||
|
const ticket = await ctx.db.get(session.ticketId)
|
||||||
|
return {
|
||||||
|
ticketId: session.ticketId,
|
||||||
|
ticketRef: ticket?.reference ?? 0,
|
||||||
|
ticketSubject: ticket?.subject ?? "",
|
||||||
|
sessionId: session._id,
|
||||||
|
agentId: session.agentId,
|
||||||
|
agentName: session.agentSnapshot?.name ?? "Agente",
|
||||||
|
unreadCount: session.unreadByAgent ?? 0,
|
||||||
|
lastActivityAt: session.lastActivityAt,
|
||||||
|
startedAt: session.startedAt,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ordenar por ultima atividade (mais recente primeiro)
|
||||||
|
return result.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import "./globals.css"
|
||||||
import { ConvexClientProvider } from "./ConvexClientProvider"
|
import { ConvexClientProvider } from "./ConvexClientProvider"
|
||||||
import { AuthProvider } from "@/lib/auth-client"
|
import { AuthProvider } from "@/lib/auth-client"
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
|
import { ChatWidgetProvider } from "@/components/chat/chat-widget-provider"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Raven - Sistema de chamados",
|
title: "Raven - Sistema de chamados",
|
||||||
|
|
@ -35,6 +36,7 @@ export default async function RootLayout({
|
||||||
<ConvexClientProvider>
|
<ConvexClientProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
|
<ChatWidgetProvider />
|
||||||
<Toaster position="bottom-center" richColors />
|
<Toaster position="bottom-center" richColors />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ConvexClientProvider>
|
</ConvexClientProvider>
|
||||||
|
|
|
||||||
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