@handled-ai/design-system 0.18.36 → 0.18.38

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.
Files changed (42) hide show
  1. package/dist/charts/chart.d.ts +1 -1
  2. package/dist/components/draft-feedback-inline.d.ts +1 -1
  3. package/dist/components/draft-feedback-inline.js +10 -10
  4. package/dist/components/draft-feedback-inline.js.map +1 -1
  5. package/dist/components/email-composer-row.d.ts +11 -0
  6. package/dist/components/email-composer-row.js +82 -0
  7. package/dist/components/email-composer-row.js.map +1 -0
  8. package/dist/components/email-preview-card.d.ts +17 -0
  9. package/dist/components/email-preview-card.js +71 -0
  10. package/dist/components/email-preview-card.js.map +1 -0
  11. package/dist/components/email-recipient-field.d.ts +26 -0
  12. package/dist/components/email-recipient-field.js +403 -0
  13. package/dist/components/email-recipient-field.js.map +1 -0
  14. package/dist/components/email-send-bar.d.ts +22 -0
  15. package/dist/components/email-send-bar.js +66 -0
  16. package/dist/components/email-send-bar.js.map +1 -0
  17. package/dist/components/entity-panel.d.ts +1 -15
  18. package/dist/components/entity-panel.js +1 -74
  19. package/dist/components/entity-panel.js.map +1 -1
  20. package/dist/components/score-feedback.js +6 -6
  21. package/dist/components/score-feedback.js.map +1 -1
  22. package/dist/components/suggested-actions.js +5 -17
  23. package/dist/components/suggested-actions.js.map +1 -1
  24. package/dist/index.d.ts +5 -1
  25. package/dist/index.js +4 -0
  26. package/dist/index.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/components/__tests__/email-composer-row.test.tsx +51 -0
  29. package/src/components/__tests__/email-preview-card.test.tsx +62 -0
  30. package/src/components/__tests__/email-recipient-field.test.tsx +256 -0
  31. package/src/components/__tests__/email-send-bar.test.tsx +80 -0
  32. package/src/components/draft-feedback-inline.tsx +13 -13
  33. package/src/components/email-composer-row.tsx +47 -0
  34. package/src/components/email-preview-card.tsx +94 -0
  35. package/src/components/email-recipient-field.tsx +461 -0
  36. package/src/components/email-send-bar.tsx +95 -0
  37. package/src/components/entity-panel.tsx +0 -117
  38. package/src/components/score-feedback.tsx +7 -7
  39. package/src/components/suggested-actions.tsx +5 -19
  40. package/src/index.ts +4 -0
  41. package/src/components/__tests__/draft-feedback-inline.test.tsx +0 -72
  42. package/src/components/__tests__/suggested-actions-feedback-header.test.tsx +0 -86
