Melhora chat ao vivo com anexos e eventos de timeline

- Reestrutura visual do widget de chat (header branco, status emerald)
- Adiciona sistema de anexos com upload e drag-and-drop
- Substitui select nativo por componente Select do shadcn
- Adiciona eventos LIVE_CHAT_STARTED e LIVE_CHAT_ENDED na timeline
- Traduz labels de chat para portugues (Chat iniciado/finalizado)
- Filtra CHAT_MESSAGE_ADDED da timeline (apenas inicio/fim aparecem)
- Restringe inicio de chat a tickets com responsavel atribuido
- Exibe duracao da sessao ao finalizar chat

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-07 02:20:11 -03:00
parent 9e676b06f9
commit 3b1cde79df
11 changed files with 782 additions and 77 deletions

View file

@ -1,18 +1,40 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useAction, 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { MessageCircle, Send, X, Minimize2, User, Headphones, ChevronDown } from "lucide-react"
import {
MessageCircle,
Send,
X,
Minimize2,
User,
Headphones,
ChevronDown,
WifiOff,
PhoneOff,
Paperclip,
FileText,
Image as ImageIcon,
Download,
} from "lucide-react"
const MAX_MESSAGE_LENGTH = 4000
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB
const MAX_ATTACHMENTS = 5
function formatTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString("pt-BR", {
@ -29,6 +51,134 @@ type ChatSession = {
unreadCount: number
}
type UploadedFile = {
storageId: string
name: string
size: number
type: string
previewUrl?: string
}
type ChatAttachment = {
storageId: Id<"_storage">
name: string
size: number | null
type: string | null
}
type ChatData = {
ticketId: string
chatEnabled: boolean
status: string
canPost: boolean
reopenDeadline: number | null
liveChat?: {
hasMachine: boolean
machineOnline: boolean
machineHostname: string | null
activeSession: {
sessionId: Id<"liveChatSessions">
agentId: Id<"users">
agentName: string | null
startedAt: number
unreadByAgent: number
} | null
}
messages: Array<{
id: Id<"ticketChatMessages">
body: string
createdAt: number
updatedAt: number
authorId: string
authorName: string | null
authorEmail: string | null
attachments: ChatAttachment[]
readBy: Array<{ userId: string; readAt: number }>
}>
}
// Componente de preview de anexo na mensagem
function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
const getFileUrl = useAction(api.files.getUrl)
const [url, setUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function loadUrl() {
try {
const fileUrl = await getFileUrl({ storageId: attachment.storageId })
if (!cancelled && fileUrl) {
setUrl(fileUrl)
}
} catch (error) {
console.error("Erro ao carregar anexo:", error)
} finally {
if (!cancelled) setLoading(false)
}
}
loadUrl()
return () => { cancelled = true }
}, [attachment.storageId, getFileUrl])
const isImage = attachment.type?.startsWith("image/")
const handleDownload = async () => {
if (!url) return
try {
const response = await fetch(url)
const blob = await response.blob()
const downloadUrl = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = downloadUrl
a.download = attachment.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(downloadUrl)
} catch (error) {
toast.error("Erro ao baixar arquivo")
}
}
if (loading) {
return (
<div className="flex size-12 items-center justify-center rounded-lg border border-slate-200 bg-slate-50">
<Spinner className="size-4 text-slate-400" />
</div>
)
}
if (isImage && url) {
return (
<button
onClick={handleDownload}
className="group relative overflow-hidden rounded-lg border border-slate-200"
>
<img
src={url}
alt={attachment.name}
className="size-16 object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<Download className="size-4 text-white" />
</div>
</button>
)
}
return (
<button
onClick={handleDownload}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-2 py-1.5 text-xs hover:bg-slate-100"
>
<FileText className="size-4 text-slate-500" />
<span className="max-w-[80px] truncate text-slate-700">{attachment.name}</span>
<Download className="size-3 text-slate-400" />
</button>
)
}
export function ChatWidget() {
const { convexUserId } = useAuth()
const viewerId = convexUserId ?? null
@ -38,11 +188,17 @@ export function ChatWidget() {
const [activeTicketId, setActiveTicketId] = useState<string | null>(null)
const [draft, setDraft] = useState("")
const [isSending, setIsSending] = useState(false)
const [isEndingChat, setIsEndingChat] = useState(false)
const [attachments, setAttachments] = useState<UploadedFile[]>([])
const [isUploading, setIsUploading] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const dropAreaRef = useRef<HTMLDivElement | null>(null)
// Buscar sessoes de chat ativas do agente
// Buscar sessões de chat ativas do agente
const activeSessions = useQuery(
api.liveChat.listAgentSessions,
viewerId ? { agentId: viewerId as Id<"users"> } : "skip"
@ -54,22 +210,27 @@ export function ChatWidget() {
viewerId && activeTicketId
? { ticketId: activeTicketId as Id<"tickets">, viewerId: viewerId as Id<"users"> }
: "skip"
)
) as ChatData | null | undefined
const postChatMessage = useMutation(api.tickets.postChatMessage)
const markChatRead = useMutation(api.tickets.markChatRead)
const endLiveChat = useMutation(api.liveChat.endSession)
const generateUploadUrl = useAction(api.files.generateUploadUrl)
const messages = chat?.messages ?? []
const totalUnread = activeSessions?.reduce((sum, s) => sum + s.unreadCount, 0) ?? 0
const liveChat = chat?.liveChat
const machineOnline = liveChat?.machineOnline ?? false
const machineHostname = liveChat?.machineHostname
// Auto-selecionar primeira sessao se nenhuma selecionada
// Auto-selecionar primeira sessão se nenhuma selecionada
useEffect(() => {
if (!activeTicketId && activeSessions && activeSessions.length > 0) {
setActiveTicketId(activeSessions[0].ticketId)
}
}, [activeTicketId, activeSessions])
// Scroll para ultima mensagem
// Scroll para última mensagem
useEffect(() => {
if (messagesEndRef.current && isOpen && !isMinimized) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
@ -90,29 +251,157 @@ export function ChatWidget() {
}).catch(console.error)
}, [viewerId, chat, activeTicketId, isOpen, isMinimized, markChatRead])
// Upload de arquivos
const uploadFiles = useCallback(async (files: File[]) => {
const maxFiles = MAX_ATTACHMENTS - attachments.length
const validFiles = files
.filter(f => f.size <= MAX_ATTACHMENT_SIZE)
.slice(0, maxFiles)
if (validFiles.length < files.length) {
toast.error(`Alguns arquivos ignorados (máx. ${MAX_ATTACHMENTS} arquivos, 5MB cada)`)
}
if (validFiles.length === 0) return
setIsUploading(true)
try {
for (const file of validFiles) {
const uploadUrl = await generateUploadUrl()
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
})
const { storageId } = await response.json()
const previewUrl = file.type.startsWith("image/")
? URL.createObjectURL(file)
: undefined
setAttachments(prev => [...prev, {
storageId,
name: file.name,
size: file.size,
type: file.type,
previewUrl,
}])
}
} catch (error) {
console.error("Erro no upload:", error)
toast.error("Erro ao enviar arquivo")
} finally {
setIsUploading(false)
}
}, [attachments.length, generateUploadUrl])
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (files.length > 0) {
await uploadFiles(files)
}
e.target.value = ""
}
const removeAttachment = (index: number) => {
setAttachments(prev => {
const removed = prev[index]
if (removed?.previewUrl) {
URL.revokeObjectURL(removed.previewUrl)
}
return prev.filter((_, i) => i !== index)
})
}
// Drag and drop handlers
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!dropAreaRef.current?.contains(e.relatedTarget as Node)) {
setIsDragging(false)
}
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
if (files.length > 0) {
await uploadFiles(files)
}
}
const handleSend = async () => {
if (!viewerId || !activeTicketId || !draft.trim()) return
if (!viewerId || !activeTicketId) return
if (!draft.trim() && attachments.length === 0) return
if (draft.length > MAX_MESSAGE_LENGTH) {
toast.error(`Mensagem muito longa (max. ${MAX_MESSAGE_LENGTH} caracteres).`)
toast.error(`Mensagem muito longa (máx. ${MAX_MESSAGE_LENGTH} caracteres).`)
return
}
setIsSending(true)
try {
await postChatMessage({
ticketId: activeTicketId as Id<"tickets">,
actorId: viewerId as Id<"users">,
body: draft.trim(),
attachments: attachments.map(a => ({
storageId: a.storageId as unknown as Id<"_storage">,
name: a.name,
size: a.size,
type: a.type,
})),
})
setDraft("")
// Limpar previews
attachments.forEach(a => {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl)
})
setAttachments([])
inputRef.current?.focus()
} catch (error) {
console.error(error)
toast.error("Nao foi possivel enviar a mensagem.")
toast.error("Não foi possível enviar a mensagem.")
} finally {
setIsSending(false)
}
}
const handleEndChat = async () => {
if (!viewerId || !liveChat?.activeSession?.sessionId || isEndingChat) return
setIsEndingChat(true)
toast.dismiss("live-chat")
try {
await endLiveChat({
sessionId: liveChat.activeSession.sessionId,
actorId: viewerId as Id<"users">,
})
toast.success("Chat ao vivo encerrado.", { id: "live-chat" })
if (activeSessions && activeSessions.length <= 1) {
setIsOpen(false)
setActiveTicketId(null)
} else {
const nextSession = activeSessions?.find((s) => s.ticketId !== activeTicketId)
if (nextSession) {
setActiveTicketId(nextSession.ticketId)
}
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Não foi possível encerrar o chat"
toast.error(message, { id: "live-chat" })
} finally {
setIsEndingChat(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
@ -120,7 +409,7 @@ export function ChatWidget() {
}
}
// Nao mostrar se nao logado ou sem sessoes
// Não mostrar se não logado ou sem sessões
if (!viewerId) return null
if (!activeSessions || activeSessions.length === 0) return null
@ -130,51 +419,103 @@ export function ChatWidget() {
<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 h-[520px] w-[400px] flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl">
{/* Header - Estilo card da aplicação */}
<div className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-3">
<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 className="flex size-10 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" />
</div>
<div>
<p className="text-sm font-semibold">Chat Ativo</p>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">Chat Ativo</p>
{/* Indicador online/offline */}
{liveChat?.hasMachine && (
machineOnline ? (
<span className="flex items-center gap-1.5 text-xs text-emerald-600">
<span className="size-2 rounded-full bg-emerald-500 animate-pulse" />
Online
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-slate-400">
<WifiOff className="size-3" />
Offline
</span>
)
)}
</div>
{activeSession && (
<p className="text-xs text-white/70">#{activeSession.ticketRef}</p>
<p className="text-xs text-slate-500">
#{activeSession.ticketRef}
{machineHostname && ` - ${machineHostname}`}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
{/* Botão encerrar chat */}
<Button
variant="ghost"
size="sm"
onClick={handleEndChat}
disabled={isEndingChat}
className="gap-1.5 text-red-600 hover:bg-red-50 hover:text-red-700"
>
{isEndingChat ? (
<Spinner className="size-3.5" />
) : (
<PhoneOff className="size-3.5" />
)}
<span className="hidden sm:inline">Encerrar</span>
</Button>
<button
onClick={() => setIsMinimized(true)}
className="rounded p-1.5 hover:bg-white/10"
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Minimizar"
>
<Minimize2 className="size-4" />
</button>
<button
onClick={() => setIsOpen(false)}
className="rounded p-1.5 hover:bg-white/10"
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Fechar"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Seletor de sessoes (se mais de uma) */}
{/* Seletor de sessões (se mais de uma) */}
{activeSessions.length > 1 && (
<div className="border-b border-slate-100 bg-slate-50 px-3 py-2">
<select
<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"
onValueChange={setActiveTicketId}
>
{activeSessions.map((session) => (
<option key={session.ticketId} value={session.ticketId}>
#{session.ticketRef} - {session.ticketSubject.slice(0, 30)}
{session.unreadCount > 0 ? ` (${session.unreadCount})` : ""}
</option>
))}
</select>
<SelectTrigger className="h-8 w-full border-slate-200 bg-white text-sm">
<SelectValue placeholder="Selecione uma conversa" />
</SelectTrigger>
<SelectContent>
{activeSessions.map((session) => (
<SelectItem key={session.ticketId} value={session.ticketId}>
#{session.ticketRef} - {session.ticketSubject.slice(0, 25)}
{session.unreadCount > 0 && (
<span className="ml-1 text-red-500">({session.unreadCount})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Aviso de máquina offline */}
{liveChat?.hasMachine && !machineOnline && (
<div className="border-b border-amber-200 bg-amber-50 px-3 py-2">
<p className="flex items-center gap-2 text-xs text-amber-700">
<WifiOff className="size-3" />
A máquina está offline. Mensagens serão entregues quando voltar.
</p>
</div>
)}
@ -211,7 +552,7 @@ export function ChatWidget() {
</div>
<div
className={cn(
"max-w-[70%] rounded-2xl px-3 py-2",
"max-w-[75%] 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"
@ -222,7 +563,17 @@ export function ChatWidget() {
{msg.authorName ?? "Cliente"}
</p>
)}
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
{msg.body && (
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
)}
{/* Anexos da mensagem */}
{msg.attachments && msg.attachments.length > 0 && (
<div className={cn("mt-2 flex flex-wrap gap-1.5", isOwn && "justify-end")}>
{msg.attachments.map((att, i) => (
<MessageAttachment key={i} attachment={att} />
))}
</div>
)}
<p className={cn("mt-1 text-right text-xs", isOwn ? "text-white/60" : "text-slate-400")}>
{formatTime(msg.createdAt)}
</p>
@ -235,8 +586,56 @@ export function ChatWidget() {
)}
</div>
{/* Input */}
<div className="border-t border-slate-200 bg-white p-3">
{/* Preview de anexos pendentes */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 border-t border-slate-100 bg-slate-50 p-2">
{attachments.map((file, index) => (
<div key={index} className="group relative">
{file.type?.startsWith("image/") && file.previewUrl ? (
<img
src={file.previewUrl}
alt={file.name}
className="size-14 rounded-lg border border-slate-200 object-cover"
/>
) : (
<div className="flex size-14 items-center justify-center rounded-lg border border-slate-200 bg-white">
<FileText className="size-5 text-slate-400" />
</div>
)}
<button
onClick={() => removeAttachment(index)}
className="absolute -right-1.5 -top-1.5 flex size-5 items-center justify-center rounded-full bg-red-500 text-white opacity-0 transition-opacity group-hover:opacity-100"
>
<X className="size-3" />
</button>
<p className="mt-0.5 max-w-[56px] truncate text-center text-[10px] text-slate-500">
{file.name}
</p>
</div>
))}
{isUploading && (
<div className="flex size-14 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<Spinner className="size-5 text-slate-400" />
</div>
)}
</div>
)}
{/* Input com dropzone */}
<div
ref={dropAreaRef}
className="relative border-t border-slate-200 bg-white p-3"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Overlay de drag */}
{isDragging && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-b-2xl border-2 border-dashed border-primary bg-primary/5">
<p className="text-sm font-medium text-primary">Solte os arquivos aqui</p>
</div>
)}
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
@ -244,20 +643,46 @@ export function ChatWidget() {
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"
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-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400"
rows={1}
disabled={isSending}
/>
{/* Botão de anexar */}
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 text-slate-500 hover:bg-slate-100 hover:text-slate-700"
onClick={() => fileInputRef.current?.click()}
disabled={attachments.length >= MAX_ATTACHMENTS || isUploading}
title="Anexar arquivo"
>
{isUploading ? (
<Spinner className="size-4" />
) : (
<Paperclip className="size-4" />
)}
</Button>
{/* Botão de enviar */}
<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"
disabled={(!draft.trim() && attachments.length === 0) || isSending}
className="size-9 bg-black text-white hover:bg-black/90"
size="icon"
>
{isSending ? <Spinner className="size-4" /> : <Send className="size-4" />}
</Button>
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv"
/>
</div>
</div>
)}
@ -272,6 +697,15 @@ export function ChatWidget() {
<span className="text-sm font-medium">
Chat #{activeSession?.ticketRef}
</span>
{/* Indicador online no minimizado */}
{liveChat?.hasMachine && (
<span
className={cn(
"size-2 rounded-full",
machineOnline ? "bg-emerald-400" : "bg-slate-400"
)}
/>
)}
{totalUnread > 0 && (
<span className="flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
{totalUnread}
@ -281,7 +715,7 @@ export function ChatWidget() {
</button>
)}
{/* Botao flutuante */}
{/* Botão flutuante */}
{!isOpen && (
<button
onClick={() => {