@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.41

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 (149) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/chunk-BJNRLLDO.js +2708 -0
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2389 -678
  7. package/dist/index.d.ts +1171 -42
  8. package/dist/index.js +3755 -843
  9. package/dist/preview/index.d.ts +157 -42
  10. package/dist/preview/index.js +772 -191
  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 +14 -3
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +347 -24
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +163 -45
  23. package/src/ConversationListItem.tsx +19 -2
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +382 -0
  29. package/src/EmojiPicker.tsx +70 -55
  30. package/src/FileIcon.test.ts +83 -0
  31. package/src/FileIcon.tsx +88 -11
  32. package/src/InteractiveMessage.test.tsx +41 -0
  33. package/src/InteractiveMessage.tsx +146 -0
  34. package/src/Lightbox.tsx +18 -3
  35. package/src/MediaRenderer.tsx +92 -16
  36. package/src/MessageBubble.test.tsx +41 -0
  37. package/src/MessageBubble.tsx +75 -6
  38. package/src/MessageComposer.test.tsx +35 -0
  39. package/src/MessageComposer.tsx +155 -19
  40. package/src/RichMessageComposer.test.tsx +113 -0
  41. package/src/RichMessageComposer.tsx +551 -0
  42. package/src/SimpleEmojiPicker.tsx +5 -3
  43. package/src/StatusTicks.tsx +1 -1
  44. package/src/Toast.tsx +4 -0
  45. package/src/Tooltip.test.ts +42 -0
  46. package/src/Tooltip.tsx +167 -0
  47. package/src/Wallpaper.test.tsx +21 -0
  48. package/src/Wallpaper.tsx +67 -7
  49. package/src/WhatsAppMessageEditor.tsx +34 -7
  50. package/src/WindowExpiredNotice.tsx +12 -4
  51. package/src/audioRecorderFormat.test.ts +67 -0
  52. package/src/buildOutput.test.ts +79 -0
  53. package/src/composer.constant.ts +33 -0
  54. package/src/conversationTranscript.test.ts +57 -0
  55. package/src/conversationTranscript.ts +29 -4
  56. package/src/conversationWindow.ts +7 -5
  57. package/src/documentTypeLabel.test.ts +57 -0
  58. package/src/documents/DocumentsWorkspace.tsx +550 -0
  59. package/src/documents/index.ts +8 -0
  60. package/src/documents/labels.ts +92 -0
  61. package/src/emojiCatalog.test.ts +35 -0
  62. package/src/emojiCatalog.ts +189 -0
  63. package/src/flows/FlowConnectionEdge.tsx +104 -0
  64. package/src/flows/FlowGroupHeader.tsx +12 -2
  65. package/src/flows/FlowLegend.tsx +125 -0
  66. package/src/flows/FlowMapCanvas.tsx +15 -12
  67. package/src/flows/FlowMapNode.tsx +4 -1
  68. package/src/flows/FlowNodeCard.tsx +219 -34
  69. package/src/flows/FlowNodePanel.tsx +153 -39
  70. package/src/flows/FlowPalette.tsx +106 -69
  71. package/src/flows/FlowPortalNode.tsx +1 -1
  72. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  73. package/src/flows/FlowsWorkspace.tsx +1219 -0
  74. package/src/flows/flowCanvasModel.test.ts +456 -0
  75. package/src/flows/flowCanvasModel.ts +378 -0
  76. package/src/flows/flowEditorOps.test.ts +276 -0
  77. package/src/flows/flowEditorOps.ts +202 -0
  78. package/src/flows/flowGraph.ts +78 -53
  79. package/src/flows/index.ts +51 -2
  80. package/src/flows/labels.ts +180 -0
  81. package/src/flows/workspaceContract.test.ts +126 -0
  82. package/src/hooks/useContainerWidth.ts +35 -0
  83. package/src/hooks/useConversationActions.ts +56 -0
  84. package/src/hooks/useConversationDocuments.ts +11 -7
  85. package/src/hooks/useConversationList.ts +15 -9
  86. package/src/hooks/useConversationMessages.ts +2 -2
  87. package/src/hooks/useConversationRealtime.ts +10 -8
  88. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  89. package/src/hooks/useUrlFilterState.ts +107 -0
  90. package/src/icon.constant.ts +12 -0
  91. package/src/index.ts +129 -13
  92. package/src/lib/cn.test.ts +29 -0
  93. package/src/lib/composer-formatting.test.ts +78 -0
  94. package/src/lib/composer-formatting.ts +145 -0
  95. package/src/lib/createMediaUrlResolver.ts +33 -0
  96. package/src/lib/paginated.test.ts +33 -0
  97. package/src/lib/paginated.ts +26 -0
  98. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  99. package/src/lib/whatsapp-formatting.tsx +28 -3
  100. package/src/listing/index.tsx +202 -0
  101. package/src/pagination.constant.ts +10 -0
  102. package/src/preview/ConversationPreview.tsx +225 -17
  103. package/src/preview/ConversationSimulatorClient.ts +143 -0
  104. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  105. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  106. package/src/preview/MediaTypesPreview.tsx +87 -0
  107. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  108. package/src/preview/createMockConversationsApi.ts +175 -15
  109. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  110. package/src/preview/createPreviewBridgeClient.ts +124 -0
  111. package/src/preview/createPreviewMediaUploader.ts +82 -0
  112. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  113. package/src/preview/createPreviewWebhookClient.ts +127 -4
  114. package/src/preview/index.ts +51 -3
  115. package/src/preview/mediaTypeOf.test.ts +15 -0
  116. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  117. package/src/preview/preview.test.ts +5 -3
  118. package/src/preview/previewFileSamples.test.ts +151 -0
  119. package/src/preview/previewFileSamples.ts +74 -0
  120. package/src/preview/previewFixtures.ts +288 -1
  121. package/src/preview/previewMediaSource.test.ts +62 -0
  122. package/src/preview/previewMediaSource.ts +91 -0
  123. package/src/preview/previewMediaUploader.test.ts +61 -0
  124. package/src/providers/ConversationsProvider.tsx +8 -6
  125. package/src/providers/types.ts +185 -10
  126. package/src/quickReply.test.ts +58 -0
  127. package/src/settings/MessagesWorkspace.tsx +571 -0
  128. package/src/settings/TopicsForm.tsx +2 -0
  129. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  130. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  131. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  132. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  133. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  134. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  135. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  136. package/src/styles.css +858 -0
  137. package/src/types.ts +64 -1
  138. package/src/useWaitingNotifications.ts +74 -29
  139. package/src/workspace/BulkTemplateModal.tsx +132 -0
  140. package/src/workspace/ConversationPane.tsx +432 -0
  141. package/src/workspace/ConversationsInboxList.tsx +194 -0
  142. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  143. package/src/workspace/index.ts +17 -0
  144. package/src/workspace/labels.test.ts +17 -0
  145. package/src/workspace/labels.ts +85 -0
  146. package/src/workspace/useConversationsInbox.ts +332 -0
  147. package/dist/chunk-4R6Y43DQ.js +0 -726
  148. package/dist/chunk-NV2RZ5KT.js +0 -56
  149. package/dist/types-C0PtaO7S.d.ts +0 -207
@@ -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ê
@@ -13,26 +17,67 @@
13
17
  */
14
18
 
15
19
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
16
- import type { MessagePayload } from '../types'
20
+ import type { InteractiveSelection, MessagePayload } from '../types'
17
21
  import type { SSEProvider } from '../providers/types'
18
22
  import { MessageBubble } from '../MessageBubble'
19
23
  import { MessageComposer } from '../MessageComposer'
20
24
  import { DateDivider } from '../DateDivider'
21
25
  import { ConversationWallpaper } from '../Wallpaper'
22
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'
23
37
 
24
38
  export type ConversationPreviewProps = {
25
- 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
26
44
  sse: SSEProvider
27
45
  conversationId: string
28
46
  loadMessages: (conversationId: string) => Promise<MessagePayload[]>
29
47
  placeholder?: string
48
+ /**
49
+ * Recarrega o transcript a cada N ms. Serve a host SEM stream: a resposta do bot é assíncrona, e
50
+ * sem SSE nem polling ela só apareceria no próximo envio — o sintoma é "às vezes ele não
51
+ * responde". Ausente, não faz polling (host com SSE não precisa).
52
+ */
53
+ pollIntervalMs?: number
54
+ /**
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.
58
+ */
59
+ uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
60
+ }
61
+
62
+ export type PreviewUploadedMedia = {
63
+ readonly mediaId: string
64
+ readonly mimeType?: string
65
+ readonly filename?: string
66
+ }
67
+
68
+ /** @deprecated Use `mediaKindOf`, que não nomeia canal. Mantido para quem já importa. */
69
+ export function mediaTypeOf(mimeType: string): SimulatorMediaKind {
70
+ return mediaKindOf(mimeType)
30
71
  }
31
72
 
32
73
  // Mesma janela usada pelo WhatsApp para colar bolhas do mesmo autor: acima disso, a mensagem
33
74
  // recomeça um grupo (com rabicho e espaçamento maior).
34
75
  const GROUPING_WINDOW_MS = 5 * 60 * 1000
35
76
 
77
+ // Escalonado, não um atraso fixo: a maioria das respostas chega em ~300ms, mas fluxo que consulta
78
+ // catálogo ou IA demora mais — e recarregar três vezes é mais barato que a conversa parecer morta.
79
+ const FOLLOW_UP_REFRESH_MS = [400, 1200, 3000]
80
+
36
81
  type RenderedMessage = {
37
82
  readonly message: MessagePayload
38
83
  readonly isFirstInGroup: boolean
@@ -55,26 +100,88 @@ function decorate(messages: readonly MessagePayload[]): RenderedMessage[] {
55
100
  })
56
101
  }
57
102
 
103
+ /**
104
+ * Status HTTP do erro, quando o host o preserva.
105
+ *
106
+ * O contrato não exige um tipo de erro — cada host tem o seu —, então a leitura é estrutural: basta
107
+ * carregar `status` (ou `statusCode`) para ser classificável. Host que joga `new Error(texto)` cai no
108
+ * caminho genérico, que ainda é melhor que silêncio.
109
+ */
110
+ function statusOf(error: unknown): number | undefined {
111
+ if (typeof error !== 'object' || error === null) return undefined
112
+ const candidate = error as { status?: unknown; statusCode?: unknown }
113
+ const value = candidate.status ?? candidate.statusCode
114
+ return typeof value === 'number' ? value : undefined
115
+ }
116
+
117
+ export function isNotFound(error: unknown): boolean {
118
+ return statusOf(error) === 404
119
+ }
120
+
121
+ export function describeLoadFailure(error: unknown): string {
122
+ const status = statusOf(error)
123
+ // 401/403 no simulador quase sempre é a aba sem sessão: `sessionStorage` é por aba, e link com
124
+ // `rel="noreferrer"` abre contexto novo que não herda o token do painel.
125
+ if (status === 401 || status === 403) {
126
+ return 'Sem sessão de administrador nesta aba: a mensagem é entregue no webhook, mas o transcript não pode ser lido. Entre no painel nesta mesma aba e reabra o simulador.'
127
+ }
128
+ if (error instanceof Error && error.message) return `Não foi possível ler o transcript: ${error.message}`
129
+ return 'Não foi possível ler o transcript da conversa.'
130
+ }
131
+
58
132
  export function ConversationPreview({
59
133
  client,
60
134
  sse,
61
135
  conversationId,
62
136
  loadMessages,
63
137
  placeholder,
138
+ pollIntervalMs,
139
+ uploadMedia,
64
140
  }: ConversationPreviewProps) {
65
141
  const [messages, setMessages] = useState<MessagePayload[]>([])
66
142
  const [failure, setFailure] = useState<string | undefined>(undefined)
143
+ const [loadFailure, setLoadFailure] = useState<string | undefined>(undefined)
144
+ const [isRecording, setIsRecording] = useState(false)
145
+ // Mensagens que o servidor ainda não devolveu. Sem isto, quem não consegue LER a conversa (sessão
146
+ // ausente, API fora) digita, envia com sucesso e não vê absolutamente nada mudar — o preview fica
147
+ // indistinguível de quebrado. São descartadas assim que uma leitura dá certo: aí quem manda na
148
+ // tela é o servidor, que já tem a mensagem gravada.
149
+ const [pendingLocal, setPendingLocal] = useState<MessagePayload[]>([])
67
150
  const loadMessagesRef = useRef(loadMessages)
68
151
  const bottomRef = useRef<HTMLDivElement>(null)
69
152
  loadMessagesRef.current = loadMessages
70
153
 
154
+ const simulator = useMemo(
155
+ () =>
156
+ isConversationSimulatorClient(client)
157
+ ? client
158
+ : toConversationSimulatorClient({ client, ...(uploadMedia ? { uploadMedia } : {}) }),
159
+ [client, uploadMedia],
160
+ )
161
+
71
162
  const refresh = useCallback(async (): Promise<void> => {
72
163
  try {
73
- setMessages(await loadMessagesRef.current(conversationId))
74
- } catch {
75
- // Conversa que ainda não existe é o estado normal do primeiro contato — a API responde erro
76
- // e a tela deve mostrar o transcript vazio, não uma falha.
77
- setMessages([])
164
+ const loaded = await loadMessagesRef.current(conversationId)
165
+ setMessages(loaded)
166
+ setLoadFailure(undefined)
167
+ // Só descarta o eco quando o servidor de fato devolveu conversa: lista vazia é "não consegui
168
+ // ver nada" (sessão ausente, primeiro contato), e limpar aí apagava da tela a mensagem que o
169
+ // usuário acabou de mandar — o sintoma que este eco existe para evitar.
170
+ if (loaded.length > 0) setPendingLocal([])
171
+ } catch (error) {
172
+ // Conversa que ainda não existe é o estado normal do primeiro contato — transcript vazio, sem
173
+ // alarme. QUALQUER outra falha precisa aparecer: engolir todas era o que transformava sessão
174
+ // expirada (401) em silêncio absoluto, com a thread limpa e o operador concluindo que o envio
175
+ // não funcionou — quando a mensagem tinha sido entregue no webhook.
176
+ if (isNotFound(error)) {
177
+ setMessages([])
178
+ setLoadFailure(undefined)
179
+ return
180
+ }
181
+
182
+ // Não limpa o que já está na tela: perder o histórico por causa de um refresh que falhou é
183
+ // dano maior que o próprio erro.
184
+ setLoadFailure(describeLoadFailure(error))
78
185
  }
79
186
  }, [conversationId])
80
187
 
@@ -96,18 +203,49 @@ export function ConversationPreview({
96
203
  }, [sse, conversationId, refresh])
97
204
 
98
205
  useEffect(() => {
99
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
206
+ if (!pollIntervalMs) return
207
+ const timer = setInterval(() => void refresh(), pollIntervalMs)
208
+ return () => clearInterval(timer)
209
+ }, [pollIntervalMs, refresh])
210
+
211
+ useEffect(() => {
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' })
100
216
  }, [messages])
101
217
 
102
- const rendered = useMemo(() => decorate(messages), [messages])
218
+ const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal])
219
+
220
+ async function refreshWithFollowUps(): Promise<void> {
221
+ await refresh()
222
+ for (const atraso of FOLLOW_UP_REFRESH_MS) {
223
+ setTimeout(() => void refresh(), atraso)
224
+ }
225
+ }
103
226
 
104
227
  async function handleSend(text: string): Promise<void> {
105
228
  setFailure(undefined)
106
229
  try {
107
- await client.sendText(text)
108
- // O bot responde de forma assíncrona e o ping do SSE cobre isso; este refresh é para a
109
- // própria mensagem enviada aparecer na hora, sem esperar o ciclo de evento.
110
- await refresh()
230
+ await simulator.sendText(text)
231
+ setPendingLocal((current) => [
232
+ ...current,
233
+ {
234
+ id: `local-${current.length}-${text.length}`,
235
+ type: 'text',
236
+ content: text,
237
+ // Do ponto de vista do servidor, mensagem do cliente é inbound — é assim que ela aparece
238
+ // como "minha" nesta visão.
239
+ direction: 'inbound',
240
+ sender: 'customer',
241
+ timestamp: new Date().toISOString(),
242
+ status: 'sent',
243
+ },
244
+ ])
245
+ // O bot responde de forma assíncrona: gravar a resposta leva algumas centenas de ms depois do
246
+ // 200 do webhook. Um refresh único aqui frequentemente chegava ANTES dela, e a conversa ficava
247
+ // com a pergunta sem resposta até o envio seguinte.
248
+ await refreshWithFollowUps()
111
249
  } catch (error) {
112
250
  // A recusa mais provável é assinatura inválida (segredo divergente do que a API valida), e
113
251
  // ela precisa aparecer na tela: silenciada, o sintoma vira "mandei e não aconteceu nada".
@@ -115,6 +253,47 @@ export function ConversationPreview({
115
253
  }
116
254
  }
117
255
 
256
+ async function handleInteractiveSelect(selection: InteractiveSelection): Promise<void> {
257
+ setFailure(undefined)
258
+ try {
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)
264
+ await refreshWithFollowUps()
265
+ } catch (error) {
266
+ setFailure(error instanceof Error ? error.message : 'Falha ao entregar a resposta no webhook.')
267
+ }
268
+ }
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
+
281
+ async function handleAttach(file: File): Promise<void> {
282
+ if (!sendMedia) return
283
+ setFailure(undefined)
284
+ try {
285
+ await sendMedia({
286
+ mediaKind: mediaKindOf(file.type),
287
+ file,
288
+ mimeType: file.type,
289
+ filename: file.name,
290
+ })
291
+ await refreshWithFollowUps()
292
+ } catch (error) {
293
+ setFailure(error instanceof Error ? error.message : 'Falha ao enviar o arquivo.')
294
+ }
295
+ }
296
+
118
297
  return (
119
298
  <div className="flex h-full min-h-0 flex-col">
120
299
  <ConversationWallpaper className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
@@ -127,6 +306,11 @@ export function ConversationPreview({
127
306
  // vista do servidor.
128
307
  isMine={message.direction === 'inbound'}
129
308
  isFirstInGroup={isFirstInGroup}
309
+ // Só o que o bot ofereceu é tocável: reoferecer as opções da própria mensagem do
310
+ // cliente deixaria o menu clicável para sempre, o que o WhatsApp não faz.
311
+ onInteractiveSelect={
312
+ message.direction === 'outbound' ? (selection) => void handleInteractiveSelect(selection) : undefined
313
+ }
130
314
  />
131
315
  </div>
132
316
  ))}
@@ -139,9 +323,33 @@ export function ConversationPreview({
139
323
  </p>
140
324
  ) : null}
141
325
 
326
+ {/* Separado da falha de ENVIO: são causas diferentes e confundi-las manda o operador
327
+ investigar assinatura de webhook quando o problema é sessão. Amarelo, não vermelho — a
328
+ mensagem foi entregue; o que faltou foi poder ler a conversa de volta. */}
329
+ {loadFailure ? (
330
+ <p role="status" className="px-4 py-2 text-sm text-amber-700 dark:text-amber-400">
331
+ {loadFailure}
332
+ </p>
333
+ ) : null}
334
+
142
335
  <MessageComposer
143
336
  onSend={(text) => void handleSend(text)}
144
- placeholder={placeholder ?? 'Escreva como o cliente…'}
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
+ }
145
353
  />
