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

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 (34) hide show
  1. package/dist/{chunk-OIDAIVCH.js → chunk-TV4OQRGH.js} +636 -434
  2. package/dist/index.d.ts +142 -9
  3. package/dist/index.js +374 -66
  4. package/dist/preview/index.d.ts +122 -3
  5. package/dist/preview/index.js +137 -21
  6. package/dist/styles.css +112 -0
  7. package/dist/{types-O7kMP1Yn.d.ts → types-C6A_9edv.d.ts} +42 -2
  8. package/package.json +2 -2
  9. package/src/AudioTranscription.test.tsx +115 -0
  10. package/src/AudioTranscription.tsx +249 -0
  11. package/src/ConversationContextPanel.tsx +205 -52
  12. package/src/ConversationLocalesProvider.tsx +28 -0
  13. package/src/MediaRenderer.tsx +37 -6
  14. package/src/MessageBubble.tsx +26 -3
  15. package/src/MessageComposer.tsx +21 -2
  16. package/src/Wallpaper.tsx +27 -13
  17. package/src/conversationTranscript.test.ts +57 -0
  18. package/src/conversationTranscript.ts +29 -4
  19. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  20. package/src/index.ts +11 -0
  21. package/src/preview/ConversationPreview.tsx +13 -4
  22. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  23. package/src/preview/ConversationSimulatorPanel.tsx +89 -0
  24. package/src/preview/createPreviewBridgeClient.ts +37 -1
  25. package/src/preview/createPreviewMediaUploader.ts +82 -0
  26. package/src/preview/createPreviewWebhookClient.test.ts +89 -0
  27. package/src/preview/createPreviewWebhookClient.ts +91 -0
  28. package/src/preview/index.ts +9 -0
  29. package/src/preview/previewMediaUploader.test.ts +61 -0
  30. package/src/providers/types.ts +12 -1
  31. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  32. package/src/settings/TranscriptionSettingsForm.tsx +189 -0
  33. package/src/styles.css +122 -0
  34. package/src/types.ts +26 -0
@@ -62,4 +62,61 @@ describe('buildTranscriptFilename', () => {
62
62
  `conversa-${NUMBER}-2026-07-27.txt`,
63
63
  )
64
64
  })
65
+
66
+ /**
67
+ * O áudio saía como `<audio>` no arquivo baixado. Um histórico onde o pedido do cliente aparece
68
+ * como marcador vazio é inútil justamente para o caso que motiva o download: auditoria e repasse.
69
+ */
70
+ it('escreve a transcrição do áudio no lugar do marcador de tipo', () => {
71
+ const texto = buildTranscriptText({
72
+ whatsappNumber: '5511999999999',
73
+ messages: [
74
+ {
75
+ id: '1',
76
+ type: 'audio',
77
+ direction: 'inbound',
78
+ sender: 'customer',
79
+ timestamp: '2026-07-31T12:00:00.000Z',
80
+ transcription: { status: 'done', text: 'quero dois quilos de arroz' },
81
+ },
82
+ ],
83
+ })
84
+
85
+ expect(texto).toContain('[áudio] quero dois quilos de arroz')
86
+ expect(texto).not.toContain('<audio>')
87
+ })
88
+
89
+ it('mantém o marcador de tipo quando o áudio não foi transcrito', () => {
90
+ const texto = buildTranscriptText({
91
+ whatsappNumber: '5511999999999',
92
+ messages: [
93
+ { id: '1', type: 'audio', direction: 'inbound', sender: 'customer', timestamp: '2026-07-31T12:00:00.000Z' },
94
+ ],
95
+ })
96
+
97
+ expect(texto).toContain('<audio>')
98
+ })
99
+
100
+ /**
101
+ * A rota de export do módulo devolve `createdAt`, não `sentAt` — quem mapeia esperando `sentAt`
102
+ * entrega `undefined` aqui, e o arquivo saía com "Invalid Date" em todas as linhas.
103
+ */
104
+ it('escreve "data indisponível" em vez de Invalid Date quando o horário não vem', () => {
105
+ const texto = buildTranscriptText({
106
+ whatsappNumber: '5511999999999',
107
+ messages: [
108
+ {
109
+ id: '1',
110
+ type: 'text',
111
+ direction: 'inbound',
112
+ sender: 'customer',
113
+ content: 'ola',
114
+ timestamp: undefined as unknown as string,
115
+ },
116
+ ],
117
+ })
118
+
119
+ expect(texto).toContain('data indisponível')
120
+ expect(texto).not.toContain('Invalid Date')
121
+ })
65
122
  })
