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

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 (99) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-OIDAIVCH.js +1985 -0
  3. package/dist/flows/index.d.ts +14 -2
  4. package/dist/flows/index.js +117 -39
  5. package/dist/index.d.ts +729 -136
  6. package/dist/index.js +1418 -1104
  7. package/dist/preview/index.d.ts +373 -0
  8. package/dist/preview/index.js +1247 -0
  9. package/dist/styles.css +198 -0
  10. package/dist/types-O7kMP1Yn.d.ts +416 -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/Avatar.tsx +30 -4
  15. package/src/ChannelIcon.tsx +87 -0
  16. package/src/ConversationContextPanel.tsx +127 -0
  17. package/src/ConversationDocumentsPanel.tsx +425 -0
  18. package/src/ConversationHeader.tsx +257 -0
  19. package/src/ConversationListItem.tsx +54 -7
  20. package/src/ConversationLocalesProvider.tsx +16 -0
  21. package/src/ConversationRow.tsx +137 -0
  22. package/src/DateDivider.tsx +16 -3
  23. package/src/DocumentsLibrary.tsx +322 -0
  24. package/src/EmojiPicker.tsx +69 -55
  25. package/src/FileIcon.test.ts +83 -0
  26. package/src/FileIcon.tsx +88 -11
  27. package/src/InteractiveMessage.test.tsx +41 -0
  28. package/src/InteractiveMessage.tsx +143 -0
  29. package/src/Lightbox.tsx +18 -3
  30. package/src/MediaRenderer.tsx +64 -21
  31. package/src/MessageBubble.tsx +54 -5
  32. package/src/MessageComposer.test.tsx +35 -0
  33. package/src/MessageComposer.tsx +146 -19
  34. package/src/RichMessageComposer.test.tsx +83 -0
  35. package/src/RichMessageComposer.tsx +380 -0
  36. package/src/Wallpaper.test.tsx +21 -0
  37. package/src/Wallpaper.tsx +54 -6
  38. package/src/WhatsAppMessageEditor.tsx +28 -4
  39. package/src/WindowExpiredNotice.tsx +57 -0
  40. package/src/audioRecorderFormat.test.ts +67 -0
  41. package/src/conversationChannel.test.ts +53 -0
  42. package/src/conversationChannel.ts +146 -0
  43. package/src/conversationTranscript.test.ts +65 -0
  44. package/src/conversationTranscript.ts +64 -0
  45. package/src/conversationWindow.test.ts +90 -0
  46. package/src/conversationWindow.ts +78 -0
  47. package/src/documentTypeLabel.test.ts +57 -0
  48. package/src/emojiCatalog.test.ts +35 -0
  49. package/src/emojiCatalog.ts +189 -0
  50. package/src/flows/FlowGroupHeader.tsx +12 -2
  51. package/src/flows/FlowMapCanvas.tsx +17 -14
  52. package/src/flows/FlowMapNode.tsx +3 -1
  53. package/src/flows/FlowNodeCard.tsx +22 -4
  54. package/src/flows/FlowNodePanel.tsx +132 -35
  55. package/src/flows/FlowPalette.tsx +6 -2
  56. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  57. package/src/flows/flowGraph.ts +5 -5
  58. package/src/flows/labels.ts +5 -0
  59. package/src/hooks/useConversationActions.ts +56 -0
  60. package/src/hooks/useConversationDocuments.ts +15 -9
  61. package/src/hooks/useConversationList.ts +15 -9
  62. package/src/hooks/useConversationMessages.ts +2 -2
  63. package/src/index.ts +129 -16
  64. package/src/lib/cn.test.ts +29 -0
  65. package/src/lib/cn.ts +15 -0
  66. package/src/lib/createMediaUrlResolver.ts +33 -0
  67. package/src/lib/paginated.test.ts +33 -0
  68. package/src/lib/paginated.ts +26 -0
  69. package/src/lib/phone.ts +34 -0
  70. package/src/preview/ConversationPreview.tsx +325 -0
  71. package/src/preview/MediaTypesPreview.tsx +87 -0
  72. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  73. package/src/preview/createMockConversationsApi.ts +271 -0
  74. package/src/preview/createMockSSEProvider.ts +40 -0
  75. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  76. package/src/preview/createPreviewBridgeClient.ts +88 -0
  77. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  78. package/src/preview/createPreviewWebhookClient.ts +131 -0
  79. package/src/preview/index.ts +61 -0
  80. package/src/preview/mediaTypeOf.test.ts +15 -0
  81. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  82. package/src/preview/mockEventSource.ts +53 -0
  83. package/src/preview/preview.test.ts +177 -0
  84. package/src/preview/previewFileSamples.test.ts +151 -0
  85. package/src/preview/previewFileSamples.ts +74 -0
  86. package/src/preview/previewFixtures.ts +440 -0
  87. package/src/preview/previewMediaSource.test.ts +62 -0
  88. package/src/preview/previewMediaSource.ts +91 -0
  89. package/src/preview/previewStore.ts +193 -0
  90. package/src/preview/startPreviewScript.ts +60 -0
  91. package/src/providers/types.ts +163 -11
  92. package/src/quickReply.test.ts +58 -0
  93. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  94. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  95. package/src/styles.css +136 -0
  96. package/src/types.ts +46 -1
  97. package/src/useDarkMode.ts +26 -0
  98. package/src/useIsNarrow.ts +29 -0
  99. package/src/useWaitingNotifications.ts +74 -29
