Add live ticket animations and fix sidebar hydration

This commit is contained in:
codex-bot 2025-10-24 17:24:51 -03:00
parent 2a9170f7dd
commit ddbf019d12
6 changed files with 90 additions and 32 deletions

View file

@ -39,6 +39,13 @@ Este documento consolida o estado atual do ambiente de desenvolvimento, descreve
- **Turbopack** segue como bundler padrão, sem flags experimentais adicionais. - **Turbopack** segue como bundler padrão, sem flags experimentais adicionais.
- **Whitelist de hosts**: o release estável 15.5 não aceita `server.allowedHosts` (vide [`invalid-next-config`](https://nextjs.org/docs/messages/invalid-next-config)), portanto bloqueamos domínios exclusivamente via `middleware.ts`. - **Whitelist de hosts**: o release estável 15.5 não aceita `server.allowedHosts` (vide [`invalid-next-config`](https://nextjs.org/docs/messages/invalid-next-config)), portanto bloqueamos domínios exclusivamente via `middleware.ts`.
### Editor rich text (TipTap) — menções de ticket
- Menções (`ticketMention`) agora têm prioridade maior (`priority: 1000`) e um Link seguro (`SafeLinkExtension`) foi introduzido para ignorar `<a data-ticket-mention="true">`. Isso evita que o `Link` do StarterKit capture as âncoras na hidratação, garantindo que as menções continuem como nodes (não como marks) durante a edição.
- O mesmo helper `normalizeTicketMentionHtml` é aplicado ao carregar/atualizar conteúdo no editor, dentro dos fluxos de comentários e no Convex. Esse helper reescreve qualquer HTML legado (`#123•Assunto`) no formato de chip completo (datasets, spans, dot).
- Resultado: o chip mantém layout e comportamento ao editar (Backspace/Delete removem o node inteiro, node view continua ativo) sem exigir reload.
- Se precisar adicionar novos comportamentos, importe `SafeLinkExtension` e mantenha a ordem `[TicketMentionExtension, StarterKit (link:false), SafeLinkExtension, Placeholder]` para que o parser continue estável.
## Comandos de qualidade ## Comandos de qualidade
- `pnpm lint`: executa ESLint (flat config) sobre os arquivos do projeto. - `pnpm lint`: executa ESLint (flat config) sobre os arquivos do projeto.

View file

@ -46,20 +46,22 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription> <CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
<CardTitle className="text-lg font-semibold text-neutral-900">{queue.name}</CardTitle> <CardTitle className="text-lg font-semibold text-neutral-900">{queue.name}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3 text-sm text-neutral-600"> <CardContent className="space-y-4">
<div className="flex justify-between"> <div className="grid gap-3 sm:grid-cols-3">
<span>Pendentes</span> <div className="flex h-full flex-col justify-between gap-2 rounded-xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-100 px-4 py-3 shadow-sm">
<span className="font-semibold text-neutral-900">{queue.pending}</span> <p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Pendentes</p>
<p className="text-3xl font-bold tracking-tight text-neutral-900 tabular-nums">{queue.pending}</p>
</div> </div>
<div className="flex justify-between"> <div className="flex h-full flex-col justify-between gap-2 rounded-xl border border-sky-200 bg-gradient-to-br from-white via-white to-sky-50 px-4 py-3 shadow-sm">
<span>Aguardando resposta</span> <p className="text-xs font-semibold uppercase tracking-wide text-sky-700">Aguardando resposta</p>
<span className="font-semibold text-neutral-900">{queue.waiting}</span> <p className="text-3xl font-bold tracking-tight text-sky-700 tabular-nums">{queue.waiting}</p>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex h-full flex-col justify-between gap-2 rounded-xl border border-amber-200 bg-gradient-to-br from-white via-white to-amber-50 px-4 py-3 shadow-sm">
<span>Violados</span> <p className="text-xs font-semibold uppercase tracking-wide text-amber-700">Violados</p>
<span className="font-semibold text-red-600">{queue.breached}</span> <p className="text-3xl font-bold tracking-tight text-amber-700 tabular-nums">{queue.breached}</p>
</div> </div>
<div className="pt-1.5"> </div>
<div className="pt-1">
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" /> <Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
<span className="mt-2 block text-xs text-neutral-500"> <span className="mt-2 block text-xs text-neutral-500">
{breachPercent}% com SLA violado nesta fila {breachPercent}% com SLA violado nesta fila

View file

@ -14,13 +14,14 @@ import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-sta
type TicketsBoardProps = { type TicketsBoardProps = {
tickets: Ticket[] tickets: Ticket[]
enteringIds?: Set<string>
} }
function formatUpdated(date: Date) { function formatUpdated(date: Date) {
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR }) return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
} }
export function TicketsBoard({ tickets }: TicketsBoardProps) { export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
if (!tickets.length) { if (!tickets.length) {
return ( return (
<div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm"> <div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm">
@ -41,11 +42,16 @@ export function TicketsBoard({ tickets }: TicketsBoardProps) {
return ( return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{tickets.map((ticket) => ( {tickets.map((ticket) => {
const isEntering = enteringIds?.has(ticket.id)
return (
<Link <Link
key={ticket.id} key={ticket.id}
href={`/tickets/${ticket.id}`} href={`/tickets/${ticket.id}`}
className="group block h-full rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300" className={cn(
"group block h-full rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300",
isEntering ? "recent-ticket-enter" : ""
)}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@ -103,7 +109,8 @@ export function TicketsBoard({ tickets }: TicketsBoardProps) {
</div> </div>
</dl> </dl>
</Link> </Link>
))} )
})}
</div> </div>
) )
} }

View file

@ -95,9 +95,10 @@ function AssigneeCell({ ticket }: { ticket: Ticket }) {
export type TicketsTableProps = { export type TicketsTableProps = {
tickets?: Ticket[] tickets?: Ticket[]
enteringIds?: Set<string>
} }
export function TicketsTable({ tickets }: TicketsTableProps) { export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
const safeTickets = tickets ?? [] const safeTickets = tickets ?? []
const [now, setNow] = useState(() => Date.now()) const [now, setNow] = useState(() => Date.now())
const serverOffsetRef = useRef<number>(0) const serverOffsetRef = useRef<number>(0)
@ -175,11 +176,15 @@ export function TicketsTable({ tickets }: TicketsTableProps) {
<TableBody> <TableBody>
{safeTickets.map((ticket) => { {safeTickets.map((ticket) => {
const ChannelIcon = channelIcon[ticket.channel] ?? MessageCircle const ChannelIcon = channelIcon[ticket.channel] ?? MessageCircle
const rowClass = cn(
`${tableRowClass} cursor-pointer`,
enteringIds?.has(ticket.id) ? "recent-ticket-enter" : undefined,
)
return ( return (
<TableRow <TableRow
key={ticket.id} key={ticket.id}
className={`${tableRowClass} cursor-pointer`} className={rowClass}
role="link" role="link"
tabIndex={0} tabIndex={0}
onClick={() => router.push(`/tickets/${ticket.id}`)} onClick={() => router.push(`/tickets/${ticket.id}`)}

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { useQuery } from "convex/react" import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
@ -165,6 +165,27 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
return working return working
}, [tickets, filters.queue, filters.status, filters.view, filters.company]) }, [tickets, filters.queue, filters.status, filters.view, filters.company])
const previousIdsRef = useRef<string[]>([])
const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set())
useEffect(() => {
if (ticketsRaw === undefined) {
previousIdsRef.current = []
setEnteringIds(new Set())
return
}
const ids = filteredTickets.map((ticket) => ticket.id)
const previous = previousIdsRef.current
previousIdsRef.current = ids
if (!previous.length) return
const newIds = ids.filter((id) => !previous.includes(id))
if (newIds.length === 0) return
const highlight = new Set(newIds)
setEnteringIds(highlight)
const timeout = window.setTimeout(() => setEnteringIds(new Set()), 600)
return () => window.clearTimeout(timeout)
}, [filteredTickets, ticketsRaw])
return ( return (
<div className="flex flex-col gap-6 px-4 lg:px-6"> <div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters <TicketsFilters
@ -223,9 +244,9 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
</div> </div>
</div> </div>
) : viewMode === "board" ? ( ) : viewMode === "board" ? (
<TicketsBoard tickets={filteredTickets} /> <TicketsBoard tickets={filteredTickets} enteringIds={enteringIds} />
) : ( ) : (
<TicketsTable tickets={filteredTickets} /> <TicketsTable tickets={filteredTickets} enteringIds={enteringIds} />
)} )}
</div> </div>
) )

View file

@ -258,8 +258,24 @@ function SidebarTrigger({
className, className,
onClick, onClick,
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button> & { fallback?: ReactNode }) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar()
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(true)
}, [])
if (!hydrated) {
return (
<div
data-sidebar="trigger"
data-slot="sidebar-trigger"
className={cn("size-7 rounded-full border border-slate-200 bg-white", className)}
role="presentation"
/>
)
}
return ( return (
<Button <Button