@adatechnology/conversations-ui 0.1.0-rc.26 → 0.1.0-rc.28

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 (81) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/{chunk-TV4OQRGH.js → chunk-UZJBYD5O.js} +123 -45
  3. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  4. package/dist/flows/index.d.ts +75 -2
  5. package/dist/flows/index.js +176 -95
  6. package/dist/index.d.ts +37 -10
  7. package/dist/index.js +694 -297
  8. package/dist/preview/index.js +3 -3
  9. package/dist/styles.css +119 -6
  10. package/package.json +2 -2
  11. package/src/AudioPlayer.tsx +8 -0
  12. package/src/AudioRecorderButton.test.tsx +3 -3
  13. package/src/AudioRecorderButton.tsx +3 -3
  14. package/src/AudioTranscription.tsx +4 -1
  15. package/src/Avatar.tsx +1 -1
  16. package/src/ConversationContextPanel.tsx +2 -2
  17. package/src/ConversationDocumentsPanel.tsx +11 -6
  18. package/src/ConversationHeader.test.tsx +66 -0
  19. package/src/ConversationHeader.tsx +147 -47
  20. package/src/ConversationListItem.tsx +3 -2
  21. package/src/ConversationRow.tsx +31 -7
  22. package/src/DocumentsLibrary.tsx +9 -4
  23. package/src/EmojiPicker.tsx +2 -1
  24. package/src/InteractiveMessage.tsx +3 -0
  25. package/src/Lightbox.tsx +1 -1
  26. package/src/MediaRenderer.tsx +2 -0
  27. package/src/MessageBubble.test.tsx +41 -0
  28. package/src/MessageBubble.tsx +27 -8
  29. package/src/MessageComposer.tsx +11 -8
  30. package/src/RichMessageComposer.test.tsx +42 -12
  31. package/src/RichMessageComposer.tsx +216 -45
  32. package/src/SimpleEmojiPicker.tsx +5 -3
  33. package/src/StatusTicks.tsx +1 -1
  34. package/src/Toast.tsx +4 -0
  35. package/src/Tooltip.test.ts +42 -0
  36. package/src/Tooltip.tsx +164 -0
  37. package/src/WhatsAppMessageEditor.tsx +4 -4
  38. package/src/WindowExpiredNotice.tsx +12 -4
  39. package/src/buildOutput.test.ts +79 -0
  40. package/src/composer.constant.ts +33 -0
  41. package/src/conversationWindow.ts +7 -5
  42. package/src/documents/DocumentsWorkspace.tsx +10 -6
  43. package/src/flows/FlowGroupHeader.tsx +2 -2
  44. package/src/flows/FlowMapNode.tsx +2 -1
  45. package/src/flows/FlowNodeCard.tsx +3 -3
  46. package/src/flows/FlowNodePanel.tsx +6 -5
  47. package/src/flows/FlowPalette.tsx +7 -1
  48. package/src/flows/FlowPortalNode.tsx +1 -1
  49. package/src/flows/FlowsWorkspace.tsx +69 -102
  50. package/src/flows/flowEditorOps.test.ts +241 -0
  51. package/src/flows/flowEditorOps.ts +177 -0
  52. package/src/flows/flowGraph.ts +1 -1
  53. package/src/flows/index.ts +16 -0
  54. package/src/flows/labels.ts +4 -0
  55. package/src/flows/workspaceContract.test.ts +95 -0
  56. package/src/hooks/useContainerWidth.ts +35 -0
  57. package/src/hooks/useConversationRealtime.ts +10 -8
  58. package/src/icon.constant.ts +12 -0
  59. package/src/index.ts +1 -0
  60. package/src/lib/composer-formatting.test.ts +78 -0
  61. package/src/lib/composer-formatting.ts +145 -0
  62. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  63. package/src/lib/whatsapp-formatting.tsx +28 -3
  64. package/src/listing/index.tsx +5 -1
  65. package/src/pagination.constant.ts +10 -0
  66. package/src/preview/ConversationSimulatorPanel.tsx +1 -1
  67. package/src/providers/ConversationsProvider.tsx +8 -6
  68. package/src/settings/MessagesWorkspace.tsx +3 -0
  69. package/src/settings/TopicsForm.tsx +2 -0
  70. package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
  71. package/src/settings/TranscriptionSettingsForm.tsx +1 -0
  72. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  73. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  74. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  75. package/src/settings/WhatsAppTemplatesSettings.tsx +2 -0
  76. package/src/styles.css +100 -4
  77. package/src/workspace/BulkTemplateModal.tsx +4 -2
  78. package/src/workspace/ConversationPane.tsx +49 -19
  79. package/src/workspace/ConversationsInboxList.tsx +17 -8
  80. package/src/workspace/ConversationsWorkspace.tsx +24 -16
  81. package/src/workspace/useConversationsInbox.ts +5 -2