@@ -1,10 +1,30 @@
1
- import { useState } from 'react'
1
+ import { useState, type ReactNode } from 'react'
2
2
  import { AudioPlayer } from './AudioPlayer'
3
3
  import { FileIcon } from './FileIcon'
4
4
  import { useConversationLocales } from './ConversationLocalesProvider'
5
5
  import { formatFileSize } from './lib/format'
6
+ import { cn } from './lib/cn'
6
7
  import type { MessagePayload } from './types'
7
8
 
9
+ /**
10
+ * Rótulo curto do tipo, para a linha de baixo da bolha de documento.
11
+ *
12
+ * O subtipo cru do Office é gigantesco — `vnd.openxmlformats-officedocument.wordprocessingml.document`
13
+ * vira uma linha de 58 caracteres em caixa alta que estica a bolha, empurra a coluna da conversa e
14
+ * quebra o layout de três painéis. A extensão do arquivo diz a mesma coisa em quatro letras.
15
+ */
16
+ export function documentTypeLabel(filename?: string, mimeType?: string): string {
17
+ const extension = filename?.split('.').pop()
18
+ if (extension && extension.length <= 5 && extension !== filename) return extension.toUpperCase()
19
+
20
+ const subtype = mimeType?.split(';')[0]?.split('/')[1]
21
+ if (!subtype) return 'FILE'
22
+ // Sem extensão e com subtipo longo (áudio/vídeo sem nome), fica o sufixo depois do último ponto:
23
+ // `…wordprocessingml.document` -> `DOCUMENT`, que ainda informa e não estoura.
24
+ const compacto = subtype.split('.').pop() ?? subtype
25
+ return compacto.slice(0, 12).toUpperCase()
26
+ }
27
+
8
28
  function resolveMediaSource(message: MessagePayload): string | null {
9
29
  if (message.mediaUrl) return message.mediaUrl
10
30
  if (message.base64) {
@@ -30,6 +50,8 @@ export interface MediaRendererProps {
30
50
  // loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
31
51
  // porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
32
52
  onResolveUrl?: ResolveMediaUrl
53
+ /** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
54
+ className?: string
33
55
  }
34
56
 
35
57
  function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl) {
@@ -55,29 +77,48 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
55
77
  return { url, loading, error, load }
56
78
  }
57
79
 
58
- export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRendererProps) {
80
+ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }: MediaRendererProps) {
59
81
  const { bubble } = useConversationLocales()
60
82
  const eagerSrc = resolveMediaSource(message)
61
83
  const lazy = useLazyMediaUrl(message, onResolveUrl)
62
84
  const src = eagerSrc ?? lazy.url
63
85
  const canLazyLoad = !eagerSrc && hasLazyRef(message) && Boolean(onResolveUrl)
64
86
 
65
- const lazyButtonClass = 'text-xs text-blue-600 underline flex items-center gap-1'
87
+ /**
88
+ * O carregamento sob demanda continua sendo um botão, mas com a forma da mídia que ele vai virar
89
+ * — um link sublinhado no meio da conversa lê como texto da mensagem, não como controle, e é a
90
+ * única bolha que não se parece com o que contém.
91
+ */
92
+ function LazyMediaButton({ icon, label }: { icon: ReactNode; label: string }) {
93
+ return (
94
+ <button
95
+ onClick={lazy.load}
96
+ disabled={lazy.loading}
97
+ 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"
98
+ >
99
+ <span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-white">
100
+ {icon}
101
+ </span>
102
+ <span className="truncate text-xs text-gray-600 dark:text-gray-300">{label}</span>
103
+ </button>
104
+ )
105
+ }
66
106
 
67
107
  switch (message.type) {
68
108
  case 'image':
69
109
  case 'sticker': {
70
110
  if (!src && canLazyLoad) {
71
111
  return (
72
- <button onClick={lazy.load} className={lazyButtonClass}>
73
- {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro tentar novamente' : bubble.viewImage}
74
- </button>
112
+ <LazyMediaButton
113
+ 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>}
114
+ label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage}
115
+ />
75
116
  )
76
117
  }
77
118
  return (
78
119
  <div className="min-w-[200px]">
79
120
  {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" />
121
+ <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
122
  ) : (
82
123
  <div className="w-full h-40 bg-gray-200 flex items-center justify-center text-gray-400">
83
124
  <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 +130,10 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
89
130
  case 'video': {
90
131
  if (!src && canLazyLoad) {
91
132
  return (
92
- <button onClick={lazy.load} className={lazyButtonClass}>
93
- {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro tentar novamente' : bubble.viewVideo}
94
- </button>
133
+ <LazyMediaButton
134
+ 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>}
135
+ label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo}
136
+ />
95
137
  )
96
138
  }
97
139
  return (
@@ -109,33 +151,34 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
109
151
  case 'audio': {
110
152
  if (!src && canLazyLoad) {
111
153
  return (
112
- <button onClick={lazy.load} className={lazyButtonClass}>
113
- {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro tentar novamente' : bubble.listenAudio}
114
- </button>
154
+ <LazyMediaButton
155
+ 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>}
156
+ label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
157
+ />
115
158
  )
116
159
  }
117
160
  return (
118
161
  <div className="min-w-[200px]">
119
162
  {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>
163
+ <div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">{bubble.mediaUnavailable}</div>
121
164
  )}
122
165
  </div>
123
166
  )
124
167
  }
125
168
  case 'document': {
126
- const typeLabel = message.mimeType?.split('/')[1]?.toUpperCase() ?? 'FILE'
169
+ const typeLabel = documentTypeLabel(message.filename, message.mimeType)
127
170
  const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
128
171
  return (
129
- <div className="flex items-center gap-3 min-w-[200px]">
172
+ <div className={cn('flex items-center gap-3 min-w-[200px]', className)}>
130
173
  <div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0">
131
174
  <FileIcon filename={message.filename} mimeType={message.mimeType} />
132
175
  </div>
133
176
  <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>
177
+ <p className="text-sm font-medium truncate">{message.filename ?? bubble.untitledDocument}</p>
178
+ <p className="truncate text-xs text-gray-500">{sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel}</p>
136
179
  </div>
137
180
  {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">
181
+ <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
182
  <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
183
  </a>
141
184
  ) : canLazyLoad ? (
@@ -143,12 +186,12 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
143
186
  onClick={lazy.load}
144
187
  disabled={lazy.loading}
145
188
  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"
189
+ aria-label={bubble.downloadFile}
147
190
  >
148
191
  <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
192
  </button>
150
193
  ) : null}
151
- {lazy.error && <span className="text-xs text-red-500 flex-shrink-0">Erro</span>}
194
+ {lazy.error && <span className="text-xs text-red-500 flex-shrink-0">{bubble.mediaError}</span>}
152
195
  </div>
153
196
  )
154
197
  }
@@ -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 } 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,17 @@ 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
+ className?: string
20
34
  }
21
35
 
22
36
  // Hex arbitrários (não os tokens `whatsapp.*` do Tailwind) — o pacote fica autocontido,
@@ -37,15 +51,26 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
37
51
  // tailwind.config do host expondo as cores `whatsapp.*` — ver Wallpaper.tsx e T6.2.
38
52
  export function MessageBubble({
39
53
  message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
40
- onResolveMediaUrl,
54
+ onResolveMediaUrl, onInteractiveSelect, className,
41
55
  }: MessageBubbleProps) {
42
56
  const { bubble, selection } = useConversationLocales()
43
57
  const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
58
+ const context = useConversations()
59
+
60
+ // Resolvedor pelo contexto quando o host não passa um: o contrato já declara `getDocumentUrl` e
61
+ // `getMediaProxyUrl`, então exigir que cada projeto ligasse esse fio só garantia que a mídia não
62
+ // carregasse em quem esquecesse. `useMemo` porque o resolvedor é a identidade de que o
63
+ // `MediaRenderer` depende para não refazer busca a cada render.
64
+ const resolveMediaUrl = useMemo(
65
+ () => onResolveMediaUrl ?? (context?.api ? createMediaUrlResolver(context.api) : undefined),
66
+ [onResolveMediaUrl, context?.api],
67
+ )
44
68
 
45
69
  const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
46
70
  const hasError = message.status === 'failed'
47
71
  const isMedia = MEDIA_TYPES.has(message.type)
48
72
  const isTemplate = message.type === 'template'
73
+ const isInteractive = message.type === 'interactive'
49
74
  const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
50
75
 
51
76
  const tooltipText = message.status === 'read' && message.readAt
@@ -73,7 +98,14 @@ export function MessageBubble({
73
98
  )
74
99
 
75
100
  return (
76
- <div className={`flex items-end gap-1.5 ${isMine ? 'justify-end' : 'justify-start'} group ${isFirstInGroup ? 'mt-2' : 'mt-0.5'}`}>
101
+ <div
102
+ className={cn(
103
+ 'flex items-end gap-1.5 group',
104
+ isMine ? 'justify-end' : 'justify-start',
105
+ isFirstInGroup ? 'mt-2' : 'mt-0.5',
106
+ className,
107
+ )}
108
+ >
77
109
  {isMine && checkbox}
78
110
  <div
79
111
  onClick={isSelecting ? onToggleSelect : undefined}
@@ -92,8 +124,25 @@ export function MessageBubble({
92
124
  </div>
93
125
  )}
94
126
 
127
+ {/* Sinaliza, não censura nem esconde: cliente xingando é cliente irritado, e o atendente
128
+ precisa ler o que foi dito para responder. A etiqueta serve para achar o caso na thread
129
+ e para justificar escalar. */}
130
+ {message.moderation?.isOffensive && (
131
+ <div
132
+ 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}
134
+ >
135
+ <span aria-hidden>⚠️</span>
136
+ <span>{bubble.moderationFlagged}</span>
137
+ </div>
138
+ )}
139
+
95
140
  {isMedia ? (
96
- <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={onResolveMediaUrl} />
141
+ <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={resolveMediaUrl} />
142
+ ) : isInteractive && message.payload ? (
143
+ // O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
144
+ // só uma cópia achatada para busca — renderizar `content` aqui duplicaria o corpo.
145
+ <InteractiveMessage payload={message.payload} onSelect={onInteractiveSelect} />
97
146
  ) : (
98
147
  <>
99
148
  {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,56 @@
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
2
  import type { ConversationsFeatures } from './types'
3
+ import { cn } from './lib/cn'
3
4
  import { EmojiPicker } from './EmojiPicker'
4
5
 
6
+ /**
7
+ * Mensagem pronta que o atendente cola no campo com um clique.
8
+ *
9
+ * `text` aceita string com `{{variavel}}` ou função: a string cobre o caso comum (copy fixa com o
10
+ * nome do cliente no meio) sem o host escrever código, e a função cobre o que precisa de lógica —
11
+ * escolher texto por produto, pluralizar, formatar moeda.
12
+ */
13
+ export interface QuickReply {
14
+ key: string
15
+ /** O que aparece no chip, emoji incluso: `👋 Saudação`. */
16
+ label: string
17
+ text: string | ((variables: Readonly<Record<string, string>>) => string)
18
+ }
19
+
20
+ /**
21
+ * Troca `{{nome}}` pelos valores passados. Variável ausente vira string vazia, e não o literal
22
+ * `{{nome}}`: mandar "Olá {{nome}}!" para o cliente é pior que mandar "Olá !".
23
+ */
24
+ export function applyQuickReplyVariables(
25
+ template: string,
26
+ variables: Readonly<Record<string, string>> = {},
27
+ ): string {
28
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, chave: string) => variables[chave] ?? '')
29
+ }
30
+
31
+ export function resolveQuickReply(
32
+ quickReply: QuickReply,
33
+ variables: Readonly<Record<string, string>> = {},
34
+ ): string {
35
+ return typeof quickReply.text === 'function'
36
+ ? quickReply.text(variables)
37
+ : applyQuickReplyVariables(quickReply.text, variables)
38
+ }
39
+
40
+ export interface MessageComposerLabels {
41
+ emoji: string
42
+ attach: string
43
+ send: string
44
+ }
45
+
46
+ export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
47
+ emoji: 'Emoji',
48
+ attach: 'Anexar',
49
+ send: 'Enviar',
50
+ }
51
+
5
52
  export interface MessageComposerProps {
53
+ labels?: Partial<MessageComposerLabels>
6
54
  onSend: (text: string) => void
7
55
  onAttach?: (file: File) => void
8
56
  value?: string
@@ -12,10 +60,42 @@ export interface MessageComposerProps {
12
60
  maxLength?: number
13
61
  disabled?: boolean
14
62
  acceptedFileTypes?: string
63
+ /**
64
+ * Ocupa o lugar do botão de enviar enquanto não há nada para enviar — é onde o WhatsApp põe o
65
+ * microfone. Fora do campo, o botão vira um bloco solto ao lado do pill e quebra a barra.
66
+ */
67
+ idleAction?: ReactNode
68
+ /** Mensagens prontas exibidas acima do campo. Vazio ou ausente, a faixa não é renderizada. */
69
+ quickReplies?: readonly QuickReply[]
70
+ /** Valores para `{{variavel}}` — tipicamente nome do cliente, produto, protocolo. */
71
+ quickReplyVariables?: Readonly<Record<string, string>>
72
+ className?: string
73
+ classNames?: Partial<MessageComposerClassNames>
15
74
  }
16
75
 
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'
76
+ export interface MessageComposerClassNames {
77
+ root: string
78
+ quickReplies: string
79
+ quickReply: string
80
+ field: string
81
+ }
82
+
83
+ /**
84
+ * Exatamente o que a Meta aceita em mensagem de mídia — imagem, sticker, áudio, vídeo e a lista
85
+ * fechada de documentos. Oferecer no seletor um formato que o WhatsApp recusa (`.zip`, `.rtf`)
86
+ * empurra a falha para depois do envio, quando já não dá para explicar ao operador o que houve.
87
+ * Produto com regra própria passa `acceptedFileTypes`.
88
+ */
89
+ export const DEFAULT_ACCEPTED_FILE_TYPES = [
90
+ 'image/jpeg,image/png,image/webp',
91
+ 'audio/aac,audio/mp4,audio/mpeg,audio/amr,audio/ogg',
92
+ 'video/mp4,video/3gpp',
93
+ 'application/pdf,text/plain,text/csv',
94
+ 'application/msword,application/vnd.ms-excel,application/vnd.ms-powerpoint',
95
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
96
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
97
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
98
+ ].join(',')
19
99
 
20
100
  interface FilePreview {
21
101
  file: File
@@ -27,12 +107,21 @@ export const MessageComposer = ({
27
107
  onAttach,
28
108
  value: externalValue,
29
109
  onChange: externalOnChange,
110
+ quickReplies,
111
+ quickReplyVariables,
30
112
  features,
31
113
  placeholder = 'Digite uma mensagem...',
32
114
  maxLength,
33
115
  disabled = false,
34
116
  acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
117
+ idleAction,
118
+ className,
119
+ classNames,
120
+ labels,
35
121
  }: MessageComposerProps) => {
122
+ const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
123
+ const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
124
+ const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
36
125
  const [internalText, setInternalText] = useState('')
37
126
  const [showEmoji, setShowEmoji] = useState(false)
38
127
  const [attachments, setAttachments] = useState<FilePreview[]>([])
@@ -134,7 +223,41 @@ export const MessageComposer = ({
134
223
  const remaining = maxLength ? maxLength - text.length : null
135
224
 
136
225
  return (
137
- <div>
226
+ /* A barra é a superfície (cinza, largura cheia, sem raio) e o campo dentro é que arredonda —
227
+ ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
228
+ descobriam o fundo branco da página, que lia como defeito. */
229
+ <div className={cn('bg-[#f0f2f5] px-2 py-2', className)}>
230
+ {/* Uma linha com scroll no celular e wrap no desktop: em 375px seis chips em wrap empurrariam
231
+ o campo para fora da tela, e o campo é a razão de a barra existir. */}
232
+ {quickReplies && quickReplies.length > 0 && (
233
+ <div
234
+ className={cn(
235
+ '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',
236
+ classNames?.quickReplies,
237
+ )}
238
+ >
239
+ {quickReplies.map((quickReply) => (
240
+ <button
241
+ key={quickReply.key}
242
+ type="button"
243
+ // Preenche o campo em vez de enviar: mensagem pronta é ponto de partida, e quem
244
+ // atende quase sempre ajusta uma palavra antes de mandar. Enviar direto no clique
245
+ // transformaria um toque errado em mensagem entregue ao cliente.
246
+ onClick={() => {
247
+ setText(resolveQuickReply(quickReply, quickReplyVariables))
248
+ textareaRef.current?.focus()
249
+ }}
250
+ className={cn(
251
+ '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',
252
+ classNames?.quickReply,
253
+ )}
254
+ >
255
+ {quickReply.label}
256
+ </button>
257
+ ))}
258
+ </div>
259
+ )}
260
+
138
261
  {attachments.length > 0 && (
139
262
  <div className="flex gap-2 px-1 pb-2 overflow-x-auto">
140
263
  {attachments.map((a, i) => (
@@ -152,10 +275,10 @@ export const MessageComposer = ({
152
275
  </div>
153
276
  )}
154
277
 
155
- <div className="flex items-end gap-1.5 bg-[#f0f2f5] rounded-xl px-3 py-2">
278
+ <div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
156
279
  {showEmojiButton && (
157
280
  <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">
281
+ <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
282
  <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
283
  </button>
161
284
  {showEmoji && (
@@ -181,24 +304,28 @@ export const MessageComposer = ({
181
304
  {showAttachButton && (
182
305
  <>
183
306
  <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">
307
+ <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
308
  <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
309
  </button>
187
310
  </>
188
311
  )}
189
312
 
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>
313
+ {!canSend && idleAction ? (
314
+ <div className="flex-shrink-0">{idleAction}</div>
315
+ ) : (
316
+ <button
317
+ onClick={sendMessage}
318
+ disabled={!canSend || disabled}
319
+ className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
320
+ canSend && !disabled
321
+ ? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
322
+ : 'bg-gray-200 text-gray-400 cursor-not-allowed'
323
+ }`}
324
+ aria-label={sendLabel}
325
+ >
326
+ <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>
327
+ </button>
328
+ )}
202
329
  </div>
203
330
 
204
331
  {remaining !== null && (
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { RichMessageComposer, type RichComposerQuickReply, type RichComposerVariable } from './RichMessageComposer'
5
+
6
+ const noop = () => {}
7
+
8
+ const QUICK_REPLIES: RichComposerQuickReply[] = [
9
+ { id: 'greeting', label: 'Saudação', text: 'Olá!', tooltip: 'Abre o atendimento' },
10
+ ]
11
+
12
+ const VARIABLES: RichComposerVariable[] = [
13
+ { id: 'name', label: 'Nome do cliente', value: '{{nome}}' },
14
+ ]
15
+
16
+ describe('RichMessageComposer', () => {
17
+ it('mostra as dicas padrão em todas as ações da barra', () => {
18
+ const markup = renderToStaticMarkup(
19
+ <RichMessageComposer value="" onChange={noop} onSend={noop} onAttachFiles={noop} />,
20
+ )
21
+
22
+ for (const tooltip of ['Negrito', 'Itálico', 'Tachado', 'Monoespaçado', 'Anexar arquivo', 'Enviar']) {
23
+ expect(markup).toContain(`title="${tooltip}"`)
24
+ }
25
+ })
26
+
27
+ it('deixa o host trocar o texto de uma dica sem mexer nas outras', () => {
28
+ const markup = renderToStaticMarkup(
29
+ <RichMessageComposer value="" onChange={noop} onSend={noop} tooltips={{ bold: 'Deixar em negrito' }} />,
30
+ )
31
+
32
+ expect(markup).toContain('title="Deixar em negrito"')
33
+ expect(markup).not.toContain('title="Negrito"')
34
+ expect(markup).toContain('title="Itálico"')
35
+ })
36
+
37
+ it('esconde só a ação que o host desligou', () => {
38
+ const markup = renderToStaticMarkup(
39
+ <RichMessageComposer value="" onChange={noop} onSend={noop} toolbar={{ monospace: false }} />,
40
+ )
41
+
42
+ expect(markup).not.toContain('title="Monoespaçado"')
43
+ expect(markup).toContain('title="Negrito"')
44
+ })
45
+
46
+ it('não desenha o anexo quando não há como anexar', () => {
47
+ const markup = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
48
+
49
+ expect(markup).not.toContain('title="Anexar arquivo"')
50
+ })
51
+
52
+ it('desenha os chips de resposta rápida com o rótulo e a dica do host', () => {
53
+ const markup = renderToStaticMarkup(
54
+ <RichMessageComposer value="" onChange={noop} onSend={noop} quickReplies={QUICK_REPLIES} />,
55
+ )
56
+
57
+ expect(markup).toContain('Saudação')
58
+ expect(markup).toContain('title="Abre o atendimento"')
59
+ })
60
+
61
+ it('só oferece variáveis quando o host manda alguma', () => {
62
+ const semVariaveis = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
63
+ const comVariaveis = renderToStaticMarkup(
64
+ <RichMessageComposer value="" onChange={noop} onSend={noop} variables={VARIABLES} />,
65
+ )
66
+
67
+ expect(semVariaveis).not.toContain('title="Variáveis disponíveis"')
68
+ expect(comVariaveis).toContain('title="Variáveis disponíveis"')
69
+ })
70
+
71
+ it('cede o lugar do enviar à ação ociosa enquanto não há texto', () => {
72
+ const vazio = renderToStaticMarkup(
73
+ <RichMessageComposer value="" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
74
+ )
75
+ const comTexto = renderToStaticMarkup(
76
+ <RichMessageComposer value="oi" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
77
+ )
78
+
79
+ expect(vazio).toContain('aria-label="Gravar áudio"')
80
+ expect(vazio).not.toContain('title="Enviar"')
81
+ expect(comTexto).toContain('title="Enviar"')
82
+ })
83
+ })