@adatechnology/conversations-ui 0.1.0-rc.3 → 0.1.0-rc.30

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 (143) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/chunk-GY472G6E.js +2316 -0
  3. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  4. package/dist/flows/index.d.ts +311 -4
  5. package/dist/flows/index.js +1322 -55
  6. package/dist/index.d.ts +1074 -42
  7. package/dist/index.js +3571 -742
  8. package/dist/preview/index.d.ts +328 -8
  9. package/dist/preview/index.js +902 -115
  10. package/dist/styles.css +819 -0
  11. package/dist/types-De5aN-E_.d.ts +502 -0
  12. package/package.json +3 -3
  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 +42 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DocumentsLibrary.tsx +382 -0
  27. package/src/EmojiPicker.tsx +70 -55
  28. package/src/FileIcon.test.ts +83 -0
  29. package/src/FileIcon.tsx +88 -11
  30. package/src/InteractiveMessage.test.tsx +41 -0
  31. package/src/InteractiveMessage.tsx +146 -0
  32. package/src/Lightbox.tsx +18 -3
  33. package/src/MediaRenderer.tsx +98 -22
  34. package/src/MessageBubble.test.tsx +41 -0
  35. package/src/MessageBubble.tsx +75 -6
  36. package/src/MessageComposer.test.tsx +35 -0
  37. package/src/MessageComposer.tsx +155 -19
  38. package/src/RichMessageComposer.test.tsx +113 -0
  39. package/src/RichMessageComposer.tsx +551 -0
  40. package/src/SimpleEmojiPicker.tsx +5 -3
  41. package/src/StatusTicks.tsx +1 -1
  42. package/src/Toast.tsx +4 -0
  43. package/src/Tooltip.test.ts +42 -0
  44. package/src/Tooltip.tsx +164 -0
  45. package/src/Wallpaper.test.tsx +21 -0
  46. package/src/Wallpaper.tsx +67 -7
  47. package/src/WhatsAppMessageEditor.tsx +28 -4
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/audioRecorderFormat.test.ts +67 -0
  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 +543 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/emojiCatalog.test.ts +35 -0
  60. package/src/emojiCatalog.ts +189 -0
  61. package/src/flows/FlowGroupHeader.tsx +12 -2
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +35 -8
  65. package/src/flows/FlowNodePanel.tsx +149 -38
  66. package/src/flows/FlowPalette.tsx +13 -3
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1003 -0
  70. package/src/flows/flowCanvasModel.test.ts +293 -0
  71. package/src/flows/flowCanvasModel.ts +342 -0
  72. package/src/flows/flowEditorOps.test.ts +241 -0
  73. package/src/flows/flowEditorOps.ts +177 -0
  74. package/src/flows/flowGraph.ts +6 -6
  75. package/src/flows/index.ts +40 -1
  76. package/src/flows/labels.ts +141 -0
  77. package/src/flows/workspaceContract.test.ts +95 -0
  78. package/src/hooks/useContainerWidth.ts +35 -0
  79. package/src/hooks/useConversationActions.ts +56 -0
  80. package/src/hooks/useConversationDocuments.ts +11 -7
  81. package/src/hooks/useConversationList.ts +15 -9
  82. package/src/hooks/useConversationMessages.ts +2 -2
  83. package/src/hooks/useConversationRealtime.ts +10 -8
  84. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  85. package/src/hooks/useUrlFilterState.ts +107 -0
  86. package/src/icon.constant.ts +12 -0
  87. package/src/index.ts +114 -13
  88. package/src/lib/cn.test.ts +29 -0
  89. package/src/lib/composer-formatting.test.ts +78 -0
  90. package/src/lib/composer-formatting.ts +145 -0
  91. package/src/lib/createMediaUrlResolver.ts +33 -0
  92. package/src/lib/paginated.test.ts +33 -0
  93. package/src/lib/paginated.ts +26 -0
  94. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  95. package/src/lib/whatsapp-formatting.tsx +28 -3
  96. package/src/listing/index.tsx +202 -0
  97. package/src/pagination.constant.ts +10 -0
  98. package/src/preview/ConversationPreview.tsx +199 -13
  99. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  100. package/src/preview/ConversationSimulatorPanel.tsx +89 -0
  101. package/src/preview/MediaTypesPreview.tsx +87 -0
  102. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  103. package/src/preview/createMockConversationsApi.ts +175 -15
  104. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  105. package/src/preview/createPreviewBridgeClient.ts +124 -0
  106. package/src/preview/createPreviewMediaUploader.ts +82 -0
  107. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  108. package/src/preview/createPreviewWebhookClient.ts +127 -4
  109. package/src/preview/index.ts +33 -3
  110. package/src/preview/mediaTypeOf.test.ts +15 -0
  111. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  112. package/src/preview/preview.test.ts +5 -3
  113. package/src/preview/previewFileSamples.test.ts +151 -0
  114. package/src/preview/previewFileSamples.ts +74 -0
  115. package/src/preview/previewFixtures.ts +288 -1
  116. package/src/preview/previewMediaSource.test.ts +62 -0
  117. package/src/preview/previewMediaSource.ts +91 -0
  118. package/src/preview/previewMediaUploader.test.ts +61 -0
  119. package/src/providers/ConversationsProvider.tsx +8 -6
  120. package/src/providers/types.ts +185 -10
  121. package/src/quickReply.test.ts +58 -0
  122. package/src/settings/MessagesWorkspace.tsx +468 -0
  123. package/src/settings/TopicsForm.tsx +2 -0
  124. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  125. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  126. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  127. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  128. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  129. package/src/settings/WhatsAppTemplatesSettings.tsx +9 -1
  130. package/src/styles.css +783 -0
  131. package/src/types.ts +64 -1
  132. package/src/useWaitingNotifications.ts +74 -29
  133. package/src/workspace/BulkTemplateModal.tsx +132 -0
  134. package/src/workspace/ConversationPane.tsx +432 -0
  135. package/src/workspace/ConversationsInboxList.tsx +194 -0
  136. package/src/workspace/ConversationsWorkspace.tsx +346 -0
  137. package/src/workspace/index.ts +12 -0
  138. package/src/workspace/labels.test.ts +17 -0
  139. package/src/workspace/labels.ts +85 -0
  140. package/src/workspace/useConversationsInbox.ts +332 -0
  141. package/dist/chunk-N7B24WYD.js +0 -719
  142. package/dist/chunk-NV2RZ5KT.js +0 -56
  143. package/dist/types-C0PtaO7S.d.ts +0 -207
