"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 { cn } from "@/lib/utils"
import { toast } from "sonner"
import {
MessageCircle,
Send,
X,
Minimize2,
User,
ChevronDown,
ChevronLeft,
WifiOff,
XCircle,
Paperclip,
FileText,
Image as ImageIcon,
Download,
ExternalLink,
Eye,
Check,
} from "lucide-react"
import { ChatSessionList } from "./chat-session-list"
const MAX_MESSAGE_LENGTH = 4000
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB
const MAX_ATTACHMENTS = 5
const STORAGE_KEY = "chat-widget-state"
type ViewMode = "list" | "chat"
type ChatWidgetState = {
isOpen: boolean
isMinimized: boolean
activeTicketId: string | null
viewMode: ViewMode
}
function formatTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
})
}
function formatDateSeparator(timestamp: number) {
const date = new Date(timestamp)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const isToday = date.toDateString() === today.toDateString()
const isYesterday = date.toDateString() === yesterday.toDateString()
if (isToday) return "Hoje"
if (isYesterday) return "Ontem"
return date.toLocaleDateString("pt-BR", {
weekday: "long",
day: "2-digit",
month: "long",
})
}
function getDateKey(timestamp: number) {
return new Date(timestamp).toDateString()
}
// Componente separador de data (estilo WhatsApp)
function DateSeparator({ timestamp }: { timestamp: number }) {
return (
{formatDateSeparator(timestamp)}
)
}
type ChatSession = {
ticketId: string
ticketRef: number
ticketSubject: string
sessionId: string
agentId: string
unreadCount: number
lastActivityAt: 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 */}
)
}
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, isStaff } = useAuth()
const viewerId = isStaff ? (convexUserId ?? null) : 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 [viewMode, setViewMode] = useState(() => {
if (typeof window === "undefined") return "list"
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const state = JSON.parse(saved) as ChatWidgetState
return state.viewMode ?? "list"
}
} catch {}
return "list"
})
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 hasInitializedSessionsRef = useRef(false)
const prevSessionIdsRef = useRef>(new Set())
// 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)
setViewMode(state.viewMode ?? "list")
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,
viewMode,
}
// Salvar no localStorage (isso dispara evento storage em outras abas automaticamente)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch {}
}, [isOpen, isMinimized, activeTicketId, viewMode])
// Auto-selecionar modo e sessao baseado na quantidade de sessoes
useEffect(() => {
if (!activeSessions) return
if (activeSessions.length === 0) {
// Sem sessoes, limpar estado
setActiveTicketId(null)
setViewMode("list")
} else if (activeSessions.length === 1) {
// Apenas 1 sessao, ir direto para chat
setActiveTicketId(activeSessions[0].ticketId)
setViewMode("chat")
} else if (!activeTicketId) {
// Multiplas sessoes mas nenhuma selecionada, mostrar lista
setViewMode("list")
}
}, [activeSessions, activeTicketId])
// Auto-abrir o widget quando ESTE agente iniciar uma nova sessão de chat.
// Nao roda na montagem inicial para nao sobrescrever o estado do localStorage.
useEffect(() => {
if (!activeSessions) return
const currentIds = new Set(activeSessions.map((s) => s.sessionId))
if (!hasInitializedSessionsRef.current) {
prevSessionIdsRef.current = currentIds
hasInitializedSessionsRef.current = true
return
}
const newSessions = activeSessions.filter((s) => !prevSessionIdsRef.current.has(s.sessionId))
prevSessionIdsRef.current = currentIds
if (newSessions.length === 0) return
if (!viewerId) return
const mine = newSessions.find((s) => s.agentId === viewerId) ?? null
if (!mine) return
setIsOpen(true)
setIsMinimized(false)
setActiveTicketId(mine.ticketId)
}, [activeSessions, viewerId])
// 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()
// Scroll para o final após enviar (com pequeno delay para garantir que a mensagem foi renderizada)
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, 100)
} 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()
}
}
// Handlers para navegacao lista/chat
const handleSelectSession = (ticketId: string) => {
setActiveTicketId(ticketId)
setViewMode("chat")
}
const handleBackToList = () => {
setViewMode("list")
}
// 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 && (
{/* Modo Lista - mostra quando viewMode === "list" e ha multiplas sessoes */}
{viewMode === "list" && activeSessions.length > 1 ? (
setIsOpen(false)}
onMinimize={() => setIsMinimized(true)}
/>
) : (
<>
{/* Header - Modo Chat */}
{/* Botao voltar para lista (quando ha multiplas sessoes) */}
{activeSessions.length > 1 && (
)}
#{activeSession?.ticketRef ?? activeTicketId?.slice(-4)}
{/* Indicador online/offline */}
{liveChat?.hasMachine && (
machineOnline ? (
Online
) : (
Offline
)
)}
{activeSession && (
)}
{/* Botao encerrar chat */}
{/* 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, index) => {
const isOwn = String(msg.authorId) === String(viewerId)
const bodyText = msg.body?.trim() ?? ""
const shouldShowBody =
bodyText.length > 0 &&
!(bodyText === "[Anexo]" && (msg.attachments?.length ?? 0) > 0)
// Verificar se precisa mostrar separador de data
const prevMsg = index > 0 ? messages[index - 1] : null
const showDateSeparator = !prevMsg || getDateKey(msg.createdAt) !== getDateKey(prevMsg.createdAt)
return (
{showDateSeparator &&
}
{isOwn ? : }
{!isOwn && (
{msg.authorName ?? "Cliente"}
)}
{shouldShowBody &&
{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}
))}
{isUploading && (
)}
)}
{/* Input com dropzone */}
>
)}
)}
{/* Widget minimizado */}
{isOpen && isMinimized && (
)}
{/* Botão flutuante */}
{!isOpen && (
)}
)
}