From 8c465008bf23aa265e3df2ed82985590544df2fb Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 7 Dec 2025 01:31:00 -0300 Subject: [PATCH] Adiciona widget de chat flutuante global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- convex/liveChat.ts | 42 +++ src/app/layout.tsx | 4 +- src/components/chat/chat-widget-provider.tsx | 13 + src/components/chat/chat-widget.tsx | 302 +++++++++++++++++++ 4 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 src/components/chat/chat-widget-provider.tsx create mode 100644 src/components/chat/chat-widget.tsx diff --git a/convex/liveChat.ts b/convex/liveChat.ts index 0f2f315..6cc4c20 100644 --- a/convex/liveChat.ts +++ b/convex/liveChat.ts @@ -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) + }, +}) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9628ea6..46ff46c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,8 @@ import "./globals.css" import { ConvexClientProvider } from "./ConvexClientProvider" import { AuthProvider } from "@/lib/auth-client" import { Toaster } from "@/components/ui/sonner" - +import { ChatWidgetProvider } from "@/components/chat/chat-widget-provider" + export const metadata: Metadata = { title: "Raven - Sistema de chamados", description: "Plataforma Raven da Rever", @@ -35,6 +36,7 @@ export default async function RootLayout({ {children} + diff --git a/src/components/chat/chat-widget-provider.tsx b/src/components/chat/chat-widget-provider.tsx new file mode 100644 index 0000000..4847834 --- /dev/null +++ b/src/components/chat/chat-widget-provider.tsx @@ -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 +} diff --git a/src/components/chat/chat-widget.tsx b/src/components/chat/chat-widget.tsx new file mode 100644 index 0000000..f646aa8 --- /dev/null +++ b/src/components/chat/chat-widget.tsx @@ -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(null) + const [draft, setDraft] = useState("") + const [isSending, setIsSending] = useState(false) + + const messagesEndRef = useRef(null) + const inputRef = useRef(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 ( +
+ {/* Widget aberto */} + {isOpen && !isMinimized && ( +
+ {/* Header */} +
+
+
+ +
+
+

Chat Ativo

+ {activeSession && ( +

#{activeSession.ticketRef}

+ )} +
+
+
+ + +
+
+ + {/* Seletor de sessoes (se mais de uma) */} + {activeSessions.length > 1 && ( +
+ +
+ )} + + {/* Mensagens */} +
+ {!chat ? ( +
+ +
+ ) : messages.length === 0 ? ( +
+
+ +
+

Nenhuma mensagem

+

Envie uma mensagem para o cliente

+
+ ) : ( +
+ {messages.map((msg) => { + const isOwn = String(msg.authorId) === String(viewerId) + return ( +
+
+ {isOwn ? : } +
+
+ {!isOwn && ( +

+ {msg.authorName ?? "Cliente"} +

+ )} +

{msg.body}

+

+ {formatTime(msg.createdAt)} +

+
+
+ ) + })} +
+
+ )} +
+ + {/* Input */} +
+
+