@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.21

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 (106) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-TV4OQRGH.js +2187 -0
  3. package/dist/flows/index.d.ts +14 -2
  4. package/dist/flows/index.js +117 -39
  5. package/dist/index.d.ts +862 -136
  6. package/dist/index.js +1746 -1124
  7. package/dist/preview/index.d.ts +470 -0
  8. package/dist/preview/index.js +1329 -0
  9. package/dist/styles.css +228 -0
  10. package/dist/types-C6A_9edv.d.ts +456 -0
  11. package/package.json +10 -3
  12. package/src/AudioRecorderButton.test.tsx +30 -0
  13. package/src/AudioRecorderButton.tsx +248 -0
  14. package/src/AudioTranscription.test.tsx +115 -0
  15. package/src/AudioTranscription.tsx +249 -0
  16. package/src/Avatar.tsx +30 -4
  17. package/src/ChannelIcon.tsx +87 -0
  18. package/src/ConversationContextPanel.tsx +280 -0
  19. package/src/ConversationDocumentsPanel.tsx +425 -0
  20. package/src/ConversationHeader.tsx +257 -0
  21. package/src/ConversationListItem.tsx +54 -7
  22. package/src/ConversationLocalesProvider.tsx +44 -0
  23. package/src/ConversationRow.tsx +137 -0
  24. package/src/DateDivider.tsx +16 -3
  25. package/src/DocumentsLibrary.tsx +322 -0
  26. package/src/EmojiPicker.tsx +69 -55
  27. package/src/FileIcon.test.ts +83 -0
  28. package/src/FileIcon.tsx +88 -11
  29. package/src/InteractiveMessage.test.tsx +41 -0
  30. package/src/InteractiveMessage.tsx +143 -0
  31. package/src/Lightbox.tsx +18 -3
  32. package/src/MediaRenderer.tsx +96 -22
  33. package/src/MessageBubble.tsx +77 -5
  34. package/src/MessageComposer.test.tsx +35 -0
  35. package/src/MessageComposer.tsx +165 -19
  36. package/src/RichMessageComposer.test.tsx +83 -0
  37. package/src/RichMessageComposer.tsx +380 -0
  38. package/src/Wallpaper.test.tsx +21 -0
  39. package/src/Wallpaper.tsx +69 -7
  40. package/src/WhatsAppMessageEditor.tsx +28 -4
  41. package/src/WindowExpiredNotice.tsx +57 -0
  42. package/src/audioRecorderFormat.test.ts +67 -0
  43. package/src/conversationChannel.test.ts +53 -0
  44. package/src/conversationChannel.ts +146 -0
  45. package/src/conversationTranscript.test.ts +122 -0
  46. package/src/conversationTranscript.ts +89 -0
  47. package/src/conversationWindow.test.ts +90 -0
  48. package/src/conversationWindow.ts +78 -0
  49. package/src/documentTypeLabel.test.ts +57 -0
  50. package/src/emojiCatalog.test.ts +35 -0
  51. package/src/emojiCatalog.ts +189 -0
  52. package/src/flows/FlowGroupHeader.tsx +12 -2
  53. package/src/flows/FlowMapCanvas.tsx +17 -14
  54. package/src/flows/FlowMapNode.tsx +3 -1
  55. package/src/flows/FlowNodeCard.tsx +22 -4
  56. package/src/flows/FlowNodePanel.tsx +132 -35
  57. package/src/flows/FlowPalette.tsx +6 -2
  58. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  59. package/src/flows/flowGraph.ts +5 -5
  60. package/src/flows/labels.ts +5 -0
  61. package/src/hooks/useConversationActions.ts +56 -0
  62. package/src/hooks/useConversationDocuments.ts +15 -9
  63. package/src/hooks/useConversationList.ts +15 -9
  64. package/src/hooks/useConversationMessages.ts +2 -2
  65. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  66. package/src/index.ts +140 -16
  67. package/src/lib/cn.test.ts +29 -0
  68. package/src/lib/cn.ts +15 -0
  69. package/src/lib/createMediaUrlResolver.ts +33 -0
  70. package/src/lib/paginated.test.ts +33 -0
  71. package/src/lib/paginated.ts +26 -0
  72. package/src/lib/phone.ts +34 -0
  73. package/src/preview/ConversationPreview.tsx +334 -0
  74. package/src/preview/MediaTypesPreview.tsx +87 -0
  75. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  76. package/src/preview/createMockConversationsApi.ts +271 -0
  77. package/src/preview/createMockSSEProvider.ts +40 -0
  78. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  79. package/src/preview/createPreviewBridgeClient.ts +124 -0
  80. package/src/preview/createPreviewMediaUploader.ts +82 -0
  81. package/src/preview/createPreviewWebhookClient.test.ts +194 -0
  82. package/src/preview/createPreviewWebhookClient.ts +222 -0
  83. package/src/preview/index.ts +67 -0
  84. package/src/preview/mediaTypeOf.test.ts +15 -0
  85. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  86. package/src/preview/mockEventSource.ts +53 -0
  87. package/src/preview/preview.test.ts +177 -0
  88. package/src/preview/previewFileSamples.test.ts +151 -0
  89. package/src/preview/previewFileSamples.ts +74 -0
  90. package/src/preview/previewFixtures.ts +440 -0
  91. package/src/preview/previewMediaSource.test.ts +62 -0
  92. package/src/preview/previewMediaSource.ts +91 -0
  93. package/src/preview/previewMediaUploader.test.ts +61 -0
  94. package/src/preview/previewStore.ts +193 -0
  95. package/src/preview/startPreviewScript.ts +60 -0
  96. package/src/providers/types.ts +175 -12
  97. package/src/quickReply.test.ts +58 -0
  98. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  99. package/src/settings/TranscriptionSettingsForm.tsx +189 -0
  100. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  101. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  102. package/src/styles.css +173 -0
  103. package/src/types.ts +72 -1
  104. package/src/useDarkMode.ts +26 -0
  105. package/src/useIsNarrow.ts +29 -0
  106. package/src/useWaitingNotifications.ts +74 -29
