@flamingo-stack/openframe-frontend-core 0.0.214 → 0.0.215-snapshot.20260529010429
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-KDNGUYZM.cjs → chunk-2G3NXF6J.cjs} +17 -17
- package/dist/{chunk-KDNGUYZM.cjs.map → chunk-2G3NXF6J.cjs.map} +1 -1
- package/dist/{chunk-HWY35EAK.js → chunk-7PCP7YQR.js} +2 -2
- package/dist/{chunk-YZUUFTIK.cjs → chunk-D4MNFY67.cjs} +3 -2
- package/dist/chunk-D4MNFY67.cjs.map +1 -0
- package/dist/{chunk-IZL2KEH6.cjs → chunk-NGFP4RVL.cjs} +24 -24
- package/dist/{chunk-IZL2KEH6.cjs.map → chunk-NGFP4RVL.cjs.map} +1 -1
- package/dist/{chunk-5OWDOFKK.js → chunk-R6MLPU4A.js} +2 -2
- package/dist/{chunk-UKND27XC.js → chunk-WQZP3JIZ.js} +3 -2
- package/dist/{chunk-UKND27XC.js.map → chunk-WQZP3JIZ.js.map} +1 -1
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/index.cjs +49 -49
- package/dist/components/index.js +2 -2
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- 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 +254 -103
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.js +184 -33
- 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 +28 -1
- package/dist/components/tickets/types.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/components/platform/ShellTypeBadge.tsx +2 -2
- package/src/components/tickets/help-center-card.tsx +8 -0
- package/src/components/tickets/help-center-list.tsx +2 -0
- package/src/components/tickets/hooks/use-ticket-actions.ts +139 -5
- package/src/components/tickets/ticket-detail-drawer.tsx +129 -1
- package/src/components/tickets/types.ts +32 -0
- package/dist/chunk-YZUUFTIK.cjs.map +0 -1
- /package/dist/{chunk-HWY35EAK.js.map → chunk-7PCP7YQR.js.map} +0 -0
- /package/dist/{chunk-5OWDOFKK.js.map → chunk-R6MLPU4A.js.map} +0 -0
|
@@ -55,6 +55,10 @@ export interface HelpCenterCardProps {
|
|
|
55
55
|
onClose: TicketDetailDrawerProps['onClose']
|
|
56
56
|
onReopen: TicketDetailDrawerProps['onReopen']
|
|
57
57
|
onActionCollapsed: () => void
|
|
58
|
+
/** Persisted reply-failure banner — forwarded to the drawer. Parent
|
|
59
|
+
* (`HelpCenterList`) reads via `actions.replyErrorFor(external_id)`. */
|
|
60
|
+
replyError?: TicketDetailDrawerProps['replyError']
|
|
61
|
+
onClearReplyError?: TicketDetailDrawerProps['onClearReplyError']
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
export function HelpCenterCard({
|
|
@@ -67,6 +71,8 @@ export function HelpCenterCard({
|
|
|
67
71
|
onClose,
|
|
68
72
|
onReopen,
|
|
69
73
|
onActionCollapsed,
|
|
74
|
+
replyError,
|
|
75
|
+
onClearReplyError,
|
|
70
76
|
}: HelpCenterCardProps) {
|
|
71
77
|
const optimistic = isOptimistic(ticket)
|
|
72
78
|
const rawStatus = (ticket.status ?? 'OPEN').toUpperCase()
|
|
@@ -177,6 +183,8 @@ export function HelpCenterCard({
|
|
|
177
183
|
onClose={onClose}
|
|
178
184
|
onReopen={onReopen}
|
|
179
185
|
onActionCollapsed={onActionCollapsed}
|
|
186
|
+
replyError={replyError}
|
|
187
|
+
onClearReplyError={onClearReplyError}
|
|
180
188
|
/>
|
|
181
189
|
</div>
|
|
182
190
|
)}
|
|
@@ -285,6 +285,8 @@ function HelpCenterListAuthed({
|
|
|
285
285
|
onClose={actions.closeTicket}
|
|
286
286
|
onReopen={actions.reopenTicket}
|
|
287
287
|
onActionCollapsed={() => setExpandedTicketId(null)}
|
|
288
|
+
replyError={actions.replyErrorFor(ticket.external_id)}
|
|
289
|
+
onClearReplyError={() => actions.clearReplyError(ticket.external_id)}
|
|
288
290
|
/>
|
|
289
291
|
))}
|
|
290
292
|
</div>
|
|
@@ -44,6 +44,17 @@ import {
|
|
|
44
44
|
|
|
45
45
|
const TICKET_ACTION_ENDPOINT = '/api/chat/agent/ticket-action'
|
|
46
46
|
|
|
47
|
+
/** Codes that populate the inline reply-failure banner above the drawer
|
|
48
|
+
* composer. Other codes (system-down, ticket-gone, rate-limit) are
|
|
49
|
+
* full-row / full-system signals covered by the toast + supportSystemDown
|
|
50
|
+
* handling — surfacing them in the inline banner too would be redundant. */
|
|
51
|
+
const REPLY_BANNER_CODES: ReadonlySet<TicketActionErrorCode> = new Set<TicketActionErrorCode>([
|
|
52
|
+
'HUBSPOT_5XX',
|
|
53
|
+
'HUBSPOT_400_VALIDATION',
|
|
54
|
+
'HUBSPOT_404_THREAD',
|
|
55
|
+
'HUBSPOT_REPLY_UNKNOWN',
|
|
56
|
+
])
|
|
57
|
+
|
|
47
58
|
/** 3 attempts × backoff (cumulative ~21s wall-clock). After this we
|
|
48
59
|
* drop the optimistic row and ask the user to reload. */
|
|
49
60
|
const MIRROR_SYNC_BACKOFF_MS = [3_000, 6_000, 12_000] as const
|
|
@@ -125,6 +136,14 @@ export interface UseTicketActionsReturn {
|
|
|
125
136
|
isSubmittingForm: boolean
|
|
126
137
|
/** Per-row in-flight set (read-only). UI uses `isRowBusy(localId)`. */
|
|
127
138
|
isRowBusy: (localId: string) => boolean
|
|
139
|
+
/** Most recent reply failure for a given ticket id (`external_id`).
|
|
140
|
+
* Drives the inline "couldn't send" banner above the composer in
|
|
141
|
+
* `<TicketDetailDrawer>`. Cleared on the next successful send OR
|
|
142
|
+
* via `clearReplyError(ticketId)`. */
|
|
143
|
+
replyErrorFor: (ticketExternalId: string) => MappedTicketActionError | null
|
|
144
|
+
/** Clear the persisted reply-failure banner for a ticket (e.g. when
|
|
145
|
+
* the user dismisses it or starts a new draft). */
|
|
146
|
+
clearReplyError: (ticketExternalId: string) => void
|
|
128
147
|
}
|
|
129
148
|
|
|
130
149
|
export function useTicketActions(options: UseTicketActionsOptions): UseTicketActionsReturn {
|
|
@@ -151,6 +170,37 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
151
170
|
}, [])
|
|
152
171
|
const isRowBusy = useCallback((id: string) => busyRows.has(id), [busyRows])
|
|
153
172
|
|
|
173
|
+
// Persisted reply-failure banner state — keyed by the ticket's
|
|
174
|
+
// HubSpot `external_id`. The drawer reads `replyErrorFor(externalId)`
|
|
175
|
+
// and renders an inline "couldn't send — retry" banner above the
|
|
176
|
+
// composer. Cleared automatically on the next successful send and
|
|
177
|
+
// explicitly by the dismiss-X / "Retry" actions in the banner UI.
|
|
178
|
+
// Distinct from the transient toast — the banner persists so the
|
|
179
|
+
// user can locate their failed draft after dismissing the toast.
|
|
180
|
+
const [replyErrorByTicket, setReplyErrorByTicket] = useState<
|
|
181
|
+
Map<string, MappedTicketActionError>
|
|
182
|
+
>(() => new Map())
|
|
183
|
+
const setReplyError = useCallback(
|
|
184
|
+
(externalId: string, mapped: MappedTicketActionError | null) => {
|
|
185
|
+
setReplyErrorByTicket((prev) => {
|
|
186
|
+
const next = new Map(prev)
|
|
187
|
+
if (mapped) next.set(externalId, mapped)
|
|
188
|
+
else next.delete(externalId)
|
|
189
|
+
return next
|
|
190
|
+
})
|
|
191
|
+
},
|
|
192
|
+
[],
|
|
193
|
+
)
|
|
194
|
+
const replyErrorFor = useCallback(
|
|
195
|
+
(externalId: string): MappedTicketActionError | null =>
|
|
196
|
+
replyErrorByTicket.get(externalId) ?? null,
|
|
197
|
+
[replyErrorByTicket],
|
|
198
|
+
)
|
|
199
|
+
const clearReplyError = useCallback(
|
|
200
|
+
(externalId: string) => setReplyError(externalId, null),
|
|
201
|
+
[setReplyError],
|
|
202
|
+
)
|
|
203
|
+
|
|
154
204
|
// Mirror-sync watcher controllers tracked by placeholder id so we can
|
|
155
205
|
// abort prior watchers when a new submit lands AND so unmount cleans
|
|
156
206
|
// them up without leaking setState calls. Single source of truth for
|
|
@@ -262,9 +312,15 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
262
312
|
[queryClient, removeOptimistic, toast],
|
|
263
313
|
)
|
|
264
314
|
|
|
315
|
+
// Last `surfaceError` mapping — sendMessage reads this immediately
|
|
316
|
+
// after the catch returns so it can decide whether to populate the
|
|
317
|
+
// inline reply banner. Cleared on every read by the consumer to
|
|
318
|
+
// prevent a stale failure from leaking into the next attempt.
|
|
319
|
+
const lastUpdateErrorRef = useRef<MappedTicketActionError | null>(null)
|
|
265
320
|
const surfaceError = useCallback(
|
|
266
321
|
(err: unknown, action: string): MappedTicketActionError => {
|
|
267
322
|
const mapped = mapTicketActionError(err)
|
|
323
|
+
lastUpdateErrorRef.current = mapped
|
|
268
324
|
if (mapped.supportSystemDown) onSupportSystemDown()
|
|
269
325
|
toast({
|
|
270
326
|
title: `Could not ${action}`,
|
|
@@ -302,6 +358,10 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
302
358
|
// seconds via the mirror refetch. Drawer uses live chat
|
|
303
359
|
// identity for own-replies during this window anyway.
|
|
304
360
|
customer_name: null,
|
|
361
|
+
// No assignee until the real ticket lands. Drawer renders
|
|
362
|
+
// "Unassigned" for this brief window.
|
|
363
|
+
assigned_to: null,
|
|
364
|
+
assignedOwner: null,
|
|
305
365
|
hubspot_updated_at: new Date().toISOString(),
|
|
306
366
|
_optimistic: true,
|
|
307
367
|
}
|
|
@@ -393,12 +453,18 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
393
453
|
)
|
|
394
454
|
|
|
395
455
|
const sendMessage = useCallback(
|
|
396
|
-
(ticket: TicketRef, text: string, attachments: ChatAttachment[]) => {
|
|
456
|
+
async (ticket: TicketRef, text: string, attachments: ChatAttachment[]) => {
|
|
397
457
|
const trimmed = text.trim()
|
|
398
458
|
const hasText = trimmed.length > 0
|
|
399
459
|
const hasFiles = attachments.length > 0
|
|
400
|
-
if (!hasText && !hasFiles) return
|
|
401
|
-
|
|
460
|
+
if (!hasText && !hasFiles) return false
|
|
461
|
+
// Clear any stale mapped error from a prior non-sendMessage action
|
|
462
|
+
// (closeTicket / reopenTicket) so the post-call read only picks up
|
|
463
|
+
// an error THIS sendMessage produced. Without this clear, a prior
|
|
464
|
+
// close-failure's mapped error could leak into the banner via the
|
|
465
|
+
// post-call `lastUpdateErrorRef.current` read.
|
|
466
|
+
lastUpdateErrorRef.current = null
|
|
467
|
+
const ok = await updateTicket(
|
|
402
468
|
ticket,
|
|
403
469
|
{
|
|
404
470
|
...(hasText ? { content_addendum: trimmed } : {}),
|
|
@@ -407,8 +473,33 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
407
473
|
TOAST_COPY.comment_success,
|
|
408
474
|
'send message',
|
|
409
475
|
)
|
|
476
|
+
// Banner-state coupling: SUCCESS clears any stale failure banner
|
|
477
|
+
// for this ticket; FAILURE populates the banner ONLY for the
|
|
478
|
+
// reply-specific code subset (HUBSPOT_5XX / 400 / 404 / UNKNOWN).
|
|
479
|
+
// Other codes (TICKET_NOT_FOUND, HUBSPOT_DISCONNECTED, RATE_LIMITED)
|
|
480
|
+
// are full-row / full-system signals already covered by the
|
|
481
|
+
// existing toast + supportSystemDown handling — surfacing them in
|
|
482
|
+
// the inline banner too would be redundant.
|
|
483
|
+
if (ok) {
|
|
484
|
+
clearReplyError(ticket.external_id)
|
|
485
|
+
} else {
|
|
486
|
+
// Line 466's `.current = null` narrows the property to literal
|
|
487
|
+
// `null`. `tsc -p tsconfig.declarations.json` (declarations
|
|
488
|
+
// build, distinct from the `tsc --noEmit` pre-step) doesn't
|
|
489
|
+
// widen that narrowing across the `await updateTicket(...)`,
|
|
490
|
+
// so the read here is typed `never`. The runtime type IS
|
|
491
|
+
// `MappedTicketActionError | null` per the useRef declaration;
|
|
492
|
+
// the assertion just tells TS to honor it instead of the stale
|
|
493
|
+
// narrowing.
|
|
494
|
+
const mapped = lastUpdateErrorRef.current as MappedTicketActionError | null
|
|
495
|
+
if (mapped && REPLY_BANNER_CODES.has(mapped.code)) {
|
|
496
|
+
setReplyError(ticket.external_id, mapped)
|
|
497
|
+
}
|
|
498
|
+
lastUpdateErrorRef.current = null
|
|
499
|
+
}
|
|
500
|
+
return ok
|
|
410
501
|
},
|
|
411
|
-
[updateTicket],
|
|
502
|
+
[updateTicket, clearReplyError, setReplyError],
|
|
412
503
|
)
|
|
413
504
|
|
|
414
505
|
const closeTicket = useCallback(
|
|
@@ -439,8 +530,19 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
|
|
|
439
530
|
reopenTicket,
|
|
440
531
|
isSubmittingForm,
|
|
441
532
|
isRowBusy,
|
|
533
|
+
replyErrorFor,
|
|
534
|
+
clearReplyError,
|
|
442
535
|
}),
|
|
443
|
-
[
|
|
536
|
+
[
|
|
537
|
+
submitTicket,
|
|
538
|
+
sendMessage,
|
|
539
|
+
closeTicket,
|
|
540
|
+
reopenTicket,
|
|
541
|
+
isSubmittingForm,
|
|
542
|
+
isRowBusy,
|
|
543
|
+
replyErrorFor,
|
|
544
|
+
clearReplyError,
|
|
545
|
+
],
|
|
444
546
|
)
|
|
445
547
|
}
|
|
446
548
|
|
|
@@ -512,6 +614,38 @@ export function mapTicketActionError(err: unknown): MappedTicketActionError {
|
|
|
512
614
|
supportSystemDown: false,
|
|
513
615
|
removeRowFromCache: false,
|
|
514
616
|
}
|
|
617
|
+
case 'HUBSPOT_5XX':
|
|
618
|
+
return {
|
|
619
|
+
code: err.code,
|
|
620
|
+
message:
|
|
621
|
+
"We couldn't reach the support system. Your reply wasn't sent — please retry in a moment.",
|
|
622
|
+
supportSystemDown: false,
|
|
623
|
+
removeRowFromCache: false,
|
|
624
|
+
}
|
|
625
|
+
case 'HUBSPOT_400_VALIDATION':
|
|
626
|
+
return {
|
|
627
|
+
code: err.code,
|
|
628
|
+
message:
|
|
629
|
+
'Your reply was rejected. Please rephrase or remove unsupported content and try again.',
|
|
630
|
+
supportSystemDown: false,
|
|
631
|
+
removeRowFromCache: false,
|
|
632
|
+
}
|
|
633
|
+
case 'HUBSPOT_404_THREAD':
|
|
634
|
+
return {
|
|
635
|
+
code: err.code,
|
|
636
|
+
message:
|
|
637
|
+
'This conversation is no longer accepting replies. Open a new ticket to continue.',
|
|
638
|
+
supportSystemDown: false,
|
|
639
|
+
removeRowFromCache: false,
|
|
640
|
+
}
|
|
641
|
+
case 'HUBSPOT_REPLY_UNKNOWN':
|
|
642
|
+
return {
|
|
643
|
+
code: err.code,
|
|
644
|
+
message:
|
|
645
|
+
"Your reply didn't go through. Please retry.",
|
|
646
|
+
supportSystemDown: false,
|
|
647
|
+
removeRowFromCache: false,
|
|
648
|
+
}
|
|
515
649
|
default:
|
|
516
650
|
return {
|
|
517
651
|
code: 'UNKNOWN',
|
|
@@ -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 }}
|
|
@@ -562,3 +598,95 @@ function OpenActions({
|
|
|
562
598
|
</div>
|
|
563
599
|
)
|
|
564
600
|
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Persistent banner above the drawer composer/actions when the most
|
|
604
|
+
* recent customer reply failed with a reply-specific code (HUBSPOT_5XX
|
|
605
|
+
* / 400 / 404 / UNKNOWN). The transient toast already fired at the
|
|
606
|
+
* moment of failure; this banner stays until the next successful send
|
|
607
|
+
* OR the user dismisses it explicitly. Wording is sourced from
|
|
608
|
+
* `mapTicketActionError` so a future copy update lives in one place.
|
|
609
|
+
*
|
|
610
|
+
* 404_THREAD is the only terminal code in the set — the banner copy
|
|
611
|
+
* reads "open a new ticket" and Retry would just re-fail. We still
|
|
612
|
+
* render a Dismiss control instead of hiding Retry so the visual shape
|
|
613
|
+
* is uniform; the parent's composer continues to function for any
|
|
614
|
+
* non-thread-deletion reply path.
|
|
615
|
+
*/
|
|
616
|
+
function ReplyFailureBanner({
|
|
617
|
+
error,
|
|
618
|
+
onDismiss,
|
|
619
|
+
}: {
|
|
620
|
+
error: MappedTicketActionError
|
|
621
|
+
onDismiss: () => void
|
|
622
|
+
}) {
|
|
623
|
+
return (
|
|
624
|
+
<div
|
|
625
|
+
role="status"
|
|
626
|
+
aria-live="polite"
|
|
627
|
+
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"
|
|
628
|
+
>
|
|
629
|
+
<span className="font-medium leading-snug">{error.message}</span>
|
|
630
|
+
<Button
|
|
631
|
+
type="button"
|
|
632
|
+
variant="transparent"
|
|
633
|
+
onClick={onDismiss}
|
|
634
|
+
aria-label="Dismiss reply failure"
|
|
635
|
+
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"
|
|
636
|
+
>
|
|
637
|
+
Dismiss
|
|
638
|
+
</Button>
|
|
639
|
+
</div>
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Compact "Assigned to" row at the top of the drawer. Surfaces the
|
|
645
|
+
* support-side agent — name + avatar — so the customer knows who's
|
|
646
|
+
* looking at their ticket. Renders "Unassigned" when the ticket has no
|
|
647
|
+
* `hubspot_owner_id` OR when the owner couldn't be resolved against
|
|
648
|
+
* the mirror (deleted between ticket update + next reconcile).
|
|
649
|
+
*
|
|
650
|
+
* Avatar comes from the canonical `<SquareAvatar variant="round">` so
|
|
651
|
+
* it picks up the initials-fallback + image-proxy behavior used
|
|
652
|
+
* everywhere else in the lib (matches the dev-section message-bubble
|
|
653
|
+
* avatars). No bespoke avatar markup.
|
|
654
|
+
*/
|
|
655
|
+
function AssignedAgentRow({
|
|
656
|
+
assignedOwner,
|
|
657
|
+
}: {
|
|
658
|
+
assignedOwner: TicketAssignedOwner | null
|
|
659
|
+
}) {
|
|
660
|
+
// Display label precedence:
|
|
661
|
+
// 1. `name` from the mirror (employee match OR HubSpot's first+last)
|
|
662
|
+
// 2. `email` local-part — covers HubSpot owners that exist but have
|
|
663
|
+
// no name (rare but real; the ticket IS assigned and rendering
|
|
664
|
+
// "Unassigned" would be misleading)
|
|
665
|
+
// 3. "Unassigned" — only when the ticket has no `assigned_to` OR
|
|
666
|
+
// the owner couldn't be resolved against the mirror at all
|
|
667
|
+
const trimmedName = assignedOwner?.name?.trim() || null
|
|
668
|
+
const emailFallback = assignedOwner?.email?.trim() || null
|
|
669
|
+
const displayLabel =
|
|
670
|
+
trimmedName ?? (emailFallback ? emailFallback.split('@')[0] : null)
|
|
671
|
+
return (
|
|
672
|
+
<div className="flex items-center gap-2 text-xs">
|
|
673
|
+
<span className="text-ods-text-secondary uppercase tracking-wider font-medium">
|
|
674
|
+
Assigned to
|
|
675
|
+
</span>
|
|
676
|
+
{displayLabel ? (
|
|
677
|
+
<span className="flex items-center gap-1.5 text-ods-text-primary font-medium">
|
|
678
|
+
<SquareAvatar
|
|
679
|
+
size="sm"
|
|
680
|
+
variant="round"
|
|
681
|
+
src={assignedOwner?.avatarUrl ?? undefined}
|
|
682
|
+
alt={displayLabel}
|
|
683
|
+
fallback={displayLabel}
|
|
684
|
+
/>
|
|
685
|
+
{displayLabel}
|
|
686
|
+
</span>
|
|
687
|
+
) : (
|
|
688
|
+
<span className="text-ods-text-secondary italic">Unassigned</span>
|
|
689
|
+
)}
|
|
690
|
+
</div>
|
|
691
|
+
)
|
|
692
|
+
}
|
|
@@ -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
|
|
@@ -122,6 +143,13 @@ export function isOptimistic(t: AnyTicket): t is OptimisticTicket {
|
|
|
122
143
|
* Stable server-side error codes the ticket-action helpers route
|
|
123
144
|
* through `mapTicketActionError`. Anything else is treated as a generic
|
|
124
145
|
* server error.
|
|
146
|
+
*
|
|
147
|
+
* Reply-specific codes (`HUBSPOT_5XX` / `HUBSPOT_400_VALIDATION` /
|
|
148
|
+
* `HUBSPOT_404_THREAD` / `HUBSPOT_REPLY_UNKNOWN`) drive the drawer
|
|
149
|
+
* banner that appears above the composer when a customer reply fails.
|
|
150
|
+
* Distinct from `HUBSPOT_DISCONNECTED` (whole-system) and
|
|
151
|
+
* `TICKET_NOT_FOUND` (terminal-row) — the reply codes are per-attempt
|
|
152
|
+
* + retryable except for `HUBSPOT_404_THREAD`.
|
|
125
153
|
*/
|
|
126
154
|
export type TicketActionErrorCode =
|
|
127
155
|
| 'PROPOSAL_NOT_CLAIMABLE'
|
|
@@ -130,6 +158,10 @@ export type TicketActionErrorCode =
|
|
|
130
158
|
| 'HUBSPOT_DISCONNECTED'
|
|
131
159
|
| 'RATE_LIMITED'
|
|
132
160
|
| 'INVALID_TOOL_ARGS'
|
|
161
|
+
| 'HUBSPOT_5XX'
|
|
162
|
+
| 'HUBSPOT_400_VALIDATION'
|
|
163
|
+
| 'HUBSPOT_404_THREAD'
|
|
164
|
+
| 'HUBSPOT_REPLY_UNKNOWN'
|
|
133
165
|
| 'UNKNOWN'
|
|
134
166
|
|
|
135
167
|
export interface MappedTicketActionError {
|