sistema-de-chamados/src/components/chat/chat-widget.tsx
rever-tecnologia efc3af3fde fix: corrige contador de mensagens nao lidas e chat desktop abrindo expandido
- Web: adiciona ref hasMarkedReadRef para evitar chamadas duplicadas ao
  markChatRead e garante que mensagens sejam marcadas como lidas mesmo
  quando o chat carrega apos isOpen se tornar true
- Desktop: aumenta periodo de estabilizacao do resize handler para 500ms,
  evitando que eventos transitórios alterem o estado isMinimized

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 16:16:23 -03:00

961 lines
33 KiB
TypeScript

"use client"
import Image from "next/image"
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,
ChevronDown,
WifiOff,
XCircle,
Paperclip,
FileText,
Image as ImageIcon,
Download,
ExternalLink,
Eye,
Check,
} from "lucide-react"
const MAX_MESSAGE_LENGTH = 4000
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB
const MAX_ATTACHMENTS = 5
const STORAGE_KEY = "chat-widget-state"
type ChatWidgetState = {
isOpen: boolean
isMinimized: boolean
activeTicketId: string | null
}
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
}
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 [downloading, setDownloading] = useState(false)
const [downloaded, setDownloaded] = useState(false)
const handleView = () => {
if (!url) return
window.open(url, "_blank", "noopener,noreferrer")
}
const handleDownload = async () => {
if (!url || downloading) return
setDownloading(true)
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)
setDownloaded(true)
setTimeout(() => setDownloaded(false), 2000)
} catch (error) {
toast.error("Erro ao baixar arquivo")
} finally {
setDownloading(false)
}
}
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 (
<div className="group relative overflow-hidden rounded-lg border border-slate-200">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={attachment.name}
className="size-16 cursor-pointer object-cover"
onClick={handleView}
/>
<div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={handleView}
className="flex size-6 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
title="Visualizar"
>
<Eye className="size-3.5 text-white" />
</button>
<button
onClick={handleDownload}
disabled={downloading}
className="flex size-6 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
title="Baixar"
>
{downloading ? (
<Spinner className="size-3 text-white" />
) : downloaded ? (
<Check className="size-3.5 text-emerald-400" />
) : (
<Download className="size-3.5 text-white" />
)}
</button>
</div>
</div>
)
}
return (
<div className="flex items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-2 py-1.5 text-xs">
<FileText className="size-4 text-slate-500" />
<span className="max-w-[80px] truncate text-slate-700">{attachment.name}</span>
<button
onClick={handleView}
className="rounded p-0.5 hover:bg-slate-200"
title="Visualizar"
>
<Eye className="size-3 text-slate-400" />
</button>
<button
onClick={handleDownload}
disabled={downloading}
className="rounded p-0.5 hover:bg-slate-200"
title="Baixar"
>
{downloading ? (
<Spinner className="size-3 text-slate-400" />
) : downloaded ? (
<Check className="size-3 text-emerald-500" />
) : (
<Download className="size-3 text-slate-400" />
)}
</button>
</div>
)
}
export function ChatWidget() {
// Detectar se esta rodando no Tauri (desktop) - nesse caso, nao renderizar
// pois o chat nativo do Tauri ja esta disponivel
const isTauriContext = typeof window !== "undefined" && "__TAURI__" in window
const { convexUserId } = useAuth()
const viewerId = convexUserId ?? null
// Inicializar estado a partir do localStorage (para persistir entre reloads)
const [isOpen, setIsOpen] = useState(() => {
if (typeof window === "undefined") return false
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const state = JSON.parse(saved) as ChatWidgetState
return state.isOpen
}
} catch {}
return false
})
const [isMinimized, setIsMinimized] = useState(() => {
if (typeof window === "undefined") return false
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const state = JSON.parse(saved) as ChatWidgetState
return state.isMinimized
}
} catch {}
return false
})
const [activeTicketId, setActiveTicketId] = useState<string | null>(() => {
if (typeof window === "undefined") return null
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const state = JSON.parse(saved) as ChatWidgetState
return state.activeTicketId
}
} catch {}
return 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)
const prevSessionCountRef = useRef<number>(-1) // -1 indica "ainda nao inicializado"
const hasRestoredStateRef = useRef<boolean>(false) // Flag para evitar sobrescrever estado do localStorage
// Buscar sessões 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"
) 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
// Sincronizar estado entre abas usando evento storage do localStorage
// O evento storage dispara automaticamente em TODAS as outras abas quando localStorage muda
useEffect(() => {
if (typeof window === "undefined") return
const handleStorageChange = (event: StorageEvent) => {
// Ignorar mudancas em outras chaves
if (event.key !== STORAGE_KEY) return
// Ignorar se nao tem valor novo
if (!event.newValue) return
try {
const state = JSON.parse(event.newValue) as ChatWidgetState
setIsOpen(state.isOpen)
setIsMinimized(state.isMinimized)
if (state.activeTicketId) {
setActiveTicketId(state.activeTicketId)
}
} catch {}
}
window.addEventListener("storage", handleStorageChange)
return () => window.removeEventListener("storage", handleStorageChange)
}, [])
// Salvar estado no localStorage quando muda (dispara evento storage em outras abas)
useEffect(() => {
if (typeof window === "undefined") return
const state: ChatWidgetState = {
isOpen,
isMinimized,
activeTicketId,
}
// Salvar no localStorage (isso dispara evento storage em outras abas automaticamente)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch {}
}, [isOpen, isMinimized, activeTicketId])
// Auto-selecionar primeira sessão se nenhuma selecionada
useEffect(() => {
if (!activeTicketId && activeSessions && activeSessions.length > 0) {
setActiveTicketId(activeSessions[0].ticketId)
}
}, [activeTicketId, activeSessions])
// Auto-abrir widget quando uma nova sessão é iniciada (apenas para sessoes NOVAS, nao na montagem inicial)
useEffect(() => {
if (!activeSessions) return
const currentCount = activeSessions.length
const prevCount = prevSessionCountRef.current
// Primeira execucao: apenas inicializar o ref, nao abrir automaticamente
// Isso preserva o estado do localStorage (se usuario tinha minimizado, mantem minimizado)
if (prevCount === -1) {
prevSessionCountRef.current = currentCount
hasRestoredStateRef.current = true
return
}
// Se aumentou o número de sessões APOS a montagem inicial, é uma nova sessão - abrir o widget expandido
if (currentCount > prevCount && hasRestoredStateRef.current) {
setIsOpen(true)
setIsMinimized(false)
// Selecionar a sessão mais recente (última da lista ou primeira se única)
const newestSession = activeSessions[activeSessions.length - 1] ?? activeSessions[0]
if (newestSession) {
setActiveTicketId(newestSession.ticketId)
}
}
prevSessionCountRef.current = currentCount
}, [activeSessions])
// Scroll para última mensagem
useEffect(() => {
if (messagesEndRef.current && isOpen && !isMinimized) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages.length, isOpen, isMinimized])
// Ref para rastrear se ja marcamos como lidas nesta abertura do chat
const hasMarkedReadRef = useRef<boolean>(false)
// Reset da flag quando fecha ou minimiza o chat
useEffect(() => {
if (!isOpen || isMinimized) {
hasMarkedReadRef.current = false
}
}, [isOpen, isMinimized])
// Marcar mensagens como lidas ao abrir/mostrar chat
// Usa um pequeno delay para garantir que o chat carregou
useEffect(() => {
// So marca quando o widget esta aberto, expandido e a aba esta ativa
if (!isOpen || isMinimized) return
if (!viewerId || !activeTicketId) return
if (typeof document !== "undefined" && document.visibilityState === "hidden") return
// Se ainda nao temos chat carregado, aguardar
if (!chat) return
// Evitar marcar multiplas vezes na mesma abertura
if (hasMarkedReadRef.current) return
const unreadIds = chat.messages
?.filter((msg) => !msg.readBy?.some((r) => r.userId === viewerId))
.map((msg) => msg.id) ?? []
if (unreadIds.length === 0) {
// Mesmo sem mensagens nao lidas, marcar que ja processamos
hasMarkedReadRef.current = true
return
}
// Marcar como lidas com pequeno delay para garantir estabilidade
const timeoutId = setTimeout(() => {
markChatRead({
ticketId: activeTicketId as Id<"tickets">,
actorId: viewerId as Id<"users">,
messageIds: unreadIds,
})
.then(() => {
hasMarkedReadRef.current = true
})
.catch(console.error)
}, 100)
return () => clearTimeout(timeoutId)
}, [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) return
if (!draft.trim() && attachments.length === 0) return
if (draft.length > MAX_MESSAGE_LENGTH) {
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("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()
handleSend()
}
}
// Nao mostrar se esta no Tauri (usa o chat nativo)
if (isTauriContext) return null
// Nao mostrar se nao logado ou sem sessoes
if (!viewerId) return null
if (!activeSessions || activeSessions.length === 0) {
// Limpar estado salvo quando nao ha sessoes
if (typeof window !== "undefined") {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {}
}
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-[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-10 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">Chat</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 && (
<a
href={`/tickets/${activeTicketId}`}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-1 text-xs text-slate-500 hover:text-slate-900 transition-colors"
>
<span>#{activeSession.ticketRef}</span>
{machineHostname && <span> - {machineHostname}</span>}
<ExternalLink className="size-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</a>
)}
</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 border border-red-300 text-red-600 hover:border-red-400 hover:bg-red-50 hover:text-red-700"
>
{isEndingChat ? (
<Spinner className="size-3.5" />
) : (
<XCircle className="size-3.5" />
)}
<span className="hidden sm:inline">Encerrar</span>
</Button>
<button
onClick={() => setIsMinimized(true)}
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-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Fechar"
>
<X className="size-4" />
</button>
</div>
</div>
{/* 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
value={activeTicketId ?? ""}
onValueChange={setActiveTicketId}
>
<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>
)}
{/* 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 ? <MessageCircle className="size-3.5" /> : <User className="size-3.5" />}
</div>
<div
className={cn(
"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"
)}
>
{!isOwn && (
<p className="mb-0.5 text-xs font-medium text-slate-500">
{msg.authorName ?? "Cliente"}
</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>
</div>
</div>
)
})}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* 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 ? (
/* eslint-disable-next-line @next/next/no-img-element */
<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}
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-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() && 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>
)}
{/* 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>
{/* 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}
</span>
)}
<ChevronDown className="size-4" />
</button>
)}
{/* Botão 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 && (
<>
{/* Anel pulsante externo */}
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{totalUnread > 99 ? "99+" : totalUnread}
</span>
</span>
</>
)}
</button>
)}
</div>
)
}