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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Peças de listagem que toda tela tabular do pacote precisa ter iguais: cabeçalho ordenável,
3
+ * filtro de seleção múltipla, barra de ação em lote e paginação (regra `web.md` §7).
4
+ *
5
+ * Vivem aqui, e não dentro de cada tela, porque a alternativa já se provou pior: cada workspace
6
+ * recriava o seu, e o mesmo "limpar filtros" ficava em três lugares com três comportamentos.
7
+ */
8
+
9
+ import { useState, type ReactNode } from 'react'
10
+ import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, X } from 'lucide-react'
11
+
12
+ import { cn } from '../lib/cn'
13
+
14
+ export type SortDirection = 'asc' | 'desc'
15
+
16
+ export interface SortableHeadProps {
17
+ readonly label: string
18
+ readonly field: string
19
+ readonly activeField: string
20
+ readonly direction: SortDirection
21
+ readonly onSort: (field: string) => void
22
+ readonly className?: string
23
+ }
24
+
25
+ export function SortableHead({ label, field, activeField, direction, onSort, className }: SortableHeadProps) {
26
+ const isActive = activeField === field
27
+ const Icon = !isActive ? ArrowUpDown : direction === 'asc' ? ArrowUp : ArrowDown
28
+
29
+ return (
30
+ <th scope="col" className={cn('px-3 py-2 text-left text-xs font-medium', className)}>
31
+ <button
32
+ data-cv-tooltip={label}
33
+ type="button"
34
+ onClick={() => onSort(field)}
35
+ aria-label={label}
36
+ className="inline-flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-100"
37
+ >
38
+ {label}
39
+ <Icon size={12} className={isActive ? '' : 'opacity-40'} aria-hidden="true" />
40
+ </button>
41
+ </th>
42
+ )
43
+ }
44
+
45
+ export interface FilterOption {
46
+ readonly value: string
47
+ readonly label: string
48
+ }
49
+
50
+ export interface MultiSelectFilterProps {
51
+ readonly label: string
52
+ readonly options: readonly FilterOption[]
53
+ readonly selected: readonly string[]
54
+ readonly onChange: (selected: readonly string[]) => void
55
+ readonly className?: string
56
+ }
57
+
58
+ export function MultiSelectFilter({ label, options, selected, onChange, className }: MultiSelectFilterProps) {
59
+ const [open, setOpen] = useState(false)
60
+
61
+ function toggle(value: string): void {
62
+ onChange(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value])
63
+ }
64
+
65
+ return (
66
+ <div className={cn('relative', className)}>
67
+ <button
68
+ data-cv-tooltip={label} aria-label={label}
69
+ type="button"
70
+ onClick={() => setOpen((current) => !current)}
71
+ className="cv-header-action inline-flex items-center gap-1"
72
+ >
73
+ {label}
74
+ {selected.length > 0 ? <span className="cv-filter-count">{selected.length}</span> : null}
75
+ <ChevronDown size={12} className={open ? 'rotate-180 transition-transform' : 'transition-transform'} aria-hidden="true" />
76
+ </button>
77
+
78
+ {open ? (
79
+ <>
80
+ {/* Sem a camada de fundo o dropdown só fechava clicando de novo no botão, e ficava aberto
81
+ por cima da tabela enquanto o usuário tentava clicar numa linha. */}
82
+ <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
83
+ <div className="cv-filter-menu">
84
+ {options.map((option) => (
85
+ <label key={option.value} className="cv-filter-option">
86
+ <input
87
+ type="checkbox"
88
+ checked={selected.includes(option.value)}
89
+ onChange={() => toggle(option.value)}
90
+ className="cursor-pointer rounded border-gray-300 text-blue-600 dark:border-gray-600"
91
+ />
92
+ {option.label}
93
+ </label>
94
+ ))}
95
+ </div>
96
+ </>
97
+ ) : null}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ export interface BulkActionBarProps {
103
+ readonly selectedCount: number
104
+ readonly selectedLabel: (count: number) => string
105
+ readonly clearLabel: string
106
+ readonly onClear: () => void
107
+ readonly children?: ReactNode
108
+ }
109
+
110
+ export function BulkActionBar({ selectedCount, selectedLabel, clearLabel, onClear, children }: BulkActionBarProps) {
111
+ if (selectedCount === 0) return null
112
+
113
+ return (
114
+ <div className="cv-bulk-bar" role="toolbar" aria-label={selectedLabel(selectedCount)}>
115
+ <span className="text-xs font-medium">{selectedLabel(selectedCount)}</span>
116
+ {children}
117
+ <button data-cv-tooltip={clearLabel} aria-label={clearLabel} type="button" onClick={onClear} className="cv-header-action ml-auto inline-flex items-center gap-1">
118
+ <X size={12} aria-hidden="true" />
119
+ {clearLabel}
120
+ </button>
121
+ </div>
122
+ )
123
+ }
124
+
125
+ export interface ListingPaginationProps {
126
+ readonly page: number
127
+ readonly total: number
128
+ readonly perPage: number
129
+ readonly perPageOptions?: readonly number[]
130
+ readonly onPageChange: (page: number) => void
131
+ readonly onPerPageChange?: (perPage: number) => void
132
+ readonly labels: {
133
+ readonly show: string
134
+ readonly perPage: string
135
+ readonly total: (count: number) => string
136
+ readonly page: (current: number, last: number) => string
137
+ readonly previous: string
138
+ readonly next: string
139
+ }
140
+ }
141
+
142
+ export function ListingPagination({
143
+ page,
144
+ total,
145
+ perPage,
146
+ perPageOptions,
147
+ onPageChange,
148
+ onPerPageChange,
149
+ labels,
150
+ }: ListingPaginationProps) {
151
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
152
+
153
+ return (
154
+ <div className="cv-listing-pagination">
155
+ <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
156
+ {onPerPageChange && perPageOptions ? (
157
+ <>
158
+ <span>{labels.show}</span>
159
+ <select
160
+ value={perPage}
161
+ onChange={(event) => onPerPageChange(Number(event.target.value))}
162
+ aria-label={labels.perPage}
163
+ className="cv-listing-perpage"
164
+ >
165
+ {perPageOptions.map((option) => (
166
+ <option key={option} value={option}>
167
+ {option}
168
+ </option>
169
+ ))}
170
+ </select>
171
+ <span>{labels.perPage}</span>
172
+ </>
173
+ ) : null}
174
+ <span className="ml-1">{labels.total(total)}</span>
175
+ </div>
176
+
177
+ <div className="flex items-center gap-2">
178
+ <button
179
+ data-cv-tooltip={labels.previous}
180
+ type="button"
181
+ onClick={() => onPageChange(page - 1)}
182
+ disabled={page <= 1}
183
+ aria-label={labels.previous}
184
+ className="cv-header-icon disabled:opacity-40"
185
+ >
186
+
187
+ </button>
188
+ <span className="text-xs text-gray-500">{labels.page(page, lastPage)}</span>
189
+ <button
190
+ data-cv-tooltip={labels.next}
191
+ type="button"
192
+ onClick={() => onPageChange(page + 1)}
193
+ disabled={page >= lastPage}
194
+ aria-label={labels.next}
195
+ className="cv-header-icon disabled:opacity-40"
196
+ >
197
+
198
+ </button>
199
+ </div>
200
+ </div>
201
+ )
202
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Rótulos dos controles de paginação. Os pagers da inbox, do painel de documentos e da biblioteca
3
+ * são o mesmo controle repetido: o texto mora aqui para os três dizerem a mesma coisa.
4
+ */
5
+ export const PAGINATION_LABELS = {
6
+ first: 'Primeira página',
7
+ previous: 'Página anterior',
8
+ next: 'Próxima página',
9
+ last: 'Última página',
10
+ } as const
@@ -1,7 +1,11 @@
1
1
  /**
2
- * Visão lado-cliente: você digita como se fosse o cliente no WhatsApp e vê o bot responder. A
3
- * mensagem sai assinada para o webhook real, então o que roda aqui é o mesmo caminho de staging e
4
- * produção webhook, parser, motor de conversa.
2
+ * Visão lado-cliente: você digita como se fosse o cliente e vê o bot responder. A mensagem sai pelo
3
+ * transporte real do canal webhook assinado no WhatsApp, rota pública do widget no chat do site —,
4
+ * então o que roda aqui é o mesmo caminho de staging e produção: parser, motor de conversa, fluxo.
5
+ *
6
+ * O canal entra por `client` (a porta `ConversationSimulatorClient`), não por condicional aqui
7
+ * dentro: esta tela não sabe qual canal está simulando, e é o que permite o painel ficar no mesmo
8
+ * lugar da conversa para todos eles.
5
9
  *
6
10
  * É o layout de conversa de verdade (wallpaper, divisor de data, agrupamento de bolhas), não uma
7
11
  * casca de teste: o preview serve para julgar copy e fluxo, e isso só funciona se o que se vê
@@ -19,11 +23,24 @@ import { MessageBubble } from '../MessageBubble'
19
23
  import { MessageComposer } from '../MessageComposer'
20
24
  import { DateDivider } from '../DateDivider'
21
25
  import { ConversationWallpaper } from '../Wallpaper'
22
- import type { PreviewWebhookClient, SendPreviewMediaParams } from './createPreviewWebhookClient'
23
- import { AudioRecorderButton } from './AudioRecorderButton'
26
+ import type { PreviewWebhookClient } from './createPreviewWebhookClient'
27
+ import {
28
+ acceptsMediaKind,
29
+ isConversationSimulatorClient,
30
+ mediaKindOf,
31
+ SIMULATOR_FILE_MEDIA_KINDS,
32
+ toConversationSimulatorClient,
33
+ type ConversationSimulatorClient,
34
+ type SimulatorMediaKind,
35
+ } from './ConversationSimulatorClient'
36
+ import { AudioRecorderButton } from '../AudioRecorderButton'
24
37
 
25
38
  export type ConversationPreviewProps = {
26
- client: PreviewWebhookClient
39
+ /**
40
+ * Transporte do canal. `PreviewWebhookClient` continua aceito — é o caminho WhatsApp de antes
41
+ * desta porta, adaptado aqui dentro para não obrigar host nenhum a mudar de chamada.
42
+ */
43
+ client: ConversationSimulatorClient | PreviewWebhookClient
27
44
  sse: SSEProvider
28
45
  conversationId: string
29
46
  loadMessages: (conversationId: string) => Promise<MessagePayload[]>
@@ -35,10 +52,9 @@ export type ConversationPreviewProps = {
35
52
  */
36
53
  pollIntervalMs?: number
37
54
  /**
38
- * Como transformar um arquivo do disco (ou o áudio gravado) na referência que o webhook carrega.
39
- * O caminho da Meta entrega mídia por `id`, e quem sabe hospedar o arquivo é o host a SDK não
40
- * inventa um endpoint de upload. Ausente, o compositor não oferece anexo nem gravação: melhor um
41
- * botão que não existe do que um que falha ao ser tocado.
55
+ * Destino alternativo do upload, no canal que sobe a mídia antes de citá-la (o caminho da Meta
56
+ * entrega mídia por `id`). Sem isto, usa o do próprio `client`. Canal que manda os bytes direto
57
+ * ignora esta prop: quem decide referência × bytes é o adaptador do canal.
42
58
  */
43
59
  uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
44
60
  }
@@ -49,12 +65,9 @@ export type PreviewUploadedMedia = {
49
65
  readonly filename?: string
50
66
  }
51
67
 
52
- /** Deriva o tipo de mídia do WhatsApp a partir do MIME do arquivo escolhido. */
53
- export function mediaTypeOf(mimeType: string): SendPreviewMediaParams['mediaType'] {
54
- if (mimeType.startsWith('image/')) return 'image'
55
- if (mimeType.startsWith('video/')) return 'video'
56
- if (mimeType.startsWith('audio/')) return 'audio'
57
- return 'document'
68
+ /** @deprecated Use `mediaKindOf`, que não nomeia canal. Mantido para quem importa. */
69
+ export function mediaTypeOf(mimeType: string): SimulatorMediaKind {
70
+ return mediaKindOf(mimeType)
58
71
  }
59
72
 
60
73
  // Mesma janela usada pelo WhatsApp para colar bolhas do mesmo autor: acima disso, a mensagem
@@ -128,6 +141,7 @@ export function ConversationPreview({
128
141
  const [messages, setMessages] = useState<MessagePayload[]>([])
129
142
  const [failure, setFailure] = useState<string | undefined>(undefined)
130
143
  const [loadFailure, setLoadFailure] = useState<string | undefined>(undefined)
144
+ const [isRecording, setIsRecording] = useState(false)
131
145
  // Mensagens que o servidor ainda não devolveu. Sem isto, quem não consegue LER a conversa (sessão
132
146
  // ausente, API fora) digita, envia com sucesso e não vê absolutamente nada mudar — o preview fica
133
147
  // indistinguível de quebrado. São descartadas assim que uma leitura dá certo: aí quem manda na
@@ -137,6 +151,14 @@ export function ConversationPreview({
137
151
  const bottomRef = useRef<HTMLDivElement>(null)
138
152
  loadMessagesRef.current = loadMessages
139
153
 
154
+ const simulator = useMemo(
155
+ () =>
156
+ isConversationSimulatorClient(client)
157
+ ? client
158
+ : toConversationSimulatorClient({ client, ...(uploadMedia ? { uploadMedia } : {}) }),
159
+ [client, uploadMedia],
160
+ )
161
+
140
162
  const refresh = useCallback(async (): Promise<void> => {
141
163
  try {
142
164
  const loaded = await loadMessagesRef.current(conversationId)
@@ -187,7 +209,10 @@ export function ConversationPreview({
187
209
  }, [pollIntervalMs, refresh])
188
210
 
189
211
  useEffect(() => {
190
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
212
+ // `block: 'nearest'` e não o padrão ('start'): o padrão alinha o elemento ao topo da área
213
+ // visível MAIS PRÓXIMA que role — e quando o container do preview não tem altura limitada, essa
214
+ // área é a PÁGINA. O efeito era a tela inteira saltar para baixo ao abrir/usar o simulador.
215
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
191
216
  }, [messages])
192
217
 
193
218
  const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal])
@@ -202,7 +227,7 @@ export function ConversationPreview({
202
227
  async function handleSend(text: string): Promise<void> {
203
228
  setFailure(undefined)
204
229
  try {
205
- await client.sendText(text)
230
+ await simulator.sendText(text)
206
231
  setPendingLocal((current) => [
207
232
  ...current,
208
233
  {
@@ -230,28 +255,38 @@ export function ConversationPreview({
230
255
 
231
256
  async function handleInteractiveSelect(selection: InteractiveSelection): Promise<void> {
232
257
  setFailure(undefined)
233
- const reply = { id: selection.option.id, title: selection.option.title }
234
258
  try {
235
- // Botão e lista são payloads diferentes para a Meta (`button_reply` × `list_reply`), e o
236
- // roteador do fluxo lê campos distintos: tratar os dois como um só faria o menu responder no
237
- // simulador e falhar no aparelho do cliente.
238
- await (selection.kind === 'button' ? client.sendButtonReply(reply) : client.sendListReply(reply))
259
+ // A seleção viaja inteira (botão × lista) porque a forma de fio é do canal: a Meta separa
260
+ // `button_reply` de `list_reply` e o roteador do fluxo lê campos distintos, enquanto o chat do
261
+ // site manda o rótulo como texto. Decidir isso aqui faria o menu responder no simulador e
262
+ // falhar no aparelho do cliente.
263
+ await simulator.sendReply(selection)
239
264
  await refreshWithFollowUps()
240
265
  } catch (error) {
241
266
  setFailure(error instanceof Error ? error.message : 'Falha ao entregar a resposta no webhook.')
242
267
  }
243
268
  }
244
269
 
270
+ /**
271
+ * O transporte do canal diz se aceita mídia do cliente; a tela só pergunta.
272
+ *
273
+ * Antes isto era `uploadMedia` puro, e o microfone só aparecia no produto que lembrasse de montar
274
+ * o upload — de onde veio a divergência entre dois simuladores da mesma casa. Agora quem responde
275
+ * é o adaptador: no WhatsApp ele precisa de um destino de upload, no chat do site manda os bytes.
276
+ */
277
+ const sendMedia = simulator.sendMedia
278
+ const canAttachFile = SIMULATOR_FILE_MEDIA_KINDS.some((kind) => acceptsMediaKind(simulator, kind))
279
+ const canRecordAudio = acceptsMediaKind(simulator, 'audio')
280
+
245
281
  async function handleAttach(file: File): Promise<void> {
246
- if (!uploadMedia) return
282
+ if (!sendMedia) return
247
283
  setFailure(undefined)
248
284
  try {
249
- const uploaded = await uploadMedia(file)
250
- await client.sendMedia({
251
- mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
252
- mediaId: uploaded.mediaId,
253
- mimeType: uploaded.mimeType ?? file.type,
254
- filename: uploaded.filename ?? file.name,
285
+ await sendMedia({
286
+ mediaKind: mediaKindOf(file.type),
287
+ file,
288
+ mimeType: file.type,
289
+ filename: file.name,
255
290
  })
256
291
  await refreshWithFollowUps()
257
292
  } catch (error) {
@@ -297,21 +332,25 @@ export function ConversationPreview({
297
332
  </p>
298
333
  ) : null}
299
334
 
300
- <div className="flex items-end gap-1">
301
- <div className="min-w-0 flex-1">
302
- <MessageComposer
303
- onSend={(text) => void handleSend(text)}
304
- onAttach={uploadMedia ? (file) => void handleAttach(file) : undefined}
305
- placeholder={placeholder ?? 'Escreva como o cliente…'}
306
- />
307
- </div>
308
- {uploadMedia ? (
309
- <AudioRecorderButton
310
- onRecorded={(file) => void handleAttach(file)}
311
- onFailure={(message) => setFailure(message)}
312
- />
313
- ) : null}
314
- </div>
335
+ <MessageComposer
336
+ onSend={(text) => void handleSend(text)}
337
+ onAttach={canAttachFile ? (file) => void handleAttach(file) : undefined}
338
+ /* Gravando, o campo diz o que falta fazer: o botão é um interruptor e o segundo toque é
339
+ que envia sem esse aviso o operador grava, não vê nada acontecer e conclui que o
340
+ microfone está quebrado. */
341
+ placeholder={
342
+ isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
343
+ }
344
+ idleAction={
345
+ canRecordAudio ? (
346
+ <AudioRecorderButton
347
+ onRecorded={(file) => void handleAttach(file)}
348
+ onFailure={(message) => setFailure(message)}
349
+ onRecordingChange={setIsRecording}
350
+ />
351
+ ) : undefined
352
+ }
353
+ />
315
354
  </div>
316
355
  )
317
356
  }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. All rights reserved.
3
+ *
4
+ * This source code is proprietary and confidential. Unauthorized copying,
5
+ * modification, distribution, or use of this file, via any medium, is
6
+ * strictly prohibited without prior written permission from Ada Technology.
7
+ *
8
+ * Author: Anderson Filho <andersonfrfilho@gmail.com>
9
+ *
10
+ * Porta do simulador: o que a visão lado-cliente precisa saber fazer, sem canal no nome.
11
+ *
12
+ * O simulador nasceu falando WhatsApp — `PreviewWebhookClient`, payload assinado, mídia por
13
+ * `mediaId`. Só que a mesma casa atende pelo chat do próprio site, e simular ali não é uma segunda
14
+ * tela: é o mesmo painel, no mesmo lugar da conversa, com outro transporte. Sem esta porta cada
15
+ * canal novo viraria uma cópia da tela — e cópia de tela diverge, que é exatamente o que o
16
+ * `ConversationSimulatorPanel` existe para ter parado.
17
+ *
18
+ * O que é específico de canal fica no adaptador, nunca aqui:
19
+ * - **resposta de menu:** a Meta distingue `button_reply` de `list_reply`, e o roteador do fluxo lê
20
+ * campos diferentes; o chat do site manda o rótulo como texto, que é literalmente o que o
21
+ * visitante produz ao tocar no botão do widget. A porta entrega a seleção inteira e deixa cada
22
+ * adaptador escolher a forma de fio.
23
+ * - **mídia:** a Meta entrega por REFERÊNCIA (sobe o arquivo primeiro, o webhook carrega o `id`); o
24
+ * widget manda os BYTES no `FormData`. A porta trafega o `File` que a tela tem em mão — quem
25
+ * tiver passo de upload faz o upload por dentro.
26
+ */
27
+
28
+ import type { InteractiveSelection } from '../types'
29
+ import type { PreviewUploadedMedia } from './createPreviewMediaUploader'
30
+ import type { PreviewWebhookClient } from './createPreviewWebhookClient'
31
+
32
+ /**
33
+ * Tipos de mídia que o cliente pode mandar de dentro do simulador.
34
+ *
35
+ * Subconjunto proposital do que a Meta aceita: `sticker` chega do aparelho, mas não há como
36
+ * escolher um no seletor de arquivo do navegador — oferecer o tipo aqui seria um caminho morto.
37
+ */
38
+ export type SimulatorMediaKind = 'image' | 'video' | 'audio' | 'document'
39
+
40
+ /** Os tipos que saem do seletor de arquivo. `audio` fica de fora: ele vem do microfone. */
41
+ export const SIMULATOR_FILE_MEDIA_KINDS: readonly SimulatorMediaKind[] = ['image', 'video', 'document']
42
+
43
+ /** Deriva o tipo de mídia a partir do MIME do arquivo escolhido. */
44
+ export function mediaKindOf(mimeType: string): SimulatorMediaKind {
45
+ if (mimeType.startsWith('image/')) return 'image'
46
+ if (mimeType.startsWith('video/')) return 'video'
47
+ if (mimeType.startsWith('audio/')) return 'audio'
48
+ return 'document'
49
+ }
50
+
51
+ export type SendSimulatorMediaParams = {
52
+ readonly mediaKind: SimulatorMediaKind
53
+ /** O arquivo do disco ou o áudio recém-gravado. Referência × bytes é decisão do adaptador. */
54
+ readonly file: File
55
+ readonly mimeType?: string
56
+ readonly filename?: string
57
+ readonly caption?: string
58
+ }
59
+
60
+ export type ConversationSimulatorClient = {
61
+ sendText(text: string): Promise<void>
62
+ sendReply(selection: InteractiveSelection): Promise<void>
63
+ /**
64
+ * Ausente = este canal não recebe mídia do cliente, e o compositor não desenha clipe nem
65
+ * microfone. Melhor um botão que não existe do que um que falha ao ser tocado.
66
+ */
67
+ sendMedia?(params: SendSimulatorMediaParams): Promise<void>
68
+ /**
69
+ * Restringe o que `sendMedia` aceita. Ausente = todos os tipos.
70
+ *
71
+ * Existe porque canal com meia capacidade é comum: o chat do site sobe áudio (a API transcreve)
72
+ * mas não tem rota para imagem. Sem esta lista o clipe e o microfone apareciam juntos, e um dos
73
+ * dois falhava ao ser tocado.
74
+ */
75
+ readonly acceptedMediaKinds?: readonly SimulatorMediaKind[]
76
+ }
77
+
78
+ /** Responde se o compositor deve desenhar o affordance daquele tipo. */
79
+ export function acceptsMediaKind(client: ConversationSimulatorClient, kind: SimulatorMediaKind): boolean {
80
+ if (!client.sendMedia) return false
81
+
82
+ return client.acceptedMediaKinds?.includes(kind) ?? true
83
+ }
84
+
85
+ export type ToSimulatorClientParams = {
86
+ readonly client: PreviewWebhookClient
87
+ /** Destino alternativo do upload. Sem isto, usa o do próprio `client`. */
88
+ readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
89
+ }
90
+
91
+ /**
92
+ * Distingue a porta neutra do cliente WhatsApp legado, que continua aceito na prop.
93
+ *
94
+ * Leitura estrutural e não `instanceof`: os dois são objetos literais devolvidos por fábrica, e o
95
+ * host pode ter montado o seu à mão.
96
+ */
97
+ export function isConversationSimulatorClient(
98
+ candidate: ConversationSimulatorClient | PreviewWebhookClient,
99
+ ): candidate is ConversationSimulatorClient {
100
+ return typeof (candidate as ConversationSimulatorClient).sendReply === 'function'
101
+ }
102
+
103
+ /**
104
+ * Adapta o cliente WhatsApp (webhook assinado ou ponte) para a porta neutra.
105
+ *
106
+ * O upload vive aqui dentro porque ele é uma etapa DO CANAL: no caminho da Meta a mídia precisa
107
+ * existir como `id` antes do webhook citá-la. Sem passo de upload disponível, `sendMedia` sai
108
+ * ausente — é o que mantém o clipe escondido em host que não montou destino para o arquivo, o
109
+ * comportamento que já existia antes desta porta.
110
+ */
111
+ export function toConversationSimulatorClient({
112
+ client,
113
+ uploadMedia,
114
+ }: ToSimulatorClientParams): ConversationSimulatorClient {
115
+ const upload = uploadMedia ?? client.uploadMedia
116
+
117
+ const base: ConversationSimulatorClient = {
118
+ sendText: (text) => client.sendText(text),
119
+ sendReply: (selection) => {
120
+ const reply = { id: selection.option.id, title: selection.option.title }
121
+ return selection.kind === 'button' ? client.sendButtonReply(reply) : client.sendListReply(reply)
122
+ },
123
+ }
124
+
125
+ if (!upload) return base
126
+
127
+ return {
128
+ ...base,
129
+ sendMedia: async ({ mediaKind, file, mimeType, filename, caption }) => {
130
+ const uploaded = await upload(file)
131
+ await client.sendMedia({
132
+ // O tipo sai do MIME que o upload devolveu quando ele existe: host que normaliza o formato
133
+ // (áudio gravado em `webm` que sobe como `ogg`) mudava de tipo, e a mídia chegava como
134
+ // documento.
135
+ mediaType: uploaded.mimeType ? mediaKindOf(uploaded.mimeType) : mediaKind,
136
+ mediaId: uploaded.mediaId,
137
+ mimeType: uploaded.mimeType ?? mimeType ?? file.type,
138
+ filename: uploaded.filename ?? filename ?? file.name,
139
+ ...(caption ? { caption } : {}),
140
+ })
141
+ },
142
+ }
143
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { ConversationSimulatorPanel, type ConversationSimulatorPanelProps } from './ConversationSimulatorPanel'
5
+ import { createMockSSEProvider } from './createMockSSEProvider'
6
+ import { createPreviewStore } from './previewStore'
7
+
8
+ function markupOf(overrides: Partial<ConversationSimulatorPanelProps> = {}): string {
9
+ const store = createPreviewStore({ conversations: [], messages: {} })
10
+
11
+ return renderToStaticMarkup(
12
+ <ConversationSimulatorPanel
13
+ conversationId="5511900000042"
14
+ onClose={() => {}}
15
+ client={{ sendText: async () => {}, sendInteractiveReply: async () => {} } as never}
16
+ sse={createMockSSEProvider({ store })}
17
+ loadMessages={async () => []}
18
+ {...overrides}
19
+ />,
20
+ )
21
+ }
22
+
23
+ describe('ConversationSimulatorPanel', () => {
24
+ it('mostra o telefone formatado pelo host, e não o id cru', () => {
25
+ expect(markupOf({ displayNumber: '+55 (11) 90000-0042' })).toContain('+55 (11) 90000-0042')
26
+ })
27
+
28
+ it('cai no id da conversa quando o host não formata', () => {
29
+ expect(markupOf()).toContain('5511900000042')
30
+ })
31
+
32
+ it('avisa para onde a mensagem vai, para ninguém achar que é conversa de mentira', () => {
33
+ expect(markupOf()).toContain('entrega no webhook real')
34
+ })
35
+
36
+ it('dá nome acessível ao botão de fechar, que só tem ícone', () => {
37
+ expect(markupOf()).toContain('aria-label="Fechar simulador"')
38
+ })
39
+
40
+ it('aceita rótulos parciais do host sem exigir o conjunto inteiro', () => {
41
+ const markup = markupOf({ labels: { title: 'Testar fluxo' } })
42
+
43
+ expect(markup).toContain('Testar fluxo')
44
+ // O que não foi sobrescrito continua no default.
45
+ expect(markup).toContain('aria-label="Fechar simulador"')
46
+ })
47
+
48
+ it('renderiza ação extra do host no cabeçalho', () => {
49
+ expect(markupOf({ headerActions: <button type="button">Rodar roteiro</button> })).toContain('Rodar roteiro')
50
+ })
51
+
52
+ it('nomeia o próprio aside, para o leitor de tela distinguir do transcript ao lado', () => {
53
+ expect(markupOf()).toContain('aria-label="Simulador do cliente"')
54
+ })
55
+ })