@adatechnology/conversations-ui 0.1.0-rc.0 → 0.1.0-rc.10

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 (83) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-FWNAWAJP.js +1743 -0
  3. package/dist/flows/index.js +3 -3
  4. package/dist/index.d.ts +619 -136
  5. package/dist/index.js +1064 -1024
  6. package/dist/preview/index.d.ts +330 -0
  7. package/dist/preview/index.js +1281 -0
  8. package/dist/styles.css +198 -0
  9. package/dist/types-B5C1DLu1.d.ts +365 -0
  10. package/package.json +10 -3
  11. package/src/Avatar.tsx +30 -4
  12. package/src/ChannelIcon.tsx +87 -0
  13. package/src/ConversationContextPanel.tsx +106 -0
  14. package/src/ConversationDocumentsPanel.tsx +425 -0
  15. package/src/ConversationHeader.tsx +257 -0
  16. package/src/ConversationListItem.tsx +54 -7
  17. package/src/ConversationLocalesProvider.tsx +16 -0
  18. package/src/ConversationRow.tsx +137 -0
  19. package/src/DateDivider.tsx +16 -3
  20. package/src/DocumentsLibrary.tsx +322 -0
  21. package/src/EmojiPicker.tsx +69 -55
  22. package/src/FileIcon.test.ts +83 -0
  23. package/src/FileIcon.tsx +88 -11
  24. package/src/InteractiveMessage.test.tsx +41 -0
  25. package/src/InteractiveMessage.tsx +143 -0
  26. package/src/Lightbox.tsx +18 -3
  27. package/src/MediaRenderer.tsx +14 -11
  28. package/src/MessageBubble.tsx +54 -5
  29. package/src/MessageComposer.test.tsx +35 -0
  30. package/src/MessageComposer.tsx +73 -19
  31. package/src/Wallpaper.test.tsx +21 -0
  32. package/src/Wallpaper.tsx +54 -6
  33. package/src/WhatsAppMessageEditor.tsx +28 -4
  34. package/src/WindowExpiredNotice.tsx +57 -0
  35. package/src/conversationChannel.test.ts +53 -0
  36. package/src/conversationChannel.ts +146 -0
  37. package/src/conversationTranscript.test.ts +65 -0
  38. package/src/conversationTranscript.ts +64 -0
  39. package/src/conversationWindow.test.ts +90 -0
  40. package/src/conversationWindow.ts +78 -0
  41. package/src/emojiCatalog.test.ts +35 -0
  42. package/src/emojiCatalog.ts +189 -0
  43. package/src/flows/FlowMapCanvas.tsx +2 -2
  44. package/src/hooks/useConversationActions.ts +56 -0
  45. package/src/hooks/useConversationDocuments.ts +15 -9
  46. package/src/hooks/useConversationList.ts +15 -9
  47. package/src/hooks/useConversationMessages.ts +2 -2
  48. package/src/index.ts +112 -16
  49. package/src/lib/cn.test.ts +29 -0
  50. package/src/lib/cn.ts +15 -0
  51. package/src/lib/createMediaUrlResolver.ts +33 -0
  52. package/src/lib/paginated.test.ts +33 -0
  53. package/src/lib/paginated.ts +26 -0
  54. package/src/lib/phone.ts +34 -0
  55. package/src/preview/AudioRecorderButton.tsx +125 -0
  56. package/src/preview/ConversationPreview.tsx +315 -0
  57. package/src/preview/MediaTypesPreview.tsx +87 -0
  58. package/src/preview/audioRecorderFormat.test.ts +67 -0
  59. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  60. package/src/preview/createMockConversationsApi.ts +271 -0
  61. package/src/preview/createMockSSEProvider.ts +40 -0
  62. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  63. package/src/preview/createPreviewWebhookClient.ts +126 -0
  64. package/src/preview/index.ts +54 -0
  65. package/src/preview/mediaTypeOf.test.ts +15 -0
  66. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  67. package/src/preview/mockEventSource.ts +53 -0
  68. package/src/preview/preview.test.ts +177 -0
  69. package/src/preview/previewFileSamples.test.ts +151 -0
  70. package/src/preview/previewFileSamples.ts +74 -0
  71. package/src/preview/previewFixtures.ts +440 -0
  72. package/src/preview/previewMediaSource.test.ts +62 -0
  73. package/src/preview/previewMediaSource.ts +91 -0
  74. package/src/preview/previewStore.ts +193 -0
  75. package/src/preview/startPreviewScript.ts +60 -0
  76. package/src/providers/types.ts +163 -11
  77. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  78. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  79. package/src/styles.css +136 -0
  80. package/src/types.ts +46 -1
  81. package/src/useDarkMode.ts +26 -0
  82. package/src/useIsNarrow.ts +29 -0
  83. package/src/useWaitingNotifications.ts +74 -29
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { InteractiveMessage } from './InteractiveMessage'
5
+ import type { InteractivePayload } from './types'
6
+
7
+ function render(payload: InteractivePayload): string {
8
+ return renderToStaticMarkup(<InteractiveMessage payload={payload} />)
9
+ }
10
+
11
+ describe('InteractiveMessage', () => {
12
+ it('formata negrito no corpo, como o aparelho faz', () => {
13
+ const markup = render({ type: 'button', body: { text: 'Qual o *prazo desejado*?' } })
14
+
15
+ expect(markup).toContain('<strong>prazo desejado</strong>')
16
+ expect(markup).not.toContain('*prazo desejado*')
17
+ })
18
+
19
+ it('formata cabeçalho e rodapé também', () => {
20
+ const markup = render({
21
+ type: 'list',
22
+ header: { text: '*Menu*' },
23
+ body: { text: 'corpo' },
24
+ footer: { text: '_Selecione_' },
25
+ })
26
+
27
+ expect(markup).toContain('<strong>Menu</strong>')
28
+ expect(markup).toContain('<em>Selecione</em>')
29
+ })
30
+
31
+ it('deixa título de opção literal, porque o WhatsApp não formata ali', () => {
32
+ const markup = render({
33
+ type: 'button',
34
+ body: { text: 'corpo' },
35
+ action: { buttons: [{ reply: { id: 'sim', title: '*Sim*' } }] },
36
+ })
37
+
38
+ expect(markup).toContain('*Sim*')
39
+ expect(markup).not.toContain('<strong>Sim</strong>')
40
+ })
41
+ })
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Menu interativo do WhatsApp — botões de resposta rápida e lista de opções.
3
+ *
4
+ * A mensagem é gravada com o payload cru que foi enviado à Meta, então a UI apenas o desenha; não
5
+ * há segunda fonte de verdade sobre quais opções existiam naquele momento. Sem isto, mensagem
6
+ * `interactive` aparecia como texto solto e o menu simplesmente sumia da conversa — inclusive no
7
+ * simulador, onde o operador precisa ver exatamente o que o cliente vê.
8
+ *
9
+ * `onSelect` é opcional de propósito: no histórico da inbox as opções são leitura (o operador não
10
+ * responde no lugar do cliente); no simulador elas são clicáveis.
11
+ */
12
+
13
+ import { useState } from 'react'
14
+ import type { InteractiveOption, InteractivePayload, InteractiveSelection } from './types'
15
+ import { cn } from './lib/cn'
16
+ import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
17
+
18
+ /** `parseWhatsAppFormatting` emite `<strong>`/`<em>`/`<del>`, que o reset do Tailwind zera. */
19
+ const FORMATTING_CLASSES = '[&_strong]:font-bold [&_em]:italic [&_del]:line-through'
20
+
21
+ export interface InteractiveMessageLabels {
22
+ /** Fallback do rótulo do botão que abre a lista, quando o payload não traz um. */
23
+ openList: string
24
+ }
25
+
26
+ export const DEFAULT_INTERACTIVE_MESSAGE_LABELS: InteractiveMessageLabels = {
27
+ openList: 'Ver opções',
28
+ }
29
+
30
+ export interface InteractiveMessageProps {
31
+ payload: InteractivePayload
32
+ onSelect?: (selection: InteractiveSelection) => void
33
+ labels?: Partial<InteractiveMessageLabels>
34
+ className?: string
35
+ }
36
+
37
+ function collectRows(payload: InteractivePayload): InteractiveOption[] {
38
+ return (payload.action?.sections ?? []).flatMap((section) => section.rows ?? [])
39
+ }
40
+
41
+ function collectButtons(payload: InteractivePayload): InteractiveOption[] {
42
+ return (payload.action?.buttons ?? [])
43
+ .map((button) => button.reply)
44
+ .filter((reply): reply is InteractiveOption => reply !== undefined)
45
+ }
46
+
47
+ export function InteractiveMessage({ payload, onSelect, labels, className }: InteractiveMessageProps) {
48
+ const openListLabel = labels?.openList ?? DEFAULT_INTERACTIVE_MESSAGE_LABELS.openList
49
+ const [isListOpen, setIsListOpen] = useState(false)
50
+
51
+ const buttons = collectButtons(payload)
52
+ const sections = payload.action?.sections ?? []
53
+ const hasRows = collectRows(payload).length > 0
54
+ const isInteractable = onSelect !== undefined
55
+
56
+ return (
57
+ <div className={cn('flex flex-col gap-1', className)}>
58
+ {/* Cabeçalho, corpo e rodapé passam pela formatação do WhatsApp — o aparelho renderiza
59
+ `*negrito*` neles como em qualquer texto. Título e descrição de opção NÃO: ali o WhatsApp
60
+ mostra os asteriscos literais, e formatá-los aqui deixaria o simulador mais bonito que o
61
+ aparelho, que é o tipo de divergência que só aparece com o cliente na frente. */}
62
+ {payload.header?.text ? (
63
+ <p className={cn('text-sm font-semibold text-gray-900 dark:text-gray-100', FORMATTING_CLASSES)}>
64
+ {parseWhatsAppFormatting(payload.header.text)}
65
+ </p>
66
+ ) : null}
67
+
68
+ {payload.body?.text ? (
69
+ <p
70
+ className={cn(
71
+ 'whitespace-pre-wrap break-words text-sm text-gray-900 dark:text-gray-100',
72
+ FORMATTING_CLASSES,
73
+ )}
74
+ >
75
+ {parseWhatsAppFormatting(payload.body.text)}
76
+ </p>
77
+ ) : null}
78
+
79
+ {payload.footer?.text ? (
80
+ <p className={cn('text-xs text-gray-500 dark:text-gray-400', FORMATTING_CLASSES)}>
81
+ {parseWhatsAppFormatting(payload.footer.text)}
82
+ </p>
83
+ ) : null}
84
+
85
+ {buttons.length > 0 ? (
86
+ <div className="mt-1 flex flex-col gap-1 border-t border-gray-200 pt-1 dark:border-gray-700">
87
+ {buttons.map((button) => (
88
+ <button
89
+ key={button.id}
90
+ type="button"
91
+ disabled={!isInteractable}
92
+ onClick={() => onSelect?.({ kind: 'button', option: button })}
93
+ className="rounded-md px-3 py-1.5 text-sm font-medium text-teal-700 transition-colors enabled:hover:bg-teal-50 disabled:cursor-default dark:text-teal-300 dark:enabled:hover:bg-teal-900/30"
94
+ >
95
+ {button.title}
96
+ </button>
97
+ ))}
98
+ </div>
99
+ ) : null}
100
+
101
+ {hasRows ? (
102
+ <div className="mt-1 border-t border-gray-200 pt-1 dark:border-gray-700">
103
+ <button
104
+ type="button"
105
+ onClick={() => setIsListOpen((open) => !open)}
106
+ aria-expanded={isListOpen}
107
+ className="w-full rounded-md px-3 py-1.5 text-sm font-medium text-teal-700 transition-colors hover:bg-teal-50 dark:text-teal-300 dark:hover:bg-teal-900/30"
108
+ >
109
+ ☰ {payload.action?.button ?? openListLabel}
110
+ </button>
111
+
112
+ {isListOpen ? (
113
+ <div className="mt-1 flex flex-col gap-1">
114
+ {sections.map((section, sectionIndex) => (
115
+ <div key={section.title ?? sectionIndex} className="flex flex-col">
116
+ {section.title ? (
117
+ <p className="px-3 py-1 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
118
+ {section.title}
119
+ </p>
120
+ ) : null}
121
+ {(section.rows ?? []).map((row) => (
122
+ <button
123
+ key={row.id}
124
+ type="button"
125
+ disabled={!isInteractable}
126
+ onClick={() => onSelect?.({ kind: 'list', option: row })}
127
+ className="rounded-md px-3 py-1.5 text-left text-sm text-gray-900 transition-colors enabled:hover:bg-gray-100 disabled:cursor-default dark:text-gray-100 dark:enabled:hover:bg-gray-700"
128
+ >
129
+ <span className="block">{row.title}</span>
130
+ {row.description ? (
131
+ <span className="block text-xs text-gray-500 dark:text-gray-400">{row.description}</span>
132
+ ) : null}
133
+ </button>
134
+ ))}
135
+ </div>
136
+ ))}
137
+ </div>
138
+ ) : null}
139
+ </div>
140
+ ) : null}
141
+ </div>
142
+ )
143
+ }
package/src/Lightbox.tsx CHANGED
@@ -1,16 +1,31 @@
1
+ export interface LightboxLabels {
2
+ /** Texto alternativo quando a imagem não tem legenda — sem ele o leitor de tela anuncia a URL. */
3
+ imageAlt: string
4
+ close: string
5
+ }
6
+
1
7
  export interface LightboxProps {
2
8
  imageUrl: string
3
9
  caption?: string
4
10
  onClose: () => void
11
+ labels?: Partial<LightboxLabels>
5
12
  }
