Add chat widget improvements and chat history component
Widget improvements: - Pulsating badge with unread message count on floating button - Clickable ticket reference link in chat header - ExternalLink icon on hover Desktop (Raven) improvements: - Track previous unread count for new message detection - Send native Windows notifications for new messages - Focus chat window when new messages arrive Chat history: - New query getTicketChatHistory for fetching chat sessions and messages - New component TicketChatHistory displaying chat sessions - Sessions can be expanded/collapsed to view messages - Pagination support for long conversations - Added to both dashboard and portal ticket views 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b194d77d57
commit
d766de4fda
6 changed files with 453 additions and 9 deletions
|
|
@ -29,6 +29,7 @@ import {
|
|||
FileText,
|
||||
Image as ImageIcon,
|
||||
Download,
|
||||
ExternalLink,
|
||||
} from "lucide-react"
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 4000
|
||||
|
|
@ -444,10 +445,16 @@ export function ChatWidget() {
|
|||
)}
|
||||
</div>
|
||||
{activeSession && (
|
||||
<p className="text-xs text-slate-500">
|
||||
#{activeSession.ticketRef}
|
||||
{machineHostname && ` - ${machineHostname}`}
|
||||
</p>
|
||||
<a
|
||||
href={`/dashboard/tickets/${activeTicketId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-1 text-xs text-slate-500 hover:text-primary 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>
|
||||
|
|
@ -725,9 +732,15 @@ export function ChatWidget() {
|
|||
>
|
||||
<MessageCircle className="size-6" />
|
||||
{totalUnread > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
|
||||
{totalUnread}
|
||||
</span>
|
||||
<>
|
||||
{/* 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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card"
|
||||
import { TicketChatHistory } from "@/components/tickets/ticket-chat-history"
|
||||
import { TicketCustomFieldsList } from "@/components/tickets/ticket-custom-fields"
|
||||
import { mapTicketCustomFields, formatTicketCustomFieldValue } from "@/lib/ticket-custom-fields"
|
||||
|
||||
|
|
@ -504,6 +505,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
<TicketCsatCard ticket={ticket} />
|
||||
</div>
|
||||
) : null}
|
||||
<TicketChatHistory ticketId={ticketId} />
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between px-5 py-4">
|
||||
|
|
|
|||
298
src/components/tickets/ticket-chat-history.tsx
Normal file
298
src/components/tickets/ticket-chat-history.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery, useAction } from "convex/react"
|
||||
import { formatDistanceToNow, format } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
MessageCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
User,
|
||||
Headphones,
|
||||
Clock,
|
||||
Download,
|
||||
FileText,
|
||||
Paperclip,
|
||||
} from "lucide-react"
|
||||
|
||||
type ChatHistoryProps = {
|
||||
ticketId: string
|
||||
}
|
||||
|
||||
type ChatMessage = {
|
||||
id: string
|
||||
body: string
|
||||
authorName: string
|
||||
authorId: string
|
||||
createdAt: number
|
||||
attachments: Array<{
|
||||
storageId: string
|
||||
name: string
|
||||
type: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
type ChatSession = {
|
||||
sessionId: string
|
||||
agentName: string
|
||||
agentEmail: string | null
|
||||
agentAvatarUrl: string | null
|
||||
machineName: string
|
||||
status: string
|
||||
startedAt: number
|
||||
endedAt: number | null
|
||||
messageCount: number
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
const MESSAGES_PER_PAGE = 20
|
||||
|
||||
function MessageAttachmentPreview({ attachment }: { attachment: { storageId: string; name: string; type: string | null } }) {
|
||||
const getFileUrl = useAction(api.files.getUrl)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleDownload = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = await getFileUrl({ storageId: attachment.storageId as Id<"_storage"> })
|
||||
if (url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = attachment.name
|
||||
a.target = "_blank"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao baixar arquivo:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isImage = attachment.type?.startsWith("image/")
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-md border border-slate-200 bg-slate-50 px-2 py-1 text-xs hover:bg-slate-100 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-3" />
|
||||
) : isImage ? (
|
||||
<FileText className="size-3 text-slate-500" />
|
||||
) : (
|
||||
<Paperclip className="size-3 text-slate-500" />
|
||||
)}
|
||||
<span className="max-w-[100px] truncate">{attachment.name}</span>
|
||||
<Download className="size-3 text-slate-400" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatSessionCard({ session, isExpanded, onToggle }: { session: ChatSession; isExpanded: boolean; onToggle: () => void }) {
|
||||
const [visibleMessages, setVisibleMessages] = useState(MESSAGES_PER_PAGE)
|
||||
|
||||
const isActive = session.status === "ACTIVE"
|
||||
const duration = session.endedAt
|
||||
? Math.round((session.endedAt - session.startedAt) / 60000)
|
||||
: Math.round((Date.now() - session.startedAt) / 60000)
|
||||
|
||||
const loadMore = () => {
|
||||
setVisibleMessages((prev) => prev + MESSAGES_PER_PAGE)
|
||||
}
|
||||
|
||||
const displayedMessages = session.messages.slice(0, visibleMessages)
|
||||
const hasMore = session.messages.length > visibleMessages
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
{/* Header da sessao */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-full",
|
||||
isActive ? "bg-emerald-100 text-emerald-600" : "bg-slate-100 text-slate-600"
|
||||
)}
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
Chat com {session.agentName}
|
||||
</p>
|
||||
{isActive && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Ativo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span>{format(session.startedAt, "dd/MM/yyyy 'as' HH:mm", { locale: ptBR })}</span>
|
||||
<span>-</span>
|
||||
<span>{session.messageCount} mensagens</span>
|
||||
<span>-</span>
|
||||
<span>{duration} min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="size-4 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Mensagens */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-slate-100">
|
||||
{session.messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-6 text-sm text-slate-400">
|
||||
Nenhuma mensagem nesta sessao
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="max-h-[400px] overflow-y-auto p-4 space-y-3 bg-slate-50/50">
|
||||
{displayedMessages.map((msg) => {
|
||||
// Verificar se eh do agente (nao eh da maquina)
|
||||
const isAgent = msg.authorName === session.agentName
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn("flex gap-2", isAgent ? "flex-row" : "flex-row-reverse")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full",
|
||||
isAgent ? "bg-primary/10 text-primary" : "bg-slate-200 text-slate-600"
|
||||
)}
|
||||
>
|
||||
{isAgent ? <Headphones className="size-3.5" /> : <User className="size-3.5" />}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-2xl px-3 py-2",
|
||||
isAgent
|
||||
? "rounded-bl-md bg-white border border-slate-200 text-slate-900"
|
||||
: "rounded-br-md bg-slate-700 text-white"
|
||||
)}
|
||||
>
|
||||
<p className="mb-0.5 text-xs font-medium opacity-70">
|
||||
{msg.authorName}
|
||||
</p>
|
||||
{msg.body && (
|
||||
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
|
||||
)}
|
||||
{msg.attachments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{msg.attachments.map((att, i) => (
|
||||
<MessageAttachmentPreview key={i} attachment={att} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className={cn("mt-1 text-right text-xs", isAgent ? "text-slate-400" : "text-white/60")}>
|
||||
{format(msg.createdAt, "HH:mm", { locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Carregar mais */}
|
||||
{hasMore && (
|
||||
<div className="border-t border-slate-100 p-3 text-center">
|
||||
<Button variant="ghost" size="sm" onClick={loadMore}>
|
||||
Carregar mais ({session.messages.length - visibleMessages} restantes)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Rodape com info de encerramento */}
|
||||
{session.endedAt && (
|
||||
<div className="border-t border-slate-100 px-4 py-2 bg-slate-50">
|
||||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Clock className="size-3" />
|
||||
Encerrado em {format(session.endedAt, "dd/MM/yyyy 'as' HH:mm", { locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TicketChatHistory({ ticketId }: ChatHistoryProps) {
|
||||
const { convexUserId } = useAuth()
|
||||
const [expandedSessions, setExpandedSessions] = useState<Set<string>>(new Set())
|
||||
|
||||
const chatHistory = useQuery(
|
||||
api.liveChat.getTicketChatHistory,
|
||||
convexUserId
|
||||
? { ticketId: ticketId as Id<"tickets">, viewerId: convexUserId as Id<"users"> }
|
||||
: "skip"
|
||||
)
|
||||
|
||||
const toggleSession = (sessionId: string) => {
|
||||
setExpandedSessions((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sessionId)) {
|
||||
next.delete(sessionId)
|
||||
} else {
|
||||
next.add(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Nao mostrar se nao ha historico
|
||||
if (!chatHistory || chatHistory.sessions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="border-b border-slate-100 bg-slate-50/50 px-4 py-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold text-slate-900">
|
||||
<MessageCircle className="size-4" />
|
||||
Historico de Chat
|
||||
<span className="ml-auto text-xs font-normal text-slate-500">
|
||||
{chatHistory.sessions.length} {chatHistory.sessions.length === 1 ? "sessao" : "sessoes"} - {chatHistory.totalMessages} mensagens
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{chatHistory.sessions.map((session: ChatSession) => (
|
||||
<ChatSessionCard
|
||||
key={session.sessionId}
|
||||
session={session}
|
||||
isExpanded={expandedSessions.has(session.sessionId)}
|
||||
onToggle={() => toggleSession(session.sessionId)}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
|||
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
||||
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card";
|
||||
import { TicketChatHistory } from "@/components/tickets/ticket-chat-history";
|
||||
import { useAuth } from "@/lib/auth-client";
|
||||
|
||||
export function TicketDetailView({ id }: { id: string }) {
|
||||
|
|
@ -107,6 +108,7 @@ export function TicketDetailView({ id }: { id: string }) {
|
|||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
<TicketComments ticket={ticket} />
|
||||
<TicketChatHistory ticketId={id} />
|
||||
<TicketTimeline ticket={ticket} />
|
||||
</div>
|
||||
<TicketDetailsPanel ticket={ticket} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue