@adatechnology/conversations-ui 0.1.1 → 0.2.1

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 (49) hide show
  1. package/dist/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
  2. package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
  3. package/dist/index.d.ts +304 -9
  4. package/dist/index.js +2181 -507
  5. package/dist/preview/index.d.ts +2 -2
  6. package/dist/preview/index.js +147 -1
  7. package/dist/styles.css +135 -0
  8. package/package.json +1 -1
  9. package/src/MessageComposer.test.tsx +14 -0
  10. package/src/MessageComposer.tsx +327 -73
  11. package/src/RichMessageComposer.test.tsx +22 -4
  12. package/src/RichMessageComposer.tsx +674 -370
  13. package/src/index.ts +28 -1
  14. package/src/preview/createMockConversationsApi.ts +184 -0
  15. package/src/providers/types.ts +34 -0
  16. package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
  17. package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
  18. package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
  19. package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
  20. package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
  21. package/src/quickReplies/createUploadQueue.test.ts +106 -0
  22. package/src/quickReplies/createUploadQueue.ts +69 -0
  23. package/src/quickReplies/index.ts +5 -0
  24. package/src/quickReplies/labels.ts +124 -0
  25. package/src/quickReplies/quickReply.types.ts +74 -0
  26. package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
  27. package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
  28. package/src/quickReplies/quickReplyAttachments.test.ts +467 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +328 -0
  30. package/src/quickReplies/quickReplySearch.test.ts +88 -0
  31. package/src/quickReplies/quickReplySearch.ts +104 -0
  32. package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
  33. package/src/quickReplies/quickReplyShortcut.ts +46 -0
  34. package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
  35. package/src/quickReplies/resolveConversationVariables.ts +41 -0
  36. package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
  37. package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
  38. package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
  39. package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
  40. package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
  41. package/src/styles.css +87 -0
  42. package/src/workspace/ConversationPane.tsx +137 -73
  43. package/src/workspace/ConversationsWorkspace.tsx +16 -1
  44. package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
  45. package/src/workspace/QueuedAttachmentsList.tsx +198 -0
  46. package/src/workspace/index.ts +1 -0
  47. package/src/workspace/labels.ts +14 -0
  48. package/src/workspace/useComposerAttachmentRetry.ts +169 -0
  49. package/src/workspace/useComposerQueue.ts +229 -0
@@ -1,9 +1,23 @@
1
- import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent, type ReactNode } from 'react'
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ type KeyboardEvent,
8
+ type ChangeEvent,
9
+ type ReactNode,
10
+ } from 'react'
2
11
  import { AudioRecorderButton } from './AudioRecorderButton'
3
12
  import type { ConversationsFeatures } from './types'
4
13
  import { cn } from './lib/cn'
5
14
  import { COMPOSER_BAR_CLASS, QUICK_REPLY_PILL_CLASS } from './composer.constant'
6
15
  import { EmojiPicker } from './EmojiPicker'
16
+ import { QuickRepliesPicker } from './quickReplies/QuickRepliesPicker'
17
+ import { useQuickRepliesPicker } from './quickReplies/useQuickRepliesPicker'
18
+ import { detectQuickReplyShortcut, replaceQuickReplyShortcut } from './quickReplies/quickReplyShortcut'
19
+ import type { QuickReply as SavedQuickReply } from './quickReplies/quickReply.types'
20
+ import type { QuickRepliesPickerLabels } from './quickReplies/labels'
7
21
 
8
22
  /**
9
23
  * Mensagem pronta que o atendente cola no campo com um clique.
@@ -23,17 +37,11 @@ export interface QuickReply {
23
37
  * Troca `{{nome}}` pelos valores passados. Variável ausente vira string vazia, e não o literal
24
38
  * `{{nome}}`: mandar "Olá {{nome}}!" para o cliente é pior que mandar "Olá !".
25
39
  */