@@ -0,0 +1,461 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { createPortal } from "react-dom"
5
+ import {
6
+ Check,
7
+ ChevronDown,
8
+ CornerDownLeft,
9
+ Plus,
10
+ Search,
11
+ Users,
12
+ X,
13
+ } from "lucide-react"
14
+
15
+ import { cn } from "../lib/utils"
16
+ import type { SuggestedContact } from "./suggested-actions"
17
+
18
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
19
+
20
+ function isValidEmail(value: string): boolean {
21
+ return EMAIL_REGEX.test(value.trim())
22
+ }
23
+
24
+ function contactEmail(contact: SuggestedContact): string | undefined {
25
+ return contact.email ?? contact.emails?.[0]
26
+ }
27
+
28
+ function getInitials(name: string, fallback: string): string {
29
+ const source = name?.trim() || fallback
30
+ return source
31
+ .split(/[\s@.]+/)
32
+ .map((part) => part[0])
33
+ .filter(Boolean)
34
+ .slice(0, 2)
35
+ .join("")
36
+ .toUpperCase()
37
+ }
38
+
39
+ export interface RecipientChip {
40
+ id: string
41
+ email: string
42
+ name: string
43
+ confirmed: boolean
44
+ }
45
+
46
+ export interface EmailRecipientFieldProps {
47
+ label: string
48
+ recipients: RecipientChip[]
49
+ onRecipientsChange: (recipients: RecipientChip[]) => void
50
+ amber?: boolean
51
+ contacts?: SuggestedContact[]
52
+ showPicker?: boolean
53
+ showCcBcc?: boolean
54
+ ccBccOpen?: boolean
55
+ onCcBccToggle?: () => void
56
+ addedEmails?: Set<string>
57
+ placeholder?: string
58
+ contactToRecipient?: (contact: SuggestedContact) => RecipientChip
59
+ }
60
+
61
+ function RecipientChipPill({
62
+ recipient,
63
+ onConfirm,
64
+ onRemove,
65
+ }: {
66
+ recipient: RecipientChip
67
+ onConfirm: () => void
68
+ onRemove: () => void
69
+ }) {
70
+ const display = recipient.name || recipient.email
71
+
72
+ if (!recipient.confirmed) {
73
+ return (
74
+ <span className="inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-amber-300 bg-amber-50 text-amber-900">
75
+ <span className="truncate max-w-[180px]">{display}</span>
76
+ <button
77
+ type="button"
78
+ onClick={onConfirm}
79
+ className="text-[10.5px] font-semibold px-[7px] py-0.5 rounded bg-amber-300/50 hover:bg-amber-300/85"
80
+ >
81
+ Confirm
82
+ </button>
83
+ <button
84
+ type="button"
85
+ aria-label={`Remove ${display}`}
86
+ onClick={onRemove}
87
+ className="inline-flex items-center justify-center size-[17px] rounded text-amber-700/80 hover:bg-amber-300/40"
88
+ >
89
+ <X className="size-3" />
90
+ </button>
91
+ </span>
92
+ )
93
+ }
94
+
95
+ return (
96
+ <span className="inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-border bg-muted/50 text-foreground">
97
+ <span className="inline-flex items-center justify-center size-[17px] rounded bg-emerald-50 text-emerald-700">
98
+ <Check className="size-3" />
99
+ </span>
100
+ <span className="truncate max-w-[180px]">{display}</span>
101
+ <button
102
+ type="button"
103
+ aria-label={`Remove ${display}`}
104
+ onClick={onRemove}
105
+ className="inline-flex items-center justify-center size-[17px] rounded text-muted-foreground hover:bg-muted"
106
+ >
107
+ <X className="size-3" />
108
+ </button>
109
+ </span>
110
+ )
111
+ }
112
+
113
+ function ContactPickerPopover({
114
+ triggerRef,
115
+ contacts,
116
+ addedEmails,
117
+ onSelect,
118
+ onAddEmail,
119
+ onClose,
120
+ }: {
121
+ triggerRef: React.RefObject<HTMLElement | null>
122
+ contacts: SuggestedContact[]
123
+ addedEmails: Set<string>
124
+ onSelect: (contact: SuggestedContact) => void
125
+ onAddEmail: (email: string) => void
126
+ onClose: () => void
127
+ }) {
128
+ const containerRef = React.useRef<HTMLDivElement>(null)
129
+ const searchRef = React.useRef<HTMLInputElement>(null)
130
+ const [query, setQuery] = React.useState("")
131
+ const [style, setStyle] = React.useState<React.CSSProperties>({
132
+ position: "fixed",
133
+ top: -9999,
134
+ left: -9999,
135
+ })
136
+
137
+ React.useEffect(() => {
138
+ const trigger = triggerRef.current
139
+ if (!trigger) return
140
+ const rect = trigger.getBoundingClientRect()
141
+ const width = Math.min(448, window.innerWidth - 32)
142
+ let left = rect.left
143
+ if (left + width > window.innerWidth - 16) {
144
+ left = window.innerWidth - 16 - width
145
+ }
146
+ if (left < 16) left = 16
147
+ const popoverHeight = 280
148
+ const spaceBelow = window.innerHeight - rect.bottom - 4
149
+ const spaceAbove = rect.top - 4
150
+ const placeAbove = spaceBelow < popoverHeight && spaceAbove > spaceBelow
151
+ let top = placeAbove ? rect.top - popoverHeight - 4 : rect.bottom + 4
152
+ if (top < 16) top = 16
153
+ setStyle({ position: "fixed", top, left, width })
154
+ }, [triggerRef])
155
+
156
+ React.useEffect(() => {
157
+ searchRef.current?.focus()
158
+ const trigger = triggerRef.current
159
+ return () => {
160
+ if (trigger && typeof trigger.focus === "function") {
161
+ trigger.focus()
162
+ }
163
+ }
164
+ }, [triggerRef])
165
+
166
+ React.useEffect(() => {
167
+ function handleMouseDown(event: MouseEvent) {
168
+ if (
169
+ containerRef.current &&
170
+ !containerRef.current.contains(event.target as Node) &&
171
+ !triggerRef.current?.contains(event.target as Node)
172
+ ) {
173
+ onClose()
174
+ }
175
+ }
176
+ function handleKeyDown(event: KeyboardEvent) {
177
+ if (event.key === "Escape") {
178
+ event.stopPropagation()
179
+ onClose()
180
+ }
181
+ }
182
+ document.addEventListener("mousedown", handleMouseDown)
183
+ document.addEventListener("keydown", handleKeyDown)
184
+ return () => {
185
+ document.removeEventListener("mousedown", handleMouseDown)
186
+ document.removeEventListener("keydown", handleKeyDown)
187
+ }
188
+ }, [onClose, triggerRef])
189
+
190
+ const normalizedQuery = query.trim().toLowerCase()
191
+ const filtered = normalizedQuery
192
+ ? contacts.filter((contact) => {
193
+ const email = contactEmail(contact) ?? ""
194
+ return (
195
+ contact.name.toLowerCase().includes(normalizedQuery) ||
196
+ contact.role.toLowerCase().includes(normalizedQuery) ||
197
+ email.toLowerCase().includes(normalizedQuery)
198
+ )
199
+ })
200
+ : contacts
201
+
202
+ const queryIsEmail = isValidEmail(query)
203
+
204
+ function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
205
+ if (event.key === "Enter" && queryIsEmail) {
206
+ event.preventDefault()
207
+ onAddEmail(query.trim())
208
+ setQuery("")
209
+ }
210
+ }
211
+
212
+ return createPortal(
213
+ <div
214
+ ref={containerRef}
215
+ style={style}
216
+ className="bg-background border rounded-lg shadow-xl z-50 pointer-events-auto"
217
+ >
218
+ <div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/50">
219
+ <Search className="size-4 text-muted-foreground shrink-0" />
220
+ <input
221
+ ref={searchRef}
222
+ autoFocus
223
+ value={query}
224
+ onChange={(event) => setQuery(event.target.value)}
225
+ onKeyDown={handleKeyDown}
226
+ className="flex-1 text-[13px] bg-transparent outline-none"
227
+ placeholder="Search contacts..."
228
+ />
229
+ </div>
230
+
231
+ <div role="listbox" className="max-h-[208px] overflow-y-auto p-1">
232
+ {filtered.length === 0 ? (
233
+ <div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
234
+ <div>No contact matches &lsquo;{query}&rsquo;.</div>
235
+ {queryIsEmail ? (
236
+ <div className="mt-1">Press Enter to add {query}.</div>
237
+ ) : null}
238
+ </div>
239
+ ) : (
240
+ filtered.map((contact, index) => {
241
+ const email = contactEmail(contact)
242
+ const noEmail = !email || !isValidEmail(email)
243
+ const alreadyAdded = email
244
+ ? addedEmails.has(email.toLowerCase())
245
+ : false
246
+ const disabled = noEmail || alreadyAdded
247
+
248
+ return (
249
+ <div
250
+ key={`${contact.name}-${email ?? index}`}
251
+ role="option"
252
+ aria-selected={false}
253
+ aria-disabled={disabled}
254
+ onClick={() => {
255
+ if (!disabled) onSelect(contact)
256
+ }}
257
+ className={cn(
258
+ "flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60",
259
+ disabled && "opacity-45 pointer-events-none",
260
+ )}
261
+ >
262
+ <div className="flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-muted text-[11px] font-medium text-muted-foreground">
263
+ {getInitials(contact.name, email ?? "?")}
264
+ </div>
265
+ <div className="min-w-0 flex-1">
266
+ <div className="flex items-center gap-1.5">
267
+ <span className="truncate text-[13px] font-medium text-foreground">
268
+ {contact.name}
269
+ </span>
270
+ <span className="truncate text-[11px] text-muted-foreground">
271
+ {contact.role}
272
+ </span>
273
+ </div>
274
+ {email ? (
275
+ <div className="truncate text-[11px] text-muted-foreground">
276
+ {email}
277
+ </div>
278
+ ) : null}
279
+ </div>
280
+ {alreadyAdded ? (
281
+ <span className="shrink-0 text-[10.5px] font-medium text-muted-foreground">
282
+ Added
283
+ </span>
284
+ ) : noEmail ? (
285
+ <span className="shrink-0 text-[10.5px] font-medium text-muted-foreground">
286
+ No email
287
+ </span>
288
+ ) : null}
289
+ </div>
290
+ )
291
+ })
292
+ )}
293
+ </div>
294
+
295
+ <div className="flex items-center gap-1.5 px-3 py-2 border-t border-border/50 text-[11px] text-muted-foreground">
296
+ <CornerDownLeft className="size-3 shrink-0" />
297
+ <span>Type an address and press Enter to add someone not listed.</span>
298
+ </div>
299
+ </div>,
300
+ document.body,
301
+ )
302
+ }
303
+
304
+ export function EmailRecipientField({
305
+ label,
306
+ recipients,
307
+ onRecipientsChange,
308
+ amber = false,
309
+ contacts = [],
310
+ showPicker = false,
311
+ showCcBcc = false,
312
+ ccBccOpen = false,
313
+ onCcBccToggle,
314
+ addedEmails,
315
+ placeholder,
316
+ contactToRecipient,
317
+ }: EmailRecipientFieldProps) {
318
+ const [value, setValue] = React.useState("")
319
+ const [pickerOpen, setPickerOpen] = React.useState(false)
320
+ const contactsTriggerRef = React.useRef<HTMLButtonElement>(null)
321
+
322
+ const hasUnconfirmed = recipients.some((r) => !r.confirmed)
323
+ const state: "default" | "amber" =
324
+ amber && hasUnconfirmed ? "amber" : "default"
325
+ const amberRow = state === "amber"
326
+
327
+ const added = addedEmails ?? new Set<string>()
328
+
329
+ const resolvedPlaceholder =
330
+ placeholder ?? (recipients.length > 0 ? "Add another..." : "Add email...")
331
+
332
+ function addEmail(email: string) {
333
+ const trimmed = email.trim()
334
+ if (!isValidEmail(trimmed)) return
335
+ if (added.has(trimmed.toLowerCase())) return
336
+ onRecipientsChange([
337
+ ...recipients,
338
+ { id: trimmed, email: trimmed, name: "", confirmed: false },
339
+ ])
340
+ setValue("")
341
+ }
342
+
343
+ function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
344
+ if ((event.key === "Enter" || event.key === ",") && isValidEmail(value)) {
345
+ event.preventDefault()
346
+ addEmail(value)
347
+ return
348
+ }
349
+ if (event.key === "Backspace" && value === "" && recipients.length > 0) {
350
+ event.preventDefault()
351
+ onRecipientsChange(recipients.slice(0, -1))
352
+ }
353
+ }
354
+
355
+ function confirmRecipient(id: string) {
356
+ onRecipientsChange(
357
+ recipients.map((r) => (r.id === id ? { ...r, confirmed: true } : r)),
358
+ )
359
+ }
360
+
361
+ function removeRecipient(id: string) {
362
+ onRecipientsChange(recipients.filter((r) => r.id !== id))
363
+ }
364
+
365
+ function selectContact(contact: SuggestedContact) {
366
+ const recipient =
367
+ contactToRecipient?.(contact) ??
368
+ ({
369
+ id: contactEmail(contact) ?? contact.name,
370
+ email: contactEmail(contact) ?? "",
371
+ name: contact.name,
372
+ confirmed: true,
373
+ } satisfies RecipientChip)
374
+ onRecipientsChange([...recipients, recipient])
375
+ setPickerOpen(false)
376
+ }
377
+
378
+ return (
379
+ <div
380
+ className={cn(
381
+ "grid grid-cols-[60px_1fr] gap-2 px-[18px] py-[9px] border-b border-border/70 items-start text-sm",
382
+ amberRow && "bg-amber-50/35 border-amber-200/80",
383
+ )}
384
+ >
385
+ <div
386
+ className={cn(
387
+ "text-[11px] font-semibold uppercase tracking-wide text-muted-foreground pt-[7px]",
388
+ amberRow && "text-amber-700",
389
+ )}
390
+ >
391
+ {label}
392
+ </div>
393
+
394
+ <div className="min-w-0">
395
+ <div className="flex flex-wrap gap-1.5 items-center">
396
+ {recipients.map((recipient) => (
397
+ <RecipientChipPill
398
+ key={recipient.id}
399
+ recipient={recipient}
400
+ onConfirm={() => confirmRecipient(recipient.id)}
401
+ onRemove={() => removeRecipient(recipient.id)}
402
+ />
403
+ ))}
404
+ <input
405
+ value={value}
406
+ onChange={(event) => setValue(event.target.value)}
407
+ onKeyDown={handleKeyDown}
408
+ onBlur={() => {
409
+ // Commit any valid pending email so it is not silently dropped
410
+ // when the user clicks Send without pressing Enter/comma first.
411
+ if (isValidEmail(value)) addEmail(value)
412
+ }}
413
+ placeholder={resolvedPlaceholder}
414
+ className="min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground"
415
+ />
416
+ </div>
417
+
418
+ {showPicker || showCcBcc ? (
419
+ <div className="flex gap-1.5 mt-2">
420
+ {showPicker ? (
421
+ <button
422
+ ref={contactsTriggerRef}
423
+ type="button"
424
+ onClick={() => setPickerOpen((open) => !open)}
425
+ className="inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]"
426
+ >
427
+ <Users className="size-3" />
428
+ Contacts
429
+ <ChevronDown className="size-3" />
430
+ </button>
431
+ ) : null}
432
+ {showCcBcc ? (
433
+ <button
434
+ type="button"
435
+ onClick={onCcBccToggle}
436
+ className="inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]"
437
+ >
438
+ <Plus className="size-3" />
439
+ {ccBccOpen ? "Hide Cc/Bcc" : "Add Cc/Bcc"}
440
+ </button>
441
+ ) : null}
442
+ </div>
443
+ ) : null}
444
+
445
+ {pickerOpen ? (
446
+ <ContactPickerPopover
447
+ triggerRef={contactsTriggerRef}
448
+ contacts={contacts}
449
+ addedEmails={added}
450
+ onSelect={selectContact}
451
+ onAddEmail={(email) => {
452
+ addEmail(email)
453
+ setPickerOpen(false)
454
+ }}
455
+ onClose={() => setPickerOpen(false)}
456
+ />
457
+ ) : null}
458
+ </div>
459
+ </div>
460
+ )
461
+ }
@@ -0,0 +1,95 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "../lib/utils"
6
+
7
+ export interface EmailSendBarAction {
8
+ key: string
9
+ label: string
10
+ icon?: React.ReactNode
11
+ onClick?: () => void
12
+ disabled?: boolean
13
+ }
14
+
15
+ export interface EmailSendBarProps {
16
+ primaryActions?: EmailSendBarAction[]
17
+ secondaryActions?: EmailSendBarAction[]
18
+ sendLabel?: string
19
+ sendIcon?: React.ReactNode
20
+ sendDisabled?: boolean
21
+ onSend?: () => void
22
+ sendHint?: React.ReactNode
23
+ className?: string
24
+ }
25
+
26
+ function GhostAction({ action }: { action: EmailSendBarAction }) {
27
+ return (
28
+ <button
29
+ type="button"
30
+ onClick={action.onClick}
31
+ disabled={action.disabled}
32
+ className="group inline-flex items-center gap-[7px] h-[30px] px-[11px] rounded-[7px] border border-transparent text-xs font-medium text-muted-foreground transition-all duration-[120ms] hover:bg-background hover:text-foreground hover:border-border hover:shadow-sm disabled:opacity-50 disabled:pointer-events-none"
33
+ >
34
+ {action.icon ? (
35
+ <span className="text-muted-foreground/60 group-hover:text-muted-foreground">
36
+ {action.icon}
37
+ </span>
38
+ ) : null}
39
+ {action.label}
40
+ </button>
41
+ )
42
+ }
43
+
44
+ export function EmailSendBar({
45
+ primaryActions = [],
46
+ secondaryActions = [],
47
+ sendLabel = "Send",
48
+ sendIcon,
49
+ sendDisabled = false,
50
+ onSend,
51
+ sendHint,
52
+ className,
53
+ }: EmailSendBarProps) {
54
+ const hasPrimary = primaryActions.length > 0
55
+ const hasSecondary = secondaryActions.length > 0
56
+
57
+ return (
58
+ <div
59
+ className={cn(
60
+ "flex items-center gap-1.5 px-3.5 py-2.5 border-t bg-muted/20",
61
+ className,
62
+ )}
63
+ >
64
+ {primaryActions.map((action) => (
65
+ <GhostAction key={action.key} action={action} />
66
+ ))}
67
+
68
+ {hasPrimary && hasSecondary ? (
69
+ <div className="w-px h-[18px] bg-border mx-1" />
70
+ ) : null}
71
+
72
+ {secondaryActions.map((action) => (
73
+ <GhostAction key={action.key} action={action} />
74
+ ))}
75
+
76
+ <div className="flex-1" />
77
+
78
+ {sendHint ? (
79
+ <span className="text-[11px] text-muted-foreground mr-1">
80
+ {sendHint}
81
+ </span>
82
+ ) : null}
83
+
84
+ <button
85
+ type="button"
86
+ onClick={onSend}
87
+ disabled={sendDisabled}
88
+ className="inline-flex items-center gap-2 h-8 px-3.5 rounded-md bg-foreground text-background text-xs font-semibold shadow-sm hover:bg-foreground/90 disabled:bg-muted disabled:text-muted-foreground disabled:shadow-none"
89
+ >
90
+ {sendIcon}
91
+ {sendLabel}
92
+ </button>
93
+ </div>
94
+ )
95
+ }
@@ -21,10 +21,8 @@ import {
21
21
  Maximize2,
22
22
  Minimize2,
23
23
  CalendarDays,
24
- ChevronRight,
25
24
  } from "lucide-react"
