'use client'
/**
* ` ` — single ticket row inside the Help Center list.
*
* Visual chrome is 1:1 with the delivery list (``) via
* the shared `` primitive. The differentiator: the
* entire summary row is a `` that toggles an expanded drawer
* beneath it (` ` — same composer + timeline +
* close/reopen affordances as the embedded ` `).
*
* Click target: the summary row only. Clicks inside the expanded
* drawer (composer textarea, attachment chips, close-dialog button)
* don't propagate up to the row's toggle handler because the drawer
* is a SIBLING of the toggle button, not nested inside it.
*/
import { useCallback, useEffect, useRef } from 'react'
import { StatusBadge, type StatusBadgeProps } from '../ui'
import { formatRelativeTime } from '../../utils/date-utils'
import { scrollElementIntoView } from '../../utils/scroll-into-view'
import { STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav'
import { getStatusColorScheme } from '../chat/utils/agent-status-message'
import { DevCardRowContent } from '../shared/dev-section/dev-card-row'
import {
TicketDetailDrawer,
type TicketDetailDrawerProps,
} from './ticket-detail-drawer'
import { useOptionalTicketLive } from './ticket-live-provider'
import type { AnyTicket } from './types'
import { isOptimistic } from './types'
export interface HelpCenterCardProps {
ticket: AnyTicket
expanded: boolean
onToggle: (id: string) => void
busy: boolean
supportSystemDown: boolean
onSendMessage: TicketDetailDrawerProps['onSendMessage']
onClose: TicketDetailDrawerProps['onClose']
onReopen: TicketDetailDrawerProps['onReopen']
onActionCollapsed: () => void
/** Persisted reply-failure banner — forwarded to the drawer. Parent
* (`HelpCenterList`) reads via `actions.replyErrorFor(external_id)`. */
replyError?: TicketDetailDrawerProps['replyError']
onClearReplyError?: TicketDetailDrawerProps['onClearReplyError']
/** DOM `id` applied to the row's outer element. Parent (`HelpCenterList`)
* sets `ticket-` so `useScrollToHash` can deep-link from
* a chat card's `?ticket=#ticket-` URL. The sticky-header
* offset is already baked in via `STICKY_HEADER_OFFSET_PX` so the row
* lands BELOW the chrome regardless of whether `id` is set. */
id?: string
}
export function HelpCenterCard({
ticket,
expanded,
onToggle,
busy,
supportSystemDown,
onSendMessage,
onClose,
onReopen,
onActionCollapsed,
replyError,
onClearReplyError,
id,
}: HelpCenterCardProps) {
const optimistic = isOptimistic(ticket)
const rawStatus = (ticket.status ?? 'OPEN').toUpperCase()
const priority = (ticket.priority ?? '').toUpperCase()
const relativeUpdated = ticket.hubspot_updated_at
? formatRelativeTime(ticket.hubspot_updated_at)
: 'recently'
// Use `||` not `??` so an EMPTY-STRING subject (legacy rows, partial
// server data) falls through to the placeholder instead of rendering
// a blank h3.
const title = (ticket.subject || '').trim() || '(untitled)'
const subtitle = `UPDATED ${relativeUpdated}, #${ticket.external_id || '—'}${
ticket.pipeline_stage_label ? `, ${ticket.pipeline_stage_label}` : ''
}`
const description = ticket.preview ?? ticket.body ?? ''
// Optimistic placeholders show as a row but aren't expandable — the
// real external_id hasn't landed so the drawer's `useTicketEngagements`
// would have nothing to fetch, and action targets would be undefined.
const isExpandable = !optimistic
const isExpanded = expanded && isExpandable
const rowRef = useRef(null)
// Click only toggles — the scroll-to-top is deferred to the effect below.
const handleClick = useCallback(() => {
onToggle(ticket.id)
}, [onToggle, ticket.id])
// Smooth-scroll the row to the top once the drawer has expanded — in an
// effect keyed on `isExpanded` (NOT the click handler, which runs before
// React commits the drawer, when the page isn't yet tall enough to scroll).
//
// The cancellation-proof motion lives in the shared `scrollElementIntoView`
// helper (self-driven rAF tween, instant per-frame writes, target recomputed
// each frame). It is immune to the browser SCROLL ANCHORING that cancelled the
// old native `window.scrollTo({behavior:'smooth'})` on every open after the
// first — the bug where smooth "only worked once" because anchoring is
// suppressed at scrollY=0 (first open) but aborts the native smooth scroll
// from any non-zero offset (every later open). See that util for the full
// mechanics. One leading rAF so the expanded drawer has committed its height
// before the first measurement; the tween then tracks the row to its resting
// position as the page finishes growing. Cleanup cancels on collapse/unmount.
useEffect(() => {
if (!isExpanded) return
const raf = requestAnimationFrame(() => {
scrollElementIntoView(rowRef.current, {
headerOffset: STICKY_HEADER_OFFSET_PX,
})
})
return () => cancelAnimationFrame(raf)
}, [isExpanded])
// Unread replies for this row — read from the provider's single summary
// map (missing key / no provider = 0). The provider masks the open
// ticket to 0 and zeroes on markRead, so no local state here.
const live = useOptionalTicketLive()
const unreadCount =
(!optimistic && ticket.external_id && live?.unreadByTicket[ticket.external_id]) || 0
const rightBadges = (
<>
{unreadCount > 0 && (
)}
{priority && (
)}
>
)
return (
)
}
/** Ticket priority → StatusBadge colorScheme. HIGH / URGENT → red,
* MEDIUM → yellow, LOW / unknown → default-muted. Kept local because
* the central `getStatusColorScheme` is keyed on workflow status, not
* severity, and conflating them would mis-render an "OPEN" status as
* a low-priority badge or vice-versa. */
function mapPriorityScheme(priority: string): NonNullable {
if (priority === 'HIGH' || priority === 'URGENT') return 'error'
if (priority === 'MEDIUM') return 'warning'
return 'default'
}