6
13
 
7
- export function Lightbox({ imageUrl, caption, onClose }: LightboxProps) {
14
+ export const DEFAULT_LIGHTBOX_LABELS: LightboxLabels = {
15
+ imageAlt: 'Imagem',
16
+ close: 'Fechar',
17
+ }
18
+
19
+ export function Lightbox({ imageUrl, caption, onClose, labels }: LightboxProps) {
20
+ const imageAltLabel = labels?.imageAlt ?? DEFAULT_LIGHTBOX_LABELS.imageAlt
21
+ const closeLabel = labels?.close ?? DEFAULT_LIGHTBOX_LABELS.close
22
+
8
23
  return (
9
24
  <div className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4" onClick={onClose}>
10
25
  <div className="max-w-[90vw] max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
11
- <img src={imageUrl} alt={caption ?? 'Image'} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
26
+ <img src={imageUrl} alt={caption ?? imageAltLabel} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
12
27
  {caption && <p className="text-white text-sm mt-3 text-center">{caption}</p>}
13
- <button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">Fechar</button>
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>
14
29
  </div>
15
30
  </div>
16
31
  )
@@ -3,6 +3,7 @@ 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
 
8
9
  function resolveMediaSource(message: MessagePayload): string | null {
@@ -30,6 +31,8 @@ export interface MediaRendererProps {
30
31
  // loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
31
32
  // porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
32
33
  onResolveUrl?: ResolveMediaUrl
34
+ /** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
35
+ className?: string
33
36
  }
34
37
 
35
38
  function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl) {
@@ -55,7 +58,7 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
55
58
  return { url, loading, error, load }
56
59
  }
57
60
 
58
- export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRendererProps) {
61
+ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }: MediaRendererProps) {
59
62
  const { bubble } = useConversationLocales()
60
63
  const eagerSrc = resolveMediaSource(message)
61
64
  const lazy = useLazyMediaUrl(message, onResolveUrl)
@@ -70,14 +73,14 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
70
73
  if (!src && canLazyLoad) {
71
74
  return (
72
75
  <button onClick={lazy.load} className={lazyButtonClass}>
73
- {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro — tentar novamente' : bubble.viewImage}
76
+ {lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage}
74
77
  </button>
75
78
  )
76
79
  }
77
80
  return (
78
81
  <div className="min-w-[200px]">
79
82
  {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" />
83
+ <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
84
  ) : (
82
85
  <div className="w-full h-40 bg-gray-200 flex items-center justify-center text-gray-400">
83
86
  <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>
@@ -90,7 +93,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
90
93
  if (!src && canLazyLoad) {
91
94
  return (
92
95
  <button onClick={lazy.load} className={lazyButtonClass}>
93
- {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro — tentar novamente' : bubble.viewVideo}
96
+ {lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo}
94
97
  </button>
95
98
  )
96
99
  }
@@ -110,14 +113,14 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
110
113
  if (!src && canLazyLoad) {
111
114
  return (
112
115
  <button onClick={lazy.load} className={lazyButtonClass}>
113
- {lazy.loading ? 'Carregando...' : lazy.error ? 'Erro — tentar novamente' : bubble.listenAudio}
116
+ {lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
114
117
  </button>
115
118
  )
116
119
  }
117
120
  return (
118
121
  <div className="min-w-[200px]">
119
122
  {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>
123
+ <div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">{bubble.mediaUnavailable}</div>
121
124
  )}
122
125
  </div>
123
126
  )
@@ -126,16 +129,16 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
126
129
  const typeLabel = message.mimeType?.split('/')[1]?.toUpperCase() ?? 'FILE'
127
130
  const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
128
131
  return (
129
- <div className="flex items-center gap-3 min-w-[200px]">
132
+ <div className={cn('flex items-center gap-3 min-w-[200px]', className)}>
130
133
  <div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0">
131
134
  <FileIcon filename={message.filename} mimeType={message.mimeType} />
132
135
  </div>
133
136
  <div className="flex-1 min-w-0">
134
- <p className="text-sm font-medium truncate">{message.filename ?? 'Document'}</p>
137
+ <p className="text-sm font-medium truncate">{message.filename ?? bubble.untitledDocument}</p>
135
138
  <p className="text-xs text-gray-500">{sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel}</p>
136
139
  </div>
137
140
  {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">
141
+ <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
142
  <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
143
  </a>
141
144
  ) : canLazyLoad ? (
@@ -143,12 +146,12 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
143
146
  onClick={lazy.load}
144
147
  disabled={lazy.loading}
145
148
  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"
149
+ aria-label={bubble.downloadFile}
147
150
  >
148
151
  <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
152
  </button>
150
153
  ) : null}
151
- {lazy.error && <span className="text-xs text-red-500 flex-shrink-0">Erro</span>}
154
+ {lazy.error && <span className="text-xs text-red-500 flex-shrink-0">{bubble.mediaError}</span>}
152
155
  </div>
153
156
  )
154
157
  }
@@ -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,22 @@
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
+ export interface MessageComposerLabels {
7
+ emoji: string
8
+ attach: string
9
+ send: string
10
+ }
11
+
12
+ export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
13
+ emoji: 'Emoji',
14
+ attach: 'Anexar',
15
+ send: 'Enviar',
16
+ }
17
+
5
18
  export interface MessageComposerProps {
19
+ labels?: Partial<MessageComposerLabels>
6
20
  onSend: (text: string) => void
7
21
  onAttach?: (file: File) => void
8
22
  value?: string
@@ -12,10 +26,36 @@ export interface MessageComposerProps {
12
26
  maxLength?: number
13
27
  disabled?: boolean
14
28
  acceptedFileTypes?: string
29
+ /**
30
+ * Ocupa o lugar do botão de enviar enquanto não há nada para enviar — é onde o WhatsApp põe o
31
+ * microfone. Fora do campo, o botão vira um bloco solto ao lado do pill e quebra a barra.
32
+ */
33
+ idleAction?: ReactNode
34
+ className?: string
35
+ classNames?: Partial<MessageComposerClassNames>
15
36
  }
16
37
 
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'
38
+ export interface MessageComposerClassNames {
39
+ root: string
40
+ field: string
41
+ }
42
+
43
+ /**
44
+ * Exatamente o que a Meta aceita em mensagem de mídia — imagem, sticker, áudio, vídeo e a lista
45
+ * fechada de documentos. Oferecer no seletor um formato que o WhatsApp recusa (`.zip`, `.rtf`)
46
+ * empurra a falha para depois do envio, quando já não dá para explicar ao operador o que houve.
47
+ * Produto com regra própria passa `acceptedFileTypes`.
48
+ */
49
+ export const DEFAULT_ACCEPTED_FILE_TYPES = [
50
+ 'image/jpeg,image/png,image/webp',
51
+ 'audio/aac,audio/mp4,audio/mpeg,audio/amr,audio/ogg',
52
+ 'video/mp4,video/3gpp',
53
+ 'application/pdf,text/plain,text/csv',
54
+ 'application/msword,application/vnd.ms-excel,application/vnd.ms-powerpoint',
55
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
56
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
57
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
58
+ ].join(',')
19
59
 
20
60
  interface FilePreview {
21
61
  file: File
@@ -32,7 +72,14 @@ export const MessageComposer = ({
32
72
  maxLength,
33
73
  disabled = false,
34
74
  acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
75
+ idleAction,
76
+ className,
77
+ classNames,
78
+ labels,
35
79
  }: MessageComposerProps) => {
80
+ const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
81
+ const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
82
+ const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
36
83
  const [internalText, setInternalText] = useState('')
37
84
  const [showEmoji, setShowEmoji] = useState(false)
38
85
  const [attachments, setAttachments] = useState<FilePreview[]>([])
@@ -134,7 +181,10 @@ export const MessageComposer = ({
134
181
  const remaining = maxLength ? maxLength - text.length : null
135
182
 
136
183
  return (
137
- <div>
184
+ /* A barra é a superfície (cinza, largura cheia, sem raio) e o campo dentro é que arredonda —
185
+ ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
186
+ descobriam o fundo branco da página, que lia como defeito. */
187
+ <div className={cn('bg-[#f0f2f5] px-2 py-2', className)}>
138
188
  {attachments.length > 0 && (
139
189
  <div className="flex gap-2 px-1 pb-2 overflow-x-auto">
140
190
  {attachments.map((a, i) => (
@@ -152,10 +202,10 @@ export const MessageComposer = ({
152
202
  </div>
153
203
  )}
154
204
 
155
- <div className="flex items-end gap-1.5 bg-[#f0f2f5] rounded-xl px-3 py-2">
205
+ <div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
156
206
  {showEmojiButton && (
157
207
  <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">
208
+ <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
209
  <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
210
  </button>
161
211
  {showEmoji && (
@@ -181,24 +231,28 @@ export const MessageComposer = ({
181
231
  {showAttachButton && (
182
232
  <>
183
233
  <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">
234
+ <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
235
  <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
236
  </button>
187
237
  </>
188
238
  )}
189
239
 
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>
240
+ {!canSend && idleAction ? (
241
+ <div className="flex-shrink-0">{idleAction}</div>
242
+ ) : (
243
+ <button
244
+ onClick={sendMessage}
245
+ disabled={!canSend || disabled}
246
+ className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
247
+ canSend && !disabled
248
+ ? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
249
+ : 'bg-gray-200 text-gray-400 cursor-not-allowed'
250
+ }`}
251
+ aria-label={sendLabel}
252
+ >
253
+ <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>
254
+ </button>
255
+ )}
202
256
  </div>
203
257
 
204
258
  {remaining !== null && (
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { ConversationWallpaper } from './Wallpaper'
5
+
6
+ describe('ConversationWallpaper', () => {
7
+ it('desenha o fundo sozinho, sem depender do styles.css do host', () => {
8
+ const markup = renderToStaticMarkup(<ConversationWallpaper />)
9
+
10
+ expect(markup).toContain('background-color:#efeae2')
11
+ expect(markup).toContain('background-image:url(&quot;data:image/svg+xml,')
12
+ expect(markup).toContain('cv-wallpaper')
13
+ })
14
+
15
+ it('deixa o produto sobrescrever o fundo por style', () => {
16
+ const markup = renderToStaticMarkup(<ConversationWallpaper style={{ backgroundColor: '#123456' }} />)
17
+
18
+ expect(markup).toContain('background-color:#123456')
19
+ expect(markup).not.toContain('background-color:#efeae2')
20
+ })
21
+ })