@@ -1,9 +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
- import type { MessagePayload } from './types'
7
+ import { cn } from './lib/cn'
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
+ }
7
28
 
8
29
  function resolveMediaSource(message: MessagePayload): string | null {
9
30
  if (message.mediaUrl) return message.mediaUrl
@@ -30,6 +51,15 @@ export interface MediaRendererProps {
30
51
  // loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
31
52
  // porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
32
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>
61
+ /** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
62
+ className?: string
33
63
  }
34
64
 
35
65
  function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl) {
@@ -55,29 +85,54 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
55
85
  return { url, loading, error, load }
56
86
  }
57
87
 
58
- export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRendererProps) {
88
+ export function MediaRenderer({
89
+ message,
90
+ onLightbox,
91
+ onResolveUrl,
92
+ onTranscribeAudio,
93
+ className,
94
+ }: MediaRendererProps) {
59
95
  const { bubble } = useConversationLocales()
60
96
  const eagerSrc = resolveMediaSource(message)
61
97
  const lazy = useLazyMediaUrl(message, onResolveUrl)
62
98
  const src = eagerSrc ?? lazy.url
63
99
  const canLazyLoad = !eagerSrc && hasLazyRef(message) && Boolean(onResolveUrl)
64
100
 
65
- 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
+ onClick={lazy.load}
110
+ disabled={lazy.loading}
111
+ 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"
112
+ >
113
+ <span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-white">
114
+ {icon}
115
+ </span>
116
+ <span className="truncate text-xs text-gray-600 dark:text-gray-300">{label}</span>
117
+ </button>
118
+ )
119
+ }
66
120
 
67
121
  switch (message.type) {
68
122
  case 'image':
69
123
  case 'sticker': {
70
124
  if (!src && canLazyLoad) {
71
125
  return (
72
- <button onClick={lazy.load} className={lazyButtonClass}>
73
- {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro tentar novamente' : bubble.viewImage}
74
- </button>
126
+ <LazyMediaButton
127
+ 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>}
128
+ label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage}
129
+ />
75
130
  )
76
131
  }
77
132
  return (
78
133
  <div className="min-w-[200px]">
79
134
  {src ? (
80
- <img src={src} alt={message.caption ?? 'Image'} className="w-full max-h-80 object-cover cursor-pointer hover:opacity-90 transition-opacity" onClick={() => onLightbox(src)} loading="lazy" />
135
+ <img src={src} alt={message.caption ?? bubble.imageAlt} className="w-full max-h-80 object-cover cursor-pointer hover:opacity-90 transition-opacity" onClick={() => onLightbox(src)} loading="lazy" />
81
136
  ) : (
82
137
  <div className="w-full h-40 bg-gray-200 flex items-center justify-center text-gray-400">
83
138
  <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><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>
@@ -89,9 +144,10 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
89
144
  case 'video': {
90
145
  if (!src && canLazyLoad) {
91
146
  return (
92
- <button onClick={lazy.load} className={lazyButtonClass}>
93
- {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro tentar novamente' : bubble.viewVideo}
94
- </button>
147
+ <LazyMediaButton
148
+ 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>}
149
+ label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo}
150
+ />
95
151
  )
96
152
  }
97
153
  return (
@@ -107,35 +163,53 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
107
163
  )
108
164
  }
109
165
  case 'audio': {
166
+ /**
167
+ * Fica fora do ramo de carregamento de propósito: a transcrição não depende dos bytes do
168
+ * áudio. Ler o que o cliente disse sem baixar e tocar a nota de voz é o caminho rápido do
169
+ * atendimento — e é justamente o que se perderia se o bloco só aparecesse depois do play.
170
+ */
171
+ const transcriptionBlock = (
172
+ <AudioTranscription
173
+ transcription={message.transcription}
174
+ isMine={message.direction === 'outbound'}
175
+ {...(onTranscribeAudio ? { onTranscribe: onTranscribeAudio } : {})}
176
+ />
177
+ )
178
+
110
179
  if (!src && canLazyLoad) {
111
180
  return (
112
- <button onClick={lazy.load} className={lazyButtonClass}>
113
- {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro — tentar novamente' : bubble.listenAudio}
114
- </button>
181
+ <div className="min-w-[200px]">
182
+ <LazyMediaButton
183
+ 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>}
184
+ label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
185
+ />
186
+ {transcriptionBlock}
187
+ </div>
115
188
  )
116
189
  }
117
190
  return (
118
191
  <div className="min-w-[200px]">
119
192
  {src ? <AudioPlayer src={src} isMine={message.direction === 'outbound'} /> : (
120
- <div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">Audio unavailable</div>
193
+ <div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">{bubble.mediaUnavailable}</div>
121
194
  )}
195
+ {transcriptionBlock}
122
196
  </div>
123
197
  )
124
198
  }
125
199
  case 'document': {
126
- const typeLabel = message.mimeType?.split('/')[1]?.toUpperCase() ?? 'FILE'
200
+ const typeLabel = documentTypeLabel(message.filename, message.mimeType)
127
201
  const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
128
202
  return (
129
- <div className="flex items-center gap-3 min-w-[200px]">
203
+ <div className={cn('flex items-center gap-3 min-w-[200px]', className)}>
130
204
  <div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0">
131
205
  <FileIcon filename={message.filename} mimeType={message.mimeType} />
132
206
  </div>
133
207
  <div className="flex-1 min-w-0">
134
- <p className="text-sm font-medium truncate">{message.filename ?? 'Document'}</p>
135
- <p className="text-xs text-gray-500">{sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel}</p>
208
+ <p className="text-sm font-medium truncate">{message.filename ?? bubble.untitledDocument}</p>
209
+ <p className="truncate text-xs text-gray-500">{sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel}</p>
136
210
  </div>
137
211
  {src ? (
138
- <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="Download">
212
+ <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}>
139
213
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-gray-600"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
140
214
  </a>
141
215
  ) : canLazyLoad ? (
@@ -143,12 +217,12 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
143
217
  onClick={lazy.load}
144
218
  disabled={lazy.loading}
145
219
  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"
146
- aria-label="Download"
220
+ aria-label={bubble.downloadFile}
147
221
  >
148
222
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-gray-600"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
149
223
  </button>
150
224
  ) : null}
151
- {lazy.error && <span className="text-xs text-red-500 flex-shrink-0">Erro</span>}
225
+ {lazy.error && <span className="text-xs text-red-500 flex-shrink-0">{bubble.mediaError}</span>}
152
226
  </div>
153
227
  )
154
228
  }
