'use client' /** * `` — the full Help Center surface (the openframe * `/tickets` page mounts this directly; third-party embedders can mount * it inside their own `` to get the same UX). * * Mounts `` so the page chrome * (hero + search + status filter + back button) is identical to * `/roadmap`, `/bug-fixes-and-enhancements`, `/releases`. The * "Open a new ticket" form lives in the new `preControls` slot above * the search/filter row. * * State ownership: * - URL params (`?search=`, `?status=`, `?page=`) → `DevSectionView` * writes search + status, `` writes page. * `useTicketsList({ search, status, page })` reads them. * - Optimistic placeholders → kept LOCAL (not in TanStack cache) so a * refetch (URL filter change) doesn't blow them away mid-flight. * - Expanded row → single id (only one drawer open at a time). * - Mutations → `useTicketActions` with prepend/remove callbacks * wired to the local placeholder state. * * Anon visitors get the same `DevSectionPage` chrome (hero + back * button) but with a single "Sign in" `` body — no form, * no list, no fetch. */ import { useCallback, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { useSearchParams, useRouter, usePathname } from '../../embed-shims' import { Button } from '../ui' import { EmptyState } from '../empty-state' import { DevSectionPage } from '../shared/dev-section' import { DevCardRowSkeletonList } from '../shared/dev-section/dev-card-row' import { UnifiedPagination } from '../unified-pagination' import { useChatIdentity } from '../chat/hooks/use-chat-identity' import { useScrollToHash } from '../../hooks/use-scroll-to-hash' import { STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav' import { DEV_SECTION_PARAM_KEYS, devSectionAnchorId } from '../../utils/dev-sections/dev-section-param-keys' import { toast as defaultToast } from '../../hooks/use-toast' import { useTicketsList } from './hooks/use-tickets-list' import { useTicketActions } from './hooks/use-ticket-actions' import { HelpCenterCard } from './help-center-card' import { HelpCenterCreateForm, HelpCenterCreateFormSkeleton } from './help-center-create-form' import type { AnyTicket, OptimisticTicket, TicketsCacheSlot } from './types' import { isOptimistic, TICKET_OPEN_PARAM } from './types' export interface HelpCenterListProps { /** Toast override (test-friendly). Defaults to the lib's shared * toast singleton. */ toast?: typeof defaultToast /** Back-button forwarded to the internal `DevSectionPage` chrome (same shape * as `DevSectionPage` / `LegalDocumentPage`: `{ label?, href? }`, or `false` * to hide). Omit ⇒ `DevSectionPage`'s default (`Back to home` → `/`), which * embedders whose home isn't `/` MUST override. */ backButton?: { label?: string; href?: string } | false /** Override the hero title (forwarded to `DevSectionPage.title`). Defaults to * the `tickets` section copy ("Help Center"). Set this to brand the surface * for an embed that wants its own label (e.g. "Support Tickets"). */ title?: string /** Render the standalone `` (forwarded to the internal * `DevSectionPage`). Default true. Pass false when the host layout already * provides the page container (avoids a nested `
`). */ shell?: boolean } export function HelpCenterList({ toast = defaultToast, backButton, title, shell }: HelpCenterListProps = {}) { const identity = useChatIdentity() const searchParams = useSearchParams() const router = useRouter() const pathname = usePathname() const search = searchParams.get(DEV_SECTION_PARAM_KEYS.search) || '' const status = searchParams.get('status') || 'all' // Deep-link: `?ticket=` auto-opens that ticket's drawer on load. // Same GET-param plumbing as `?search=` — read here, drilled to the authed // child which expands the matching row once it's in the fetched list. const ticketParam = searchParams.get(TICKET_OPEN_PARAM) || '' // 1-based page from the URL. `` writes `?page=N` // on navigation; we read it here and re-fetch on change. Invalid // values fall back to page 1. const rawPage = Number(searchParams.get('page')) const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1 // Identity gate FIRST — anon visitors skip every fetch + hook below. // `useChatIdentity` has a brief `isLoading` window on first render // before the identity resolves; we render the skeleton until it lands // to avoid flashing the sign-in EmptyState for authed users. The // skeleton mirrors the AUTHED layout — form placeholder above the // search/filter row, list-rows skeleton below — so the chrome // doesn't shift vertically when identity resolves and the real form // mounts in the `preControls` slot. if (identity.isLoading) { return ( } > ) } if (identity.authTier === 'anon' || !identity.user?.email) { return ( ) } // Identity is loaded + has an email (gated above). Resolve the // authoritative session display name + email HERE so the create-form // child doesn't have to call `useChatIdentity` itself — that hook is // a plain `useState`+`useEffect` (no shared cache), so a second call // in the child would race the first render and lock RHF's // `defaultValues.email` to '' for the form's lifetime. const sessionName = [identity.user?.firstName, identity.user?.lastName].filter(Boolean).join(' ').trim() || identity.user?.email?.split('@')[0] || 'Customer' const sessionEmail = identity.user!.email! return ( ) } interface AuthedProps { search: string status: string page: number /** `?ticket=` deep-link target — auto-opens that drawer. */ ticketParam: string searchParams: ReturnType router: ReturnType pathname: string toast: typeof defaultToast sessionName: string sessionEmail: string backButton?: { label?: string; href?: string } | false title?: string shell?: boolean } function HelpCenterListAuthed({ search, status, page, ticketParam, searchParams, router, pathname, toast, sessionName, sessionEmail, backButton, title, shell, }: AuthedProps) { const queryClient = useQueryClient() const [optimisticTickets, setOptimisticTickets] = useState([]) const [supportSystemDown, setSupportSystemDown] = useState(false) // SINGLE source of truth for "which ticket is open" = the `?ticket=` // URL param (same model as `?search=` / `?status=`). Click-to-open and the // deep-link path are now ONE code path: a click writes the param, the drawer's // open state is DERIVED from the param. No separate `expandedTicketId` state, // no auto-open effect, no re-open guard — opening, closing, deep-linking, and // sharing a URL all flow through the same param. const setOpenTicket = useCallback( (externalId: string | null) => { const params = new URLSearchParams(searchParams.toString()) if (externalId) params.set(TICKET_OPEN_PARAM, externalId) else params.delete(TICKET_OPEN_PARAM) const qs = params.toString() router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }) }, [searchParams, router, pathname], ) const { tickets, isLoading, isFetching, error, refetch, totalPages } = useTicketsList({ // `sessionEmail` is drilled in from the parent — see the same // pattern + race-cause rationale documented in // `HelpCenterCreateForm.sessionName/sessionEmail`. Calling // `useChatIdentity` inside `useTicketsList` would race the // parent's already-resolved identity and produce an empty-state // flash on first render. customerEmail: sessionEmail, search, status, page, // No interval polling (deleted 2026-08): `TicketLiveProvider` // invalidates `['tickets']` on stream events; focus/mount refetch // covers hosts without a stream. Disclosed trade-off: a bare // status/pipeline change with no accompanying reply has no live // path until the next event/focus/reconnect. }) // Open state DERIVED from the URL param. `?ticket=` carries the user-facing // `external_id`; map it to the internal row id the card matches on. Resolves // to null until the ticket lands in the fetched list (deep-link cold load) and // auto-collapses if the open ticket disappears (e.g. TICKET_NOT_FOUND removal). const expandedTicketId = (ticketParam && tickets.find((t) => t.external_id === ticketParam)?.id) || null // Optimistic cache management. Kept LOCAL (not in the query cache) so // a refetch (e.g. URL-filter change) doesn't blow away pending // placeholders. Merged view is `[...optimistic, ...server]` so // placeholders sit at the top until they're explicitly removed. const prependOptimistic = useCallback((placeholder: OptimisticTicket) => { setOptimisticTickets((prev) => [placeholder, ...prev]) }, []) const removeOptimistic = useCallback((placeholderId: string) => { setOptimisticTickets((prev) => prev.filter((t) => t.id !== placeholderId)) // No drawer-collapse needed: optimistic placeholders have no `external_id`, // so they can never be the URL-derived open ticket. }, []) const removeTicketFromCache = useCallback( (ticketId: string) => { // Every cache slot under the ['tickets'] prefix — the queryKey // includes search + status + page + pageSize segments so a bare // write would miss most slots. // // Cache slot is `TicketsCacheSlot` (`{ tickets, count, … }`), NOT // a bare `TicketData[]`. The previous version called `.filter()` // directly on the object — silently crashing only on the rare // TICKET_NOT_FOUND path; the prod regression that landed // 2026-05-29 surfaced the same shape mismatch in the // close/reopen optimistic-update path. Project, filter, reassemble. queryClient.setQueriesData( { queryKey: ['tickets'] }, (prev) => { if (!prev || !Array.isArray(prev.tickets)) return prev const nextTickets = prev.tickets.filter((t) => t.id !== ticketId) if (nextTickets.length === prev.tickets.length) return prev return { ...prev, tickets: nextTickets } }, ) // The drawer auto-collapses on its own: once the ticket leaves the list, // the URL-derived `expandedTicketId` finds no match → null. No state to clear. }, [queryClient], ) const actions = useTicketActions({ prependOptimistic, removeOptimistic, removeTicketFromCache, toast, onSupportSystemDown: () => setSupportSystemDown(true), }) // Toggle = write the URL param (open) or clear it (close). The clicked card's // internal id maps to its `external_id` for the param; optimistic rows (no // external_id) aren't expandable so they short-circuit. This is the ONE open // path — a click, a deep link, and a shared URL are indistinguishable. const toggleRow = useCallback( (id: string) => { const t = tickets.find((x) => x.id === id) if (!t?.external_id) return setOpenTicket(t.external_id === ticketParam ? null : t.external_id) }, [tickets, ticketParam, setOpenTicket], ) const merged: AnyTicket[] = [...optimisticTickets, ...tickets] // Deep-link hash dispatch — `/tickets#ticket-` from a // chat card (or any other in-app link). The `?ticket=` // query param keeps owning drawer auto-open; this hook owns the // scroll-to-row independently. Both can fire on the same URL // (`/tickets?ticket=X#ticket-X`) — drawer opens AND row scrolls into // view. Shared `useScrollToHash` polls until the row mounts (handles // the SWR fetch race), uses the canonical `scrollElementIntoView` tween. useScrollToHash(tickets, { headerOffset: STICKY_HEADER_OFFSET_PX }) const hasActiveFilters = search !== '' || (status !== '' && status !== 'all') const hasResults = merged.length > 0 // Form is the canonical lib `` (NOT a new ticket-specific // form) — we hide every contact-only field, supply the customer's // identity from `useChatIdentity` so Zod's name+email validators // pass, slot a Subject `` into the new `extraTopField` // position, and forward submission through `actions.submitTicket`. // Same primitives, same wrapper styling, same visual treatment as // every other primary form in the app. const form = ( ) const body = (
{error && (

Couldn’t load your tickets. {error.message}

)} {!error && (
{isLoading ? ( ) : !hasResults && isFetching ? ( // Bridge state — background refetch in flight and the // optimistic placeholder was just removed by the mutation // callback. Without this branch "No tickets yet" would flash // for ~50ms between `removeOptimistic` and the server // response landing. ) : !hasResults ? ( hasActiveFilters ? ( { const params = new URLSearchParams(searchParams.toString()) params.delete('search') params.delete('status') router.replace(`${pathname}?${params.toString()}`, { scroll: false }) }} /> ) : ( ) ) : ( // `overflow-clip` (NOT `overflow-hidden`) — both visually // clip the rounded corners, but `hidden` makes the element // a "scroll container" per CSSOM spec, which causes // `scrollIntoView` calls inside (`` click // handlers) to try scrolling THIS div (can't, overflow // hidden) instead of bubbling up to the window. `clip` // keeps the visual clip but NOT the scroll-container // status, so click-to-scroll actually moves the page.
{merged.map((ticket) => ( setOpenTicket(null)} replyError={actions.replyErrorFor(ticket.external_id)} onClearReplyError={() => actions.clearReplyError(ticket.external_id)} /> ))}
)}
)} {/* Pagination — `` owns the URL `?page=N` rewrite on click; we just feed it the server-echoed current page + totalPages. Hidden when there's at most one page so the list doesn't reserve vertical space when it isn't actionable. */} {!error && totalPages > 1 && ( )}
) return ( {body} ) }