@@ -13,13 +13,14 @@
13
13
  */
14
14
 
15
15
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
16
- import type { MessagePayload } from '../types'
16
+ import type { InteractiveSelection, MessagePayload } from '../types'
17
17
  import type { SSEProvider } from '../providers/types'
18
18
  import { MessageBubble } from '../MessageBubble'
19
19
  import { MessageComposer } from '../MessageComposer'
20
20
  import { DateDivider } from '../DateDivider'
21
21
  import { ConversationWallpaper } from '../Wallpaper'
22
- import type { PreviewWebhookClient } from './createPreviewWebhookClient'
22
+ import type { PreviewWebhookClient, SendPreviewMediaParams } from './createPreviewWebhookClient'
23
+ import { AudioRecorderButton } from '../AudioRecorderButton'
23
24
 
24
25
  export type ConversationPreviewProps = {
25
26
  client: PreviewWebhookClient
@@ -27,12 +28,44 @@ export type ConversationPreviewProps = {
27
28
  conversationId: string
28
29
  loadMessages: (conversationId: string) => Promise<MessagePayload[]>
29
30
  placeholder?: string
31
+ /**
32
+ * Recarrega o transcript a cada N ms. Serve a host SEM stream: a resposta do bot é assíncrona, e
33
+ * sem SSE nem polling ela só apareceria no próximo envio — o sintoma é "às vezes ele não
34
+ * responde". Ausente, não faz polling (host com SSE não precisa).
35
+ */
36
+ pollIntervalMs?: number
37
+ /**
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.
42
+ */
43
+ /** Destino alternativo do áudio gravado. Sem isto, usa o do próprio `client`. */
44
+ uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
45
+ }
46
+
47
+ export type PreviewUploadedMedia = {
48
+ readonly mediaId: string
49
+ readonly mimeType?: string
50
+ readonly filename?: string
51
+ }
52
+
53
+ /** Deriva o tipo de mídia do WhatsApp a partir do MIME do arquivo escolhido. */
54
+ export function mediaTypeOf(mimeType: string): SendPreviewMediaParams['mediaType'] {
55
+ if (mimeType.startsWith('image/')) return 'image'
56
+ if (mimeType.startsWith('video/')) return 'video'
57
+ if (mimeType.startsWith('audio/')) return 'audio'
58
+ return 'document'
30
59
  }
31
60
 
32
61
  // Mesma janela usada pelo WhatsApp para colar bolhas do mesmo autor: acima disso, a mensagem
33
62
  // recomeça um grupo (com rabicho e espaçamento maior).
34
63
  const GROUPING_WINDOW_MS = 5 * 60 * 1000
35
64
 
65
+ // Escalonado, não um atraso fixo: a maioria das respostas chega em ~300ms, mas fluxo que consulta
66
+ // catálogo ou IA demora mais — e recarregar três vezes é mais barato que a conversa parecer morta.
67
+ const FOLLOW_UP_REFRESH_MS = [400, 1200, 3000]
68
+
36
69
  type RenderedMessage = {
37
70
  readonly message: MessagePayload
38
71
  readonly isFirstInGroup: boolean
@@ -55,26 +88,80 @@ function decorate(messages: readonly MessagePayload[]): RenderedMessage[] {
55
88
  })
56
89
  }