@@ -1,11 +1,15 @@
1
- import { useState } from 'react'
1
+ import { useMemo, useState } from 'react'
2
2
  import { Check } from 'lucide-react'
3
- import type { 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'
7
7
  import { Lightbox } from './Lightbox'
8
+ import { InteractiveMessage } from './InteractiveMessage'
8
9
  import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
10
+ import { cn } from './lib/cn'
11
+ import { createMediaUrlResolver } from './lib/createMediaUrlResolver'
12
+ import { useConversations } from './providers/ConversationsProvider'
9
13
  import { formatTimestamp, formatDateTime } from './lib/format'
10
14
 
11
15
  export interface MessageBubbleProps {
@@ -16,7 +20,23 @@ export interface MessageBubbleProps {
16
20
  isSelecting?: boolean
17
21
  isSelected?: boolean
18
22
  onToggleSelect?: () => void
23
+ /**
24
+ * Como buscar a mídia da mensagem. Ausente, o balão usa o `ConversationsApi` do
25
+ * `ConversationsProvider` — passe apenas para sobrescrever (cache próprio, CDN, proxy do host).
26
+ */
19
27
  onResolveMediaUrl?: ResolveMediaUrl
28
+ /**
29
+ * Toque numa opção do menu interativo. Ausente, as opções aparecem desabilitadas — que é o certo
30
+ * no histórico da inbox: o operador vê o que foi oferecido, sem responder no lugar do cliente.
31
+ */
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>
39
+ className?: string
20
40
  }
21
41
 
22
42
  // Hex arbitrários (não os tokens `whatsapp.*` do Tailwind) — o pacote fica autocontido,
@@ -37,15 +57,38 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
37
57
  // tailwind.config do host expondo as cores `whatsapp.*` — ver Wallpaper.tsx e T6.2.
38
58
  export function MessageBubble({
39
59
  message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
40
- onResolveMediaUrl,
60
+ onResolveMediaUrl, onInteractiveSelect, onTranscribeAudio, className,
41
61
  }: MessageBubbleProps) {
42
62
  const { bubble, selection } = useConversationLocales()
43
63
  const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
64
+ const context = useConversations()
65
+
66
+ // Resolvedor pelo contexto quando o host não passa um: o contrato já declara `getDocumentUrl` e
67
+ // `getMediaProxyUrl`, então exigir que cada projeto ligasse esse fio só garantia que a mídia não
68
+ // carregasse em quem esquecesse. `useMemo` porque o resolvedor é a identidade de que o
69
+ // `MediaRenderer` depende para não refazer busca a cada render.
70
+ const resolveMediaUrl = useMemo(
71
+ () => onResolveMediaUrl ?? (context?.api ? createMediaUrlResolver(context.api) : undefined),
72
+ [onResolveMediaUrl, context?.api],
73
+ )
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])
44
86
 
45
87
  const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
46
88
  const hasError = message.status === 'failed'
47
89
  const isMedia = MEDIA_TYPES.has(message.type)
48
90
  const isTemplate = message.type === 'template'
91
+ const isInteractive = message.type === 'interactive'
49
92
  const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
50
93
 
51
94
  const tooltipText = message.status === 'read' && message.readAt
@@ -73,7 +116,14 @@ export function MessageBubble({
73
116
  )
74
117
 
75
118
  return (
76
- <div className={`flex items-end gap-1.5 ${isMine ? 'justify-end' : 'justify-start'} group ${isFirstInGroup ? 'mt-2' : 'mt-0.5'}`}>
119
+ <div
120
+ className={cn(
121
+ 'flex items-end gap-1.5 group',
122
+ isMine ? 'justify-end' : 'justify-start',
123
+ isFirstInGroup ? 'mt-2' : 'mt-0.5',
124
+ className,
125
+ )}
126
+ >
77
127
  {isMine && checkbox}
78
128
  <div
79
129
  onClick={isSelecting ? onToggleSelect : undefined}
@@ -92,8 +142,30 @@ export function MessageBubble({
92
142
  </div>
93
143
  )}
94
144
 
145
+ {/* Sinaliza, não censura nem esconde: cliente xingando é cliente irritado, e o atendente
146
+ precisa ler o que foi dito para responder. A etiqueta serve para achar o caso na thread
147
+ e para justificar escalar. */}
148
+ {message.moderation?.isOffensive && (
149
+ <div
150
+ 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"
151
+ title={message.moderation.terms.length > 0 ? message.moderation.terms.join(', ') : undefined}
152
+ >
153
+ <span aria-hidden>⚠️</span>
154
+ <span>{bubble.moderationFlagged}</span>
155
+ </div>
156
+ )}
157
+
95
158
  {isMedia ? (
96
- <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={onResolveMediaUrl} />
159
+ <MediaRenderer
160
+ message={message}
161
+ onLightbox={setLightboxSrc}
162
+ onResolveUrl={resolveMediaUrl}
163
+ {...(requestTranscription ? { onTranscribeAudio: requestTranscription } : {})}
164
+ />
165
+ ) : isInteractive && message.payload ? (
166
+ // O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
167
+ // só uma cópia achatada para busca — renderizar `content` aqui duplicaria o corpo.
168
+ <InteractiveMessage payload={message.payload} onSelect={onInteractiveSelect} />
97
169
  ) : (
98
170
  <>
99
171
  {isTemplate && (
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { MessageComposer } from './MessageComposer'
5
+
6
+ describe('MessageComposer', () => {
7
+ it('mostra a ação ociosa no lugar do enviar quando não há nada para enviar', () => {
8
+ const markup = renderToStaticMarkup(
9
+ <MessageComposer onSend={() => {}} idleAction={<button aria-label="Gravar áudio" />} />,
10
+ )
11
+
12
+ expect(markup).toContain('aria-label="Gravar áudio"')
13
+ expect(markup).not.toContain('aria-label="Enviar"')
14
+ })
15
+
16
+ it('mantém o botão de enviar quando não há ação ociosa', () => {
17
+ const markup = renderToStaticMarkup(<MessageComposer onSend={() => {}} />)
18
+
19
+ expect(markup).toContain('aria-label="Enviar"')
20
+ })
21
+
22
+ it('mostra o enviar assim que há texto, mesmo com ação ociosa configurada', () => {
23
+ const markup = renderToStaticMarkup(
24
+ <MessageComposer
25
+ onSend={() => {}}
26
+ value="oi"
27
+ onChange={() => {}}
28
+ idleAction={<button aria-label="Gravar áudio" />}
29
+ />,
30
+ )
31
+
32
+ expect(markup).toContain('aria-label="Enviar"')
33
+ expect(markup).not.toContain('aria-label="Gravar áudio"')
34
+ })
35
+ })
@@ -1,8 +1,57 @@
1
- import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent } from 'react'
1
+ import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent, type ReactNode } from 'react'
2
+ import { AudioRecorderButton } from './AudioRecorderButton'
2
3
  import type { ConversationsFeatures } from './types'
4
+ import { cn } from './lib/cn'
3
5
  import { EmojiPicker } from './EmojiPicker'
4
6
 
7
+ /**
8
+ * Mensagem pronta que o atendente cola no campo com um clique.
9
+ *
10
+ * `text` aceita string com `{{variavel}}` ou função: a string cobre o caso comum (copy fixa com o
11
+ * nome do cliente no meio) sem o host escrever código, e a função cobre o que precisa de lógica —
12
+ * escolher texto por produto, pluralizar, formatar moeda.
13
+ */
14
+ export interface QuickReply {
15
+ key: string
16
+ /** O que aparece no chip, emoji incluso: `👋 Saudação`. */
17
+ label: string
18
+ text: string | ((variables: Readonly<Record<string, string>>) => string)
19
+ }
20
+
21
+ /**
22
+ * Troca `{{nome}}` pelos valores passados. Variável ausente vira string vazia, e não o literal
23
+ * `{{nome}}`: mandar "Olá {{nome}}!" para o cliente é pior que mandar "Olá !".
24
+ */
25
+ export function applyQuickReplyVariables(
26
+ template: string,
27
+ variables: Readonly<Record<string, string>> = {},
28
+ ): string {
29
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, chave: string) => variables[chave] ?? '')
30
+ }
31
+
32
+ export function resolveQuickReply(
33
+ quickReply: QuickReply,
34
+ variables: Readonly<Record<string, string>> = {},
35
+ ): string {
36
+ return typeof quickReply.text === 'function'
37
+ ? quickReply.text(variables)
38
+ : applyQuickReplyVariables(quickReply.text, variables)
39
+ }
40
+
41
+ export interface MessageComposerLabels {
42
+ emoji: string
43
+ attach: string
44
+ send: string
45
+ }
46
+
47
+ export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
48
+ emoji: 'Emoji',
49
+ attach: 'Anexar',
50
+ send: 'Enviar',
51
+ }
52
+
5
53
  export interface MessageComposerProps {
54
+ labels?: Partial<MessageComposerLabels>
6
55
  onSend: (text: string) => void
7
56
  onAttach?: (file: File) => void
8
57
  value?: string
@@ -12,10 +61,42 @@ export interface MessageComposerProps {
12
61
  maxLength?: number
13
62
  disabled?: boolean
14
63
  acceptedFileTypes?: string
64
+ /**
65
+ * Ocupa o lugar do botão de enviar enquanto não há nada para enviar — é onde o WhatsApp põe o
66
+ * microfone. Fora do campo, o botão vira um bloco solto ao lado do pill e quebra a barra.
67
+ */
68
+ idleAction?: ReactNode
69
+ /** Mensagens prontas exibidas acima do campo. Vazio ou ausente, a faixa não é renderizada. */
70
+ quickReplies?: readonly QuickReply[]
71
+ /** Valores para `{{variavel}}` — tipicamente nome do cliente, produto, protocolo. */
72
+ quickReplyVariables?: Readonly<Record<string, string>>
73
+ className?: string
74
+ classNames?: Partial<MessageComposerClassNames>
75
+ }
76
+
77
+ export interface MessageComposerClassNames {
78
+ root: string
79
+ quickReplies: string
80
+ quickReply: string
81
+ field: string
15
82
  }
16
83
 
17
- // Paridade com financiamento-imobiliario-bot/apps/web/src/pages/ConversationsPage.tsx:1490
18
- const DEFAULT_ACCEPTED_FILE_TYPES = 'image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip'
84
+ /**
85
+ * Exatamente o que a Meta aceita em mensagem de mídia — imagem, sticker, áudio, vídeo e a lista
86
+ * fechada de documentos. Oferecer no seletor um formato que o WhatsApp recusa (`.zip`, `.rtf`)
87
+ * empurra a falha para depois do envio, quando já não dá para explicar ao operador o que houve.
88
+ * Produto com regra própria passa `acceptedFileTypes`.
89
+ */
90
+ export const DEFAULT_ACCEPTED_FILE_TYPES = [
91
+ 'image/jpeg,image/png,image/webp',
92
+ 'audio/aac,audio/mp4,audio/mpeg,audio/amr,audio/ogg',
93
+ 'video/mp4,video/3gpp',
94
+ 'application/pdf,text/plain,text/csv',
95
+ 'application/msword,application/vnd.ms-excel,application/vnd.ms-powerpoint',
96
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
97
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
98
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
99
+ ].join(',')
19
100
 
20
101
  interface FilePreview {
21
102
  file: File
@@ -27,12 +108,21 @@ export const MessageComposer = ({
27
108
  onAttach,
28
109
  value: externalValue,
29
110
  onChange: externalOnChange,
111
+ quickReplies,
112
+ quickReplyVariables,
30
113
  features,
31
114
  placeholder = 'Digite uma mensagem...',
32
115
  maxLength,
33
116
  disabled = false,
34
117
  acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
118
+ idleAction,
119
+ className,
120
+ classNames,
121
+ labels,
35
122
  }: MessageComposerProps) => {
123
+ const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
124
+ const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
125
+ const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
36
126
  const [internalText, setInternalText] = useState('')
37
127
  const [showEmoji, setShowEmoji] = useState(false)
38
128
  const [attachments, setAttachments] = useState<FilePreview[]>([])
@@ -130,11 +220,63 @@ export const MessageComposer = ({
130
220
  }
131
221
  }, [text, setText])
132
222
 
223
+ /**
224
+ * Microfone por padrão, sem o host precisar compor nada.
225
+ *
226
+ * Antes o gravador era só um componente exportado e um slot vazio: cada inbox tinha que lembrar de
227
+ * juntar os dois. Dois produtos, dois resultados — um ligou, o outro não, e a ausência não dava
228
+ * erro nenhum. Um composer de WhatsApp sem microfone está incompleto, então o default certo é ter.
229
+ *
230
+ * Depende de `onAttach` porque áudio gravado é um arquivo para entregar, e microfone que grava sem
231
+ * ter para onde mandar é pior que microfone nenhum — o operador fala e o áudio evapora. É a mesma
232
+ * regra de capacidade usada no resto do pacote: sem a porta, a afordância não aparece.
233
+ *
234
+ * `idleAction` continua vencendo: quem já compunha o próprio gravador (com rótulos, limite de
235
+ * duração ou revisão diferentes) não muda de comportamento ao atualizar.
236
+ */
237
+ const effectiveIdleAction =
238
+ idleAction ??
239
+ (onAttach ? <AudioRecorderButton onRecorded={(file) => onAttach(file)} /> : undefined)
240
+
133
241
  const canSend = text.trim().length > 0 || attachments.length > 0
134
242
  const remaining = maxLength ? maxLength - text.length : null
135
243
 
136
244
  return (
137
- <div>
245
+ /* A barra é a superfície (cinza, largura cheia, sem raio) e o campo dentro é que arredonda —
246
+ ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
247
+ descobriam o fundo branco da página, que lia como defeito. */
248
+ <div className={cn('bg-[#f0f2f5] px-2 py-2', className)}>
249
+ {/* Uma linha com scroll no celular e wrap no desktop: em 375px seis chips em wrap empurrariam
250
+ o campo para fora da tela, e o campo é a razão de a barra existir. */}
251
+ {quickReplies && quickReplies.length > 0 && (
252
+ <div
253
+ className={cn(
254
+ 'mb-2 flex flex-nowrap gap-1 overflow-x-auto px-1 sm:flex-wrap sm:overflow-x-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
255
+ classNames?.quickReplies,
256
+ )}
257
+ >
258
+ {quickReplies.map((quickReply) => (
259
+ <button
260
+ key={quickReply.key}
261
+ type="button"
262
+ // Preenche o campo em vez de enviar: mensagem pronta é ponto de partida, e quem
263
+ // atende quase sempre ajusta uma palavra antes de mandar. Enviar direto no clique
264
+ // transformaria um toque errado em mensagem entregue ao cliente.
265
+ onClick={() => {
266
+ setText(resolveQuickReply(quickReply, quickReplyVariables))
267
+ textareaRef.current?.focus()
268
+ }}
269
+ className={cn(
270
+ 'whitespace-nowrap rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400',
271
+ classNames?.quickReply,
272
+ )}
273
+ >
274
+ {quickReply.label}
275
+ </button>
276
+ ))}
277
+ </div>
278
+ )}
279
+
138
280
  {attachments.length > 0 && (
139
281
  <div className="flex gap-2 px-1 pb-2 overflow-x-auto">
140
282
  {attachments.map((a, i) => (
@@ -152,10 +294,10 @@ export const MessageComposer = ({
152
294
  </div>
153
295
  )}
154
296
 
155
- <div className="flex items-end gap-1.5 bg-[#f0f2f5] rounded-xl px-3 py-2">
297
+ <div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
156
298
  {showEmojiButton && (
157
299
  <div className="relative flex-shrink-0">
158
- <button 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="Emoji">
300
+ <button 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}>
159
301
  <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>
160
302
  </button>
161
303
  {showEmoji && (
@@ -181,24 +323,28 @@ export const MessageComposer = ({
181
323
  {showAttachButton && (
182
324
  <>
183
325
  <input ref={fileInputRef} type="file" multiple accept={acceptedFileTypes} onChange={handleFileChange} className="hidden" />
184
- <button 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="Anexar">
326
+ <button 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}>
185
327
  <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>
186
328
  </button>
187
329
  </>
188
330
  )}
189
331
 
190
- <button
191
- onClick={sendMessage}
192
- disabled={!canSend || disabled}
193
- className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
194
- canSend && !disabled
195
- ? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
196
- : 'bg-gray-200 text-gray-400 cursor-not-allowed'
197
- }`}
198
- aria-label="Enviar"
199
- >
200
- <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>
201
- </button>
332
+ {!canSend && effectiveIdleAction ? (
333
+ <div className="flex-shrink-0">{effectiveIdleAction}</div>
334
+ ) : (
335
+ <button
336
+ onClick={sendMessage}
337
+ disabled={!canSend || disabled}
338
+ className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
339
+ canSend && !disabled
340
+ ? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
341
+ : 'bg-gray-200 text-gray-400 cursor-not-allowed'
342
+ }`}
343
+ aria-label={sendLabel}
344
+ >
345
+ <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>
346
+ </button>
347
+ )}
202
348
  </div>
203
349
 
204
350
  {remaining !== null && (