146
354
  </div>
147
355
  )
@@ -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
+ })
@@ -0,0 +1,131 @@
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
+ * Painel lateral do simulador de cliente: a moldura em volta do `ConversationPreview`.
11
+ *
12
+ * Existe como componente do pacote porque cada produto tinha reescrito a mesma moldura —
13
+ * `<aside>`, cabeçalho com título e telefone, botão de fechar — e as cópias divergiram. Uma delas
14
+ * migrou para o cliente-ponte (assinatura no servidor) e a outra ficou assinando no navegador, com
15
+ * o app secret publicado no bundle. É o tipo de correção que precisa chegar a todo mundo de uma vez.
16
+ *
17
+ * Mora DENTRO da tela de conversas, e não numa aba própria, por um motivo prático: o token do
18
+ * atendente costuma viver em `sessionStorage`, que é por aba. Numa aba separada o simulador nascia
19
+ * sem sessão, o transcript nunca carregava, e o sintoma era "mandei e não aconteceu nada".
20
+ *
21
+ * O efeito de cada envio aparece na thread ao lado pelo mesmo stream que a inbox já assina — este
22
+ * painel não abre conexão própria.
23
+ *
24
+ * A moldura é a mesma em todo canal — o que muda é o transporte, que entra pelo `client`, e duas
25
+ * frases do cabeçalho: dizer "entrega no webhook real" num chat de site descreveria um caminho que
26
+ * ali não existe, e "escreva como o cliente" num visitante anônimo nomeia alguém que ainda não é.
27
+ */
28
+
29
+ import type { ReactNode } from 'react'
30
+
31
+ import { CONVERSATION_CHANNEL, DEFAULT_CONVERSATION_CHANNEL, type ConversationChannel } from '../conversationChannel'
32
+ import { ConversationPreview, type ConversationPreviewProps } from './ConversationPreview'
33
+
34
+ export type ConversationSimulatorPanelLabels = {
35
+ readonly title: string
36
+ /** Complementa o identificador no subtítulo, explicando para onde a mensagem realmente vai. */
37
+ readonly destinationHint: string
38
+ readonly close: string
39
+ readonly placeholder: string
40
+ }
41
+
42
+ export const DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS: ConversationSimulatorPanelLabels = {
43
+ title: 'Simulador do cliente',
44
+ destinationHint: 'entrega no webhook real',
45
+ close: 'Fechar simulador',
46
+ placeholder: 'Escreva como o cliente…',
47
+ }
48
+
49
+ type ChannelWording = Pick<ConversationSimulatorPanelLabels, 'destinationHint' | 'placeholder'>
50
+
51
+ /** Só o que muda de canal para canal; o resto continua vindo do default. */
52
+ const SIMULATOR_PANEL_CHANNEL_WORDING: Readonly<Record<ConversationChannel, ChannelWording>> = {
53
+ [CONVERSATION_CHANNEL.WHATSAPP]: {
54
+ destinationHint: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.destinationHint,
55
+ placeholder: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.placeholder,
56
+ },
57
+ [CONVERSATION_CHANNEL.MESSENGER]: {
58
+ destinationHint: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.destinationHint,
59
+ placeholder: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.placeholder,
60
+ },
61
+ [CONVERSATION_CHANNEL.INSTAGRAM]: {
62
+ destinationHint: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.destinationHint,
63
+ placeholder: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.placeholder,
64
+ },
65
+ [CONVERSATION_CHANNEL.WEBCHAT]: {
66
+ destinationHint: 'entrega na API do chat do site',
67
+ placeholder: 'Escreva como o visitante…',
68
+ },
69
+ }
70
+
71
+ /** Rótulos do painel já resolvidos para o canal — útil para o host que monta o cabeçalho por fora. */
72
+ export function simulatorPanelLabelsOf(channel: ConversationChannel | undefined): ConversationSimulatorPanelLabels {
73
+ return {
74
+ ...DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS,
75
+ ...SIMULATOR_PANEL_CHANNEL_WORDING[channel ?? DEFAULT_CONVERSATION_CHANNEL],
76
+ }
77
+ }
78
+
79
+ export type ConversationSimulatorPanelProps = Omit<ConversationPreviewProps, 'placeholder'> & {
80
+ readonly onClose: () => void
81
+ /** Ausente = WhatsApp, que era o único canal antes desta prop existir. */
82
+ readonly channel?: ConversationChannel
83
+ /**
84
+ * Identificador do contato já formatado para leitura — telefone no WhatsApp, apelido no Instagram,
85
+ * "Visitante 3f9c21" no chat do site. É o host que formata: máscara de telefone é convenção
86
+ * regional, e o pacote não tem como saber a do produto.
87
+ */
88
+ readonly displayHandle?: string
89
+ /** @deprecated Use `displayHandle` — o simulador deixou de ser só telefone. */
90
+ readonly displayNumber?: string
91
+ readonly labels?: Partial<ConversationSimulatorPanelLabels>
92
+ /** Ações extras no cabeçalho — roteiro automático, limpar conversa, trocar de contato. */
93
+ readonly headerActions?: ReactNode
94
+ }
95
+
96
+ export function ConversationSimulatorPanel({
97
+ onClose,
98
+ channel,
99
+ displayHandle,
100
+ displayNumber,
101
+ labels,
102
+ headerActions,
103
+ ...previewProps
104
+ }: ConversationSimulatorPanelProps) {
105
+ const text = { ...simulatorPanelLabelsOf(channel), ...labels }
106
+ const subtitle = [displayHandle ?? displayNumber ?? previewProps.conversationId, text.destinationHint].join(' · ')
107
+
108
+ return (
109
+ <aside className="cv-simulator-panel" aria-label={text.title}>
110
+ <header className="cv-simulator-panel__header">
111
+ <div className="cv-simulator-panel__heading">
112
+ <h2 className="cv-simulator-panel__title">{text.title}</h2>
113
+ <p className="cv-simulator-panel__subtitle">{subtitle}</p>
114
+ </div>
115
+ <div className="cv-simulator-panel__actions">
116
+ {headerActions}
117
+ <button type="button" onClick={onClose} data-cv-tooltip={text.close} aria-label={text.close} className="cv-simulator-panel__close">
118
+ {/* SVG inline em vez de lucide-react: o pacote não impõe biblioteca de ícone ao host. */}
119
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
120
+ <path d="M18 6 6 18M6 6l12 12" />
121
+ </svg>
122
+ </button>
123
+ </div>
124
+ </header>
125
+
126
+ <div className="cv-simulator-panel__body">
127
+ <ConversationPreview {...previewProps} placeholder={text.placeholder} />
128
+ </div>
129
+ </aside>
130
+ )
131
+ }