57
90
 
91
+ /**
92
+ * Status HTTP do erro, quando o host o preserva.
93
+ *
94
+ * O contrato não exige um tipo de erro — cada host tem o seu —, então a leitura é estrutural: basta
95
+ * carregar `status` (ou `statusCode`) para ser classificável. Host que joga `new Error(texto)` cai no
96
+ * caminho genérico, que ainda é melhor que silêncio.
97
+ */
98
+ function statusOf(error: unknown): number | undefined {
99
+ if (typeof error !== 'object' || error === null) return undefined
100
+ const candidate = error as { status?: unknown; statusCode?: unknown }
101
+ const value = candidate.status ?? candidate.statusCode
102
+ return typeof value === 'number' ? value : undefined
103
+ }
104
+
105
+ export function isNotFound(error: unknown): boolean {
106
+ return statusOf(error) === 404
107
+ }
108
+
109
+ export function describeLoadFailure(error: unknown): string {
110
+ const status = statusOf(error)
111
+ // 401/403 no simulador quase sempre é a aba sem sessão: `sessionStorage` é por aba, e link com
112
+ // `rel="noreferrer"` abre contexto novo que não herda o token do painel.
113
+ if (status === 401 || status === 403) {
114
+ 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.'
115
+ }
116
+ if (error instanceof Error && error.message) return `Não foi possível ler o transcript: ${error.message}`
117
+ return 'Não foi possível ler o transcript da conversa.'
118
+ }
119
+
58
120
  export function ConversationPreview({
59
121
  client,
60
122
  sse,
61
123
  conversationId,
62
124
  loadMessages,
63
125
  placeholder,
126
+ pollIntervalMs,
127
+ uploadMedia,
64
128
  }: ConversationPreviewProps) {
65
129
  const [messages, setMessages] = useState<MessagePayload[]>([])
66
130
  const [failure, setFailure] = useState<string | undefined>(undefined)
131
+ const [loadFailure, setLoadFailure] = useState<string | undefined>(undefined)
132
+ const [isRecording, setIsRecording] = useState(false)
133
+ // Mensagens que o servidor ainda não devolveu. Sem isto, quem não consegue LER a conversa (sessão
134
+ // ausente, API fora) digita, envia com sucesso e não vê absolutamente nada mudar — o preview fica
135
+ // indistinguível de quebrado. São descartadas assim que uma leitura dá certo: aí quem manda na
136
+ // tela é o servidor, que já tem a mensagem gravada.
137
+ const [pendingLocal, setPendingLocal] = useState<MessagePayload[]>([])
67
138
  const loadMessagesRef = useRef(loadMessages)
68
139
  const bottomRef = useRef<HTMLDivElement>(null)
69
140
  loadMessagesRef.current = loadMessages
70
141
 
71
142
  const refresh = useCallback(async (): Promise<void> => {
72
143
  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([])
144
+ const loaded = await loadMessagesRef.current(conversationId)
145
+ setMessages(loaded)
146
+ setLoadFailure(undefined)
147
+ // Só descarta o eco quando o servidor de fato devolveu conversa: lista vazia é "não consegui
148
+ // ver nada" (sessão ausente, primeiro contato), e limpar aí apagava da tela a mensagem que o
149
+ // usuário acabou de mandar — o sintoma que este eco existe para evitar.
150
+ if (loaded.length > 0) setPendingLocal([])
151
+ } catch (error) {
152
+ // Conversa que ainda não existe é o estado normal do primeiro contato — transcript vazio, sem
153
+ // alarme. QUALQUER outra falha precisa aparecer: engolir todas era o que transformava sessão
154
+ // expirada (401) em silêncio absoluto, com a thread limpa e o operador concluindo que o envio
155
+ // não funcionou — quando a mensagem tinha sido entregue no webhook.
156
+ if (isNotFound(error)) {
157
+ setMessages([])
158
+ setLoadFailure(undefined)
159
+ return
160
+ }
161
+
162
+ // Não limpa o que já está na tela: perder o histórico por causa de um refresh que falhou é
163
+ // dano maior que o próprio erro.
164
+ setLoadFailure(describeLoadFailure(error))
78
165
  }
79
166
  }, [conversationId])
