@adatechnology/conversations-ui 0.1.0-rc.21 → 0.1.0-rc.23

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.21",
3
+ "version": "0.1.0-rc.23",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/index.ts CHANGED
@@ -209,3 +209,18 @@ export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
209
209
  export type { ConversationHeaderUtility } from './ConversationHeader'
210
210
  export { applyQuickReplyVariables, resolveQuickReply } from './MessageComposer'
211
211
  export type { QuickReply } from './MessageComposer'
212
+
213
+ // Tela de atendimento completa. Fica no export principal — e não num subpath — porque é a
214
+ // composição padrão do pacote: quem instala conversas quer esta tela, e as peças continuam
215
+ // exportadas ao lado para quem precisar montar outra.
216
+ export { ConversationsWorkspace, ConversationPane, ConversationsInboxList } from './workspace'
217
+ export { useConversationsInbox, CONVERSATIONS_PER_PAGE, DEFAULT_CONVERSATIONS_WORKSPACE_LABELS } from './workspace'
218
+ export type {
219
+ ConversationsWorkspaceProps,
220
+ ConversationsWorkspaceSimulator,
221
+ ConversationsWorkspaceLabels,
222
+ ConversationPaneProps,
223
+ ConversationsInboxListProps,
224
+ UseConversationsInboxParams,
225
+ UseConversationsInboxResult,
226
+ } from './workspace'
@@ -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
+ }
@@ -36,6 +36,13 @@ function createCapturingFetch(status = 200): { fetchImplementation: typeof fetch
36
36
  return { fetchImplementation, captured }
37
37
  }
38
38
 
39
+ /** `uploadMedia` é opcional no contrato — o cliente do webhook sempre traz, o teste garante isso. */
40
+ function uploadMediaOf(client: { uploadMedia?: (file: File) => Promise<unknown> }) {
41
+ const upload = client.uploadMedia
42
+ if (!upload) throw new Error('createPreviewWebhookClient deveria expor uploadMedia')
43
+ return upload
44
+ }
45
+
39
46
  describe('createPreviewWebhookClient', () => {
40
47
  it('assina com o mesmo HMAC que o servidor calcula em node:crypto', async () => {
41
48
  const { fetchImplementation, captured } = createCapturingFetch()
@@ -144,7 +151,7 @@ describe('createPreviewWebhookClient.uploadMedia', () => {
144
151
  fetchImplementation,
145
152
  })
146
153
 
147
- const uploaded = await client.uploadMedia(audioFile())
154
+ const uploaded = (await uploadMediaOf(client)(audioFile())) as { mediaId: string; mimeType?: string }
148
155
 
149
156
  expect(calls[0]?.url).toBe('http://localhost:3000/v1/preview/media')
150
157
  expect(uploaded.mediaId).toBe(`${PREVIEW_MEDIA_ID_PREFIX}upl_123`)
@@ -160,7 +167,7 @@ describe('createPreviewWebhookClient.uploadMedia', () => {
160
167
  fetchImplementation,
161
168
  })
162
169
 
163
- await client.uploadMedia(audioFile())
170
+ await uploadMediaOf(client)(audioFile())
164
171
 
165
172
  const expected = `sha256=${createHmac('sha256', APP_SECRET).update('audio/ogg').digest('hex')}`
166
173
  expect(calls[0]?.signature).toBe(expected)
@@ -175,7 +182,7 @@ describe('createPreviewWebhookClient.uploadMedia', () => {
175
182
  fetchImplementation,
176
183
  })
177
184
 
178
- await client.uploadMedia(audioFile())
185
+ await uploadMediaOf(client)(audioFile())
179
186
 
180
187
  expect(calls[0]?.url).toBe('/v1/preview/media')
181
188
  })
@@ -189,6 +196,6 @@ describe('createPreviewWebhookClient.uploadMedia', () => {
189
196
  fetchImplementation,
190
197
  })
191
198
 
192
- await expect(client.uploadMedia(audioFile())).rejects.toBeInstanceOf(PreviewMediaUploadRejectedError)
199
+ await expect(uploadMediaOf(client)(audioFile())).rejects.toBeInstanceOf(PreviewMediaUploadRejectedError)
193
200
  })
194
201
  })
@@ -25,6 +25,9 @@ export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
25
25
 
26
26
  export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES, PREVIEW_DOCUMENTS } from './previewFixtures'
27
27
 
28
+ export { ConversationSimulatorPanel, DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS } from './ConversationSimulatorPanel'
29
+ export type { ConversationSimulatorPanelProps, ConversationSimulatorPanelLabels } from './ConversationSimulatorPanel'
30
+
28
31
  export { ConversationPreview } from './ConversationPreview'
29
32
  export { mediaTypeOf } from './ConversationPreview'
30
33
  export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'