Redesenha widget de chat com visual moderno

- Layout estilo messenger com baloes de mensagem
- Avatares para agente (headphones) e usuario (user)
- Cores distintas: preto para agente, branco para cliente
- Header com status online/offline da maquina
- Input com Enter para enviar
- Scroll automatico para novas mensagens

🤖 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 01:24:33 -03:00
parent b7f150e2b7
commit 2a78d14a74

View file

@ -6,14 +6,13 @@ import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea" import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
import { formatDistanceToNowStrict } from "date-fns" import { formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { MessageCircle, MonitorSmartphone, WifiOff, X } from "lucide-react" import { MessageCircle, Send, WifiOff, X, User, Headphones } from "lucide-react"
const MAX_MESSAGE_LENGTH = 4000 const MAX_MESSAGE_LENGTH = 4000
@ -25,6 +24,13 @@ function formatRelative(timestamp: number) {
} }
} }
function formatTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
})
}
type TicketChatPanelProps = { type TicketChatPanelProps = {
ticketId: string ticketId: string
} }
@ -42,7 +48,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
status: string status: string
canPost: boolean canPost: boolean
reopenDeadline: number | null reopenDeadline: number | null
liveChat: { liveChat?: {
hasMachine: boolean hasMachine: boolean
machineOnline: boolean machineOnline: boolean
machineHostname: string | null machineHostname: string | null
@ -73,7 +79,9 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
const postChatMessage = useMutation(api.tickets.postChatMessage) const postChatMessage = useMutation(api.tickets.postChatMessage)
const startLiveChat = useMutation(api.liveChat.startSession) const startLiveChat = useMutation(api.liveChat.startSession)
const endLiveChat = useMutation(api.liveChat.endSession) const endLiveChat = useMutation(api.liveChat.endSession)
const messagesEndRef = useRef<HTMLDivElement | null>(null) const messagesEndRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const [draft, setDraft] = useState("") const [draft, setDraft] = useState("")
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const [isStartingChat, setIsStartingChat] = useState(false) const [isStartingChat, setIsStartingChat] = useState(false)
@ -82,6 +90,8 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
const messages = chat?.messages ?? [] const messages = chat?.messages ?? []
const canPost = Boolean(chat?.canPost && viewerId) const canPost = Boolean(chat?.canPost && viewerId)
const chatEnabled = Boolean(chat?.chatEnabled) const chatEnabled = Boolean(chat?.chatEnabled)
const liveChat = chat?.liveChat
const hasActiveSession = Boolean(liveChat?.activeSession)
useEffect(() => { useEffect(() => {
if (!viewerId || !chat || !Array.isArray(chat.messages) || chat.messages.length === 0) return if (!viewerId || !chat || !Array.isArray(chat.messages) || chat.messages.length === 0) return
@ -109,7 +119,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
const disabledReason = useMemo(() => { const disabledReason = useMemo(() => {
if (!chatEnabled) return "Chat desativado para este ticket" if (!chatEnabled) return "Chat desativado para este ticket"
if (!canPost) return "Você não tem permissão para enviar mensagens" if (!canPost) return "Voce nao tem permissao para enviar mensagens"
return null return null
}, [canPost, chatEnabled]) }, [canPost, chatEnabled])
@ -160,69 +170,81 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
return return
} }
setIsSending(true) setIsSending(true)
toast.dismiss("ticket-chat")
toast.loading("Enviando mensagem...", { id: "ticket-chat" })
try { try {
await postChatMessage({ await postChatMessage({
ticketId: ticketId as Id<"tickets">, ticketId: ticketId as Id<"tickets">,
actorId: viewerId as Id<"users">, actorId: viewerId as Id<"users">,
body: draft, body: draft.trim(),
}) })
setDraft("") setDraft("")
toast.success("Mensagem enviada!", { id: "ticket-chat" }) inputRef.current?.focus()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
toast.error("Nao foi possivel enviar a mensagem.", { id: "ticket-chat" }) toast.error("Nao foi possivel enviar a mensagem.")
} finally { } finally {
setIsSending(false) setIsSending(false)
} }
} }
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
if (!viewerId) { if (!viewerId) {
return null return null
} }
const liveChat = chat?.liveChat
const hasActiveSession = Boolean(liveChat?.activeSession)
return ( return (
<Card className="border-slate-200"> <Card className="flex flex-col overflow-hidden border-slate-200">
<CardHeader className="flex flex-row items-center justify-between gap-2"> {/* Header */}
<div className="flex items-center gap-2"> <CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-slate-100 bg-slate-50 px-4 py-3">
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle> <div className="flex items-center gap-3">
{liveChat?.hasMachine && ( <div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
<div className="flex items-center gap-1.5"> <MessageCircle className="size-5" />
{liveChat.machineOnline ? ( </div>
<div>
<p className="text-sm font-semibold text-slate-900">Chat do Atendimento</p>
<div className="flex items-center gap-2">
{liveChat?.hasMachine && (
<>
{liveChat.machineOnline ? (
<span className="flex items-center gap-1 text-xs text-green-600">
<span className="size-1.5 rounded-full bg-green-500" />
{liveChat.machineHostname ?? "Maquina"} online
</span>
) : (
<span className="flex items-center gap-1 text-xs text-slate-400">
<WifiOff className="size-3" />
{liveChat.machineHostname ?? "Maquina"} offline
</span>
)}
</>
)}
{hasActiveSession && (
<span className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700"> <span className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
<span className="size-1.5 rounded-full bg-green-500" /> Chat ativo
Online
</span>
) : (
<span className="flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-500">
<WifiOff className="size-3" />
Offline
</span> </span>
)} )}
</div> </div>
)} </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!chatEnabled ? (
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
) : null}
{liveChat?.hasMachine && ( {liveChat?.hasMachine && (
<> <>
{hasActiveSession ? ( {hasActiveSession ? (
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
size="sm" size="sm"
onClick={handleEndLiveChat} onClick={handleEndLiveChat}
disabled={isEndingChat} disabled={isEndingChat}
className="gap-1.5 border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700" className="gap-1.5 text-red-600 hover:bg-red-50 hover:text-red-700"
> >
{isEndingChat ? <Spinner className="size-3" /> : <X className="size-3" />} {isEndingChat ? <Spinner className="size-3" /> : <X className="size-3" />}
Encerrar Chat Encerrar
</Button> </Button>
) : ( ) : (
<Button <Button
@ -242,89 +264,112 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
)} )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4">
{hasActiveSession && liveChat?.activeSession && ( {/* Messages */}
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2"> <CardContent className="flex-1 overflow-y-auto bg-slate-50/50 p-0">
<div className="flex size-6 items-center justify-center rounded-full bg-green-100"> <div className="min-h-[300px] max-h-[400px] overflow-y-auto p-4">
<MessageCircle className="size-3.5 text-green-600" /> {chat === undefined ? (
<div className="flex h-full min-h-[200px] items-center justify-center">
<div className="flex flex-col items-center gap-2 text-slate-400">
<Spinner className="size-6" />
<p className="text-sm">Carregando mensagens...</p>
</div>
</div> </div>
<div className="flex-1"> ) : messages.length === 0 ? (
<p className="text-sm font-medium text-green-800">Chat ao vivo ativo</p> <div className="flex h-full min-h-[200px] flex-col items-center justify-center text-center">
<p className="text-xs text-green-600"> <div className="flex size-12 items-center justify-center rounded-full bg-slate-100">
Iniciado por {liveChat.activeSession.agentName ?? "agente"} {formatRelative(liveChat.activeSession.startedAt)} <MessageCircle className="size-6 text-slate-400" />
{liveChat.activeSession.unreadByAgent > 0 && ( </div>
<span className="ml-2 rounded-full bg-green-600 px-1.5 py-0.5 text-white"> <p className="mt-3 text-sm font-medium text-slate-600">Nenhuma mensagem ainda</p>
{liveChat.activeSession.unreadByAgent} nao lidas <p className="mt-1 text-xs text-slate-400">
</span> {hasActiveSession ? "Envie uma mensagem para iniciar a conversa" : "Inicie um chat para conversar com o cliente"}
)}
</p> </p>
</div> </div>
</div> ) : (
)} <div className="space-y-4">
{chat === undefined ? ( {messages.map((message) => {
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-slate-200 py-6 text-sm text-neutral-500"> const isOwn = String(message.authorId) === String(viewerId)
<Spinner className="size-4" /> Carregando mensagens... return (
</div>
) : messages.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-neutral-500">
Nenhuma mensagem registrada no chat até o momento.
</div>
) : (
<div className="max-h-72 space-y-3 overflow-y-auto pr-2">
{messages.map((message) => {
const isOwn = String(message.authorId) === String(viewerId)
return (
<div
key={message.id}
className={cn(
"flex flex-col gap-1 rounded-lg border px-3 py-2 text-sm",
isOwn ? "border-slate-300 bg-slate-50" : "border-slate-200 bg-white"
)}
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold text-neutral-800">{message.authorName ?? "Usuário"}</span>
<span className="text-xs text-neutral-500">{formatRelative(message.createdAt)}</span>
</div>
<div <div
className="prose prose-sm max-w-none text-neutral-700" key={message.id}
dangerouslySetInnerHTML={{ __html: message.body }} className={cn("flex gap-2", isOwn ? "flex-row-reverse" : "flex-row")}
/> >
</div> {/* Avatar */}
) <div
})} className={cn(
<div ref={messagesEndRef} /> "flex size-8 shrink-0 items-center justify-center rounded-full",
</div> isOwn ? "bg-black text-white" : "bg-slate-200 text-slate-600"
)} )}
<div className="space-y-2"> >
<Textarea {isOwn ? <Headphones className="size-4" /> : <User className="size-4" />}
value={draft} </div>
onChange={(event) => setDraft(event.target.value)}
placeholder={disabledReason ?? "Digite uma mensagem"} {/* Bubble */}
rows={3} <div
disabled={isSending || !canPost || !chatEnabled} className={cn(
/> "max-w-[75%] rounded-2xl px-4 py-2",
<div className="flex items-center justify-between text-xs text-neutral-500"> isOwn
<span>{draft.length}/{MAX_MESSAGE_LENGTH}</span> ? "rounded-br-md bg-black text-white"
<div className="inline-flex items-center gap-2"> : "rounded-bl-md bg-white text-slate-900 shadow-sm border border-slate-100"
{!chatEnabled ? ( )}
<span className="text-neutral-500">Chat indisponível</span> >
) : null} {!isOwn && (
<Button <p className={cn("mb-1 text-xs font-medium", isOwn ? "text-white/70" : "text-slate-500")}>
type="button" {message.authorName ?? "Usuario"}
size="sm" </p>
onClick={handleSend} )}
disabled={isSending || !canPost || !chatEnabled || draft.trim().length === 0} <p className="whitespace-pre-wrap text-sm">{message.body}</p>
> <p
{isSending ? <Spinner className="mr-2 size-4" /> : null} className={cn(
Enviar "mt-1 text-right text-xs",
</Button> isOwn ? "text-white/60" : "text-slate-400"
)}
>
{formatTime(message.createdAt)}
</p>
</div>
</div>
)
})}
<div ref={messagesEndRef} />
</div> </div>
</div> )}
{disabledReason && chatEnabled ? (
<p className="text-xs text-neutral-500">{disabledReason}</p>
) : null}
</div> </div>
</CardContent> </CardContent>
{/* Input */}
<div className="border-t border-slate-200 bg-white p-3">
{!chatEnabled ? (
<p className="text-center text-sm text-slate-400">Chat desativado para este ticket</p>
) : !canPost ? (
<p className="text-center text-sm text-slate-400">Voce nao pode enviar mensagens neste chat</p>
) : (
<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-24 min-h-[40px] 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-10 items-center justify-center rounded-lg bg-black text-white hover:bg-black/90"
>
{isSending ? (
<Spinner className="size-4" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
)}
</div>
</Card> </Card>
) )
} }