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 */}
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-slate-100 bg-slate-50 px-4 py-3">
<div className="flex items-center gap-3">
<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 text-slate-900">Chat do Atendimento</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
{liveChat?.hasMachine && ( {liveChat?.hasMachine && (
<div className="flex items-center gap-1.5"> <>
{liveChat.machineOnline ? ( {liveChat.machineOnline ? (
<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 text-xs text-green-600">
<span className="size-1.5 rounded-full bg-green-500" /> <span className="size-1.5 rounded-full bg-green-500" />
Online {liveChat.machineHostname ?? "Maquina"} online
</span> </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"> <span className="flex items-center gap-1 text-xs text-slate-400">
<WifiOff className="size-3" /> <WifiOff className="size-3" />
Offline {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">
Chat ativo
</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" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-green-800">Chat ao vivo ativo</p>
<p className="text-xs text-green-600">
Iniciado por {liveChat.activeSession.agentName ?? "agente"} {formatRelative(liveChat.activeSession.startedAt)}
{liveChat.activeSession.unreadByAgent > 0 && (
<span className="ml-2 rounded-full bg-green-600 px-1.5 py-0.5 text-white">
{liveChat.activeSession.unreadByAgent} nao lidas
</span>
)}
</p>
</div>
</div>
)}
{chat === undefined ? ( {chat === undefined ? (
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-slate-200 py-6 text-sm text-neutral-500"> <div className="flex h-full min-h-[200px] items-center justify-center">
<Spinner className="size-4" /> Carregando mensagens... <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>
) : messages.length === 0 ? ( ) : 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"> <div className="flex h-full min-h-[200px] flex-col items-center justify-center text-center">
Nenhuma mensagem registrada no chat até o momento. <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 ainda</p>
<p className="mt-1 text-xs text-slate-400">
{hasActiveSession ? "Envie uma mensagem para iniciar a conversa" : "Inicie um chat para conversar com o cliente"}
</p>
</div> </div>
) : ( ) : (
<div className="max-h-72 space-y-3 overflow-y-auto pr-2"> <div className="space-y-4">
{messages.map((message) => { {messages.map((message) => {
const isOwn = String(message.authorId) === String(viewerId) const isOwn = String(message.authorId) === String(viewerId)
return ( return (
<div <div
key={message.id} key={message.id}
className={cn("flex gap-2", isOwn ? "flex-row-reverse" : "flex-row")}
>
{/* Avatar */}
<div
className={cn( className={cn(
"flex flex-col gap-1 rounded-lg border px-3 py-2 text-sm", "flex size-8 shrink-0 items-center justify-center rounded-full",
isOwn ? "border-slate-300 bg-slate-50" : "border-slate-200 bg-white" isOwn ? "bg-black text-white" : "bg-slate-200 text-slate-600"
)} )}
> >
<div className="flex items-center justify-between gap-3"> {isOwn ? <Headphones className="size-4" /> : <User className="size-4" />}
<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>
{/* Bubble */}
<div <div
className="prose prose-sm max-w-none text-neutral-700" className={cn(
dangerouslySetInnerHTML={{ __html: message.body }} "max-w-[75%] rounded-2xl px-4 py-2",
/> isOwn
? "rounded-br-md bg-black text-white"
: "rounded-bl-md bg-white text-slate-900 shadow-sm border border-slate-100"
)}
>
{!isOwn && (
<p className={cn("mb-1 text-xs font-medium", isOwn ? "text-white/70" : "text-slate-500")}>
{message.authorName ?? "Usuario"}
</p>
)}
<p className="whitespace-pre-wrap text-sm">{message.body}</p>
<p
className={cn(
"mt-1 text-right text-xs",
isOwn ? "text-white/60" : "text-slate-400"
)}
>
{formatTime(message.createdAt)}
</p>
</div>
</div> </div>
) )
})} })}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
)} )}
<div className="space-y-2">
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={disabledReason ?? "Digite uma mensagem"}
rows={3}
disabled={isSending || !canPost || !chatEnabled}
/>
<div className="flex items-center justify-between text-xs text-neutral-500">
<span>{draft.length}/{MAX_MESSAGE_LENGTH}</span>
<div className="inline-flex items-center gap-2">
{!chatEnabled ? (
<span className="text-neutral-500">Chat indisponível</span>
) : null}
<Button
type="button"
size="sm"
onClick={handleSend}
disabled={isSending || !canPost || !chatEnabled || draft.trim().length === 0}
>
{isSending ? <Spinner className="mr-2 size-4" /> : null}
Enviar
</Button>
</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>
) )
} }