@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.0

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 (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,92 @@
1
+ import { Moon, Sun } from 'lucide-react'
2
+
3
+ import { cn } from './lib/cn'
4
+ import { ICON_SIZE_ACTION } from './icon.constant'
5
+
6
+ export interface DarkModeToggleLabels {
7
+ /** Ação de ir para o tema escuro — é o rótulo lido enquanto o tema claro está ativo. */
8
+ toDark: string
9
+ toLight: string
10
+ }
11
+
12
+ export interface DarkModeToggleClassNames {
13
+ /** O tamanho do ícone vem daqui quando o host usa a própria escala (`size-4`, `size-5`). */
14
+ icon?: string
15
+ label?: string
16
+ }
17
+
18
+ export interface DarkModeToggleProps {
19
+ isDark: boolean
20
+ onToggle: () => void
21
+ /** Rótulo ao lado do ícone. Só-ícone exige a área de toque mínima, que a classe `cv-touch` dá. */
22
+ showLabel?: boolean
23
+ labels?: Partial<DarkModeToggleLabels>
24
+ /**
25
+ * Substitui a aparência do botão. As classes passam por `tailwind-merge`, então utilitário do
26
+ * host vence o do pacote em vez de somar: `px-4` descarta o `px-3` daqui.
27
+ *
28
+ * A exceção é utilitário de token do host (`rounded-panel`), que o merge não reconhece como
29
+ * família e deixa passar junto com o `rounded-lg` do pacote — aí quem decide é a ordem no CSS
30
+ * gerado, não a do atributo. Nesse caso use a forma arbitrária (`rounded-[var(--radius-panel)]`),
31
+ * que o merge entende e substitui.
32
+ */
33
+ className?: string
34
+ classNames?: DarkModeToggleClassNames
35
+ }
36
+
37
+ export const DEFAULT_DARK_MODE_TOGGLE_LABELS: DarkModeToggleLabels = {
38
+ toDark: 'Tema escuro',
39
+ toLight: 'Tema claro',
40
+ }
41
+
42
+ /**
43
+ * Interruptor de tema, apresentacional: o estado vem do host, junto com `useDarkMode`.
44
+ *
45
+ * Ele não monta o hook de propósito. `useDarkMode` é o controlador — escreve a classe `dark` no
46
+ * `<html>` e persiste a escolha —, e uma barra lateral que também existe como gaveta fica montada
47
+ * duas vezes na mesma tela. Dois controladores guardam estados iniciais separados, e o segundo
48
+ * botão passa a mostrar o ícone contrário ao tema que está no ar. Com o estado no host, o hook é
49
+ * chamado uma vez acima dos dois e as duas cópias concordam.
50
+ *
51
+ * O rótulo nomeia o destino, não o estado atual: no claro se lê "Tema escuro", que é o que o clique
52
+ * faz. `aria-pressed` diria o oposto na mesma frase, então fica de fora.
53
+ */
54
+ export function DarkModeToggle({
55
+ isDark,
56
+ onToggle,
57
+ showLabel = false,
58
+ labels,
59
+ className,
60
+ classNames,
61
+ }: DarkModeToggleProps) {
62
+ const label = isDark
63
+ ? (labels?.toLight ?? DEFAULT_DARK_MODE_TOGGLE_LABELS.toLight)
64
+ : (labels?.toDark ?? DEFAULT_DARK_MODE_TOGGLE_LABELS.toDark)
65
+
66
+ const Icon = isDark ? Sun : Moon
67
+
68
+ return (
69
+ <button
70
+ type="button"
71
+ onClick={onToggle}
72
+ data-cv-tooltip={showLabel ? undefined : label}
73
+ aria-label={label}
74
+ className={cn(
75
+ 'inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors',
76
+ 'hover:bg-gray-100 hover:text-gray-900',
77
+ 'dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100',
78
+ !showLabel && 'cv-touch',
79
+ className,
80
+ )}
81
+ >
82
+ {/* Sem `size` quando o host manda classe: a prop vira `width`/`height` no SVG e atributo
83
+ ganha da folha, deixando o ícone fora da escala de tipografia do host. */}
84
+ <Icon
85
+ aria-hidden="true"
86
+ className={cn('shrink-0', classNames?.icon)}
87
+ size={classNames?.icon ? undefined : ICON_SIZE_ACTION}
88
+ />
89
+ {showLabel ? <span className={classNames?.label}>{label}</span> : null}
90
+ </button>
91
+ )
92
+ }
@@ -7,12 +7,13 @@
7
7
  * clicável. Sem essa referência, uma lista global de anexos não responde nenhuma pergunta.
8
8
  */
9
9
 
10
- import { useEffect, useState } from 'react'
11
- import { ArrowUpDown, Bot, Download, Eye, MessageSquare, Users } from 'lucide-react'
10
+ import { useEffect, useRef, useState } from 'react'
11
+ import { ArrowUpDown, Bot, Download, Eye, MessageSquare, Upload, Users } from 'lucide-react'
12
12
  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'
@@ -33,6 +34,8 @@ export interface DocumentsLibraryLabels {
33
34
  sortMostRecent: string
34
35
  sortOldest: string
35
36
  clearFilters: string
37
+ upload: string
38
+ uploadError: string
36
39
  total: (count: number) => string
37
40
  page: (current: number, last: number) => string
38
41
  }
@@ -53,6 +56,8 @@ export const DEFAULT_DOCUMENTS_LIBRARY_LABELS: DocumentsLibraryLabels = {
53
56
  sortMostRecent: 'Mais recentes',
54
57
  sortOldest: 'Mais antigos',
55
58
  clearFilters: 'Limpar filtros',
59
+ upload: 'Enviar documento',
60
+ uploadError: 'Não foi possível enviar o arquivo.',
56
61
  total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
57
62
  page: (current: number, last: number) => `${current} / ${last}`,
58
63
  }
@@ -105,9 +110,15 @@ export function DocumentsLibrary({
105
110
  const [loading, setLoading] = useState(false)
106
111
  const [failed, setFailed] = useState(false)
107
112
 
113
+ const [uploading, setUploading] = useState(false)
114
+ const [uploadFailed, setUploadFailed] = useState(false)
115
+ const [reloadToken, setReloadToken] = useState(0)
116
+ const fileInputRef = useRef<HTMLInputElement>(null)
117
+
108
118
  const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
109
119
  const lastPage = Math.max(1, Math.ceil(total / perPage))
110
120
  const fetchAll = context?.api.getAllDocuments
121
+ const uploadDocument = context?.api.uploadDocument
111
122
 
112
123
  useEffect(() => {
113
124
  if (!fetchAll) return
@@ -139,7 +150,7 @@ export function DocumentsLibrary({
139
150
  return () => {
140
151
  active = false
141
152
  }
142
- }, [fetchAll, search, sourceFilter, sortDirection, page, perPage])
153
+ }, [fetchAll, search, sourceFilter, sortDirection, page, perPage, reloadToken])
143
154
 
144
155
  function applyFilter(change: () => void): void {
145
156
  change()
@@ -151,6 +162,20 @@ export function DocumentsLibrary({
151
162
  if (url) window.open(url, '_blank', 'noopener,noreferrer')
152
163
  }
153
164
 
165
+ async function handleUpload(file: File): Promise<void> {
166
+ if (!uploadDocument) return
167
+ setUploading(true)
168
+ setUploadFailed(false)
169
+ try {
170
+ await uploadDocument(file)
171
+ setReloadToken((token) => token + 1)
172
+ } catch {
173
+ setUploadFailed(true)
174
+ } finally {
175
+ setUploading(false)
176
+ }
177
+ }
178
+
154
179
  // Host sem `getAllDocuments` não tem o que mostrar aqui — some, em vez de renderizar vazio para
155
180
  // sempre e fazer parecer que a empresa não tem arquivo nenhum.
156
181
  if (!fetchAll) return null
@@ -181,6 +206,7 @@ export function DocumentsLibrary({
181
206
  </select>
182
207
 
183
208
  <button
209
+ data-cv-tooltip={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest} aria-label={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
184
210
  type="button"
185
211
  onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
186
212
  className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
@@ -191,6 +217,7 @@ export function DocumentsLibrary({
191
217
 
192
218
  {hasFilters ? (
193
219
  <button
220
+ data-cv-tooltip={labels.clearFilters} aria-label={labels.clearFilters}
194
221
  type="button"
195
222
  onClick={() =>
196
223
  applyFilter(() => {
@@ -204,6 +231,32 @@ export function DocumentsLibrary({
204
231
  {labels.clearFilters}
205
232
  </button>
206
233
  ) : null}
234
+
235
+ {uploadDocument ? (
236
+ <>
237
+ <input
238
+ ref={fileInputRef}
239
+ type="file"
240
+ hidden
241
+ onChange={(event) => {
242
+ const file = event.target.files?.[0]
243
+ event.target.value = ''
244
+ if (file) void handleUpload(file)
245
+ }}
246
+ />
247
+ <button
248
+ data-cv-tooltip={labels.upload}
249
+ aria-label={labels.upload}
250
+ type="button"
251
+ onClick={() => fileInputRef.current?.click()}
252
+ disabled={uploading}
253
+ className="cv-header-action ml-auto inline-flex items-center gap-1 disabled:opacity-40"
254
+ >
255
+ <Upload size={14} aria-hidden="true" />
256
+ {labels.upload}
257
+ </button>
258
+ </>
259
+ ) : null}
207
260
  </div>
208
261
 
209
262
  {loading ? <p className={cn('text-sm text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
@@ -212,6 +265,11 @@ export function DocumentsLibrary({
212
265
  {labels.failure}
213
266
  </p>
214
267
  ) : null}
268
+ {uploadFailed ? (
269
+ <p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
270
+ {labels.uploadError}
271
+ </p>
272
+ ) : null}
215
273
  {!loading && !failed && documents.length === 0 ? (
216
274
  <p className={cn('text-sm text-gray-500', classNames?.status)}>
217
275
  {hasFilters ? labels.noResults : labels.empty}
@@ -234,7 +292,7 @@ export function DocumentsLibrary({
234
292
  <div className="flex min-w-0 flex-1 items-center gap-3">
235
293
  <FileIcon filename={document.filename} mimeType={document.mimeType} />
236
294
  <div className="min-w-0 flex-1">
237
- <div className={cn('truncate text-sm font-medium', classNames?.filename)} title={document.filename}>
295
+ <div className={cn('truncate text-sm font-medium', classNames?.filename)} data-cv-tooltip={document.filename}>
238
296
  {document.filename}
239
297
  </div>
240
298
  <div className={cn('flex flex-wrap items-center gap-x-2 text-xs text-gray-500', classNames?.meta)}>
@@ -256,7 +314,7 @@ export function DocumentsLibrary({
256
314
  <button
257
315
  type="button"
258
316
  onClick={() => onOpenConversation(document.conversationId)}
259
- title={labels.openConversation}
317
+ data-cv-tooltip={labels.openConversation} aria-label={labels.openConversation}
260
318
  className={cn('cv-header-action inline-flex shrink-0 items-center gap-1', classNames?.conversationLink)}
261
319
  >
262
320
  <MessageSquare size={12} />
@@ -272,7 +330,7 @@ export function DocumentsLibrary({
272
330
  <button
273
331
  type="button"
274
332
  onClick={() => void handleOpen(document.id, 'inline')}
275
- title={labels.view}
333
+ data-cv-tooltip={labels.view}
276
334
  aria-label={`${labels.view}: ${document.filename}`}
277
335
  className="cv-header-icon"
278
336
  >
@@ -281,7 +339,7 @@ export function DocumentsLibrary({
281
339
  <button
282
340
  type="button"
283
341
  onClick={() => void handleOpen(document.id, 'attachment')}
284
- title={labels.download}
342
+ data-cv-tooltip={labels.download}
285
343
  aria-label={`${labels.download}: ${document.filename}`}
286
344
  className="cv-header-icon"
287
345
  >
@@ -298,6 +356,7 @@ export function DocumentsLibrary({
298
356
  <span className="text-gray-400">{labels.total(total)}</span>
299
357
  <div className="flex items-center gap-2">
300
358
  <button
359
+ data-cv-tooltip={PAGINATION_LABELS.previous} aria-label={PAGINATION_LABELS.previous}
301
360
  type="button"
302
361
  onClick={() => setPage(page - 1)}
303
362
  disabled={page <= 1}
@@ -307,6 +366,7 @@ export function DocumentsLibrary({
307
366
  </button>
308
367
  <span className="text-gray-500">{labels.page(page, lastPage)}</span>
309
368
  <button
369
+ data-cv-tooltip={PAGINATION_LABELS.next} aria-label={PAGINATION_LABELS.next}
310
370
  type="button"
311
371
  onClick={() => setPage(page + 1)}
312
372
  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
  )
@@ -1,10 +1,30 @@
1
- import { useState } from 'react'
1
+ import { useState, type ReactNode } from 'react'
2
2
  import { AudioPlayer } from './AudioPlayer'
3
+ import { AudioTranscription } from './AudioTranscription'
3
4
  import { FileIcon } from './FileIcon'
4
5
  import { useConversationLocales } from './ConversationLocalesProvider'
5
6
  import { formatFileSize } from './lib/format'
6
7
  import { cn } from './lib/cn'
7
- import type { MessagePayload } from './types'
8
+ import type { MessagePayload, MessageTranscription } from './types'
9
+
10
+ /**
11
+ * Rótulo curto do tipo, para a linha de baixo da bolha de documento.
12
+ *
13
+ * O subtipo cru do Office é gigantesco — `vnd.openxmlformats-officedocument.wordprocessingml.document`
14
+ * vira uma linha de 58 caracteres em caixa alta que estica a bolha, empurra a coluna da conversa e
15
+ * quebra o layout de três painéis. A extensão do arquivo diz a mesma coisa em quatro letras.
16
+ */
17
+ export function documentTypeLabel(filename?: string, mimeType?: string): string {
18
+ const extension = filename?.split('.').pop()
19
+ if (extension && extension.length <= 5 && extension !== filename) return extension.toUpperCase()
20
+
21
+ const subtype = mimeType?.split(';')[0]?.split('/')[1]
22
+ if (!subtype) return 'FILE'
23
+ // Sem extensão e com subtipo longo (áudio/vídeo sem nome), fica o sufixo depois do último ponto:
24
+ // `…wordprocessingml.document` -> `DOCUMENT`, que ainda informa e não estoura.
25
+ const compacto = subtype.split('.').pop() ?? subtype
26
+ return compacto.slice(0, 12).toUpperCase()
27
+ }
8
28
 
9
29
  function resolveMediaSource(message: MessagePayload): string | null {
10
30
  if (message.mediaUrl) return message.mediaUrl
@@ -31,6 +51,13 @@ export interface MediaRendererProps {
31
51
  // loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
32
52
  // porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
33
53
  onResolveUrl?: ResolveMediaUrl
54
+ /**
55
+ * Pede ao backend a transcrição do áudio desta mensagem. Ausente, o bloco de transcrição só exibe
56
+ * o que já veio pronto — sem oferecer um botão que o host não sabe atender.
57
+ *
58
+ * O que devolver é exibido na hora, sem esperar refetch da lista.
59
+ */
60
+ onTranscribeAudio?: () => Promise<MessageTranscription | void>
34
61
  /** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
35
62
  className?: string
36
63
  }
@@ -58,23 +85,49 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
58
85
  return { url, loading, error, load }
59
86
  }
60
87
 
61
- export function MediaRenderer({ message, onLightbox, onResolveUrl, className }: MediaRendererProps) {
88
+ export function MediaRenderer({
89
+ message,
90
+ onLightbox,
91
+ onResolveUrl,
92
+ onTranscribeAudio,
93
+ className,
94
+ }: MediaRendererProps) {
62
95
  const { bubble } = useConversationLocales()
63
96
  const eagerSrc = resolveMediaSource(message)
64
97
  const lazy = useLazyMediaUrl(message, onResolveUrl)
65
98
  const src = eagerSrc ?? lazy.url
66
99
  const canLazyLoad = !eagerSrc && hasLazyRef(message) && Boolean(onResolveUrl)
67
100
 
68
- const lazyButtonClass = 'text-xs text-blue-600 underline flex items-center gap-1'
101
+ /**
102
+ * O carregamento sob demanda continua sendo um botão, mas com a forma da mídia que ele vai virar
103
+ * — um link sublinhado no meio da conversa lê como texto da mensagem, não como controle, e é a
104
+ * única bolha que não se parece com o que contém.
105
+ */
106
+ function LazyMediaButton({ icon, label }: { icon: ReactNode; label: string }) {
107
+ return (
108
+ <button
109
+ data-cv-tooltip={label} aria-label={label}
110
+ onClick={lazy.load}
111
+ disabled={lazy.loading}
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"
113
+ >
114
+ <span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-white">
115
+ {icon}
116
+ </span>
117
+ <span className="truncate text-xs text-gray-600 dark:text-gray-300">{label}</span>
118
+ </button>
119
+ )
120
+ }
69
121
 
70
122
  switch (message.type) {
71
123
  case 'image':
72
124
  case 'sticker': {
73
125
  if (!src && canLazyLoad) {
74
126
  return (
75
- <button onClick={lazy.load} className={lazyButtonClass}>
76
- {lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage}
77
- </button>
127
+ <LazyMediaButton
128
+ icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /></svg>}
129
+ label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage}
130
+ />
78
131
  )
79
132
  }
80
133
  return (
@@ -92,9 +145,10 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
92
145
  case 'video': {
93
146
  if (!src && canLazyLoad) {
94
147
  return (
95
- <button onClick={lazy.load} className={lazyButtonClass}>
96
- {lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo}
97
- </button>
148
+ <LazyMediaButton
149
+ icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="23 7 16 12 23 17 23 7" /><rect x="1" y="5" width="15" height="14" rx="2" ry="2" /></svg>}
150
+ label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo}
151
+ />
98
152
  )
99
153
  }
100
154
  return (
@@ -110,11 +164,28 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
110
164
  )
111
165
  }
112
166
  case 'audio': {
167
+ /**
168
+ * Fica fora do ramo de carregamento de propósito: a transcrição não depende dos bytes do
169
+ * áudio. Ler o que o cliente disse sem baixar e tocar a nota de voz é o caminho rápido do
170
+ * atendimento — e é justamente o que se perderia se o bloco só aparecesse depois do play.
171
+ */
172
+ const transcriptionBlock = (
173
+ <AudioTranscription
174
+ transcription={message.transcription}
175
+ isMine={message.direction === 'outbound'}
176
+ {...(onTranscribeAudio ? { onTranscribe: onTranscribeAudio } : {})}
177
+ />
178
+ )
179
+
113
180
  if (!src && canLazyLoad) {
114
181
  return (
115
- <button onClick={lazy.load} className={lazyButtonClass}>
116
- {lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
117
- </button>
182
+ <div className="min-w-[200px]">
183
+ <LazyMediaButton
184
+ icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="translate-x-0.5"><polygon points="5 3 19 12 5 21 5 3" /></svg>}
185
+ label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
186
+ />
187
+ {transcriptionBlock}
188
+ </div>
118
189
  )
119
190
  }
120
191
  return (
@@ -122,11 +193,12 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
122
193
  {src ? <AudioPlayer src={src} isMine={message.direction === 'outbound'} /> : (
123
194
  <div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">{bubble.mediaUnavailable}</div>
124
195
  )}
196
+ {transcriptionBlock}
125
197
  </div>
126
198
  )
127
199
  }
128
200
  case 'document': {
129
- const typeLabel = message.mimeType?.split('/')[1]?.toUpperCase() ?? 'FILE'
201
+ const typeLabel = documentTypeLabel(message.filename, message.mimeType)
130
202
  const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
131
203
  return (
132
204
  <div className={cn('flex items-center gap-3 min-w-[200px]', className)}>
@@ -135,7 +207,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
135
207
  </div>
136
208
  <div className="flex-1 min-w-0">
137
209
  <p className="text-sm font-medium truncate">{message.filename ?? bubble.untitledDocument}</p>
138
- <p className="text-xs text-gray-500">{sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel}</p>
210
+ <p className="truncate text-xs text-gray-500">{sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel}</p>
139
211
  </div>
140
212
  {src ? (
141
213
  <a href={src} download={message.filename} className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 flex-shrink-0 transition-colors" aria-label={bubble.downloadFile}>
@@ -143,6 +215,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
143
215
  </a>
144
216
  ) : canLazyLoad ? (
145
217
  <button
218
+ data-cv-tooltip={bubble.downloadFile}
146
219
  onClick={lazy.load}
147
220
  disabled={lazy.loading}
148
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
+ })
@@ -1,6 +1,6 @@
1
1
  import { useMemo, useState } from 'react'
2
2
  import { Check } from 'lucide-react'
3
- import type { InteractiveSelection, MessagePayload } from './types'
3
+ import type { InteractiveSelection, MessagePayload, MessageTranscription } from './types'
4
4
  import { useConversationLocales } from './ConversationLocalesProvider'
5
5
  import { StatusTicks } from './StatusTicks'
6
6
  import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
@@ -30,6 +30,12 @@ export interface MessageBubbleProps {
30
30
  * no histórico da inbox: o operador vê o que foi oferecido, sem responder no lugar do cliente.
31
31
  */
32
32
  onInteractiveSelect?: (selection: InteractiveSelection) => void
33
+ /**
34
+ * Pede a transcrição do áudio. Ausente, o balão usa `transcribeAudio` do `ConversationsApi` do
35
+ * contexto — passe apenas para sobrescrever. Sem nenhum dos dois, o bloco de transcrição só exibe
36
+ * o que já veio pronto do backend.
37
+ */
38
+ onTranscribeAudio?: (messageId: string) => Promise<MessageTranscription | void>
33
39
  className?: string
34
40
  }
35
41
 
@@ -51,7 +57,7 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
51
57
  // tailwind.config do host expondo as cores `whatsapp.*` — ver Wallpaper.tsx e T6.2.
52
58
  export function MessageBubble({
53
59
  message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
54
- onResolveMediaUrl, onInteractiveSelect, className,
60
+ onResolveMediaUrl, onInteractiveSelect, onTranscribeAudio, className,
55
61
  }: MessageBubbleProps) {
56
62
  const { bubble, selection } = useConversationLocales()
57
63
  const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
@@ -66,11 +72,35 @@ export function MessageBubble({
66
72
  [onResolveMediaUrl, context?.api],
67
73
  )
68
74
 
75
+ /**
76
+ * `undefined` quando nem o host nem o `ConversationsApi` sabem transcrever — é essa ausência que o
77
+ * bloco de transcrição consulta para decidir se desenha a afordância, mesmo padrão de
78
+ * `takeover`/`release`.
79
+ */
80
+ const requestTranscription = useMemo(() => {
81
+ const transcribe = onTranscribeAudio ?? context?.api?.transcribeAudio?.bind(context.api)
82
+ if (!transcribe) return undefined
83
+ // Devolve o resultado em vez de descartar: é o que o bloco exibe na hora, sem esperar refetch.
84
+ return () => transcribe(message.id)
85
+ }, [onTranscribeAudio, context?.api, message.id])
86
+
69
87
  const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
70
88
  const hasError = message.status === 'failed'
71
89
  const isMedia = MEDIA_TYPES.has(message.type)
72
90
  const isTemplate = message.type === 'template'
73
91
  const isInteractive = message.type === 'interactive'
92
+
93
+ /**
94
+ * A legenda chega em `caption` quando o cliente manda, e em `content` quando somos nós — é lá que
95
+ * a API grava o texto do envio. Sem legenda a API repete o nome do arquivo em `content`, que não
96
+ * é conteúdo e não deve aparecer sob a mídia.
97
+ */
98
+ const mediaCaption = isMedia
99
+ ? [message.caption, message.content].find((text) => {
100
+ const trimmed = text?.trim()
101
+ return trimmed && trimmed !== message.filename
102
+ })
103
+ : undefined
74
104
  const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
75
105
 
76
106
  const tooltipText = message.status === 'read' && message.readAt
@@ -86,7 +116,7 @@ export function MessageBubble({
86
116
  const checkbox = (
87
117
  <button
88
118
  onClick={(e) => { e.stopPropagation(); onToggleSelect?.() }}
89
- title={selection.select}
119
+ data-cv-tooltip={selection.select} aria-label={selection.select}
90
120
  className={`
91
121
  flex-shrink-0 self-end mb-1 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all
92
122
  ${isSelected ? 'bg-teal-600 border-teal-600' : 'bg-white/80 dark:bg-black/40 border-black/20 dark:border-white/30'}
@@ -130,7 +160,7 @@ export function MessageBubble({
130
160
  {message.moderation?.isOffensive && (
131
161
  <div
132
162
  className="mb-1 inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-950/60 dark:text-amber-300"
133
- title={message.moderation.terms.length > 0 ? message.moderation.terms.join(', ') : undefined}
163
+ data-cv-tooltip={message.moderation.terms.length > 0 ? message.moderation.terms.join(', ') : undefined}
134
164
  >
135
165
  <span aria-hidden>⚠️</span>
136
166
  <span>{bubble.moderationFlagged}</span>
@@ -138,7 +168,19 @@ export function MessageBubble({
138
168
  )}
139
169
 
140
170
  {isMedia ? (
141
- <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={resolveMediaUrl} />
171
+ <>
172
+ <MediaRenderer
173
+ message={message}
174
+ onLightbox={setLightboxSrc}
175
+ onResolveUrl={resolveMediaUrl}
176
+ {...(requestTranscription ? { onTranscribeAudio: requestTranscription } : {})}
177
+ />
178
+ {mediaCaption ? (
179
+ <div className="mt-1 text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words leading-[19px]">
180
+ {parseWhatsAppFormatting(mediaCaption)}
181
+ </div>
182
+ ) : null}
183
+ </>
142
184
  ) : isInteractive && message.payload ? (
143
185
  // O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
144
186
  // só uma cópia achatada para busca — renderizar `content` aqui duplicaria o corpo.