"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(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 (
) } if (isImage && url) { return (
{/* eslint-disable-next-line @next/next/no-img-element */} {attachment.name}
) } return (
{attachment.name}
) } 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(() => { 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([]) const [isUploading, setIsUploading] = useState(false) const [isDragging, setIsDragging] = useState(false) const messagesEndRef = useRef(null) const inputRef = useRef(null) const fileInputRef = useRef(null) const dropAreaRef = useRef(null) const prevSessionCountRef = useRef(-1) // -1 indica "ainda nao inicializado" const hasRestoredStateRef = useRef(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(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) => { 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 (
{/* Widget aberto */} {isOpen && !isMinimized && (
{/* Header - Estilo card da aplicação */}

Chat

{/* Indicador online/offline */} {liveChat?.hasMachine && ( machineOnline ? ( Online ) : ( Offline ) )}
{activeSession && ( #{activeSession.ticketRef} {machineHostname && - {machineHostname}} )}
{/* Botão encerrar chat */}
{/* Seletor de sessões (se mais de uma) */} {activeSessions.length > 1 && (
)} {/* Aviso de máquina offline */} {liveChat?.hasMachine && !machineOnline && (

A máquina está offline. Mensagens serão entregues quando voltar.

)} {/* Mensagens */}
{!chat ? (
) : messages.length === 0 ? (

Nenhuma mensagem

Envie uma mensagem para o cliente

) : (
{messages.map((msg) => { const isOwn = String(msg.authorId) === String(viewerId) return (
{isOwn ? : }
{!isOwn && (

{msg.authorName ?? "Cliente"}

)} {msg.body && (

{msg.body}

)} {/* Anexos da mensagem */} {msg.attachments && msg.attachments.length > 0 && (
{msg.attachments.map((att, i) => ( ))}
)}

{formatTime(msg.createdAt)}

) })}
)}
{/* Preview de anexos pendentes */} {attachments.length > 0 && (
{attachments.map((file, index) => (
{file.type?.startsWith("image/") && file.previewUrl ? ( /* eslint-disable-next-line @next/next/no-img-element */ {file.name} ) : (
)}

{file.name}

))} {isUploading && (
)}
)} {/* Input com dropzone */}
{/* Overlay de drag */} {isDragging && (

Solte os arquivos aqui

)}