@flamingo-stack/openframe-frontend-core 0.0.215 → 0.0.216
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.
- package/dist/chunk-2V4SACHE.js +302 -0
- package/dist/chunk-2V4SACHE.js.map +1 -0
- package/dist/chunk-572WQWIX.cjs +348 -0
- package/dist/chunk-572WQWIX.cjs.map +1 -0
- package/dist/{chunk-WT5JV2GS.cjs → chunk-5V6MSE3B.cjs} +39 -39
- package/dist/chunk-5V6MSE3B.cjs.map +1 -0
- package/dist/{chunk-WQZP3JIZ.js → chunk-CDLYRFDE.js} +1894 -1472
- package/dist/chunk-CDLYRFDE.js.map +1 -0
- package/dist/chunk-GVNQAGXB.js +232 -0
- package/dist/chunk-GVNQAGXB.js.map +1 -0
- package/dist/{chunk-P5EE2VJX.cjs → chunk-HOHDXYPR.cjs} +1 -1
- package/dist/chunk-HOHDXYPR.cjs.map +1 -0
- package/dist/chunk-IH76P5R6.cjs +232 -0
- package/dist/chunk-IH76P5R6.cjs.map +1 -0
- package/dist/{chunk-24KCAECR.cjs → chunk-JJR27M56.cjs} +3 -3
- package/dist/{chunk-24KCAECR.cjs.map → chunk-JJR27M56.cjs.map} +1 -1
- package/dist/chunk-K4DFAVSO.cjs +302 -0
- package/dist/chunk-K4DFAVSO.cjs.map +1 -0
- package/dist/{chunk-HICZPTRR.js → chunk-LCLTCCXS.js} +14 -14
- package/dist/chunk-LCLTCCXS.js.map +1 -0
- package/dist/{chunk-VFKQMAUF.cjs → chunk-OB45JHDY.cjs} +3 -3
- package/dist/{chunk-VFKQMAUF.cjs.map → chunk-OB45JHDY.cjs.map} +1 -1
- package/dist/{chunk-4XLJWX2N.js → chunk-ORJREQ2W.js} +4 -4
- package/dist/{chunk-7PCP7YQR.js → chunk-QTKU6ULP.js} +6 -6
- package/dist/{chunk-CIPO6DXK.js → chunk-QY75VKAS.js} +5 -5
- package/dist/{chunk-ZG2YY5E7.js → chunk-RFONYT63.js} +1 -1
- package/dist/chunk-RFONYT63.js.map +1 -0
- package/dist/{chunk-NGFP4RVL.cjs → chunk-SMCG2CCC.cjs} +30 -30
- package/dist/{chunk-NGFP4RVL.cjs.map → chunk-SMCG2CCC.cjs.map} +1 -1
- package/dist/{chunk-MX5MIFWA.js → chunk-UEBM4PC4.js} +5 -5
- package/dist/chunk-VC3ND5RB.js +348 -0
- package/dist/chunk-VC3ND5RB.js.map +1 -0
- package/dist/{chunk-UXZ3ZJ3M.cjs → chunk-XDPSSE4O.cjs} +4 -4
- package/dist/{chunk-UXZ3ZJ3M.cjs.map → chunk-XDPSSE4O.cjs.map} +1 -1
- package/dist/{chunk-D4MNFY67.cjs → chunk-ZGTDUPTW.cjs} +1316 -894
- package/dist/chunk-ZGTDUPTW.cjs.map +1 -0
- package/dist/components/chat/entity-cards/blog-card.d.ts +1 -1
- package/dist/components/chat/entity-cards/blog-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/case-study-card.d.ts +1 -1
- package/dist/components/chat/entity-cards/case-study-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/customer-interview-card.d.ts +1 -1
- package/dist/components/chat/entity-cards/customer-interview-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/investor-update-card.d.ts +1 -1
- package/dist/components/chat/entity-cards/investor-update-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts +1 -1
- package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/program-card.d.ts +1 -1
- package/dist/components/chat/entity-cards/program-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/use-entity-card-link.d.ts +14 -0
- package/dist/components/chat/entity-cards/use-entity-card-link.d.ts.map +1 -0
- package/dist/components/chat/entity-cards/use-entity-card-placeholder.d.ts +13 -0
- package/dist/components/chat/entity-cards/use-entity-card-placeholder.d.ts.map +1 -0
- package/dist/components/chat/index.cjs +11 -11
- package/dist/components/chat/index.js +10 -10
- package/dist/components/contact/index.cjs +12 -12
- package/dist/components/contact/index.js +11 -11
- package/dist/components/features/captions-url.d.ts +18 -0
- package/dist/components/features/captions-url.d.ts.map +1 -0
- package/dist/components/features/index.cjs +23 -11
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +2 -0
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +24 -12
- package/dist/components/features/mux-origins.cjs +10 -0
- package/dist/components/features/mux-origins.cjs.map +1 -0
- package/dist/components/features/mux-origins.d.ts +26 -0
- package/dist/components/features/mux-origins.d.ts.map +1 -0
- package/dist/components/features/mux-origins.js +7 -0
- package/dist/components/features/mux-origins.js.map +1 -0
- package/dist/components/features/notifications/index.d.ts +2 -0
- package/dist/components/features/notifications/index.d.ts.map +1 -1
- package/dist/components/features/notifications/notification-drawer.d.ts +2 -1
- package/dist/components/features/notifications/notification-drawer.d.ts.map +1 -1
- package/dist/components/features/notifications/notification-popups.d.ts +10 -0
- package/dist/components/features/notifications/notification-popups.d.ts.map +1 -0
- package/dist/components/features/notifications/notifications-context.d.ts +8 -1
- package/dist/components/features/notifications/notifications-context.d.ts.map +1 -1
- package/dist/components/features/notifications/types.d.ts +1 -0
- package/dist/components/features/notifications/types.d.ts.map +1 -1
- package/dist/components/features/use-video-warmup.d.ts +53 -0
- package/dist/components/features/use-video-warmup.d.ts.map +1 -0
- package/dist/components/icons/index.cjs +3 -3
- package/dist/components/icons/index.js +2 -2
- package/dist/components/icons-v2-generated/index.cjs +2 -2
- package/dist/components/icons-v2-generated/index.cjs.map +1 -1
- package/dist/components/icons-v2-generated/index.js +4 -4
- package/dist/components/index.cjs +132 -102
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +94 -64
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +11 -11
- package/dist/components/navigation/index.js +10 -10
- package/dist/components/onboarding-guides/build-default-href.d.ts +15 -0
- package/dist/components/onboarding-guides/build-default-href.d.ts.map +1 -0
- package/dist/components/onboarding-guides/hooks/use-onboarding-guides.d.ts +28 -0
- package/dist/components/onboarding-guides/hooks/use-onboarding-guides.d.ts.map +1 -0
- package/dist/components/onboarding-guides/index.cjs +373 -0
- package/dist/components/onboarding-guides/index.cjs.map +1 -0
- package/dist/components/onboarding-guides/index.d.ts +25 -0
- package/dist/components/onboarding-guides/index.d.ts.map +1 -0
- package/dist/components/onboarding-guides/index.js +373 -0
- package/dist/components/onboarding-guides/index.js.map +1 -0
- package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts +52 -0
- package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -0
- package/dist/components/onboarding-guides/onboarding-guides-catalog-skeleton.d.ts +17 -0
- package/dist/components/onboarding-guides/onboarding-guides-catalog-skeleton.d.ts.map +1 -0
- package/dist/components/onboarding-guides/onboarding-guides-catalog-view.d.ts +43 -0
- package/dist/components/onboarding-guides/onboarding-guides-catalog-view.d.ts.map +1 -0
- package/dist/components/shared/doc-search/doc-search-bar.d.ts +59 -0
- package/dist/components/shared/doc-search/doc-search-bar.d.ts.map +1 -0
- package/dist/components/shared/doc-search/doc-search-result-row.d.ts +18 -0
- package/dist/components/shared/doc-search/doc-search-result-row.d.ts.map +1 -0
- package/dist/components/shared/doc-search/format-relative-path.d.ts +10 -0
- package/dist/components/shared/doc-search/format-relative-path.d.ts.map +1 -0
- package/dist/components/shared/doc-search/index.d.ts +8 -0
- package/dist/components/shared/doc-search/index.d.ts.map +1 -0
- package/dist/components/shared/doc-search/map-doc-search-results.d.ts +15 -0
- package/dist/components/shared/doc-search/map-doc-search-results.d.ts.map +1 -0
- package/dist/components/shared/doc-search/resolve-search-result-action.d.ts +37 -0
- package/dist/components/shared/doc-search/resolve-search-result-action.d.ts.map +1 -0
- package/dist/components/shared/doc-search/types.d.ts +29 -0
- package/dist/components/shared/doc-search/types.d.ts.map +1 -0
- package/dist/components/shared/doc-search/use-doc-search.d.ts +46 -0
- package/dist/components/shared/doc-search/use-doc-search.d.ts.map +1 -0
- package/dist/components/tickets/help-center-card.d.ts +5 -1
- package/dist/components/tickets/help-center-card.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-ticket-actions.d.ts +8 -0
- package/dist/components/tickets/hooks/use-ticket-actions.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +316 -145
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.js +237 -66
- package/dist/components/tickets/index.js.map +1 -1
- package/dist/components/tickets/ticket-detail-drawer.d.ts +11 -2
- package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
- package/dist/components/tickets/types.d.ts +50 -1
- package/dist/components/tickets/types.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +51 -51
- package/dist/components/ui/file-manager/index.cjs.map +1 -1
- package/dist/components/ui/file-manager/index.js +2 -2
- package/dist/components/ui/filter-pill-row.d.ts +20 -0
- package/dist/components/ui/filter-pill-row.d.ts.map +1 -0
- package/dist/components/ui/index.cjs +16 -14
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +21 -19
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
- package/dist/contexts/chat-runtime-context.d.ts +42 -0
- package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
- package/dist/contexts/index.cjs +2 -2
- package/dist/contexts/index.js +1 -1
- package/dist/embed-shims/index.cjs +3 -3
- package/dist/embed-shims/index.cjs.map +1 -1
- package/dist/embed-shims/index.js +5 -5
- package/dist/hooks/index.cjs +6 -6
- package/dist/hooks/index.js +5 -5
- package/dist/index.cjs +28 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +59 -45
- package/dist/utils/dev-sections/openframe-dev-sections.d.ts +2 -2
- package/dist/utils/dev-sections/openframe-dev-sections.d.ts.map +1 -1
- package/dist/utils/index.cjs +11 -5
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js +11 -5
- package/dist/utils/index.js.map +1 -1
- package/package.json +13 -1
- package/src/components/chat/entity-cards/blog-card.tsx +17 -5
- package/src/components/chat/entity-cards/case-study-card.tsx +23 -1
- package/src/components/chat/entity-cards/customer-interview-card.tsx +23 -1
- package/src/components/chat/entity-cards/dispatch.tsx +21 -0
- package/src/components/chat/entity-cards/investor-update-card.tsx +23 -1
- package/src/components/chat/entity-cards/onboarding-guide-card.tsx +30 -4
- package/src/components/chat/entity-cards/program-card.tsx +17 -3
- package/src/components/chat/entity-cards/use-entity-card-link.ts +66 -0
- package/src/components/chat/entity-cards/use-entity-card-placeholder.ts +50 -0
- package/src/components/features/captions-url.ts +25 -0
- package/src/components/features/index.ts +2 -0
- package/src/components/features/mux-origins.ts +27 -0
- package/src/components/features/notifications/index.ts +2 -0
- package/src/components/features/notifications/notification-drawer.tsx +100 -16
- package/src/components/features/notifications/notification-popups.tsx +105 -0
- package/src/components/features/notifications/notifications-context.tsx +16 -0
- package/src/components/features/notifications/types.ts +1 -0
- package/src/components/features/use-video-warmup.ts +176 -0
- package/src/components/index.ts +5 -0
- package/src/components/onboarding-guides/build-default-href.ts +16 -0
- package/src/components/onboarding-guides/hooks/use-onboarding-guides.ts +90 -0
- package/src/components/onboarding-guides/index.ts +39 -0
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +215 -0
- package/src/components/onboarding-guides/onboarding-guides-catalog-skeleton.tsx +62 -0
- package/src/components/onboarding-guides/onboarding-guides-catalog-view.tsx +230 -0
- package/src/components/shared/doc-search/doc-search-bar.tsx +100 -0
- package/src/components/shared/doc-search/doc-search-result-row.tsx +73 -0
- package/src/components/shared/doc-search/format-relative-path.ts +17 -0
- package/src/components/shared/doc-search/index.ts +24 -0
- package/src/components/shared/doc-search/map-doc-search-results.ts +113 -0
- package/src/components/shared/doc-search/resolve-search-result-action.ts +68 -0
- package/src/components/shared/doc-search/types.ts +28 -0
- package/src/components/shared/doc-search/use-doc-search.ts +263 -0
- package/src/components/tickets/help-center-card.tsx +8 -0
- package/src/components/tickets/help-center-list.tsx +17 -3
- package/src/components/tickets/hooks/use-ticket-actions.ts +210 -14
- package/src/components/tickets/ticket-detail-drawer.tsx +145 -5
- package/src/components/tickets/types.ts +55 -0
- package/src/components/ui/filter-pill-row.tsx +72 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/simple-markdown-renderer.tsx +24 -1
- package/src/components/ui/toaster.tsx +3 -3
- package/src/contexts/chat-runtime-context.tsx +41 -0
- package/src/stories/NotificationDrawer.stories.tsx +18 -2
- package/src/utils/dev-sections/openframe-dev-sections.ts +12 -5
- package/dist/chunk-2G3NXF6J.cjs +0 -521
- package/dist/chunk-2G3NXF6J.cjs.map +0 -1
- package/dist/chunk-D4MNFY67.cjs.map +0 -1
- package/dist/chunk-HICZPTRR.js.map +0 -1
- package/dist/chunk-P5EE2VJX.cjs.map +0 -1
- package/dist/chunk-R6MLPU4A.js +0 -521
- package/dist/chunk-R6MLPU4A.js.map +0 -1
- package/dist/chunk-WQZP3JIZ.js.map +0 -1
- package/dist/chunk-WT5JV2GS.cjs.map +0 -1
- package/dist/chunk-ZG2YY5E7.js.map +0 -1
- /package/dist/{chunk-4XLJWX2N.js.map → chunk-ORJREQ2W.js.map} +0 -0
- /package/dist/{chunk-7PCP7YQR.js.map → chunk-QTKU6ULP.js.map} +0 -0
- /package/dist/{chunk-CIPO6DXK.js.map → chunk-QY75VKAS.js.map} +0 -0
- /package/dist/{chunk-MX5MIFWA.js.map → chunk-UEBM4PC4.js.map} +0 -0
|
@@ -39,11 +39,23 @@ import {
|
|
|
39
39
|
type OptimisticTicket,
|
|
40
40
|
type TicketActionErrorCode,
|
|
41
41
|
type TicketData,
|
|
42
|
+
type TicketsCacheSlot,
|
|
42
43
|
TOAST_COPY,
|
|
43
44
|
} from '../types'
|
|
44
45
|
|
|
45
46
|
const TICKET_ACTION_ENDPOINT = '/api/chat/agent/ticket-action'
|
|
46
47
|
|
|
48
|
+
/** Codes that populate the inline reply-failure banner above the drawer
|
|
49
|
+
* composer. Other codes (system-down, ticket-gone, rate-limit) are
|
|
50
|
+
* full-row / full-system signals covered by the toast + supportSystemDown
|
|
51
|
+
* handling — surfacing them in the inline banner too would be redundant. */
|
|
52
|
+
const REPLY_BANNER_CODES: ReadonlySet<TicketActionErrorCode> = new Set<TicketActionErrorCode>([
|
|
53
|
+
'HUBSPOT_5XX',
|
|
54
|
+
'HUBSPOT_400_VALIDATION',
|
|
55
|
+
'HUBSPOT_404_THREAD',
|
|
56
|
+
'HUBSPOT_REPLY_UNKNOWN',
|
|
57
|
+
])
|
|
58
|
+
|
|
47
59
|
/** 3 attempts × backoff (cumulative ~21s wall-clock). After this we
|
|
48
60
|
* drop the optimistic row and ask the user to reload. */
|
|
49
61
|
const MIRROR_SYNC_BACKOFF_MS = [3_000, 6_000, 12_000] as const
|
|
@@ -125,6 +137,14 @@ export interface UseTicketActionsReturn {
|
|
|
125
137
|
isSubmittingForm: boolean
|
|
126
138
|
/** Per-row in-flight set (read-only). UI uses `isRowBusy(localId)`. */
|
|
127
139
|
isRowBusy: (localId: string) => boolean
|
|
140
|
+
/** Most recent reply failure for a given ticket id (`external_id`).
|
|
141
|
+
* Drives the inline "couldn't send" banner above the composer in
|
|
142
|
+
* `<TicketDetailDrawer>`. Cleared on the next successful send OR
|
|
143
|
+
* via `clearReplyError(ticketId)`. */
|
|
144
|
+
replyErrorFor: (ticketExternalId: string) => MappedTicketActionError | null
|
|
145
|
+
/** Clear the persisted reply-failure banner for a ticket (e.g. when
|
|
146
|
+
* the user dismisses it or starts a new draft). */
|
|
147
|
+
clearReplyError: (ticketExternalId: string) => void
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
export function useTicketActions(options: UseTicketActionsOptions): UseTicketActionsReturn {
|
|
@@ -151,6 +171,37 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
151
171
|
}, [])
|
|
152
172
|
const isRowBusy = useCallback((id: string) => busyRows.has(id), [busyRows])
|
|
153
173
|
|
|
174
|
+
// Persisted reply-failure banner state — keyed by the ticket's
|
|
175
|
+
// HubSpot `external_id`. The drawer reads `replyErrorFor(externalId)`
|
|
176
|
+
// and renders an inline "couldn't send — retry" banner above the
|
|
177
|
+
// composer. Cleared automatically on the next successful send and
|
|
178
|
+
// explicitly by the dismiss-X / "Retry" actions in the banner UI.
|
|
179
|
+
// Distinct from the transient toast — the banner persists so the
|
|
180
|
+
// user can locate their failed draft after dismissing the toast.
|
|
181
|
+
const [replyErrorByTicket, setReplyErrorByTicket] = useState<
|
|
182
|
+
Map<string, MappedTicketActionError>
|
|
183
|
+
>(() => new Map())
|
|
184
|
+
const setReplyError = useCallback(
|
|
185
|
+
(externalId: string, mapped: MappedTicketActionError | null) => {
|
|
186
|
+
setReplyErrorByTicket((prev) => {
|
|
187
|
+
const next = new Map(prev)
|
|
188
|
+
if (mapped) next.set(externalId, mapped)
|
|
189
|
+
else next.delete(externalId)
|
|
190
|
+
return next
|
|
191
|
+
})
|
|
192
|
+
},
|
|
193
|
+
[],
|
|
194
|
+
)
|
|
195
|
+
const replyErrorFor = useCallback(
|
|
196
|
+
(externalId: string): MappedTicketActionError | null =>
|
|
197
|
+
replyErrorByTicket.get(externalId) ?? null,
|
|
198
|
+
[replyErrorByTicket],
|
|
199
|
+
)
|
|
200
|
+
const clearReplyError = useCallback(
|
|
201
|
+
(externalId: string) => setReplyError(externalId, null),
|
|
202
|
+
[setReplyError],
|
|
203
|
+
)
|
|
204
|
+
|
|
154
205
|
// Mirror-sync watcher controllers tracked by placeholder id so we can
|
|
155
206
|
// abort prior watchers when a new submit lands AND so unmount cleans
|
|
156
207
|
// them up without leaking setState calls. Single source of truth for
|
|
@@ -262,9 +313,15 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
262
313
|
[queryClient, removeOptimistic, toast],
|
|
263
314
|
)
|
|
264
315
|
|
|
316
|
+
// Last `surfaceError` mapping — sendMessage reads this immediately
|
|
317
|
+
// after the catch returns so it can decide whether to populate the
|
|
318
|
+
// inline reply banner. Cleared on every read by the consumer to
|
|
319
|
+
// prevent a stale failure from leaking into the next attempt.
|
|
320
|
+
const lastUpdateErrorRef = useRef<MappedTicketActionError | null>(null)
|
|
265
321
|
const surfaceError = useCallback(
|
|
266
322
|
(err: unknown, action: string): MappedTicketActionError => {
|
|
267
323
|
const mapped = mapTicketActionError(err)
|
|
324
|
+
lastUpdateErrorRef.current = mapped
|
|
268
325
|
if (mapped.supportSystemDown) onSupportSystemDown()
|
|
269
326
|
toast({
|
|
270
327
|
title: `Could not ${action}`,
|
|
@@ -302,6 +359,10 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
302
359
|
// seconds via the mirror refetch. Drawer uses live chat
|
|
303
360
|
// identity for own-replies during this window anyway.
|
|
304
361
|
customer_name: null,
|
|
362
|
+
// No assignee until the real ticket lands. Drawer renders
|
|
363
|
+
// "Unassigned" for this brief window.
|
|
364
|
+
assigned_to: null,
|
|
365
|
+
assignedOwner: null,
|
|
305
366
|
hubspot_updated_at: new Date().toISOString(),
|
|
306
367
|
_optimistic: true,
|
|
307
368
|
}
|
|
@@ -366,13 +427,63 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
366
427
|
ticket_id: ticket.external_id,
|
|
367
428
|
} as unknown as Record<string, unknown>)
|
|
368
429
|
toast(successCopy)
|
|
369
|
-
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
430
|
+
|
|
431
|
+
// OPTIMISTIC in-place row update on the tickets cache.
|
|
432
|
+
//
|
|
433
|
+
// Previously this code called
|
|
434
|
+
// `queryClient.invalidateQueries({ queryKey: ['tickets'] })`
|
|
435
|
+
// which forced a full refetch. When the user is on a
|
|
436
|
+
// filtered view (e.g. ?status=open) and CLOSES a ticket from
|
|
437
|
+
// its drawer, the refetched list excludes the now-closed
|
|
438
|
+
// row, the parent `<HelpCenterCard>` for that row unmounts,
|
|
439
|
+
// and the inline drawer dies with it — user-facing bug
|
|
440
|
+
// "close button refreshes the whole page and dismisses the
|
|
441
|
+
// ticket I was working on" (reported 2026-05-29).
|
|
442
|
+
//
|
|
443
|
+
// The mutation already knows what changed (status, content
|
|
444
|
+
// addendum, attachments) — apply those fields in place
|
|
445
|
+
// across every `['tickets']` cache slot. The row stays in
|
|
446
|
+
// the list with the new badge; React doesn't reconcile away
|
|
447
|
+
// the card; the drawer stays mounted and the user can
|
|
448
|
+
// continue working.
|
|
449
|
+
//
|
|
450
|
+
// Filter-mismatch trade-off: a row that no longer matches a
|
|
451
|
+
// slot's filter (e.g. CLOSED row in ?status=open cache)
|
|
452
|
+
// stays visually until next manual refetch (filter change,
|
|
453
|
+
// page nav, manual reload). Acceptable — the user opted into
|
|
454
|
+
// the action; carrying their drawer through it is more
|
|
455
|
+
// important than instantly hiding the row.
|
|
456
|
+
const statusUpdate =
|
|
457
|
+
(serverArgs as { status?: 'OPEN' | 'CLOSED' }).status ?? null
|
|
458
|
+
if (statusUpdate) {
|
|
459
|
+
// The `useTicketsList` query (in `use-tickets-list.ts`)
|
|
460
|
+
// returns `FindTicketResponse` — an OBJECT shape
|
|
461
|
+
// `{ tickets: TicketData[], count, page, totalPages, ... }` —
|
|
462
|
+
// NOT a bare `TicketData[]`. The previous version of this
|
|
463
|
+
// callback assumed an array and crashed at runtime with
|
|
464
|
+
// `t.map is not a function` on every close/reopen
|
|
465
|
+
// (reported 2026-05-29 in prod). Project the nested
|
|
466
|
+
// tickets array, map, and reassemble the wrapper.
|
|
467
|
+
queryClient.setQueriesData<TicketsCacheSlot | undefined>(
|
|
468
|
+
{ queryKey: ['tickets'] },
|
|
469
|
+
(prev) => {
|
|
470
|
+
if (!prev || !Array.isArray(prev.tickets)) return prev
|
|
471
|
+
let mutated = false
|
|
472
|
+
const nextTickets = prev.tickets.map((t) => {
|
|
473
|
+
if (t.id !== ticket.id || t.status === statusUpdate) return t
|
|
474
|
+
mutated = true
|
|
475
|
+
return { ...t, status: statusUpdate }
|
|
476
|
+
})
|
|
477
|
+
return mutated ? { ...prev, tickets: nextTickets } : prev
|
|
478
|
+
},
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Engagements ALWAYS need to refetch — the addendum / new
|
|
483
|
+
// attachment / status-change-note must land in the timeline.
|
|
484
|
+
// Scoped to the engagements query only; doesn't touch the
|
|
485
|
+
// list cache.
|
|
486
|
+
await queryClient.invalidateQueries({ queryKey: ['ticket-engagements'] })
|
|
376
487
|
return true
|
|
377
488
|
})
|
|
378
489
|
} catch (err) {
|
|
@@ -393,12 +504,18 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
393
504
|
)
|
|
394
505
|
|
|
395
506
|
const sendMessage = useCallback(
|
|
396
|
-
(ticket: TicketRef, text: string, attachments: ChatAttachment[]) => {
|
|
507
|
+
async (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => {
|
|
397
508
|
const trimmed = text.trim()
|
|
398
509
|
const hasText = trimmed.length > 0
|
|
399
510
|
const hasFiles = attachments.length > 0
|
|
400
|
-
if (!hasText && !hasFiles) return
|
|
401
|
-
|
|
511
|
+
if (!hasText && !hasFiles) return false
|
|
512
|
+
// Clear any stale mapped error from a prior non-sendMessage action
|
|
513
|
+
// (closeTicket / reopenTicket) so the post-call read only picks up
|
|
514
|
+
// an error THIS sendMessage produced. Without this clear, a prior
|
|
515
|
+
// close-failure's mapped error could leak into the banner via the
|
|
516
|
+
// post-call `lastUpdateErrorRef.current` read.
|
|
517
|
+
lastUpdateErrorRef.current = null
|
|
518
|
+
const ok = await updateTicket(
|
|
402
519
|
ticket,
|
|
403
520
|
{
|
|
404
521
|
...(hasText ? { content_addendum: trimmed } : {}),
|
|
@@ -407,8 +524,33 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
407
524
|
TOAST_COPY.comment_success,
|
|
408
525
|
'send message',
|
|
409
526
|
)
|
|
527
|
+
// Banner-state coupling: SUCCESS clears any stale failure banner
|
|
528
|
+
// for this ticket; FAILURE populates the banner ONLY for the
|
|
529
|
+
// reply-specific code subset (HUBSPOT_5XX / 400 / 404 / UNKNOWN).
|
|
530
|
+
// Other codes (TICKET_NOT_FOUND, HUBSPOT_DISCONNECTED, RATE_LIMITED)
|
|
531
|
+
// are full-row / full-system signals already covered by the
|
|
532
|
+
// existing toast + supportSystemDown handling — surfacing them in
|
|
533
|
+
// the inline banner too would be redundant.
|
|
534
|
+
if (ok) {
|
|
535
|
+
clearReplyError(ticket.external_id)
|
|
536
|
+
} else {
|
|
537
|
+
// Line 466's `.current = null` narrows the property to literal
|
|
538
|
+
// `null`. `tsc -p tsconfig.declarations.json` (declarations
|
|
539
|
+
// build, distinct from the `tsc --noEmit` pre-step) doesn't
|
|
540
|
+
// widen that narrowing across the `await updateTicket(...)`,
|
|
541
|
+
// so the read here is typed `never`. The runtime type IS
|
|
542
|
+
// `MappedTicketActionError | null` per the useRef declaration;
|
|
543
|
+
// the assertion just tells TS to honor it instead of the stale
|
|
544
|
+
// narrowing.
|
|
545
|
+
const mapped = lastUpdateErrorRef.current as MappedTicketActionError | null
|
|
546
|
+
if (mapped && REPLY_BANNER_CODES.has(mapped.code)) {
|
|
547
|
+
setReplyError(ticket.external_id, mapped)
|
|
548
|
+
}
|
|
549
|
+
lastUpdateErrorRef.current = null
|
|
550
|
+
}
|
|
551
|
+
return ok
|
|
410
552
|
},
|
|
411
|
-
[updateTicket],
|
|
553
|
+
[updateTicket, clearReplyError, setReplyError],
|
|
412
554
|
)
|
|
413
555
|
|
|
414
556
|
const closeTicket = useCallback(
|
|
@@ -439,8 +581,19 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
439
581
|
reopenTicket,
|
|
440
582
|
isSubmittingForm,
|
|
441
583
|
isRowBusy,
|
|
584
|
+
replyErrorFor,
|
|
585
|
+
clearReplyError,
|
|
442
586
|
}),
|
|
443
|
-
[
|
|
587
|
+
[
|
|
588
|
+
submitTicket,
|
|
589
|
+
sendMessage,
|
|
590
|
+
closeTicket,
|
|
591
|
+
reopenTicket,
|
|
592
|
+
isSubmittingForm,
|
|
593
|
+
isRowBusy,
|
|
594
|
+
replyErrorFor,
|
|
595
|
+
clearReplyError,
|
|
596
|
+
],
|
|
444
597
|
)
|
|
445
598
|
}
|
|
446
599
|
|
|
@@ -512,6 +665,38 @@ export function mapTicketActionError(err: unknown): MappedTicketActionError {
|
|
|
512
665
|
supportSystemDown: false,
|
|
513
666
|
removeRowFromCache: false,
|
|
514
667
|
}
|
|
668
|
+
case 'HUBSPOT_5XX':
|
|
669
|
+
return {
|
|
670
|
+
code: err.code,
|
|
671
|
+
message:
|
|
672
|
+
"We couldn't reach the support system. Your reply wasn't sent — please retry in a moment.",
|
|
673
|
+
supportSystemDown: false,
|
|
674
|
+
removeRowFromCache: false,
|
|
675
|
+
}
|
|
676
|
+
case 'HUBSPOT_400_VALIDATION':
|
|
677
|
+
return {
|
|
678
|
+
code: err.code,
|
|
679
|
+
message:
|
|
680
|
+
'Your reply was rejected. Please rephrase or remove unsupported content and try again.',
|
|
681
|
+
supportSystemDown: false,
|
|
682
|
+
removeRowFromCache: false,
|
|
683
|
+
}
|
|
684
|
+
case 'HUBSPOT_404_THREAD':
|
|
685
|
+
return {
|
|
686
|
+
code: err.code,
|
|
687
|
+
message:
|
|
688
|
+
'This conversation is no longer accepting replies. Open a new ticket to continue.',
|
|
689
|
+
supportSystemDown: false,
|
|
690
|
+
removeRowFromCache: false,
|
|
691
|
+
}
|
|
692
|
+
case 'HUBSPOT_REPLY_UNKNOWN':
|
|
693
|
+
return {
|
|
694
|
+
code: err.code,
|
|
695
|
+
message:
|
|
696
|
+
"Your reply didn't go through. Please retry.",
|
|
697
|
+
supportSystemDown: false,
|
|
698
|
+
removeRowFromCache: false,
|
|
699
|
+
}
|
|
515
700
|
default:
|
|
516
701
|
return {
|
|
517
702
|
code: 'UNKNOWN',
|
|
@@ -546,9 +731,20 @@ function cacheContainsTicket(
|
|
|
546
731
|
queryClient: ReturnType<typeof useQueryClient>,
|
|
547
732
|
expectedTicketId: string,
|
|
548
733
|
): boolean {
|
|
549
|
-
|
|
734
|
+
// Cache slot is `TicketsCacheSlot` (`{ tickets, count, … }`), NOT a
|
|
735
|
+
// bare `TicketData[]`. The previous code's `Array.isArray(data)` guard
|
|
736
|
+
// silently fell through to `return false` on real responses — the
|
|
737
|
+
// post-create watcher therefore NEVER detected the real row arriving
|
|
738
|
+
// early and always waited the full timeout. Project the nested array.
|
|
739
|
+
const entries = queryClient.getQueriesData<TicketsCacheSlot | undefined>({
|
|
740
|
+
queryKey: ['tickets'],
|
|
741
|
+
})
|
|
550
742
|
for (const [, data] of entries) {
|
|
551
|
-
if (
|
|
743
|
+
if (
|
|
744
|
+
data &&
|
|
745
|
+
Array.isArray(data.tickets) &&
|
|
746
|
+
data.tickets.some((t) => t.external_id === expectedTicketId)
|
|
747
|
+
) {
|
|
552
748
|
return true
|
|
553
749
|
}
|
|
554
750
|
}
|
|
@@ -46,12 +46,17 @@ import {
|
|
|
46
46
|
ConversationCardRowSkeletonList,
|
|
47
47
|
} from './../shared/dev-section/dev-card-row'
|
|
48
48
|
import type { TicketAttachment } from './../ui/ticket-attachments-list'
|
|
49
|
+
import { SquareAvatar } from './../ui/square-avatar'
|
|
49
50
|
import { useTicketEngagements } from './hooks/use-ticket-engagements'
|
|
50
51
|
import type {
|
|
51
52
|
TicketEngagementFile,
|
|
52
53
|
} from './hooks/use-ticket-engagements'
|
|
53
54
|
import { TicketLinkedDeliveryCard } from './ticket-linked-delivery-card'
|
|
54
|
-
import type {
|
|
55
|
+
import type {
|
|
56
|
+
AnyTicket,
|
|
57
|
+
TicketAssignedOwner,
|
|
58
|
+
MappedTicketActionError,
|
|
59
|
+
} from './types'
|
|
55
60
|
import { isOptimistic, TICKET_TEXT_MAX_CHARS } from './types'
|
|
56
61
|
|
|
57
62
|
/** Identity bundle threaded through the action callbacks: local mirror
|
|
@@ -76,6 +81,15 @@ export interface TicketDetailDrawerProps {
|
|
|
76
81
|
/** Called after a successful close/reopen so the parent can collapse
|
|
77
82
|
* the drawer (status flipped — current action set is now stale). */
|
|
78
83
|
onActionCollapsed: () => void
|
|
84
|
+
/** Persisted reply-failure surface — when non-null the drawer renders
|
|
85
|
+
* an inline banner above the composer with the mapped copy + a
|
|
86
|
+
* dismiss control. Distinct from the transient toast; the banner
|
|
87
|
+
* stays visible so the customer can locate the failed draft after
|
|
88
|
+
* the toast disappears. Cleared on the next successful send. */
|
|
89
|
+
replyError?: MappedTicketActionError | null
|
|
90
|
+
/** Dismiss-X handler for the banner. Parent calls
|
|
91
|
+
* `actions.clearReplyError(ticket.external_id)`. */
|
|
92
|
+
onClearReplyError?: () => void
|
|
79
93
|
}
|
|
80
94
|
|
|
81
95
|
export function TicketDetailDrawer({
|
|
@@ -86,10 +100,19 @@ export function TicketDetailDrawer({
|
|
|
86
100
|
onClose,
|
|
87
101
|
onReopen,
|
|
88
102
|
onActionCollapsed,
|
|
103
|
+
replyError,
|
|
104
|
+
onClearReplyError,
|
|
89
105
|
}: TicketDetailDrawerProps) {
|
|
90
106
|
const isClosed = (ticket.status ?? '').toUpperCase() === 'CLOSED'
|
|
91
107
|
return (
|
|
92
108
|
<div className="bg-ods-card border-t border-ods-border px-4 py-4 flex flex-col gap-4">
|
|
109
|
+
{/* Assignee header — surfaces who's looking at this ticket on the
|
|
110
|
+
support side. Populated server-side via `attachOwnerProfiles`;
|
|
111
|
+
falls back to "Unassigned" when no agent is assigned OR when
|
|
112
|
+
the owner couldn't be resolved (deleted between ticket update
|
|
113
|
+
+ next owners reconcile). */}
|
|
114
|
+
<AssignedAgentRow assignedOwner={ticket.assignedOwner} />
|
|
115
|
+
|
|
93
116
|
{/* Linked ClickUp delivery — rendered only when the server's
|
|
94
117
|
`attachClickupTasks` step populated `ticket.clickup`. Customer
|
|
95
118
|
tickets with no linked task skip this entirely. The card itself
|
|
@@ -107,6 +130,19 @@ export function TicketDetailDrawer({
|
|
|
107
130
|
</div>
|
|
108
131
|
|
|
109
132
|
<div className="border-t border-ods-border pt-4">
|
|
133
|
+
{/* Reply-failure banner — populated by `useTicketActions` when
|
|
134
|
+
the last sendMessage attempt for THIS ticket failed with a
|
|
135
|
+
reply-specific code. Rendered above the composer/reopen so
|
|
136
|
+
the customer sees it in context of their failed draft. Open
|
|
137
|
+
(composer) actions still allow Retry; the closed (reopen)
|
|
138
|
+
state still shows the banner because the user might have
|
|
139
|
+
tried to reply to a then-closing ticket. */}
|
|
140
|
+
{replyError && (
|
|
141
|
+
<ReplyFailureBanner
|
|
142
|
+
error={replyError}
|
|
143
|
+
onDismiss={onClearReplyError ?? (() => undefined)}
|
|
144
|
+
/>
|
|
145
|
+
)}
|
|
110
146
|
{isClosed ? (
|
|
111
147
|
<ReopenAction
|
|
112
148
|
ticketRef={{ id: ticket.id, external_id: ticket.external_id }}
|
|
@@ -408,8 +444,15 @@ function ReopenAction({
|
|
|
408
444
|
onActionCollapsed: TicketDetailDrawerProps['onActionCollapsed']
|
|
409
445
|
}) {
|
|
410
446
|
const handleReopen = async () => {
|
|
411
|
-
|
|
412
|
-
|
|
447
|
+
// Intentionally do NOT call `onActionCollapsed()` here. Pre-PR #1053
|
|
448
|
+
// every reopen was followed by a full list refetch which removed
|
|
449
|
+
// the (now-OPEN) row from a `?status=closed` view, so collapsing
|
|
450
|
+
// the drawer hid the disappearance flash. After #1053+#1055 the
|
|
451
|
+
// row stays in the list with the optimistic in-place status
|
|
452
|
+
// update — collapsing the drawer now actively dismisses the
|
|
453
|
+
// ticket the user is working on. Keep it mounted; the badge flip
|
|
454
|
+
// is enough feedback. (Reported 2026-05-29.)
|
|
455
|
+
void (await onReopen(ticketRef))
|
|
413
456
|
}
|
|
414
457
|
return (
|
|
415
458
|
<div className="flex justify-end">
|
|
@@ -465,9 +508,14 @@ function OpenActions({
|
|
|
465
508
|
|
|
466
509
|
const confirmClose = async () => {
|
|
467
510
|
setCloseDialogOpen(false)
|
|
468
|
-
|
|
511
|
+
await onClose(ticketRef, resolution.trim() || undefined)
|
|
469
512
|
setResolution('')
|
|
470
|
-
|
|
513
|
+
// Intentionally do NOT call `onActionCollapsed()` here. See the
|
|
514
|
+
// matching comment in ReopenActions.handleReopen above — collapsing
|
|
515
|
+
// the drawer after a successful close now dismisses the ticket the
|
|
516
|
+
// user is actively working on, which is the exact UX bug PR #1053
|
|
517
|
+
// set out to fix. The optimistic in-place status update keeps the
|
|
518
|
+
// row mounted with the new badge; that's the only feedback needed.
|
|
471
519
|
}
|
|
472
520
|
|
|
473
521
|
return (
|
|
@@ -562,3 +610,95 @@ function OpenActions({
|
|
|
562
610
|
</div>
|
|
563
611
|
)
|
|
564
612
|
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Persistent banner above the drawer composer/actions when the most
|
|
616
|
+
* recent customer reply failed with a reply-specific code (HUBSPOT_5XX
|
|
617
|
+
* / 400 / 404 / UNKNOWN). The transient toast already fired at the
|
|
618
|
+
* moment of failure; this banner stays until the next successful send
|
|
619
|
+
* OR the user dismisses it explicitly. Wording is sourced from
|
|
620
|
+
* `mapTicketActionError` so a future copy update lives in one place.
|
|
621
|
+
*
|
|
622
|
+
* 404_THREAD is the only terminal code in the set — the banner copy
|
|
623
|
+
* reads "open a new ticket" and Retry would just re-fail. We still
|
|
624
|
+
* render a Dismiss control instead of hiding Retry so the visual shape
|
|
625
|
+
* is uniform; the parent's composer continues to function for any
|
|
626
|
+
* non-thread-deletion reply path.
|
|
627
|
+
*/
|
|
628
|
+
function ReplyFailureBanner({
|
|
629
|
+
error,
|
|
630
|
+
onDismiss,
|
|
631
|
+
}: {
|
|
632
|
+
error: MappedTicketActionError
|
|
633
|
+
onDismiss: () => void
|
|
634
|
+
}) {
|
|
635
|
+
return (
|
|
636
|
+
<div
|
|
637
|
+
role="status"
|
|
638
|
+
aria-live="polite"
|
|
639
|
+
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"
|
|
640
|
+
>
|
|
641
|
+
<span className="font-medium leading-snug">{error.message}</span>
|
|
642
|
+
<Button
|
|
643
|
+
type="button"
|
|
644
|
+
variant="transparent"
|
|
645
|
+
onClick={onDismiss}
|
|
646
|
+
aria-label="Dismiss reply failure"
|
|
647
|
+
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"
|
|
648
|
+
>
|
|
649
|
+
Dismiss
|
|
650
|
+
</Button>
|
|
651
|
+
</div>
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Compact "Assigned to" row at the top of the drawer. Surfaces the
|
|
657
|
+
* support-side agent — name + avatar — so the customer knows who's
|
|
658
|
+
* looking at their ticket. Renders "Unassigned" when the ticket has no
|
|
659
|
+
* `hubspot_owner_id` OR when the owner couldn't be resolved against
|
|
660
|
+
* the mirror (deleted between ticket update + next reconcile).
|
|
661
|
+
*
|
|
662
|
+
* Avatar comes from the canonical `<SquareAvatar variant="round">` so
|
|
663
|
+
* it picks up the initials-fallback + image-proxy behavior used
|
|
664
|
+
* everywhere else in the lib (matches the dev-section message-bubble
|
|
665
|
+
* avatars). No bespoke avatar markup.
|
|
666
|
+
*/
|
|
667
|
+
function AssignedAgentRow({
|
|
668
|
+
assignedOwner,
|
|
669
|
+
}: {
|
|
670
|
+
assignedOwner: TicketAssignedOwner | null
|
|
671
|
+
}) {
|
|
672
|
+
// Display label precedence:
|
|
673
|
+
// 1. `name` from the mirror (employee match OR HubSpot's first+last)
|
|
674
|
+
// 2. `email` local-part — covers HubSpot owners that exist but have
|
|
675
|
+
// no name (rare but real; the ticket IS assigned and rendering
|
|
676
|
+
// "Unassigned" would be misleading)
|
|
677
|
+
// 3. "Unassigned" — only when the ticket has no `assigned_to` OR
|
|
678
|
+
// the owner couldn't be resolved against the mirror at all
|
|
679
|
+
const trimmedName = assignedOwner?.name?.trim() || null
|
|
680
|
+
const emailFallback = assignedOwner?.email?.trim() || null
|
|
681
|
+
const displayLabel =
|
|
682
|
+
trimmedName ?? (emailFallback ? emailFallback.split('@')[0] : null)
|
|
683
|
+
return (
|
|
684
|
+
<div className="flex items-center gap-2 text-xs">
|
|
685
|
+
<span className="text-ods-text-secondary uppercase tracking-wider font-medium">
|
|
686
|
+
Assigned to
|
|
687
|
+
</span>
|
|
688
|
+
{displayLabel ? (
|
|
689
|
+
<span className="flex items-center gap-1.5 text-ods-text-primary font-medium">
|
|
690
|
+
<SquareAvatar
|
|
691
|
+
size="sm"
|
|
692
|
+
variant="round"
|
|
693
|
+
src={assignedOwner?.avatarUrl ?? undefined}
|
|
694
|
+
alt={displayLabel}
|
|
695
|
+
fallback={displayLabel}
|
|
696
|
+
/>
|
|
697
|
+
{displayLabel}
|
|
698
|
+
</span>
|
|
699
|
+
) : (
|
|
700
|
+
<span className="text-ods-text-secondary italic">Unassigned</span>
|
|
701
|
+
)}
|
|
702
|
+
</div>
|
|
703
|
+
)
|
|
704
|
+
}
|
|
@@ -51,9 +51,30 @@ export interface TicketData {
|
|
|
51
51
|
* Channels, so this is the only reliable source for "what's the
|
|
52
52
|
* customer's name." */
|
|
53
53
|
customer_name: string | null
|
|
54
|
+
/** HubSpot owner id of the agent assigned to this ticket. Carried as
|
|
55
|
+
* raw id for debugging; rendering goes through `assignedOwner`. Null
|
|
56
|
+
* when unassigned. */
|
|
57
|
+
assigned_to: string | null
|
|
58
|
+
/** Resolved assigned-owner profile — name + email + avatar. Populated
|
|
59
|
+
* server-side via `attachOwnerProfiles` which joins through the
|
|
60
|
+
* `hubspot_owners` mirror to `profiles` by email. Drives the
|
|
61
|
+
* "Assigned to" attribution in the drawer header. Null when
|
|
62
|
+
* unassigned OR the owner couldn't be resolved (rare — only when
|
|
63
|
+
* the agent was deleted from HubSpot between the ticket update and
|
|
64
|
+
* the next owners reconcile). */
|
|
65
|
+
assignedOwner: TicketAssignedOwner | null
|
|
54
66
|
hubspot_updated_at: string
|
|
55
67
|
}
|
|
56
68
|
|
|
69
|
+
/** Resolved profile of a ticket's assigned agent — surfaced in the
|
|
70
|
+
* drawer header. Subset of the server's `MirroredOwnerProfile`
|
|
71
|
+
* trimmed to just the rendering fields. */
|
|
72
|
+
export interface TicketAssignedOwner {
|
|
73
|
+
name: string | null
|
|
74
|
+
email: string | null
|
|
75
|
+
avatarUrl: string | null
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
/** Compact projection of a linked ClickUp task — matches the server's
|
|
58
79
|
* `ClickupSummary` and aligns with `DeliveryItem` so the linked-card
|
|
59
80
|
* on a ticket can render through the same `DeliveryRow` primitive used
|
|
@@ -118,10 +139,40 @@ export function isOptimistic(t: AnyTicket): t is OptimisticTicket {
|
|
|
118
139
|
return (t as OptimisticTicket)._optimistic === true
|
|
119
140
|
}
|
|
120
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Shape of a single `['tickets', …]` TanStack-Query cache slot.
|
|
144
|
+
* Mirrors `FindTicketResponse` in `hooks/use-tickets-list.ts` — kept
|
|
145
|
+
* here because the cache-mutation call sites in `useTicketActions` and
|
|
146
|
+
* `<HelpCenterList>` would otherwise have to redeclare the shape inline.
|
|
147
|
+
*
|
|
148
|
+
* A 2026-05-29 prod regression (`t.map is not a function` on
|
|
149
|
+
* close/reopen) was caused by assuming the cache held a bare
|
|
150
|
+
* `TicketData[]` instead of this wrapper — every helper that calls
|
|
151
|
+
* `queryClient.setQueriesData` / `getQueriesData` on `['tickets']`
|
|
152
|
+
* MUST type the value through this shape and project / reassemble
|
|
153
|
+
* `tickets` explicitly.
|
|
154
|
+
*/
|
|
155
|
+
export interface TicketsCacheSlot {
|
|
156
|
+
tickets?: TicketData[]
|
|
157
|
+
count?: number
|
|
158
|
+
totalCount?: number
|
|
159
|
+
page?: number
|
|
160
|
+
pageSize?: number
|
|
161
|
+
totalPages?: number
|
|
162
|
+
scope?: 'self' | 'all'
|
|
163
|
+
}
|
|
164
|
+
|
|
121
165
|
/**
|
|
122
166
|
* Stable server-side error codes the ticket-action helpers route
|
|
123
167
|
* through `mapTicketActionError`. Anything else is treated as a generic
|
|
124
168
|
* server error.
|
|
169
|
+
*
|
|
170
|
+
* Reply-specific codes (`HUBSPOT_5XX` / `HUBSPOT_400_VALIDATION` /
|
|
171
|
+
* `HUBSPOT_404_THREAD` / `HUBSPOT_REPLY_UNKNOWN`) drive the drawer
|
|
172
|
+
* banner that appears above the composer when a customer reply fails.
|
|
173
|
+
* Distinct from `HUBSPOT_DISCONNECTED` (whole-system) and
|
|
174
|
+
* `TICKET_NOT_FOUND` (terminal-row) — the reply codes are per-attempt
|
|
175
|
+
* + retryable except for `HUBSPOT_404_THREAD`.
|
|
125
176
|
*/
|
|
126
177
|
export type TicketActionErrorCode =
|
|
127
178
|
| 'PROPOSAL_NOT_CLAIMABLE'
|
|
@@ -130,6 +181,10 @@ export type TicketActionErrorCode =
|
|
|
130
181
|
| 'HUBSPOT_DISCONNECTED'
|
|
131
182
|
| 'RATE_LIMITED'
|
|
132
183
|
| 'INVALID_TOOL_ARGS'
|
|
184
|
+
| 'HUBSPOT_5XX'
|
|
185
|
+
| 'HUBSPOT_400_VALIDATION'
|
|
186
|
+
| 'HUBSPOT_404_THREAD'
|
|
187
|
+
| 'HUBSPOT_REPLY_UNKNOWN'
|
|
133
188
|
| 'UNKNOWN'
|
|
134
189
|
|
|
135
190
|
export interface MappedTicketActionError {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared filter section wrapper with responsive layout.
|
|
5
|
+
*
|
|
6
|
+
* Provides the consistent card container for any filter row (status,
|
|
7
|
+
* platform, section, etc.) Single row on desktop with flex-wrap,
|
|
8
|
+
* stacks naturally on mobile.
|
|
9
|
+
*
|
|
10
|
+
* Lifted from hub `components/admin/shared/filter-section.tsx` so
|
|
11
|
+
* lib catalog views can render their own filter pills without an
|
|
12
|
+
* injected slot.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Filter } from 'lucide-react'
|
|
16
|
+
import { Button } from './button'
|
|
17
|
+
|
|
18
|
+
export interface FilterPillRowOption {
|
|
19
|
+
value: string
|
|
20
|
+
label: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FilterPillRowProps {
|
|
24
|
+
/** Label shown next to the filter icon, e.g. "Section", "Platform". */
|
|
25
|
+
label: string
|
|
26
|
+
/** The currently selected filter value. */
|
|
27
|
+
selectedValue: string
|
|
28
|
+
/** Callback when a filter option is selected. */
|
|
29
|
+
onValueChange: (value: string) => void
|
|
30
|
+
/** Available filter options. */
|
|
31
|
+
options: FilterPillRowOption[]
|
|
32
|
+
/** Optional count label, e.g. "Showing 1-10 of 42 items". */
|
|
33
|
+
countLabel?: string
|
|
34
|
+
/** Optional children to render instead of options (for custom filter content). */
|
|
35
|
+
children?: React.ReactNode
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function FilterPillRow({
|
|
39
|
+
label,
|
|
40
|
+
selectedValue,
|
|
41
|
+
onValueChange,
|
|
42
|
+
options,
|
|
43
|
+
countLabel,
|
|
44
|
+
children,
|
|
45
|
+
}: FilterPillRowProps) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex flex-wrap items-center gap-3 p-4 bg-ods-card border border-ods-border rounded-lg">
|
|
48
|
+
<div className="flex items-center gap-2">
|
|
49
|
+
<Filter className="h-4 w-4 text-ods-accent" />
|
|
50
|
+
<span className="text-h5 text-ods-text-secondary">{label}</span>
|
|
51
|
+
</div>
|
|
52
|
+
{children ||
|
|
53
|
+
options.map((opt) => (
|
|
54
|
+
<Button
|
|
55
|
+
key={opt.value}
|
|
56
|
+
type="button"
|
|
57
|
+
variant={selectedValue === opt.value ? 'accent' : 'outline'}
|
|
58
|
+
size="small-legacy"
|
|
59
|
+
onClick={() => onValueChange(opt.value)}
|
|
60
|
+
className="text-h3"
|
|
61
|
+
>
|
|
62
|
+
{opt.label}
|
|
63
|
+
</Button>
|
|
64
|
+
))}
|
|
65
|
+
{countLabel && (
|
|
66
|
+
<div className="ml-auto text-[12px] font-['DM_Sans'] text-ods-text-secondary shrink-0">
|
|
67
|
+
{countLabel}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|