Fix lint errors and improve chat UI

Lint fixes:
- Move HIDDEN_EVENT_TYPES constant outside component to fix useMemo dependency
- Add eslint-disable comments for img elements using blob URLs

Chat widget improvements:
- Add view and download buttons with loading and success indicators
- Click image to open in new tab, download button to save file
- Show check icon after successful download

Chat history fixes:
- Fix title to "Histórico de chat" with proper accents
- Change agent icon from Headphones to MessageCircle
- Change agent icon background from primary to gray

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-07 03:39:14 -03:00
parent f0882c612f
commit ebeda62cfb
3 changed files with 74 additions and 23 deletions

View file

@ -1,5 +1,6 @@
"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"
@ -30,6 +31,8 @@ import {
Image as ImageIcon,
Download,
ExternalLink,
Eye,
Check,
} from "lucide-react"
const MAX_MESSAGE_LENGTH = 4000
@ -122,9 +125,17 @@ function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
}, [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) return
if (!url || downloading) return
setDownloading(true)
try {
const response = await fetch(url)
const blob = await response.blob()
@ -136,8 +147,12 @@ function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
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)
}
}
@ -151,31 +166,67 @@ function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
if (isImage && url) {
return (
<button
onClick={handleDownload}
className="group relative overflow-hidden rounded-lg border border-slate-200"
>
<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 object-cover"
className="size-16 cursor-pointer object-cover"
onClick={handleView}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<Download className="size-4 text-white" />
<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>
</button>
</div>
)
}
return (
<button
onClick={handleDownload}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-2 py-1.5 text-xs hover:bg-slate-100"
>
<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>
<Download className="size-3 text-slate-400" />
</button>
<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>
)
}
@ -598,6 +649,7 @@ export function ChatWidget() {
{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}

View file

@ -16,7 +16,6 @@ import {
ChevronDown,
ChevronRight,
User,
Headphones,
Clock,
Download,
FileText,
@ -183,10 +182,10 @@ function ChatSessionCard({ session, isExpanded, onToggle }: { session: ChatSessi
<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 ? "bg-slate-100 text-slate-600" : "bg-slate-200 text-slate-600"
)}
>
{isAgent ? <Headphones className="size-3.5" /> : <User className="size-3.5" />}
{isAgent ? <MessageCircle className="size-3.5" /> : <User className="size-3.5" />}
</div>
<div
className={cn(
@ -277,9 +276,9 @@ export function TicketChatHistory({ ticketId }: ChatHistoryProps) {
<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
Histórico 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
{chatHistory.sessions.length} {chatHistory.sessions.length === 1 ? "sessão" : "sessões"} - {chatHistory.totalMessages} mensagens
</span>
</CardTitle>
</CardHeader>

View file

@ -58,6 +58,9 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
const timelineLabels: Record<string, string> = TICKET_TIMELINE_LABELS
// Tipos de eventos que não devem aparecer na timeline
const HIDDEN_EVENT_TYPES = ["CHAT_MESSAGE_ADDED"]
interface TicketTimelineProps {
ticket: TicketWithDetails
}
@ -82,9 +85,6 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
const [page, setPage] = useState(1)
// Tipos de eventos que não devem aparecer na timeline
const HIDDEN_EVENT_TYPES = ["CHAT_MESSAGE_ADDED"]
const sortedTimeline = useMemo(
() => [...ticket.timeline]
.filter((event) => !HIDDEN_EVENT_TYPES.includes(event.type))