@@ -33,16 +33,41 @@ export function buildTranscriptText(params: BuildTranscriptTextParams): string {
33
33
  ]
34
34
 
35
35
  const lines = params.messages.map((message) => {
36
- const stamp = new Date(message.timestamp).toLocaleString('pt-BR')
37
36
  const author = message.agentName ?? SENDER_LABEL[message.sender]
38
- // Mídia sem legenda não tem texto nenhum; marcar o tipo evita uma linha vazia sem explicação.
39
- const body = message.content ?? message.caption ?? `<${message.type}>`
40
- return `[${stamp}] ${author}: ${body}`
37
+ return `[${formatStamp(message.timestamp)}] ${author}: ${bodyOf(message)}`
41
38
  })
42
39
 
43
40
  return [...header, ...lines].join('\n')
44
41
  }
45
42
 
43
+ /**
44
+ * Corpo da linha no arquivo.
45
+ *
46
+ * Áudio transcrito entra com o TEXTO, não como `<audio>`. Quem baixa a conversa quer lê-la, e um
47
+ * histórico onde o pedido do cliente aparece como marcador vazio é inútil justamente para o caso que
48
+ * motiva o download: auditoria e repasse. O prefixo `[áudio]` fica na frente para a linha não passar
49
+ * por mensagem digitada — quem audita precisa saber que aquilo foi falado e transcrito por máquina.
50
+ */
51
+ function bodyOf(message: MessagePayload): string {
52
+ const transcript = message.transcription?.text?.trim()
53
+ if (message.type === 'audio' && transcript) return `[áudio] ${transcript}`
54
+
55
+ // Mídia sem legenda não tem texto nenhum; marcar o tipo evita uma linha vazia sem explicação.
56
+ return message.content ?? message.caption ?? `<${message.type}>`
57
+ }
58
+
59
+ /**
60
+ * `Invalid Date` no arquivo é pior do que data ausente: parece dado corrompido e põe em dúvida o
61
+ * resto do transcript. E acontece de verdade — a rota de export do módulo devolve `createdAt`, não
62
+ * `sentAt`, então quem mapeia esperando `sentAt` recebe `undefined` aqui.
63
+ */
64
+ function formatStamp(timestamp: string | undefined): string {
65
+ if (!timestamp) return 'data indisponível'
66
+
67
+ const parsed = new Date(timestamp)
68
+ return Number.isNaN(parsed.getTime()) ? 'data indisponível' : parsed.toLocaleString('pt-BR')
69
+ }
70
+
46
71
  export function buildTranscriptFilename(whatsappNumber: string, generatedAt: Date): string {
47
72
  const stamp = generatedAt.toISOString().slice(0, 10)
48
73
  return `conversa-${whatsappNumber}-${stamp}.txt`
@@ -0,0 +1,127 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState, type RefObject, type UIEvent } from 'react'
2
+
3
+ /**
4
+ * Mantém a conversa no fim, como todo mensageiro.
5
+ *
6
+ * Abrir uma conversa no topo do histórico é errado por um motivo simples: o que interessa ao
7
+ * atendente é a última mensagem, e ele teria que rolar por semanas de conversa para chegar nela.
8
+ *
9
+ * Existe no pacote, e não em cada host, porque a regra tem duas sutilezas que se descobre só
10
+ * errando — o salto instantâneo na troca de conversa e o respeito a quem rolou para trás — e
11
+ * reimplementá-las em cada inbox garante que uma delas fique de fora.
12
+ */
13
+
14
+ /**
15
+ * Distância do fim, em pixels, dentro da qual ainda consideramos o operador "acompanhando".
16
+ *
17
+ * Não é zero porque o navegador arredonda `scrollTop` fracionário em telas com zoom ou densidade
18
+ * alta: exigir o fim exato faria o painel achar que o operador rolou para trás sem ele ter tocado
19
+ * em nada, e a próxima mensagem não apareceria.
20
+ */
21
+ const NEAR_BOTTOM_THRESHOLD_PX = 120
22
+
23
+ /**
24
+ * Toda rolagem aqui é `'auto'` — nunca `'smooth'`.
25
+ *
26
+ * Tentamos suave para mensagem nova e medimos: em ambiente onde a rolagem suave está desligada, o
27
+ * `scrollTo({ behavior: 'smooth' })` **não faz nada e não avisa** — a mensagem nova simplesmente não
28
+ * entra na vista. E não dá para detectar isso pelo `prefers-reduced-motion`: no navegador em que
29
+ * reproduzimos, a media query respondia `false` e o smooth continuava sendo no-op.
30
+ *
31
+ * Trocar uma animação cosmética por garantia de que o operador vê a mensagem é barato: seguir uma
32
+ * mensagem nova salta a altura de uma bolha, que é quase imperceptível de qualquer forma.
33
+ */
34
+ const SCROLL_BEHAVIOR: ScrollBehavior = 'auto'
35
+
36
+ export type UseScrollToLatestMessageParams = {
37
+ /** Troca de conversa. Muda ⇒ salto instantâneo para o fim. */
38
+ readonly conversationId: string | undefined
39
+ /** Quantidade de mensagens carregadas. Cresce ⇒ acompanha o fim, se o operador estiver lá. */
40
+ readonly messageCount: number
41
+ }
42
+
43
+ export type UseScrollToLatestMessageResult = {
44
+ /** Vai no elemento que rola — tipicamente o `ConversationWallpaper`. */
45
+ readonly containerRef: RefObject<HTMLDivElement | null>
46
+ /** Ligue no `onScroll` do mesmo elemento: é o que detecta o operador lendo o histórico. */
47
+ readonly handleScroll: (event: UIEvent<HTMLDivElement>) => void
48
+ /** `true` quando o operador rolou para trás — serve a um botão "ir para a última". */
49
+ readonly isAwayFromBottom: boolean
50
+ readonly scrollToBottom: (behavior?: ScrollBehavior) => void
51
+ }
52
+
53
+ export function useScrollToLatestMessage({
54
+ conversationId,
55
+ messageCount,
56
+ }: UseScrollToLatestMessageParams): UseScrollToLatestMessageResult {
57
+ const containerRef = useRef<HTMLDivElement | null>(null)
58
+ const [isAwayFromBottom, setIsAwayFromBottom] = useState(false)
59
+
60
+ /**
61
+ * Espelha `isAwayFromBottom` para o efeito de mensagens novas ler o valor atual sem depender dele.
62
+ *
63
+ * Se o efeito dependesse do estado, cada rolagem do operador o re-disparava e o puxava de volta
64
+ * para o fim — exatamente o que a checagem existe para evitar.
65
+ */
66
+ const isAwayFromBottomRef = useRef(false)
67
+
68
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = SCROLL_BEHAVIOR) => {
69
+ const container = containerRef.current
70
+ if (!container) return
71
+ container.scrollTo({ top: container.scrollHeight, behavior })
72
+ }, [])
73
+
74
+ const handleScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
75
+ const target = event.currentTarget
76
+ const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight
77
+ const away = distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX
78
+
79
+ isAwayFromBottomRef.current = away
80
+ setIsAwayFromBottom((current) => (current === away ? current : away))
81
+ }, [])
82
+
83
+ /**
84
+ * Qual conversa já recebeu o salto de abertura.
85
+ *
86
+ * É o que separa "abriu a conversa" de "chegou mensagem", e não dá para usar só `conversationId`
87
+ * num efeito próprio: quando a conversa troca, a lista de mensagens ainda está vazia, então um
88
+ * salto ali rola um container sem conteúdo e não faz nada. O salto real precisa esperar a primeira
89
+ * leva de mensagens — e foi exatamente isso que deixou a conversa abrindo no topo.
90
+ */
91
+ const jumpedForConversationRef = useRef<string | undefined>(undefined)
92
+
93
+ useLayoutEffect(() => {
94
+ const isNewConversation = jumpedForConversationRef.current !== conversationId
95
+
96
+ if (isNewConversation) {
97
+ // Zera antes de qualquer coisa: "rolado para trás" da conversa anterior não pode bloquear o
98
+ // salto de abertura desta.
99
+ isAwayFromBottomRef.current = false
100
+ setIsAwayFromBottom(false)
101
+
102
+ // Sem mensagens ainda — o salto acontece quando a primeira leva chegar.
103
+ if (messageCount === 0) return
104
+
105
+ jumpedForConversationRef.current = conversationId
106
+ /**
107
+ * `'auto'`, sempre. Animar a rolagem por meses de histórico demora, mostra um borrão de
108
+ * mensagens antigas que ninguém pediu, e some por completo onde a rolagem suave está desligada
109
+ * (`prefers-reduced-motion`, alguns navegadores automatizados) — a conversa simplesmente
110
+ * abriria no topo. Abertura é salto, não animação.
111
+ */
112
+ scrollToBottom(SCROLL_BEHAVIOR)
113
+ return
114
+ }
115
+
116
+ /**
117
+ * Mensagem nova: acompanha, mas só se o operador já estava no fim.
118
+ *
119
+ * Puxar a rolagem de quem está lendo o histórico é pior do que não mostrar a mensagem — ele perde
120
+ * a posição e não sabe por quê. Quem rolou para trás recebe `isAwayFromBottom` e decide.
121
+ */
122
+ if (isAwayFromBottomRef.current) return
123
+ scrollToBottom(SCROLL_BEHAVIOR)
124
+ }, [conversationId, messageCount, scrollToBottom])
125
+
126
+ return { containerRef, handleScroll, isAwayFromBottom, scrollToBottom }
127
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export { InteractiveMessage, DEFAULT_INTERACTIVE_MESSAGE_LABELS } from './Intera
3
3
  export { ConversationWallpaper } from './Wallpaper'
