@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.
- package/dist/{types-De5aN-E_.d.ts → ConversationSimulatorPanel--5fIzXWY.d.ts} +303 -1
- package/dist/{chunk-CUYYYZWD.js → chunk-BJNRLLDO.js} +393 -2
- package/dist/index.d.ts +38 -6
- package/dist/index.js +44 -85
- package/dist/preview/index.d.ts +5 -210
- package/dist/preview/index.js +36 -242
- package/package.json +1 -1
- package/src/index.ts +12 -0
- package/src/preview/ConversationPreview.tsx +56 -34
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.tsx +46 -4
- package/src/preview/index.ts +19 -1
- package/src/workspace/ConversationsWorkspace.tsx +82 -5
- package/src/workspace/index.ts +6 -1
|
@@ -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
|
|
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
|
-
*
|
|
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 = { ...
|
|
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}>
|
package/src/preview/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
|
|
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
|
|
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>
|
package/src/workspace/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { ConversationsWorkspace } from './ConversationsWorkspace'
|
|
2
|
-
export type {
|
|
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'
|