@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.
- package/dist/charts/chart.d.ts +1 -1
- package/dist/components/draft-feedback-inline.d.ts +1 -1
- package/dist/components/draft-feedback-inline.js +10 -10
- package/dist/components/draft-feedback-inline.js.map +1 -1
- package/dist/components/email-composer-row.d.ts +11 -0
- package/dist/components/email-composer-row.js +82 -0
- package/dist/components/email-composer-row.js.map +1 -0
- package/dist/components/email-preview-card.d.ts +17 -0
- package/dist/components/email-preview-card.js +71 -0
- package/dist/components/email-preview-card.js.map +1 -0
- package/dist/components/email-recipient-field.d.ts +26 -0
- package/dist/components/email-recipient-field.js +403 -0
- package/dist/components/email-recipient-field.js.map +1 -0
- package/dist/components/email-send-bar.d.ts +22 -0
- package/dist/components/email-send-bar.js +66 -0
- package/dist/components/email-send-bar.js.map +1 -0
- package/dist/components/entity-panel.d.ts +1 -15
- package/dist/components/entity-panel.js +1 -74
- package/dist/components/entity-panel.js.map +1 -1
- package/dist/components/score-feedback.js +6 -6
- package/dist/components/score-feedback.js.map +1 -1
- package/dist/components/suggested-actions.js +5 -17
- package/dist/components/suggested-actions.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/email-composer-row.test.tsx +51 -0
- package/src/components/__tests__/email-preview-card.test.tsx +62 -0
- package/src/components/__tests__/email-recipient-field.test.tsx +256 -0
- package/src/components/__tests__/email-send-bar.test.tsx +80 -0
- package/src/components/draft-feedback-inline.tsx +13 -13
- package/src/components/email-composer-row.tsx +47 -0
- package/src/components/email-preview-card.tsx +94 -0
- package/src/components/email-recipient-field.tsx +461 -0
- package/src/components/email-send-bar.tsx +95 -0
- package/src/components/entity-panel.tsx +0 -117
- package/src/components/score-feedback.tsx +7 -7
- package/src/components/suggested-actions.tsx +5 -19
- package/src/index.ts +4 -0
- package/src/components/__tests__/draft-feedback-inline.test.tsx +0 -72
- 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 ‘{query}’.</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
|
|