- 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>
961 lines
33 KiB
TypeScript
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>
|
|
)
|
|
}
|