@adatechnology/conversations-ui 0.1.0-rc.34 → 0.1.0-rc.35

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.
@@ -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
+ }
@@ -20,15 +20,20 @@
20
20
  *
21
21
  * O efeito de cada envio aparece na thread ao lado pelo mesmo stream que a inbox já assina — este
22
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 é.
23
27
  */
24
28
 
25
29
  import type { ReactNode } from 'react'
26
30
 
31
+ import { CONVERSATION_CHANNEL, DEFAULT_CONVERSATION_CHANNEL, type ConversationChannel } from '../conversationChannel'
27
32
  import { ConversationPreview, type ConversationPreviewProps } from './ConversationPreview'
28
33
 
29
34
  export type ConversationSimulatorPanelLabels = {
30
35
  readonly title: string
31
- /** Complementa o telefone no subtítulo, explicando para onde a mensagem realmente vai. */
36
+ /** Complementa o identificador no subtítulo, explicando para onde a mensagem realmente vai. */
32
37
  readonly destinationHint: string
33
38
  readonly close: string
34
39
  readonly placeholder: string
@@ -41,12 +46,47 @@ export const DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS: ConversationSimulatorP
41
46
  placeholder: 'Escreva como o cliente…',
42
47
  }
43
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
+
44
79
  export type ConversationSimulatorPanelProps = Omit<ConversationPreviewProps, 'placeholder'> & {
45
80
  readonly onClose: () => void
81
+ /** Ausente = WhatsApp, que era o único canal antes desta prop existir. */
82
+ readonly channel?: ConversationChannel
46
83
  /**
47
- * Telefone já formatado para leitura. É o host que formata: máscara de telefone é convenção
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
48
86
  * regional, e o pacote não tem como saber a do produto.
49
87
  */
88
+ readonly displayHandle?: string
89
+ /** @deprecated Use `displayHandle` — o simulador deixou de ser só telefone. */
50
90
  readonly displayNumber?: string
51
91
  readonly labels?: Partial<ConversationSimulatorPanelLabels>
52
92
  /** Ações extras no cabeçalho — roteiro automático, limpar conversa, trocar de contato. */
@@ -55,13 +95,15 @@ export type ConversationSimulatorPanelProps = Omit<ConversationPreviewProps, 'pl
55
95
 
56
96
  export function ConversationSimulatorPanel({
57
97
  onClose,
98
+ channel,
99
+ displayHandle,
58
100
  displayNumber,
59
101
  labels,
60
102
  headerActions,
61
103
  ...previewProps
62
104
  }: ConversationSimulatorPanelProps) {
63
- const text = { ...DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, ...labels }
64
- const subtitle = [displayNumber ?? previewProps.conversationId, text.destinationHint].join(' · ')
105
+ const text = { ...simulatorPanelLabelsOf(channel), ...labels }
106
+ const subtitle = [displayHandle ?? displayNumber ?? previewProps.conversationId, text.destinationHint].join(' · ')
65
107
 
66
108
  return (
67
109
  <aside className="cv-simulator-panel" aria-label={text.title}>
@@ -25,9 +25,27 @@ 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'
28
+ export {
29
+ ConversationSimulatorPanel,
30
+ DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS,
31
+ simulatorPanelLabelsOf,
32
+ } from './ConversationSimulatorPanel'
29
33
  export type { ConversationSimulatorPanelProps, ConversationSimulatorPanelLabels } from './ConversationSimulatorPanel'
30
34
 
35
+ export {
36
+ acceptsMediaKind,
37
+ mediaKindOf,
38
+ isConversationSimulatorClient,
39
+ toConversationSimulatorClient,
40
+ SIMULATOR_FILE_MEDIA_KINDS,
41
+ } from './ConversationSimulatorClient'
42
+ export type {
43
+ ConversationSimulatorClient,
44
+ SendSimulatorMediaParams,
45
+ SimulatorMediaKind,
46
+ ToSimulatorClientParams,
47
+ } from './ConversationSimulatorClient'
48
+
31
49
  export { ConversationPreview } from './ConversationPreview'
32
50
  export { mediaTypeOf } from './ConversationPreview'
33
51
  export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
@@ -9,6 +9,11 @@
9
9
  * Customização: `labels` (texto e idioma), `renderFilters` / `renderBulkActions` /
10
10
  * `renderAboveTranscript` / `renderRow` (peças do produto), `contextEntriesOf` (vocabulário do
11
11
  * contexto), `extraUtilities` (ações no cabeçalho da conversa) e `simulator` (painel de preview).
12
+ *
13
+ * O simulador é montado aqui, e não pelo host: com `simulator.transports` o produto entrega só o
14
+ * transporte de cada canal e a moldura vem do pacote. Isso traz o `preview/` para o bundle de quem
15
+ * usa o workspace — o preço de a tela composta ser o padrão de consumo, que é o que impede a inbox
16
+ * de divergir entre produtos.
12
17
  */
13
18
 
14
19
  import { useEffect, useMemo, useState, type ReactNode } from 'react'
@@ -20,6 +25,14 @@ import type { ConversationHeaderUtility } from '../ConversationHeader'
20
25
  import type { QuickReply } from '../MessageComposer'
21
26
  import type { RichComposerVariable } from '../RichMessageComposer'
22
27
  import type { ConversationSummary } from '../providers/types'
28
+ import { useConversations } from '../providers/ConversationsProvider'
29
+ import { DEFAULT_CONVERSATION_CHANNEL, formatContactHandle, type ConversationChannel } from '../conversationChannel'
30
+ import type { ConversationSimulatorClient } from '../preview/ConversationSimulatorClient'
31
+ import type { PreviewUploadedMedia } from '../preview/createPreviewMediaUploader'
32
+ import {
33
+ ConversationSimulatorPanel,
34
+ type ConversationSimulatorPanelLabels,
35
+ } from '../preview/ConversationSimulatorPanel'
23
36
  import { BulkTemplateModal } from './BulkTemplateModal'
24
37
  import { ConversationPane } from './ConversationPane'
25
38
  import { ConversationsInboxList } from './ConversationsInboxList'
@@ -27,17 +40,49 @@ import { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, type ConversationsWorkspaceLabe
27
40
  import { useConversationsInbox, type UseConversationsInboxResult } from './useConversationsInbox'
28
41
  import { TooltipLayer } from '../Tooltip'
29
42
 
43
+ export type SimulatorTransportParams = {
44
+ readonly conversationId: string
45
+ readonly channel: ConversationChannel
46
+ /** Identificador do contato no canal: telefone no WhatsApp, id de sessão no chat do site. */
47
+ readonly handle: string
48
+ }
49
+
50
+ /**
51
+ * Fábrica do transporte daquele canal.
52
+ *
53
+ * É o único ponto onde o host precisa saber de canal: o painel, a moldura e o comportamento são os
54
+ * mesmos em todos. No WhatsApp devolve o cliente-ponte (assinatura no servidor do host); no chat do
55
+ * site, o cliente das rotas do widget.
56
+ */
57
+ export type SimulatorTransportFactory = (params: SimulatorTransportParams) => ConversationSimulatorClient
58
+
30
59
  export interface ConversationsWorkspaceSimulator {
31
60
  /**
32
- * Desenha o painel do cliente. Fica com o host porque o simulador precisa da rota assinada no
33
- * servidor DELE — e é o que mantém o `preview/` fora do bundle de quem não usa.
61
+ * Um transporte por canal — o workspace monta o painel com o da conversa selecionada.
62
+ *
63
+ * Canal sem transporte não desenha o botão: capacidade é opcional por ausência, e oferecer
64
+ * "simular" numa conversa que não tem como receber a mensagem é um botão que falha ao ser tocado.
65
+ */
66
+ readonly transports?: Partial<Record<ConversationChannel, SimulatorTransportFactory>>
67
+ /**
68
+ * Válvula de escape: o host desenha o painel inteiro. Tem precedência sobre `transports`.
69
+ *
70
+ * Era a única porta antes de `transports`, quando o simulador só falava WhatsApp e cada produto
71
+ * remontava a moldura — o que fez duas telas da mesma casa divergirem. Continua aceito para não
72
+ * quebrar quem já a usa.
34
73
  */
35
- render(params: { conversationId: string; close: () => void }): ReactNode
74
+ render?(params: { conversationId: string; channel: ConversationChannel; close: () => void }): ReactNode
36
75
  /** Ausente = ligado. Serve para esconder fora de desenvolvimento sem condicionar o JSX. */
37
76
  readonly enabled?: boolean
38
77
  /** Ícone da biblioteca (lucide) no utilitário do cabeçalho. Ausente, entra o frasco de teste. */
39
78
  readonly icon?: ReactNode
40
79
  readonly label?: string
80
+ /** Vocabulário do painel. O que muda de canal (destino, placeholder) já vem resolvido. */
81
+ readonly labels?: Partial<ConversationSimulatorPanelLabels>
82
+ /** Destino do upload no canal que entrega mídia por referência (o caminho da Meta). */
83
+ readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
84
+ /** Recarrega o transcript a cada N ms. Serve a host sem stream. */
85
+ readonly pollIntervalMs?: number
41
86
  }
42
87
 
43
88
  export interface ConversationsWorkspaceProps {
@@ -157,9 +202,23 @@ export function ConversationsWorkspace({
157
202
  }, [initialConversationId, initialWhatsappNumber, openedFromLink, inbox])
158
203
 
159
204
  const selected = inbox.selectedConversation
160
- const simulatorEnabled = Boolean(simulator && (simulator.enabled ?? true))
205
+ const conversations = useConversations()
206
+ const selectedId = selected?.id
207
+ const channel = selected?.channel ?? DEFAULT_CONVERSATION_CHANNEL
208
+ const handle = selected ? (selected.contactId ?? selected.whatsappNumber) : ''
209
+ const transport = simulator?.transports?.[channel]
210
+ const simulatorEnabled = Boolean(
211
+ simulator && (simulator.enabled ?? true) && (simulator.render ?? transport),
212
+ )
161
213
  const showSimulator = simulatorEnabled && simulatorOpen && Boolean(selected)
162
214
 
215
+ // O transporte é recriado só quando a conversa (ou o canal dela) muda: identidade nova a cada
216
+ // render reiniciaria a leitura do transcript dentro do painel a cada digitação.
217
+ const simulatorClient = useMemo(
218
+ () => (transport && selectedId ? transport({ conversationId: selectedId, channel, handle }) : undefined),
219
+ [transport, selectedId, channel, handle],
220
+ )
221
+
163
222
  const paneUtilities = useMemo(() => {
164
223
  if (!selected) return undefined
165
224
  const fromProduct = extraUtilitiesFor?.(selected) ?? []
@@ -325,7 +384,25 @@ export function ConversationsWorkspace({
325
384
  // `min-height:0` junto do `min-width:0`: sem isso a linha do grid cresce com o conteúdo do
326
385
  // painel, o scroll interno nunca ativa e quem rola passa a ser a página inteira.
327
386
  <div className="cv-workspace-simulator">
328
- {simulator?.render({ conversationId: selected.id, close: () => setSimulatorOpen(false) })}
387
+ {simulator?.render ? (
388
+ simulator.render({ conversationId: selected.id, channel, close: () => setSimulatorOpen(false) })
389
+ ) : simulatorClient && conversations ? (
390
+ // `key` pela conversa: trocar de contato sem remontar deixaria o transcript e o campo
391
+ // de texto do contato anterior na tela.
392
+ <ConversationSimulatorPanel
393
+ key={selected.id}
394
+ client={simulatorClient}
395
+ sse={conversations.sse}
396
+ conversationId={selected.id}
397
+ channel={channel}
398
+ displayHandle={formatContactHandle({ handle, channel })}
399
+ loadMessages={(conversationId) => conversations.api.fetchMessages(conversationId)}
400
+ onClose={() => setSimulatorOpen(false)}
401
+ {...(simulator?.labels ? { labels: simulator.labels } : {})}
402
+ {...(simulator?.uploadMedia ? { uploadMedia: simulator.uploadMedia } : {})}
403
+ {...(simulator?.pollIntervalMs ? { pollIntervalMs: simulator.pollIntervalMs } : {})}
404
+ />
405
+ ) : null}
329
406
  </div>
330
407
  ) : null}
331
408
  </div>
@@ -1,5 +1,10 @@
1
1
  export { ConversationsWorkspace } from './ConversationsWorkspace'
2
- export type { ConversationsWorkspaceProps, ConversationsWorkspaceSimulator } from './ConversationsWorkspace'
2
+ export type {
3
+ ConversationsWorkspaceProps,
4
+ ConversationsWorkspaceSimulator,
5
+ SimulatorTransportFactory,
6
+ SimulatorTransportParams,
7
+ } from './ConversationsWorkspace'
3
8
  export { ConversationPane } from './ConversationPane'
4
9
  export type { ConversationPaneProps } from './ConversationPane'
5
10
  export { BulkTemplateModal } from './BulkTemplateModal'