26
- export function applyQuickReplyVariables(
27
- template: string,
28
- variables: Readonly<Record<string, string>> = {},
29
- ): string {
40
+ export function applyQuickReplyVariables(template: string, variables: Readonly<Record<string, string>> = {}): string {
30
41
  return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, chave: string) => variables[chave] ?? '')
31
42
  }
32
43
 
33
- export function resolveQuickReply(
34
- quickReply: QuickReply,
35
- variables: Readonly<Record<string, string>> = {},
36
- ): string {
44
+ export function resolveQuickReply(quickReply: QuickReply, variables: Readonly<Record<string, string>> = {}): string {
37
45
  return typeof quickReply.text === 'function'
38
46
  ? quickReply.text(variables)
39
47
  : applyQuickReplyVariables(quickReply.text, variables)
@@ -44,6 +52,7 @@ export interface MessageComposerLabels {
44
52
  attach: string
45
53
  send: string
46
54
  removeAttachment: string
55
+ quickReplies: string
47
56
  }
48
57
 
49
58
  export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
@@ -51,6 +60,27 @@ export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
51
60
  attach: 'Anexar',
52
61
  send: 'Enviar',
53
62
  removeAttachment: 'Remover anexo',
63
+ quickReplies: 'Mensagens prontas',
64
+ }
65
+
66
+ /**
67
+ * Mensagens prontas do produto, cadastradas pela tela de gestão. **Opcional por capacidade:** sem
68
+ * esta prop o botão de raio e o atalho `/` simplesmente não existem — não um botão que abre uma
69
+ * lista vazia.
70
+ */
71
+ export interface MessageComposerSavedQuickReplies {
72
+ readonly listQuickReplies: (params?: { search?: string }) => Promise<SavedQuickReply[]>
73
+ /** Chave do cache do picker — troca de conversa não deve reconsultar o que já carregou. */
74
+ readonly conversationId: string
75
+ readonly variables?: Readonly<Record<string, string>>
76
+ readonly labels?: Partial<QuickRepliesPickerLabels>
77
+ /**
78
+ * Avisada antes de inserir o texto, para o host empurrar os anexos da mensagem escolhida na fila
79
+ * do composer (QR-32). Sem esta prop nada muda — o texto continua sendo inserido do mesmo jeito.
80
+ */
81
+ readonly onSelect?: (quickReply: SavedQuickReply) => void
82
+ /** Sem `sendStoredAttachments` no host, a linha com anexo avisa em vez de prometer envio (QR-33). */
83
+ readonly hasAttachmentsCapability?: boolean
54
84
  }
55
85
 
56
86
  export interface MessageComposerProps {
@@ -73,6 +103,8 @@ export interface MessageComposerProps {
73
103
  quickReplies?: readonly QuickReply[]
74
104
  /** Valores para `{{variavel}}` — tipicamente nome do cliente, produto, protocolo. */
75
105
  quickReplyVariables?: Readonly<Record<string, string>>
106
+ /** Botão de raio + atalho `/` para as mensagens prontas cadastradas. Ausente, nenhum dos dois aparece. */
107
+ savedQuickReplies?: MessageComposerSavedQuickReplies
76
108
  className?: string
77
109
  classNames?: Partial<MessageComposerClassNames>
78
110
  }
@@ -113,6 +145,7 @@ export const MessageComposer = ({
113
145
  onChange: externalOnChange,
114
146
  quickReplies,
115
147
  quickReplyVariables,
148
+ savedQuickReplies,
116
149
  features,
117
150
  placeholder = 'Digite uma mensagem...',
118
151
  maxLength,
@@ -127,25 +160,150 @@ export const MessageComposer = ({
127
160
  const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
128
161
  const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
129
162
  const removeAttachmentLabel = labels?.removeAttachment ?? DEFAULT_MESSAGE_COMPOSER_LABELS.removeAttachment
163
+ const quickRepliesLabel = labels?.quickReplies ?? DEFAULT_MESSAGE_COMPOSER_LABELS.quickReplies
130
164
  const [internalText, setInternalText] = useState('')
131
165
  const [showEmoji, setShowEmoji] = useState(false)
132
166
  const [attachments, setAttachments] = useState<FilePreview[]>([])
133
167
  const textareaRef = useRef<HTMLTextAreaElement>(null)
134
168
  const fileInputRef = useRef<HTMLInputElement>(null)
169
+ const quickRepliesListboxId = useId()
170
+ const [isQuickRepliesOpen, setIsQuickRepliesOpen] = useState(false)
171
+ const [quickRepliesTerm, setQuickRepliesTerm] = useState('')
172
+ const [shortcutStart, setShortcutStart] = useState<number | null>(null)
173
+ /** `shortcut`: a busca vem do `/termo` digitado no campo. `button`: busca própria do picker — o
174
+ * raio nunca insere `/` na mensagem (QR-04). */
175
+ const [quickRepliesMode, setQuickRepliesMode] = useState<'shortcut' | 'button'>('shortcut')
176
+ const [caretPosition, setCaretPosition] = useState(0)
177
+ const quickRepliesPopoverRef = useRef<HTMLDivElement>(null)
178
+ const quickRepliesTriggerRef = useRef<HTMLButtonElement>(null)
135
179
 
136
180
  const isControlled = externalValue !== undefined
137
181
  const text = isControlled ? externalValue : internalText
138
182
 
139
- const setText = useCallback((newText: string) => {
140
- if (isControlled) {
141
- externalOnChange?.(newText)
142
- } else {
143
- setInternalText(newText)
144
- }
145
- }, [isControlled, externalOnChange])
183
+ const setText = useCallback(
184
+ (newText: string) => {
185
+ if (isControlled) {
186
+ externalOnChange?.(newText)
187
+ } else {
188
+ setInternalText(newText)
189
+ }
190
+ },
191
+ [isControlled, externalOnChange],
192
+ )
146
193
 
147
194
  const showEmojiButton = features?.emoji !== false
148
195
  const showAttachButton = features?.documents !== false
196
+ const showQuickRepliesButton = Boolean(savedQuickReplies)
197
+
198
+ // Deriva o picker só do texto até o cursor: digitar "/doc" abre, apagar a "/" fecha, e mover o
199
+ // cursor para antes do atalho fecha também — sem isso o picker continuava aberto olhando para um
200
+ // "/termo" que não é mais o que está na frente do cursor (a raiz do bug de caret).
201
+ useEffect(() => {
202
+ if (!showQuickRepliesButton || quickRepliesMode === 'button') return
203
+ const match = detectQuickReplyShortcut(text.slice(0, caretPosition))
204
+ setIsQuickRepliesOpen(Boolean(match))
205
+ setQuickRepliesTerm(match?.term ?? '')
206
+ setShortcutStart(match?.start ?? null)
207
+ }, [text, caretPosition, showQuickRepliesButton, quickRepliesMode])
208
+
209
+ useEffect(() => {
210
+ if (!showQuickRepliesButton) setIsQuickRepliesOpen(false)
211
+ }, [showQuickRepliesButton])
212
+
213
+ const closeQuickReplies = useCallback(() => {
214
+ setIsQuickRepliesOpen(false)
215
+ setShortcutStart(null)
216
+ setQuickRepliesTerm('')
217
+ setQuickRepliesMode('shortcut')
218
+ }, [])
219
+
220
+ const closeQuickRepliesAndRefocus = useCallback(() => {
221
+ closeQuickReplies()
222
+ textareaRef.current?.focus()
223
+ }, [closeQuickReplies])
224
+
225
+ const trackCaret = useCallback((event: { currentTarget: HTMLTextAreaElement }) => {
226
+ setCaretPosition(event.currentTarget.selectionStart)
227
+ }, [])
228
+
229
+ const insertSavedQuickReply = useCallback(
230
+ (quickReply: SavedQuickReply) => {
231
+ savedQuickReplies?.onSelect?.(quickReply)
232
+ const ta = textareaRef.current
233
+ if (!ta) return
234
+ const resolvedBody = applyQuickReplyVariables(quickReply.body, savedQuickReplies?.variables)
235
+
236
+ if (quickRepliesMode === 'button') {
237
+ const start = ta.selectionStart
238
+ const end = ta.selectionEnd
239
+ const newText = text.slice(0, start) + resolvedBody + text.slice(end)
240
+ setText(newText)
241
+ closeQuickReplies()
242
+ requestAnimationFrame(() => {
243
+ ta.focus()
244
+ const caret = start + resolvedBody.length
245
+ ta.setSelectionRange(caret, caret)
246
+ })
247
+ return
248
+ }
249
+
250
+ if (shortcutStart === null) return
251
+ // Faixa exata do atalho detectado (`/` + termo), nunca a posição atual do cursor — que pode
252
+ // ter se movido para antes ou depois do "/termo" desde que ele foi digitado.
253
+ const shortcutEnd = shortcutStart + 1 + quickRepliesTerm.length
254
+ const { text: newText, caret } = replaceQuickReplyShortcut({
255
+ text,
256
+ start: shortcutStart,
257
+ caret: shortcutEnd,
258
+ replacement: resolvedBody,
259
+ })
260
+ setText(newText)
261
+ closeQuickReplies()
262
+ requestAnimationFrame(() => {
263
+ ta.focus()
264
+ ta.setSelectionRange(caret, caret)
265
+ })
266
+ },
267
+ [text, setText, shortcutStart, quickRepliesTerm, quickRepliesMode, savedQuickReplies, closeQuickReplies],
268
+ )
269
+
270
+ const quickRepliesPicker = useQuickRepliesPicker({
271
+ isOpen: isQuickRepliesOpen,
272
+ search: quickRepliesTerm,
273
+ listQuickReplies: savedQuickReplies?.listQuickReplies,
274
+ quickReplyVariables: savedQuickReplies?.variables,
275
+ onSelect: insertSavedQuickReply,
276
+ onClose: closeQuickRepliesAndRefocus,
277
+ })
278
+
279
+ // Botão de raio: abre o picker com busca própria — o campo de mensagem não é tocado (QR-04).
280
+ // Clicar de novo com o picker já aberto nesse modo fecha — o raio é um toggle, não só um "abrir".
281
+ const openQuickRepliesViaButton = useCallback(() => {
282
+ if (isQuickRepliesOpen && quickRepliesMode === 'button') {
283
+ closeQuickRepliesAndRefocus()
284
+ return
285
+ }
286
+ setQuickRepliesMode('button')
287
+ setQuickRepliesTerm('')
288
+ setShortcutStart(null)
289
+ setIsQuickRepliesOpen(true)
290
+ }, [isQuickRepliesOpen, quickRepliesMode, closeQuickRepliesAndRefocus])
291
+
292
+ // Clique fora do popover em modo botão fecha e devolve o foco ao campo — a busca própria não
293
+ // tem `blur` do campo para fechar sozinha, como o modo atalho tem. O próprio botão de raio é
294
+ // ignorado aqui: o `mousedown` nele já é tratado por `openQuickRepliesViaButton` como toggle, e
295
+ // sem essa exclusão o outside-click fechava primeiro e o `onClick` do botão reabria em seguida.
296
+ useEffect(() => {
297
+ if (!isQuickRepliesOpen || quickRepliesMode !== 'button') return
298
+ const handleOutsideClick = (event: MouseEvent) => {
299
+ const target = event.target as Node
300
+ if (quickRepliesPopoverRef.current?.contains(target)) return
301
+ if (quickRepliesTriggerRef.current?.contains(target)) return
302
+ closeQuickRepliesAndRefocus()
303
+ }
304
+ document.addEventListener('mousedown', handleOutsideClick)
305
+ return () => document.removeEventListener('mousedown', handleOutsideClick)
306
+ }, [isQuickRepliesOpen, quickRepliesMode, closeQuickRepliesAndRefocus])
149
307
 
150
308
  const sendMessage = useCallback(() => {
151
309
  const trimmed = text.trim()
@@ -161,12 +319,20 @@ export const MessageComposer = ({
161
319
  if (textareaRef.current) textareaRef.current.style.height = 'auto'
162
320
  }, [text, attachments, onSend, onAttach, isControlled])
163
321
 
164
- const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
165
- if (e.key === 'Enter' && !e.shiftKey) {
166
- e.preventDefault()
167
- if (!disabled) sendMessage()
168
- }
169
- }, [sendMessage, disabled])
322
+ const handleKeyDown = useCallback(
323
+ (e: KeyboardEvent<HTMLTextAreaElement>) => {
324
+ // Em modo botão quem recebe as teclas é a busca própria do picker, não o campo.
325
+ if (isQuickRepliesOpen && quickRepliesMode === 'shortcut') {
326
+ quickRepliesPicker.handleKeyDown(e)
327
+ return
328
+ }
329
+ if (e.key === 'Enter' && !e.shiftKey) {
330
+ e.preventDefault()
331
+ if (!disabled) sendMessage()
332
+ }
333
+ },
334
+ [sendMessage, disabled, isQuickRepliesOpen, quickRepliesMode, quickRepliesPicker],
335
+ )
170
336
 
171
337
  const handleInput = useCallback(() => {
172
338
  const ta = textareaRef.current
@@ -175,19 +341,25 @@ export const MessageComposer = ({
175
341
  ta.style.height = `${Math.min(ta.scrollHeight, 100)}px`
176
342
  }, [])
177
343
 
178
- const handleEmojiSelect = useCallback((emoji: string) => {
179
- const ta = textareaRef.current
180
- if (!ta) { setText(text + emoji); return }
181
- const start = ta.selectionStart
182
- const end = ta.selectionEnd
183
- const newText = text.slice(0, start) + emoji + text.slice(end)
184
- setText(newText)
185
- requestAnimationFrame(() => {
186
- ta.focus()
187
- ta.setSelectionRange(start + emoji.length, start + emoji.length)
188
- handleInput()
189
- })
190
- }, [text, setText, handleInput])
344
+ const handleEmojiSelect = useCallback(
345
+ (emoji: string) => {
346
+ const ta = textareaRef.current
347
+ if (!ta) {
348
+ setText(text + emoji)
349
+ return
350
+ }
351
+ const start = ta.selectionStart
352
+ const end = ta.selectionEnd
353
+ const newText = text.slice(0, start) + emoji + text.slice(end)
354
+ setText(newText)
355
+ requestAnimationFrame(() => {
356
+ ta.focus()
357
+ ta.setSelectionRange(start + emoji.length, start + emoji.length)
358
+ handleInput()
359
+ })
360
+ },
361
+ [text, setText, handleInput],
362
+ )
191
363
 
192
364
  const handleFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
193
365
  const files = e.target.files
@@ -197,12 +369,12 @@ export const MessageComposer = ({
197
369
  const file = files[i]
198
370
  previews.push({ file, previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' })
199
371
  }
200
- setAttachments(prev => [...prev, ...previews])
372
+ setAttachments((prev) => [...prev, ...previews])
201
373
  if (fileInputRef.current) fileInputRef.current.value = ''
202
374
  }, [])
203
375
 
204
376
  const removeAttachment = useCallback((index: number) => {
205
- setAttachments(prev => {
377
+ setAttachments((prev) => {
206
378
  const next = [...prev]
207
379
  if (next[index].previewUrl) URL.revokeObjectURL(next[index].previewUrl)
208
380
  next.splice(index, 1)
@@ -210,20 +382,6 @@ export const MessageComposer = ({
210
382
  })
211
383
  }, [])
212
384
 
213
- const insertFormatting = useCallback((marker: string) => {
214
- const ta = textareaRef.current
215
- if (!ta) return
216
- const start = ta.selectionStart; const end = ta.selectionEnd
217
- const sel = text.slice(start, end)
218
- if (sel) {
219
- setText(text.slice(0, start) + marker + sel + marker + text.slice(end))
220
- requestAnimationFrame(() => {
221
- ta.focus()
222
- ta.setSelectionRange(start + marker.length + sel.length + marker.length, start + marker.length + sel.length + marker.length)
223
- })
224
- }
225
- }, [text, setText])
226
-
227
385
  /**
228
386
  * Microfone por padrão, sem o host precisar compor nada.
229
387
  *
@@ -239,8 +397,7 @@ export const MessageComposer = ({
239
397
  * duração ou revisão diferentes) não muda de comportamento ao atualizar.
240
398
  */
241
399
  const effectiveIdleAction =
242
- idleAction ??
243
- (onAttach ? <AudioRecorderButton onRecorded={(file) => onAttach(file)} /> : undefined)
400
+ idleAction ?? (onAttach ? <AudioRecorderButton onRecorded={(file) => onAttach(file)} /> : undefined)
244
401
 
245
402
  const canSend = text.trim().length > 0 || attachments.length > 0
246
403
  const remaining = maxLength ? maxLength - text.length : null
@@ -261,7 +418,8 @@ export const MessageComposer = ({
261
418
  >
262
419
  {quickReplies.map((quickReply) => (
263
420
  <button
264
- data-cv-tooltip={quickReply.label} aria-label={quickReply.label}
421
+ data-cv-tooltip={quickReply.label}
422
+ aria-label={quickReply.label}
265
423
  key={quickReply.key}
266
424
  type="button"
267
425
  // Preenche o campo em vez de enviar: mensagem pronta é ponto de partida, e quem
@@ -287,10 +445,20 @@ export const MessageComposer = ({
287
445
  <img src={a.previewUrl} alt="" className="w-16 h-16 object-cover rounded-lg border border-gray-200" />
288
446
  ) : (
289
447
  <div className="w-16 h-16 bg-gray-100 rounded-lg border border-gray-200 flex items-center justify-center">
290
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
448
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="1.5">
449
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
450
+ <polyline points="14 2 14 8 20 8" />
451
+ </svg>
291
452
  </div>
292
453
  )}
293
- <button data-cv-tooltip={removeAttachmentLabel} aria-label={removeAttachmentLabel} onClick={() => removeAttachment(i)} className="absolute -top-2 -right-2 w-5 h-5 bg-gray-600 text-white rounded-full flex items-center justify-center hover:bg-gray-800 text-xs">✕</button>
454
+ <button
455
+ data-cv-tooltip={removeAttachmentLabel}
456
+ aria-label={removeAttachmentLabel}
457
+ onClick={() => removeAttachment(i)}
458
+ className="absolute -top-2 -right-2 w-5 h-5 bg-gray-600 text-white rounded-full flex items-center justify-center hover:bg-gray-800 text-xs"
459
+ >
460
+
461
+ </button>
294
462
  </div>
295
463
  ))}
296
464
  </div>
@@ -299,8 +467,18 @@ export const MessageComposer = ({
299
467
  <div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
300
468
  {showEmojiButton && (
301
469
  <div className="relative flex-shrink-0">
302
- <button data-cv-tooltip={emojiLabel} onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label={emojiLabel}>
303
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><circle cx="9" cy="9" r="0.5" fill="currentColor"/><circle cx="15" cy="9" r="0.5" fill="currentColor"/></svg>
470
+ <button
471
+ data-cv-tooltip={emojiLabel}
472
+ onClick={() => setShowEmoji((v) => !v)}
473
+ className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors"
474
+ aria-label={emojiLabel}
475
+ >
476
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
477
+ <circle cx="12" cy="12" r="10" />
478
+ <path d="M8 14s1.5 2 4 2 4-2 4-2" />
479
+ <circle cx="9" cy="9" r="0.5" fill="currentColor" />
480
+ <circle cx="15" cy="9" r="0.5" fill="currentColor" />
481
+ </svg>
304
482
  </button>
305
483
  {showEmoji && (
306
484
  <div className="absolute bottom-full left-0 mb-2 z-10">
@@ -310,23 +488,97 @@ export const MessageComposer = ({
310
488
  </div>
311
489
  )}
312
490
 
313
- <textarea
314
- ref={textareaRef}
315
- value={text}
316
- onChange={e => { setText(e.target.value); handleInput() }}
317
- onKeyDown={handleKeyDown}
318
- placeholder={placeholder}
319
- rows={1}
320
- disabled={disabled}
321
- className="flex-1 resize-none bg-transparent text-[15px] text-[#3b4a54] placeholder-[#8696a0] outline-none py-1.5 max-h-[100px] leading-relaxed"
322
- style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" }}
323
- />
491
+ <div className="relative flex-1">
492
+ <textarea
493
+ ref={textareaRef}
494
+ value={text}
495
+ onChange={(e) => {
496
+ setText(e.target.value)
497
+ setCaretPosition(e.target.selectionStart)
498
+ handleInput()
499
+ }}
500
+ onKeyDown={handleKeyDown}
501
+ onKeyUp={trackCaret}
502
+ onClick={trackCaret}
503
+ onSelect={trackCaret}
504
+ placeholder={placeholder}
505
+ rows={1}
506
+ disabled={disabled}
507
+ role={showQuickRepliesButton ? 'combobox' : undefined}
508
+ aria-expanded={showQuickRepliesButton ? isQuickRepliesOpen : undefined}
509
+ aria-controls={showQuickRepliesButton ? quickRepliesListboxId : undefined}
510
+ aria-activedescendant={
511
+ isQuickRepliesOpen && quickRepliesMode === 'shortcut'
512
+ ? `${quickRepliesListboxId}-option-${quickRepliesPicker.highlightedIndex}`
513
+ : undefined
514
+ }
515
+ className="w-full resize-none bg-transparent text-[15px] text-[#3b4a54] placeholder-[#8696a0] outline-none py-1.5 max-h-[100px] leading-relaxed"
516
+ style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" }}
517
+ />
518
+ {isQuickRepliesOpen && (
519
+ <div ref={quickRepliesPopoverRef} className="absolute bottom-full left-0 mb-2 z-10">
520
+ <QuickRepliesPicker
521
+ key={quickRepliesMode}
522
+ id={quickRepliesListboxId}
523
+ items={quickRepliesPicker.items}
524
+ search={quickRepliesTerm}
525
+ highlightedIndex={quickRepliesPicker.highlightedIndex}
526
+ isLoading={quickRepliesPicker.isLoading}
527
+ hasError={quickRepliesPicker.hasError}
528
+ onHover={quickRepliesPicker.setHighlightedIndex}
529
+ onSelect={insertSavedQuickReply}
530
+ labels={savedQuickReplies?.labels}
531
+ variables={savedQuickReplies?.variables}
532
+ hasAttachmentsCapability={savedQuickReplies?.hasAttachmentsCapability}
533
+ ownSearch={
534
+ quickRepliesMode === 'button'
535
+ ? {
536
+ value: quickRepliesTerm,
537
+ onChange: setQuickRepliesTerm,
538
+ onKeyDown: quickRepliesPicker.handleKeyDown,
539
+ label: quickRepliesLabel,
540
+ }
541
+ : undefined
542
+ }
543
+ />
544
+ </div>
545
+ )}
546
+ </div>
547
+
548
+ {showQuickRepliesButton && (
549
+ <button
550
+ ref={quickRepliesTriggerRef}
551
+ type="button"
552
+ data-cv-tooltip={quickRepliesLabel}
553
+ aria-label={quickRepliesLabel}
554
+ onClick={openQuickRepliesViaButton}
555
+ className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors"
556
+ >
557
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
558
+ <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
559
+ </svg>
560
+ </button>
561
+ )}
324
562
 
325
563
  {showAttachButton && (
326
564
  <>
327
- <input ref={fileInputRef} type="file" multiple accept={acceptedFileTypes} onChange={handleFileChange} className="hidden" />
328
- <button data-cv-tooltip={attachLabel} onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label={attachLabel}>
329
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
565
+ <input
566
+ ref={fileInputRef}
567
+ type="file"
568
+ multiple
569
+ accept={acceptedFileTypes}
570
+ onChange={handleFileChange}
571
+ className="hidden"
572
+ />
573
+ <button
574
+ data-cv-tooltip={attachLabel}
575
+ onClick={() => fileInputRef.current?.click()}
576
+ className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors"
577
+ aria-label={attachLabel}
578
+ >
579
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
580
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
581
+ </svg>
330
582
  </button>
331
583
  </>
332
584
  )}
@@ -345,7 +597,9 @@ export const MessageComposer = ({
345
597
  }`}
346
598
  aria-label={sendLabel}
347
599
  >
348
- <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
600
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
601
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
602
+ </svg>
349
603
  </button>
350
604
  )}
351
605
  </div>
@@ -9,9 +9,7 @@ const QUICK_REPLIES: RichComposerQuickReply[] = [
9
9
  { id: 'greeting', label: 'Saudação', text: 'Olá!', tooltip: 'Abre o atendimento' },
10
10
  ]
11
11
 
12
- const VARIABLES: RichComposerVariable[] = [
13
- { id: 'name', label: 'Nome do cliente', value: '{{nome}}' },
14
- ]
12
+ const VARIABLES: RichComposerVariable[] = [{ id: 'name', label: 'Nome do cliente', value: '{{nome}}' }]
15
13
 
16
14
  describe('RichMessageComposer', () => {
17
15
  it('mostra as dicas padrão em todas as ações da barra', () => {
@@ -80,7 +78,12 @@ describe('RichMessageComposer', () => {
80
78
  <RichMessageComposer value="" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
81
79
  )
82
80
  const comTexto = renderToStaticMarkup(
83
- <RichMessageComposer value="oi" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
81
+ <RichMessageComposer
82
+ value="oi"
83
+ onChange={noop}
84
+ onSend={noop}
85
+ idleAction={<button aria-label="Gravar áudio" />}
86
+ />,
84
87
  )
85
88
 
86
89
  expect(vazio).toContain('aria-label="Gravar áudio"')
@@ -110,4 +113,19 @@ describe('RichMessageComposer', () => {
110
113
  expect(markup).toContain('data-cv-tooltip="Enviar"')
111
114
  expect(markup).not.toContain('aria-label="Gravar áudio"')
112
115
  })
116
+
117
+ it('sem savedQuickReplies, não desenha o botão de raio', () => {
118
+ const withPort = renderToStaticMarkup(
119
+ <RichMessageComposer
120
+ value=""
121
+ onChange={noop}
122
+ onSend={noop}
123
+ savedQuickReplies={{ listQuickReplies: async () => [], conversationId: 'c1' }}
124
+ />,
125
+ )
126
+ const withoutPort = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
127
+
128
+ expect(withPort).toContain('data-cv-tooltip="Mensagens prontas"')
129
+ expect(withoutPort).not.toContain('data-cv-tooltip="Mensagens prontas"')
130
+ })
113
131
  })