26
25
  import { TimelineActivity, type TimelineEvent } from "./timeline-activity"
27
- import { BRAND_ICONS } from "../lib/icons"
28
26
 
29
27
  // ---------------------------------------------------------------------------
30
28
  // EntityPanel -- supports Sheet (side panel), wide, and fullscreen modes
@@ -780,121 +778,6 @@ export function EntityDetails({ onClose: _onClose }: { onClose?: () => void }) {
780
778
  // SourcesToggle – collapsible sources list
781
779
  // ---------------------------------------------------------------------------
782
780
 
783
- // ---------------------------------------------------------------------------
784
- // RelatedRecordActionCard – clickable card for related records (cases, accounts, etc.)
785
- // ---------------------------------------------------------------------------
786
-
787
- /** Icon can be a ReactNode (e.g. a Lucide icon) or the string "salesforce" for the brand logo. */
788
- export type RelatedRecordActionIcon = React.ReactNode | "salesforce"
789
-
790
- export interface RelatedRecordActionCardProps {
791
- kind: string
792
- label: string
793
- subtitle?: string
794
- href?: string
795
- external?: boolean
796
- icon?: RelatedRecordActionIcon
797
- onClick?: () => void
798
- disabledReason?: string
799
- testId?: string
800
- }
801
-
802
- export function RelatedRecordActionCard({
803
- kind: _kind,
804
- label,
805
- subtitle,
806
- href,
807
- external,
808
- icon,
809
- onClick,
810
- disabledReason,
811
- testId,
812
- }: RelatedRecordActionCardProps) {
813
- const disabled = !!disabledReason
814
-
815
- const iconNode =
816
- icon === "salesforce" ? (
817
- <img
818
- src={BRAND_ICONS.salesforce}
819
- alt="Salesforce"
820
- className="w-4 h-4 object-contain"
821
- />
822
- ) : (
823
- icon ?? <FileText className="h-4 w-4" aria-hidden="true" />
824
- )
825
-
826
- const content = (
827
- <>
828
- <div className="flex items-center gap-2.5 min-w-0">
829
- <div className="w-7 h-7 rounded-md border border-border/60 bg-muted/30 flex items-center justify-center shrink-0 text-muted-foreground">
830
- {iconNode}
831
- </div>
832
- <div className="min-w-0">
833
- <p className="text-sm font-medium text-foreground leading-snug truncate">
834
- {label}
835
- </p>
836
- {subtitle && (
837
- <p className="text-[11px] text-muted-foreground/70 truncate">
838
- {subtitle}
839
- </p>
840
- )}
841
- </div>
842
- </div>
843
- <div className="flex items-center gap-1 shrink-0 text-muted-foreground">
844
- {external && <ExternalLink className="w-3 h-3" />}
845
- <ChevronRight className="w-3.5 h-3.5" />
846
- </div>
847
- </>
848
- )
849
-
850
- const baseClassName =
851
- "flex items-center justify-between gap-3 py-2 px-2 -mx-2 rounded-md transition-colors"
852
- const interactiveClassName = disabled
853
- ? `${baseClassName} opacity-50 cursor-not-allowed`
854
- : `${baseClassName} hover:bg-muted/50 cursor-pointer`
855
-
856
- if (disabled) {
857
- return (
858
- <div
859
- className={interactiveClassName}
860
- title={disabledReason}
861
- data-testid={testId}
862
- >
863
- {content}
864
- </div>
865
- )
866
- }
867
-
868
- if (href) {
869
- return (
870
- <a
871
- href={href}
872
- target={external ? "_blank" : undefined}
873
- rel={external ? "noopener noreferrer" : undefined}
874
- className={interactiveClassName}
875
- data-testid={testId}
876
- >
877
- {content}
878
- </a>
879
- )
880
- }
881
-
882
- return (
883
- <button
884
- type="button"
885
- onClick={onClick}
886
- className={`${interactiveClassName} w-full text-left`}
887
- data-testid={testId}
888
- >
889
- {content}
890
- </button>
891
- )
892
- }
893
-
894
- // ---------------------------------------------------------------------------
895
- // SourcesToggle – collapsible sources list
896
- // ---------------------------------------------------------------------------
897
-
898
781
  function SourcesToggle() {
899
782
  const [expanded, setExpanded] = React.useState(false)
900
783