@@ -6,14 +6,29 @@
6
6
  * papel do pacote saber a rota de cada host. Quem passa os handlers é o produto.
7
7
  */
8
8
 
9
- import { useState } from 'react'
9
+ import { useRef, useState, type ReactNode } from 'react'
10
+ import { ArrowLeft, Bot, Download, FileText, Headset, MoreVertical, UserRound, X } from 'lucide-react'
10
11
 
11
12
  import type { ConversationSummary } from './providers/types'
12
13
  import { capabilitiesOf, contactFlag, formatContactHandle } from './conversationChannel'
13
14
  import { cn } from './lib/cn'
15
+ import { ICON_SIZE_ACTION, ICON_SIZE_INLINE } from './icon.constant'
16
+ import { useContainerWidth } from './hooks/useContainerWidth'
14
17
  import { Avatar } from './Avatar'
15
18
  import { ChannelIcon } from './ChannelIcon'
16
19
 
20
+ /**
21
+ * Larguras do próprio cabeçalho (em px) que decidem o quanto ele recolhe. São da faixa, não da
22
+ * janela: com a prévia do simulador aberta a coluna fica com metade da tela, e pelo breakpoint da
23
+ * janela o cabeçalho continuava desenhando tudo — sobrava uma letra do nome do cliente.
24
+ *
25
+ * Abaixo da primeira, os utilitários (ícones de consulta) viram itens do menu ⋮ e o telefone volta a
26
+ * caber; abaixo da segunda, até as ações escritas entram no menu, porque nem elas cabem sem comer o
27
+ * nome. Quem identifica a conversa é o nome — ele é o último a ceder espaço.
28
+ */
29
+ const HEADER_UTILITIES_MIN_WIDTH = 720
30
+ const HEADER_ACTIONS_MIN_WIDTH = 600
31
+
17
32
  export interface ConversationHeaderLabels {
18
33
  botMode: string
19
34
  humanMode: string
@@ -27,8 +42,10 @@ export interface ConversationHeaderLabels {
27
42
  }
28
43
 
29
44
  export const DEFAULT_CONVERSATION_HEADER_LABELS: ConversationHeaderLabels = {
30
- botMode: '🤖 atendimento automático',
31
- humanMode: '🧑‍💼 atendimento humano',
45
+ // Sem emoji no rótulo: quem desenha o modo é o ícone ao lado, e o emoji embutido no texto voltava
46
+ // a aparecer no tooltip e no leitor de tela como um caractere sem nome.
47
+ botMode: 'atendimento automático',
48
+ humanMode: 'atendimento humano',
32
49
  returnToBot: 'Devolver ao bot',
33
50
  finish: 'Finalizar',
34
51
  takeover: 'Assumir atendimento',
@@ -59,13 +76,26 @@ export interface ConversationHeaderClassNames {
59
76
  */
60
77
  export interface ConversationHeaderUtility {
61
78
  key: string
62
- /** Emoji, para casar com os utilitários nativos do cabeçalho. */
63
- icon: string
79
+ /** Ícone da biblioteca (lucide), no tamanho dos utilitários nativos: `<Play size={16} />`. */
80
+ icon: ReactNode
64
81
  label: string
65
82
  run: () => void
66
83
  active?: boolean
67
84
  }
68
85
 
86
+ /** Utilitário já resolvido para desenho: o `active` opcional do contrato vira estado explícito. */
87
+ type ResolvedUtility = ConversationHeaderUtility & { active: boolean }
88
+
89
+ /** Ação de atendimento pronta para virar botão na faixa ou item do menu ⋮. */
90
+ type HeaderAction = {
91
+ key: string
92
+ icon: ReactNode
93
+ label: string
94
+ hint: string
95
+ run: () => void
96
+ className: string
97
+ }
98
+
69
99
  export interface ConversationHeaderProps {
70
100
  conversation: ConversationSummary
71
101
  busy?: boolean
@@ -105,42 +135,92 @@ export function ConversationHeader({
105
135
  const flag = contactFlag({ handle, channel: conversation.channel })
106
136
  const capabilities = capabilitiesOf(conversation.channel)
107
137
  const [menuOpen, setMenuOpen] = useState(false)
138
+ const headerRef = useRef<HTMLElement>(null)
139
+ const headerWidth = useContainerWidth(headerRef)
140
+
141
+ // Sem medida ainda (SSR, primeiro render) o cabeçalho nasce completo: é o layout correto na maior
142
+ // parte dos casos, e recolher depois não tira nada do lugar de quem já estava lendo.
143
+ const showsUtilitiesInline = headerWidth === undefined || headerWidth >= HEADER_UTILITIES_MIN_WIDTH
144
+ const showsActionsInline = headerWidth === undefined || headerWidth >= HEADER_ACTIONS_MIN_WIDTH
108
145
 
109
146
  // Utilitários: ícone no desktop, item de menu no celular. Três ícones lado a lado em 375px não
110
147
  // caberiam com área de toque decente, e nenhum deles é a ação principal do atendimento.
111
- const utilities = [
148
+ const utilityCandidates: readonly (ResolvedUtility | undefined)[] = [
112
149
  onOpenDocuments
113
- ? { key: 'documents', icon: '📄', label: labels.documents, run: onOpenDocuments, active: documentsOpen }
150
+ ? {
151
+ key: 'documents',
152
+ icon: <FileText size={ICON_SIZE_ACTION} />,
153
+ label: labels.documents,
154
+ run: onOpenDocuments,
155
+ active: documentsOpen,
156
+ }
157
+ : undefined,
158
+ onDownload
159
+ ? {
160
+ key: 'download',
161
+ icon: <Download size={ICON_SIZE_ACTION} />,
162
+ label: labels.download,
163
+ run: onDownload,
164
+ active: false,
165
+ }
114
166
  : undefined,
115
- onDownload ? { key: 'download', icon: '⬇️', label: labels.download, run: onDownload, active: false } : undefined,
116
167
  ...(extraUtilities ?? []).map((utility) => ({ ...utility, active: utility.active ?? false })),
117
- ].filter(
118
- (utility): utility is { key: string; icon: string; label: string; run: () => void; active: boolean } =>
119
- Boolean(utility),
120
- )
168
+ ]
169
+ const utilities = utilityCandidates.filter((utility): utility is ResolvedUtility => Boolean(utility))
121
170
 
122
171
  // Lista única de ações: alimenta os botões do desktop e o menu do celular, para as duas
123
172
  // superfícies nunca divergirem sobre o que está disponível.
124
- const actions = [
173
+ // `hint` existe separado do `label` porque o host pode traduzir o rótulo com prefixo ou contagem:
174
+ // no tooltip isso vira ruído, e é o texto limpo que descreve a ação.
175
+ const actionCandidates: readonly (HeaderAction | undefined)[] = [
125
176
  !isHuman && onTakeover
126
- ? { key: 'takeover', label: labels.takeover, run: onTakeover, className: 'cv-header-action--primary' }
177
+ ? {
178
+ key: 'takeover',
179
+ icon: <Headset size={ICON_SIZE_ACTION} />,
180
+ label: labels.takeover,
181
+ hint: labels.takeover,
182
+ run: onTakeover,
183
+ className: 'cv-header-action--primary',
184
+ }
185
+ : undefined,
186
+ isHuman && onReturnToBot
187
+ ? {
188
+ key: 'release',
189
+ icon: <Bot size={ICON_SIZE_ACTION} />,
190
+ label: labels.returnToBot,
191
+ hint: labels.returnToBot,
192
+ run: onReturnToBot,
193
+ className: '',
194
+ }
127
195
  : undefined,
128
- isHuman && onReturnToBot ? { key: 'release', label: labels.returnToBot, run: onReturnToBot, className: '' } : undefined,
129
196
  isHuman && onFinish
130
- ? { key: 'finish', label: `✕ ${labels.finish}`, run: onFinish, className: 'cv-header-action--danger' }
197
+ ? {
198
+ key: 'finish',
199
+ icon: <X size={ICON_SIZE_ACTION} />,
200
+ label: labels.finish,
201
+ hint: labels.finish,
202
+ run: onFinish,
203
+ className: 'cv-header-action--danger',
204
+ }
131
205
  : undefined,
132
- ].filter((action): action is { key: string; label: string; run: () => void; className: string } => Boolean(action))
206
+ ]
207
+ const actions = actionCandidates.filter((action): action is HeaderAction => Boolean(action))
133
208
 
134
- // No celular utilitários e ações moram no mesmo menu: utilitários primeiro, porque são consulta e
135
- // não alteram estado da conversa.
209
+ // O menu recebe só o que não coube na faixa: utilitários primeiro, porque são consulta e não
210
+ // alteram estado da conversa. Nada aparece nos dois lugares — item duplicado é o operador achando
211
+ // que são duas coisas diferentes.
136
212
  const menuItems = [
137
- ...utilities.map((utility) => ({
138
- key: utility.key,
139
- label: `${utility.icon} ${utility.label}`,
140
- run: utility.run,
141
- className: '',
142
- })),
143
- ...actions,
213
+ ...(showsUtilitiesInline
214
+ ? []
215
+ : utilities.map((utility) => ({
216
+ key: utility.key,
217
+ icon: utility.icon,
218
+ label: utility.label,
219
+ hint: utility.label,
220
+ run: utility.run,
221
+ className: '',
222
+ }))),
223
+ ...(showsActionsInline ? [] : actions),
144
224
  ]
145
225
 
146
226
  // `flex-nowrap` no cabeçalho: com wrap, o nome do cliente ocupava a largura toda em 375px e
@@ -148,6 +228,7 @@ export function ConversationHeader({
148
228
  // fora da tela. O bloco de identificação encurta (truncate) em vez de empurrar.
149
229
  return (
150
230
  <header
231
+ ref={headerRef}
151
232
  className={cn(
152
233
  'flex flex-nowrap items-center justify-between gap-3 border-b px-4 py-3',
153
234
  classNames?.root,
@@ -158,76 +239,93 @@ export function ConversationHeader({
158
239
  {/* Voltar existe só em tela estreita, onde lista e conversa não cabem juntas: sem ele, abrir
159
240
  uma conversa no celular é um beco sem saída. A classe some acima de 1024px. */}
160
241
  {onBack ? (
161
- <button type="button" onClick={onBack} className="cv-back cv-touch" aria-label={labels.back} title={labels.back}>
162
- ←
242
+ <button type="button" onClick={onBack} className="cv-back cv-touch" aria-label={labels.back} data-cv-tooltip={labels.back}>
243
+ <ArrowLeft size={ICON_SIZE_ACTION} aria-hidden="true" />
163
244
  </button>
164
245
  ) : null}
165
246
  {/* Sem nome, o Avatar cai na silhueta — melhor que dois dígitos do telefone como iniciais. */}
166
247
  <Avatar name={conversation.clientName} size="md" />
167
248
  <div className="min-w-0">
168
249
  <p className={cn('truncate font-medium', classNames?.name)}>{conversation.clientName ?? displayHandle}</p>
169
- <p className={cn('truncate text-xs text-gray-500', classNames?.meta)}>
250
+ {/* Flex com `items-center`, não texto corrido: ícone de canal e emoji de modo têm altura
251
+ maior que a fonte, e como elementos inline cada um assentava na própria baseline —
252
+ telefone, canal e modo saíam em três alturas diferentes. O gap substitui as margens. */}
253
+ <p className={cn('flex min-w-0 flex-nowrap items-center gap-2 overflow-hidden text-xs text-gray-500', classNames?.meta)}>
170
254
  {/* Bandeira derivada do DDI e só quando o identificador é telefone. Estava fixa em 🇧🇷,
171
255
  o que rotulava qualquer contato como brasileiro. */}
172
- {flag ? `${flag} ` : ''}
173
- {displayHandle}
174
- {/* Em tela estreita sobra o ícone do canal e o emoji do modo: o rótulo escrito empurra o
256
+ <span className="truncate">
257
+ {flag ? `${flag} ` : ''}
258
+ {displayHandle}
259
+ </span>
260
+ {/* Em faixa estreita sobra só o ícone do canal e o do modo: o rótulo escrito empurra o
175
261
  telefone para fora e não diz nada que o ícone já não diga. */}
176
- <span className="ml-2 inline-flex items-center gap-1">
262
+ <span className="inline-flex flex-shrink-0 items-center gap-1">
177
263
  <ChannelIcon channel={conversation.channel} />
178
- <span className="cv-only-wide">{capabilities.label}</span>
264
+ {showsUtilitiesInline ? <span>{capabilities.label}</span> : null}
179
265
  </span>
180
- <span className="ml-2 cv-only-wide">{isHuman ? labels.humanMode : labels.botMode}</span>
181
- <span className="ml-1 cv-only-narrow" title={isHuman ? labels.humanMode : labels.botMode}>
182
- {isHuman ? '🧑‍💼' : '🤖'}
266
+ <span
267
+ className="inline-flex flex-shrink-0 items-center gap-1"
268
+ // Sem o rótulo escrito, o ícone sozinho não diria nada ao leitor de tela.
269
+ {...(showsUtilitiesInline
270
+ ? {}
271
+ : { role: 'img', 'aria-label': isHuman ? labels.humanMode : labels.botMode, 'data-cv-tooltip': isHuman ? labels.humanMode : labels.botMode })}
272
+ >
273
+ {isHuman ? (
274
+ <UserRound size={ICON_SIZE_INLINE} aria-hidden="true" />
275
+ ) : (
276
+ <Bot size={ICON_SIZE_INLINE} aria-hidden="true" />
277
+ )}
278
+ {showsUtilitiesInline ? <span>{isHuman ? labels.humanMode : labels.botMode}</span> : null}
183
279
  </span>
184
280
  </p>
185
281
  </div>
186
282
  </div>
187
283
 
188
284
  <div className={cn('flex shrink-0 items-center gap-2', classNames?.actions)}>
189
- {/* Desktop: utilitários como ícone e ações como botão, tudo visível de uma vez. */}
190
- <div className={cn('hidden items-center gap-2 lg:flex', classNames?.desktopActions)}>
191
- {utilities.map((utility) => (
285
+ {/* Faixa larga: utilitários como ícone e ações como botão, tudo visível de uma vez. */}
286
+ <div className={cn('flex items-center gap-2', classNames?.desktopActions)}>
287
+ {(showsUtilitiesInline ? utilities : []).map((utility) => (
192
288
  <button
193
289
  key={utility.key}
194
290
  type="button"
195
291
  onClick={utility.run}
196
292
  disabled={busy}
197
293
  aria-pressed={utility.active}
198
- title={utility.label}
294
+ data-cv-tooltip={utility.label}
199
295
  aria-label={utility.label}
200
296
  className={`cv-header-icon ${utility.active ? 'cv-header-icon--active' : ''}`}
201
297
  >
202
298
  {utility.icon}
203
299
  </button>
204
300
  ))}
205
- {actions.map((action) => (
301
+ {(showsActionsInline ? actions : []).map((action) => (
206
302
  <button
207
303
  key={action.key}
208
304
  type="button"
209
305
  onClick={action.run}
210
306
  disabled={busy}
307
+ data-cv-tooltip={action.hint} aria-label={action.hint}
211
308
  className={`cv-header-action ${action.className}`}
212
309
  >
310
+ {action.icon}
213
311
  {action.label}
214
312
  </button>
215
313
  ))}
216
314
  </div>
217
315
 
218
- {/* Celular: um único ⋮ com tudo dentro. Três ícones de 30px lado a lado violavam o mínimo
219
- de 44px de área de toque e disputavam espaço com o nome do cliente. */}
316
+ {/* Faixa estreita: um único ⋮ com o que não coube. Ícones de 30px lado a lado violavam o
317
+ mínimo de 44px de área de toque e disputavam espaço com o nome do cliente. */}
220
318
  {menuItems.length > 0 ? (
221
- <div className={cn('cv-menu lg:hidden', classNames?.mobileMenu)}>
319
+ <div className={cn('cv-menu', classNames?.mobileMenu)}>
222
320
  <button
223
321
  type="button"
224
322
  onClick={() => setMenuOpen(!menuOpen)}
225
323
  aria-expanded={menuOpen}
226
324
  aria-label={labels.moreActions}
227
- title={labels.moreActions}
325
+ data-cv-tooltip={labels.moreActions}
228
326
  className="cv-header-icon cv-touch"
229
327
  >
230
- ⋮
328
+ <MoreVertical size={ICON_SIZE_ACTION} aria-hidden="true" />
231
329
  </button>
232
330
 
233
331
  {menuOpen ? (
@@ -242,8 +340,10 @@ export function ConversationHeader({
242
340
  item.run()
243
341
  }}
244
342
  disabled={busy}
343
+ data-cv-tooltip={item.hint} aria-label={item.hint}
245
344
  className={`cv-header-action cv-touch ${item.className}`}
246
345
  >
346
+ {item.icon}
247
347
  {item.label}
248
348
  </button>
249
349
  ))}
@@ -128,6 +128,7 @@ export const ConversationListItem = ({
128
128
 
129
129
  return (
130
130
  <button
131
+ data-cv-tooltip={name} aria-label={name}
131
132
  onClick={handleClick}
132
133
  className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${highlightActive ? "hover:bg-[#f0f2f5]" : ""} ${showDivider ? "border-b border-[#e9edef]" : ""}`}
133
134
  style={{
@@ -150,10 +151,10 @@ export const ConversationListItem = ({
150
151
  {!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
151
152
  <span className="text-[16px] text-[#111b21] truncate">{name}</span>
152
153
  {windowStatus && windowStatus.label === 'expired' && (
153
- <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title={expiredWindowLabel} />
154
+ <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" data-cv-tooltip={expiredWindowLabel} />
154
155
  )}
155
156
  {windowStatus && windowStatus.label === 'warning' && (
156
- <span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title={warningWindowLabel} />
157
+ <span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" data-cv-tooltip={warningWindowLabel} />
157
158
  )}
158
159
  </div>
159
160
  {timestamp && (
@@ -7,8 +7,11 @@
7
7
  * como se opera uma fila de atendimento no WhatsApp.
8
8
  */
9
9
 
10
+ import { AlarmClock, Bot, Hourglass, Play, UserRound } from 'lucide-react'
11
+
10
12
  import { ConversationListItem } from './ConversationListItem'
11
13
  import { capabilitiesOf } from './conversationChannel'
14
+ import { ICON_SIZE_ACTION, ICON_SIZE_PILL } from './icon.constant'
12
15
  import { cn } from './lib/cn'
13
16
  import { ChannelIcon } from './ChannelIcon'
14
17
  import type { ConversationSummary } from './providers/types'
@@ -31,6 +34,8 @@ const WINDOW_TITLE: Record<ConversationWindow, string> = {
31
34
  // Abaixo disto a conversa é recente e o aviso de "parada" só faria ruído.
32
35
  const STALLED_THRESHOLD_MS = 60 * 60 * 1000
33
36
 
37
+ const TAKEOVER_LABEL = 'Continuar Atendimento'
38
+
34
39
  const WINDOW_BAR_CLASS: Record<ConversationWindow, string> = {
35
40
  [CONVERSATION_WINDOW.ALL]: 'bg-transparent',
36
41
  [CONVERSATION_WINDOW.FRESH]: 'bg-green-500',
@@ -85,7 +90,7 @@ export function ConversationRow({
85
90
  duplica as bolinhas que o próprio ConversationListItem já desenha. */}
86
91
  <span
87
92
  className={cn('w-1 shrink-0', WINDOW_BAR_CLASS[window], classNames?.windowBar)}
88
- title={WINDOW_TITLE[window]}
93
+ data-cv-tooltip={WINDOW_TITLE[window]}
89
94
  aria-label={WINDOW_TITLE[window]}
90
95
  />
91
96
 
@@ -118,16 +123,35 @@ export function ConversationRow({
118
123
  <ChannelIcon channel={conversation.channel} /> {capabilities.label}
119
124
  </span>
120
125
  {conversation.mode === 'human' ? (
121
- <span className="cv-pill cv-pill--success">🧑‍💼 em atendimento</span>
126
+ <span className="cv-pill cv-pill--success">
127
+ <UserRound size={ICON_SIZE_PILL} aria-hidden="true" /> em atendimento
128
+ </span>
129
+ ) : null}
130
+ {isWaiting ? (
131
+ <span className="cv-pill cv-pill--warning">
132
+ <Hourglass size={ICON_SIZE_PILL} aria-hidden="true" /> aguardando atendimento
133
+ </span>
134
+ ) : null}
135
+ {!isWaiting && conversation.mode === 'bot' ? (
136
+ <span className="cv-pill">
137
+ <Bot size={ICON_SIZE_PILL} aria-hidden="true" /> bot ativo
138
+ </span>
122
139
  ) : null}
123
- {isWaiting ? <span className="cv-pill cv-pill--warning">⏳ aguardando atendimento</span> : null}
124
- {!isWaiting && conversation.mode === 'bot' ? <span className="cv-pill">🤖 bot ativo</span> : null}
125
140
  {isStalled ? (
126
- <span className="cv-pill cv-pill--danger">⏱️ parada há {formatStalledFor(conversation.lastAt, now)}</span>
141
+ <span className="cv-pill cv-pill--danger">
142
+ <AlarmClock size={ICON_SIZE_PILL} aria-hidden="true" /> parada há{' '}
143
+ {formatStalledFor(conversation.lastAt, now)}
144
+ </span>
127
145
  ) : null}
128
146
  {isWaiting ? (
129
- <button type="button" onClick={onTakeover} disabled={busy} className="cv-header-action">
130
- ▶️ Continuar Atendimento
147
+ <button
148
+ type="button"
149
+ onClick={onTakeover}
150
+ disabled={busy}
151
+ data-cv-tooltip={TAKEOVER_LABEL} aria-label={TAKEOVER_LABEL}
152
+ className="cv-header-action"
153
+ >
154
+ <Play size={ICON_SIZE_ACTION} aria-hidden="true" /> {TAKEOVER_LABEL}
131
155
  </button>
132
156
  ) : null}
133
157
  </div>
@@ -13,6 +13,7 @@ import { useConversations } from './providers/ConversationsProvider'
13
13
  import { DOCUMENT_SOURCE_FILTER, type DocumentSourceFilter } from './ConversationDocumentsPanel'
14
14
  import { FileIcon } from './FileIcon'
15
15
  import { cn } from './lib/cn'
16
+ import { PAGINATION_LABELS } from './pagination.constant'
16
17
  import { formatDateTime, formatFileSize } from './lib/format'
17
18
  import { formatPhone } from './lib/phone'
18
19
  import type { CompanyDocument } from './providers/types'
@@ -181,6 +182,7 @@ export function DocumentsLibrary({
181
182
  </select>
182
183
 
183
184
  <button
185
+ data-cv-tooltip={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest} aria-label={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
184
186
  type="button"
185
187
  onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
186
188
  className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
@@ -191,6 +193,7 @@ export function DocumentsLibrary({
191
193
 
192
194
  {hasFilters ? (
193
195
  <button
196
+ data-cv-tooltip={labels.clearFilters} aria-label={labels.clearFilters}
194
197
  type="button"
195
198
  onClick={() =>
196
199
  applyFilter(() => {
@@ -234,7 +237,7 @@ export function DocumentsLibrary({
234
237
  <div className="flex min-w-0 flex-1 items-center gap-3">
235
238
  <FileIcon filename={document.filename} mimeType={document.mimeType} />
236
239
  <div className="min-w-0 flex-1">
237
- <div className={cn('truncate text-sm font-medium', classNames?.filename)} title={document.filename}>
240
+ <div className={cn('truncate text-sm font-medium', classNames?.filename)} data-cv-tooltip={document.filename}>
238
241
  {document.filename}
239
242
  </div>
240
243
  <div className={cn('flex flex-wrap items-center gap-x-2 text-xs text-gray-500', classNames?.meta)}>
@@ -256,7 +259,7 @@ export function DocumentsLibrary({
256
259
  <button
257
260
  type="button"
258
261
  onClick={() => onOpenConversation(document.conversationId)}
259
- title={labels.openConversation}
262
+ data-cv-tooltip={labels.openConversation} aria-label={labels.openConversation}
260
263
  className={cn('cv-header-action inline-flex shrink-0 items-center gap-1', classNames?.conversationLink)}
261
264
  >
262
265
  <MessageSquare size={12} />
@@ -272,7 +275,7 @@ export function DocumentsLibrary({
272
275
  <button
273
276
  type="button"
274
277
  onClick={() => void handleOpen(document.id, 'inline')}
275
- title={labels.view}
278
+ data-cv-tooltip={labels.view}
276
279
  aria-label={`${labels.view}: ${document.filename}`}
277
280
  className="cv-header-icon"
278
281
  >
@@ -281,7 +284,7 @@ export function DocumentsLibrary({
281
284
  <button
282
285
  type="button"
283
286
  onClick={() => void handleOpen(document.id, 'attachment')}
284
- title={labels.download}
287
+ data-cv-tooltip={labels.download}
285
288
  aria-label={`${labels.download}: ${document.filename}`}
286
289
  className="cv-header-icon"
287
290
  >
@@ -298,6 +301,7 @@ export function DocumentsLibrary({
298
301
  <span className="text-gray-400">{labels.total(total)}</span>
299
302
  <div className="flex items-center gap-2">
300
303
  <button
304
+ data-cv-tooltip={PAGINATION_LABELS.previous} aria-label={PAGINATION_LABELS.previous}
301
305
  type="button"
302
306
  onClick={() => setPage(page - 1)}
303
307
  disabled={page <= 1}
@@ -307,6 +311,7 @@ export function DocumentsLibrary({
307
311
  </button>
308
312
  <span className="text-gray-500">{labels.page(page, lastPage)}</span>
309
313
  <button
314
+ data-cv-tooltip={PAGINATION_LABELS.next} aria-label={PAGINATION_LABELS.next}
310
315
  type="button"
311
316
  onClick={() => setPage(page + 1)}
312
317
  disabled={page >= lastPage}
@@ -55,6 +55,7 @@ export const EmojiPicker = ({ onSelect, labels, className = '' }: EmojiPickerPro
55
55
  <div className="flex border-b border-gray-200 overflow-x-auto">
56
56
  {EMOJI_CATEGORIES.map((category, index) => (
57
57
  <button
58
+ data-cv-tooltip={category.name} aria-label={category.name}
58
59
  key={category.name}
59
60
  onClick={() => setActiveCategory(index)}
60
61
  className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
@@ -79,7 +80,7 @@ export const EmojiPicker = ({ onSelect, labels, className = '' }: EmojiPickerPro
79
80
  onClick={() => handleSelect(entry.emoji)}
80
81
  className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
81
82
  aria-label={entry.emoji}
82
- title={entry.keywords[0]}
83
+ data-cv-tooltip={entry.keywords[0]}
83
84
  >
84
85
  {entry.emoji}
85
86
  </button>
@@ -86,6 +86,7 @@ export function InteractiveMessage({ payload, onSelect, labels, className }: Int
86
86
  <div className="mt-1 flex flex-col gap-1 border-t border-gray-200 pt-1 dark:border-gray-700">
87
87
  {buttons.map((button) => (
88
88
  <button
89
+ data-cv-tooltip={button.title} aria-label={button.title}
89
90
  key={button.id}
90
91
  type="button"
91
92
  disabled={!isInteractable}
@@ -101,6 +102,7 @@ export function InteractiveMessage({ payload, onSelect, labels, className }: Int
101
102
  {hasRows ? (
102
103
  <div className="mt-1 border-t border-gray-200 pt-1 dark:border-gray-700">
103
104
  <button
105
+ data-cv-tooltip={payload.action?.button ?? openListLabel} aria-label={payload.action?.button ?? openListLabel}
104
106
  type="button"
105
107
  onClick={() => setIsListOpen((open) => !open)}
106
108
  aria-expanded={isListOpen}
@@ -120,6 +122,7 @@ export function InteractiveMessage({ payload, onSelect, labels, className }: Int
120
122
  ) : null}
121
123
  {(section.rows ?? []).map((row) => (
122
124
  <button
125
+ data-cv-tooltip={row.title} aria-label={row.title}
123
126
  key={row.id}
124
127
  type="button"
125
128
  disabled={!isInteractable}
package/src/Lightbox.tsx CHANGED
@@ -25,7 +25,7 @@ export function Lightbox({ imageUrl, caption, onClose, labels }: LightboxProps)
25
25
  <div className="max-w-[90vw] max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
26
26
  <img src={imageUrl} alt={caption ?? imageAltLabel} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
27
27
  {caption && <p className="text-white text-sm mt-3 text-center">{caption}</p>}
28
- <button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">{closeLabel}</button>
28
+ <button data-cv-tooltip={closeLabel} aria-label={closeLabel} onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">{closeLabel}</button>
29
29
  </div>
30
30
  </div>
31
31
  )
@@ -106,6 +106,7 @@ export function MediaRenderer({
106
106
  function LazyMediaButton({ icon, label }: { icon: ReactNode; label: string }) {
107
107
  return (
108
108
  <button
109
+ data-cv-tooltip={label} aria-label={label}
109
110
  onClick={lazy.load}
110
111
  disabled={lazy.loading}
111
112
  className="flex min-w-[180px] items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 text-left transition-colors hover:bg-black/10 disabled:opacity-60 dark:bg-white/10 dark:hover:bg-white/15"
@@ -214,6 +215,7 @@ export function MediaRenderer({
214
215
  </a>
215
216
  ) : canLazyLoad ? (
216
217
  <button
218
+ data-cv-tooltip={bubble.downloadFile}
217
219
  onClick={lazy.load}
218
220
  disabled={lazy.loading}
219
221
  className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 flex-shrink-0 transition-colors disabled:opacity-50"
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { MessageBubble } from './MessageBubble'
5
+ import type { MessagePayload } from './types'
6
+
7
+ const IMAGE_MESSAGE: MessagePayload = {
8
+ id: 'msg-1',
9
+ type: 'image',
10
+ direction: 'outbound',
11
+ sender: 'agent',
12
+ timestamp: '2026-08-05T12:00:00.000Z',
13
+ filename: 'planta-baixa.png',
14
+ mediaUrl: 'https://cdn.test/planta-baixa.png',
15
+ }
16
+
17
+ describe('MessageBubble com mídia', () => {
18
+ it('mostra a legenda que veio no conteúdo do envio', () => {
19
+ const markup = renderToStaticMarkup(
20
+ <MessageBubble message={{ ...IMAGE_MESSAGE, content: 'Planta do 302' }} isMine />,
21
+ )
22
+
23
+ expect(markup).toContain('Planta do 302')
24
+ })
25
+
26
+ it('mostra a legenda que o cliente mandou junto da imagem', () => {
27
+ const markup = renderToStaticMarkup(
28
+ <MessageBubble message={{ ...IMAGE_MESSAGE, direction: 'inbound', sender: 'customer', caption: 'É esse?' }} isMine={false} />,
29
+ )
30
+
31
+ expect(markup).toContain('É esse?')
32
+ })
33
+
34
+ it('não repete o nome do arquivo como se fosse legenda', () => {
35
+ const markup = renderToStaticMarkup(
36
+ <MessageBubble message={{ ...IMAGE_MESSAGE, content: 'planta-baixa.png' }} isMine />,
37
+ )
38
+
39
+ expect(markup).not.toContain('>planta-baixa.png<')
40
+ })
41
+ })