597 lines
27 KiB
TypeScript
597 lines
27 KiB
TypeScript
import Link from "next/link"
|
|
import { useEffect, useMemo, useState, type ReactNode, type ComponentType } from "react"
|
|
import { format } from "date-fns"
|
|
import { ptBR } from "date-fns/locale"
|
|
import {
|
|
IconCalendar,
|
|
IconClockHour4,
|
|
IconFolders,
|
|
IconNote,
|
|
IconPaperclip,
|
|
IconSquareCheck,
|
|
IconStar,
|
|
IconUserCircle,
|
|
IconLink,
|
|
} from "@tabler/icons-react"
|
|
|
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
|
import { Card, CardContent } from "@/components/ui/card"
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import {
|
|
Pagination,
|
|
PaginationContent,
|
|
PaginationEllipsis,
|
|
PaginationItem,
|
|
PaginationLink,
|
|
PaginationNext,
|
|
PaginationPrevious,
|
|
} from "@/components/ui/pagination"
|
|
import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels"
|
|
import { formatTicketCustomFieldValue } from "@/lib/ticket-custom-fields"
|
|
|
|
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
|
CREATED: IconUserCircle,
|
|
STATUS_CHANGED: IconSquareCheck,
|
|
ASSIGNEE_CHANGED: IconUserCircle,
|
|
COMMENT_ADDED: IconNote,
|
|
COMMENT_EDITED: IconNote,
|
|
WORK_STARTED: IconClockHour4,
|
|
WORK_PAUSED: IconClockHour4,
|
|
SUBJECT_CHANGED: IconNote,
|
|
SUMMARY_CHANGED: IconNote,
|
|
QUEUE_CHANGED: IconSquareCheck,
|
|
PRIORITY_CHANGED: IconSquareCheck,
|
|
ATTACHMENT_REMOVED: IconPaperclip,
|
|
CATEGORY_CHANGED: IconFolders,
|
|
REQUESTER_CHANGED: IconUserCircle,
|
|
MANAGER_NOTIFIED: IconUserCircle,
|
|
VISIT_SCHEDULED: IconCalendar,
|
|
CSAT_RECEIVED: IconStar,
|
|
CSAT_RATED: IconStar,
|
|
TICKET_LINKED: IconLink,
|
|
}
|
|
|
|
const timelineLabels: Record<string, string> = TICKET_TIMELINE_LABELS
|
|
|
|
interface TicketTimelineProps {
|
|
ticket: TicketWithDetails
|
|
}
|
|
|
|
const ITEMS_PER_PAGE = 10
|
|
|
|
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|
const formatDuration = (durationMs: number) => {
|
|
if (!durationMs || durationMs <= 0) return "0s"
|
|
const totalSeconds = Math.floor(durationMs / 1000)
|
|
const hours = Math.floor(totalSeconds / 3600)
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
const seconds = totalSeconds % 60
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
|
|
}
|
|
if (minutes > 0) {
|
|
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
|
|
}
|
|
return `${seconds}s`
|
|
}
|
|
|
|
const [page, setPage] = useState(1)
|
|
|
|
const totalItems = ticket.timeline.length
|
|
const totalPages = Math.max(1, Math.ceil(totalItems / ITEMS_PER_PAGE))
|
|
const currentPage = Math.min(page, totalPages)
|
|
const pageOffset = (currentPage - 1) * ITEMS_PER_PAGE
|
|
|
|
const currentEvents = useMemo(
|
|
() => ticket.timeline.slice(pageOffset, pageOffset + ITEMS_PER_PAGE),
|
|
[pageOffset, ticket.timeline]
|
|
)
|
|
|
|
const paginationRange = useMemo(() => {
|
|
if (totalPages <= 7) {
|
|
return Array.from({ length: totalPages }, (_, index) => index + 1)
|
|
}
|
|
const range: Array<number | "ellipsis-left" | "ellipsis-right"> = [1]
|
|
const left = Math.max(2, currentPage - 1)
|
|
const right = Math.min(totalPages - 1, currentPage + 1)
|
|
if (left > 2) {
|
|
range.push("ellipsis-left")
|
|
}
|
|
for (let i = left; i <= right; i += 1) {
|
|
range.push(i)
|
|
}
|
|
if (right < totalPages - 1) {
|
|
range.push("ellipsis-right")
|
|
}
|
|
range.push(totalPages)
|
|
return range
|
|
}, [currentPage, totalPages])
|
|
|
|
const rangeStart = totalItems === 0 ? 0 : pageOffset + 1
|
|
const rangeEnd = totalItems === 0 ? 0 : Math.min(pageOffset + ITEMS_PER_PAGE, totalItems)
|
|
|
|
useEffect(() => {
|
|
setPage(1)
|
|
}, [ticket.id])
|
|
|
|
useEffect(() => {
|
|
if (page > totalPages) {
|
|
setPage(totalPages)
|
|
}
|
|
}, [page, totalPages])
|
|
|
|
if (totalItems === 0) {
|
|
return (
|
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<CardContent className="px-4 py-10">
|
|
<p className="text-center text-sm text-neutral-500">
|
|
Nenhum evento registrado neste ticket ainda.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<CardContent className="space-y-6 px-4 pb-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 pb-4">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-neutral-900">Linha do tempo</h3>
|
|
<p className="text-sm text-neutral-500">
|
|
Mostrando {rangeStart}-{rangeEnd} de {totalItems} eventos
|
|
</p>
|
|
</div>
|
|
{totalPages > 1 ? (
|
|
<Pagination>
|
|
<PaginationContent>
|
|
<PaginationItem>
|
|
<PaginationPrevious
|
|
disabled={currentPage === 1}
|
|
onClick={() => setPage((previous) => Math.max(1, previous - 1))}
|
|
/>
|
|
</PaginationItem>
|
|
{paginationRange.map((item, index) => {
|
|
if (typeof item === "number") {
|
|
return (
|
|
<PaginationItem key={`page-${item}`}>
|
|
<PaginationLink
|
|
href="#"
|
|
isActive={item === currentPage}
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
setPage(item)
|
|
}}
|
|
>
|
|
{item}
|
|
</PaginationLink>
|
|
</PaginationItem>
|
|
)
|
|
}
|
|
return (
|
|
<PaginationItem key={`ellipsis-${item}-${index}`}>
|
|
<PaginationEllipsis />
|
|
</PaginationItem>
|
|
)
|
|
})}
|
|
<PaginationItem>
|
|
<PaginationNext
|
|
disabled={currentPage === totalPages}
|
|
onClick={() => setPage((previous) => Math.min(totalPages, previous + 1))}
|
|
/>
|
|
</PaginationItem>
|
|
</PaginationContent>
|
|
</Pagination>
|
|
) : null}
|
|
</div>
|
|
|
|
{currentEvents.map((entry, index) => {
|
|
const Icon = timelineIcons[entry.type] ?? IconClockHour4
|
|
const isLastGlobal = pageOffset + index === totalItems - 1
|
|
return (
|
|
<div key={entry.id} className="relative pl-11">
|
|
{!isLastGlobal && (
|
|
<span className="absolute left-[14px] top-6 h-full w-px bg-slate-200" aria-hidden />
|
|
)}
|
|
<span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-700 shadow-sm">
|
|
<Icon className="size-4" />
|
|
</span>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
<span className="text-sm font-semibold text-neutral-900">
|
|
{timelineLabels[entry.type] ?? entry.type}
|
|
</span>
|
|
{entry.payload?.actorName ? (
|
|
<span className="flex items-center gap-1 text-xs text-neutral-500">
|
|
<Avatar className="size-5 border border-slate-200">
|
|
<AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} />
|
|
<AvatarFallback>
|
|
{String(entry.payload?.actorName ?? "").split(" ").slice(0, 2).map((part: string) => part[0]).join("").toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
por {String(entry.payload?.actorName ?? "")}
|
|
</span>
|
|
) : null}
|
|
<span className="text-xs text-neutral-500">
|
|
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
|
|
</span>
|
|
</div>
|
|
{(() => {
|
|
const payload = (entry.payload || {}) as {
|
|
toLabel?: string
|
|
to?: string
|
|
assigneeName?: string
|
|
assigneeId?: string
|
|
queueName?: string
|
|
queueId?: string
|
|
requesterName?: string
|
|
authorName?: string
|
|
authorId?: string
|
|
actorName?: string
|
|
actorId?: string
|
|
from?: string
|
|
attachmentName?: string
|
|
sessionDurationMs?: number
|
|
categoryName?: string
|
|
subcategoryName?: string
|
|
pauseReason?: string
|
|
pauseReasonLabel?: string
|
|
pauseNote?: string
|
|
managerName?: string
|
|
managerUserName?: string
|
|
manager?: string
|
|
managerId?: string
|
|
managerUserId?: string
|
|
scheduledFor?: number | string
|
|
scheduledAt?: number | string
|
|
score?: number
|
|
rating?: number
|
|
maxScore?: number
|
|
max?: number
|
|
}
|
|
|
|
let message: ReactNode = null
|
|
if (entry.type === "TICKET_LINKED") {
|
|
const payloadLink = payload as {
|
|
linkedTicketId?: string
|
|
linkedReference?: number | string | null
|
|
linkedSubject?: string | null
|
|
kind?: string | null
|
|
}
|
|
const linkedTicketId =
|
|
typeof payloadLink.linkedTicketId === "string" && payloadLink.linkedTicketId.trim().length > 0
|
|
? payloadLink.linkedTicketId.trim()
|
|
: null
|
|
const referenceValue = payloadLink.linkedReference
|
|
const referenceLabelRaw =
|
|
typeof referenceValue === "number"
|
|
? referenceValue.toString()
|
|
: typeof referenceValue === "string"
|
|
? referenceValue.trim()
|
|
: null
|
|
const referenceLabel = referenceLabelRaw && referenceLabelRaw.length > 0 ? referenceLabelRaw : null
|
|
const linkContent = referenceLabel ? `#${referenceLabel}` : "Ver ticket"
|
|
const subject =
|
|
typeof payloadLink.linkedSubject === "string" && payloadLink.linkedSubject.trim().length > 0
|
|
? payloadLink.linkedSubject.trim()
|
|
: null
|
|
const kindRaw =
|
|
typeof payloadLink.kind === "string" && payloadLink.kind.trim().length > 0
|
|
? payloadLink.kind.trim()
|
|
: "related"
|
|
const labelPrefix =
|
|
kindRaw === "resolution_parent"
|
|
? "Ticket vinculado a este chamado"
|
|
: kindRaw === "resolved_with"
|
|
? "Ticket resolvido com"
|
|
: "Ticket vinculado"
|
|
message = (
|
|
<div className="space-y-1">
|
|
<span className="block text-sm text-neutral-600">
|
|
<span className="font-semibold text-neutral-800">{labelPrefix}</span>{" "}
|
|
{linkedTicketId ? (
|
|
<Link
|
|
href={`/tickets/${linkedTicketId}`}
|
|
className="font-semibold text-primary transition hover:text-primary/80 hover:underline"
|
|
>
|
|
{linkContent}
|
|
</Link>
|
|
) : (
|
|
<span className="font-medium text-neutral-800">{linkContent}</span>
|
|
)}
|
|
{subject ? <span className="text-neutral-500"> — {subject}</span> : null}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
if (entry.type === "CUSTOM_FIELDS_UPDATED") {
|
|
type FieldPayload = {
|
|
fieldKey?: string
|
|
label?: string
|
|
type?: string
|
|
previousValue?: unknown
|
|
nextValue?: unknown
|
|
previousDisplayValue?: string | null
|
|
nextDisplayValue?: string | null
|
|
changeType?: string
|
|
}
|
|
const payloadFields = Array.isArray((payload as { fields?: unknown }).fields)
|
|
? ((payload as { fields?: FieldPayload[] }).fields ?? [])
|
|
: []
|
|
const hasValueDetails = payloadFields.some((field) => {
|
|
if (!field) return false
|
|
return (
|
|
Object.prototype.hasOwnProperty.call(field, "previousValue") ||
|
|
Object.prototype.hasOwnProperty.call(field, "nextValue") ||
|
|
Object.prototype.hasOwnProperty.call(field, "previousDisplayValue") ||
|
|
Object.prototype.hasOwnProperty.call(field, "nextDisplayValue")
|
|
)
|
|
})
|
|
if (hasValueDetails && payloadFields.length > 0) {
|
|
message = (
|
|
<div className="space-y-1">
|
|
<span className="block text-sm font-semibold text-neutral-800">
|
|
Campos personalizados atualizados
|
|
</span>
|
|
<ul className="mt-1 space-y-0.5 text-xs text-neutral-600">
|
|
{payloadFields.map((field, index) => {
|
|
const label =
|
|
typeof field?.label === "string" && field.label.trim().length > 0
|
|
? field.label.trim()
|
|
: `Campo ${index + 1}`
|
|
const baseType =
|
|
typeof field?.type === "string" && field.type.trim().length > 0
|
|
? field.type
|
|
: "text"
|
|
const previousFormatted = formatTicketCustomFieldValue({
|
|
type: baseType,
|
|
value: field?.previousValue,
|
|
displayValue:
|
|
typeof field?.previousDisplayValue === "string" && field.previousDisplayValue.trim().length > 0
|
|
? field.previousDisplayValue
|
|
: undefined,
|
|
})
|
|
const nextFormatted = formatTicketCustomFieldValue({
|
|
type: baseType,
|
|
value: field?.nextValue,
|
|
displayValue:
|
|
typeof field?.nextDisplayValue === "string" && field.nextDisplayValue.trim().length > 0
|
|
? field.nextDisplayValue
|
|
: undefined,
|
|
})
|
|
return (
|
|
<li key={`${entry.id}-${field?.fieldKey ?? label}`}>
|
|
<span className="font-semibold text-neutral-800">{label}</span>{" "}
|
|
<span className="text-neutral-500">
|
|
{previousFormatted} → {nextFormatted}
|
|
</span>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</div>
|
|
)
|
|
} else {
|
|
const fieldLabels = payloadFields
|
|
.map((field) => (typeof field?.label === "string" ? field.label.trim() : ""))
|
|
.filter((label) => label.length > 0)
|
|
message = (
|
|
<div className="space-y-1">
|
|
<span className="block text-sm text-neutral-600">
|
|
<span className="font-semibold text-neutral-800">Campos personalizados atualizados</span>
|
|
{fieldLabels.length > 0 ? (
|
|
<span className="text-neutral-500"> ({fieldLabels.join(", ")})</span>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
}
|
|
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
|
|
message = "Status alterado para " + (payload.toLabel || payload.to)
|
|
}
|
|
if (entry.type === "ASSIGNEE_CHANGED") {
|
|
const nextAssigneeRaw = payload.assigneeName ?? payload.assigneeId ?? null
|
|
const previousAssigneeRaw = (payload as { previousAssigneeName?: unknown }).previousAssigneeName ?? null
|
|
const nextAssignee =
|
|
typeof nextAssigneeRaw === "string" && nextAssigneeRaw.trim().length > 0
|
|
? nextAssigneeRaw.trim()
|
|
: typeof nextAssigneeRaw === "number"
|
|
? String(nextAssigneeRaw)
|
|
: null
|
|
const previousAssignee =
|
|
typeof previousAssigneeRaw === "string" && previousAssigneeRaw.trim().length > 0
|
|
? previousAssigneeRaw.trim()
|
|
: null
|
|
const reasonRaw = (payload as { reason?: unknown }).reason
|
|
const reason =
|
|
typeof reasonRaw === "string" && reasonRaw.trim().length > 0
|
|
? reasonRaw.trim()
|
|
: null
|
|
message = (
|
|
<div className="space-y-1">
|
|
<span className="block text-sm text-neutral-600">
|
|
<span className="font-semibold text-neutral-800">Responsável alterado</span>
|
|
{nextAssignee ? (
|
|
<>
|
|
{" "}
|
|
para <span className="font-semibold text-neutral-900">{nextAssignee}</span>
|
|
</>
|
|
) : (
|
|
<>.</>
|
|
)}
|
|
{previousAssignee ? (
|
|
<span className="text-neutral-500"> (antes: {previousAssignee})</span>
|
|
) : null}
|
|
</span>
|
|
{reason ? (
|
|
<p className="whitespace-pre-line rounded-lg bg-slate-100 px-3 py-2 text-xs text-neutral-600">
|
|
{reason}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) {
|
|
message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "")
|
|
}
|
|
if (entry.type === "REQUESTER_CHANGED") {
|
|
const payloadRequest = payload as {
|
|
requesterName?: string | null
|
|
requesterEmail?: string | null
|
|
requesterId?: string | null
|
|
companyName?: string | null
|
|
}
|
|
const name = payloadRequest.requesterName?.trim()
|
|
const email = payloadRequest.requesterEmail?.trim()
|
|
const fallback = payloadRequest.requesterId ?? null
|
|
const identifier = name ? (email ? `${name} · ${email}` : name) : email ?? fallback
|
|
const company = payloadRequest.companyName?.trim()
|
|
if (identifier && company) {
|
|
message = `Solicitante alterado para ${identifier} • Empresa: ${company}`
|
|
} else if (identifier) {
|
|
message = `Solicitante alterado para ${identifier}`
|
|
} else if (company) {
|
|
message = `Solicitante associado à empresa ${company}`
|
|
} else {
|
|
message = "Solicitante alterado"
|
|
}
|
|
}
|
|
if (entry.type === "PRIORITY_CHANGED" && (payload.toLabel || payload.to)) {
|
|
message = "Prioridade alterada para " + (payload.toLabel || payload.to)
|
|
}
|
|
if (entry.type === "CREATED" && payload.requesterName) {
|
|
message = "Criado por " + payload.requesterName
|
|
}
|
|
if (entry.type === "COMMENT_ADDED" && (payload.authorName || payload.authorId)) {
|
|
message = "Comentário adicionado" + (payload.authorName ? " por " + payload.authorName : "")
|
|
}
|
|
if (entry.type === "COMMENT_EDITED" && (payload.actorName || payload.actorId || payload.authorName)) {
|
|
const name = payload.actorName ?? payload.authorName
|
|
message = "Comentário editado" + (name ? " por " + name : "")
|
|
}
|
|
if (entry.type === "SUBJECT_CHANGED" && (payload.to || payload.toLabel)) {
|
|
message = "Assunto alterado" + (payload.to ? " para \"" + payload.to + "\"" : "")
|
|
}
|
|
if (entry.type === "SUMMARY_CHANGED") {
|
|
message = "Resumo atualizado"
|
|
}
|
|
if (entry.type === "ATTACHMENT_REMOVED" && payload.attachmentName) {
|
|
message = `Anexo removido: ${payload.attachmentName}`
|
|
}
|
|
if (entry.type === "WORK_PAUSED") {
|
|
const parts: string[] = []
|
|
if (payload.pauseReasonLabel || payload.pauseReason) {
|
|
parts.push(`Motivo: ${payload.pauseReasonLabel ?? payload.pauseReason}`)
|
|
}
|
|
if (typeof payload.sessionDurationMs === "number") {
|
|
parts.push(`Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`)
|
|
}
|
|
message = (
|
|
<div className="space-y-1">
|
|
<span>{parts.length > 0 ? parts.join(" • ") : "Atendimento pausado"}</span>
|
|
{payload.pauseNote ? (
|
|
<span className="block text-xs text-neutral-500">Observação: {payload.pauseNote}</span>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
if (entry.type === "CATEGORY_CHANGED") {
|
|
if (payload.categoryName || payload.subcategoryName) {
|
|
message = `Categoria alterada para ${payload.categoryName ?? ""}${
|
|
payload.subcategoryName ? ` • ${payload.subcategoryName}` : ""
|
|
}`
|
|
} else {
|
|
message = "Categoria removida"
|
|
}
|
|
}
|
|
if (entry.type === "MANAGER_NOTIFIED") {
|
|
const manager =
|
|
payload.managerName ??
|
|
payload.managerUserName ??
|
|
payload.manager ??
|
|
payload.managerId ??
|
|
payload.managerUserId
|
|
message = manager ? `Gestor notificado: ${manager}` : "Gestor notificado"
|
|
}
|
|
if (entry.type === "VISIT_SCHEDULED") {
|
|
const scheduledRaw = payload.scheduledFor ?? payload.scheduledAt
|
|
let formatted: string | null = null
|
|
if (typeof scheduledRaw === "number" || typeof scheduledRaw === "string") {
|
|
const date = new Date(scheduledRaw)
|
|
if (!Number.isNaN(date.getTime())) {
|
|
formatted = format(date, "dd MMM yyyy HH:mm", { locale: ptBR })
|
|
}
|
|
}
|
|
message = formatted ? `Visita agendada para ${formatted}` : "Visita agendada"
|
|
}
|
|
if (entry.type === "CSAT_RECEIVED") {
|
|
message = "CSAT recebido"
|
|
}
|
|
if (entry.type === "CSAT_RATED") {
|
|
const rawScoreSource = (payload as { score?: unknown; rating?: unknown }) ?? {}
|
|
const rawScore =
|
|
typeof rawScoreSource.score === "number"
|
|
? rawScoreSource.score
|
|
: typeof rawScoreSource.rating === "number"
|
|
? rawScoreSource.rating
|
|
: null
|
|
const rawMaxSource = (payload as { maxScore?: unknown; max?: unknown }) ?? {}
|
|
const rawMax =
|
|
typeof rawMaxSource.maxScore === "number"
|
|
? rawMaxSource.maxScore
|
|
: typeof rawMaxSource.max === "number"
|
|
? rawMaxSource.max
|
|
: undefined
|
|
const safeMax = rawMax && Number.isFinite(rawMax) && rawMax > 0 ? Math.round(rawMax) : 5
|
|
const safeScore =
|
|
typeof rawScore === "number" && Number.isFinite(rawScore)
|
|
? Math.max(1, Math.min(safeMax, Math.round(rawScore)))
|
|
: null
|
|
const rawComment = (payload as { comment?: unknown })?.comment
|
|
const comment =
|
|
typeof rawComment === "string" && rawComment.trim().length > 0
|
|
? rawComment.trim()
|
|
: null
|
|
message = (
|
|
<div className="space-y-1">
|
|
<span>
|
|
CSAT avaliado:{" "}
|
|
<span className="font-semibold text-neutral-900">
|
|
{safeScore ?? "—"}/{safeMax}
|
|
</span>
|
|
</span>
|
|
<div className="flex items-center gap-1 text-amber-500">
|
|
{Array.from({ length: safeMax }).map((_, index) => (
|
|
<IconStar
|
|
key={index}
|
|
className="size-3.5"
|
|
strokeWidth={1.5}
|
|
fill={safeScore !== null && index < safeScore ? "currentColor" : "none"}
|
|
/>
|
|
))}
|
|
</div>
|
|
{comment ? (
|
|
<span className="block rounded-lg bg-slate-100 px-3 py-1 text-xs text-neutral-600">
|
|
“{comment}”
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
if (!message) return null
|
|
|
|
return (
|
|
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">{message}</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
<Separator className="bg-slate-200" />
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|