@flamingo-stack/openframe-frontend-core 0.0.217-snapshot.20260601003634 → 0.0.217

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/components/tickets/index.cjs","../../../src/components/tickets/ticket-center.tsx","../../../src/components/tickets/ticket-open-form.tsx","../../../src/components/tickets/types.ts","../../../src/components/tickets/ticket-row.tsx","../../../src/components/collapsible.tsx","../../../src/components/tickets/ticket-detail-drawer.tsx","../../../src/components/tickets/hooks/use-ticket-engagements.ts","../../../src/components/tickets/ticket-linked-delivery-card.tsx","../../../src/components/tickets/ticket-reply-composer.tsx","../../../src/components/tickets/hooks/use-tickets-list.ts","../../../src/components/tickets/hooks/use-ticket-actions.ts","../../../src/components/tickets/help-center-list.tsx","../../../src/components/tickets/help-center-card.tsx","../../../src/components/tickets/help-center-create-form.tsx"],"names":["jsx","jsxs","CollapsibleContent","useState","useCallback","toast"],"mappings":"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","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/components/tickets/index.cjs","sourcesContent":[null,"'use client'\n\n/**\n * `<TicketCenter />` — the customer-facing ticket management surface.\n *\n * Single component the hub mounts at `/tickets` and that third-party\n * apps embed alongside `<EmbeddableChat />`. The lib intentionally does\n * NOT bundle a QueryClientProvider or ChatRuntimeContext.Provider — the\n * embedder mounts both at their app root (same pattern as\n * `<EmbeddableChat />`).\n *\n * Identity gate: if the chat-identity hook reports anon, render ONLY a\n * sign-in EmptyState — no form, no list, no fetch. This keeps the\n * Network tab clean for anon visitors and prevents the form from\n * accepting input that would 401 on submit.\n */\n\nimport { useCallback, useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { Card } from '../ui/card'\nimport { Button } from '../ui/button'\nimport { Skeleton } from '../ui/skeleton'\nimport { EmptyState } from '../empty-state'\nimport { RefreshCw } from 'lucide-react'\nimport { useChatIdentity } from '../chat/hooks/use-chat-identity'\nimport { toast as defaultToast } from '../../hooks/use-toast'\nimport { formatRelativeTime } from '../../utils/date-utils'\nimport { TicketOpenForm } from './ticket-open-form'\nimport { TicketRow } from './ticket-row'\nimport { useTicketsList } from './hooks/use-tickets-list'\nimport { useTicketActions } from './hooks/use-ticket-actions'\nimport type { AnyTicket, OptimisticTicket, TicketData } from './types'\nimport { isOptimistic } from './types'\n\nexport interface TicketCenterProps {\n /** Optional toast override (test-friendly). Defaults to the lib's\n * shared toast singleton. */\n toast?: typeof defaultToast\n}\n\nexport function TicketCenter({ toast = defaultToast }: TicketCenterProps = {}) {\n const identity = useChatIdentity()\n // Loading window — wait for the capability bag to resolve before\n // deciding what to render. `identity.isLoading` is the first-mount\n // window; once resolved we know authTier definitively.\n if (identity.isLoading) {\n return <TicketCenterSkeleton />\n }\n if (identity.authTier === 'anon' || !identity.user?.email) {\n return (\n <EmptyState\n type=\"generic\"\n title=\"Sign in to manage tickets\"\n description=\"View, open, and follow up on support tickets after signing in.\"\n showCTA={false}\n />\n )\n }\n return (\n <TicketCenterAuthed\n toast={toast}\n sessionEmail={identity.user.email}\n />\n )\n}\n\nfunction TicketCenterAuthed({\n toast,\n sessionEmail,\n}: {\n toast: typeof defaultToast\n /** Identity drilled from the parent — see `useTicketsList`'s\n * `customerEmail` arg doc for the race-cause rationale. */\n sessionEmail: string\n}) {\n const queryClient = useQueryClient()\n const { tickets, isLoading, isFetching, refetch, lastUpdatedAt } = useTicketsList({\n customerEmail: sessionEmail,\n })\n const [optimisticTickets, setOptimisticTickets] = useState<OptimisticTicket[]>([])\n const [expandedTicketId, setExpandedTicketId] = useState<string | null>(null)\n const [supportSystemDown, setSupportSystemDown] = useState(false)\n\n // Optimistic cache management. Kept LOCAL (not in the query cache) so\n // a refetch doesn't blow away pending placeholders mid-flight. The\n // merged view is `[...optimistic, ...server]` so optimistic rows sit\n // at the top until they're explicitly removed.\n const prependOptimistic = useCallback((placeholder: OptimisticTicket) => {\n setOptimisticTickets((prev) => [placeholder, ...prev])\n }, [])\n const removeOptimistic = useCallback((placeholderId: string) => {\n setOptimisticTickets((prev) => prev.filter((t) => t.id !== placeholderId))\n // If the parent had this temp id expanded (shouldn't happen — the\n // drawer is hidden on optimistic rows — but defensive), null it\n // so we don't dangle a stale id.\n setExpandedTicketId((prev) => (prev === placeholderId ? null : prev))\n }, [])\n const removeTicketFromCache = useCallback(\n (ticketId: string) => {\n // Target every cache slot under the ['tickets'] prefix — the\n // queryKey now includes an identityKey segment (use-tickets-list)\n // so a bare ['tickets', 'self'] write would no-op silently.\n queryClient.setQueriesData<TicketData[] | undefined>(\n { queryKey: ['tickets'] },\n (prev) => (prev ?? []).filter((t) => t.id !== ticketId),\n )\n setExpandedTicketId((prev) => (prev === ticketId ? null : prev))\n },\n [queryClient],\n )\n\n const actions = useTicketActions({\n prependOptimistic,\n removeOptimistic,\n removeTicketFromCache,\n toast,\n onSupportSystemDown: () => setSupportSystemDown(true),\n })\n\n const toggleRow = useCallback((id: string) => {\n setExpandedTicketId((prev) => (prev === id ? null : id))\n }, [])\n\n const merged: AnyTicket[] = [...optimisticTickets, ...tickets]\n\n return (\n <div className=\"flex flex-col gap-6\">\n <TicketOpenForm\n onSubmit={(input) => actions.submitTicket(input)}\n isSubmitting={actions.isSubmittingForm}\n supportSystemDown={supportSystemDown}\n />\n\n <div className=\"flex flex-col gap-2\">\n <div className=\"flex items-center justify-between gap-3\">\n <p className=\"text-xs font-medium text-ods-text-secondary uppercase tracking-wider\">\n Your Current Tickets\n </p>\n <div className=\"flex items-center gap-3 text-xs text-ods-text-secondary\">\n {lastUpdatedAt && (\n <span>Updated {formatRelativeTime(new Date(lastUpdatedAt))}</span>\n )}\n <Button\n type=\"button\"\n variant=\"transparent\"\n size=\"small\"\n onClick={refetch}\n disabled={isFetching}\n aria-label=\"Refresh ticket list\"\n leftIcon={<RefreshCw className=\"h-4 w-4\" />}\n />\n </div>\n </div>\n\n {isLoading ? (\n <TicketListSkeleton />\n ) : merged.length === 0 ? (\n <Card className=\"p-6\">\n <EmptyState\n type=\"generic\"\n title=\"No tickets yet\"\n description=\"Open one above to start the conversation.\"\n showCTA={false}\n />\n </Card>\n ) : (\n <Card className=\"overflow-hidden\">\n {merged.map((ticket) => (\n <TicketRow\n key={ticket.id}\n ticket={ticket}\n expanded={expandedTicketId === ticket.id}\n onToggle={toggleRow}\n busy={isOptimistic(ticket) ? false : actions.isRowBusy(ticket.id)}\n supportSystemDown={supportSystemDown}\n onSendMessage={actions.sendMessage}\n onClose={actions.closeTicket}\n onReopen={actions.reopenTicket}\n onActionCollapsed={() => setExpandedTicketId(null)}\n />\n ))}\n </Card>\n )}\n </div>\n </div>\n )\n}\n\nfunction TicketCenterSkeleton() {\n return (\n <div className=\"flex flex-col gap-6\">\n <Card className=\"p-6\">\n <Skeleton className=\"h-7 w-48 mb-4\" />\n <Skeleton className=\"h-10 w-full mb-3\" />\n <Skeleton className=\"h-24 w-full\" />\n </Card>\n <TicketListSkeleton />\n </div>\n )\n}\n\nfunction TicketListSkeleton() {\n return (\n <Card className=\"overflow-hidden\">\n {[0, 1, 2].map((i) => (\n <div key={i} className=\"h-20 px-4 flex items-center gap-4 border-b border-ods-border last:border-b-0\">\n <div className=\"flex-1 flex flex-col gap-2\">\n <Skeleton className=\"h-4 w-2/3\" />\n <Skeleton className=\"h-3 w-full\" />\n </div>\n <Skeleton className=\"h-8 w-20\" />\n <Skeleton className=\"h-8 w-16\" />\n </div>\n ))}\n </Card>\n )\n}\n","'use client'\n\n/**\n * The \"Open Ticket\" form at the top of <TicketCenter />.\n *\n * Composition only — every leaf is an existing oss-lib primitive:\n * - `<Input>` for subject\n * - `<Textarea>` for content\n * - `<ChatAttachmentAddButton>` + `<ChatAttachmentChipStrip>` for files\n * - `<Button>` for submit\n *\n * Submit gating combines:\n * - subject + content trim non-empty\n * - no uploads in flight\n * - not currently submitting (single-flight, parent-owned)\n * - support system online\n */\n\nimport { useState } from 'react'\nimport { Card } from '../ui/card'\nimport { Input } from '../ui/input'\nimport { Textarea } from '../ui/textarea'\nimport { Button } from '../ui/button'\nimport {\n ChatAttachmentAddButton,\n ChatAttachmentChipStrip,\n} from '../chat/chat-attachment-bar'\nimport { useChatAttachments } from '../chat/hooks/use-chat-attachments'\nimport { TICKET_TEXT_MAX_CHARS } from './types'\n\nconst COUNTER_VISIBLE_AT = Math.floor(TICKET_TEXT_MAX_CHARS * 0.8)\n\nexport interface TicketOpenFormProps {\n /** Wired to `useTicketActions().submitTicket`. Returns true on success\n * so the form can clear itself. */\n onSubmit: (input: { subject: string; content: string; attachments: import('../chat/utils/chat-attachment-markdown').ChatAttachment[] }) => Promise<boolean>\n isSubmitting: boolean\n supportSystemDown: boolean\n}\n\nexport function TicketOpenForm({\n onSubmit,\n isSubmitting,\n supportSystemDown,\n}: TicketOpenFormProps) {\n const [subject, setSubject] = useState('')\n const [content, setContent] = useState('')\n\n // Reuse the chat composer's attachment hook directly — same upload\n // pipeline, same readiness flags. The hook returns `readyAttachments`\n // (the wire-shape projection) and `hasInflightUploads`.\n const { attachments, readyAttachments, hasInflightUploads, addFiles, removeAttachment, clear } = useChatAttachments()\n\n const trimmedSubject = subject.trim()\n const trimmedContent = content.trim()\n const overCap = content.length > TICKET_TEXT_MAX_CHARS\n const showCounter = content.length >= COUNTER_VISIBLE_AT\n\n const canSubmit =\n !isSubmitting &&\n !supportSystemDown &&\n !hasInflightUploads &&\n trimmedSubject.length > 0 &&\n trimmedContent.length > 0 &&\n !overCap\n\n const handleSubmit = async (e: React.FormEvent) => {\n e.preventDefault()\n if (!canSubmit) return\n const ok = await onSubmit({\n subject: trimmedSubject,\n content: trimmedContent,\n attachments: readyAttachments,\n })\n if (ok) {\n setSubject('')\n setContent('')\n clear()\n }\n }\n\n return (\n <Card className=\"p-6\">\n <form onSubmit={handleSubmit} className=\"flex flex-col md:flex-row gap-6\">\n <div className=\"flex-1 min-w-0 md:max-w-md\">\n <h2 className=\"text-2xl font-semibold text-ods-text-primary mb-2\">\n Need Support?\n </h2>\n <p className=\"text-ods-text-secondary text-sm\">\n Can&apos;t find what you&apos;re looking for? Submit a support ticket\n below — we&apos;ll follow up shortly.\n </p>\n {supportSystemDown && (\n <p className=\"mt-4 text-sm text-ods-error\">\n Support system temporarily unavailable. Please try again shortly.\n </p>\n )}\n </div>\n\n <div className=\"flex-1 min-w-0 flex flex-col gap-4\">\n <div>\n <label\n htmlFor=\"ticket-subject\"\n className=\"block text-sm font-medium text-ods-text-primary mb-1\"\n >\n Ticket Subject\n </label>\n <Input\n id=\"ticket-subject\"\n type=\"text\"\n placeholder=\"Enter Subject Here\"\n value={subject}\n onChange={(e) => setSubject(e.target.value)}\n disabled={isSubmitting || supportSystemDown}\n maxLength={200}\n />\n </div>\n\n <div>\n <label\n htmlFor=\"ticket-content\"\n className=\"block text-sm font-medium text-ods-text-primary mb-1\"\n >\n Your Message\n </label>\n <Textarea\n id=\"ticket-content\"\n placeholder=\"Describe your issue or question in detail...\"\n value={content}\n onChange={(e) => setContent(e.target.value)}\n disabled={isSubmitting || supportSystemDown}\n rows={5}\n className=\"resize-none\"\n />\n {showCounter && (\n <p\n className={`mt-1 text-xs text-right ${\n overCap ? 'text-ods-error' : 'text-ods-text-secondary'\n }`}\n >\n {content.length}/{TICKET_TEXT_MAX_CHARS}\n </p>\n )}\n </div>\n\n <ChatAttachmentChipStrip\n attachments={attachments}\n onRemove={removeAttachment}\n disabled={isSubmitting || supportSystemDown}\n />\n\n <div className=\"flex items-center justify-between gap-3\">\n <ChatAttachmentAddButton\n attachmentsEnabled={!supportSystemDown}\n attachmentsCount={attachments.length}\n onAddFiles={addFiles}\n disabled={isSubmitting}\n />\n <Button\n type=\"submit\"\n disabled={!canSubmit}\n loading={isSubmitting}\n >\n Open Ticket\n </Button>\n </div>\n </div>\n </form>\n </Card>\n )\n}\n","/**\n * Wire shape of a row returned by `POST /api/chat/agent/find-ticket`.\n * Mirrors the executor's projection at `lib/data/hubspot-tools.ts`\n * (`FIND_TICKET_SELECT` / `FindTicketResult`).\n *\n * Cross-repo duplication is INTENTIONAL: this lib ships independently\n * of the hub, so we can't import `FindTicketResult` from\n * `hubspot-tools.ts` directly. If the server adds a column to\n * `FIND_TICKET_SELECT`, also add it here. The smoke test in §F of the\n * plan covers the happy path; a wire-contract test belongs in the hub.\n *\n * `find-ticket` returns `customer_emails: string[]` (jsonb array), NOT\n * a single `customer_email`. The list is server-self-scoped to the\n * caller's session email; the array is exposed for admin/staff\n * surfaces, which the ticket center doesn't render.\n */\nexport interface TicketData {\n id: string\n /** HubSpot ticket id (display number, e.g. \"1234\"). */\n external_id: string\n subject: string | null\n /** Short (≤400 char) HTML-stripped preview of the ticket body —\n * used for the list-card subtitle when needed. */\n preview: string | null\n /** Longer (≤4k char) sanitized body. INCLUDES every appended\n * `content_addendum` comment because the `update_ticket` executor\n * reads + re-writes the `content` property server-side with a\n * `---` separator. Render this in the drawer's description block\n * and the user sees both the original message + every comment they\n * (or staff) added. */\n body: string | null\n /** Canonical OPEN | CLOSED (HubSpot pipeline derived). */\n status: string | null\n /** Human label like \"New\" / \"Working on it\" / \"Waiting on contact\" /\n * \"Closed\". Drives the badge text; canonical status drives color. */\n pipeline_stage_label: string | null\n clickup_task_id: string | null\n /** Snapshot of the linked ClickUp delivery task — populated server-side\n * via the `clickup_tasks` mirror when `clickup_task_id` is set. Drives\n * the \"Linked delivery\" card surface on the ticket drawer (status\n * badge + ClickUp deep link). `null` when no link OR the ClickUp row\n * was deleted / not yet synced. */\n clickup: TicketClickupSummary | null\n priority: string | null\n customer_emails: string[]\n customer_company: string | null\n /** HubSpot contact's display name. Drives the customer attribution\n * on the drawer when the viewer is NOT the customer themselves\n * (admin browsing / multi-contact second viewer). Conversations\n * API messages don't carry per-message sender info on Custom\n * Channels, so this is the only reliable source for \"what's the\n * customer's name.\" */\n customer_name: string | null\n /** HubSpot owner id of the agent assigned to this ticket. Carried as\n * raw id for debugging; rendering goes through `assignedOwner`. Null\n * when unassigned. */\n assigned_to: string | null\n /** Resolved assigned-owner profile — name + email + avatar. Populated\n * server-side via `attachOwnerProfiles` which joins through the\n * `hubspot_owners` mirror to `profiles` by email. Drives the\n * \"Assigned to\" attribution in the drawer header. Null when\n * unassigned OR the owner couldn't be resolved (rare — only when\n * the agent was deleted from HubSpot between the ticket update and\n * the next owners reconcile). */\n assignedOwner: TicketAssignedOwner | null\n hubspot_updated_at: string\n}\n\n/** Resolved profile of a ticket's assigned agent — surfaced in the\n * drawer header. Subset of the server's `MirroredOwnerProfile`\n * trimmed to just the rendering fields. */\nexport interface TicketAssignedOwner {\n name: string | null\n email: string | null\n avatarUrl: string | null\n}\n\n/** Compact projection of a linked ClickUp task — matches the server's\n * `ClickupSummary` and aligns with `DeliveryItem` so the linked-card\n * on a ticket can render through the same `DeliveryRow` primitive used\n * on `/bug-fixes-and-enhancements`. */\nexport interface TicketClickupSummary {\n /** ClickUp task external_id (e.g. \"86ad4e022\"). Used as the\n * `?focus=<id>` URL param to scroll the public delivery page to\n * this row. */\n external_id: string\n title: string | null\n description: string | null\n /** ClickUp status name — e.g. \"complete\" / \"working\" / \"design approved\"\n * / \"waiting for release\". Used as the badge label. */\n status: string | null\n /** ClickUp's per-status hex color (e.g. \"#008844\"). Forwarded to the\n * badge so colors match the ClickUp board exactly. */\n status_color: string | null\n /** Bucket — `'backlog' | 'working' | 'complete' | 'unknown'`. Used as\n * a fallback when status_color is missing. */\n status_category: string | null\n /** ClickUp custom item label (`'Bug'` / `'Request'`) — drives the\n * type badge (\"BUG-FIX\" / \"ENHANCEMENT\"). */\n task_type: string | null\n custom_item_id: number | null\n /** Every ClickUp list the task is associated with. UI joins with \", \". */\n list_names: string[]\n /** Unix-ms timestamps so the row's \"ACTIVE X ago\" subtitle uses the\n * shared `getRelativeTime()` helper. */\n date_opened: number | null\n date_updated: number | null\n date_closed: number | null\n /** Direct https://app.clickup.com/t/<id> deep link. Kept on the wire\n * for admin surfaces; the customer-facing linked card navigates\n * internally instead. */\n clickup_url: string | null\n /** Composed server-side via the SAME `buildDevSectionUrl` helper the\n * chat-inline delivery card uses. Carries `?search=<id>` so the\n * delivery list filters to that single task on landing. */\n delivery_href: string\n /** Target platform name for the host's `useNavLink` to decide\n * same-tab vs new-tab on cross-platform links. */\n delivery_target_platform: string\n /** Release version label set by the delivery team, e.g. \"0.9\" / \"1.0\".\n * Shown beside the status when present. */\n target_version: string | null\n}\n\n/**\n * Optimistic placeholder a `submitTicket` call prepends to the list\n * BEFORE the server roundtrip resolves. Drawer is hidden until the\n * real id arrives. The wrapper destructures `_optimistic` before\n * forwarding to `<ChatTicketItem>` so the DOM doesn't see an unknown\n * prop.\n */\nexport interface OptimisticTicket extends TicketData {\n _optimistic: true\n}\n\nexport type AnyTicket = TicketData | OptimisticTicket\n\nexport function isOptimistic(t: AnyTicket): t is OptimisticTicket {\n return (t as OptimisticTicket)._optimistic === true\n}\n\n/**\n * Shape of a single `['tickets', …]` TanStack-Query cache slot.\n * Mirrors `FindTicketResponse` in `hooks/use-tickets-list.ts` — kept\n * here because the cache-mutation call sites in `useTicketActions` and\n * `<HelpCenterList>` would otherwise have to redeclare the shape inline.\n *\n * A 2026-05-29 prod regression (`t.map is not a function` on\n * close/reopen) was caused by assuming the cache held a bare\n * `TicketData[]` instead of this wrapper — every helper that calls\n * `queryClient.setQueriesData` / `getQueriesData` on `['tickets']`\n * MUST type the value through this shape and project / reassemble\n * `tickets` explicitly.\n */\nexport interface TicketsCacheSlot {\n tickets?: TicketData[]\n count?: number\n totalCount?: number\n page?: number\n pageSize?: number\n totalPages?: number\n scope?: 'self' | 'all'\n}\n\n/**\n * Stable server-side error codes the ticket-action helpers route\n * through `mapTicketActionError`. Anything else is treated as a generic\n * server error.\n *\n * Reply-specific codes (`HUBSPOT_5XX` / `HUBSPOT_400_VALIDATION` /\n * `HUBSPOT_404_THREAD` / `HUBSPOT_REPLY_UNKNOWN`) drive the drawer\n * banner that appears above the composer when a customer reply fails.\n * Distinct from `HUBSPOT_DISCONNECTED` (whole-system) and\n * `TICKET_NOT_FOUND` (terminal-row) — the reply codes are per-attempt\n * + retryable except for `HUBSPOT_404_THREAD`.\n */\nexport type TicketActionErrorCode =\n | 'PROPOSAL_NOT_CLAIMABLE'\n | 'TICKET_NOT_FOUND'\n | 'TICKET_OWNERSHIP_DENIED'\n | 'HUBSPOT_DISCONNECTED'\n | 'RATE_LIMITED'\n | 'INVALID_TOOL_ARGS'\n | 'HUBSPOT_5XX'\n | 'HUBSPOT_400_VALIDATION'\n | 'HUBSPOT_404_THREAD'\n | 'HUBSPOT_REPLY_UNKNOWN'\n | 'UNKNOWN'\n\nexport interface MappedTicketActionError {\n code: TicketActionErrorCode\n /** Human-readable copy safe to show in a toast. */\n message: string\n /** When true, the form should disable submit + show the\n * support-down banner. Set only for HUBSPOT_DISCONNECTED. */\n supportSystemDown: boolean\n /** When true, the helper should remove the affected row optimistically\n * (TICKET_NOT_FOUND). */\n removeRowFromCache: boolean\n /** Retry hint surfaced from a 429 response. Caller decides whether\n * to mention it in the toast. */\n retryAfterSeconds?: number\n}\n\n/**\n * Defensive client-side cap on ticket text (initial content + comment\n * addendums). HubSpot Note engagements accept more, but a 100KB paste\n * should fail fast at the UI rather than burning a server round-trip.\n * Both the open-ticket form and the per-row comment textarea import\n * this so a future server-side hardening only touches one place.\n */\nexport const TICKET_TEXT_MAX_CHARS = 5000\n\n/**\n * Live-refresh cadence (ms) for an OPEN ticket drawer. Drives BOTH\n * surfaces that must stay current while the customer is looking at a\n * ticket:\n * - the ticket LIST poll (`useTicketsList.refetchInterval`) → surfaces\n * out-of-band status / pipeline / priority / assignee changes;\n * - the CONVERSATION poll (`useTicketEngagements.refetchInterval`) →\n * surfaces new agent replies + attachments.\n * Single source of truth so the two surfaces never drift. Both leave\n * `refetchIntervalInBackground` at its default (false), so polling pauses\n * on a hidden tab — no wasted requests when the user tabs away.\n */\nexport const TICKET_LIVE_POLL_MS = 8000\n\n/**\n * Centralized toast copy. Keep all wording here so QA / localization\n * can find every user-visible string in one file.\n */\nexport const TOAST_COPY = {\n open_success: { title: 'Ticket opened', description: 'We received your message and will follow up shortly.' },\n open_mirror_pending: { title: 'Ticket opened', description: 'Syncing — your ticket will appear momentarily.' },\n close_success: { title: 'Ticket closed' },\n reopen_success: { title: 'Ticket reopened' },\n comment_success: { title: 'Comment added' },\n attach_success: { title: 'Files attached' },\n // Failure variants are constructed dynamically from MappedTicketActionError.\n} as const\n","'use client'\n\n/**\n * Single ticket row + expanded details drawer.\n *\n * The COMPACT summary tile (`<ChatTicketItem>`) is the row chrome\n * specific to this composition — used by the lib's `<TicketCenter />`\n * embed. The drawer body itself is extracted into\n * `<TicketDetailDrawer />` so the hub's DevSection-style ticket card\n * (different chrome, same drawer) can drop it in too.\n *\n * Layout:\n * 1. `<ChatTicketItem>` summary tile. Clicking it toggles the\n * `expandedTicketId` state owned by the parent `<TicketCenter>` —\n * we use the item's existing `onClick` prop rather than nesting a\n * `<CollapsibleTrigger>` (button-in-button is invalid).\n * 2. `<CollapsibleContent>` wrapping `<TicketDetailDrawer />`,\n * rendered only when this row is the expanded one.\n */\n\nimport { useCallback, useRef } from 'react'\nimport {\n Collapsible,\n CollapsibleContent,\n} from '../collapsible'\nimport {\n ChatTicketItem,\n type ChatTicketItemData,\n} from '../chat/entity-cards/chat-ticket-item'\nimport { formatRelativeTime } from '../../utils/date-utils'\nimport { scrollElementIntoView } from '../../utils/scroll-into-view'\nimport {\n TicketDetailDrawer,\n type TicketDetailDrawerProps,\n} from './ticket-detail-drawer'\nimport type { AnyTicket } from './types'\nimport { isOptimistic } from './types'\n\nexport interface TicketRowProps {\n ticket: AnyTicket\n expanded: boolean\n onToggle: (ticketId: string) => void\n busy: boolean\n supportSystemDown: boolean\n onSendMessage: TicketDetailDrawerProps['onSendMessage']\n onClose: TicketDetailDrawerProps['onClose']\n onReopen: TicketDetailDrawerProps['onReopen']\n /** Called after a successful close/reopen so the parent can collapse\n * the drawer (status flipped — current action set is now stale). */\n onActionCollapsed: TicketDetailDrawerProps['onActionCollapsed']\n}\n\nexport function TicketRow({\n ticket,\n expanded,\n onToggle,\n busy,\n supportSystemDown,\n onSendMessage,\n onClose,\n onReopen,\n onActionCollapsed,\n}: TicketRowProps) {\n // Optimistic placeholders have no drawer — the real id hasn't\n // arrived yet, so action targets would be undefined.\n const optimistic = isOptimistic(ticket)\n\n // Scroll the clicked card to the top of the viewport. Every click\n // scrolls — first-click expansion, same-row re-click, cross-row\n // switch. The clicked card lands at the top.\n //\n // Cross-row gotcha: if ANOTHER row above this one is currently\n // expanded, its drawer is about to collapse simultaneously with our\n // toggle. We pre-subtract its height from the target Y so the\n // smooth-scroll lands at the FINAL post-collapse position cleanly.\n // Same pattern as `<HelpCenterCard>` — the only diff is the drawer\n // id prefix (`ticket-drawer-` vs `help-center-drawer-`).\n const rowRef = useRef<HTMLDivElement | null>(null)\n const handleClick = useCallback(() => {\n onToggle(ticket.id)\n scrollElementIntoView(rowRef.current, {\n adjustTargetY: (raw) => {\n if (!rowRef.current) return raw\n const expandedDrawer = document.querySelector(\n 'div[id^=\"ticket-drawer-\"]',\n )\n if (!(expandedDrawer instanceof HTMLElement)) return raw\n const drawerRect = expandedDrawer.getBoundingClientRect()\n const myRect = rowRef.current.getBoundingClientRect()\n // Only adjust when the drawer is ABOVE us. Drawers below\n // don't shift our position when they collapse.\n if (drawerRect.bottom > myRect.top) return raw\n return raw - drawerRect.height\n },\n })\n }, [onToggle, ticket.id])\n\n const tileData: ChatTicketItemData = {\n id: ticket.id,\n title: ticket.subject ?? '(untitled)',\n ticketNumber: `#${ticket.external_id}`,\n status: ticket.status ?? 'OPEN',\n // Surface the HubSpot pipeline stage label (\"New\" / \"Closed\" /\n // \"Waiting on contact\" / \"Waiting on version release\") instead of\n // the canonical \"Active\"/\"Resolved\" default. The variant + check\n // icon still come from `status` (CLOSED → check; OPEN → no check),\n // so the badge accurately reflects \"Closed\" with a checkmark.\n statusLabel: ticket.pipeline_stage_label ?? undefined,\n category: ticket.customer_company ?? undefined,\n timeAgo: ticket.hubspot_updated_at\n ? formatRelativeTime(ticket.hubspot_updated_at)\n : undefined,\n // Linked-work chip: surfaced whenever the ticket has a linked\n // ClickUp task. Uses the linked task's own status so the chip text\n // reads \"Working\" / \"Waiting on version release\" / etc. — useful\n // signal pre-expand. Falls back to a generic \"Linked work\" label\n // when the task exists but its status hasn't synced yet.\n linkedTaskLabel: ticket.clickup\n ? ticket.clickup.status\n ? ticket.clickup.status.replace(/\\b\\w/g, (c) => c.toUpperCase())\n : 'Linked work'\n : undefined,\n }\n\n return (\n <div ref={rowRef} className=\"scroll-mt-24\">\n <Collapsible\n open={expanded && !optimistic}\n className=\"border-b border-ods-border last:border-b-0\"\n >\n <ChatTicketItem\n ticket={tileData}\n onClick={optimistic ? undefined : handleClick}\n aria-expanded={expanded && !optimistic}\n aria-controls={`ticket-drawer-${ticket.id}`}\n />\n <CollapsibleContent\n id={`ticket-drawer-${ticket.id}`}\n className=\"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down\"\n >\n <TicketDetailDrawer\n ticket={ticket}\n busy={busy}\n supportSystemDown={supportSystemDown}\n onSendMessage={onSendMessage}\n onClose={onClose}\n onReopen={onReopen}\n onActionCollapsed={onActionCollapsed}\n />\n </CollapsibleContent>\n </Collapsible>\n </div>\n )\n}\n","\"use client\"\n\nimport * as CollapsiblePrimitive from \"@radix-ui/react-collapsible\"\n\nconst Collapsible = CollapsiblePrimitive.Root\n\nconst CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger\n\nconst CollapsibleContent = CollapsiblePrimitive.CollapsibleContent\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent }\n","'use client'\n\n/**\n * `<TicketDetailDrawer />` — the expanded view of a single ticket.\n *\n * Extracted from the original `ticket-row.tsx` so both compositions\n * share it:\n * - Lib's `TicketRow` (compact `<ChatTicketItem>` summary + drawer\n * beneath; what third-party embedders use via `TicketCenter`).\n * - Hub's `<TicketCard>` (the DevSection-style card chrome on the\n * openframe `/tickets` page).\n *\n * The drawer owns everything BELOW the summary tile:\n * 1. Metadata strip (ticket #, priority, pipeline, company, updated)\n * 2. Conversation timeline (`<TicketTimelinePanel>`) — original body\n * turns + Note engagements + attachments\n * 3. Status-dependent actions (composer + close OR reopen)\n *\n * State is local to this component (composer text, attachment bag,\n * close-confirm dialog). The parent owns the ticket data + mutation\n * callbacks; we don't reach into the QueryClient.\n */\n\nimport { useStickToBottom } from 'use-stick-to-bottom'\nimport { Button } from './../ui/button'\nimport { useChatIdentity } from './../chat/hooks/use-chat-identity'\nimport {\n ChatMessageRow,\n ChatMessageRowSkeleton,\n} from './../chat/chat-message-row'\nimport { EmptyState } from './../empty-state'\nimport {\n TicketAttachmentsList,\n type TicketAttachment,\n} from './../ui/ticket-attachments-list'\nimport { SquareAvatar } from './../ui/square-avatar'\nimport { formatRelativeTime } from './../../utils/date-utils'\nimport { useTicketEngagements } from './hooks/use-ticket-engagements'\nimport type {\n TicketEngagementFile,\n} from './hooks/use-ticket-engagements'\nimport { TicketLinkedDeliveryCard } from './ticket-linked-delivery-card'\nimport { TicketReplyComposer } from './ticket-reply-composer'\nimport type {\n AnyTicket,\n TicketAssignedOwner,\n MappedTicketActionError,\n} from './types'\nimport { isOptimistic, TICKET_LIVE_POLL_MS } from './types'\n\n/** Identity bundle threaded through the action callbacks: local mirror\n * UUID + HubSpot external_id. Actions send `external_id` to HubSpot\n * (the only id it recognizes) and use `id` for the React-side mutex +\n * TanStack cache. */\nexport type TicketRef = { id: string; external_id: string }\n\nexport interface TicketDetailDrawerProps {\n ticket: AnyTicket\n busy: boolean\n supportSystemDown: boolean\n /** Single combined \"reply\" — text + optional attachments delivered as\n * ONE Note engagement. */\n onSendMessage: (\n ticket: TicketRef,\n text: string,\n attachments: import('./../chat/utils/chat-attachment-markdown').ChatAttachment[],\n ) => Promise<boolean>\n onClose: (ticket: TicketRef, resolution?: string) => Promise<boolean>\n onReopen: (ticket: TicketRef) => Promise<boolean>\n /** Called after a successful close/reopen so the parent can collapse\n * the drawer (status flipped — current action set is now stale). */\n onActionCollapsed: () => void\n /** Persisted reply-failure surface — when non-null the drawer renders\n * an inline banner above the composer with the mapped copy + a\n * dismiss control. Distinct from the transient toast; the banner\n * stays visible so the customer can locate the failed draft after\n * the toast disappears. Cleared on the next successful send. */\n replyError?: MappedTicketActionError | null\n /** Dismiss-X handler for the banner. Parent calls\n * `actions.clearReplyError(ticket.external_id)`. */\n onClearReplyError?: () => void\n}\n\nexport function TicketDetailDrawer({\n ticket,\n busy,\n supportSystemDown,\n onSendMessage,\n onClose,\n onReopen,\n onActionCollapsed,\n replyError,\n onClearReplyError,\n}: TicketDetailDrawerProps) {\n const isClosed = (ticket.status ?? '').toUpperCase() === 'CLOSED'\n return (\n <div className=\"bg-ods-card border-t border-ods-border px-4 py-4 flex flex-col gap-4\">\n {/* Assignee header — surfaces who's looking at this ticket on the\n support side. Populated server-side via `attachOwnerProfiles`;\n falls back to \"Unassigned\" when no agent is assigned OR when\n the owner couldn't be resolved (deleted between ticket update\n + next owners reconcile). */}\n <AssignedAgentRow assignedOwner={ticket.assignedOwner} />\n\n {/* Linked ClickUp delivery — rendered only when the server's\n `attachClickupTasks` step populated `ticket.clickup`. Customer\n tickets with no linked task skip this entirely. The card itself\n links out to ClickUp with the per-status color badge so the\n customer can follow the delivery progress. */}\n {ticket.clickup && (\n <TicketLinkedDeliveryCard clickup={ticket.clickup} />\n )}\n\n <div>\n <p className=\"text-xs font-medium text-ods-text-secondary mb-2 uppercase tracking-wider\">\n Conversation\n </p>\n <TicketTimelinePanel ticket={ticket} />\n </div>\n\n <div className=\"border-t border-ods-border pt-4\">\n {/* Reply-failure banner — populated by `useTicketActions` when\n the last sendMessage attempt for THIS ticket failed with a\n reply-specific code. Rendered above the composer/reopen so\n the customer sees it in context of their failed draft. Open\n (composer) actions still allow Retry; the closed (reopen)\n state still shows the banner because the user might have\n tried to reply to a then-closing ticket. */}\n {replyError && (\n <ReplyFailureBanner\n error={replyError}\n onDismiss={onClearReplyError ?? (() => undefined)}\n />\n )}\n {isClosed ? (\n <ReopenAction\n ticketRef={{ id: ticket.id, external_id: ticket.external_id }}\n busy={busy}\n supportSystemDown={supportSystemDown}\n onReopen={onReopen}\n onActionCollapsed={onActionCollapsed}\n />\n ) : (\n <TicketReplyComposer\n ticket={ticket}\n busy={busy}\n supportSystemDown={supportSystemDown}\n onSendMessage={onSendMessage}\n onClose={onClose}\n />\n )}\n </div>\n </div>\n )\n}\n\n/**\n * Render the ticket conversation as a chronological list of\n * `<ConversationCardRow>` cards inside a single bordered container.\n *\n * Top: the original ticket description (`ticket.body`). Below: every\n * Note engagement attached to the ticket via `useTicketEngagements` —\n * each with its own attachments rendered through the shared\n * `<TicketAttachmentsList>` (no more 📎-emoji chips).\n *\n * Legacy tickets whose old comments STILL live inside `ticket.body`\n * (joined by ` --- `) split on that delimiter so the historical\n * conversation surfaces correctly during the transition.\n *\n * Scroll behavior — INTENTIONALLY NONE. The drawer grows with the\n * conversation; the page scrolls. The previous `max-h-96 overflow-y-auto`\n * created two competing scroll surfaces (inner + page) which felt\n * janky on long threads and hid the composer on short ones. 2026\n * helpdesk best practice (UXPin / Coveo research) is a single\n * threaded surface that flows with the page.\n */\n// Bounded quantifiers (`{1,16}`) protect against the polynomial-time\n// backtracking class CodeQL flags for unbounded `\\s+` on user input.\n// 16 chars of leading/trailing whitespace around `---` is far more\n// than any composed ticket body needs, so no real input is rejected.\nconst TURN_SEPARATOR_RE = /[\\s]{1,16}---[\\s]{1,16}/g\n\n// Slack-channel feed framing — ported from the hub's live community feed\n// (`components/slack/chat-interface.tsx`: `:62` bounded card, `:83` padding +\n// `overflow-y-auto`, `:85` `gap-4 md:gap-6` message column). Single source\n// within the ticket feed; the delivery `DevCardRowSkeletonList` keeps its own\n// (separate, untouched) frame literal. `max-h` is responsive (vs Slack's fixed\n// height).\nconst TICKET_FEED_FRAME =\n 'bg-ods-card border border-ods-border rounded-[6px] overflow-y-auto w-full'\n// FIXED height for EVERY state (skeleton, content, empty) — the Slack feed uses\n// a fixed-height box too (`chat-interface.tsx:62`). Fixed (not `max-h`) is the\n// fix for the \"open shows 1 message, then the container grows as engagements\n// land\" jank: the feed is its final size from first paint, so loaded content\n// just fills/scrolls inside it — the box never resizes.\nconst TICKET_FEED_HEIGHT = 'h-[60vh] md:h-[420px]'\nconst TICKET_FEED_INNER = 'flex flex-col gap-4 md:gap-6 px-4 md:px-6 py-4 md:py-6'\n// Enough skeleton rows to fill the fixed height (avatar 40px + header + 2 body\n// lines + gap-6) so the loading state looks like a full conversation.\nconst TICKET_FEED_SKELETON_ROWS = 6\n\nfunction TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {\n const identity = useChatIdentity()\n // Optimistic placeholders don't have a real external_id yet — skip\n // the engagement fetch until the real ticket lands.\n const externalId = isOptimistic(ticket) ? null : ticket.external_id\n // Live conversation refresh: this panel only mounts while the drawer is\n // open, so the constant interval is already gated to \"open\" (closing the\n // drawer unmounts the panel → polling stops). New agent replies +\n // attachments surface within one cadence without a manual refresh — the\n // same 8s the list-level status/assignee poll uses (single source:\n // TICKET_LIVE_POLL_MS). A background poll never flashes the skeleton\n // (the `isLoading` guard below keys off \"no data yet\", not `isFetching`).\n const { engagements, isLoading } = useTicketEngagements(\n externalId,\n !!externalId,\n TICKET_LIVE_POLL_MS,\n )\n\n // Slack-style auto-tail (same lib mechanism `ChatMessageList` uses): jump to\n // the newest message on open (`initial:'instant'`), smooth-scroll on a new\n // reply. Called unconditionally here, BEFORE the empty/loading early-returns\n // (Rules of Hooks); the refs attach ONLY to the content branch's scroll frame\n // + column — never the cold-start skeleton (refs there would snap to skeleton\n // height, then again to real content). This inner scroll is a SEPARATE\n // container from `HelpCenterCard`'s page-level expand-scroll, so it never\n // fights the \"scroll to top of the ticket card\" behavior.\n const { scrollRef, contentRef } = useStickToBottom({ initial: 'instant', resize: 'smooth' })\n\n const bodyTurns = ticket.body\n ? ticket.body.split(TURN_SEPARATOR_RE).map((t) => t.trim()).filter(Boolean)\n : []\n\n // Suppress `bodyTurns[0]` (\"Original message\") when the engagement\n // timeline already has a customer-authored message whose body\n // matches it. The channel-first create path in the hub writes the\n // customer's message body BOTH into `hubspot_tickets.content` AND\n // into the first `hubspot_ticket_conversation_messages` row — pre-\n // 2026-05-29 the bot-intake-burst filter on the server dropped the\n // first-customer message from engagements, so `bodyTurns[0]` was\n // the only render. With channel-first, the engagement survives and\n // both surfaces render the same text. Drop the redundant\n // \"Original message\" turn when we detect that overlap.\n //\n // Only `bodyTurns[0]` is conditional. Subsequent turns (\"Update N\",\n // \"[Resolution]\") come from `update_ticket.content_addendum` and\n // are NEVER customer-written, so the engagement timeline can't\n // match them. Leave their indices intact so `Update 1` still\n // labels as such when `bodyTurns[0]` is suppressed.\n const customerEngagementBodies = new Set<string>(\n engagements\n .filter((e) => e.authorRole === 'customer')\n .map((e) => (e.body ?? '').trim())\n .filter(Boolean),\n )\n const suppressBodyTurnZero =\n bodyTurns.length > 0 &&\n customerEngagementBodies.has(bodyTurns[0])\n\n // Customer name resolution precedence:\n // 1. LIVE chat identity (`identity.user.name`) — when the viewer\n // is the ticket's own customer. Always fresh.\n // 2. Mirror's `customer_name` — the HubSpot contact's display\n // name, captured by the ticket sync. Falls back here when the\n // viewer is NOT the customer (admin browsing / multi-contact\n // second viewer) so the customer bubble still shows the real\n // person's name instead of \"Customer\" generic.\n // 3. Session email — last resort.\n // 4. \"You\" — anonymous viewer.\n const sessionEmailLower = identity.user?.email?.trim().toLowerCase() ?? null\n const isViewerTheCustomer =\n !!sessionEmailLower &&\n ticket.customer_emails.some((e) => e.trim().toLowerCase() === sessionEmailLower)\n const viewerName = identity.user?.name?.trim() || null\n const ticketCustomerName = ticket.customer_name?.trim() || null\n const customerName =\n (isViewerTheCustomer ? viewerName : null) ||\n ticketCustomerName ||\n viewerName ||\n identity.user?.email ||\n 'You'\n const customerAvatar = isViewerTheCustomer\n ? identity.user?.avatarUrl ?? undefined\n : undefined\n\n // Loading takes precedence over partial content — this is the fix for the\n // \"open shows 1 message, then the rest load and the box grows\" jank. The\n // ticket BODY is available synchronously, but the engagement timeline is\n // fetched on open (caches off → EVERY open refetches). Rendering the body\n // alone and then appending engagements as they arrive is the pop-in/grow the\n // user hit. Instead: show the FULL-HEIGHT skeleton until the fetch settles,\n // THEN render the whole conversation at once. Fixed height + skeleton-first =\n // zero reflow and no partial render. `isLoading` (not `isFetching`) is true\n // only on a cold open with no data yet — so a background refetch after sending\n // a reply does NOT flash the skeleton; the new row just appends.\n if (isLoading) {\n // NO scroll refs here — they attach only to the real-content branch (refs on\n // the skeleton would snap-to-bottom on skeleton height, then again on content).\n return (\n <div className={`${TICKET_FEED_FRAME} ${TICKET_FEED_HEIGHT}`}>\n <div className={TICKET_FEED_INNER}>\n {Array.from({ length: TICKET_FEED_SKELETON_ROWS }, (_, i) => (\n <ChatMessageRowSkeleton key={i} />\n ))}\n </div>\n </div>\n )\n }\n\n if (bodyTurns.length === 0 && engagements.length === 0) {\n return (\n <EmptyState\n type=\"generic\"\n title=\"No conversation yet\"\n description=\"Reply below to start the thread with the support team.\"\n showCTA={false}\n />\n )\n }\n\n return (\n <div ref={scrollRef} className={`${TICKET_FEED_FRAME} ${TICKET_FEED_HEIGHT}`}>\n <div ref={contentRef} className={TICKET_FEED_INNER}>\n {/* Customer-authored description + any legacy `---`-joined\n comments. Always rendered ABOVE the engagement timeline as\n \"Original message\" because the server's intake-burst filter\n (see `filterCustomerVisibleTimeline` in\n `hubspot-conversations-utils.ts`) drops the customer's first\n message from engagements when it was part of the HubSpot\n Custom Channel bot intake — bodyTurns IS the canonical\n original for those tickets. For tickets created without bot\n intake (admin-created, email channel) bodyTurns shows the\n manually-entered description and engagements show subsequent\n replies — same flow, no duplication. */}\n {bodyTurns.map((turn, i) => {\n // Drop the redundant first turn when the engagement timeline\n // below already renders the same customer-authored body. See\n // `suppressBodyTurnZero` derivation above for the rationale.\n if (i === 0 && suppressBodyTurnZero) return null\n const isResolution = turn.startsWith('[Resolution]')\n const text = isResolution ? turn.replace(/^\\[Resolution\\]\\s*/, '') : turn\n // Body turns don't carry per-turn timestamps — `ticket.body` is a\n // single content field that HubSpot appends to. They render as the\n // customer's own messages (no role chip — the Slack-channel feed has\n // no role labels), oldest-first above the engagement timeline.\n return (\n <ChatMessageRow\n key={`body-${i}-${turn.slice(0, 24)}`}\n displayName={customerName}\n avatarUrl={customerAvatar}\n body={text}\n />\n )\n })}\n\n {/* Engagement timeline — interleaves customer-authored Custom\n Channel messages (authorRole='customer') and team-authored\n Notes (authorRole='support').\n ATTRIBUTION RULES (per repo convention):\n - CUSTOMER messages whose sender email matches the\n current chat-identity user → render BOTH name AND\n avatar LIVE from `identity.user.*` (1:1 from the\n X-Chat-First-Name + X-Chat-Last-Name + X-Chat-Avatar-Url\n headers that drive the identity webservice). This is\n the source of truth for the logged-in user; we never\n query `profiles` for customer display info.\n - CUSTOMER messages from a DIFFERENT email (rare — the\n /tickets surface only shows the current user's own\n threads) → fall back to whatever the mirror has\n (eng.authorName / eng.authorEmail), no profile lookup.\n - SUPPORT/Note messages → the server has already\n resolved `hubspot_owner_id` → owner email → `profiles`\n row in `list-engagements`. `eng.authorName` +\n `eng.authorAvatarUrl` carry the matched employee's\n display info. When the owner isn't a known Flamingo\n employee, both fields are null and we fall back to\n the generic \"Support team\" treatment. */}\n {engagements.map((eng) => {\n const isCustomer = eng.authorRole === 'customer'\n // Per-message own-reply check: the server populates `authorId`\n // with the message sender's email (resolved server-side via\n // the HubSpot Conversations actor batch-read for Custom\n // Channel visitor messages). When that email matches the\n // session viewer's email, the bubble is the viewer's OWN\n // reply and renders with LIVE chat identity (name + avatar\n // from chat-auth headers).\n const isOwnReply =\n isCustomer &&\n !!eng.authorId &&\n !!identity.user?.email &&\n eng.authorId.toLowerCase() === identity.user.email.toLowerCase()\n\n let author: string\n let avatarSrc: string | undefined\n if (isCustomer && isOwnReply) {\n // Live identity — 1:1 from chat auth headers.\n author = identity.user?.name?.trim() || customerName\n avatarSrc = identity.user?.avatarUrl ?? undefined\n } else if (isCustomer) {\n // Customer bubble whose sender email isn't the current\n // session viewer. Two sub-cases:\n // (a) Same customer as the ticket but viewed by an admin\n // (or no sender_email on the engagement at all — the\n // Conversations API leaves it null on Custom Channels).\n // Use `ticket.customer_name` from the mirror — that's\n // the canonical HubSpot contact name for THIS ticket.\n // (b) Multi-contact ticket (CC/BCC) — a different customer\n // email appears here. We fall back to the same\n // `ticket.customer_name` rather than leak the second\n // contact's address; close enough for the rare case.\n author = ticketCustomerName || 'Customer'\n avatarSrc = undefined\n } else if (eng.authorName && eng.authorAvatarUrl) {\n // Resolved Flamingo employee — server matched the HubSpot\n // owner's email against `profiles` AND has an avatar to\n // prove it. Avatar presence IS the trust signal: only\n // owner-resolved employees carry one; raw HubSpot\n // `sender_name` (bots, integrations, system actors,\n // unmatched humans) carries name without avatar and gets\n // the generic \"Support team\" treatment so we never\n // attribute a customer-facing bubble to a bot string\n // (\"HubSpot Bot\", \"Slack Integration\", etc.).\n author = eng.authorName\n avatarSrc = eng.authorAvatarUrl\n } else {\n // Unmatched / unknown / bot / integration / system actor —\n // generic fallback. Customer doesn't need to see internal\n // tool branding (which has the customer \"talking to\" a bot\n // string instead of a person).\n author = 'Support team'\n avatarSrc = undefined\n }\n\n // Role label: every engagement is a customer-visible\n // Conversations message (customer ↔ agent on the Custom\n // Channel). There are no internal Notes on this surface\n // anymore — the read path explicitly filters them. So\n // \"Reply\" for BOTH sides. The previous \"Note\" label for\n // support bubbles was a legacy artifact from when Notes\n // were rendered and made customers think their support\n // engineer was leaving internal comments on their ticket.\n const engAttachments = mapEngagementAttachments(eng.attachments)\n return (\n <ChatMessageRow\n key={eng.id}\n displayName={author}\n avatarUrl={avatarSrc}\n timeLabel={eng.createdAt ? formatRelativeTime(eng.createdAt) : null}\n body={stripAttachmentsPreamble(eng.body ?? '')}\n footer={\n engAttachments.length > 0 ? (\n <div className=\"mt-2\">\n <TicketAttachmentsList attachments={engAttachments} size=\"compact\" />\n </div>\n ) : null\n }\n />\n )\n })}\n\n {/* No trailing refetch skeleton in the tailing feed: a skeleton mounted\n inside `contentRef` on a background refetch would make the auto-tail\n smooth-scroll to the skeleton and then again to the real row (a\n double-jump). The smooth-tail to the appended real reply IS the\n feedback. (Removed the former background-refetch rows={1} skeleton.) */}\n </div>\n </div>\n )\n}\n\n/** Map the engagement file shape to the lib's canonical\n * `TicketAttachment` so we can hand it straight to\n * `<TicketAttachmentsList>`. Engagement `url` becomes a\n * window.open-style download click; missing names degrade to\n * `file-<id>` so the chip never renders an empty label. */\nfunction mapEngagementAttachments(\n files: TicketEngagementFile[],\n): TicketAttachment[] {\n return files.map((f) => ({\n id: f.id,\n fileName: f.name ?? `file-${f.id}`,\n fileSize: f.size ? formatBytes(f.size) : '',\n // Show an inline thumbnail for image attachments (the signed `url` is a\n // viewable URL). Non-images fall back to the file-type icon. SquareAvatar\n // degrades to initials on a broken/expired image URL.\n thumbnailSrc:\n f.url && (f.mime?.startsWith('image/') ?? false) ? f.url : undefined,\n onDownload: f.url\n ? () => window.open(f.url!, '_blank', 'noopener,noreferrer')\n : undefined,\n }))\n}\n\nfunction formatBytes(size: number): string {\n if (size < 1024) return `${size} B`\n if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`\n return `${(size / (1024 * 1024)).toFixed(1)} MB`\n}\n\n/** Strip the redundant `Attachments:\\n\\n filename.png\\n filename2.png`\n * preamble that the server appends to Note engagement bodies. We\n * already render the same files through `<TicketAttachmentsList>` with\n * proper icons + download buttons — showing the raw filename list\n * again above the chip strip is duplicate noise. The regex matches\n * ANY trailing block that starts with \"Attachments:\" (case-insensitive,\n * optional leading whitespace) and consumes everything to end-of-string,\n * so server-side wording tweaks like \"Attachments (3):\" still strip\n * cleanly. Idempotent — a body with no preamble passes through\n * untouched. */\nconst ATTACHMENTS_PREAMBLE_RE = /\\s*\\n\\s*Attachments\\b[^]*$/i\nfunction stripAttachmentsPreamble(body: string): string {\n return body.replace(ATTACHMENTS_PREAMBLE_RE, '').trim()\n}\n\nfunction ReopenAction({\n ticketRef,\n busy,\n supportSystemDown,\n onReopen,\n onActionCollapsed,\n}: {\n ticketRef: TicketRef\n busy: boolean\n supportSystemDown: boolean\n onReopen: TicketDetailDrawerProps['onReopen']\n onActionCollapsed: TicketDetailDrawerProps['onActionCollapsed']\n}) {\n const handleReopen = async () => {\n // Intentionally do NOT call `onActionCollapsed()` here. Pre-PR #1053\n // every reopen was followed by a full list refetch which removed\n // the (now-OPEN) row from a `?status=closed` view, so collapsing\n // the drawer hid the disappearance flash. After #1053+#1055 the\n // row stays in the list with the optimistic in-place status\n // update — collapsing the drawer now actively dismisses the\n // ticket the user is working on. Keep it mounted; the badge flip\n // is enough feedback. (Reported 2026-05-29.)\n void (await onReopen(ticketRef))\n }\n return (\n <div className=\"flex justify-end\">\n {/* Reopen is a secondary, reversible action — `outline` (not the filled\n accent primary) so it reads as available without dominating the\n closed-ticket view. */}\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"small\"\n onClick={() => void handleReopen()}\n disabled={busy || supportSystemDown}\n loading={busy}\n >\n Reopen\n </Button>\n </div>\n )\n}\n\n/**\n * Persistent banner above the drawer composer/actions when the most\n * recent customer reply failed with a reply-specific code (HUBSPOT_5XX\n * / 400 / 404 / UNKNOWN). The transient toast already fired at the\n * moment of failure; this banner stays until the next successful send\n * OR the user dismisses it explicitly. Wording is sourced from\n * `mapTicketActionError` so a future copy update lives in one place.\n *\n * 404_THREAD is the only terminal code in the set — the banner copy\n * reads \"open a new ticket\" and Retry would just re-fail. We still\n * render a Dismiss control instead of hiding Retry so the visual shape\n * is uniform; the parent's composer continues to function for any\n * non-thread-deletion reply path.\n */\nfunction ReplyFailureBanner({\n error,\n onDismiss,\n}: {\n error: MappedTicketActionError\n onDismiss: () => void\n}) {\n return (\n <div\n role=\"status\"\n aria-live=\"polite\"\n className=\"mb-3 flex items-start gap-3 rounded-md border border-ods-attention-red-error bg-ods-attention-red-error-secondary px-3 py-2 text-sm text-ods-attention-red-error\"\n >\n <span className=\"font-medium leading-snug\">{error.message}</span>\n <Button\n type=\"button\"\n variant=\"transparent\"\n onClick={onDismiss}\n aria-label=\"Dismiss reply failure\"\n className=\"ml-auto px-2 py-0.5 text-xs font-medium uppercase tracking-wider text-ods-attention-red-error hover:bg-ods-attention-red-error/10 border-transparent\"\n >\n Dismiss\n </Button>\n </div>\n )\n}\n\n/**\n * Compact \"Assigned to\" row at the top of the drawer. Surfaces the\n * support-side agent — name + avatar — so the customer knows who's\n * looking at their ticket. Renders \"Unassigned\" when the ticket has no\n * `hubspot_owner_id` OR when the owner couldn't be resolved against\n * the mirror (deleted between ticket update + next reconcile).\n *\n * Avatar comes from the canonical `<SquareAvatar variant=\"round\">` so\n * it picks up the initials-fallback + image-proxy behavior used\n * everywhere else in the lib (matches the dev-section message-bubble\n * avatars). No bespoke avatar markup.\n */\nfunction AssignedAgentRow({\n assignedOwner,\n}: {\n assignedOwner: TicketAssignedOwner | null\n}) {\n // Display label precedence:\n // 1. `name` from the mirror (employee match OR HubSpot's first+last)\n // 2. `email` local-part — covers HubSpot owners that exist but have\n // no name (rare but real; the ticket IS assigned and rendering\n // \"Unassigned\" would be misleading)\n // 3. \"Unassigned\" — only when the ticket has no `assigned_to` OR\n // the owner couldn't be resolved against the mirror at all\n const trimmedName = assignedOwner?.name?.trim() || null\n const emailFallback = assignedOwner?.email?.trim() || null\n const displayLabel =\n trimmedName ?? (emailFallback ? emailFallback.split('@')[0] : null)\n return (\n <div className=\"flex items-center gap-2 text-xs\">\n <span className=\"text-ods-text-secondary uppercase tracking-wider font-medium\">\n Assigned to\n </span>\n {displayLabel ? (\n <span className=\"flex items-center gap-1.5 text-ods-text-primary font-medium\">\n <SquareAvatar\n size=\"sm\"\n variant=\"round\"\n src={assignedOwner?.avatarUrl ?? undefined}\n alt={displayLabel}\n fallback={displayLabel}\n />\n {displayLabel}\n </span>\n ) : (\n <span className=\"text-ods-text-secondary italic\">Unassigned</span>\n )}\n </div>\n )\n}\n","'use client'\n\n/**\n * Fetch the conversation timeline (Note engagements + attachments) for\n * a single ticket. Powers the drawer's timeline view — separate from\n * `useTicketsList` because the engagements are expensive to fetch\n * (multi-stage HubSpot API calls) and only needed when a row is\n * expanded.\n *\n * Auth: rides `embedAuthedFetch` (same proxy creds as the chat). The\n * server-side route asserts ticket ownership via\n * `ticketBelongsToCustomer` for self-scoped sources before reading\n * notes — a customer can't enumerate another customer's notes by\n * guessing ticket ids.\n */\n\nimport { useQuery } from '@tanstack/react-query'\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch'\nimport { useChatIdentity } from '../../chat/hooks/use-chat-identity'\n\nconst LIST_ENGAGEMENTS_ENDPOINT = '/api/chat/agent/list-engagements'\n\nexport interface TicketEngagementFile {\n id: string\n name: string | null\n url: string | null\n mime: string | null\n size: number | null\n}\n\nexport interface TicketEngagement {\n id: string\n body: string | null\n authorId: string | null\n /** Whether this engagement is customer-authored (Custom Channels\n * Messages — INCOMING direction) or team-authored (Notes + future\n * OUTGOING messages). Drives avatar variant + the \"Customer\"/\"Support\n * team\" header label in the drawer's conversation thread. */\n authorRole: 'customer' | 'support'\n /** Display name. Server resolves it differently per role:\n * - `support` (Notes) → HubSpot owner id is resolved to an owner\n * email, then matched against our `profiles` table; the matched\n * employee's `full_name` is returned here. Null when the owner\n * isn't a known Flamingo employee.\n * - `customer` (Conversations messages) → null on new messages\n * (drawer renders identity.user.name LIVE for the current\n * user's own messages). Set only on legacy rows from earlier\n * migrations. */\n authorName: string | null\n /** Resolved author email — for `support` it's the HubSpot owner's\n * email; for `customer` it's the message sender. Used by the\n * drawer to cross-check \"is this me?\" against `identity.user.email`. */\n authorEmail: string | null\n /** Avatar URL. For `support`, resolved from the matched `profiles`\n * row's `avatar_url`. Null when the owner isn't a known Flamingo\n * employee. For `customer`, always null on the wire (drawer reads\n * identity.user.avatarUrl live for own messages). */\n authorAvatarUrl: string | null\n createdAt: string\n attachments: TicketEngagementFile[]\n}\n\ninterface ListEngagementsResponse {\n engagements?: TicketEngagement[]\n count?: number\n}\n\nexport interface UseTicketEngagementsReturn {\n engagements: TicketEngagement[]\n isLoading: boolean\n isFetching: boolean\n error: Error | null\n refetch: () => void\n}\n\nexport function useTicketEngagements(\n externalTicketId: string | null | undefined,\n enabled = true,\n /** Poll cadence (ms) for live conversation refresh while the drawer is\n * open. The drawer only mounts this hook when expanded, so a constant\n * here is already gated to \"drawer open\" — closing the drawer unmounts\n * the panel and the polling stops. `false`/undefined disables it (the\n * default, preserving prior fetch-once-per-open behavior for any other\n * caller). Mirrors `useTicketsList.refetchInterval`; see\n * `TICKET_LIVE_POLL_MS`. */\n refetchInterval: number | false = false,\n): UseTicketEngagementsReturn {\n const identity = useChatIdentity()\n const identityKey = identity.user?.email ?? 'anon'\n\n // \"Will this ticket fetch its timeline once identity is ready?\" — i.e. it's a\n // real, non-optimistic ticket the caller enabled. INDEPENDENT of whether\n // identity has resolved yet, so the loading state is correct from the very\n // first render (before `useChatIdentity` settles).\n const fetchable =\n enabled &&\n !!externalTicketId &&\n !externalTicketId.startsWith('temp-') // optimistic placeholders have no real id yet\n\n const queryEnabled =\n fetchable && identity.authTier !== 'anon' && !!identity.user?.email\n\n const query = useQuery({\n queryKey: ['ticket-engagements', externalTicketId, identityKey],\n enabled: queryEnabled,\n // Caches OFF — same reasoning as `useTicketsList`. The conversation\n // timeline must reflect HubSpot truth on every drawer-open; a stale\n // window risks hiding a freshly-arrived agent reply.\n staleTime: 0,\n gcTime: 0,\n refetchOnMount: 'always',\n refetchOnWindowFocus: true,\n // Live conversation: poll while the caller opts in (drawer open). New\n // agent replies + attachments appear within one interval without a\n // manual refresh. `refetchIntervalInBackground` stays false (default)\n // so polling pauses on a hidden tab.\n refetchInterval,\n queryFn: async (): Promise<TicketEngagement[]> => {\n const response = await embedAuthedFetch(LIST_ENGAGEMENTS_ENDPOINT, {\n method: 'POST',\n body: JSON.stringify({ ticket_id: externalTicketId }),\n })\n if (!response.ok) {\n const text = await response.text().catch(() => '')\n throw new Error(`list-engagements failed: ${response.status} ${text.slice(0, 200)}`)\n }\n const body = (await response.json()) as ListEngagementsResponse\n return Array.isArray(body.engagements) ? body.engagements : []\n },\n })\n\n return {\n engagements: query.data ?? [],\n // Loading-state truth that prevents the \"body → blink → skeleton → data\"\n // double-flash. The bug: `useChatIdentity` starts at anon defaults and\n // resolves async, so on the first render `queryEnabled` is false and the\n // OLD `queryEnabled && query.isLoading` returned FALSE — the panel rendered\n // the ticket body, THEN identity resolved, the query enabled, isLoading\n // flipped true → skeleton appeared (the blink), then data landed.\n //\n // Fix: for a fetchable ticket we are \"loading\" whenever we don't yet have\n // the timeline to show — that includes the window while identity is still\n // resolving (so we skeleton from the FIRST render, never the body) AND the\n // cold query fetch (`data === undefined`). A background poll keeps\n // `query.data` defined, so it never re-flashes the skeleton. Non-fetchable\n // (optimistic/disabled) or a resolved-anon viewer → not loading.\n isLoading:\n fetchable &&\n (identity.isLoading || (queryEnabled && query.data === undefined)),\n isFetching: query.isFetching,\n error: (query.error as Error | null) ?? null,\n refetch: () => {\n void query.refetch()\n },\n }\n}\n","'use client'\n\n/**\n * `<TicketLinkedDeliveryCard />` — renders the ClickUp delivery task\n * linked to a HubSpot ticket as a single `<DeliveryRow />` (the same\n * primitive `/bug-fixes-and-enhancements` uses for its row tiles).\n *\n * Navigation is unified with the chat-inline delivery card: both go\n * through `buildDevSectionUrl('delivery', external_id)`, composed\n * server-side and shipped on `clickup.delivery_href`. The URL carries\n * `?search=<external_id>` so the landing list filters to that exact\n * task (the canonical \"deep-link to a specific delivery row\" mechanism\n * already in place for chat).\n *\n * Soft-nav happens via the env-aware `next/link` shim that the host\n * registers — back-button restores /tickets with React state intact\n * (no skeleton flash, no TanStack-Query cache loss).\n */\n\nimport { DeliveryRow } from '../shared/delivery/delivery-row'\nimport type { DeliveryItem } from '../../types/delivery'\nimport type { TicketClickupSummary } from './types'\n\nexport interface TicketLinkedDeliveryCardProps {\n clickup: TicketClickupSummary\n className?: string\n}\n\nexport function TicketLinkedDeliveryCard({\n clickup,\n className,\n}: TicketLinkedDeliveryCardProps) {\n const item: DeliveryItem = {\n id: clickup.external_id,\n title: clickup.title ?? 'Linked delivery task',\n description: clickup.description ?? '',\n status: clickup.status ?? 'unknown',\n statusColor: clickup.status_color ?? '#87909e',\n taskType: clickup.task_type ?? 'Request',\n customItemId: clickup.custom_item_id,\n listNames: clickup.list_names,\n dateOpened: clickup.date_opened ?? 0,\n dateUpdated: clickup.date_updated ?? clickup.date_opened ?? Date.now(),\n dateClosed: clickup.date_closed,\n clickupUrl: clickup.clickup_url ?? '',\n }\n\n return (\n <div\n className={`rounded-md border border-ods-border bg-ods-bg overflow-hidden ${className ?? ''}`}\n >\n <DeliveryRow\n item={item}\n href={clickup.delivery_href}\n caption=\"Linked delivery\"\n />\n </div>\n )\n}\n","'use client'\n\nimport { useCallback, useState } from 'react'\nimport { Button } from './../ui/button'\nimport { Textarea } from './../ui/textarea'\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from './../ui/alert-dialog'\nimport { ChatInput } from './../chat/chat-input'\nimport {\n ChatAttachmentAddButton,\n ChatAttachmentChipStrip,\n} from './../chat/chat-attachment-bar'\nimport { useChatAttachments } from './../chat/hooks/use-chat-attachments'\nimport type { AnyTicket } from './types'\nimport { TICKET_TEXT_MAX_CHARS } from './types'\nimport type { TicketDetailDrawerProps, TicketRef } from './ticket-detail-drawer'\n\nexport interface TicketReplyComposerProps {\n ticket: AnyTicket\n busy: boolean\n supportSystemDown: boolean\n onSendMessage: TicketDetailDrawerProps['onSendMessage']\n onClose: TicketDetailDrawerProps['onClose']\n}\n\n/**\n * Open-ticket reply composer — REUSES the exact same layout as the global\n * Ask-AI chat composer (`embeddable-chat.tsx`): the shared `<ChatInput>` with\n * the staged-file chip strip above it and the attachment `+` button in a\n * controls row BELOW the input (identical placement to the global chat), plus\n * the destructive close-confirm `AlertDialog`.\n *\n * Replaces the former `OpenActions` raw-`<Textarea>` composer. The text lives\n * inside `ChatInput`; this component owns only the attachment bag + the close\n * dialog. Send semantics:\n * - `sending={busy || hasInflightUploads}` disables the input while sending\n * OR uploading (same as the global chat); `allowEmptySend` lets an\n * attachments-only reply send once uploads finish;\n * - the typed draft + staged files survive a FAILED send: `handleSend`\n * returns `false`, so `ChatInput` keeps the text and we only `clear()` the\n * attachments on success;\n * - close does NOT collapse the drawer (no `onActionCollapsed`).\n *\n * `disabled={supportSystemDown}` is the only flag that drives the \"Connection\n * lost…\" placeholder. The `+` attach gate stays `!supportSystemDown` (same gate\n * the old composer used).\n */\nexport function TicketReplyComposer({\n ticket,\n busy,\n supportSystemDown,\n onSendMessage,\n onClose,\n}: TicketReplyComposerProps) {\n const [resolution, setResolution] = useState('')\n const [closeDialogOpen, setCloseDialogOpen] = useState(false)\n const attachments = useChatAttachments()\n\n const ticketRef: TicketRef = { id: ticket.id, external_id: ticket.external_id }\n const hasReadyFiles = attachments.readyAttachments.length > 0\n\n const handleSend = useCallback(\n async (text: string): Promise<boolean> => {\n const ref: TicketRef = { id: ticket.id, external_id: ticket.external_id }\n const ok = await onSendMessage(ref, text.trim(), attachments.readyAttachments)\n if (ok) attachments.clear()\n return ok\n },\n // Depend on the reactive projections, not the whole bag (a fresh object each\n // render). `readyAttachments` is memo-stable; `clear` is callback-stable.\n [\n onSendMessage,\n ticket.id,\n ticket.external_id,\n attachments.readyAttachments,\n attachments.clear,\n ],\n )\n\n const confirmClose = async () => {\n setCloseDialogOpen(false)\n await onClose(ticketRef, resolution.trim() || undefined)\n setResolution('')\n // Intentionally NO `onActionCollapsed()` — collapsing the drawer after a\n // close dismisses the ticket the user is working on (PR #1053). The\n // optimistic in-place status update keeps the row mounted with the new\n // badge; that is the only feedback needed.\n }\n\n const disabled = busy || supportSystemDown\n\n return (\n <div className=\"flex flex-col gap-2\">\n {/* Unified composer — mirrors the global Ask-AI chat layout\n (embeddable-chat.tsx :1160-1206): compact staged-file chip strip\n above, the shared <ChatInput> (Send icon = the PRIMARY action), then a\n quiet bottom toolbar: attachment \"+\" on the left, and a LOW-EMPHASIS\n \"Close ticket\" text button on the right. Closing is reversible (Reopen\n exists), so it is NOT styled as destructive/danger — that would\n over-signal a routine, undoable status change (UX best practice:\n reserve red for irreversible actions). */}\n <ChatAttachmentChipStrip\n attachments={attachments.attachments}\n onRemove={attachments.removeAttachment}\n disabled={disabled}\n size=\"compact\"\n />\n <ChatInput\n fullWidth\n // Focus the reply box when the drawer opens so the customer can type\n // immediately. `ChatInput`'s autoFocus uses `{ preventScroll: true }`,\n // so this does NOT scroll the page — the card's smooth scroll-to-top\n // (HelpCenterCard) wins, and the input stays focused + visible (it\n // sits within the viewport below the fixed-height feed).\n autoFocus\n placeholder=\"Type a reply…\"\n sending={busy || attachments.hasInflightUploads}\n disabled={supportSystemDown}\n allowEmptySend={hasReadyFiles}\n maxLength={TICKET_TEXT_MAX_CHARS}\n onSend={handleSend}\n />\n <div className=\"flex items-center gap-2 w-full\">\n {!supportSystemDown && (\n <ChatAttachmentAddButton\n attachmentsEnabled\n attachmentsCount={attachments.attachments.length}\n onAddFiles={attachments.addFiles}\n disabled={disabled}\n size=\"compact\"\n />\n )}\n <div className=\"flex-1 min-w-0\" />\n <Button\n type=\"button\"\n variant=\"transparent\"\n size=\"small\"\n onClick={() => setCloseDialogOpen(true)}\n disabled={disabled}\n className=\"text-ods-text-secondary hover:text-ods-text-primary\"\n >\n Close ticket\n </Button>\n </div>\n\n {/* Confirm dialog — collects an optional resolution note. Closing is\n REVERSIBLE, so the confirm action is the standard accent primary, NOT\n a red destructive button. */}\n <AlertDialog open={closeDialogOpen} onOpenChange={setCloseDialogOpen}>\n <AlertDialogContent className=\"bg-ods-card border-ods-border\">\n <AlertDialogHeader>\n <AlertDialogTitle className=\"text-ods-text-primary\">\n Close this ticket?\n </AlertDialogTitle>\n <AlertDialogDescription className=\"text-ods-text-secondary\">\n Add an optional resolution note below. You can reopen the ticket\n later if needed.\n </AlertDialogDescription>\n </AlertDialogHeader>\n <Textarea\n value={resolution}\n onChange={(e) => setResolution(e.target.value)}\n placeholder=\"Resolution (optional)\"\n rows={3}\n maxLength={TICKET_TEXT_MAX_CHARS}\n className=\"mt-2\"\n />\n <AlertDialogFooter>\n <AlertDialogCancel\n disabled={busy}\n className=\"bg-transparent border-ods-border text-ods-text-primary hover:bg-ods-border\"\n >\n Cancel\n </AlertDialogCancel>\n <AlertDialogAction\n onClick={() => void confirmClose()}\n disabled={busy}\n className=\"bg-ods-accent text-ods-text-on-accent hover:bg-ods-accent-hover\"\n >\n Close ticket\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </div>\n )\n}\n","'use client'\n\n/**\n * Customer-scoped ticket list — wraps `POST /api/chat/agent/find-ticket`.\n * The server treats `query: ''` as \"list all my tickets\" for self-scoped\n * sources, accepts an optional `status` ('open' | 'closed') filter, and\n * paginates via `page` + `pageSize` (server caps pageSize at 100). All\n * three fold into ONE mirror SELECT with `count: 'exact'` so the\n * response carries both the rows AND total count in a single round-trip.\n *\n * Auth: rides on `embedAuthedFetch`. The server self-scopes by session\n * email — there's no client-supplied scope. An anon caller receives 401;\n * we short-circuit before fetching to avoid the wasted round-trip.\n *\n * Cache: keyed by `['tickets', 'self', identity, search, status, page, pageSize]`.\n * Each filter+page combo gets its own slot, so toggling the URL never\n * blows away the existing slot — TanStack just serves the slot for the\n * new key. `useTicketActions` calls `queryClient.invalidateQueries({queryKey:['tickets']})`\n * after every mutation so all slots refresh together.\n */\n\nimport { useQuery } from '@tanstack/react-query'\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch'\nimport type { TicketData } from '../types'\n\nconst FIND_TICKET_ENDPOINT = '/api/chat/agent/find-ticket'\nconst DEFAULT_PAGE_SIZE = 20\n\ninterface FindTicketResponse {\n tickets?: TicketData[]\n count?: number\n totalCount?: number\n page?: number\n pageSize?: number\n totalPages?: number\n scope?: 'self' | 'all'\n}\n\nexport interface UseTicketsListFilters {\n /** Customer email — the identity the parent already resolved via\n * `useChatIdentity` at the gate. THIS hook does NOT call\n * `useChatIdentity` itself: `useChatIdentity` is plain\n * `useState`+`useEffect` (no shared cache), so calling it again\n * here would mount with `user = null` racing the parent's already-\n * resolved identity. On the first render of `HelpCenterListAuthed`\n * that race produced `enabled = false` for one frame, the\n * conditional fell through to `!hasResults` → EmptyState flashed\n * before the tickets fetch fired. Drilling `customerEmail` in\n * from the parent makes the loading state monotonic. */\n customerEmail: string\n /** Free-text query — server runs FTS on `search_vector`. Empty → no\n * search filter (self-scoped \"list all my tickets\"). */\n search?: string\n /** Canonical 'open' | 'closed'. Server maps to the underlying mirror\n * status column. Empty / 'all' → no status filter. */\n status?: string\n /** 1-based page number. Defaults to 1. */\n page?: number\n /** Items per page (server caps at 100). Defaults to 20. */\n pageSize?: number\n /** Poll interval (ms) for live updates — e.g. so a ticket CLOSED out-of-band\n * on HubSpot flips the status badge + open/reopen affordance without a manual\n * refresh. `false`/0/undefined disables polling. The hub passes a value only\n * while a drawer is open (mirror webhooks keep the server fresh; this surfaces\n * it client-side). TanStack pauses interval polling while the tab is hidden,\n * so there are no wasted background requests. */\n refetchInterval?: number | false\n}\n\nexport interface UseTicketsListReturn {\n tickets: TicketData[]\n isLoading: boolean\n isFetching: boolean\n error: Error | null\n refetch: () => void\n /** Wall-clock timestamp of the last successful fetch; null while\n * loading the first time. */\n lastUpdatedAt: number | null\n /** Total ticket count across all pages (NOT just the current page).\n * Drives the `<UnifiedPagination>` total-pages calculation. */\n totalCount: number\n /** 1-based current page (echoed from the server response so the URL\n * and the rendered set always agree). */\n page: number\n /** Page size in use — echoed from the server (capped at 100). */\n pageSize: number\n /** Pre-computed `Math.ceil(totalCount / pageSize)` clamped to ≥1 so\n * the pagination renders \"1 / 1\" instead of \"1 / 0\" on empty\n * result sets. */\n totalPages: number\n}\n\nexport function useTicketsList(filters: UseTicketsListFilters): UseTicketsListReturn {\n const customerEmail = filters.customerEmail\n const search = (filters.search ?? '').trim()\n const status = (filters.status ?? '').trim().toLowerCase()\n const statusFilter = status && status !== 'all' ? status : ''\n const page = Math.max(1, Math.floor(filters.page ?? 1) || 1)\n const pageSize = Math.max(1, Math.min(100, Math.floor(filters.pageSize ?? DEFAULT_PAGE_SIZE) || DEFAULT_PAGE_SIZE))\n\n // `customerEmail` is the source of truth — parent (HelpCenterList)\n // already gates on `identity.user?.email` being truthy before\n // mounting the consumer of this hook. An empty string here means\n // the consumer was mounted incorrectly (developer error); the\n // query stays disabled.\n const enabled = !!customerEmail\n\n // Identity-keyed cache: admin swaps proxy creds in /debug mid-session\n // → new key from the parent → new cache slot → no flash of the\n // previous identity's data.\n const identityKey = customerEmail || 'anon'\n\n const refetchInterval = filters.refetchInterval ?? false\n\n const query = useQuery({\n queryKey: ['tickets', 'self', identityKey, search, statusFilter, page, pageSize],\n enabled,\n // Caches OFF — every mount + every focus + every navigation triggers\n // a fresh fetch. The ticket data is the customer's own list of\n // tickets they expect to be live (sync agents reply, statuses flip,\n // new comments arrive); a stale window of any size is worse than\n // a sub-second refetch.\n staleTime: 0,\n gcTime: 0,\n refetchOnMount: 'always',\n refetchOnWindowFocus: true,\n // Live status: poll while the caller opts in (drawer open). Defaults to\n // false. `refetchIntervalInBackground` stays false (the default) so polling\n // pauses on a hidden tab — no wasted requests when the user tabs away.\n refetchInterval,\n queryFn: async (): Promise<FindTicketResponse> => {\n const body: Record<string, string | number> = {\n query: search,\n page,\n pageSize,\n }\n if (statusFilter) body.status = statusFilter\n const response = await embedAuthedFetch(FIND_TICKET_ENDPOINT, {\n method: 'POST',\n body: JSON.stringify(body),\n })\n if (!response.ok) {\n const text = await response.text().catch(() => '')\n throw new Error(`find-ticket failed: ${response.status} ${text.slice(0, 200)}`)\n }\n return (await response.json()) as FindTicketResponse\n },\n })\n\n const data = query.data\n const totalCount = data?.totalCount ?? data?.count ?? (data?.tickets?.length ?? 0)\n const echoedPage = data?.page ?? page\n const echoedPageSize = data?.pageSize ?? pageSize\n const totalPages = data?.totalPages ?? Math.max(1, Math.ceil(totalCount / echoedPageSize))\n\n return {\n tickets: data?.tickets ?? [],\n // Loading-state-truth = `data === undefined`. TanStack v5's\n // `isPending` / `isLoading` flags can be `false` in transient\n // windows where the query is enabled-but-fetch-not-yet-fired\n // OR where stale-data exists from a sibling cache slot — both\n // produced the EmptyState flash on /tickets first load. Treating\n // \"no data for THIS query slot yet\" as the universal loading\n // signal can't lie:\n // - Initial render after enabled flips: data === undefined → load\n // - Background refetch with existing data: data !== undefined → no load\n // - Filter-change refetch landing on empty results: data?.tickets===[]\n // + isFetching → bridge skeleton (the `||` branch)\n // Loading-state-truth = `data === undefined`. TanStack v5's\n // `isPending` / `isLoading` flags can be `false` in transient\n // windows where the query is enabled-but-fetch-not-yet-fired\n // OR where stale-data exists from a sibling cache slot. Treating\n // \"no data for THIS query slot yet\" as the universal loading\n // signal can't lie:\n // - Initial render after enabled flips: data === undefined → load\n // - Background refetch with existing data: data !== undefined → no load\n // - Filter-change refetch landing on empty results: data?.tickets===[]\n // + isFetching → bridge skeleton (the `||` branch)\n isLoading:\n enabled &&\n (data === undefined ||\n (query.isFetching && (data?.tickets ?? []).length === 0)),\n isFetching: query.isFetching,\n error: (query.error as Error | null) ?? null,\n refetch: () => {\n void query.refetch()\n },\n lastUpdatedAt: query.dataUpdatedAt || null,\n totalCount,\n page: echoedPage,\n pageSize: echoedPageSize,\n totalPages,\n }\n}\n","'use client'\n\n/**\n * All 5 ticket write actions funnel through one helper:\n * `executeTicketAction()`, which POSTs to `/api/chat/agent/ticket-action`\n * — a single-roundtrip endpoint that runs the SAME `ChatToolHandler.execute`\n * the chat agent's `confirm-tool` route uses (same ACL re-bind, same audit\n * row, same HubSpot REST call). The only difference is REST shape: the\n * chat path needs `propose → confirm-tool` because the LLM emits a\n * `tool_use` the user must approve in a proposal card. The /tickets form\n * has no approval step (the user already clicked \"Open ticket\" / \"Send\"),\n * so the two-step is pure overhead — we collapse to one POST + JSON.\n *\n * Reuses every existing piece:\n * - `embedAuthedFetch` for the bearer/act-as headers (same auth as chat).\n * - TanStack-Query `invalidateQueries` for refetch.\n *\n * Single-flight + serialization:\n * - Form-level `formInFlight` ref drops second submits while one is\n * in flight.\n * - Per-row `Set<ticket_id>` mutex prevents fan-out on the same row.\n * - `mutationQueue` serializes ALL mutations through a depth=1 queue\n * so 10× \"Close\" doesn't stampede the server's auth-write 60/min\n * rate-limit.\n *\n * Mirror-sync retry: on `result.mirror_synced === false`, the helper\n * schedules 3s/6s/12s refetches (30s wall-clock cap). If the placeholder\n * is still present after the last attempt, it's removed and the parent\n * surfaces an inline \"Couldn't confirm — Reload\" affordance.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch'\nimport type { ChatAttachment } from '../../chat/utils/chat-attachment-markdown'\nimport {\n type AnyTicket,\n type MappedTicketActionError,\n type OptimisticTicket,\n type TicketActionErrorCode,\n type TicketData,\n type TicketsCacheSlot,\n TOAST_COPY,\n} from '../types'\n\nconst TICKET_ACTION_ENDPOINT = '/api/chat/agent/ticket-action'\n\n/** Codes that populate the inline reply-failure banner above the drawer\n * composer. Other codes (system-down, ticket-gone, rate-limit) are\n * full-row / full-system signals covered by the toast + supportSystemDown\n * handling — surfacing them in the inline banner too would be redundant. */\nconst REPLY_BANNER_CODES: ReadonlySet<TicketActionErrorCode> = new Set<TicketActionErrorCode>([\n 'HUBSPOT_5XX',\n 'HUBSPOT_400_VALIDATION',\n 'HUBSPOT_404_THREAD',\n 'HUBSPOT_REPLY_UNKNOWN',\n])\n\n/** 3 attempts × backoff (cumulative ~21s wall-clock). After this we\n * drop the optimistic row and ask the user to reload. */\nconst MIRROR_SYNC_BACKOFF_MS = [3_000, 6_000, 12_000] as const\n\ntype ToolName = 'create_ticket' | 'update_ticket'\n\n/** Wire shape returned by the new `/api/chat/agent/ticket-action` endpoint.\n * Flat — no decision-frame wrapping — because there's no LLM approval\n * loop on the form path. Mirrors `ExecuteResult` from\n * `chat-source-strategy.ts` plus `{ ok, ticket_id }` at the top so\n * callers don't need to know about the underlying `id` field. */\ninterface TicketActionResponse {\n ok?: boolean\n ticket_id?: string\n status?: string | null\n mirror_synced?: boolean\n raw?: unknown\n error?: string\n code?: string\n}\n\ninterface SubmitTicketInput {\n subject: string\n content: string\n attachments?: ChatAttachment[]\n}\n\ninterface UpdateTicketArgs {\n ticket_id: string\n status?: 'OPEN' | 'CLOSED'\n content_addendum?: string\n resolution?: string\n attachments?: ChatAttachment[]\n}\n\nexport interface UseTicketActionsOptions {\n /** Called when the parent should prepend an optimistic placeholder\n * to the local cache. Implementer mutates the QueryClient cache\n * directly so the row appears before the server roundtrip. */\n prependOptimistic: (placeholder: OptimisticTicket) => void\n /** Called when the optimistic placeholder should be removed\n * (mirror-sync failure, or replacement after real ticket arrives). */\n removeOptimistic: (placeholderId: string) => void\n /** Called when a ticket should be removed from the cache without\n * a refetch (TICKET_NOT_FOUND). */\n removeTicketFromCache: (ticketId: string) => void\n /** Toast helper from `@flamingo-stack/openframe-frontend-core/hooks`.\n * Passed in so the lib doesn't import the toast singleton itself\n * (test-friendly). */\n toast: (input: { title: string; description?: string; variant?: 'success' | 'destructive' | 'default' }) => void\n /** Called when a 412 HUBSPOT_DISCONNECTED arrives so the parent can\n * flip its `supportSystemDown` flag. */\n onSupportSystemDown: () => void\n}\n\n/**\n * Identity bundle used by every row action: the LOCAL mirror UUID\n * (drives the React-side mutex + optimistic cache removal) AND the\n * HubSpot ticket id (`external_id` — drives the server call, the only\n * id HubSpot REST recognizes). Decoupling these is mandatory: passing\n * the UUID to HubSpot gets you a 404 \"Object not found. objectId are\n * usually numeric\"; passing the external id to the React-side mutex\n * breaks per-row disable when the cache differs.\n */\nexport interface TicketRef {\n id: string\n external_id: string\n}\n\nexport interface UseTicketActionsReturn {\n submitTicket: (input: SubmitTicketInput) => Promise<boolean>\n /** Single combined \"reply\" action — text + optional attachments\n * delivered as ONE HubSpot Note engagement (one bubble in the\n * timeline). Server creates a merged note when both are present. */\n sendMessage: (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => Promise<boolean>\n closeTicket: (ticket: TicketRef, resolution?: string) => Promise<boolean>\n reopenTicket: (ticket: TicketRef) => Promise<boolean>\n /** `true` while the form-level submit is in flight. */\n isSubmittingForm: boolean\n /** Per-row in-flight set (read-only). UI uses `isRowBusy(localId)`. */\n isRowBusy: (localId: string) => boolean\n /** Most recent reply failure for a given ticket id (`external_id`).\n * Drives the inline \"couldn't send\" banner above the composer in\n * `<TicketDetailDrawer>`. Cleared on the next successful send OR\n * via `clearReplyError(ticketId)`. */\n replyErrorFor: (ticketExternalId: string) => MappedTicketActionError | null\n /** Clear the persisted reply-failure banner for a ticket (e.g. when\n * the user dismisses it or starts a new draft). */\n clearReplyError: (ticketExternalId: string) => void\n}\n\nexport function useTicketActions(options: UseTicketActionsOptions): UseTicketActionsReturn {\n const queryClient = useQueryClient()\n const { prependOptimistic, removeOptimistic, removeTicketFromCache, toast, onSupportSystemDown } = options\n\n // Form-level single-flight uses BOTH a ref (for synchronous guarding\n // inside `submitTicket`, since React state setters are async) and a\n // state mirror (for UI disable / loading prop). Two rapid clicks in\n // the same tick would otherwise both see state==false and fan out\n // duplicate propose calls.\n const formInFlightRef = useRef(false)\n const [isSubmittingForm, setIsSubmittingForm] = useState(false)\n\n // Per-row mutex — same split: ref for synchronous has/add/delete,\n // state for the `isRowBusy` selector that drives row disable.\n const busyRowsRef = useRef<Set<string>>(new Set())\n const [busyRows, setBusyRows] = useState<Set<string>>(() => new Set())\n const setRowBusy = useCallback((id: string, busy: boolean) => {\n if (busy) busyRowsRef.current.add(id)\n else busyRowsRef.current.delete(id)\n // Mirror to state (new Set so React notices). Render-only side.\n setBusyRows(new Set(busyRowsRef.current))\n }, [])\n const isRowBusy = useCallback((id: string) => busyRows.has(id), [busyRows])\n\n // Persisted reply-failure banner state — keyed by the ticket's\n // HubSpot `external_id`. The drawer reads `replyErrorFor(externalId)`\n // and renders an inline \"couldn't send — retry\" banner above the\n // composer. Cleared automatically on the next successful send and\n // explicitly by the dismiss-X / \"Retry\" actions in the banner UI.\n // Distinct from the transient toast — the banner persists so the\n // user can locate their failed draft after dismissing the toast.\n const [replyErrorByTicket, setReplyErrorByTicket] = useState<\n Map<string, MappedTicketActionError>\n >(() => new Map())\n const setReplyError = useCallback(\n (externalId: string, mapped: MappedTicketActionError | null) => {\n setReplyErrorByTicket((prev) => {\n const next = new Map(prev)\n if (mapped) next.set(externalId, mapped)\n else next.delete(externalId)\n return next\n })\n },\n [],\n )\n const replyErrorFor = useCallback(\n (externalId: string): MappedTicketActionError | null =>\n replyErrorByTicket.get(externalId) ?? null,\n [replyErrorByTicket],\n )\n const clearReplyError = useCallback(\n (externalId: string) => setReplyError(externalId, null),\n [setReplyError],\n )\n\n // Mirror-sync watcher controllers tracked by placeholder id so we can\n // abort prior watchers when a new submit lands AND so unmount cleans\n // them up without leaking setState calls. Single source of truth for\n // active watchers — never duplicate-schedule.\n const watcherControllersRef = useRef<Map<string, AbortController>>(new Map())\n useEffect(() => {\n return () => {\n // Component unmount — abort every live watcher so setState calls\n // inside the scheduler don't fire on an unmounted component.\n for (const controller of watcherControllersRef.current.values()) {\n controller.abort()\n }\n watcherControllersRef.current.clear()\n }\n }, [])\n\n // Single-flight queue (depth=1). Subsequent calls await the prior\n // promise. Local backoff timers and SSE drains run inside the queued\n // closure so the second user click waits for the first to fully\n // resolve before issuing its own propose call. This is the\n // server-stampede defense.\n const queueRef = useRef<Promise<unknown>>(Promise.resolve())\n const enqueue = useCallback(<T,>(work: () => Promise<T>): Promise<T> => {\n const next = queueRef.current.then(work, work)\n // Swallow rejection from the prior step so a single failure doesn't\n // poison every subsequent enqueue.\n queueRef.current = next.catch(() => undefined)\n return next\n }, [])\n\n const executeTicketAction = useCallback(\n async (toolName: ToolName, args: Record<string, unknown>): Promise<TicketActionResponse> => {\n const res = await embedAuthedFetch(TICKET_ACTION_ENDPOINT, {\n method: 'POST',\n body: JSON.stringify({ tool_name: toolName, args }),\n })\n // Server returns JSON for both success and failure — no SSE on this\n // route. Parse once, branch on `res.ok`.\n const body = (await res.json().catch(() => ({}))) as TicketActionResponse\n if (!res.ok) {\n const code = resolveErrorCode(body.code, res.status)\n const message = body.error || `${toolName} failed (${res.status})`\n throw new TicketActionFailure(code, message, res)\n }\n return body\n },\n [],\n )\n\n // Mirror-sync watcher — backoff refetches when the post-create mirror\n // upsert fails. Tracked in `watcherControllersRef` so unmount aborts\n // every live scheduler and a duplicate submit for the same placeholder\n // replaces the prior controller cleanly (no orphaned schedulers).\n //\n // `expectedTicketId` is the external_id the server returned from\n // create_ticket. After each invalidation refetch lands, if any cache\n // slot now contains a ticket with that id, the placeholder is dropped\n // immediately — preventing the duplicate-row window where placeholder\n // + real row both render until the 30s cap fires.\n const watchMirrorSync = useCallback(\n (placeholderId: string, expectedTicketId: string | undefined) => {\n const prior = watcherControllersRef.current.get(placeholderId)\n if (prior) prior.abort()\n const controller = new AbortController()\n watcherControllersRef.current.set(placeholderId, controller)\n const schedule = async () => {\n try {\n for (let i = 0; i < MIRROR_SYNC_BACKOFF_MS.length; i++) {\n if (controller.signal.aborted) return\n await new Promise<void>((resolve) => {\n const t = setTimeout(resolve, MIRROR_SYNC_BACKOFF_MS[i])\n controller.signal.addEventListener(\n 'abort',\n () => {\n clearTimeout(t)\n resolve()\n },\n { once: true },\n )\n })\n if (controller.signal.aborted) return\n await queryClient.invalidateQueries({ queryKey: ['tickets'] })\n // If the real ticket landed during this refetch, drop the\n // placeholder + stop scheduling — no duplicate-row window.\n if (expectedTicketId && cacheContainsTicket(queryClient, expectedTicketId)) {\n removeOptimistic(placeholderId)\n return\n }\n }\n // Last-resort cleanup — placeholder didn't get replaced.\n if (!controller.signal.aborted) {\n removeOptimistic(placeholderId)\n toast({\n title: \"Couldn't confirm ticket\",\n description: \"If the ticket doesn't appear shortly, please contact support.\",\n variant: 'destructive',\n })\n }\n } finally {\n // Self-deregister on natural completion or abort so the map\n // doesn't accrete dead controllers across many submits.\n if (watcherControllersRef.current.get(placeholderId) === controller) {\n watcherControllersRef.current.delete(placeholderId)\n }\n }\n }\n void schedule()\n },\n [queryClient, removeOptimistic, toast],\n )\n\n // Last `surfaceError` mapping — sendMessage reads this immediately\n // after the catch returns so it can decide whether to populate the\n // inline reply banner. Cleared on every read by the consumer to\n // prevent a stale failure from leaking into the next attempt.\n const lastUpdateErrorRef = useRef<MappedTicketActionError | null>(null)\n const surfaceError = useCallback(\n (err: unknown, action: string): MappedTicketActionError => {\n const mapped = mapTicketActionError(err)\n lastUpdateErrorRef.current = mapped\n if (mapped.supportSystemDown) onSupportSystemDown()\n toast({\n title: `Could not ${action}`,\n description: mapped.message,\n variant: 'destructive',\n })\n return mapped\n },\n [toast, onSupportSystemDown],\n )\n\n const submitTicket = useCallback(\n async (input: SubmitTicketInput): Promise<boolean> => {\n // Synchronous ref guard — closes the same-tick double-click race\n // that the state-only guard couldn't (setIsSubmittingForm is async).\n if (formInFlightRef.current) return false\n formInFlightRef.current = true\n setIsSubmittingForm(true)\n const placeholderId = `temp-${cryptoRandomId()}`\n const placeholder: OptimisticTicket = {\n id: placeholderId,\n external_id: 'Pending sync…',\n subject: input.subject.trim(),\n preview: input.content.trim().slice(0, 400),\n body: input.content.trim(),\n status: 'OPEN',\n pipeline_stage_label: 'New',\n clickup_task_id: null,\n clickup: null,\n priority: null,\n customer_emails: [],\n customer_company: null,\n // Optimistic placeholder has no resolved HubSpot contact yet\n // — the real ticket row replaces this within a couple of\n // seconds via the mirror refetch. Drawer uses live chat\n // identity for own-replies during this window anyway.\n customer_name: null,\n // No assignee until the real ticket lands. Drawer renders\n // \"Unassigned\" for this brief window.\n assigned_to: null,\n assignedOwner: null,\n hubspot_updated_at: new Date().toISOString(),\n _optimistic: true,\n }\n prependOptimistic(placeholder)\n try {\n return await enqueue(async () => {\n const result = await executeTicketAction('create_ticket', {\n subject: input.subject.trim(),\n content: input.content.trim(),\n ...(input.attachments?.length ? { attachments: input.attachments } : {}),\n })\n if (result.mirror_synced === false) {\n toast(TOAST_COPY.open_mirror_pending)\n watchMirrorSync(placeholderId, result.ticket_id)\n } else {\n toast(TOAST_COPY.open_success)\n // Invalidate FIRST so the refetch lands before the\n // placeholder is removed — prevents a one-tick flash of\n // EmptyState when the prior cache was empty.\n await queryClient.invalidateQueries({ queryKey: ['tickets'] })\n removeOptimistic(placeholderId)\n }\n return true\n })\n } catch (err) {\n removeOptimistic(placeholderId)\n surfaceError(err, 'open ticket')\n return false\n } finally {\n formInFlightRef.current = false\n setIsSubmittingForm(false)\n }\n },\n [\n enqueue,\n executeTicketAction,\n prependOptimistic,\n removeOptimistic,\n queryClient,\n toast,\n watchMirrorSync,\n surfaceError,\n ],\n )\n\n const updateTicket = useCallback(\n async (\n ticket: TicketRef,\n serverArgs: Omit<UpdateTicketArgs, 'ticket_id'>,\n successCopy: { title: string; description?: string },\n action: string,\n ): Promise<boolean> => {\n // Mutex keyed on the LOCAL mirror id (stable across the React tree\n // + matches the cache row's `id` for optimistic removal). Server\n // arg uses `external_id` — HubSpot's only-numeric ticket id.\n if (busyRowsRef.current.has(ticket.id)) return false\n setRowBusy(ticket.id, true)\n try {\n return await enqueue(async () => {\n await executeTicketAction('update_ticket', {\n ...serverArgs,\n ticket_id: ticket.external_id,\n } as unknown as Record<string, unknown>)\n toast(successCopy)\n\n // OPTIMISTIC in-place row update on the tickets cache.\n //\n // Previously this code called\n // `queryClient.invalidateQueries({ queryKey: ['tickets'] })`\n // which forced a full refetch. When the user is on a\n // filtered view (e.g. ?status=open) and CLOSES a ticket from\n // its drawer, the refetched list excludes the now-closed\n // row, the parent `<HelpCenterCard>` for that row unmounts,\n // and the inline drawer dies with it — user-facing bug\n // \"close button refreshes the whole page and dismisses the\n // ticket I was working on\" (reported 2026-05-29).\n //\n // The mutation already knows what changed (status, content\n // addendum, attachments) — apply those fields in place\n // across every `['tickets']` cache slot. The row stays in\n // the list with the new badge; React doesn't reconcile away\n // the card; the drawer stays mounted and the user can\n // continue working.\n //\n // Filter-mismatch trade-off: a row that no longer matches a\n // slot's filter (e.g. CLOSED row in ?status=open cache)\n // stays visually until next manual refetch (filter change,\n // page nav, manual reload). Acceptable — the user opted into\n // the action; carrying their drawer through it is more\n // important than instantly hiding the row.\n const statusUpdate =\n (serverArgs as { status?: 'OPEN' | 'CLOSED' }).status ?? null\n if (statusUpdate) {\n // The `useTicketsList` query (in `use-tickets-list.ts`)\n // returns `FindTicketResponse` — an OBJECT shape\n // `{ tickets: TicketData[], count, page, totalPages, ... }` —\n // NOT a bare `TicketData[]`. The previous version of this\n // callback assumed an array and crashed at runtime with\n // `t.map is not a function` on every close/reopen\n // (reported 2026-05-29 in prod). Project the nested\n // tickets array, map, and reassemble the wrapper.\n queryClient.setQueriesData<TicketsCacheSlot | undefined>(\n { queryKey: ['tickets'] },\n (prev) => {\n if (!prev || !Array.isArray(prev.tickets)) return prev\n let mutated = false\n const nextTickets = prev.tickets.map((t) => {\n if (t.id !== ticket.id || t.status === statusUpdate) return t\n mutated = true\n return { ...t, status: statusUpdate }\n })\n return mutated ? { ...prev, tickets: nextTickets } : prev\n },\n )\n }\n\n // Engagements ALWAYS need to refetch — the addendum / new\n // attachment / status-change-note must land in the timeline.\n // Scoped to the engagements query only; doesn't touch the\n // list cache.\n await queryClient.invalidateQueries({ queryKey: ['ticket-engagements'] })\n return true\n })\n } catch (err) {\n const mapped = surfaceError(err, action)\n if (mapped.removeRowFromCache) {\n removeTicketFromCache(ticket.id)\n }\n return false\n } finally {\n setRowBusy(ticket.id, false)\n }\n },\n // `busyRowsRef` is read via .current — needs no dep entry. `busyRows`\n // state isn't read inside this callback (only by `isRowBusy` selector\n // outside), so listing it would churn the closure on every flag flip\n // and cascade-recreate addNote/closeTicket/etc.\n [setRowBusy, enqueue, executeTicketAction, queryClient, toast, surfaceError, removeTicketFromCache],\n )\n\n const sendMessage = useCallback(\n async (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => {\n const trimmed = text.trim()\n const hasText = trimmed.length > 0\n const hasFiles = attachments.length > 0\n if (!hasText && !hasFiles) return false\n // Clear any stale mapped error from a prior non-sendMessage action\n // (closeTicket / reopenTicket) so the post-call read only picks up\n // an error THIS sendMessage produced. Without this clear, a prior\n // close-failure's mapped error could leak into the banner via the\n // post-call `lastUpdateErrorRef.current` read.\n lastUpdateErrorRef.current = null\n const ok = await updateTicket(\n ticket,\n {\n ...(hasText ? { content_addendum: trimmed } : {}),\n ...(hasFiles ? { attachments } : {}),\n },\n TOAST_COPY.comment_success,\n 'send message',\n )\n // Banner-state coupling: SUCCESS clears any stale failure banner\n // for this ticket; FAILURE populates the banner ONLY for the\n // reply-specific code subset (HUBSPOT_5XX / 400 / 404 / UNKNOWN).\n // Other codes (TICKET_NOT_FOUND, HUBSPOT_DISCONNECTED, RATE_LIMITED)\n // are full-row / full-system signals already covered by the\n // existing toast + supportSystemDown handling — surfacing them in\n // the inline banner too would be redundant.\n if (ok) {\n clearReplyError(ticket.external_id)\n } else {\n // Line 466's `.current = null` narrows the property to literal\n // `null`. `tsc -p tsconfig.declarations.json` (declarations\n // build, distinct from the `tsc --noEmit` pre-step) doesn't\n // widen that narrowing across the `await updateTicket(...)`,\n // so the read here is typed `never`. The runtime type IS\n // `MappedTicketActionError | null` per the useRef declaration;\n // the assertion just tells TS to honor it instead of the stale\n // narrowing.\n const mapped = lastUpdateErrorRef.current as MappedTicketActionError | null\n if (mapped && REPLY_BANNER_CODES.has(mapped.code)) {\n setReplyError(ticket.external_id, mapped)\n }\n lastUpdateErrorRef.current = null\n }\n return ok\n },\n [updateTicket, clearReplyError, setReplyError],\n )\n\n const closeTicket = useCallback(\n (ticket: TicketRef, resolution?: string) =>\n updateTicket(\n ticket,\n {\n status: 'CLOSED',\n ...(resolution?.trim() ? { resolution: resolution.trim() } : {}),\n },\n TOAST_COPY.close_success,\n 'close ticket',\n ),\n [updateTicket],\n )\n\n const reopenTicket = useCallback(\n (ticket: TicketRef) =>\n updateTicket(ticket, { status: 'OPEN' }, TOAST_COPY.reopen_success, 'reopen ticket'),\n [updateTicket],\n )\n\n return useMemo<UseTicketActionsReturn>(\n () => ({\n submitTicket,\n sendMessage,\n closeTicket,\n reopenTicket,\n isSubmittingForm,\n isRowBusy,\n replyErrorFor,\n clearReplyError,\n }),\n [\n submitTicket,\n sendMessage,\n closeTicket,\n reopenTicket,\n isSubmittingForm,\n isRowBusy,\n replyErrorFor,\n clearReplyError,\n ],\n )\n}\n\n/** Exported so unit tests can construct an instance to exercise the\n * per-code branches of `mapTicketActionError`. Not part of the public\n * surface — kept out of `tickets/index.ts`. */\nexport class TicketActionFailure extends Error {\n code: TicketActionErrorCode\n response?: Response\n constructor(code: TicketActionErrorCode, message: string, response?: Response) {\n super(message)\n this.code = code\n this.response = response\n }\n}\n\n/**\n * Translate a server error envelope into user-facing copy. Exported so\n * a future chat refactor can adopt the same translation table.\n */\nexport function mapTicketActionError(err: unknown): MappedTicketActionError {\n if (err instanceof TicketActionFailure) {\n switch (err.code) {\n case 'PROPOSAL_NOT_CLAIMABLE':\n return {\n code: err.code,\n message: 'This action was already processed.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'TICKET_NOT_FOUND':\n return {\n code: err.code,\n message: 'This ticket is no longer available.',\n supportSystemDown: false,\n removeRowFromCache: true,\n }\n case 'TICKET_OWNERSHIP_DENIED':\n return {\n code: err.code,\n message: 'You can only act on tickets you opened.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'HUBSPOT_DISCONNECTED':\n return {\n code: err.code,\n message: 'Support system temporarily unavailable.',\n supportSystemDown: true,\n removeRowFromCache: false,\n }\n case 'RATE_LIMITED': {\n const retryAfterRaw = err.response?.headers.get('Retry-After')\n const retryAfterSeconds = retryAfterRaw ? parseInt(retryAfterRaw, 10) : undefined\n return {\n code: err.code,\n message: retryAfterSeconds\n ? `Too many actions. Try again in ${retryAfterSeconds}s.`\n : 'Too many actions. Try again shortly.',\n supportSystemDown: false,\n removeRowFromCache: false,\n ...(retryAfterSeconds ? { retryAfterSeconds } : {}),\n }\n }\n case 'INVALID_TOOL_ARGS':\n return {\n code: err.code,\n message: 'Your input was rejected. Please review and try again.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'HUBSPOT_5XX':\n return {\n code: err.code,\n message:\n \"We couldn't reach the support system. Your reply wasn't sent — please retry in a moment.\",\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'HUBSPOT_400_VALIDATION':\n return {\n code: err.code,\n message:\n 'Your reply was rejected. Please rephrase or remove unsupported content and try again.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'HUBSPOT_404_THREAD':\n return {\n code: err.code,\n message:\n 'This conversation is no longer accepting replies. Open a new ticket to continue.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'HUBSPOT_REPLY_UNKNOWN':\n return {\n code: err.code,\n message:\n \"Your reply didn't go through. Please retry.\",\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n default:\n return {\n code: 'UNKNOWN',\n message: err.message || 'Something went wrong. Please try again.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n }\n }\n return {\n code: 'UNKNOWN',\n message: err instanceof Error ? err.message : 'Something went wrong. Please try again.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n}\n\n/** Small id generator that doesn't require pulling in nanoid as a new\n * dep. Sufficient for client-only optimistic ids. */\nfunction cryptoRandomId(): string {\n if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {\n return crypto.randomUUID()\n }\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`\n}\n\n/** True iff any ['tickets', …] cache slot contains a ticket whose\n * HubSpot id (external_id) matches the target. Used by the mirror-sync\n * watcher to detect \"the real row just arrived\" and drop the placeholder\n * early instead of waiting for the 30s timeout. */\nfunction cacheContainsTicket(\n queryClient: ReturnType<typeof useQueryClient>,\n expectedTicketId: string,\n): boolean {\n // Cache slot is `TicketsCacheSlot` (`{ tickets, count, … }`), NOT a\n // bare `TicketData[]`. The previous code's `Array.isArray(data)` guard\n // silently fell through to `return false` on real responses — the\n // post-create watcher therefore NEVER detected the real row arriving\n // early and always waited the full timeout. Project the nested array.\n const entries = queryClient.getQueriesData<TicketsCacheSlot | undefined>({\n queryKey: ['tickets'],\n })\n for (const [, data] of entries) {\n if (\n data &&\n Array.isArray(data.tickets) &&\n data.tickets.some((t) => t.external_id === expectedTicketId)\n ) {\n return true\n }\n }\n return false\n}\n\n/** Resolve the canonical error code from the server's body + HTTP status.\n * Body code wins when present; status-derived code is the fallback so a\n * bare 429/412 (no body code) still maps cleanly through the user-facing\n * branches. */\nfunction resolveErrorCode(\n bodyCode: string | undefined,\n status: number,\n): TicketActionErrorCode {\n if (bodyCode) return bodyCode as TicketActionErrorCode\n if (status === 429) return 'RATE_LIMITED'\n if (status === 412) return 'HUBSPOT_DISCONNECTED'\n return 'UNKNOWN'\n}\n\n// Re-export so callers can narrow the type when needed.\nexport type { AnyTicket, OptimisticTicket }\n","'use client'\n\n/**\n * `<HelpCenterList />` — the full Help Center surface (the openframe\n * `/tickets` page mounts this directly; third-party embedders can mount\n * it inside their own `<PageShell>` to get the same UX).\n *\n * Mounts `<DevSectionPage sectionKey=\"tickets\">` so the page chrome\n * (hero + search + status filter + back button) is identical to\n * `/roadmap`, `/bug-fixes-and-enhancements`, `/releases`. The\n * \"Open a new ticket\" form lives in the new `preControls` slot above\n * the search/filter row.\n *\n * State ownership:\n * - URL params (`?search=`, `?status=`, `?page=`) → `DevSectionView`\n * writes search + status, `<UnifiedPagination>` writes page.\n * `useTicketsList({ search, status, page })` reads them.\n * - Optimistic placeholders → kept LOCAL (not in TanStack cache) so a\n * refetch (URL filter change) doesn't blow them away mid-flight.\n * - Expanded row → single id (only one drawer open at a time).\n * - Mutations → `useTicketActions` with prepend/remove callbacks\n * wired to the local placeholder state.\n *\n * Anon visitors get the same `DevSectionPage` chrome (hero + back\n * button) but with a single \"Sign in\" `<EmptyState>` body — no form,\n * no list, no fetch.\n */\n\nimport { useCallback, useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { useSearchParams, useRouter, usePathname } from '../../embed-shims'\nimport { Button } from '../ui'\nimport { EmptyState } from '../empty-state'\nimport { DevSectionPage } from '../shared/dev-section'\nimport { DevCardRowSkeletonList } from '../shared/dev-section/dev-card-row'\nimport { UnifiedPagination } from '../unified-pagination'\nimport { useChatIdentity } from '../chat/hooks/use-chat-identity'\nimport { toast as defaultToast } from '../../hooks/use-toast'\nimport { useTicketsList } from './hooks/use-tickets-list'\nimport { useTicketActions } from './hooks/use-ticket-actions'\nimport { HelpCenterCard } from './help-center-card'\nimport { HelpCenterCreateForm, HelpCenterCreateFormSkeleton } from './help-center-create-form'\nimport type { AnyTicket, OptimisticTicket, TicketsCacheSlot } from './types'\nimport { isOptimistic, TICKET_LIVE_POLL_MS } from './types'\n\nexport interface HelpCenterListProps {\n /** Toast override (test-friendly). Defaults to the lib's shared\n * toast singleton. */\n toast?: typeof defaultToast\n}\n\nexport function HelpCenterList({ toast = defaultToast }: HelpCenterListProps = {}) {\n const identity = useChatIdentity()\n const searchParams = useSearchParams()\n const router = useRouter()\n const pathname = usePathname()\n\n const search = searchParams.get('search') || ''\n const status = searchParams.get('status') || 'all'\n // Deep-link: `?ticket=<external_id>` auto-opens that ticket's drawer on load.\n // Same GET-param plumbing as `?search=` — read here, drilled to the authed\n // child which expands the matching row once it's in the fetched list.\n const ticketParam = searchParams.get('ticket') || ''\n // 1-based page from the URL. `<UnifiedPagination>` writes `?page=N`\n // on navigation; we read it here and re-fetch on change. Invalid\n // values fall back to page 1.\n const rawPage = Number(searchParams.get('page'))\n const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1\n\n // Identity gate FIRST — anon visitors skip every fetch + hook below.\n // `useChatIdentity` has a brief `isLoading` window on first render\n // before the identity resolves; we render the skeleton until it lands\n // to avoid flashing the sign-in EmptyState for authed users. The\n // skeleton mirrors the AUTHED layout — form placeholder above the\n // search/filter row, list-rows skeleton below — so the chrome\n // doesn't shift vertically when identity resolves and the real form\n // mounts in the `preControls` slot.\n if (identity.isLoading) {\n return (\n <DevSectionPage sectionKey=\"tickets\" preControls={<HelpCenterCreateFormSkeleton />}>\n <DevCardRowSkeletonList />\n </DevSectionPage>\n )\n }\n if (identity.authTier === 'anon' || !identity.user?.email) {\n return (\n <DevSectionPage sectionKey=\"tickets\">\n <EmptyState\n type=\"generic\"\n title=\"Sign in to manage tickets\"\n description=\"View, open, and follow up on support tickets after signing in.\"\n showCTA={false}\n />\n </DevSectionPage>\n )\n }\n\n // Identity is loaded + has an email (gated above). Resolve the\n // authoritative session display name + email HERE so the create-form\n // child doesn't have to call `useChatIdentity` itself — that hook is\n // a plain `useState`+`useEffect` (no shared cache), so a second call\n // in the child would race the first render and lock RHF's\n // `defaultValues.email` to '' for the form's lifetime.\n const sessionName =\n [identity.user?.firstName, identity.user?.lastName].filter(Boolean).join(' ').trim() ||\n identity.user?.email?.split('@')[0] ||\n 'Customer'\n const sessionEmail = identity.user!.email!\n\n return (\n <HelpCenterListAuthed\n search={search}\n status={status}\n page={page}\n ticketParam={ticketParam}\n searchParams={searchParams}\n router={router}\n pathname={pathname}\n toast={toast}\n sessionName={sessionName}\n sessionEmail={sessionEmail}\n />\n )\n}\n\ninterface AuthedProps {\n search: string\n status: string\n page: number\n /** `?ticket=<external_id>` deep-link target — auto-opens that drawer. */\n ticketParam: string\n searchParams: ReturnType<typeof useSearchParams>\n router: ReturnType<typeof useRouter>\n pathname: string\n toast: typeof defaultToast\n sessionName: string\n sessionEmail: string\n}\n\nfunction HelpCenterListAuthed({\n search,\n status,\n page,\n ticketParam,\n searchParams,\n router,\n pathname,\n toast,\n sessionName,\n sessionEmail,\n}: AuthedProps) {\n const queryClient = useQueryClient()\n const [optimisticTickets, setOptimisticTickets] = useState<OptimisticTicket[]>([])\n const [supportSystemDown, setSupportSystemDown] = useState(false)\n\n // SINGLE source of truth for \"which ticket is open\" = the `?ticket=<external_id>`\n // URL param (same model as `?search=` / `?status=`). Click-to-open and the\n // deep-link path are now ONE code path: a click writes the param, the drawer's\n // open state is DERIVED from the param. No separate `expandedTicketId` state,\n // no auto-open effect, no re-open guard — opening, closing, deep-linking, and\n // sharing a URL all flow through the same param.\n const setOpenTicket = useCallback(\n (externalId: string | null) => {\n const params = new URLSearchParams(searchParams.toString())\n if (externalId) params.set('ticket', externalId)\n else params.delete('ticket')\n const qs = params.toString()\n router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false })\n },\n [searchParams, router, pathname],\n )\n\n const { tickets, isLoading, isFetching, error, refetch, totalPages } = useTicketsList({\n // `sessionEmail` is drilled in from the parent — see the same\n // pattern + race-cause rationale documented in\n // `HelpCenterCreateForm.sessionName/sessionEmail`. Calling\n // `useChatIdentity` inside `useTicketsList` would race the\n // parent's already-resolved identity and produce an empty-state\n // flash on first render.\n customerEmail: sessionEmail,\n search,\n status,\n page,\n // Live status: while a drawer is open, poll so an out-of-band HubSpot\n // status change (e.g. agent closes the ticket) flips the badge +\n // open/reopen affordance within one interval. Idle (no drawer) → no poll.\n // `ticketParam` (the open ticket's external_id) is the open signal.\n refetchInterval: ticketParam ? TICKET_LIVE_POLL_MS : false,\n })\n\n // Open state DERIVED from the URL param. `?ticket=` carries the user-facing\n // `external_id`; map it to the internal row id the card matches on. Resolves\n // to null until the ticket lands in the fetched list (deep-link cold load) and\n // auto-collapses if the open ticket disappears (e.g. TICKET_NOT_FOUND removal).\n const expandedTicketId =\n (ticketParam && tickets.find((t) => t.external_id === ticketParam)?.id) || null\n\n // Optimistic cache management. Kept LOCAL (not in the query cache) so\n // a refetch (e.g. URL-filter change) doesn't blow away pending\n // placeholders. Merged view is `[...optimistic, ...server]` so\n // placeholders sit at the top until they're explicitly removed.\n const prependOptimistic = useCallback((placeholder: OptimisticTicket) => {\n setOptimisticTickets((prev) => [placeholder, ...prev])\n }, [])\n const removeOptimistic = useCallback((placeholderId: string) => {\n setOptimisticTickets((prev) => prev.filter((t) => t.id !== placeholderId))\n // No drawer-collapse needed: optimistic placeholders have no `external_id`,\n // so they can never be the URL-derived open ticket.\n }, [])\n const removeTicketFromCache = useCallback(\n (ticketId: string) => {\n // Every cache slot under the ['tickets'] prefix — the queryKey\n // includes search + status + page + pageSize segments so a bare\n // write would miss most slots.\n //\n // Cache slot is `TicketsCacheSlot` (`{ tickets, count, … }`), NOT\n // a bare `TicketData[]`. The previous version called `.filter()`\n // directly on the object — silently crashing only on the rare\n // TICKET_NOT_FOUND path; the prod regression that landed\n // 2026-05-29 surfaced the same shape mismatch in the\n // close/reopen optimistic-update path. Project, filter, reassemble.\n queryClient.setQueriesData<TicketsCacheSlot | undefined>(\n { queryKey: ['tickets'] },\n (prev) => {\n if (!prev || !Array.isArray(prev.tickets)) return prev\n const nextTickets = prev.tickets.filter((t) => t.id !== ticketId)\n if (nextTickets.length === prev.tickets.length) return prev\n return { ...prev, tickets: nextTickets }\n },\n )\n // The drawer auto-collapses on its own: once the ticket leaves the list,\n // the URL-derived `expandedTicketId` finds no match → null. No state to clear.\n },\n [queryClient],\n )\n\n const actions = useTicketActions({\n prependOptimistic,\n removeOptimistic,\n removeTicketFromCache,\n toast,\n onSupportSystemDown: () => setSupportSystemDown(true),\n })\n\n // Toggle = write the URL param (open) or clear it (close). The clicked card's\n // internal id maps to its `external_id` for the param; optimistic rows (no\n // external_id) aren't expandable so they short-circuit. This is the ONE open\n // path — a click, a deep link, and a shared URL are indistinguishable.\n const toggleRow = useCallback(\n (id: string) => {\n const t = tickets.find((x) => x.id === id)\n if (!t?.external_id) return\n setOpenTicket(t.external_id === ticketParam ? null : t.external_id)\n },\n [tickets, ticketParam, setOpenTicket],\n )\n\n const merged: AnyTicket[] = [...optimisticTickets, ...tickets]\n const hasActiveFilters = search !== '' || (status !== '' && status !== 'all')\n const hasResults = merged.length > 0\n\n // Form is the canonical lib `<ContactForm>` (NOT a new ticket-specific\n // form) — we hide every contact-only field, supply the customer's\n // identity from `useChatIdentity` so Zod's name+email validators\n // pass, slot a Subject `<Input>` into the new `extraTopField`\n // position, and forward submission through `actions.submitTicket`.\n // Same primitives, same wrapper styling, same visual treatment as\n // every other primary form in the app.\n const form = (\n <HelpCenterCreateForm\n actions={actions}\n sessionName={sessionName}\n sessionEmail={sessionEmail}\n supportSystemDown={supportSystemDown}\n />\n )\n\n const body = (\n <div className=\"w-full flex flex-col gap-[40px]\">\n {error && (\n <div className=\"bg-ods-card border border-ods-border rounded-[6px] p-[40px] text-center w-full flex flex-col items-center gap-3\">\n <p className=\"text-ods-error text-base\">\n Couldn&rsquo;t load your tickets. {error.message}\n </p>\n <Button type=\"button\" variant=\"accent\" onClick={() => refetch()}>\n Retry\n </Button>\n </div>\n )}\n\n {!error && (\n <div className=\"w-full\">\n {isLoading ? (\n <DevCardRowSkeletonList />\n ) : !hasResults && isFetching ? (\n // Bridge state — background refetch in flight and the\n // optimistic placeholder was just removed by the mutation\n // callback. Without this branch \"No tickets yet\" would flash\n // for ~50ms between `removeOptimistic` and the server\n // response landing.\n <DevCardRowSkeletonList rows={1} />\n ) : !hasResults ? (\n hasActiveFilters ? (\n <EmptyState\n type=\"search\"\n title=\"No tickets found\"\n description=\"No tickets match your current filters. Try clearing them or broadening your search.\"\n showCTA\n ctaText=\"Reset filters\"\n onCtaClick={() => {\n const params = new URLSearchParams(searchParams.toString())\n params.delete('search')\n params.delete('status')\n router.replace(`${pathname}?${params.toString()}`, { scroll: false })\n }}\n />\n ) : (\n <EmptyState\n type=\"generic\"\n title=\"No tickets yet\"\n description=\"Open one above to start the conversation with the support team.\"\n showCTA={false}\n />\n )\n ) : (\n // `overflow-clip` (NOT `overflow-hidden`) — both visually\n // clip the rounded corners, but `hidden` makes the element\n // a \"scroll container\" per CSSOM spec, which causes\n // `scrollIntoView` calls inside (`<HelpCenterCard>` click\n // handlers) to try scrolling THIS div (can't, overflow\n // hidden) instead of bubbling up to the window. `clip`\n // keeps the visual clip but NOT the scroll-container\n // status, so click-to-scroll actually moves the page.\n <div className=\"bg-ods-card border border-ods-border rounded-[6px] overflow-clip w-full\">\n {merged.map((ticket) => (\n <HelpCenterCard\n key={ticket.id}\n ticket={ticket}\n expanded={expandedTicketId === ticket.id}\n onToggle={toggleRow}\n busy={isOptimistic(ticket) ? false : actions.isRowBusy(ticket.id)}\n supportSystemDown={supportSystemDown}\n onSendMessage={actions.sendMessage}\n onClose={actions.closeTicket}\n onReopen={actions.reopenTicket}\n onActionCollapsed={() => setOpenTicket(null)}\n replyError={actions.replyErrorFor(ticket.external_id)}\n onClearReplyError={() => actions.clearReplyError(ticket.external_id)}\n />\n ))}\n </div>\n )}\n </div>\n )}\n\n {/* Pagination — `<UnifiedPagination>` owns the URL `?page=N`\n rewrite on click; we just feed it the server-echoed current\n page + totalPages. Hidden when there's at most one page so\n the list doesn't reserve vertical space when it isn't\n actionable. */}\n {!error && totalPages > 1 && (\n <UnifiedPagination currentPage={page} totalPages={totalPages} />\n )}\n </div>\n )\n\n return (\n <DevSectionPage sectionKey=\"tickets\" preControls={form}>\n {body}\n </DevSectionPage>\n )\n}\n","'use client'\n\n/**\n * `<HelpCenterCard />` — single ticket row inside the Help Center list.\n *\n * Visual chrome is 1:1 with the delivery list (`<DeliveryTable>`) via\n * the shared `<DevCardRowContent>` primitive. The differentiator: the\n * entire summary row is a `<button>` that toggles an expanded drawer\n * beneath it (`<TicketDetailDrawer />` — same composer + timeline +\n * close/reopen affordances as the embedded `<TicketCenter />`).\n *\n * Click target: the summary row only. Clicks inside the expanded\n * drawer (composer textarea, attachment chips, close-dialog button)\n * don't propagate up to the row's toggle handler because the drawer\n * is a SIBLING of the toggle button, not nested inside it.\n */\n\nimport { useCallback, useEffect, useRef } from 'react'\nimport { StatusBadge, type StatusBadgeProps } from '../ui'\nimport { formatRelativeTime } from '../../utils/date-utils'\nimport { scrollElementIntoView } from '../../utils/scroll-into-view'\nimport { getStatusColorScheme } from '../chat/utils/agent-status-message'\nimport { DevCardRowContent } from '../shared/dev-section/dev-card-row'\nimport {\n TicketDetailDrawer,\n type TicketDetailDrawerProps,\n} from './ticket-detail-drawer'\nimport type { AnyTicket } from './types'\nimport { isOptimistic } from './types'\n\n/** Sticky page-chrome offset, applied two ways from this ONE constant:\n *\n * 1. As `scrollMarginTop` inline style on the wrapper — so any\n * anchor-driven or `scrollIntoView()`-driven scroll (browser\n * `#hash` navigation, Tab-focus into the card) lands BELOW the\n * sticky header.\n * 2. As `headerOffset` passed to `scrollElementIntoView(...)` — for\n * the click-to-expand `window.scrollTo` path, which pre-computes\n * its target pixel and ignores CSS `scroll-margin-top`.\n *\n * Single source of truth: change 96 here and BOTH paths follow. The\n * previous code combined a `scroll-mt-24` (=96px) Tailwind class\n * with this constant — two declarations, one comment binding them,\n * drift hazard. Now there's nothing to keep in sync.\n */\nconst STICKY_HEADER_OFFSET_PX = 96\n\nexport interface HelpCenterCardProps {\n ticket: AnyTicket\n expanded: boolean\n onToggle: (id: string) => void\n busy: boolean\n supportSystemDown: boolean\n onSendMessage: TicketDetailDrawerProps['onSendMessage']\n onClose: TicketDetailDrawerProps['onClose']\n onReopen: TicketDetailDrawerProps['onReopen']\n onActionCollapsed: () => void\n /** Persisted reply-failure banner — forwarded to the drawer. Parent\n * (`HelpCenterList`) reads via `actions.replyErrorFor(external_id)`. */\n replyError?: TicketDetailDrawerProps['replyError']\n onClearReplyError?: TicketDetailDrawerProps['onClearReplyError']\n}\n\nexport function HelpCenterCard({\n ticket,\n expanded,\n onToggle,\n busy,\n supportSystemDown,\n onSendMessage,\n onClose,\n onReopen,\n onActionCollapsed,\n replyError,\n onClearReplyError,\n}: HelpCenterCardProps) {\n const optimistic = isOptimistic(ticket)\n const rawStatus = (ticket.status ?? 'OPEN').toUpperCase()\n const priority = (ticket.priority ?? '').toUpperCase()\n\n const relativeUpdated = ticket.hubspot_updated_at\n ? formatRelativeTime(ticket.hubspot_updated_at)\n : 'recently'\n\n // Use `||` not `??` so an EMPTY-STRING subject (legacy rows, partial\n // server data) falls through to the placeholder instead of rendering\n // a blank h3.\n const title = (ticket.subject || '').trim() || '(untitled)'\n const subtitle = `UPDATED ${relativeUpdated}, #${ticket.external_id || '—'}${\n ticket.pipeline_stage_label ? `, ${ticket.pipeline_stage_label}` : ''\n }`\n const description = ticket.preview ?? ticket.body ?? ''\n\n // Optimistic placeholders show as a row but aren't expandable — the\n // real external_id hasn't landed so the drawer's `useTicketEngagements`\n // would have nothing to fetch, and action targets would be undefined.\n const isExpandable = !optimistic\n const isExpanded = expanded && isExpandable\n\n const rowRef = useRef<HTMLDivElement | null>(null)\n // Click only toggles — the scroll-to-top is deferred to the effect below.\n const handleClick = useCallback(() => {\n onToggle(ticket.id)\n }, [onToggle, ticket.id])\n\n // Smooth-scroll the row to the top once the drawer has expanded — in an\n // effect keyed on `isExpanded` (NOT the click handler, which runs before\n // React commits the drawer, when the page isn't yet tall enough to scroll).\n //\n // The cancellation-proof motion lives in the shared `scrollElementIntoView`\n // helper (self-driven rAF tween, instant per-frame writes, target recomputed\n // each frame). It is immune to the browser SCROLL ANCHORING that cancelled the\n // old native `window.scrollTo({behavior:'smooth'})` on every open after the\n // first — the bug where smooth \"only worked once\" because anchoring is\n // suppressed at scrollY=0 (first open) but aborts the native smooth scroll\n // from any non-zero offset (every later open). See that util for the full\n // mechanics. One leading rAF so the expanded drawer has committed its height\n // before the first measurement; the tween then tracks the row to its resting\n // position as the page finishes growing. Cleanup cancels on collapse/unmount.\n useEffect(() => {\n if (!isExpanded) return\n const raf = requestAnimationFrame(() => {\n scrollElementIntoView(rowRef.current, {\n headerOffset: STICKY_HEADER_OFFSET_PX,\n })\n })\n return () => cancelAnimationFrame(raf)\n }, [isExpanded])\n\n const rightBadges = (\n <>\n <StatusBadge\n text={rawStatus}\n colorScheme={getStatusColorScheme(rawStatus)}\n variant=\"card\"\n className=\"border border-ods-border\"\n />\n {priority && (\n <StatusBadge\n text={priority}\n colorScheme={mapPriorityScheme(priority)}\n variant=\"card\"\n className=\"border border-ods-border\"\n />\n )}\n </>\n )\n\n return (\n <div\n ref={rowRef}\n style={{ scrollMarginTop: STICKY_HEADER_OFFSET_PX }}\n className={`border-b border-ods-border last:border-b-0 ${optimistic ? 'opacity-60' : ''}`}\n aria-busy={optimistic || undefined}\n >\n <button\n type=\"button\"\n onClick={isExpandable ? handleClick : undefined}\n disabled={!isExpandable}\n aria-expanded={isExpandable ? isExpanded : undefined}\n aria-controls={isExpanded ? `help-center-drawer-${ticket.id}` : undefined}\n className=\"w-full text-left p-[12px] md:p-[16px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-inset disabled:cursor-default\"\n >\n <DevCardRowContent\n title={title}\n subtitle={subtitle}\n description={description}\n emptyDescription=\"No description provided\"\n rightBadges={rightBadges}\n />\n </button>\n\n {isExpanded && (\n <div id={`help-center-drawer-${ticket.id}`}>\n <TicketDetailDrawer\n ticket={ticket}\n busy={busy}\n supportSystemDown={supportSystemDown}\n onSendMessage={onSendMessage}\n onClose={onClose}\n onReopen={onReopen}\n onActionCollapsed={onActionCollapsed}\n replyError={replyError}\n onClearReplyError={onClearReplyError}\n />\n </div>\n )}\n </div>\n )\n}\n\n/** Ticket priority → StatusBadge colorScheme. HIGH / URGENT → red,\n * MEDIUM → yellow, LOW / unknown → default-muted. Kept local because\n * the central `getStatusColorScheme` is keyed on workflow status, not\n * severity, and conflating them would mis-render an \"OPEN\" status as\n * a low-priority badge or vice-versa. */\nfunction mapPriorityScheme(priority: string): NonNullable<StatusBadgeProps['colorScheme']> {\n if (priority === 'HIGH' || priority === 'URGENT') return 'error'\n if (priority === 'MEDIUM') return 'warning'\n return 'default'\n}\n","'use client'\n\n/**\n * `<HelpCenterCreateForm />` — thin wrapper around the canonical\n * `<ContactForm />`. Does NOT reimplement form layout / validation /\n * primitives — it just preconfigures `<ContactForm />` for ticket\n * creation:\n *\n * - Hides every contact-only field (name + email from session,\n * companySize / referralSource / helpCategory irrelevant).\n * - Slots a Subject `<Input>` into `extraTopField` (Subject isn't\n * part of `ContactSchema`; the wrapper manages it locally and\n * reads the value back in `onCustomSubmit`).\n * - Pre-fills the hidden name / email / helpCategory from\n * `useChatIdentity` so Zod's required-field validators pass even\n * though those inputs aren't rendered.\n * - Wires `onCustomSubmit` to `actions.submitTicket(...)` so the\n * ticket lands through the same optimistic-placeholder flow the\n * rest of the Help Center uses.\n *\n * Why a wrapper instead of inlining `<ContactForm />` directly inside\n * `<HelpCenterList />`: the Subject input needs local state + error\n * state + validation that conceptually pairs with the form, not the\n * orchestrator. Keeping that tiny state machine in its own file keeps\n * `<HelpCenterList />` focused on list/state/pagination concerns.\n */\n\nimport { useState, type ChangeEvent } from 'react'\nimport { Input, Label } from '../ui'\nimport { ContactForm } from '../contact'\nimport type { UseTicketActionsReturn } from './hooks/use-ticket-actions'\n\nconst SUBJECT_MAX_CHARS = 200\n\nexport interface HelpCenterCreateFormProps {\n /** The full actions bag from `useTicketActions` — we read\n * `submitTicket` from it. Passing the whole bag (rather than just\n * the one method) keeps the wiring shape consistent with other\n * composition points (e.g. `<HelpCenterCard>` takes individual\n * action callbacks because the drawer needs four of them; the form\n * only needs one but the parent already has the bag in scope). */\n actions: UseTicketActionsReturn\n /** Authoritative session identity, resolved by the parent\n * (`HelpCenterList`) which already gates rendering on\n * `identity.isLoading === false`. Passing these in (instead of\n * calling `useChatIdentity` again here) avoids a subtle race:\n * `useChatIdentity` is a plain `useState`+`useEffect` hook (no\n * shared cache), so a second call inside this child would mount\n * with `user = null` and a stale `sessionEmail = ''`, locking\n * react-hook-form's `defaultValues.email` to an empty string for\n * the lifetime of the form — Zod then rejects the submit silently. */\n sessionName: string\n sessionEmail: string\n /** Disables every input + button when the support backend (HubSpot)\n * is down. Wired from the parent's\n * `useTicketActions.onSupportSystemDown` flag. */\n supportSystemDown?: boolean\n}\n\n/**\n * Loading placeholder that mirrors `<HelpCenterCreateForm>`'s outer\n * dimensions PIXEL-FOR-PIXEL so the page chrome doesn't shift when\n * identity resolves and the real form mounts.\n *\n * Verified against live DOM bounding-rect measurements at the lg\n * breakpoint — every section's top + height matches the real form to\n * the pixel (skeleton wrapper 556px == real wrapper 556px). Each\n * section uses the SAME flex / spacing / padding classes the real\n * form uses, so the gaps propagate identically; only the inner\n * content swaps an `<Input>` / `<Textarea>` / `<Button>` for a\n * same-sized animated `bg-ods-border` bar.\n *\n * wrapper → `p-6 md:p-8 lg:p-10` + border + rounded-3xl\n * heading area → 56px (`mb-6 md:mb-8` container, h-10 inner bar\n * + `mb-3 md:mb-4` = 40 + 16)\n * subject section → 79px (`h-[27px]` label + `mb-1` (4px) + h-12\n * input = 27 + 4 + 48)\n * message section → 127px (`h-[27px]` label + `mb-1` + h-24\n * textarea = 27 + 4 + 96)\n * attachments row → 28px (h-7 add button + helper label)\n * footer → 56px (h-12 button + `pt-2 mt-auto`)\n * between sections → `space-y-4 md:space-y-6` (16/24px)\n *\n * One non-obvious detail: the real `<ContactForm>` renders 4\n * `<input type=\"hidden\">` registrations BEFORE the visible Subject\n * section (for the hidden name/email/helpCategory/message fields).\n * Tailwind's `space-y-*` rule (`:not([hidden]) ~ :not([hidden])`)\n * counts `type=\"hidden\"` inputs as siblings, so Subject gets a 24px\n * top margin. The skeleton mirrors those 4 hidden inputs exactly so\n * the spacing rule fires identically — without them the whole stack\n * shifts up 24px on every page load.\n */\nexport function HelpCenterCreateFormSkeleton() {\n return (\n <div className=\"h-full flex flex-col border border-ods-border rounded-2xl md:rounded-3xl p-6 md:p-8 lg:p-10\">\n {/* Heading container — mirrors `mb-6 md:mb-8` + h2 with its own\n `mb-3 md:mb-4` and `text-h2` height (32px font, line-height\n ~1.25 → 40px). h-10 bar matches the rendered h2 height. */}\n <div className=\"mb-6 md:mb-8\">\n <div className=\"h-10 w-72 bg-ods-border rounded animate-pulse mb-3 md:mb-4\" />\n </div>\n\n {/* Form body — same `space-y-4 md:space-y-6` gap stack.\n IMPORTANT: the real `<ContactForm>` prepends 4\n `<input type=\"hidden\">` registrations for the hidden\n name/email/helpCategory/message fields (see contact-form.tsx).\n `space-y-*` uses `:not([hidden]) ~ :not([hidden])` — `type=\"hidden\"`\n inputs aren't excluded — so those hidden inputs ARE counted as\n siblings, and the visible Subject section gets a 24px top\n margin. The skeleton mirrors that exact structure with the\n same 4 hidden inputs so the Subject placeholder lands at the\n same Y as the real Subject input. Removing them would shift\n the whole stack up by 24px on every page load. */}\n <div className=\"flex flex-col flex-grow space-y-4 md:space-y-6\">\n <input type=\"hidden\" aria-hidden />\n <input type=\"hidden\" aria-hidden />\n <input type=\"hidden\" aria-hidden />\n <input type=\"hidden\" aria-hidden />\n {/* Subject section — `flex flex-col` matches real form. Label\n bar uses arbitrary `h-[27px]` to match the live Label\n component (18px font * 1.5 line-height = 27px) and `mb-1`\n (4px) which is Tailwind's default `mb-1`. Total section\n height: 27 + 4 + 48 (h-12 input) = 79px, identical to\n real form. */}\n <div className=\"flex flex-col\">\n <div className=\"h-[27px] w-20 bg-ods-border rounded animate-pulse mb-1\" />\n <div className=\"h-12 w-full bg-ods-border rounded animate-pulse\" />\n </div>\n\n {/* Message section — `flex flex-col flex-grow` matches real\n form (textarea fills remaining vertical space inside the\n wrapper). h-24 bar (96px) = textarea's natural rendered\n height when the wrapper has the standard list + footer\n below it. Same `h-[27px]` + `mb-1` Label pattern as Subject:\n 27 + 4 + 96 = 127px, identical to real form. */}\n <div className=\"flex flex-col flex-grow\">\n <div className=\"h-[27px] w-32 bg-ods-border rounded animate-pulse mb-1\" />\n <div className=\"h-24 w-full bg-ods-border rounded animate-pulse flex-grow\" />\n </div>\n\n {/* Attachments row — mirrors the real `flex flex-col gap-2`\n container with chip strip (empty when no files staged) +\n the h-7 add button + helper text. */}\n <div className=\"flex flex-col gap-2\">\n <div className=\"flex items-center gap-2\">\n <div className=\"h-7 w-7 bg-ods-border rounded animate-pulse shrink-0\" />\n <div className=\"h-4 w-40 bg-ods-border rounded animate-pulse\" />\n </div>\n </div>\n\n {/* Footer — same `pt-2 mt-auto` so it sticks to the bottom.\n Button bar is h-12 to match the real `<Button>` height\n (48px). */}\n <div className=\"flex flex-col md:flex-row gap-4 md:gap-6 items-center justify-end w-full pt-2 mt-auto\">\n <div className=\"h-4 w-72 bg-ods-border rounded animate-pulse\" />\n <div className=\"h-12 w-32 bg-ods-border rounded animate-pulse\" />\n </div>\n </div>\n </div>\n )\n}\n\nexport function HelpCenterCreateForm({\n actions,\n sessionName,\n sessionEmail,\n supportSystemDown = false,\n}: HelpCenterCreateFormProps) {\n const [subject, setSubject] = useState('')\n const [subjectError, setSubjectError] = useState<string | null>(null)\n\n // Subject input — slotted into `<ContactForm>`'s new `extraTopField`\n // position. Local state + error so the input behaves like the\n // schema-driven siblings.\n const subjectField = (\n <div className=\"flex flex-col\">\n <Label htmlFor=\"help-center-subject\">\n Subject<span className=\"text-ods-accent\">*</span>\n </Label>\n <Input\n id=\"help-center-subject\"\n type=\"text\"\n value={subject}\n onChange={(e: ChangeEvent<HTMLInputElement>) => {\n setSubject(e.target.value)\n if (subjectError) setSubjectError(null)\n }}\n placeholder=\"Briefly describe what's going on\"\n maxLength={SUBJECT_MAX_CHARS}\n aria-invalid={!!subjectError}\n aria-describedby={subjectError ? 'help-center-subject-error' : undefined}\n disabled={supportSystemDown}\n className=\"bg-ods-card border-ods-border text-ods-text-primary placeholder-ods-text-secondary px-3 h-12\"\n />\n {subjectError && (\n <span\n id=\"help-center-subject-error\"\n className=\"text-ods-error text-xs font-['DM_Sans'] mt-1\"\n >\n {subjectError}\n </span>\n )}\n </div>\n )\n\n return (\n <ContactForm\n title=\"Open a new ticket\"\n footerText=\"The support team typically responds within one business day.\"\n hideFields={['name', 'email', 'companySize', 'referralSource', 'helpCategory']}\n defaultValues={{\n name: sessionName,\n email: sessionEmail,\n helpCategory: 'Support Request',\n }}\n extraTopField={subjectField}\n submitLabel=\"Open ticket\"\n attachmentsEnabled\n onCustomSubmit={async (data, attachments) => {\n const trimmedSubject = subject.trim()\n if (!trimmedSubject) {\n setSubjectError('Subject is required')\n // Throw so `<ContactForm>`'s catch path doesn't `reset()` —\n // user keeps their typed message body and just adds a subject.\n throw new Error('SUBJECT_REQUIRED')\n }\n setSubjectError(null)\n const ok = await actions.submitTicket({\n subject: trimmedSubject,\n content: data.message,\n attachments: attachments.length > 0 ? attachments : undefined,\n })\n if (ok) {\n setSubject('')\n } else {\n // Same as above — keep inputs for retry. The toast is already\n // surfaced by `useTicketActions`.\n throw new Error('TICKET_SUBMIT_FAILED')\n }\n }}\n />\n )\n}\n"]}
1
+ {"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/components/tickets/index.cjs","../../../src/components/tickets/ticket-center.tsx","../../../src/components/tickets/ticket-open-form.tsx","../../../src/components/tickets/types.ts","../../../src/components/tickets/ticket-row.tsx","../../../src/components/collapsible.tsx","../../../src/components/tickets/ticket-detail-drawer.tsx","../../../src/components/tickets/hooks/use-ticket-engagements.ts","../../../src/components/tickets/ticket-linked-delivery-card.tsx","../../../src/components/tickets/ticket-reply-composer.tsx","../../../src/components/tickets/hooks/use-tickets-list.ts","../../../src/components/tickets/hooks/use-ticket-actions.ts","../../../src/components/tickets/help-center-list.tsx","../../../src/components/tickets/help-center-card.tsx","../../../src/components/tickets/help-center-create-form.tsx"],"names":["jsx","jsxs","CollapsibleContent","useState","useCallback","toast"],"mappings":"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","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/components/tickets/index.cjs","sourcesContent":[null,"'use client'\n\n/**\n * `<TicketCenter />` — the customer-facing ticket management surface.\n *\n * Single component the hub mounts at `/tickets` and that third-party\n * apps embed alongside `<EmbeddableChat />`. The lib intentionally does\n * NOT bundle a QueryClientProvider or ChatRuntimeContext.Provider — the\n * embedder mounts both at their app root (same pattern as\n * `<EmbeddableChat />`).\n *\n * Identity gate: if the chat-identity hook reports anon, render ONLY a\n * sign-in EmptyState — no form, no list, no fetch. This keeps the\n * Network tab clean for anon visitors and prevents the form from\n * accepting input that would 401 on submit.\n */\n\nimport { useCallback, useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { Card } from '../ui/card'\nimport { Button } from '../ui/button'\nimport { Skeleton } from '../ui/skeleton'\nimport { EmptyState } from '../empty-state'\nimport { RefreshCw } from 'lucide-react'\nimport { useChatIdentity } from '../chat/hooks/use-chat-identity'\nimport { toast as defaultToast } from '../../hooks/use-toast'\nimport { formatRelativeTime } from '../../utils/date-utils'\nimport { TicketOpenForm } from './ticket-open-form'\nimport { TicketRow } from './ticket-row'\nimport { useTicketsList } from './hooks/use-tickets-list'\nimport { useTicketActions } from './hooks/use-ticket-actions'\nimport type { AnyTicket, OptimisticTicket, TicketData } from './types'\nimport { isOptimistic } from './types'\n\nexport interface TicketCenterProps {\n /** Optional toast override (test-friendly). Defaults to the lib's\n * shared toast singleton. */\n toast?: typeof defaultToast\n}\n\nexport function TicketCenter({ toast = defaultToast }: TicketCenterProps = {}) {\n const identity = useChatIdentity()\n // Loading window — wait for the capability bag to resolve before\n // deciding what to render. `identity.isLoading` is the first-mount\n // window; once resolved we know authTier definitively.\n if (identity.isLoading) {\n return <TicketCenterSkeleton />\n }\n if (identity.authTier === 'anon' || !identity.user?.email) {\n return (\n <EmptyState\n type=\"generic\"\n title=\"Sign in to manage tickets\"\n description=\"View, open, and follow up on support tickets after signing in.\"\n showCTA={false}\n />\n )\n }\n return (\n <TicketCenterAuthed\n toast={toast}\n sessionEmail={identity.user.email}\n />\n )\n}\n\nfunction TicketCenterAuthed({\n toast,\n sessionEmail,\n}: {\n toast: typeof defaultToast\n /** Identity drilled from the parent — see `useTicketsList`'s\n * `customerEmail` arg doc for the race-cause rationale. */\n sessionEmail: string\n}) {\n const queryClient = useQueryClient()\n const { tickets, isLoading, isFetching, refetch, lastUpdatedAt } = useTicketsList({\n customerEmail: sessionEmail,\n })\n const [optimisticTickets, setOptimisticTickets] = useState<OptimisticTicket[]>([])\n const [expandedTicketId, setExpandedTicketId] = useState<string | null>(null)\n const [supportSystemDown, setSupportSystemDown] = useState(false)\n\n // Optimistic cache management. Kept LOCAL (not in the query cache) so\n // a refetch doesn't blow away pending placeholders mid-flight. The\n // merged view is `[...optimistic, ...server]` so optimistic rows sit\n // at the top until they're explicitly removed.\n const prependOptimistic = useCallback((placeholder: OptimisticTicket) => {\n setOptimisticTickets((prev) => [placeholder, ...prev])\n }, [])\n const removeOptimistic = useCallback((placeholderId: string) => {\n setOptimisticTickets((prev) => prev.filter((t) => t.id !== placeholderId))\n // If the parent had this temp id expanded (shouldn't happen — the\n // drawer is hidden on optimistic rows — but defensive), null it\n // so we don't dangle a stale id.\n setExpandedTicketId((prev) => (prev === placeholderId ? null : prev))\n }, [])\n const removeTicketFromCache = useCallback(\n (ticketId: string) => {\n // Target every cache slot under the ['tickets'] prefix — the\n // queryKey now includes an identityKey segment (use-tickets-list)\n // so a bare ['tickets', 'self'] write would no-op silently.\n queryClient.setQueriesData<TicketData[] | undefined>(\n { queryKey: ['tickets'] },\n (prev) => (prev ?? []).filter((t) => t.id !== ticketId),\n )\n setExpandedTicketId((prev) => (prev === ticketId ? null : prev))\n },\n [queryClient],\n )\n\n const actions = useTicketActions({\n prependOptimistic,\n removeOptimistic,\n removeTicketFromCache,\n toast,\n onSupportSystemDown: () => setSupportSystemDown(true),\n })\n\n const toggleRow = useCallback((id: string) => {\n setExpandedTicketId((prev) => (prev === id ? null : id))\n }, [])\n\n const merged: AnyTicket[] = [...optimisticTickets, ...tickets]\n\n return (\n <div className=\"flex flex-col gap-6\">\n <TicketOpenForm\n onSubmit={(input) => actions.submitTicket(input)}\n isSubmitting={actions.isSubmittingForm}\n supportSystemDown={supportSystemDown}\n />\n\n <div className=\"flex flex-col gap-2\">\n <div className=\"flex items-center justify-between gap-3\">\n <p className=\"text-xs font-medium text-ods-text-secondary uppercase tracking-wider\">\n Your Current Tickets\n </p>\n <div className=\"flex items-center gap-3 text-xs text-ods-text-secondary\">\n {lastUpdatedAt && (\n <span>Updated {formatRelativeTime(new Date(lastUpdatedAt))}</span>\n )}\n <Button\n type=\"button\"\n variant=\"transparent\"\n size=\"small\"\n onClick={refetch}\n disabled={isFetching}\n aria-label=\"Refresh ticket list\"\n leftIcon={<RefreshCw className=\"h-4 w-4\" />}\n />\n </div>\n </div>\n\n {isLoading ? (\n <TicketListSkeleton />\n ) : merged.length === 0 ? (\n <Card className=\"p-6\">\n <EmptyState\n type=\"generic\"\n title=\"No tickets yet\"\n description=\"Open one above to start the conversation.\"\n showCTA={false}\n />\n </Card>\n ) : (\n <Card className=\"overflow-hidden\">\n {merged.map((ticket) => (\n <TicketRow\n key={ticket.id}\n ticket={ticket}\n expanded={expandedTicketId === ticket.id}\n onToggle={toggleRow}\n busy={isOptimistic(ticket) ? false : actions.isRowBusy(ticket.id)}\n supportSystemDown={supportSystemDown}\n onSendMessage={actions.sendMessage}\n onClose={actions.closeTicket}\n onReopen={actions.reopenTicket}\n onActionCollapsed={() => setExpandedTicketId(null)}\n />\n ))}\n </Card>\n )}\n </div>\n </div>\n )\n}\n\nfunction TicketCenterSkeleton() {\n return (\n <div className=\"flex flex-col gap-6\">\n <Card className=\"p-6\">\n <Skeleton className=\"h-7 w-48 mb-4\" />\n <Skeleton className=\"h-10 w-full mb-3\" />\n <Skeleton className=\"h-24 w-full\" />\n </Card>\n <TicketListSkeleton />\n </div>\n )\n}\n\nfunction TicketListSkeleton() {\n return (\n <Card className=\"overflow-hidden\">\n {[0, 1, 2].map((i) => (\n <div key={i} className=\"h-20 px-4 flex items-center gap-4 border-b border-ods-border last:border-b-0\">\n <div className=\"flex-1 flex flex-col gap-2\">\n <Skeleton className=\"h-4 w-2/3\" />\n <Skeleton className=\"h-3 w-full\" />\n </div>\n <Skeleton className=\"h-8 w-20\" />\n <Skeleton className=\"h-8 w-16\" />\n </div>\n ))}\n </Card>\n )\n}\n","'use client'\n\n/**\n * The \"Open Ticket\" form at the top of <TicketCenter />.\n *\n * Composition only — every leaf is an existing oss-lib primitive:\n * - `<Input>` for subject\n * - `<Textarea>` for content\n * - `<ChatAttachmentAddButton>` + `<ChatAttachmentChipStrip>` for files\n * - `<Button>` for submit\n *\n * Submit gating combines:\n * - subject + content trim non-empty\n * - no uploads in flight\n * - not currently submitting (single-flight, parent-owned)\n * - support system online\n */\n\nimport { useState } from 'react'\nimport { Card } from '../ui/card'\nimport { Input } from '../ui/input'\nimport { Textarea } from '../ui/textarea'\nimport { Button } from '../ui/button'\nimport {\n ChatAttachmentAddButton,\n ChatAttachmentChipStrip,\n} from '../chat/chat-attachment-bar'\nimport { useChatAttachments } from '../chat/hooks/use-chat-attachments'\nimport { TICKET_TEXT_MAX_CHARS } from './types'\n\nconst COUNTER_VISIBLE_AT = Math.floor(TICKET_TEXT_MAX_CHARS * 0.8)\n\nexport interface TicketOpenFormProps {\n /** Wired to `useTicketActions().submitTicket`. Returns true on success\n * so the form can clear itself. */\n onSubmit: (input: { subject: string; content: string; attachments: import('../chat/utils/chat-attachment-markdown').ChatAttachment[] }) => Promise<boolean>\n isSubmitting: boolean\n supportSystemDown: boolean\n}\n\nexport function TicketOpenForm({\n onSubmit,\n isSubmitting,\n supportSystemDown,\n}: TicketOpenFormProps) {\n const [subject, setSubject] = useState('')\n const [content, setContent] = useState('')\n\n // Reuse the chat composer's attachment hook directly — same upload\n // pipeline, same readiness flags. The hook returns `readyAttachments`\n // (the wire-shape projection) and `hasInflightUploads`.\n const { attachments, readyAttachments, hasInflightUploads, addFiles, removeAttachment, clear } = useChatAttachments()\n\n const trimmedSubject = subject.trim()\n const trimmedContent = content.trim()\n const overCap = content.length > TICKET_TEXT_MAX_CHARS\n const showCounter = content.length >= COUNTER_VISIBLE_AT\n\n const canSubmit =\n !isSubmitting &&\n !supportSystemDown &&\n !hasInflightUploads &&\n trimmedSubject.length > 0 &&\n trimmedContent.length > 0 &&\n !overCap\n\n const handleSubmit = async (e: React.FormEvent) => {\n e.preventDefault()\n if (!canSubmit) return\n const ok = await onSubmit({\n subject: trimmedSubject,\n content: trimmedContent,\n attachments: readyAttachments,\n })\n if (ok) {\n setSubject('')\n setContent('')\n clear()\n }\n }\n\n return (\n <Card className=\"p-6\">\n <form onSubmit={handleSubmit} className=\"flex flex-col md:flex-row gap-6\">\n <div className=\"flex-1 min-w-0 md:max-w-md\">\n <h2 className=\"text-2xl font-semibold text-ods-text-primary mb-2\">\n Need Support?\n </h2>\n <p className=\"text-ods-text-secondary text-sm\">\n Can&apos;t find what you&apos;re looking for? Submit a support ticket\n below — we&apos;ll follow up shortly.\n </p>\n {supportSystemDown && (\n <p className=\"mt-4 text-sm text-ods-error\">\n Support system temporarily unavailable. Please try again shortly.\n </p>\n )}\n </div>\n\n <div className=\"flex-1 min-w-0 flex flex-col gap-4\">\n <div>\n <label\n htmlFor=\"ticket-subject\"\n className=\"block text-sm font-medium text-ods-text-primary mb-1\"\n >\n Ticket Subject\n </label>\n <Input\n id=\"ticket-subject\"\n type=\"text\"\n placeholder=\"Enter Subject Here\"\n value={subject}\n onChange={(e) => setSubject(e.target.value)}\n disabled={isSubmitting || supportSystemDown}\n maxLength={200}\n />\n </div>\n\n <div>\n <label\n htmlFor=\"ticket-content\"\n className=\"block text-sm font-medium text-ods-text-primary mb-1\"\n >\n Your Message\n </label>\n <Textarea\n id=\"ticket-content\"\n placeholder=\"Describe your issue or question in detail...\"\n value={content}\n onChange={(e) => setContent(e.target.value)}\n disabled={isSubmitting || supportSystemDown}\n rows={5}\n className=\"resize-none\"\n />\n {showCounter && (\n <p\n className={`mt-1 text-xs text-right ${\n overCap ? 'text-ods-error' : 'text-ods-text-secondary'\n }`}\n >\n {content.length}/{TICKET_TEXT_MAX_CHARS}\n </p>\n )}\n </div>\n\n <ChatAttachmentChipStrip\n attachments={attachments}\n onRemove={removeAttachment}\n disabled={isSubmitting || supportSystemDown}\n />\n\n <div className=\"flex items-center justify-between gap-3\">\n <ChatAttachmentAddButton\n attachmentsEnabled={!supportSystemDown}\n attachmentsCount={attachments.length}\n onAddFiles={addFiles}\n disabled={isSubmitting}\n />\n <Button\n type=\"submit\"\n disabled={!canSubmit}\n loading={isSubmitting}\n >\n Open Ticket\n </Button>\n </div>\n </div>\n </form>\n </Card>\n )\n}\n","/**\n * Wire shape of a row returned by `POST /api/chat/agent/find-ticket`.\n * Mirrors the executor's projection at `lib/data/hubspot-tools.ts`\n * (`FIND_TICKET_SELECT` / `FindTicketResult`).\n *\n * Cross-repo duplication is INTENTIONAL: this lib ships independently\n * of the hub, so we can't import `FindTicketResult` from\n * `hubspot-tools.ts` directly. If the server adds a column to\n * `FIND_TICKET_SELECT`, also add it here. The smoke test in §F of the\n * plan covers the happy path; a wire-contract test belongs in the hub.\n *\n * `find-ticket` returns `customer_emails: string[]` (jsonb array), NOT\n * a single `customer_email`. The list is server-self-scoped to the\n * caller's session email; the array is exposed for admin/staff\n * surfaces, which the ticket center doesn't render.\n */\nexport interface TicketData {\n id: string\n /** HubSpot ticket id (display number, e.g. \"1234\"). */\n external_id: string\n subject: string | null\n /** Short (≤400 char) HTML-stripped preview of the ticket body —\n * used for the list-card subtitle when needed. */\n preview: string | null\n /** Longer (≤4k char) sanitized body. INCLUDES every appended\n * `content_addendum` comment because the `update_ticket` executor\n * reads + re-writes the `content` property server-side with a\n * `---` separator. Render this in the drawer's description block\n * and the user sees both the original message + every comment they\n * (or staff) added. */\n body: string | null\n /** Canonical OPEN | CLOSED (HubSpot pipeline derived). */\n status: string | null\n /** Human label like \"New\" / \"Working on it\" / \"Waiting on contact\" /\n * \"Closed\". Drives the badge text; canonical status drives color. */\n pipeline_stage_label: string | null\n clickup_task_id: string | null\n /** Snapshot of the linked ClickUp delivery task — populated server-side\n * via the `clickup_tasks` mirror when `clickup_task_id` is set. Drives\n * the \"Linked delivery\" card surface on the ticket drawer (status\n * badge + ClickUp deep link). `null` when no link OR the ClickUp row\n * was deleted / not yet synced. */\n clickup: TicketClickupSummary | null\n priority: string | null\n customer_emails: string[]\n customer_company: string | null\n /** HubSpot contact's display name. Drives the customer attribution\n * on the drawer when the viewer is NOT the customer themselves\n * (admin browsing / multi-contact second viewer). Conversations\n * API messages don't carry per-message sender info on Custom\n * Channels, so this is the only reliable source for \"what's the\n * customer's name.\" */\n customer_name: string | null\n /** HubSpot owner id of the agent assigned to this ticket. Carried as\n * raw id for debugging; rendering goes through `assignedOwner`. Null\n * when unassigned. */\n assigned_to: string | null\n /** Resolved assigned-owner profile — name + email + avatar. Populated\n * server-side via `attachOwnerProfiles` which joins through the\n * `hubspot_owners` mirror to `profiles` by email. Drives the\n * \"Assigned to\" attribution in the drawer header. Null when\n * unassigned OR the owner couldn't be resolved (rare — only when\n * the agent was deleted from HubSpot between the ticket update and\n * the next owners reconcile). */\n assignedOwner: TicketAssignedOwner | null\n hubspot_updated_at: string\n}\n\n/** Resolved profile of a ticket's assigned agent — surfaced in the\n * drawer header. Subset of the server's `MirroredOwnerProfile`\n * trimmed to just the rendering fields. */\nexport interface TicketAssignedOwner {\n name: string | null\n email: string | null\n avatarUrl: string | null\n}\n\n/** Compact projection of a linked ClickUp task — matches the server's\n * `ClickupSummary` and aligns with `DeliveryItem` so the linked-card\n * on a ticket can render through the same `DeliveryRow` primitive used\n * on `/bug-fixes-and-enhancements`. */\nexport interface TicketClickupSummary {\n /** ClickUp task external_id (e.g. \"86ad4e022\"). Used as the\n * `?focus=<id>` URL param to scroll the public delivery page to\n * this row. */\n external_id: string\n title: string | null\n description: string | null\n /** ClickUp status name — e.g. \"complete\" / \"working\" / \"design approved\"\n * / \"waiting for release\". Used as the badge label. */\n status: string | null\n /** ClickUp's per-status hex color (e.g. \"#008844\"). Forwarded to the\n * badge so colors match the ClickUp board exactly. */\n status_color: string | null\n /** Bucket — `'backlog' | 'working' | 'complete' | 'unknown'`. Used as\n * a fallback when status_color is missing. */\n status_category: string | null\n /** ClickUp custom item label (`'Bug'` / `'Request'`) — drives the\n * type badge (\"BUG-FIX\" / \"ENHANCEMENT\"). */\n task_type: string | null\n custom_item_id: number | null\n /** Every ClickUp list the task is associated with. UI joins with \", \". */\n list_names: string[]\n /** Unix-ms timestamps so the row's \"ACTIVE X ago\" subtitle uses the\n * shared `getRelativeTime()` helper. */\n date_opened: number | null\n date_updated: number | null\n date_closed: number | null\n /** Direct https://app.clickup.com/t/<id> deep link. Kept on the wire\n * for admin surfaces; the customer-facing linked card navigates\n * internally instead. */\n clickup_url: string | null\n /** Composed server-side via the SAME `buildDevSectionUrl` helper the\n * chat-inline delivery card uses. Carries `?search=<id>` so the\n * delivery list filters to that single task on landing. */\n delivery_href: string\n /** Target platform name for the host's `useNavLink` to decide\n * same-tab vs new-tab on cross-platform links. */\n delivery_target_platform: string\n /** Release version label set by the delivery team, e.g. \"0.9\" / \"1.0\".\n * Shown beside the status when present. */\n target_version: string | null\n}\n\n/**\n * Optimistic placeholder a `submitTicket` call prepends to the list\n * BEFORE the server roundtrip resolves. Drawer is hidden until the\n * real id arrives. The wrapper destructures `_optimistic` before\n * forwarding to `<ChatTicketItem>` so the DOM doesn't see an unknown\n * prop.\n */\nexport interface OptimisticTicket extends TicketData {\n _optimistic: true\n}\n\nexport type AnyTicket = TicketData | OptimisticTicket\n\nexport function isOptimistic(t: AnyTicket): t is OptimisticTicket {\n return (t as OptimisticTicket)._optimistic === true\n}\n\n/**\n * Shape of a single `['tickets', …]` TanStack-Query cache slot.\n * Mirrors `FindTicketResponse` in `hooks/use-tickets-list.ts` — kept\n * here because the cache-mutation call sites in `useTicketActions` and\n * `<HelpCenterList>` would otherwise have to redeclare the shape inline.\n *\n * A 2026-05-29 prod regression (`t.map is not a function` on\n * close/reopen) was caused by assuming the cache held a bare\n * `TicketData[]` instead of this wrapper — every helper that calls\n * `queryClient.setQueriesData` / `getQueriesData` on `['tickets']`\n * MUST type the value through this shape and project / reassemble\n * `tickets` explicitly.\n */\nexport interface TicketsCacheSlot {\n tickets?: TicketData[]\n count?: number\n totalCount?: number\n page?: number\n pageSize?: number\n totalPages?: number\n scope?: 'self' | 'all'\n}\n\n/**\n * Stable server-side error codes the ticket-action helpers route\n * through `mapTicketActionError`. Anything else is treated as a generic\n * server error.\n *\n * Reply-specific codes (`HUBSPOT_5XX` / `HUBSPOT_400_VALIDATION` /\n * `HUBSPOT_404_THREAD` / `HUBSPOT_REPLY_UNKNOWN`) drive the drawer\n * banner that appears above the composer when a customer reply fails.\n * Distinct from `HUBSPOT_DISCONNECTED` (whole-system) and\n * `TICKET_NOT_FOUND` (terminal-row) — the reply codes are per-attempt\n * + retryable except for `HUBSPOT_404_THREAD`.\n */\nexport type TicketActionErrorCode =\n | 'PROPOSAL_NOT_CLAIMABLE'\n | 'TICKET_NOT_FOUND'\n | 'TICKET_OWNERSHIP_DENIED'\n | 'HUBSPOT_DISCONNECTED'\n | 'RATE_LIMITED'\n | 'INVALID_TOOL_ARGS'\n | 'HUBSPOT_5XX'\n | 'HUBSPOT_400_VALIDATION'\n | 'HUBSPOT_404_THREAD'\n | 'HUBSPOT_REPLY_UNKNOWN'\n | 'UNKNOWN'\n\nexport interface MappedTicketActionError {\n code: TicketActionErrorCode\n /** Human-readable copy safe to show in a toast. */\n message: string\n /** When true, the form should disable submit + show the\n * support-down banner. Set only for HUBSPOT_DISCONNECTED. */\n supportSystemDown: boolean\n /** When true, the helper should remove the affected row optimistically\n * (TICKET_NOT_FOUND). */\n removeRowFromCache: boolean\n /** Retry hint surfaced from a 429 response. Caller decides whether\n * to mention it in the toast. */\n retryAfterSeconds?: number\n}\n\n/**\n * Defensive client-side cap on ticket text (initial content + comment\n * addendums). HubSpot Note engagements accept more, but a 100KB paste\n * should fail fast at the UI rather than burning a server round-trip.\n * Both the open-ticket form and the per-row comment textarea import\n * this so a future server-side hardening only touches one place.\n */\nexport const TICKET_TEXT_MAX_CHARS = 5000\n\n/**\n * Live-refresh cadence (ms) for an OPEN ticket drawer. Drives BOTH\n * surfaces that must stay current while the customer is looking at a\n * ticket:\n * - the ticket LIST poll (`useTicketsList.refetchInterval`) → surfaces\n * out-of-band status / pipeline / priority / assignee changes;\n * - the CONVERSATION poll (`useTicketEngagements.refetchInterval`) →\n * surfaces new agent replies + attachments.\n * Single source of truth so the two surfaces never drift. Both leave\n * `refetchIntervalInBackground` at its default (false), so polling pauses\n * on a hidden tab — no wasted requests when the user tabs away.\n */\nexport const TICKET_LIVE_POLL_MS = 8000\n\n/**\n * Centralized toast copy. Keep all wording here so QA / localization\n * can find every user-visible string in one file.\n */\nexport const TOAST_COPY = {\n open_success: { title: 'Ticket opened', description: 'We received your message and will follow up shortly.' },\n open_mirror_pending: { title: 'Ticket opened', description: 'Syncing — your ticket will appear momentarily.' },\n close_success: { title: 'Ticket closed' },\n reopen_success: { title: 'Ticket reopened' },\n comment_success: { title: 'Comment added' },\n attach_success: { title: 'Files attached' },\n // Failure variants are constructed dynamically from MappedTicketActionError.\n} as const\n","'use client'\n\n/**\n * Single ticket row + expanded details drawer.\n *\n * The COMPACT summary tile (`<ChatTicketItem>`) is the row chrome\n * specific to this composition — used by the lib's `<TicketCenter />`\n * embed. The drawer body itself is extracted into\n * `<TicketDetailDrawer />` so the hub's DevSection-style ticket card\n * (different chrome, same drawer) can drop it in too.\n *\n * Layout:\n * 1. `<ChatTicketItem>` summary tile. Clicking it toggles the\n * `expandedTicketId` state owned by the parent `<TicketCenter>` —\n * we use the item's existing `onClick` prop rather than nesting a\n * `<CollapsibleTrigger>` (button-in-button is invalid).\n * 2. `<CollapsibleContent>` wrapping `<TicketDetailDrawer />`,\n * rendered only when this row is the expanded one.\n */\n\nimport { useCallback, useRef } from 'react'\nimport {\n Collapsible,\n CollapsibleContent,\n} from '../collapsible'\nimport {\n ChatTicketItem,\n type ChatTicketItemData,\n} from '../chat/entity-cards/chat-ticket-item'\nimport { formatRelativeTime } from '../../utils/date-utils'\nimport { scrollElementIntoView } from '../../utils/scroll-into-view'\nimport {\n TicketDetailDrawer,\n type TicketDetailDrawerProps,\n} from './ticket-detail-drawer'\nimport type { AnyTicket } from './types'\nimport { isOptimistic } from './types'\n\nexport interface TicketRowProps {\n ticket: AnyTicket\n expanded: boolean\n onToggle: (ticketId: string) => void\n busy: boolean\n supportSystemDown: boolean\n onSendMessage: TicketDetailDrawerProps['onSendMessage']\n onClose: TicketDetailDrawerProps['onClose']\n onReopen: TicketDetailDrawerProps['onReopen']\n /** Called after a successful close/reopen so the parent can collapse\n * the drawer (status flipped — current action set is now stale). */\n onActionCollapsed: TicketDetailDrawerProps['onActionCollapsed']\n}\n\nexport function TicketRow({\n ticket,\n expanded,\n onToggle,\n busy,\n supportSystemDown,\n onSendMessage,\n onClose,\n onReopen,\n onActionCollapsed,\n}: TicketRowProps) {\n // Optimistic placeholders have no drawer — the real id hasn't\n // arrived yet, so action targets would be undefined.\n const optimistic = isOptimistic(ticket)\n\n // Scroll the clicked card to the top of the viewport. Every click\n // scrolls — first-click expansion, same-row re-click, cross-row\n // switch. The clicked card lands at the top.\n //\n // Cross-row gotcha: if ANOTHER row above this one is currently\n // expanded, its drawer is about to collapse simultaneously with our\n // toggle. We pre-subtract its height from the target Y so the\n // smooth-scroll lands at the FINAL post-collapse position cleanly.\n // Same pattern as `<HelpCenterCard>` — the only diff is the drawer\n // id prefix (`ticket-drawer-` vs `help-center-drawer-`).\n const rowRef = useRef<HTMLDivElement | null>(null)\n const handleClick = useCallback(() => {\n onToggle(ticket.id)\n scrollElementIntoView(rowRef.current, {\n adjustTargetY: (raw) => {\n if (!rowRef.current) return raw\n const expandedDrawer = document.querySelector(\n 'div[id^=\"ticket-drawer-\"]',\n )\n if (!(expandedDrawer instanceof HTMLElement)) return raw\n const drawerRect = expandedDrawer.getBoundingClientRect()\n const myRect = rowRef.current.getBoundingClientRect()\n // Only adjust when the drawer is ABOVE us. Drawers below\n // don't shift our position when they collapse.\n if (drawerRect.bottom > myRect.top) return raw\n return raw - drawerRect.height\n },\n })\n }, [onToggle, ticket.id])\n\n const tileData: ChatTicketItemData = {\n id: ticket.id,\n title: ticket.subject ?? '(untitled)',\n ticketNumber: `#${ticket.external_id}`,\n status: ticket.status ?? 'OPEN',\n // Surface the HubSpot pipeline stage label (\"New\" / \"Closed\" /\n // \"Waiting on contact\" / \"Waiting on version release\") instead of\n // the canonical \"Active\"/\"Resolved\" default. The variant + check\n // icon still come from `status` (CLOSED → check; OPEN → no check),\n // so the badge accurately reflects \"Closed\" with a checkmark.\n statusLabel: ticket.pipeline_stage_label ?? undefined,\n category: ticket.customer_company ?? undefined,\n timeAgo: ticket.hubspot_updated_at\n ? formatRelativeTime(ticket.hubspot_updated_at)\n : undefined,\n // Linked-work chip: surfaced whenever the ticket has a linked\n // ClickUp task. Uses the linked task's own status so the chip text\n // reads \"Working\" / \"Waiting on version release\" / etc. — useful\n // signal pre-expand. Falls back to a generic \"Linked work\" label\n // when the task exists but its status hasn't synced yet.\n linkedTaskLabel: ticket.clickup\n ? ticket.clickup.status\n ? ticket.clickup.status.replace(/\\b\\w/g, (c) => c.toUpperCase())\n : 'Linked work'\n : undefined,\n }\n\n return (\n <div ref={rowRef} className=\"scroll-mt-24\">\n <Collapsible\n open={expanded && !optimistic}\n className=\"border-b border-ods-border last:border-b-0\"\n >\n <ChatTicketItem\n ticket={tileData}\n onClick={optimistic ? undefined : handleClick}\n aria-expanded={expanded && !optimistic}\n aria-controls={`ticket-drawer-${ticket.id}`}\n />\n <CollapsibleContent\n id={`ticket-drawer-${ticket.id}`}\n className=\"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down\"\n >\n <TicketDetailDrawer\n ticket={ticket}\n busy={busy}\n supportSystemDown={supportSystemDown}\n onSendMessage={onSendMessage}\n onClose={onClose}\n onReopen={onReopen}\n onActionCollapsed={onActionCollapsed}\n />\n </CollapsibleContent>\n </Collapsible>\n </div>\n )\n}\n","\"use client\"\n\nimport * as CollapsiblePrimitive from \"@radix-ui/react-collapsible\"\n\nconst Collapsible = CollapsiblePrimitive.Root\n\nconst CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger\n\nconst CollapsibleContent = CollapsiblePrimitive.CollapsibleContent\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent }\n","'use client'\n\n/**\n * `<TicketDetailDrawer />` — the expanded view of a single ticket.\n *\n * Extracted from the original `ticket-row.tsx` so both compositions\n * share it:\n * - Lib's `TicketRow` (compact `<ChatTicketItem>` summary + drawer\n * beneath; what third-party embedders use via `TicketCenter`).\n * - Hub's `<TicketCard>` (the DevSection-style card chrome on the\n * openframe `/tickets` page).\n *\n * The drawer owns everything BELOW the summary tile:\n * 1. Metadata strip (ticket #, priority, pipeline, company, updated)\n * 2. Conversation timeline (`<TicketTimelinePanel>`) — original body\n * turns + Note engagements + attachments\n * 3. Status-dependent actions (composer + close OR reopen)\n *\n * State is local to this component (composer text, attachment bag,\n * close-confirm dialog). The parent owns the ticket data + mutation\n * callbacks; we don't reach into the QueryClient.\n */\n\nimport { useStickToBottom } from 'use-stick-to-bottom'\nimport { Button } from './../ui/button'\nimport { useChatIdentity } from './../chat/hooks/use-chat-identity'\nimport {\n ChatMessageRow,\n ChatMessageRowSkeleton,\n} from './../chat/chat-message-row'\nimport { EmptyState } from './../empty-state'\nimport {\n TicketAttachmentsList,\n type TicketAttachment,\n} from './../ui/ticket-attachments-list'\nimport { SquareAvatar } from './../ui/square-avatar'\nimport { formatRelativeTime } from './../../utils/date-utils'\nimport { useTicketEngagements } from './hooks/use-ticket-engagements'\nimport type {\n TicketEngagementFile,\n} from './hooks/use-ticket-engagements'\nimport { TicketLinkedDeliveryCard } from './ticket-linked-delivery-card'\nimport { TicketReplyComposer } from './ticket-reply-composer'\nimport type {\n AnyTicket,\n TicketAssignedOwner,\n MappedTicketActionError,\n} from './types'\nimport { isOptimistic, TICKET_LIVE_POLL_MS } from './types'\n\n/** Identity bundle threaded through the action callbacks: local mirror\n * UUID + HubSpot external_id. Actions send `external_id` to HubSpot\n * (the only id it recognizes) and use `id` for the React-side mutex +\n * TanStack cache. */\nexport type TicketRef = { id: string; external_id: string }\n\nexport interface TicketDetailDrawerProps {\n ticket: AnyTicket\n busy: boolean\n supportSystemDown: boolean\n /** Single combined \"reply\" — text + optional attachments delivered as\n * ONE Note engagement. */\n onSendMessage: (\n ticket: TicketRef,\n text: string,\n attachments: import('./../chat/utils/chat-attachment-markdown').ChatAttachment[],\n ) => Promise<boolean>\n onClose: (ticket: TicketRef, resolution?: string) => Promise<boolean>\n onReopen: (ticket: TicketRef) => Promise<boolean>\n /** Called after a successful close/reopen so the parent can collapse\n * the drawer (status flipped — current action set is now stale). */\n onActionCollapsed: () => void\n /** Persisted reply-failure surface — when non-null the drawer renders\n * an inline banner above the composer with the mapped copy + a\n * dismiss control. Distinct from the transient toast; the banner\n * stays visible so the customer can locate the failed draft after\n * the toast disappears. Cleared on the next successful send. */\n replyError?: MappedTicketActionError | null\n /** Dismiss-X handler for the banner. Parent calls\n * `actions.clearReplyError(ticket.external_id)`. */\n onClearReplyError?: () => void\n}\n\nexport function TicketDetailDrawer({\n ticket,\n busy,\n supportSystemDown,\n onSendMessage,\n onClose,\n onReopen,\n onActionCollapsed,\n replyError,\n onClearReplyError,\n}: TicketDetailDrawerProps) {\n const isClosed = (ticket.status ?? '').toUpperCase() === 'CLOSED'\n return (\n <div className=\"bg-ods-card border-t border-ods-border px-4 py-4 flex flex-col gap-4\">\n {/* Assignee header — surfaces who's looking at this ticket on the\n support side. Populated server-side via `attachOwnerProfiles`;\n falls back to \"Unassigned\" when no agent is assigned OR when\n the owner couldn't be resolved (deleted between ticket update\n + next owners reconcile). */}\n <AssignedAgentRow assignedOwner={ticket.assignedOwner} />\n\n {/* Linked ClickUp delivery — rendered only when the server's\n `attachClickupTasks` step populated `ticket.clickup`. Customer\n tickets with no linked task skip this entirely. The card itself\n links out to ClickUp with the per-status color badge so the\n customer can follow the delivery progress. */}\n {ticket.clickup && (\n <TicketLinkedDeliveryCard clickup={ticket.clickup} />\n )}\n\n <div>\n <p className=\"text-xs font-medium text-ods-text-secondary mb-2 uppercase tracking-wider\">\n Conversation\n </p>\n <TicketTimelinePanel ticket={ticket} />\n </div>\n\n <div className=\"border-t border-ods-border pt-4\">\n {/* Reply-failure banner — populated by `useTicketActions` when\n the last sendMessage attempt for THIS ticket failed with a\n reply-specific code. Rendered above the composer/reopen so\n the customer sees it in context of their failed draft. Open\n (composer) actions still allow Retry; the closed (reopen)\n state still shows the banner because the user might have\n tried to reply to a then-closing ticket. */}\n {replyError && (\n <ReplyFailureBanner\n error={replyError}\n onDismiss={onClearReplyError ?? (() => undefined)}\n />\n )}\n {isClosed ? (\n <ReopenAction\n ticketRef={{ id: ticket.id, external_id: ticket.external_id }}\n busy={busy}\n supportSystemDown={supportSystemDown}\n onReopen={onReopen}\n onActionCollapsed={onActionCollapsed}\n />\n ) : (\n <TicketReplyComposer\n ticket={ticket}\n busy={busy}\n supportSystemDown={supportSystemDown}\n onSendMessage={onSendMessage}\n onClose={onClose}\n />\n )}\n </div>\n </div>\n )\n}\n\n/**\n * Render the ticket conversation as a chronological list of\n * `<ConversationCardRow>` cards inside a single bordered container.\n *\n * Top: the original ticket description (`ticket.body`). Below: every\n * Note engagement attached to the ticket via `useTicketEngagements` —\n * each with its own attachments rendered through the shared\n * `<TicketAttachmentsList>` (no more 📎-emoji chips).\n *\n * Legacy tickets whose old comments STILL live inside `ticket.body`\n * (joined by ` --- `) split on that delimiter so the historical\n * conversation surfaces correctly during the transition.\n *\n * Scroll behavior — INTENTIONALLY NONE. The drawer grows with the\n * conversation; the page scrolls. The previous `max-h-96 overflow-y-auto`\n * created two competing scroll surfaces (inner + page) which felt\n * janky on long threads and hid the composer on short ones. 2026\n * helpdesk best practice (UXPin / Coveo research) is a single\n * threaded surface that flows with the page.\n */\n// Bounded quantifiers (`{1,16}`) protect against the polynomial-time\n// backtracking class CodeQL flags for unbounded `\\s+` on user input.\n// 16 chars of leading/trailing whitespace around `---` is far more\n// than any composed ticket body needs, so no real input is rejected.\nconst TURN_SEPARATOR_RE = /[\\s]{1,16}---[\\s]{1,16}/g\n\n// Slack-channel feed framing — ported from the hub's live community feed\n// (`components/slack/chat-interface.tsx`: `:62` bounded card, `:83` padding +\n// `overflow-y-auto`, `:85` `gap-4 md:gap-6` message column). Single source\n// within the ticket feed; the delivery `DevCardRowSkeletonList` keeps its own\n// (separate, untouched) frame literal. `max-h` is responsive (vs Slack's fixed\n// height).\nconst TICKET_FEED_FRAME =\n 'bg-ods-card border border-ods-border rounded-[6px] overflow-y-auto w-full'\n// FIXED height for EVERY state (skeleton, content, empty) — the Slack feed uses\n// a fixed-height box too (`chat-interface.tsx:62`). Fixed (not `max-h`) is the\n// fix for the \"open shows 1 message, then the container grows as engagements\n// land\" jank: the feed is its final size from first paint, so loaded content\n// just fills/scrolls inside it — the box never resizes.\nconst TICKET_FEED_HEIGHT = 'h-[60vh] md:h-[420px]'\nconst TICKET_FEED_INNER = 'flex flex-col gap-4 md:gap-6 px-4 md:px-6 py-4 md:py-6'\n// Enough skeleton rows to fill the fixed height (avatar 40px + header + 2 body\n// lines + gap-6) so the loading state looks like a full conversation.\nconst TICKET_FEED_SKELETON_ROWS = 6\n\nfunction TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {\n const identity = useChatIdentity()\n // Optimistic placeholders don't have a real external_id yet — skip\n // the engagement fetch until the real ticket lands.\n const externalId = isOptimistic(ticket) ? null : ticket.external_id\n // Live conversation refresh: this panel only mounts while the drawer is\n // open, so the constant interval is already gated to \"open\" (closing the\n // drawer unmounts the panel → polling stops). New agent replies +\n // attachments surface within one cadence without a manual refresh — the\n // same 8s the list-level status/assignee poll uses (single source:\n // TICKET_LIVE_POLL_MS). A background poll never flashes the skeleton\n // (the `isLoading` guard below keys off \"no data yet\", not `isFetching`).\n const { engagements, isLoading } = useTicketEngagements(\n externalId,\n !!externalId,\n TICKET_LIVE_POLL_MS,\n )\n\n // Slack-style auto-tail (same lib mechanism `ChatMessageList` uses): jump to\n // the newest message on open (`initial:'instant'`), smooth-scroll on a new\n // reply. Called unconditionally here, BEFORE the empty/loading early-returns\n // (Rules of Hooks); the refs attach ONLY to the content branch's scroll frame\n // + column — never the cold-start skeleton (refs there would snap to skeleton\n // height, then again to real content). This inner scroll is a SEPARATE\n // container from `HelpCenterCard`'s page-level expand-scroll, so it never\n // fights the \"scroll to top of the ticket card\" behavior.\n const { scrollRef, contentRef } = useStickToBottom({ initial: 'instant', resize: 'smooth' })\n\n const bodyTurns = ticket.body\n ? ticket.body.split(TURN_SEPARATOR_RE).map((t) => t.trim()).filter(Boolean)\n : []\n\n // Suppress `bodyTurns[0]` (\"Original message\") when the engagement\n // timeline already has a customer-authored message whose body\n // matches it. The channel-first create path in the hub writes the\n // customer's message body BOTH into `hubspot_tickets.content` AND\n // into the first `hubspot_ticket_conversation_messages` row — pre-\n // 2026-05-29 the bot-intake-burst filter on the server dropped the\n // first-customer message from engagements, so `bodyTurns[0]` was\n // the only render. With channel-first, the engagement survives and\n // both surfaces render the same text. Drop the redundant\n // \"Original message\" turn when we detect that overlap.\n //\n // Only `bodyTurns[0]` is conditional. Subsequent turns (\"Update N\",\n // \"[Resolution]\") come from `update_ticket.content_addendum` and\n // are NEVER customer-written, so the engagement timeline can't\n // match them. Leave their indices intact so `Update 1` still\n // labels as such when `bodyTurns[0]` is suppressed.\n const customerEngagementBodies = new Set<string>(\n engagements\n .filter((e) => e.authorRole === 'customer')\n .map((e) => (e.body ?? '').trim())\n .filter(Boolean),\n )\n const suppressBodyTurnZero =\n bodyTurns.length > 0 &&\n customerEngagementBodies.has(bodyTurns[0])\n\n // Customer name resolution precedence:\n // 1. LIVE chat identity (`identity.user.name`) — when the viewer\n // is the ticket's own customer. Always fresh.\n // 2. Mirror's `customer_name` — the HubSpot contact's display\n // name, captured by the ticket sync. Falls back here when the\n // viewer is NOT the customer (admin browsing / multi-contact\n // second viewer) so the customer bubble still shows the real\n // person's name instead of \"Customer\" generic.\n // 3. Session email — last resort.\n // 4. \"You\" — anonymous viewer.\n const sessionEmailLower = identity.user?.email?.trim().toLowerCase() ?? null\n const isViewerTheCustomer =\n !!sessionEmailLower &&\n ticket.customer_emails.some((e) => e.trim().toLowerCase() === sessionEmailLower)\n const viewerName = identity.user?.name?.trim() || null\n const ticketCustomerName = ticket.customer_name?.trim() || null\n const customerName =\n (isViewerTheCustomer ? viewerName : null) ||\n ticketCustomerName ||\n viewerName ||\n identity.user?.email ||\n 'You'\n const customerAvatar = isViewerTheCustomer\n ? identity.user?.avatarUrl ?? undefined\n : undefined\n\n // Loading takes precedence over partial content — this is the fix for the\n // \"open shows 1 message, then the rest load and the box grows\" jank. The\n // ticket BODY is available synchronously, but the engagement timeline is\n // fetched on open (caches off → EVERY open refetches). Rendering the body\n // alone and then appending engagements as they arrive is the pop-in/grow the\n // user hit. Instead: show the FULL-HEIGHT skeleton until the fetch settles,\n // THEN render the whole conversation at once. Fixed height + skeleton-first =\n // zero reflow and no partial render. `isLoading` (not `isFetching`) is true\n // only on a cold open with no data yet — so a background refetch after sending\n // a reply does NOT flash the skeleton; the new row just appends.\n if (isLoading) {\n // NO scroll refs here — they attach only to the real-content branch (refs on\n // the skeleton would snap-to-bottom on skeleton height, then again on content).\n return (\n <div className={`${TICKET_FEED_FRAME} ${TICKET_FEED_HEIGHT}`}>\n <div className={TICKET_FEED_INNER}>\n {Array.from({ length: TICKET_FEED_SKELETON_ROWS }, (_, i) => (\n <ChatMessageRowSkeleton key={i} />\n ))}\n </div>\n </div>\n )\n }\n\n if (bodyTurns.length === 0 && engagements.length === 0) {\n return (\n <EmptyState\n type=\"generic\"\n title=\"No conversation yet\"\n description=\"Reply below to start the thread with the support team.\"\n showCTA={false}\n />\n )\n }\n\n return (\n <div ref={scrollRef} className={`${TICKET_FEED_FRAME} ${TICKET_FEED_HEIGHT}`}>\n <div ref={contentRef} className={TICKET_FEED_INNER}>\n {/* Customer-authored description + any legacy `---`-joined\n comments. Always rendered ABOVE the engagement timeline as\n \"Original message\" because the server's intake-burst filter\n (see `filterCustomerVisibleTimeline` in\n `hubspot-conversations-utils.ts`) drops the customer's first\n message from engagements when it was part of the HubSpot\n Custom Channel bot intake — bodyTurns IS the canonical\n original for those tickets. For tickets created without bot\n intake (admin-created, email channel) bodyTurns shows the\n manually-entered description and engagements show subsequent\n replies — same flow, no duplication. */}\n {bodyTurns.map((turn, i) => {\n // Drop the redundant first turn when the engagement timeline\n // below already renders the same customer-authored body. See\n // `suppressBodyTurnZero` derivation above for the rationale.\n if (i === 0 && suppressBodyTurnZero) return null\n const isResolution = turn.startsWith('[Resolution]')\n const text = isResolution ? turn.replace(/^\\[Resolution\\]\\s*/, '') : turn\n // Body turns don't carry per-turn timestamps — `ticket.body` is a\n // single content field that HubSpot appends to. They render as the\n // customer's own messages (no role chip — the Slack-channel feed has\n // no role labels), oldest-first above the engagement timeline.\n return (\n <ChatMessageRow\n key={`body-${i}-${turn.slice(0, 24)}`}\n displayName={customerName}\n avatarUrl={customerAvatar}\n body={text}\n />\n )\n })}\n\n {/* Engagement timeline — interleaves customer-authored Custom\n Channel messages (authorRole='customer') and team-authored\n Notes (authorRole='support').\n ATTRIBUTION RULES (per repo convention):\n - CUSTOMER messages whose sender email matches the\n current chat-identity user → render BOTH name AND\n avatar LIVE from `identity.user.*` (1:1 from the\n X-Chat-First-Name + X-Chat-Last-Name + X-Chat-Avatar-Url\n headers that drive the identity webservice). This is\n the source of truth for the logged-in user; we never\n query `profiles` for customer display info.\n - CUSTOMER messages from a DIFFERENT email (rare — the\n /tickets surface only shows the current user's own\n threads) → fall back to whatever the mirror has\n (eng.authorName / eng.authorEmail), no profile lookup.\n - SUPPORT/Note messages → the server has already\n resolved `hubspot_owner_id` → owner email → `profiles`\n row in `list-engagements`. `eng.authorName` +\n `eng.authorAvatarUrl` carry the matched employee's\n display info. When the owner isn't a known Flamingo\n employee, both fields are null and we fall back to\n the generic \"Support team\" treatment. */}\n {engagements.map((eng) => {\n const isCustomer = eng.authorRole === 'customer'\n // Per-message own-reply check: the server populates `authorId`\n // with the message sender's email (resolved server-side via\n // the HubSpot Conversations actor batch-read for Custom\n // Channel visitor messages). When that email matches the\n // session viewer's email, the bubble is the viewer's OWN\n // reply and renders with LIVE chat identity (name + avatar\n // from chat-auth headers).\n const isOwnReply =\n isCustomer &&\n !!eng.authorId &&\n !!identity.user?.email &&\n eng.authorId.toLowerCase() === identity.user.email.toLowerCase()\n\n let author: string\n let avatarSrc: string | undefined\n if (isCustomer && isOwnReply) {\n // Live identity — 1:1 from chat auth headers.\n author = identity.user?.name?.trim() || customerName\n avatarSrc = identity.user?.avatarUrl ?? undefined\n } else if (isCustomer) {\n // Customer bubble whose sender email isn't the current\n // session viewer. Two sub-cases:\n // (a) Same customer as the ticket but viewed by an admin\n // (or no sender_email on the engagement at all — the\n // Conversations API leaves it null on Custom Channels).\n // Use `ticket.customer_name` from the mirror — that's\n // the canonical HubSpot contact name for THIS ticket.\n // (b) Multi-contact ticket (CC/BCC) — a different customer\n // email appears here. We fall back to the same\n // `ticket.customer_name` rather than leak the second\n // contact's address; close enough for the rare case.\n author = ticketCustomerName || 'Customer'\n avatarSrc = undefined\n } else if (eng.authorName && eng.authorAvatarUrl) {\n // Resolved Flamingo employee — server matched the HubSpot\n // owner's email against `profiles` AND has an avatar to\n // prove it. Avatar presence IS the trust signal: only\n // owner-resolved employees carry one; raw HubSpot\n // `sender_name` (bots, integrations, system actors,\n // unmatched humans) carries name without avatar and gets\n // the generic \"Support team\" treatment so we never\n // attribute a customer-facing bubble to a bot string\n // (\"HubSpot Bot\", \"Slack Integration\", etc.).\n author = eng.authorName\n avatarSrc = eng.authorAvatarUrl\n } else {\n // Unmatched / unknown / bot / integration / system actor —\n // generic fallback. Customer doesn't need to see internal\n // tool branding (which has the customer \"talking to\" a bot\n // string instead of a person).\n author = 'Support team'\n avatarSrc = undefined\n }\n\n // Role label: every engagement is a customer-visible\n // Conversations message (customer ↔ agent on the Custom\n // Channel). There are no internal Notes on this surface\n // anymore — the read path explicitly filters them. So\n // \"Reply\" for BOTH sides. The previous \"Note\" label for\n // support bubbles was a legacy artifact from when Notes\n // were rendered and made customers think their support\n // engineer was leaving internal comments on their ticket.\n const engAttachments = mapEngagementAttachments(eng.attachments)\n return (\n <ChatMessageRow\n key={eng.id}\n displayName={author}\n avatarUrl={avatarSrc}\n timeLabel={eng.createdAt ? formatRelativeTime(eng.createdAt) : null}\n body={stripAttachmentsPreamble(eng.body ?? '')}\n footer={\n engAttachments.length > 0 ? (\n <div className=\"mt-2\">\n <TicketAttachmentsList attachments={engAttachments} size=\"compact\" />\n </div>\n ) : null\n }\n />\n )\n })}\n\n {/* No trailing refetch skeleton in the tailing feed: a skeleton mounted\n inside `contentRef` on a background refetch would make the auto-tail\n smooth-scroll to the skeleton and then again to the real row (a\n double-jump). The smooth-tail to the appended real reply IS the\n feedback. (Removed the former background-refetch rows={1} skeleton.) */}\n </div>\n </div>\n )\n}\n\n/** Map the engagement file shape to the lib's canonical\n * `TicketAttachment` so we can hand it straight to\n * `<TicketAttachmentsList>`. Engagement `url` becomes a\n * window.open-style download click; missing names degrade to\n * `file-<id>` so the chip never renders an empty label. */\nfunction mapEngagementAttachments(\n files: TicketEngagementFile[],\n): TicketAttachment[] {\n return files.map((f) => ({\n id: f.id,\n fileName: f.name ?? `file-${f.id}`,\n fileSize: f.size ? formatBytes(f.size) : '',\n // Show an inline thumbnail for image attachments (the signed `url` is a\n // viewable URL). Non-images fall back to the file-type icon. SquareAvatar\n // degrades to initials on a broken/expired image URL.\n thumbnailSrc:\n f.url && (f.mime?.startsWith('image/') ?? false) ? f.url : undefined,\n onDownload: f.url\n ? () => window.open(f.url!, '_blank', 'noopener,noreferrer')\n : undefined,\n }))\n}\n\nfunction formatBytes(size: number): string {\n if (size < 1024) return `${size} B`\n if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`\n return `${(size / (1024 * 1024)).toFixed(1)} MB`\n}\n\n/** Strip the redundant `Attachments:\\n\\n filename.png\\n filename2.png`\n * preamble that the server appends to Note engagement bodies. We\n * already render the same files through `<TicketAttachmentsList>` with\n * proper icons + download buttons — showing the raw filename list\n * again above the chip strip is duplicate noise. The regex matches\n * ANY trailing block that starts with \"Attachments:\" (case-insensitive,\n * optional leading whitespace) and consumes everything to end-of-string,\n * so server-side wording tweaks like \"Attachments (3):\" still strip\n * cleanly. Idempotent — a body with no preamble passes through\n * untouched. */\nconst ATTACHMENTS_PREAMBLE_RE = /\\s*\\n\\s*Attachments\\b[^]*$/i\nfunction stripAttachmentsPreamble(body: string): string {\n return body.replace(ATTACHMENTS_PREAMBLE_RE, '').trim()\n}\n\nfunction ReopenAction({\n ticketRef,\n busy,\n supportSystemDown,\n onReopen,\n onActionCollapsed,\n}: {\n ticketRef: TicketRef\n busy: boolean\n supportSystemDown: boolean\n onReopen: TicketDetailDrawerProps['onReopen']\n onActionCollapsed: TicketDetailDrawerProps['onActionCollapsed']\n}) {\n const handleReopen = async () => {\n // Intentionally do NOT call `onActionCollapsed()` here. Pre-PR #1053\n // every reopen was followed by a full list refetch which removed\n // the (now-OPEN) row from a `?status=closed` view, so collapsing\n // the drawer hid the disappearance flash. After #1053+#1055 the\n // row stays in the list with the optimistic in-place status\n // update — collapsing the drawer now actively dismisses the\n // ticket the user is working on. Keep it mounted; the badge flip\n // is enough feedback. (Reported 2026-05-29.)\n void (await onReopen(ticketRef))\n }\n return (\n <div className=\"flex justify-end\">\n {/* Reopen is a secondary, reversible action — `outline` (not the filled\n accent primary) so it reads as available without dominating the\n closed-ticket view. */}\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"small\"\n onClick={() => void handleReopen()}\n disabled={busy || supportSystemDown}\n loading={busy}\n >\n Reopen\n </Button>\n </div>\n )\n}\n\n/**\n * Persistent banner above the drawer composer/actions when the most\n * recent customer reply failed with a reply-specific code (HUBSPOT_5XX\n * / 400 / 404 / UNKNOWN). The transient toast already fired at the\n * moment of failure; this banner stays until the next successful send\n * OR the user dismisses it explicitly. Wording is sourced from\n * `mapTicketActionError` so a future copy update lives in one place.\n *\n * 404_THREAD is the only terminal code in the set — the banner copy\n * reads \"open a new ticket\" and Retry would just re-fail. We still\n * render a Dismiss control instead of hiding Retry so the visual shape\n * is uniform; the parent's composer continues to function for any\n * non-thread-deletion reply path.\n */\nfunction ReplyFailureBanner({\n error,\n onDismiss,\n}: {\n error: MappedTicketActionError\n onDismiss: () => void\n}) {\n return (\n <div\n role=\"status\"\n aria-live=\"polite\"\n className=\"mb-3 flex items-start gap-3 rounded-md border border-ods-attention-red-error bg-ods-attention-red-error-secondary px-3 py-2 text-sm text-ods-attention-red-error\"\n >\n <span className=\"font-medium leading-snug\">{error.message}</span>\n <Button\n type=\"button\"\n variant=\"transparent\"\n onClick={onDismiss}\n aria-label=\"Dismiss reply failure\"\n className=\"ml-auto px-2 py-0.5 text-xs font-medium uppercase tracking-wider text-ods-attention-red-error hover:bg-ods-attention-red-error/10 border-transparent\"\n >\n Dismiss\n </Button>\n </div>\n )\n}\n\n/**\n * Compact \"Assigned to\" row at the top of the drawer. Surfaces the\n * support-side agent — name + avatar — so the customer knows who's\n * looking at their ticket. Renders \"Unassigned\" when the ticket has no\n * `hubspot_owner_id` OR when the owner couldn't be resolved against\n * the mirror (deleted between ticket update + next reconcile).\n *\n * Avatar comes from the canonical `<SquareAvatar variant=\"round\">` so\n * it picks up the initials-fallback + image-proxy behavior used\n * everywhere else in the lib (matches the dev-section message-bubble\n * avatars). No bespoke avatar markup.\n */\nfunction AssignedAgentRow({\n assignedOwner,\n}: {\n assignedOwner: TicketAssignedOwner | null\n}) {\n // Display label precedence:\n // 1. `name` from the mirror (employee match OR HubSpot's first+last)\n // 2. `email` local-part — covers HubSpot owners that exist but have\n // no name (rare but real; the ticket IS assigned and rendering\n // \"Unassigned\" would be misleading)\n // 3. \"Unassigned\" — only when the ticket has no `assigned_to` OR\n // the owner couldn't be resolved against the mirror at all\n const trimmedName = assignedOwner?.name?.trim() || null\n const emailFallback = assignedOwner?.email?.trim() || null\n const displayLabel =\n trimmedName ?? (emailFallback ? emailFallback.split('@')[0] : null)\n return (\n <div className=\"flex items-center gap-2 text-xs\">\n <span className=\"text-ods-text-secondary uppercase tracking-wider font-medium\">\n Assigned to\n </span>\n {displayLabel ? (\n <span className=\"flex items-center gap-1.5 text-ods-text-primary font-medium\">\n <SquareAvatar\n size=\"sm\"\n variant=\"round\"\n src={assignedOwner?.avatarUrl ?? undefined}\n alt={displayLabel}\n fallback={displayLabel}\n />\n {displayLabel}\n </span>\n ) : (\n <span className=\"text-ods-text-secondary italic\">Unassigned</span>\n )}\n </div>\n )\n}\n","'use client'\n\n/**\n * Fetch the conversation timeline (Note engagements + attachments) for\n * a single ticket. Powers the drawer's timeline view — separate from\n * `useTicketsList` because the engagements are expensive to fetch\n * (multi-stage HubSpot API calls) and only needed when a row is\n * expanded.\n *\n * Auth: rides `embedAuthedFetch` (same proxy creds as the chat). The\n * server-side route asserts ticket ownership via\n * `ticketBelongsToCustomer` for self-scoped sources before reading\n * notes — a customer can't enumerate another customer's notes by\n * guessing ticket ids.\n */\n\nimport { useQuery } from '@tanstack/react-query'\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch'\nimport { useChatIdentity } from '../../chat/hooks/use-chat-identity'\n\nconst LIST_ENGAGEMENTS_ENDPOINT = '/api/chat/agent/list-engagements'\n\nexport interface TicketEngagementFile {\n id: string\n name: string | null\n url: string | null\n mime: string | null\n size: number | null\n}\n\nexport interface TicketEngagement {\n id: string\n body: string | null\n authorId: string | null\n /** Whether this engagement is customer-authored (Custom Channels\n * Messages — INCOMING direction) or team-authored (Notes + future\n * OUTGOING messages). Drives avatar variant + the \"Customer\"/\"Support\n * team\" header label in the drawer's conversation thread. */\n authorRole: 'customer' | 'support'\n /** Display name. Server resolves it differently per role:\n * - `support` (Notes) → HubSpot owner id is resolved to an owner\n * email, then matched against our `profiles` table; the matched\n * employee's `full_name` is returned here. Null when the owner\n * isn't a known Flamingo employee.\n * - `customer` (Conversations messages) → null on new messages\n * (drawer renders identity.user.name LIVE for the current\n * user's own messages). Set only on legacy rows from earlier\n * migrations. */\n authorName: string | null\n /** Resolved author email — for `support` it's the HubSpot owner's\n * email; for `customer` it's the message sender. Used by the\n * drawer to cross-check \"is this me?\" against `identity.user.email`. */\n authorEmail: string | null\n /** Avatar URL. For `support`, resolved from the matched `profiles`\n * row's `avatar_url`. Null when the owner isn't a known Flamingo\n * employee. For `customer`, always null on the wire (drawer reads\n * identity.user.avatarUrl live for own messages). */\n authorAvatarUrl: string | null\n createdAt: string\n attachments: TicketEngagementFile[]\n}\n\ninterface ListEngagementsResponse {\n engagements?: TicketEngagement[]\n count?: number\n}\n\nexport interface UseTicketEngagementsReturn {\n engagements: TicketEngagement[]\n isLoading: boolean\n isFetching: boolean\n error: Error | null\n refetch: () => void\n}\n\nexport function useTicketEngagements(\n externalTicketId: string | null | undefined,\n enabled = true,\n /** Poll cadence (ms) for live conversation refresh while the drawer is\n * open. The drawer only mounts this hook when expanded, so a constant\n * here is already gated to \"drawer open\" — closing the drawer unmounts\n * the panel and the polling stops. `false`/undefined disables it (the\n * default, preserving prior fetch-once-per-open behavior for any other\n * caller). Mirrors `useTicketsList.refetchInterval`; see\n * `TICKET_LIVE_POLL_MS`. */\n refetchInterval: number | false = false,\n): UseTicketEngagementsReturn {\n const identity = useChatIdentity()\n const identityKey = identity.user?.email ?? 'anon'\n\n const queryEnabled =\n enabled &&\n identity.authTier !== 'anon' &&\n !!identity.user?.email &&\n !!externalTicketId &&\n !externalTicketId.startsWith('temp-') // optimistic placeholders have no real id yet\n\n const query = useQuery({\n queryKey: ['ticket-engagements', externalTicketId, identityKey],\n enabled: queryEnabled,\n // Caches OFF — same reasoning as `useTicketsList`. The conversation\n // timeline must reflect HubSpot truth on every drawer-open; a stale\n // window risks hiding a freshly-arrived agent reply.\n staleTime: 0,\n gcTime: 0,\n refetchOnMount: 'always',\n refetchOnWindowFocus: true,\n // Live conversation: poll while the caller opts in (drawer open). New\n // agent replies + attachments appear within one interval without a\n // manual refresh. `refetchIntervalInBackground` stays false (default)\n // so polling pauses on a hidden tab.\n refetchInterval,\n queryFn: async (): Promise<TicketEngagement[]> => {\n const response = await embedAuthedFetch(LIST_ENGAGEMENTS_ENDPOINT, {\n method: 'POST',\n body: JSON.stringify({ ticket_id: externalTicketId }),\n })\n if (!response.ok) {\n const text = await response.text().catch(() => '')\n throw new Error(`list-engagements failed: ${response.status} ${text.slice(0, 200)}`)\n }\n const body = (await response.json()) as ListEngagementsResponse\n return Array.isArray(body.engagements) ? body.engagements : []\n },\n })\n\n return {\n engagements: query.data ?? [],\n isLoading: queryEnabled && query.isLoading,\n isFetching: query.isFetching,\n error: (query.error as Error | null) ?? null,\n refetch: () => {\n void query.refetch()\n },\n }\n}\n","'use client'\n\n/**\n * `<TicketLinkedDeliveryCard />` — renders the ClickUp delivery task\n * linked to a HubSpot ticket as a single `<DeliveryRow />` (the same\n * primitive `/bug-fixes-and-enhancements` uses for its row tiles).\n *\n * Navigation is unified with the chat-inline delivery card: both go\n * through `buildDevSectionUrl('delivery', external_id)`, composed\n * server-side and shipped on `clickup.delivery_href`. The URL carries\n * `?search=<external_id>` so the landing list filters to that exact\n * task (the canonical \"deep-link to a specific delivery row\" mechanism\n * already in place for chat).\n *\n * Soft-nav happens via the env-aware `next/link` shim that the host\n * registers — back-button restores /tickets with React state intact\n * (no skeleton flash, no TanStack-Query cache loss).\n */\n\nimport { DeliveryRow } from '../shared/delivery/delivery-row'\nimport type { DeliveryItem } from '../../types/delivery'\nimport type { TicketClickupSummary } from './types'\n\nexport interface TicketLinkedDeliveryCardProps {\n clickup: TicketClickupSummary\n className?: string\n}\n\nexport function TicketLinkedDeliveryCard({\n clickup,\n className,\n}: TicketLinkedDeliveryCardProps) {\n const item: DeliveryItem = {\n id: clickup.external_id,\n title: clickup.title ?? 'Linked delivery task',\n description: clickup.description ?? '',\n status: clickup.status ?? 'unknown',\n statusColor: clickup.status_color ?? '#87909e',\n taskType: clickup.task_type ?? 'Request',\n customItemId: clickup.custom_item_id,\n listNames: clickup.list_names,\n dateOpened: clickup.date_opened ?? 0,\n dateUpdated: clickup.date_updated ?? clickup.date_opened ?? Date.now(),\n dateClosed: clickup.date_closed,\n clickupUrl: clickup.clickup_url ?? '',\n }\n\n return (\n <div\n className={`rounded-md border border-ods-border bg-ods-bg overflow-hidden ${className ?? ''}`}\n >\n <DeliveryRow\n item={item}\n href={clickup.delivery_href}\n caption=\"Linked delivery\"\n />\n </div>\n )\n}\n","'use client'\n\nimport { useCallback, useState } from 'react'\nimport { Button } from './../ui/button'\nimport { Textarea } from './../ui/textarea'\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from './../ui/alert-dialog'\nimport { ChatInput } from './../chat/chat-input'\nimport {\n ChatAttachmentAddButton,\n ChatAttachmentChipStrip,\n} from './../chat/chat-attachment-bar'\nimport { useChatAttachments } from './../chat/hooks/use-chat-attachments'\nimport type { AnyTicket } from './types'\nimport { TICKET_TEXT_MAX_CHARS } from './types'\nimport type { TicketDetailDrawerProps, TicketRef } from './ticket-detail-drawer'\n\nexport interface TicketReplyComposerProps {\n ticket: AnyTicket\n busy: boolean\n supportSystemDown: boolean\n onSendMessage: TicketDetailDrawerProps['onSendMessage']\n onClose: TicketDetailDrawerProps['onClose']\n}\n\n/**\n * Open-ticket reply composer — REUSES the exact same layout as the global\n * Ask-AI chat composer (`embeddable-chat.tsx`): the shared `<ChatInput>` with\n * the staged-file chip strip above it and the attachment `+` button in a\n * controls row BELOW the input (identical placement to the global chat), plus\n * the destructive close-confirm `AlertDialog`.\n *\n * Replaces the former `OpenActions` raw-`<Textarea>` composer. The text lives\n * inside `ChatInput`; this component owns only the attachment bag + the close\n * dialog. Send semantics:\n * - `sending={busy || hasInflightUploads}` disables the input while sending\n * OR uploading (same as the global chat); `allowEmptySend` lets an\n * attachments-only reply send once uploads finish;\n * - the typed draft + staged files survive a FAILED send: `handleSend`\n * returns `false`, so `ChatInput` keeps the text and we only `clear()` the\n * attachments on success;\n * - close does NOT collapse the drawer (no `onActionCollapsed`).\n *\n * `disabled={supportSystemDown}` is the only flag that drives the \"Connection\n * lost…\" placeholder. The `+` attach gate stays `!supportSystemDown` (same gate\n * the old composer used).\n */\nexport function TicketReplyComposer({\n ticket,\n busy,\n supportSystemDown,\n onSendMessage,\n onClose,\n}: TicketReplyComposerProps) {\n const [resolution, setResolution] = useState('')\n const [closeDialogOpen, setCloseDialogOpen] = useState(false)\n const attachments = useChatAttachments()\n\n const ticketRef: TicketRef = { id: ticket.id, external_id: ticket.external_id }\n const hasReadyFiles = attachments.readyAttachments.length > 0\n\n const handleSend = useCallback(\n async (text: string): Promise<boolean> => {\n const ref: TicketRef = { id: ticket.id, external_id: ticket.external_id }\n const ok = await onSendMessage(ref, text.trim(), attachments.readyAttachments)\n if (ok) attachments.clear()\n return ok\n },\n // Depend on the reactive projections, not the whole bag (a fresh object each\n // render). `readyAttachments` is memo-stable; `clear` is callback-stable.\n [\n onSendMessage,\n ticket.id,\n ticket.external_id,\n attachments.readyAttachments,\n attachments.clear,\n ],\n )\n\n const confirmClose = async () => {\n setCloseDialogOpen(false)\n await onClose(ticketRef, resolution.trim() || undefined)\n setResolution('')\n // Intentionally NO `onActionCollapsed()` — collapsing the drawer after a\n // close dismisses the ticket the user is working on (PR #1053). The\n // optimistic in-place status update keeps the row mounted with the new\n // badge; that is the only feedback needed.\n }\n\n const disabled = busy || supportSystemDown\n\n return (\n <div className=\"flex flex-col gap-2\">\n {/* Unified composer — mirrors the global Ask-AI chat layout\n (embeddable-chat.tsx :1160-1206): compact staged-file chip strip\n above, the shared <ChatInput> (Send icon = the PRIMARY action), then a\n quiet bottom toolbar: attachment \"+\" on the left, and a LOW-EMPHASIS\n \"Close ticket\" text button on the right. Closing is reversible (Reopen\n exists), so it is NOT styled as destructive/danger — that would\n over-signal a routine, undoable status change (UX best practice:\n reserve red for irreversible actions). */}\n <ChatAttachmentChipStrip\n attachments={attachments.attachments}\n onRemove={attachments.removeAttachment}\n disabled={disabled}\n size=\"compact\"\n />\n <ChatInput\n fullWidth\n // Focus the reply box when the drawer opens so the customer can type\n // immediately. `ChatInput`'s autoFocus uses `{ preventScroll: true }`,\n // so this does NOT scroll the page — the card's smooth scroll-to-top\n // (HelpCenterCard) wins, and the input stays focused + visible (it\n // sits within the viewport below the fixed-height feed).\n autoFocus\n placeholder=\"Type a reply…\"\n sending={busy || attachments.hasInflightUploads}\n disabled={supportSystemDown}\n allowEmptySend={hasReadyFiles}\n maxLength={TICKET_TEXT_MAX_CHARS}\n onSend={handleSend}\n />\n <div className=\"flex items-center gap-2 w-full\">\n {!supportSystemDown && (\n <ChatAttachmentAddButton\n attachmentsEnabled\n attachmentsCount={attachments.attachments.length}\n onAddFiles={attachments.addFiles}\n disabled={disabled}\n size=\"compact\"\n />\n )}\n <div className=\"flex-1 min-w-0\" />\n <Button\n type=\"button\"\n variant=\"transparent\"\n size=\"small\"\n onClick={() => setCloseDialogOpen(true)}\n disabled={disabled}\n className=\"text-ods-text-secondary hover:text-ods-text-primary\"\n >\n Close ticket\n </Button>\n </div>\n\n {/* Confirm dialog — collects an optional resolution note. Closing is\n REVERSIBLE, so the confirm action is the standard accent primary, NOT\n a red destructive button. */}\n <AlertDialog open={closeDialogOpen} onOpenChange={setCloseDialogOpen}>\n <AlertDialogContent className=\"bg-ods-card border-ods-border\">\n <AlertDialogHeader>\n <AlertDialogTitle className=\"text-ods-text-primary\">\n Close this ticket?\n </AlertDialogTitle>\n <AlertDialogDescription className=\"text-ods-text-secondary\">\n Add an optional resolution note below. You can reopen the ticket\n later if needed.\n </AlertDialogDescription>\n </AlertDialogHeader>\n <Textarea\n value={resolution}\n onChange={(e) => setResolution(e.target.value)}\n placeholder=\"Resolution (optional)\"\n rows={3}\n maxLength={TICKET_TEXT_MAX_CHARS}\n className=\"mt-2\"\n />\n <AlertDialogFooter>\n <AlertDialogCancel\n disabled={busy}\n className=\"bg-transparent border-ods-border text-ods-text-primary hover:bg-ods-border\"\n >\n Cancel\n </AlertDialogCancel>\n <AlertDialogAction\n onClick={() => void confirmClose()}\n disabled={busy}\n className=\"bg-ods-accent text-ods-text-on-accent hover:bg-ods-accent-hover\"\n >\n Close ticket\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </div>\n )\n}\n","'use client'\n\n/**\n * Customer-scoped ticket list — wraps `POST /api/chat/agent/find-ticket`.\n * The server treats `query: ''` as \"list all my tickets\" for self-scoped\n * sources, accepts an optional `status` ('open' | 'closed') filter, and\n * paginates via `page` + `pageSize` (server caps pageSize at 100). All\n * three fold into ONE mirror SELECT with `count: 'exact'` so the\n * response carries both the rows AND total count in a single round-trip.\n *\n * Auth: rides on `embedAuthedFetch`. The server self-scopes by session\n * email — there's no client-supplied scope. An anon caller receives 401;\n * we short-circuit before fetching to avoid the wasted round-trip.\n *\n * Cache: keyed by `['tickets', 'self', identity, search, status, page, pageSize]`.\n * Each filter+page combo gets its own slot, so toggling the URL never\n * blows away the existing slot — TanStack just serves the slot for the\n * new key. `useTicketActions` calls `queryClient.invalidateQueries({queryKey:['tickets']})`\n * after every mutation so all slots refresh together.\n */\n\nimport { useQuery } from '@tanstack/react-query'\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch'\nimport type { TicketData } from '../types'\n\nconst FIND_TICKET_ENDPOINT = '/api/chat/agent/find-ticket'\nconst DEFAULT_PAGE_SIZE = 20\n\ninterface FindTicketResponse {\n tickets?: TicketData[]\n count?: number\n totalCount?: number\n page?: number\n pageSize?: number\n totalPages?: number\n scope?: 'self' | 'all'\n}\n\nexport interface UseTicketsListFilters {\n /** Customer email — the identity the parent already resolved via\n * `useChatIdentity` at the gate. THIS hook does NOT call\n * `useChatIdentity` itself: `useChatIdentity` is plain\n * `useState`+`useEffect` (no shared cache), so calling it again\n * here would mount with `user = null` racing the parent's already-\n * resolved identity. On the first render of `HelpCenterListAuthed`\n * that race produced `enabled = false` for one frame, the\n * conditional fell through to `!hasResults` → EmptyState flashed\n * before the tickets fetch fired. Drilling `customerEmail` in\n * from the parent makes the loading state monotonic. */\n customerEmail: string\n /** Free-text query — server runs FTS on `search_vector`. Empty → no\n * search filter (self-scoped \"list all my tickets\"). */\n search?: string\n /** Canonical 'open' | 'closed'. Server maps to the underlying mirror\n * status column. Empty / 'all' → no status filter. */\n status?: string\n /** 1-based page number. Defaults to 1. */\n page?: number\n /** Items per page (server caps at 100). Defaults to 20. */\n pageSize?: number\n /** Poll interval (ms) for live updates — e.g. so a ticket CLOSED out-of-band\n * on HubSpot flips the status badge + open/reopen affordance without a manual\n * refresh. `false`/0/undefined disables polling. The hub passes a value only\n * while a drawer is open (mirror webhooks keep the server fresh; this surfaces\n * it client-side). TanStack pauses interval polling while the tab is hidden,\n * so there are no wasted background requests. */\n refetchInterval?: number | false\n}\n\nexport interface UseTicketsListReturn {\n tickets: TicketData[]\n isLoading: boolean\n isFetching: boolean\n error: Error | null\n refetch: () => void\n /** Wall-clock timestamp of the last successful fetch; null while\n * loading the first time. */\n lastUpdatedAt: number | null\n /** Total ticket count across all pages (NOT just the current page).\n * Drives the `<UnifiedPagination>` total-pages calculation. */\n totalCount: number\n /** 1-based current page (echoed from the server response so the URL\n * and the rendered set always agree). */\n page: number\n /** Page size in use — echoed from the server (capped at 100). */\n pageSize: number\n /** Pre-computed `Math.ceil(totalCount / pageSize)` clamped to ≥1 so\n * the pagination renders \"1 / 1\" instead of \"1 / 0\" on empty\n * result sets. */\n totalPages: number\n}\n\nexport function useTicketsList(filters: UseTicketsListFilters): UseTicketsListReturn {\n const customerEmail = filters.customerEmail\n const search = (filters.search ?? '').trim()\n const status = (filters.status ?? '').trim().toLowerCase()\n const statusFilter = status && status !== 'all' ? status : ''\n const page = Math.max(1, Math.floor(filters.page ?? 1) || 1)\n const pageSize = Math.max(1, Math.min(100, Math.floor(filters.pageSize ?? DEFAULT_PAGE_SIZE) || DEFAULT_PAGE_SIZE))\n\n // `customerEmail` is the source of truth — parent (HelpCenterList)\n // already gates on `identity.user?.email` being truthy before\n // mounting the consumer of this hook. An empty string here means\n // the consumer was mounted incorrectly (developer error); the\n // query stays disabled.\n const enabled = !!customerEmail\n\n // Identity-keyed cache: admin swaps proxy creds in /debug mid-session\n // → new key from the parent → new cache slot → no flash of the\n // previous identity's data.\n const identityKey = customerEmail || 'anon'\n\n const refetchInterval = filters.refetchInterval ?? false\n\n const query = useQuery({\n queryKey: ['tickets', 'self', identityKey, search, statusFilter, page, pageSize],\n enabled,\n // Caches OFF — every mount + every focus + every navigation triggers\n // a fresh fetch. The ticket data is the customer's own list of\n // tickets they expect to be live (sync agents reply, statuses flip,\n // new comments arrive); a stale window of any size is worse than\n // a sub-second refetch.\n staleTime: 0,\n gcTime: 0,\n refetchOnMount: 'always',\n refetchOnWindowFocus: true,\n // Live status: poll while the caller opts in (drawer open). Defaults to\n // false. `refetchIntervalInBackground` stays false (the default) so polling\n // pauses on a hidden tab — no wasted requests when the user tabs away.\n refetchInterval,\n queryFn: async (): Promise<FindTicketResponse> => {\n const body: Record<string, string | number> = {\n query: search,\n page,\n pageSize,\n }\n if (statusFilter) body.status = statusFilter\n const response = await embedAuthedFetch(FIND_TICKET_ENDPOINT, {\n method: 'POST',\n body: JSON.stringify(body),\n })\n if (!response.ok) {\n const text = await response.text().catch(() => '')\n throw new Error(`find-ticket failed: ${response.status} ${text.slice(0, 200)}`)\n }\n return (await response.json()) as FindTicketResponse\n },\n })\n\n const data = query.data\n const totalCount = data?.totalCount ?? data?.count ?? (data?.tickets?.length ?? 0)\n const echoedPage = data?.page ?? page\n const echoedPageSize = data?.pageSize ?? pageSize\n const totalPages = data?.totalPages ?? Math.max(1, Math.ceil(totalCount / echoedPageSize))\n\n return {\n tickets: data?.tickets ?? [],\n // Loading-state-truth = `data === undefined`. TanStack v5's\n // `isPending` / `isLoading` flags can be `false` in transient\n // windows where the query is enabled-but-fetch-not-yet-fired\n // OR where stale-data exists from a sibling cache slot — both\n // produced the EmptyState flash on /tickets first load. Treating\n // \"no data for THIS query slot yet\" as the universal loading\n // signal can't lie:\n // - Initial render after enabled flips: data === undefined → load\n // - Background refetch with existing data: data !== undefined → no load\n // - Filter-change refetch landing on empty results: data?.tickets===[]\n // + isFetching → bridge skeleton (the `||` branch)\n // Loading-state-truth = `data === undefined`. TanStack v5's\n // `isPending` / `isLoading` flags can be `false` in transient\n // windows where the query is enabled-but-fetch-not-yet-fired\n // OR where stale-data exists from a sibling cache slot. Treating\n // \"no data for THIS query slot yet\" as the universal loading\n // signal can't lie:\n // - Initial render after enabled flips: data === undefined → load\n // - Background refetch with existing data: data !== undefined → no load\n // - Filter-change refetch landing on empty results: data?.tickets===[]\n // + isFetching → bridge skeleton (the `||` branch)\n isLoading:\n enabled &&\n (data === undefined ||\n (query.isFetching && (data?.tickets ?? []).length === 0)),\n isFetching: query.isFetching,\n error: (query.error as Error | null) ?? null,\n refetch: () => {\n void query.refetch()\n },\n lastUpdatedAt: query.dataUpdatedAt || null,\n totalCount,\n page: echoedPage,\n pageSize: echoedPageSize,\n totalPages,\n }\n}\n","'use client'\n\n/**\n * All 5 ticket write actions funnel through one helper:\n * `executeTicketAction()`, which POSTs to `/api/chat/agent/ticket-action`\n * — a single-roundtrip endpoint that runs the SAME `ChatToolHandler.execute`\n * the chat agent's `confirm-tool` route uses (same ACL re-bind, same audit\n * row, same HubSpot REST call). The only difference is REST shape: the\n * chat path needs `propose → confirm-tool` because the LLM emits a\n * `tool_use` the user must approve in a proposal card. The /tickets form\n * has no approval step (the user already clicked \"Open ticket\" / \"Send\"),\n * so the two-step is pure overhead — we collapse to one POST + JSON.\n *\n * Reuses every existing piece:\n * - `embedAuthedFetch` for the bearer/act-as headers (same auth as chat).\n * - TanStack-Query `invalidateQueries` for refetch.\n *\n * Single-flight + serialization:\n * - Form-level `formInFlight` ref drops second submits while one is\n * in flight.\n * - Per-row `Set<ticket_id>` mutex prevents fan-out on the same row.\n * - `mutationQueue` serializes ALL mutations through a depth=1 queue\n * so 10× \"Close\" doesn't stampede the server's auth-write 60/min\n * rate-limit.\n *\n * Mirror-sync retry: on `result.mirror_synced === false`, the helper\n * schedules 3s/6s/12s refetches (30s wall-clock cap). If the placeholder\n * is still present after the last attempt, it's removed and the parent\n * surfaces an inline \"Couldn't confirm — Reload\" affordance.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch'\nimport type { ChatAttachment } from '../../chat/utils/chat-attachment-markdown'\nimport {\n type AnyTicket,\n type MappedTicketActionError,\n type OptimisticTicket,\n type TicketActionErrorCode,\n type TicketData,\n type TicketsCacheSlot,\n TOAST_COPY,\n} from '../types'\n\nconst TICKET_ACTION_ENDPOINT = '/api/chat/agent/ticket-action'\n\n/** Codes that populate the inline reply-failure banner above the drawer\n * composer. Other codes (system-down, ticket-gone, rate-limit) are\n * full-row / full-system signals covered by the toast + supportSystemDown\n * handling — surfacing them in the inline banner too would be redundant. */\nconst REPLY_BANNER_CODES: ReadonlySet<TicketActionErrorCode> = new Set<TicketActionErrorCode>([\n 'HUBSPOT_5XX',\n 'HUBSPOT_400_VALIDATION',\n 'HUBSPOT_404_THREAD',\n 'HUBSPOT_REPLY_UNKNOWN',\n])\n\n/** 3 attempts × backoff (cumulative ~21s wall-clock). After this we\n * drop the optimistic row and ask the user to reload. */\nconst MIRROR_SYNC_BACKOFF_MS = [3_000, 6_000, 12_000] as const\n\ntype ToolName = 'create_ticket' | 'update_ticket'\n\n/** Wire shape returned by the new `/api/chat/agent/ticket-action` endpoint.\n * Flat — no decision-frame wrapping — because there's no LLM approval\n * loop on the form path. Mirrors `ExecuteResult` from\n * `chat-source-strategy.ts` plus `{ ok, ticket_id }` at the top so\n * callers don't need to know about the underlying `id` field. */\ninterface TicketActionResponse {\n ok?: boolean\n ticket_id?: string\n status?: string | null\n mirror_synced?: boolean\n raw?: unknown\n error?: string\n code?: string\n}\n\ninterface SubmitTicketInput {\n subject: string\n content: string\n attachments?: ChatAttachment[]\n}\n\ninterface UpdateTicketArgs {\n ticket_id: string\n status?: 'OPEN' | 'CLOSED'\n content_addendum?: string\n resolution?: string\n attachments?: ChatAttachment[]\n}\n\nexport interface UseTicketActionsOptions {\n /** Called when the parent should prepend an optimistic placeholder\n * to the local cache. Implementer mutates the QueryClient cache\n * directly so the row appears before the server roundtrip. */\n prependOptimistic: (placeholder: OptimisticTicket) => void\n /** Called when the optimistic placeholder should be removed\n * (mirror-sync failure, or replacement after real ticket arrives). */\n removeOptimistic: (placeholderId: string) => void\n /** Called when a ticket should be removed from the cache without\n * a refetch (TICKET_NOT_FOUND). */\n removeTicketFromCache: (ticketId: string) => void\n /** Toast helper from `@flamingo-stack/openframe-frontend-core/hooks`.\n * Passed in so the lib doesn't import the toast singleton itself\n * (test-friendly). */\n toast: (input: { title: string; description?: string; variant?: 'success' | 'destructive' | 'default' }) => void\n /** Called when a 412 HUBSPOT_DISCONNECTED arrives so the parent can\n * flip its `supportSystemDown` flag. */\n onSupportSystemDown: () => void\n}\n\n/**\n * Identity bundle used by every row action: the LOCAL mirror UUID\n * (drives the React-side mutex + optimistic cache removal) AND the\n * HubSpot ticket id (`external_id` — drives the server call, the only\n * id HubSpot REST recognizes). Decoupling these is mandatory: passing\n * the UUID to HubSpot gets you a 404 \"Object not found. objectId are\n * usually numeric\"; passing the external id to the React-side mutex\n * breaks per-row disable when the cache differs.\n */\nexport interface TicketRef {\n id: string\n external_id: string\n}\n\nexport interface UseTicketActionsReturn {\n submitTicket: (input: SubmitTicketInput) => Promise<boolean>\n /** Single combined \"reply\" action — text + optional attachments\n * delivered as ONE HubSpot Note engagement (one bubble in the\n * timeline). Server creates a merged note when both are present. */\n sendMessage: (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => Promise<boolean>\n closeTicket: (ticket: TicketRef, resolution?: string) => Promise<boolean>\n reopenTicket: (ticket: TicketRef) => Promise<boolean>\n /** `true` while the form-level submit is in flight. */\n isSubmittingForm: boolean\n /** Per-row in-flight set (read-only). UI uses `isRowBusy(localId)`. */\n isRowBusy: (localId: string) => boolean\n /** Most recent reply failure for a given ticket id (`external_id`).\n * Drives the inline \"couldn't send\" banner above the composer in\n * `<TicketDetailDrawer>`. Cleared on the next successful send OR\n * via `clearReplyError(ticketId)`. */\n replyErrorFor: (ticketExternalId: string) => MappedTicketActionError | null\n /** Clear the persisted reply-failure banner for a ticket (e.g. when\n * the user dismisses it or starts a new draft). */\n clearReplyError: (ticketExternalId: string) => void\n}\n\nexport function useTicketActions(options: UseTicketActionsOptions): UseTicketActionsReturn {\n const queryClient = useQueryClient()\n const { prependOptimistic, removeOptimistic, removeTicketFromCache, toast, onSupportSystemDown } = options\n\n // Form-level single-flight uses BOTH a ref (for synchronous guarding\n // inside `submitTicket`, since React state setters are async) and a\n // state mirror (for UI disable / loading prop). Two rapid clicks in\n // the same tick would otherwise both see state==false and fan out\n // duplicate propose calls.\n const formInFlightRef = useRef(false)\n const [isSubmittingForm, setIsSubmittingForm] = useState(false)\n\n // Per-row mutex — same split: ref for synchronous has/add/delete,\n // state for the `isRowBusy` selector that drives row disable.\n const busyRowsRef = useRef<Set<string>>(new Set())\n const [busyRows, setBusyRows] = useState<Set<string>>(() => new Set())\n const setRowBusy = useCallback((id: string, busy: boolean) => {\n if (busy) busyRowsRef.current.add(id)\n else busyRowsRef.current.delete(id)\n // Mirror to state (new Set so React notices). Render-only side.\n setBusyRows(new Set(busyRowsRef.current))\n }, [])\n const isRowBusy = useCallback((id: string) => busyRows.has(id), [busyRows])\n\n // Persisted reply-failure banner state — keyed by the ticket's\n // HubSpot `external_id`. The drawer reads `replyErrorFor(externalId)`\n // and renders an inline \"couldn't send — retry\" banner above the\n // composer. Cleared automatically on the next successful send and\n // explicitly by the dismiss-X / \"Retry\" actions in the banner UI.\n // Distinct from the transient toast — the banner persists so the\n // user can locate their failed draft after dismissing the toast.\n const [replyErrorByTicket, setReplyErrorByTicket] = useState<\n Map<string, MappedTicketActionError>\n >(() => new Map())\n const setReplyError = useCallback(\n (externalId: string, mapped: MappedTicketActionError | null) => {\n setReplyErrorByTicket((prev) => {\n const next = new Map(prev)\n if (mapped) next.set(externalId, mapped)\n else next.delete(externalId)\n return next\n })\n },\n [],\n )\n const replyErrorFor = useCallback(\n (externalId: string): MappedTicketActionError | null =>\n replyErrorByTicket.get(externalId) ?? null,\n [replyErrorByTicket],\n )\n const clearReplyError = useCallback(\n (externalId: string) => setReplyError(externalId, null),\n [setReplyError],\n )\n\n // Mirror-sync watcher controllers tracked by placeholder id so we can\n // abort prior watchers when a new submit lands AND so unmount cleans\n // them up without leaking setState calls. Single source of truth for\n // active watchers — never duplicate-schedule.\n const watcherControllersRef = useRef<Map<string, AbortController>>(new Map())\n useEffect(() => {\n return () => {\n // Component unmount — abort every live watcher so setState calls\n // inside the scheduler don't fire on an unmounted component.\n for (const controller of watcherControllersRef.current.values()) {\n controller.abort()\n }\n watcherControllersRef.current.clear()\n }\n }, [])\n\n // Single-flight queue (depth=1). Subsequent calls await the prior\n // promise. Local backoff timers and SSE drains run inside the queued\n // closure so the second user click waits for the first to fully\n // resolve before issuing its own propose call. This is the\n // server-stampede defense.\n const queueRef = useRef<Promise<unknown>>(Promise.resolve())\n const enqueue = useCallback(<T,>(work: () => Promise<T>): Promise<T> => {\n const next = queueRef.current.then(work, work)\n // Swallow rejection from the prior step so a single failure doesn't\n // poison every subsequent enqueue.\n queueRef.current = next.catch(() => undefined)\n return next\n }, [])\n\n const executeTicketAction = useCallback(\n async (toolName: ToolName, args: Record<string, unknown>): Promise<TicketActionResponse> => {\n const res = await embedAuthedFetch(TICKET_ACTION_ENDPOINT, {\n method: 'POST',\n body: JSON.stringify({ tool_name: toolName, args }),\n })\n // Server returns JSON for both success and failure — no SSE on this\n // route. Parse once, branch on `res.ok`.\n const body = (await res.json().catch(() => ({}))) as TicketActionResponse\n if (!res.ok) {\n const code = resolveErrorCode(body.code, res.status)\n const message = body.error || `${toolName} failed (${res.status})`\n throw new TicketActionFailure(code, message, res)\n }\n return body\n },\n [],\n )\n\n // Mirror-sync watcher — backoff refetches when the post-create mirror\n // upsert fails. Tracked in `watcherControllersRef` so unmount aborts\n // every live scheduler and a duplicate submit for the same placeholder\n // replaces the prior controller cleanly (no orphaned schedulers).\n //\n // `expectedTicketId` is the external_id the server returned from\n // create_ticket. After each invalidation refetch lands, if any cache\n // slot now contains a ticket with that id, the placeholder is dropped\n // immediately — preventing the duplicate-row window where placeholder\n // + real row both render until the 30s cap fires.\n const watchMirrorSync = useCallback(\n (placeholderId: string, expectedTicketId: string | undefined) => {\n const prior = watcherControllersRef.current.get(placeholderId)\n if (prior) prior.abort()\n const controller = new AbortController()\n watcherControllersRef.current.set(placeholderId, controller)\n const schedule = async () => {\n try {\n for (let i = 0; i < MIRROR_SYNC_BACKOFF_MS.length; i++) {\n if (controller.signal.aborted) return\n await new Promise<void>((resolve) => {\n const t = setTimeout(resolve, MIRROR_SYNC_BACKOFF_MS[i])\n controller.signal.addEventListener(\n 'abort',\n () => {\n clearTimeout(t)\n resolve()\n },\n { once: true },\n )\n })\n if (controller.signal.aborted) return\n await queryClient.invalidateQueries({ queryKey: ['tickets'] })\n // If the real ticket landed during this refetch, drop the\n // placeholder + stop scheduling — no duplicate-row window.\n if (expectedTicketId && cacheContainsTicket(queryClient, expectedTicketId)) {\n removeOptimistic(placeholderId)\n return\n }\n }\n // Last-resort cleanup — placeholder didn't get replaced.\n if (!controller.signal.aborted) {\n removeOptimistic(placeholderId)\n toast({\n title: \"Couldn't confirm ticket\",\n description: \"If the ticket doesn't appear shortly, please contact support.\",\n variant: 'destructive',\n })\n }\n } finally {\n // Self-deregister on natural completion or abort so the map\n // doesn't accrete dead controllers across many submits.\n if (watcherControllersRef.current.get(placeholderId) === controller) {\n watcherControllersRef.current.delete(placeholderId)\n }\n }\n }\n void schedule()\n },\n [queryClient, removeOptimistic, toast],\n )\n\n // Last `surfaceError` mapping — sendMessage reads this immediately\n // after the catch returns so it can decide whether to populate the\n // inline reply banner. Cleared on every read by the consumer to\n // prevent a stale failure from leaking into the next attempt.\n const lastUpdateErrorRef = useRef<MappedTicketActionError | null>(null)\n const surfaceError = useCallback(\n (err: unknown, action: string): MappedTicketActionError => {\n const mapped = mapTicketActionError(err)\n lastUpdateErrorRef.current = mapped\n if (mapped.supportSystemDown) onSupportSystemDown()\n toast({\n title: `Could not ${action}`,\n description: mapped.message,\n variant: 'destructive',\n })\n return mapped\n },\n [toast, onSupportSystemDown],\n )\n\n const submitTicket = useCallback(\n async (input: SubmitTicketInput): Promise<boolean> => {\n // Synchronous ref guard — closes the same-tick double-click race\n // that the state-only guard couldn't (setIsSubmittingForm is async).\n if (formInFlightRef.current) return false\n formInFlightRef.current = true\n setIsSubmittingForm(true)\n const placeholderId = `temp-${cryptoRandomId()}`\n const placeholder: OptimisticTicket = {\n id: placeholderId,\n external_id: 'Pending sync…',\n subject: input.subject.trim(),\n preview: input.content.trim().slice(0, 400),\n body: input.content.trim(),\n status: 'OPEN',\n pipeline_stage_label: 'New',\n clickup_task_id: null,\n clickup: null,\n priority: null,\n customer_emails: [],\n customer_company: null,\n // Optimistic placeholder has no resolved HubSpot contact yet\n // — the real ticket row replaces this within a couple of\n // seconds via the mirror refetch. Drawer uses live chat\n // identity for own-replies during this window anyway.\n customer_name: null,\n // No assignee until the real ticket lands. Drawer renders\n // \"Unassigned\" for this brief window.\n assigned_to: null,\n assignedOwner: null,\n hubspot_updated_at: new Date().toISOString(),\n _optimistic: true,\n }\n prependOptimistic(placeholder)\n try {\n return await enqueue(async () => {\n const result = await executeTicketAction('create_ticket', {\n subject: input.subject.trim(),\n content: input.content.trim(),\n ...(input.attachments?.length ? { attachments: input.attachments } : {}),\n })\n if (result.mirror_synced === false) {\n toast(TOAST_COPY.open_mirror_pending)\n watchMirrorSync(placeholderId, result.ticket_id)\n } else {\n toast(TOAST_COPY.open_success)\n // Invalidate FIRST so the refetch lands before the\n // placeholder is removed — prevents a one-tick flash of\n // EmptyState when the prior cache was empty.\n await queryClient.invalidateQueries({ queryKey: ['tickets'] })\n removeOptimistic(placeholderId)\n }\n return true\n })\n } catch (err) {\n removeOptimistic(placeholderId)\n surfaceError(err, 'open ticket')\n return false\n } finally {\n formInFlightRef.current = false\n setIsSubmittingForm(false)\n }\n },\n [\n enqueue,\n executeTicketAction,\n prependOptimistic,\n removeOptimistic,\n queryClient,\n toast,\n watchMirrorSync,\n surfaceError,\n ],\n )\n\n const updateTicket = useCallback(\n async (\n ticket: TicketRef,\n serverArgs: Omit<UpdateTicketArgs, 'ticket_id'>,\n successCopy: { title: string; description?: string },\n action: string,\n ): Promise<boolean> => {\n // Mutex keyed on the LOCAL mirror id (stable across the React tree\n // + matches the cache row's `id` for optimistic removal). Server\n // arg uses `external_id` — HubSpot's only-numeric ticket id.\n if (busyRowsRef.current.has(ticket.id)) return false\n setRowBusy(ticket.id, true)\n try {\n return await enqueue(async () => {\n await executeTicketAction('update_ticket', {\n ...serverArgs,\n ticket_id: ticket.external_id,\n } as unknown as Record<string, unknown>)\n toast(successCopy)\n\n // OPTIMISTIC in-place row update on the tickets cache.\n //\n // Previously this code called\n // `queryClient.invalidateQueries({ queryKey: ['tickets'] })`\n // which forced a full refetch. When the user is on a\n // filtered view (e.g. ?status=open) and CLOSES a ticket from\n // its drawer, the refetched list excludes the now-closed\n // row, the parent `<HelpCenterCard>` for that row unmounts,\n // and the inline drawer dies with it — user-facing bug\n // \"close button refreshes the whole page and dismisses the\n // ticket I was working on\" (reported 2026-05-29).\n //\n // The mutation already knows what changed (status, content\n // addendum, attachments) — apply those fields in place\n // across every `['tickets']` cache slot. The row stays in\n // the list with the new badge; React doesn't reconcile away\n // the card; the drawer stays mounted and the user can\n // continue working.\n //\n // Filter-mismatch trade-off: a row that no longer matches a\n // slot's filter (e.g. CLOSED row in ?status=open cache)\n // stays visually until next manual refetch (filter change,\n // page nav, manual reload). Acceptable — the user opted into\n // the action; carrying their drawer through it is more\n // important than instantly hiding the row.\n const statusUpdate =\n (serverArgs as { status?: 'OPEN' | 'CLOSED' }).status ?? null\n if (statusUpdate) {\n // The `useTicketsList` query (in `use-tickets-list.ts`)\n // returns `FindTicketResponse` — an OBJECT shape\n // `{ tickets: TicketData[], count, page, totalPages, ... }` —\n // NOT a bare `TicketData[]`. The previous version of this\n // callback assumed an array and crashed at runtime with\n // `t.map is not a function` on every close/reopen\n // (reported 2026-05-29 in prod). Project the nested\n // tickets array, map, and reassemble the wrapper.\n queryClient.setQueriesData<TicketsCacheSlot | undefined>(\n { queryKey: ['tickets'] },\n (prev) => {\n if (!prev || !Array.isArray(prev.tickets)) return prev\n let mutated = false\n const nextTickets = prev.tickets.map((t) => {\n if (t.id !== ticket.id || t.status === statusUpdate) return t\n mutated = true\n return { ...t, status: statusUpdate }\n })\n return mutated ? { ...prev, tickets: nextTickets } : prev\n },\n )\n }\n\n // Engagements ALWAYS need to refetch — the addendum / new\n // attachment / status-change-note must land in the timeline.\n // Scoped to the engagements query only; doesn't touch the\n // list cache.\n await queryClient.invalidateQueries({ queryKey: ['ticket-engagements'] })\n return true\n })\n } catch (err) {\n const mapped = surfaceError(err, action)\n if (mapped.removeRowFromCache) {\n removeTicketFromCache(ticket.id)\n }\n return false\n } finally {\n setRowBusy(ticket.id, false)\n }\n },\n // `busyRowsRef` is read via .current — needs no dep entry. `busyRows`\n // state isn't read inside this callback (only by `isRowBusy` selector\n // outside), so listing it would churn the closure on every flag flip\n // and cascade-recreate addNote/closeTicket/etc.\n [setRowBusy, enqueue, executeTicketAction, queryClient, toast, surfaceError, removeTicketFromCache],\n )\n\n const sendMessage = useCallback(\n async (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => {\n const trimmed = text.trim()\n const hasText = trimmed.length > 0\n const hasFiles = attachments.length > 0\n if (!hasText && !hasFiles) return false\n // Clear any stale mapped error from a prior non-sendMessage action\n // (closeTicket / reopenTicket) so the post-call read only picks up\n // an error THIS sendMessage produced. Without this clear, a prior\n // close-failure's mapped error could leak into the banner via the\n // post-call `lastUpdateErrorRef.current` read.\n lastUpdateErrorRef.current = null\n const ok = await updateTicket(\n ticket,\n {\n ...(hasText ? { content_addendum: trimmed } : {}),\n ...(hasFiles ? { attachments } : {}),\n },\n TOAST_COPY.comment_success,\n 'send message',\n )\n // Banner-state coupling: SUCCESS clears any stale failure banner\n // for this ticket; FAILURE populates the banner ONLY for the\n // reply-specific code subset (HUBSPOT_5XX / 400 / 404 / UNKNOWN).\n // Other codes (TICKET_NOT_FOUND, HUBSPOT_DISCONNECTED, RATE_LIMITED)\n // are full-row / full-system signals already covered by the\n // existing toast + supportSystemDown handling — surfacing them in\n // the inline banner too would be redundant.\n if (ok) {\n clearReplyError(ticket.external_id)\n } else {\n // Line 466's `.current = null` narrows the property to literal\n // `null`. `tsc -p tsconfig.declarations.json` (declarations\n // build, distinct from the `tsc --noEmit` pre-step) doesn't\n // widen that narrowing across the `await updateTicket(...)`,\n // so the read here is typed `never`. The runtime type IS\n // `MappedTicketActionError | null` per the useRef declaration;\n // the assertion just tells TS to honor it instead of the stale\n // narrowing.\n const mapped = lastUpdateErrorRef.current as MappedTicketActionError | null\n if (mapped && REPLY_BANNER_CODES.has(mapped.code)) {\n setReplyError(ticket.external_id, mapped)\n }\n lastUpdateErrorRef.current = null\n }\n return ok\n },\n [updateTicket, clearReplyError, setReplyError],\n )\n\n const closeTicket = useCallback(\n (ticket: TicketRef, resolution?: string) =>\n updateTicket(\n ticket,\n {\n status: 'CLOSED',\n ...(resolution?.trim() ? { resolution: resolution.trim() } : {}),\n },\n TOAST_COPY.close_success,\n 'close ticket',\n ),\n [updateTicket],\n )\n\n const reopenTicket = useCallback(\n (ticket: TicketRef) =>\n updateTicket(ticket, { status: 'OPEN' }, TOAST_COPY.reopen_success, 'reopen ticket'),\n [updateTicket],\n )\n\n return useMemo<UseTicketActionsReturn>(\n () => ({\n submitTicket,\n sendMessage,\n closeTicket,\n reopenTicket,\n isSubmittingForm,\n isRowBusy,\n replyErrorFor,\n clearReplyError,\n }),\n [\n submitTicket,\n sendMessage,\n closeTicket,\n reopenTicket,\n isSubmittingForm,\n isRowBusy,\n replyErrorFor,\n clearReplyError,\n ],\n )\n}\n\n/** Exported so unit tests can construct an instance to exercise the\n * per-code branches of `mapTicketActionError`. Not part of the public\n * surface — kept out of `tickets/index.ts`. */\nexport class TicketActionFailure extends Error {\n code: TicketActionErrorCode\n response?: Response\n constructor(code: TicketActionErrorCode, message: string, response?: Response) {\n super(message)\n this.code = code\n this.response = response\n }\n}\n\n/**\n * Translate a server error envelope into user-facing copy. Exported so\n * a future chat refactor can adopt the same translation table.\n */\nexport function mapTicketActionError(err: unknown): MappedTicketActionError {\n if (err instanceof TicketActionFailure) {\n switch (err.code) {\n case 'PROPOSAL_NOT_CLAIMABLE':\n return {\n code: err.code,\n message: 'This action was already processed.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'TICKET_NOT_FOUND':\n return {\n code: err.code,\n message: 'This ticket is no longer available.',\n supportSystemDown: false,\n removeRowFromCache: true,\n }\n case 'TICKET_OWNERSHIP_DENIED':\n return {\n code: err.code,\n message: 'You can only act on tickets you opened.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'HUBSPOT_DISCONNECTED':\n return {\n code: err.code,\n message: 'Support system temporarily unavailable.',\n supportSystemDown: true,\n removeRowFromCache: false,\n }\n case 'RATE_LIMITED': {\n const retryAfterRaw = err.response?.headers.get('Retry-After')\n const retryAfterSeconds = retryAfterRaw ? parseInt(retryAfterRaw, 10) : undefined\n return {\n code: err.code,\n message: retryAfterSeconds\n ? `Too many actions. Try again in ${retryAfterSeconds}s.`\n : 'Too many actions. Try again shortly.',\n supportSystemDown: false,\n removeRowFromCache: false,\n ...(retryAfterSeconds ? { retryAfterSeconds } : {}),\n }\n }\n case 'INVALID_TOOL_ARGS':\n return {\n code: err.code,\n message: 'Your input was rejected. Please review and try again.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'HUBSPOT_5XX':\n return {\n code: err.code,\n message:\n \"We couldn't reach the support system. Your reply wasn't sent — please retry in a moment.\",\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'HUBSPOT_400_VALIDATION':\n return {\n code: err.code,\n message:\n 'Your reply was rejected. Please rephrase or remove unsupported content and try again.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'HUBSPOT_404_THREAD':\n return {\n code: err.code,\n message:\n 'This conversation is no longer accepting replies. Open a new ticket to continue.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n case 'HUBSPOT_REPLY_UNKNOWN':\n return {\n code: err.code,\n message:\n \"Your reply didn't go through. Please retry.\",\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n default:\n return {\n code: 'UNKNOWN',\n message: err.message || 'Something went wrong. Please try again.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n }\n }\n return {\n code: 'UNKNOWN',\n message: err instanceof Error ? err.message : 'Something went wrong. Please try again.',\n supportSystemDown: false,\n removeRowFromCache: false,\n }\n}\n\n/** Small id generator that doesn't require pulling in nanoid as a new\n * dep. Sufficient for client-only optimistic ids. */\nfunction cryptoRandomId(): string {\n if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {\n return crypto.randomUUID()\n }\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`\n}\n\n/** True iff any ['tickets', …] cache slot contains a ticket whose\n * HubSpot id (external_id) matches the target. Used by the mirror-sync\n * watcher to detect \"the real row just arrived\" and drop the placeholder\n * early instead of waiting for the 30s timeout. */\nfunction cacheContainsTicket(\n queryClient: ReturnType<typeof useQueryClient>,\n expectedTicketId: string,\n): boolean {\n // Cache slot is `TicketsCacheSlot` (`{ tickets, count, … }`), NOT a\n // bare `TicketData[]`. The previous code's `Array.isArray(data)` guard\n // silently fell through to `return false` on real responses — the\n // post-create watcher therefore NEVER detected the real row arriving\n // early and always waited the full timeout. Project the nested array.\n const entries = queryClient.getQueriesData<TicketsCacheSlot | undefined>({\n queryKey: ['tickets'],\n })\n for (const [, data] of entries) {\n if (\n data &&\n Array.isArray(data.tickets) &&\n data.tickets.some((t) => t.external_id === expectedTicketId)\n ) {\n return true\n }\n }\n return false\n}\n\n/** Resolve the canonical error code from the server's body + HTTP status.\n * Body code wins when present; status-derived code is the fallback so a\n * bare 429/412 (no body code) still maps cleanly through the user-facing\n * branches. */\nfunction resolveErrorCode(\n bodyCode: string | undefined,\n status: number,\n): TicketActionErrorCode {\n if (bodyCode) return bodyCode as TicketActionErrorCode\n if (status === 429) return 'RATE_LIMITED'\n if (status === 412) return 'HUBSPOT_DISCONNECTED'\n return 'UNKNOWN'\n}\n\n// Re-export so callers can narrow the type when needed.\nexport type { AnyTicket, OptimisticTicket }\n","'use client'\n\n/**\n * `<HelpCenterList />` — the full Help Center surface (the openframe\n * `/tickets` page mounts this directly; third-party embedders can mount\n * it inside their own `<PageShell>` to get the same UX).\n *\n * Mounts `<DevSectionPage sectionKey=\"tickets\">` so the page chrome\n * (hero + search + status filter + back button) is identical to\n * `/roadmap`, `/bug-fixes-and-enhancements`, `/releases`. The\n * \"Open a new ticket\" form lives in the new `preControls` slot above\n * the search/filter row.\n *\n * State ownership:\n * - URL params (`?search=`, `?status=`, `?page=`) → `DevSectionView`\n * writes search + status, `<UnifiedPagination>` writes page.\n * `useTicketsList({ search, status, page })` reads them.\n * - Optimistic placeholders → kept LOCAL (not in TanStack cache) so a\n * refetch (URL filter change) doesn't blow them away mid-flight.\n * - Expanded row → single id (only one drawer open at a time).\n * - Mutations → `useTicketActions` with prepend/remove callbacks\n * wired to the local placeholder state.\n *\n * Anon visitors get the same `DevSectionPage` chrome (hero + back\n * button) but with a single \"Sign in\" `<EmptyState>` body — no form,\n * no list, no fetch.\n */\n\nimport { useCallback, useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { useSearchParams, useRouter, usePathname } from '../../embed-shims'\nimport { Button } from '../ui'\nimport { EmptyState } from '../empty-state'\nimport { DevSectionPage } from '../shared/dev-section'\nimport { DevCardRowSkeletonList } from '../shared/dev-section/dev-card-row'\nimport { UnifiedPagination } from '../unified-pagination'\nimport { useChatIdentity } from '../chat/hooks/use-chat-identity'\nimport { toast as defaultToast } from '../../hooks/use-toast'\nimport { useTicketsList } from './hooks/use-tickets-list'\nimport { useTicketActions } from './hooks/use-ticket-actions'\nimport { HelpCenterCard } from './help-center-card'\nimport { HelpCenterCreateForm, HelpCenterCreateFormSkeleton } from './help-center-create-form'\nimport type { AnyTicket, OptimisticTicket, TicketsCacheSlot } from './types'\nimport { isOptimistic, TICKET_LIVE_POLL_MS } from './types'\n\nexport interface HelpCenterListProps {\n /** Toast override (test-friendly). Defaults to the lib's shared\n * toast singleton. */\n toast?: typeof defaultToast\n}\n\nexport function HelpCenterList({ toast = defaultToast }: HelpCenterListProps = {}) {\n const identity = useChatIdentity()\n const searchParams = useSearchParams()\n const router = useRouter()\n const pathname = usePathname()\n\n const search = searchParams.get('search') || ''\n const status = searchParams.get('status') || 'all'\n // Deep-link: `?ticket=<external_id>` auto-opens that ticket's drawer on load.\n // Same GET-param plumbing as `?search=` — read here, drilled to the authed\n // child which expands the matching row once it's in the fetched list.\n const ticketParam = searchParams.get('ticket') || ''\n // 1-based page from the URL. `<UnifiedPagination>` writes `?page=N`\n // on navigation; we read it here and re-fetch on change. Invalid\n // values fall back to page 1.\n const rawPage = Number(searchParams.get('page'))\n const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1\n\n // Identity gate FIRST — anon visitors skip every fetch + hook below.\n // `useChatIdentity` has a brief `isLoading` window on first render\n // before the identity resolves; we render the skeleton until it lands\n // to avoid flashing the sign-in EmptyState for authed users. The\n // skeleton mirrors the AUTHED layout — form placeholder above the\n // search/filter row, list-rows skeleton below — so the chrome\n // doesn't shift vertically when identity resolves and the real form\n // mounts in the `preControls` slot.\n if (identity.isLoading) {\n return (\n <DevSectionPage sectionKey=\"tickets\" preControls={<HelpCenterCreateFormSkeleton />}>\n <DevCardRowSkeletonList />\n </DevSectionPage>\n )\n }\n if (identity.authTier === 'anon' || !identity.user?.email) {\n return (\n <DevSectionPage sectionKey=\"tickets\">\n <EmptyState\n type=\"generic\"\n title=\"Sign in to manage tickets\"\n description=\"View, open, and follow up on support tickets after signing in.\"\n showCTA={false}\n />\n </DevSectionPage>\n )\n }\n\n // Identity is loaded + has an email (gated above). Resolve the\n // authoritative session display name + email HERE so the create-form\n // child doesn't have to call `useChatIdentity` itself — that hook is\n // a plain `useState`+`useEffect` (no shared cache), so a second call\n // in the child would race the first render and lock RHF's\n // `defaultValues.email` to '' for the form's lifetime.\n const sessionName =\n [identity.user?.firstName, identity.user?.lastName].filter(Boolean).join(' ').trim() ||\n identity.user?.email?.split('@')[0] ||\n 'Customer'\n const sessionEmail = identity.user!.email!\n\n return (\n <HelpCenterListAuthed\n search={search}\n status={status}\n page={page}\n ticketParam={ticketParam}\n searchParams={searchParams}\n router={router}\n pathname={pathname}\n toast={toast}\n sessionName={sessionName}\n sessionEmail={sessionEmail}\n />\n )\n}\n\ninterface AuthedProps {\n search: string\n status: string\n page: number\n /** `?ticket=<external_id>` deep-link target — auto-opens that drawer. */\n ticketParam: string\n searchParams: ReturnType<typeof useSearchParams>\n router: ReturnType<typeof useRouter>\n pathname: string\n toast: typeof defaultToast\n sessionName: string\n sessionEmail: string\n}\n\nfunction HelpCenterListAuthed({\n search,\n status,\n page,\n ticketParam,\n searchParams,\n router,\n pathname,\n toast,\n sessionName,\n sessionEmail,\n}: AuthedProps) {\n const queryClient = useQueryClient()\n const [optimisticTickets, setOptimisticTickets] = useState<OptimisticTicket[]>([])\n const [supportSystemDown, setSupportSystemDown] = useState(false)\n\n // SINGLE source of truth for \"which ticket is open\" = the `?ticket=<external_id>`\n // URL param (same model as `?search=` / `?status=`). Click-to-open and the\n // deep-link path are now ONE code path: a click writes the param, the drawer's\n // open state is DERIVED from the param. No separate `expandedTicketId` state,\n // no auto-open effect, no re-open guard — opening, closing, deep-linking, and\n // sharing a URL all flow through the same param.\n const setOpenTicket = useCallback(\n (externalId: string | null) => {\n const params = new URLSearchParams(searchParams.toString())\n if (externalId) params.set('ticket', externalId)\n else params.delete('ticket')\n const qs = params.toString()\n router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false })\n },\n [searchParams, router, pathname],\n )\n\n const { tickets, isLoading, isFetching, error, refetch, totalPages } = useTicketsList({\n // `sessionEmail` is drilled in from the parent — see the same\n // pattern + race-cause rationale documented in\n // `HelpCenterCreateForm.sessionName/sessionEmail`. Calling\n // `useChatIdentity` inside `useTicketsList` would race the\n // parent's already-resolved identity and produce an empty-state\n // flash on first render.\n customerEmail: sessionEmail,\n search,\n status,\n page,\n // Live status: while a drawer is open, poll so an out-of-band HubSpot\n // status change (e.g. agent closes the ticket) flips the badge +\n // open/reopen affordance within one interval. Idle (no drawer) → no poll.\n // `ticketParam` (the open ticket's external_id) is the open signal.\n refetchInterval: ticketParam ? TICKET_LIVE_POLL_MS : false,\n })\n\n // Open state DERIVED from the URL param. `?ticket=` carries the user-facing\n // `external_id`; map it to the internal row id the card matches on. Resolves\n // to null until the ticket lands in the fetched list (deep-link cold load) and\n // auto-collapses if the open ticket disappears (e.g. TICKET_NOT_FOUND removal).\n const expandedTicketId =\n (ticketParam && tickets.find((t) => t.external_id === ticketParam)?.id) || null\n\n // Optimistic cache management. Kept LOCAL (not in the query cache) so\n // a refetch (e.g. URL-filter change) doesn't blow away pending\n // placeholders. Merged view is `[...optimistic, ...server]` so\n // placeholders sit at the top until they're explicitly removed.\n const prependOptimistic = useCallback((placeholder: OptimisticTicket) => {\n setOptimisticTickets((prev) => [placeholder, ...prev])\n }, [])\n const removeOptimistic = useCallback((placeholderId: string) => {\n setOptimisticTickets((prev) => prev.filter((t) => t.id !== placeholderId))\n // No drawer-collapse needed: optimistic placeholders have no `external_id`,\n // so they can never be the URL-derived open ticket.\n }, [])\n const removeTicketFromCache = useCallback(\n (ticketId: string) => {\n // Every cache slot under the ['tickets'] prefix — the queryKey\n // includes search + status + page + pageSize segments so a bare\n // write would miss most slots.\n //\n // Cache slot is `TicketsCacheSlot` (`{ tickets, count, … }`), NOT\n // a bare `TicketData[]`. The previous version called `.filter()`\n // directly on the object — silently crashing only on the rare\n // TICKET_NOT_FOUND path; the prod regression that landed\n // 2026-05-29 surfaced the same shape mismatch in the\n // close/reopen optimistic-update path. Project, filter, reassemble.\n queryClient.setQueriesData<TicketsCacheSlot | undefined>(\n { queryKey: ['tickets'] },\n (prev) => {\n if (!prev || !Array.isArray(prev.tickets)) return prev\n const nextTickets = prev.tickets.filter((t) => t.id !== ticketId)\n if (nextTickets.length === prev.tickets.length) return prev\n return { ...prev, tickets: nextTickets }\n },\n )\n // The drawer auto-collapses on its own: once the ticket leaves the list,\n // the URL-derived `expandedTicketId` finds no match → null. No state to clear.\n },\n [queryClient],\n )\n\n const actions = useTicketActions({\n prependOptimistic,\n removeOptimistic,\n removeTicketFromCache,\n toast,\n onSupportSystemDown: () => setSupportSystemDown(true),\n })\n\n // Toggle = write the URL param (open) or clear it (close). The clicked card's\n // internal id maps to its `external_id` for the param; optimistic rows (no\n // external_id) aren't expandable so they short-circuit. This is the ONE open\n // path — a click, a deep link, and a shared URL are indistinguishable.\n const toggleRow = useCallback(\n (id: string) => {\n const t = tickets.find((x) => x.id === id)\n if (!t?.external_id) return\n setOpenTicket(t.external_id === ticketParam ? null : t.external_id)\n },\n [tickets, ticketParam, setOpenTicket],\n )\n\n const merged: AnyTicket[] = [...optimisticTickets, ...tickets]\n const hasActiveFilters = search !== '' || (status !== '' && status !== 'all')\n const hasResults = merged.length > 0\n\n // Form is the canonical lib `<ContactForm>` (NOT a new ticket-specific\n // form) — we hide every contact-only field, supply the customer's\n // identity from `useChatIdentity` so Zod's name+email validators\n // pass, slot a Subject `<Input>` into the new `extraTopField`\n // position, and forward submission through `actions.submitTicket`.\n // Same primitives, same wrapper styling, same visual treatment as\n // every other primary form in the app.\n const form = (\n <HelpCenterCreateForm\n actions={actions}\n sessionName={sessionName}\n sessionEmail={sessionEmail}\n supportSystemDown={supportSystemDown}\n />\n )\n\n const body = (\n <div className=\"w-full flex flex-col gap-[40px]\">\n {error && (\n <div className=\"bg-ods-card border border-ods-border rounded-[6px] p-[40px] text-center w-full flex flex-col items-center gap-3\">\n <p className=\"text-ods-error text-base\">\n Couldn&rsquo;t load your tickets. {error.message}\n </p>\n <Button type=\"button\" variant=\"accent\" onClick={() => refetch()}>\n Retry\n </Button>\n </div>\n )}\n\n {!error && (\n <div className=\"w-full\">\n {isLoading ? (\n <DevCardRowSkeletonList />\n ) : !hasResults && isFetching ? (\n // Bridge state — background refetch in flight and the\n // optimistic placeholder was just removed by the mutation\n // callback. Without this branch \"No tickets yet\" would flash\n // for ~50ms between `removeOptimistic` and the server\n // response landing.\n <DevCardRowSkeletonList rows={1} />\n ) : !hasResults ? (\n hasActiveFilters ? (\n <EmptyState\n type=\"search\"\n title=\"No tickets found\"\n description=\"No tickets match your current filters. Try clearing them or broadening your search.\"\n showCTA\n ctaText=\"Reset filters\"\n onCtaClick={() => {\n const params = new URLSearchParams(searchParams.toString())\n params.delete('search')\n params.delete('status')\n router.replace(`${pathname}?${params.toString()}`, { scroll: false })\n }}\n />\n ) : (\n <EmptyState\n type=\"generic\"\n title=\"No tickets yet\"\n description=\"Open one above to start the conversation with the support team.\"\n showCTA={false}\n />\n )\n ) : (\n // `overflow-clip` (NOT `overflow-hidden`) — both visually\n // clip the rounded corners, but `hidden` makes the element\n // a \"scroll container\" per CSSOM spec, which causes\n // `scrollIntoView` calls inside (`<HelpCenterCard>` click\n // handlers) to try scrolling THIS div (can't, overflow\n // hidden) instead of bubbling up to the window. `clip`\n // keeps the visual clip but NOT the scroll-container\n // status, so click-to-scroll actually moves the page.\n <div className=\"bg-ods-card border border-ods-border rounded-[6px] overflow-clip w-full\">\n {merged.map((ticket) => (\n <HelpCenterCard\n key={ticket.id}\n ticket={ticket}\n expanded={expandedTicketId === ticket.id}\n onToggle={toggleRow}\n busy={isOptimistic(ticket) ? false : actions.isRowBusy(ticket.id)}\n supportSystemDown={supportSystemDown}\n onSendMessage={actions.sendMessage}\n onClose={actions.closeTicket}\n onReopen={actions.reopenTicket}\n onActionCollapsed={() => setOpenTicket(null)}\n replyError={actions.replyErrorFor(ticket.external_id)}\n onClearReplyError={() => actions.clearReplyError(ticket.external_id)}\n />\n ))}\n </div>\n )}\n </div>\n )}\n\n {/* Pagination — `<UnifiedPagination>` owns the URL `?page=N`\n rewrite on click; we just feed it the server-echoed current\n page + totalPages. Hidden when there's at most one page so\n the list doesn't reserve vertical space when it isn't\n actionable. */}\n {!error && totalPages > 1 && (\n <UnifiedPagination currentPage={page} totalPages={totalPages} />\n )}\n </div>\n )\n\n return (\n <DevSectionPage sectionKey=\"tickets\" preControls={form}>\n {body}\n </DevSectionPage>\n )\n}\n","'use client'\n\n/**\n * `<HelpCenterCard />` — single ticket row inside the Help Center list.\n *\n * Visual chrome is 1:1 with the delivery list (`<DeliveryTable>`) via\n * the shared `<DevCardRowContent>` primitive. The differentiator: the\n * entire summary row is a `<button>` that toggles an expanded drawer\n * beneath it (`<TicketDetailDrawer />` — same composer + timeline +\n * close/reopen affordances as the embedded `<TicketCenter />`).\n *\n * Click target: the summary row only. Clicks inside the expanded\n * drawer (composer textarea, attachment chips, close-dialog button)\n * don't propagate up to the row's toggle handler because the drawer\n * is a SIBLING of the toggle button, not nested inside it.\n */\n\nimport { useCallback, useEffect, useRef } from 'react'\nimport { StatusBadge, type StatusBadgeProps } from '../ui'\nimport { formatRelativeTime } from '../../utils/date-utils'\nimport { scrollElementIntoView } from '../../utils/scroll-into-view'\nimport { getStatusColorScheme } from '../chat/utils/agent-status-message'\nimport { DevCardRowContent } from '../shared/dev-section/dev-card-row'\nimport {\n TicketDetailDrawer,\n type TicketDetailDrawerProps,\n} from './ticket-detail-drawer'\nimport type { AnyTicket } from './types'\nimport { isOptimistic } from './types'\n\n/** Sticky page-chrome offset, applied two ways from this ONE constant:\n *\n * 1. As `scrollMarginTop` inline style on the wrapper — so any\n * anchor-driven or `scrollIntoView()`-driven scroll (browser\n * `#hash` navigation, Tab-focus into the card) lands BELOW the\n * sticky header.\n * 2. As `headerOffset` passed to `scrollElementIntoView(...)` — for\n * the click-to-expand `window.scrollTo` path, which pre-computes\n * its target pixel and ignores CSS `scroll-margin-top`.\n *\n * Single source of truth: change 96 here and BOTH paths follow. The\n * previous code combined a `scroll-mt-24` (=96px) Tailwind class\n * with this constant — two declarations, one comment binding them,\n * drift hazard. Now there's nothing to keep in sync.\n */\nconst STICKY_HEADER_OFFSET_PX = 96\n\nexport interface HelpCenterCardProps {\n ticket: AnyTicket\n expanded: boolean\n onToggle: (id: string) => void\n busy: boolean\n supportSystemDown: boolean\n onSendMessage: TicketDetailDrawerProps['onSendMessage']\n onClose: TicketDetailDrawerProps['onClose']\n onReopen: TicketDetailDrawerProps['onReopen']\n onActionCollapsed: () => void\n /** Persisted reply-failure banner — forwarded to the drawer. Parent\n * (`HelpCenterList`) reads via `actions.replyErrorFor(external_id)`. */\n replyError?: TicketDetailDrawerProps['replyError']\n onClearReplyError?: TicketDetailDrawerProps['onClearReplyError']\n}\n\nexport function HelpCenterCard({\n ticket,\n expanded,\n onToggle,\n busy,\n supportSystemDown,\n onSendMessage,\n onClose,\n onReopen,\n onActionCollapsed,\n replyError,\n onClearReplyError,\n}: HelpCenterCardProps) {\n const optimistic = isOptimistic(ticket)\n const rawStatus = (ticket.status ?? 'OPEN').toUpperCase()\n const priority = (ticket.priority ?? '').toUpperCase()\n\n const relativeUpdated = ticket.hubspot_updated_at\n ? formatRelativeTime(ticket.hubspot_updated_at)\n : 'recently'\n\n // Use `||` not `??` so an EMPTY-STRING subject (legacy rows, partial\n // server data) falls through to the placeholder instead of rendering\n // a blank h3.\n const title = (ticket.subject || '').trim() || '(untitled)'\n const subtitle = `UPDATED ${relativeUpdated}, #${ticket.external_id || '—'}${\n ticket.pipeline_stage_label ? `, ${ticket.pipeline_stage_label}` : ''\n }`\n const description = ticket.preview ?? ticket.body ?? ''\n\n // Optimistic placeholders show as a row but aren't expandable — the\n // real external_id hasn't landed so the drawer's `useTicketEngagements`\n // would have nothing to fetch, and action targets would be undefined.\n const isExpandable = !optimistic\n const isExpanded = expanded && isExpandable\n\n const rowRef = useRef<HTMLDivElement | null>(null)\n // Click only toggles — the scroll-to-top is deferred to the effect below.\n const handleClick = useCallback(() => {\n onToggle(ticket.id)\n }, [onToggle, ticket.id])\n\n // Smooth-scroll the row to the top once the drawer has expanded — in an\n // effect keyed on `isExpanded` (NOT the click handler, which runs before\n // React commits the drawer, when the page isn't yet tall enough to scroll).\n //\n // The cancellation-proof motion lives in the shared `scrollElementIntoView`\n // helper (self-driven rAF tween, instant per-frame writes, target recomputed\n // each frame). It is immune to the browser SCROLL ANCHORING that cancelled the\n // old native `window.scrollTo({behavior:'smooth'})` on every open after the\n // first — the bug where smooth \"only worked once\" because anchoring is\n // suppressed at scrollY=0 (first open) but aborts the native smooth scroll\n // from any non-zero offset (every later open). See that util for the full\n // mechanics. One leading rAF so the expanded drawer has committed its height\n // before the first measurement; the tween then tracks the row to its resting\n // position as the page finishes growing. Cleanup cancels on collapse/unmount.\n useEffect(() => {\n if (!isExpanded) return\n const raf = requestAnimationFrame(() => {\n scrollElementIntoView(rowRef.current, {\n headerOffset: STICKY_HEADER_OFFSET_PX,\n })\n })\n return () => cancelAnimationFrame(raf)\n }, [isExpanded])\n\n const rightBadges = (\n <>\n <StatusBadge\n text={rawStatus}\n colorScheme={getStatusColorScheme(rawStatus)}\n variant=\"card\"\n className=\"border border-ods-border\"\n />\n {priority && (\n <StatusBadge\n text={priority}\n colorScheme={mapPriorityScheme(priority)}\n variant=\"card\"\n className=\"border border-ods-border\"\n />\n )}\n </>\n )\n\n return (\n <div\n ref={rowRef}\n style={{ scrollMarginTop: STICKY_HEADER_OFFSET_PX }}\n className={`border-b border-ods-border last:border-b-0 ${optimistic ? 'opacity-60' : ''}`}\n aria-busy={optimistic || undefined}\n >\n <button\n type=\"button\"\n onClick={isExpandable ? handleClick : undefined}\n disabled={!isExpandable}\n aria-expanded={isExpandable ? isExpanded : undefined}\n aria-controls={isExpanded ? `help-center-drawer-${ticket.id}` : undefined}\n className=\"w-full text-left p-[12px] md:p-[16px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-inset disabled:cursor-default\"\n >\n <DevCardRowContent\n title={title}\n subtitle={subtitle}\n description={description}\n emptyDescription=\"No description provided\"\n rightBadges={rightBadges}\n />\n </button>\n\n {isExpanded && (\n <div id={`help-center-drawer-${ticket.id}`}>\n <TicketDetailDrawer\n ticket={ticket}\n busy={busy}\n supportSystemDown={supportSystemDown}\n onSendMessage={onSendMessage}\n onClose={onClose}\n onReopen={onReopen}\n onActionCollapsed={onActionCollapsed}\n replyError={replyError}\n onClearReplyError={onClearReplyError}\n />\n </div>\n )}\n </div>\n )\n}\n\n/** Ticket priority → StatusBadge colorScheme. HIGH / URGENT → red,\n * MEDIUM → yellow, LOW / unknown → default-muted. Kept local because\n * the central `getStatusColorScheme` is keyed on workflow status, not\n * severity, and conflating them would mis-render an \"OPEN\" status as\n * a low-priority badge or vice-versa. */\nfunction mapPriorityScheme(priority: string): NonNullable<StatusBadgeProps['colorScheme']> {\n if (priority === 'HIGH' || priority === 'URGENT') return 'error'\n if (priority === 'MEDIUM') return 'warning'\n return 'default'\n}\n","'use client'\n\n/**\n * `<HelpCenterCreateForm />` — thin wrapper around the canonical\n * `<ContactForm />`. Does NOT reimplement form layout / validation /\n * primitives — it just preconfigures `<ContactForm />` for ticket\n * creation:\n *\n * - Hides every contact-only field (name + email from session,\n * companySize / referralSource / helpCategory irrelevant).\n * - Slots a Subject `<Input>` into `extraTopField` (Subject isn't\n * part of `ContactSchema`; the wrapper manages it locally and\n * reads the value back in `onCustomSubmit`).\n * - Pre-fills the hidden name / email / helpCategory from\n * `useChatIdentity` so Zod's required-field validators pass even\n * though those inputs aren't rendered.\n * - Wires `onCustomSubmit` to `actions.submitTicket(...)` so the\n * ticket lands through the same optimistic-placeholder flow the\n * rest of the Help Center uses.\n *\n * Why a wrapper instead of inlining `<ContactForm />` directly inside\n * `<HelpCenterList />`: the Subject input needs local state + error\n * state + validation that conceptually pairs with the form, not the\n * orchestrator. Keeping that tiny state machine in its own file keeps\n * `<HelpCenterList />` focused on list/state/pagination concerns.\n */\n\nimport { useState, type ChangeEvent } from 'react'\nimport { Input, Label } from '../ui'\nimport { ContactForm } from '../contact'\nimport type { UseTicketActionsReturn } from './hooks/use-ticket-actions'\n\nconst SUBJECT_MAX_CHARS = 200\n\nexport interface HelpCenterCreateFormProps {\n /** The full actions bag from `useTicketActions` — we read\n * `submitTicket` from it. Passing the whole bag (rather than just\n * the one method) keeps the wiring shape consistent with other\n * composition points (e.g. `<HelpCenterCard>` takes individual\n * action callbacks because the drawer needs four of them; the form\n * only needs one but the parent already has the bag in scope). */\n actions: UseTicketActionsReturn\n /** Authoritative session identity, resolved by the parent\n * (`HelpCenterList`) which already gates rendering on\n * `identity.isLoading === false`. Passing these in (instead of\n * calling `useChatIdentity` again here) avoids a subtle race:\n * `useChatIdentity` is a plain `useState`+`useEffect` hook (no\n * shared cache), so a second call inside this child would mount\n * with `user = null` and a stale `sessionEmail = ''`, locking\n * react-hook-form's `defaultValues.email` to an empty string for\n * the lifetime of the form — Zod then rejects the submit silently. */\n sessionName: string\n sessionEmail: string\n /** Disables every input + button when the support backend (HubSpot)\n * is down. Wired from the parent's\n * `useTicketActions.onSupportSystemDown` flag. */\n supportSystemDown?: boolean\n}\n\n/**\n * Loading placeholder that mirrors `<HelpCenterCreateForm>`'s outer\n * dimensions PIXEL-FOR-PIXEL so the page chrome doesn't shift when\n * identity resolves and the real form mounts.\n *\n * Verified against live DOM bounding-rect measurements at the lg\n * breakpoint — every section's top + height matches the real form to\n * the pixel (skeleton wrapper 556px == real wrapper 556px). Each\n * section uses the SAME flex / spacing / padding classes the real\n * form uses, so the gaps propagate identically; only the inner\n * content swaps an `<Input>` / `<Textarea>` / `<Button>` for a\n * same-sized animated `bg-ods-border` bar.\n *\n * wrapper → `p-6 md:p-8 lg:p-10` + border + rounded-3xl\n * heading area → 56px (`mb-6 md:mb-8` container, h-10 inner bar\n * + `mb-3 md:mb-4` = 40 + 16)\n * subject section → 79px (`h-[27px]` label + `mb-1` (4px) + h-12\n * input = 27 + 4 + 48)\n * message section → 127px (`h-[27px]` label + `mb-1` + h-24\n * textarea = 27 + 4 + 96)\n * attachments row → 28px (h-7 add button + helper label)\n * footer → 56px (h-12 button + `pt-2 mt-auto`)\n * between sections → `space-y-4 md:space-y-6` (16/24px)\n *\n * One non-obvious detail: the real `<ContactForm>` renders 4\n * `<input type=\"hidden\">` registrations BEFORE the visible Subject\n * section (for the hidden name/email/helpCategory/message fields).\n * Tailwind's `space-y-*` rule (`:not([hidden]) ~ :not([hidden])`)\n * counts `type=\"hidden\"` inputs as siblings, so Subject gets a 24px\n * top margin. The skeleton mirrors those 4 hidden inputs exactly so\n * the spacing rule fires identically — without them the whole stack\n * shifts up 24px on every page load.\n */\nexport function HelpCenterCreateFormSkeleton() {\n return (\n <div className=\"h-full flex flex-col border border-ods-border rounded-2xl md:rounded-3xl p-6 md:p-8 lg:p-10\">\n {/* Heading container — mirrors `mb-6 md:mb-8` + h2 with its own\n `mb-3 md:mb-4` and `text-h2` height (32px font, line-height\n ~1.25 → 40px). h-10 bar matches the rendered h2 height. */}\n <div className=\"mb-6 md:mb-8\">\n <div className=\"h-10 w-72 bg-ods-border rounded animate-pulse mb-3 md:mb-4\" />\n </div>\n\n {/* Form body — same `space-y-4 md:space-y-6` gap stack.\n IMPORTANT: the real `<ContactForm>` prepends 4\n `<input type=\"hidden\">` registrations for the hidden\n name/email/helpCategory/message fields (see contact-form.tsx).\n `space-y-*` uses `:not([hidden]) ~ :not([hidden])` — `type=\"hidden\"`\n inputs aren't excluded — so those hidden inputs ARE counted as\n siblings, and the visible Subject section gets a 24px top\n margin. The skeleton mirrors that exact structure with the\n same 4 hidden inputs so the Subject placeholder lands at the\n same Y as the real Subject input. Removing them would shift\n the whole stack up by 24px on every page load. */}\n <div className=\"flex flex-col flex-grow space-y-4 md:space-y-6\">\n <input type=\"hidden\" aria-hidden />\n <input type=\"hidden\" aria-hidden />\n <input type=\"hidden\" aria-hidden />\n <input type=\"hidden\" aria-hidden />\n {/* Subject section — `flex flex-col` matches real form. Label\n bar uses arbitrary `h-[27px]` to match the live Label\n component (18px font * 1.5 line-height = 27px) and `mb-1`\n (4px) which is Tailwind's default `mb-1`. Total section\n height: 27 + 4 + 48 (h-12 input) = 79px, identical to\n real form. */}\n <div className=\"flex flex-col\">\n <div className=\"h-[27px] w-20 bg-ods-border rounded animate-pulse mb-1\" />\n <div className=\"h-12 w-full bg-ods-border rounded animate-pulse\" />\n </div>\n\n {/* Message section — `flex flex-col flex-grow` matches real\n form (textarea fills remaining vertical space inside the\n wrapper). h-24 bar (96px) = textarea's natural rendered\n height when the wrapper has the standard list + footer\n below it. Same `h-[27px]` + `mb-1` Label pattern as Subject:\n 27 + 4 + 96 = 127px, identical to real form. */}\n <div className=\"flex flex-col flex-grow\">\n <div className=\"h-[27px] w-32 bg-ods-border rounded animate-pulse mb-1\" />\n <div className=\"h-24 w-full bg-ods-border rounded animate-pulse flex-grow\" />\n </div>\n\n {/* Attachments row — mirrors the real `flex flex-col gap-2`\n container with chip strip (empty when no files staged) +\n the h-7 add button + helper text. */}\n <div className=\"flex flex-col gap-2\">\n <div className=\"flex items-center gap-2\">\n <div className=\"h-7 w-7 bg-ods-border rounded animate-pulse shrink-0\" />\n <div className=\"h-4 w-40 bg-ods-border rounded animate-pulse\" />\n </div>\n </div>\n\n {/* Footer — same `pt-2 mt-auto` so it sticks to the bottom.\n Button bar is h-12 to match the real `<Button>` height\n (48px). */}\n <div className=\"flex flex-col md:flex-row gap-4 md:gap-6 items-center justify-end w-full pt-2 mt-auto\">\n <div className=\"h-4 w-72 bg-ods-border rounded animate-pulse\" />\n <div className=\"h-12 w-32 bg-ods-border rounded animate-pulse\" />\n </div>\n </div>\n </div>\n )\n}\n\nexport function HelpCenterCreateForm({\n actions,\n sessionName,\n sessionEmail,\n supportSystemDown = false,\n}: HelpCenterCreateFormProps) {\n const [subject, setSubject] = useState('')\n const [subjectError, setSubjectError] = useState<string | null>(null)\n\n // Subject input — slotted into `<ContactForm>`'s new `extraTopField`\n // position. Local state + error so the input behaves like the\n // schema-driven siblings.\n const subjectField = (\n <div className=\"flex flex-col\">\n <Label htmlFor=\"help-center-subject\">\n Subject<span className=\"text-ods-accent\">*</span>\n </Label>\n <Input\n id=\"help-center-subject\"\n type=\"text\"\n value={subject}\n onChange={(e: ChangeEvent<HTMLInputElement>) => {\n setSubject(e.target.value)\n if (subjectError) setSubjectError(null)\n }}\n placeholder=\"Briefly describe what's going on\"\n maxLength={SUBJECT_MAX_CHARS}\n aria-invalid={!!subjectError}\n aria-describedby={subjectError ? 'help-center-subject-error' : undefined}\n disabled={supportSystemDown}\n className=\"bg-ods-card border-ods-border text-ods-text-primary placeholder-ods-text-secondary px-3 h-12\"\n />\n {subjectError && (\n <span\n id=\"help-center-subject-error\"\n className=\"text-ods-error text-xs font-['DM_Sans'] mt-1\"\n >\n {subjectError}\n </span>\n )}\n </div>\n )\n\n return (\n <ContactForm\n title=\"Open a new ticket\"\n footerText=\"The support team typically responds within one business day.\"\n hideFields={['name', 'email', 'companySize', 'referralSource', 'helpCategory']}\n defaultValues={{\n name: sessionName,\n email: sessionEmail,\n helpCategory: 'Support Request',\n }}\n extraTopField={subjectField}\n submitLabel=\"Open ticket\"\n attachmentsEnabled\n onCustomSubmit={async (data, attachments) => {\n const trimmedSubject = subject.trim()\n if (!trimmedSubject) {\n setSubjectError('Subject is required')\n // Throw so `<ContactForm>`'s catch path doesn't `reset()` —\n // user keeps their typed message body and just adds a subject.\n throw new Error('SUBJECT_REQUIRED')\n }\n setSubjectError(null)\n const ok = await actions.submitTicket({\n subject: trimmedSubject,\n content: data.message,\n attachments: attachments.length > 0 ? attachments : undefined,\n })\n if (ok) {\n setSubject('')\n } else {\n // Same as above — keep inputs for retry. The toast is already\n // surfaced by `useTicketActions`.\n throw new Error('TICKET_SUBMIT_FAILED')\n }\n }}\n />\n )\n}\n"]}