80
167
 
@@ -96,18 +183,49 @@ export function ConversationPreview({
96
183
  }, [sse, conversationId, refresh])
97
184
 
98
185
  useEffect(() => {
99
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
186
+ if (!pollIntervalMs) return
187
+ const timer = setInterval(() => void refresh(), pollIntervalMs)
188
+ return () => clearInterval(timer)
189
+ }, [pollIntervalMs, refresh])
190
+
191
+ useEffect(() => {
192
+ // `block: 'nearest'` e não o padrão ('start'): o padrão alinha o elemento ao topo da área
193
+ // visível MAIS PRÓXIMA que role — e quando o container do preview não tem altura limitada, essa
194
+ // área é a PÁGINA. O efeito era a tela inteira saltar para baixo ao abrir/usar o simulador.
195
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
100
196
  }, [messages])
101
197
 
102
- const rendered = useMemo(() => decorate(messages), [messages])
198
+ const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal])
199
+
200
+ async function refreshWithFollowUps(): Promise<void> {
201
+ await refresh()
202
+ for (const atraso of FOLLOW_UP_REFRESH_MS) {
203
+ setTimeout(() => void refresh(), atraso)
204
+ }
205
+ }
103
206
 
104
207
  async function handleSend(text: string): Promise<void> {
105
208
  setFailure(undefined)
106
209
  try {
107
210
  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()
211
+ setPendingLocal((current) => [
212
+ ...current,
213
+ {
214
+ id: `local-${current.length}-${text.length}`,
215
+ type: 'text',
216
+ content: text,
217
+ // Do ponto de vista do servidor, mensagem do cliente é inbound — é assim que ela aparece
218
+ // como "minha" nesta visão.
219
+ direction: 'inbound',
220
+ sender: 'customer',
221
+ timestamp: new Date().toISOString(),
222
+ status: 'sent',
223
+ },
224
+ ])
225
+ // O bot responde de forma assíncrona: gravar a resposta leva algumas centenas de ms depois do
226
+ // 200 do webhook. Um refresh único aqui frequentemente chegava ANTES dela, e a conversa ficava
227
+ // com a pergunta sem resposta até o envio seguinte.
228
+ await refreshWithFollowUps()
111
229
  } catch (error) {
112
230
  // A recusa mais provável é assinatura inválida (segredo divergente do que a API valida), e
113
231
  // ela precisa aparecer na tela: silenciada, o sintoma vira "mandei e não aconteceu nada".
@@ -115,6 +233,45 @@ export function ConversationPreview({
115
233
  }
116
234
  }
117
235
 
236
+ async function handleInteractiveSelect(selection: InteractiveSelection): Promise<void> {
237
+ setFailure(undefined)
238
+ const reply = { id: selection.option.id, title: selection.option.title }
239
+ try {
240
+ // Botão e lista são payloads diferentes para a Meta (`button_reply` × `list_reply`), e o
241
+ // roteador do fluxo lê campos distintos: tratar os dois como um só faria o menu responder no
242
+ // simulador e falhar no aparelho do cliente.
243
+ await (selection.kind === 'button' ? client.sendButtonReply(reply) : client.sendListReply(reply))
244
+ await refreshWithFollowUps()
245
+ } catch (error) {
246
+ setFailure(error instanceof Error ? error.message : 'Falha ao entregar a resposta no webhook.')
247
+ }
248
+ }
249
+
250
+ /**
251
+ * O cliente do preview sabe subir mídia sozinho; a prop é só para quem quer outro destino.
252
+ *
253
+ * Antes isto era `uploadMedia` puro, e o microfone só aparecia no produto que lembrasse de montar
254
+ * o upload — de onde veio a divergência entre dois simuladores da mesma casa.
255
+ */
256
+ const uploadFile = uploadMedia ?? client.uploadMedia
257
+
258
+ async function handleAttach(file: File): Promise<void> {
259
+ if (!uploadFile) return
260
+ setFailure(undefined)
261
+ try {
262
+ const uploaded = await uploadFile(file)
263
+ await client.sendMedia({
264
+ mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
265
+ mediaId: uploaded.mediaId,
266
+ mimeType: uploaded.mimeType ?? file.type,
267
+ filename: uploaded.filename ?? file.name,
268
+ })
269
+ await refreshWithFollowUps()
270
+ } catch (error) {
271
+ setFailure(error instanceof Error ? error.message : 'Falha ao enviar o arquivo.')
272
+ }
273
+ }
274
+
118
275
  return (
119
276
  <div className="flex h-full min-h-0 flex-col">
120
277
  <ConversationWallpaper className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
@@ -127,6 +284,11 @@ export function ConversationPreview({
127
284
  // vista do servidor.
128
285
  isMine={message.direction === 'inbound'}
129
286
  isFirstInGroup={isFirstInGroup}
287
+ // Só o que o bot ofereceu é tocável: reoferecer as opções da própria mensagem do
288
+ // cliente deixaria o menu clicável para sempre, o que o WhatsApp não faz.
289
+ onInteractiveSelect={
290
+ message.direction === 'outbound' ? (selection) => void handleInteractiveSelect(selection) : undefined
291
+ }
130
292
  />
131
293
  </div>
132
294
  ))}
@@ -139,9 +301,33 @@ export function ConversationPreview({
139
301
  </p>
140
302
  ) : null}
141
303
 
304
+ {/* Separado da falha de ENVIO: são causas diferentes e confundi-las manda o operador
305
+ investigar assinatura de webhook quando o problema é sessão. Amarelo, não vermelho — a
306
+ mensagem foi entregue; o que faltou foi poder ler a conversa de volta. */}
307
+ {loadFailure ? (
308
+ <p role="status" className="px-4 py-2 text-sm text-amber-700 dark:text-amber-400">
309
+ {loadFailure}
310
+ </p>
311
+ ) : null}
312
+
142
313
  <MessageComposer
143
314
  onSend={(text) => void handleSend(text)}
144
- placeholder={placeholder ?? 'Escreva como o cliente…'}
315
+ onAttach={uploadFile ? (file) => void handleAttach(file) : undefined}
316
+ /* Gravando, o campo diz o que falta fazer: o botão é um interruptor e o segundo toque é
317
+ que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
318
+ microfone está quebrado. */
319
+ placeholder={
320
+ isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
321
+ }
322
+ idleAction={
323
+ uploadFile ? (
324
+ <AudioRecorderButton
325
+ onRecorded={(file) => void handleAttach(file)}
326
+ onFailure={(message) => setFailure(message)}
327
+ onRecordingChange={setIsRecording}
328
+ />
329
+ ) : undefined
330
+ }
145
331
  />
146
332
  </div>
147
333
  )
@@ -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,89 @@
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
+
25
+ import type { ReactNode } from 'react'
26
+
27
+ import { ConversationPreview, type ConversationPreviewProps } from './ConversationPreview'
28
+
29
+ export type ConversationSimulatorPanelLabels = {
30
+ readonly title: string
31
+ /** Complementa o telefone no subtítulo, explicando para onde a mensagem realmente vai. */
32
+ readonly destinationHint: string
33
+ readonly close: string
34
+ readonly placeholder: string
35
+ }
36
+
37
+ export const DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS: ConversationSimulatorPanelLabels = {
38
+ title: 'Simulador do cliente',
39
+ destinationHint: 'entrega no webhook real',
40
+ close: 'Fechar simulador',
41
+ placeholder: 'Escreva como o cliente…',
42
+ }
43
+
44
+ export type ConversationSimulatorPanelProps = Omit<ConversationPreviewProps, 'placeholder'> & {
45
+ readonly onClose: () => void
46
+ /**
47
+ * Telefone já formatado para leitura. É o host que formata: máscara de telefone é convenção
48
+ * regional, e o pacote não tem como saber a do produto.
49
+ */
50
+ readonly displayNumber?: string
51
+ readonly labels?: Partial<ConversationSimulatorPanelLabels>
52
+ /** Ações extras no cabeçalho — roteiro automático, limpar conversa, trocar de contato. */
53
+ readonly headerActions?: ReactNode
54
+ }
55
+
56
+ export function ConversationSimulatorPanel({
57
+ onClose,
58
+ displayNumber,
59
+ labels,
60
+ headerActions,
61
+ ...previewProps
62
+ }: ConversationSimulatorPanelProps) {
63
+ const text = { ...DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, ...labels }
64
+ const subtitle = [displayNumber ?? previewProps.conversationId, text.destinationHint].join(' · ')
65
+
66
+ return (
67
+ <aside className="cv-simulator-panel" aria-label={text.title}>
68
+ <header className="cv-simulator-panel__header">
69
+ <div className="cv-simulator-panel__heading">
70
+ <h2 className="cv-simulator-panel__title">{text.title}</h2>
71
+ <p className="cv-simulator-panel__subtitle">{subtitle}</p>
72
+ </div>
73
+ <div className="cv-simulator-panel__actions">
74
+ {headerActions}
75
+ <button type="button" onClick={onClose} data-cv-tooltip={text.close} aria-label={text.close} className="cv-simulator-panel__close">
76
+ {/* SVG inline em vez de lucide-react: o pacote não impõe biblioteca de ícone ao host. */}
77
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
78
+ <path d="M18 6 6 18M6 6l12 12" />
79
+ </svg>
80
+ </button>
81
+ </div>
82
+ </header>
83
+
84
+ <div className="cv-simulator-panel__body">
85
+ <ConversationPreview {...previewProps} placeholder={text.placeholder} />
86
+ </div>
87
+ </aside>
88
+ )
89
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Bancada de teste manual de mídia — montável em uma linha por qualquer projeto que adote o SDK.
3
+ *
4
+ * Existe porque o defeito que ela pega não é pegável por teste automatizado: "o PDF abre?" depende
5
+ * do leitor do navegador, "o vídeo toca?" do decodificador, e "a aba abre?" da política do Chrome
6
+ * sobre `data:` URL. Teste unitário confere bytes; só o olho confere que o arquivo abre. Sem uma
7
+ * superfície pronta no pacote, cada projeto teria de montar a sua — e, na prática, nenhum montava.
8
+ *
9
+ * Traz o próprio store, o próprio mock e o próprio provider: o host não injeta nada. E não passa
10
+ * `onResolveMediaUrl` em lugar nenhum de propósito — é o `MessageBubble` resolvendo mídia pelo
11
+ * `ConversationsApi` do contexto, então se essa resolução automática quebrar, esta tela mostra.
12
+ */
13
+
14
+ import { useMemo } from 'react'
15
+ import { ConversationsProvider } from '../providers/ConversationsProvider'
16
+ import { DocumentsLibrary } from '../DocumentsLibrary'
17
+ import { ConversationDocumentsPanel } from '../ConversationDocumentsPanel'
18
+ import { MessageBubble } from '../MessageBubble'
19
+ import { ConversationWallpaper } from '../Wallpaper'
20
+ import { createMockConversationsApi } from './createMockConversationsApi'
21
+ import { createMockSSEProvider } from './createMockSSEProvider'
22
+ import { createPreviewStore } from './previewStore'
23
+ import { PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_MESSAGES } from './previewFixtures'
24
+
25
+ /** A conversa do fixture que carrega uma mensagem de cada tipo aceito. */
26
+ export const MEDIA_TYPES_CONVERSATION_ID = '5511944443333'
27
+
28
+ export type MediaTypesPreviewProps = {
29
+ /** Outra conversa do fixture, se o projeto tiver acrescentado a sua. */
30
+ conversationId?: string
31
+ className?: string
32
+ }
33
+
34
+ export function MediaTypesPreview({
35
+ conversationId = MEDIA_TYPES_CONVERSATION_ID,
36
+ className,
37
+ }: MediaTypesPreviewProps) {
38
+ const store = useMemo(
39
+ () => createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES }),
40
+ [],
41
+ )
42
+ const api = useMemo(() => createMockConversationsApi({ store }), [store])
43
+ const sse = useMemo(() => createMockSSEProvider({ store }), [store])
44
+
45
+ const messages = PREVIEW_MESSAGES[conversationId] ?? []
46
+ const documents = PREVIEW_DOCUMENTS[conversationId] ?? []
47
+ const mimeTypes = [...new Set(documents.map((document) => document.mimeType))]
48
+
49
+ return (
50
+ <ConversationsProvider api={api} sse={sse}>
51
+ <div className={className}>
52
+ <header className="border-b px-4 py-3 dark:border-gray-700">
53
+ <h1 className="text-lg font-semibold">Teste manual de mídia</h1>
54
+ <p className="text-sm text-gray-500">
55
+ {documents.length} arquivos, {mimeTypes.length} tipos. Clique no olho para abrir em aba nova e no
56
+ botão da bolha para carregar a mídia na thread — é o que teste automatizado não vê.
57
+ </p>
58
+ </header>
59
+
60
+ <div className="grid gap-4 p-4 lg:grid-cols-2">
61
+ <section className="space-y-3">
62
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Biblioteca da empresa</h2>
63
+ {/* Sem paginar: a bancada serve para ver TODOS os tipos de uma vez. */}
64
+ <DocumentsLibrary perPage={documents.length || 20} />
65
+ </section>
66
+
67
+ <section className="space-y-3">
68
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Painel da conversa</h2>
69
+ <ConversationDocumentsPanel conversationId={conversationId} open perPage={documents.length || 20} />
70
+
71
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Bolhas na thread</h2>
72
+ <ConversationWallpaper className="max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2">
73
+ {messages.map((message, index) => (
74
+ <MessageBubble
75
+ key={message.id}
76
+ message={message}
77
+ isMine={message.direction === 'outbound'}
78
+ isFirstInGroup={index === 0 || messages[index - 1]?.sender !== message.sender}
79
+ />
80
+ ))}
81
+ </ConversationWallpaper>
82
+ </section>
83
+ </div>
84
+ </div>
85
+ </ConversationsProvider>
86
+ )
87
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Guarda a classificação de falha do simulador do cliente.
3
+ *
4
+ * O defeito que motivou o arquivo: o `refresh` tratava QUALQUER erro ao ler o transcript como
5
+ * "conversa ainda não existe", mostrava thread vazia e não dizia nada. Com sessão ausente (401) o
6
+ * sintoma era o pior possível — a mensagem ia para o webhook, era aceita, e a tela ficava igual.
7
+ * Quem olhava concluía que o envio estava quebrado.
8
+ */
9
+
10
+ import { describe, expect, it } from 'bun:test'
11
+
12
+ import { describeLoadFailure, isNotFound } from './ConversationPreview'
13
+
14
+ class HttpError extends Error {
15
+ constructor(
16
+ message: string,
17
+ readonly status: number,
18
+ ) {
19
+ super(message)
20
+ }
21
+ }
22
+
23
+ describe('isNotFound', () => {
24
+ // Primeiro contato: a conversa não existe e transcript vazio é o estado correto, sem alarme.
25
+ it('reconhece 404 como conversa inexistente', () => {
26
+ expect(isNotFound(new HttpError('Conversa não encontrada', 404))).toBe(true)
27
+ })
28
+
29
+ it('não confunde 401 com conversa inexistente', () => {
30
+ expect(isNotFound(new HttpError('Unauthorized', 401))).toBe(false)
31
+ })
32
+
33
+ it('erro sem status não é tratado como inexistente', () => {
34
+ expect(isNotFound(new Error('Failed to fetch'))).toBe(false)
35
+ expect(isNotFound(undefined)).toBe(false)
36
+ })
37
+
38
+ // Aceita `statusCode` também: nem todo host nomeia o campo igual.
39
+ it('lê statusCode quando é esse o nome do campo', () => {
40
+ expect(isNotFound({ statusCode: 404 })).toBe(true)
41
+ })
42
+ })
43
+
44
+ describe('describeLoadFailure', () => {
45
+ it('explica que falta sessão e que a mensagem FOI entregue', () => {
46
+ const mensagem = describeLoadFailure(new HttpError('Unauthorized', 401))
47
+
48
+ expect(mensagem).toContain('Sem sessão')
49
+ // O ponto central: não deixar o operador achar que o envio falhou.
50
+ expect(mensagem).toContain('entregue no webhook')
51
+ })
52
+
53
+ it('trata 403 igual a 401', () => {
54
+ expect(describeLoadFailure(new HttpError('Forbidden', 403))).toContain('Sem sessão')
55
+ })
56
+
57
+ it('preserva a mensagem do host em falha genérica', () => {
58
+ expect(describeLoadFailure(new HttpError('API fora do ar', 500))).toContain('API fora do ar')
59
+ })
60
+
61
+ it('tem texto para erro sem mensagem', () => {
62
+ expect(describeLoadFailure({}).length).toBeGreaterThan(0)
63
+ })
64
+ })