4
4
  export { ConversationLocalesProvider, useConversationLocales } from './ConversationLocalesProvider'
5
5
  export { AudioPlayer } from './AudioPlayer'
6
+ export { AudioTranscription } from './AudioTranscription'
6
7
  export { EmojiPicker, DEFAULT_EMOJI_PICKER_LABELS } from './EmojiPicker'
7
8
  export { EMOJI_CATEGORIES, searchEmojis } from './emojiCatalog'
8
9
  export { MessageComposer, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
@@ -77,6 +78,7 @@ export { ConversationContextPanel, DEFAULT_CONVERSATION_CONTEXT_LABELS } from '.
77
78
  export type {
78
79
  ConversationContextPanelProps,
79
80
  ConversationContextEntry,
81
+ ConversationContextStatus,
80
82
  ConversationContextPanelLabels,
81
83
  ConversationContextPanelClassNames,
82
84
  } from './ConversationContextPanel'
@@ -108,6 +110,7 @@ export { ConversationsProvider, useConversations } from './providers/Conversatio
108
110
  export { WhatsAppTemplateSettingsForm } from './settings/WhatsAppTemplateSettingsForm'
109
111
  export { WhatsAppCreateTemplateForm } from './settings/WhatsAppCreateTemplateForm'
110
112
  export { WelcomeFarewellForm } from './settings/WelcomeFarewellForm'
113
+ export { TranscriptionSettingsForm } from './settings/TranscriptionSettingsForm'
111
114
  export {
112
115
  WhatsAppTemplatesSettings,
113
116
  TEMPLATE_SETTINGS_TAB,
@@ -118,6 +121,7 @@ export { TopicsForm } from './settings/TopicsForm'
118
121
  // Camada headless (T6.9) — hooks de dados/ações independentes de qualquer tela, para o
119
122
  // produto montar sua própria UI sobre eles. Requerem <ConversationsProvider> como ancestral.
120
123
  export { useConversationMessages } from './hooks/useConversationMessages'
124
+ export { useScrollToLatestMessage } from './hooks/useScrollToLatestMessage'
121
125
  export { useConversationList } from './hooks/useConversationList'
122
126
  export { useConversationContext } from './hooks/useConversationContext'
123
127
  export { useConversationDocuments } from './hooks/useConversationDocuments'
@@ -132,6 +136,7 @@ export { formatTimestamp, formatFileSize, formatDateTime, isSameDay } from './li
132
136
 
133
137
  export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
134
138
  export type { InteractivePayload, InteractiveSection, InteractiveOption, InteractiveSelection } from './types'
139
+ export type { MessageTranscription, TranscriptionStatus, TranscriptionMode } from './types'
135
140
  export type {
136
141
  ConversationsApi,
137
142
  SSEProvider,
@@ -152,6 +157,7 @@ export type { MessageBubbleProps } from './MessageBubble'
152
157
  export type { ConversationWallpaperProps } from './Wallpaper'
153
158
  export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
154
159
  export type { AudioPlayerProps } from './AudioPlayer'
160
+ export type { AudioTranscriptionProps } from './AudioTranscription'
155
161
  export type { EmojiPickerProps, EmojiPickerLabels } from './EmojiPicker'
156
162
  export type { EmojiEntry, EmojiCategory } from './emojiCatalog'
157
163
  export type { InteractiveMessageProps, InteractiveMessageLabels } from './InteractiveMessage'
@@ -181,6 +187,10 @@ export type {
181
187
  WhatsAppCreateTemplateFormLabels,
182
188
  } from './settings/WhatsAppCreateTemplateForm'
183
189
  export type { WelcomeFarewellFormProps, WelcomeFarewellFormLabels } from './settings/WelcomeFarewellForm'
190
+ export type {
191
+ TranscriptionSettingsFormProps,
192
+ TranscriptionSettingsFormLabels,
193
+ } from './settings/TranscriptionSettingsForm'
184
194
  export type {
185
195
  WhatsAppTemplatesSettingsProps,
186
196
  WhatsAppTemplatesSettingsLabels,
@@ -191,6 +201,7 @@ export type { TopicsFormProps, TopicItem, TopicsFormLabels } from './settings/To
191
201
  export type { UseConversationMessagesResult } from './hooks/useConversationMessages'
192
202
  export type { UseConversationListParams, UseConversationListResult } from './hooks/useConversationList'
193
203
  export type { UseConversationContextResult } from './hooks/useConversationContext'
204
+ export type { UseScrollToLatestMessageParams, UseScrollToLatestMessageResult } from './hooks/useScrollToLatestMessage'
194
205
  export type { UseConversationDocumentsParams, UseConversationDocumentsResult } from './hooks/useConversationDocuments'
195
206
  export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
196
207
  export type { AsyncResourceState } from './hooks/useAsyncResource'
@@ -40,6 +40,7 @@ export type ConversationPreviewProps = {
40
40
  * inventa um endpoint de upload. Ausente, o compositor não oferece anexo nem gravação: melhor um
41
41
  * botão que não existe do que um que falha ao ser tocado.
42
42
  */
43
+ /** Destino alternativo do áudio gravado. Sem isto, usa o do próprio `client`. */
43
44
  uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
44
45
  }
45
46
 
@@ -246,11 +247,19 @@ export function ConversationPreview({
246
247
  }
247
248
  }
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
+
249
258
  async function handleAttach(file: File): Promise<void> {
250
- if (!uploadMedia) return
259
+ if (!uploadFile) return
251
260
  setFailure(undefined)
252
261
  try {
253
- const uploaded = await uploadMedia(file)
262
+ const uploaded = await uploadFile(file)
254
263
  await client.sendMedia({
255
264
  mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
256
265
  mediaId: uploaded.mediaId,
@@ -303,7 +312,7 @@ export function ConversationPreview({
303
312
 
304
313
  <MessageComposer
305
314
  onSend={(text) => void handleSend(text)}
306
- onAttach={uploadMedia ? (file) => void handleAttach(file) : undefined}
315
+ onAttach={uploadFile ? (file) => void handleAttach(file) : undefined}
307
316
  /* Gravando, o campo diz o que falta fazer: o botão é um interruptor e o segundo toque é
308
317
  que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
309
318
  microfone está quebrado. */
@@ -311,7 +320,7 @@ export function ConversationPreview({
311
320
  isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
312
321
  }
313
322
  idleAction={
314
- uploadMedia ? (
323
+ uploadFile ? (
315
324
  <AudioRecorderButton
316
325
  onRecorded={(file) => void handleAttach(file)}
317
326
  onFailure={(message) => setFailure(message)}
@@ -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} title={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
+ }
@@ -16,7 +16,13 @@
16
16
  */
17
17
 
18
18
  import type { InboundMediaType, InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing'
19
- import type { PreviewWebhookClient, SendPreviewMediaParams } from './createPreviewWebhookClient'
19
+ import {
20
+ createPreviewMediaPoster,
21
+ defaultMediaUploadUrl,
22
+ type PreviewWebhookClient,
23
+ type SendPreviewMediaParams,
24
+ } from './createPreviewWebhookClient'
25
+ import type { PreviewUploadedMedia } from './createPreviewMediaUploader'
20
26
 
21
27
  /**
22
28
  * Comando semântico entregue ao host. É deliberadamente o QUE o cliente fez, não o payload da Meta:
@@ -50,6 +56,18 @@ export type CreatePreviewBridgeClientParams = {
50
56
  readonly endpointUrl?: string
51
57
  readonly headers?: Readonly<Record<string, string>>
52
58
  readonly fetchImplementation?: typeof fetch
59
+ /**
60
+ * Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na origem do `endpointUrl`.
61
+ *
62
+ * Aqui não há assinatura a calcular: a ponte existe justamente para não ter segredo no navegador,
63
+ * e a rota é protegida pela sessão do painel — os mesmos `headers` do comando valem para o upload.
64
+ */
65
+ readonly mediaUploadUrl?: string
66
+ /**
67
+ * Substitui o upload embutido. Necessário para host que só passa `sendCommand`: sem `endpointUrl`
68
+ * não há origem a derivar, e sem destino o gravador não é desenhado.
69
+ */
70
+ readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
53
71
  }
54
72
 
55
73
  function buildFetchSender(params: CreatePreviewBridgeClientParams): SendPreviewInboundCommand {
@@ -72,9 +90,26 @@ function buildFetchSender(params: CreatePreviewBridgeClientParams): SendPreviewI
72
90
  }
73
91
  }
74
92
 
93
+ /** Só existe quando há para onde mandar: rota explícita, ou origem herdada do `endpointUrl`. */
94
+ function resolveBridgeUpload(
95
+ params: CreatePreviewBridgeClientParams,
96
+ ): ((file: File) => Promise<PreviewUploadedMedia>) | undefined {
97
+ if (params.uploadMedia) return params.uploadMedia
98
+
99
+ const url = params.mediaUploadUrl ?? (params.endpointUrl ? defaultMediaUploadUrl(params.endpointUrl) : undefined)
100
+ if (!url) return undefined
101
+
102
+ return createPreviewMediaPoster({
103
+ url,
104
+ ...(params.headers ? { headers: async () => params.headers ?? {} } : {}),
105
+ ...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
106
+ })
107
+ }
108
+
75
109
  export function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient {
76
110
  const send = params.sendCommand ?? buildFetchSender(params)
77
111
  const from = params.from
112
+ const uploadMedia = resolveBridgeUpload(params)
78
113
 
79
114
  return {
80
115
  sendText: (text) => send({ kind: 'text', from, text }),
@@ -82,6 +117,7 @@ export function createPreviewBridgeClient(params: CreatePreviewBridgeClientParam
82
117
  sendListReply: (reply) => send({ kind: 'listReply', from, reply }),
83
118
  sendAudio: (mediaId) => send({ kind: 'audio', from, mediaId }),
84
119
  sendMedia: (media) => send({ kind: 'media', from, ...media }),
120
+ ...(uploadMedia ? { uploadMedia } : {}),
85
121
  }
86
122
  }
87
123
 
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Entrega ao simulador o `uploadMedia` que ele precisa para desenhar o microfone.
3
+ *
4
+ * O `ConversationPreview` esconde o gravador sem esta função, e com razão: microfone que grava sem
5
+ * ter onde guardar o arquivo faz o operador falar para o vazio. O que faltava era montar isto —
6
+ * lê o `File`, manda para a rota do host, devolve o `mediaId` prefixado que o webhook referencia.
7
+ *
8
+ * Fica no pacote porque a parte que erra é sempre a mesma em todo produto: converter o binário sem
9
+ * estourar a pilha e marcar o id com o prefixo que o backend reconhece. O que muda por produto é só
10
+ * a rota e o cliente HTTP — e é exatamente isso que entra por parâmetro.
11
+ */
12
+
13
+ /**
14
+ * Do `contracts`, que este pacote já consome — não uma cópia.
15
+ *
16
+ * A convenção tem duas pontas (o front gera o id, o backend resolve) e a versão anterior disso vivia
17
+ * duplicada em dois pacotes de um produto, cada cópia com um comentário pedindo para não divergir.
18
+ * Contrato compartilhado é o que o `contracts` existe para guardar.
19
+ */
20
+ export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
21
+ import { toPreviewMediaId } from '@adatechnology/meta-whatsapp-contracts'
22
+
23
+ export type PreviewUploadedMedia = {
24
+ readonly mediaId: string
25
+ readonly mimeType?: string
26
+ readonly filename?: string
27
+ }
28
+
29
+ export type PreviewMediaUploadRequest = {
30
+ readonly base64: string
31
+ readonly mimeType: string
32
+ readonly filename: string
33
+ }
34
+
35
+ export type CreatePreviewMediaUploaderParams = {
36
+ /**
37
+ * Envia o arquivo à rota do host e devolve o `uploadId` (sem prefixo) que o backend gerou.
38
+ *
39
+ * Recebe a função inteira, e não uma URL, porque autenticação varia: uma instalação assina com
40
+ * HMAC, outra manda token de admin, outra usa cookie de sessão. Pedir a URL obrigaria o pacote a
41
+ * escolher por elas.
42
+ */
43
+ readonly upload: (request: PreviewMediaUploadRequest) => Promise<{ uploadId: string }>
44
+ /** Nome usado quando o gravador entrega o áudio sem nome próprio. */
45
+ readonly fallbackFilename?: string
46
+ readonly fallbackMimeType?: string
47
+ }
48
+
49
+ /**
50
+ * Converte em blocos, não com `String.fromCharCode(...bytes)` de uma vez.
51
+ *
52
+ * Espalhar centenas de milhares de bytes como argumentos estoura o limite da engine — poucos segundos
53
+ * de áudio já chegam perto. O sintoma seria `RangeError` só nos arquivos grandes: passa no teste com
54
+ * um clipe curto e falha na primeira gravação de verdade.
55
+ */
56
+ const CHUNK_SIZE = 8192
57
+
58
+ async function fileToBase64(file: File): Promise<string> {
59
+ const bytes = new Uint8Array(await file.arrayBuffer())
60
+ let binary = ''
61
+ for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) {
62
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + CHUNK_SIZE))
63
+ }
64
+ return btoa(binary)
65
+ }
66
+
67
+ export function createPreviewMediaUploader(
68
+ params: CreatePreviewMediaUploaderParams,
69
+ ): (file: File) => Promise<PreviewUploadedMedia> {
70
+ const fallbackMimeType = params.fallbackMimeType ?? 'audio/ogg'
71
+ const fallbackFilename = params.fallbackFilename ?? 'audio.ogg'
72
+
73
+ return async function uploadPreviewMedia(file: File): Promise<PreviewUploadedMedia> {
74
+ // Gravação de voz chega sem nome, e sem mime em navegador antigo.
75
+ const mimeType = file.type || fallbackMimeType
76
+ const filename = file.name || fallbackFilename
77
+
78
+ const { uploadId } = await params.upload({ base64: await fileToBase64(file), mimeType, filename })
79
+
80
+ return { mediaId: toPreviewMediaId(uploadId), mimeType, filename }
81
+ }
82
+ }