@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.41
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/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/chunk-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2389 -678
- package/dist/index.d.ts +1171 -42
- package/dist/index.js +3755 -843
- package/dist/preview/index.d.ts +157 -42
- package/dist/preview/index.js +772 -191
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +14 -3
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +347 -24
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +163 -45
- package/src/ConversationListItem.tsx +19 -2
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +31 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +382 -0
- package/src/EmojiPicker.tsx +70 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +146 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +92 -16
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +75 -6
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +155 -19
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +34 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +106 -69
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1219 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +11 -7
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +129 -13
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +225 -17
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +175 -15
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +127 -4
- package/src/preview/index.ts +51 -3
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/preview.test.ts +5 -3
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +288 -1
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +185 -10
- package/src/quickReply.test.ts +58 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/types.ts +64 -1
- package/src/useWaitingNotifications.ts +74 -29
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-4R6Y43DQ.js +0 -726
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-C0PtaO7S.d.ts +0 -207
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Visão lado-cliente: você digita como se fosse o cliente
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Visão lado-cliente: você digita como se fosse o cliente e vê o bot responder. A mensagem sai pelo
|
|
3
|
+
* transporte real do canal — webhook assinado no WhatsApp, rota pública do widget no chat do site —,
|
|
4
|
+
* então o que roda aqui é o mesmo caminho de staging e produção: parser, motor de conversa, fluxo.
|
|
5
|
+
*
|
|
6
|
+
* O canal entra por `client` (a porta `ConversationSimulatorClient`), não por condicional aqui
|
|
7
|
+
* dentro: esta tela não sabe qual canal está simulando, e é o que permite o painel ficar no mesmo
|
|
8
|
+
* lugar da conversa para todos eles.
|
|
5
9
|
*
|
|
6
10
|
* É o layout de conversa de verdade (wallpaper, divisor de data, agrupamento de bolhas), não uma
|
|
7
11
|
* casca de teste: o preview serve para julgar copy e fluxo, e isso só funciona se o que se vê
|
|
@@ -13,26 +17,67 @@
|
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
16
|
-
import type { MessagePayload } from '../types'
|
|
20
|
+
import type { InteractiveSelection, MessagePayload } from '../types'
|
|
17
21
|
import type { SSEProvider } from '../providers/types'
|
|
18
22
|
import { MessageBubble } from '../MessageBubble'
|
|
19
23
|
import { MessageComposer } from '../MessageComposer'
|
|
20
24
|
import { DateDivider } from '../DateDivider'
|
|
21
25
|
import { ConversationWallpaper } from '../Wallpaper'
|
|
22
26
|
import type { PreviewWebhookClient } from './createPreviewWebhookClient'
|
|
27
|
+
import {
|
|
28
|
+
acceptsMediaKind,
|
|
29
|
+
isConversationSimulatorClient,
|
|
30
|
+
mediaKindOf,
|
|
31
|
+
SIMULATOR_FILE_MEDIA_KINDS,
|
|
32
|
+
toConversationSimulatorClient,
|
|
33
|
+
type ConversationSimulatorClient,
|
|
34
|
+
type SimulatorMediaKind,
|
|
35
|
+
} from './ConversationSimulatorClient'
|
|
36
|
+
import { AudioRecorderButton } from '../AudioRecorderButton'
|
|
23
37
|
|
|
24
38
|
export type ConversationPreviewProps = {
|
|
25
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Transporte do canal. `PreviewWebhookClient` continua aceito — é o caminho WhatsApp de antes
|
|
41
|
+
* desta porta, adaptado aqui dentro para não obrigar host nenhum a mudar de chamada.
|
|
42
|
+
*/
|
|
43
|
+
client: ConversationSimulatorClient | PreviewWebhookClient
|
|
26
44
|
sse: SSEProvider
|
|
27
45
|
conversationId: string
|
|
28
46
|
loadMessages: (conversationId: string) => Promise<MessagePayload[]>
|
|
29
47
|
placeholder?: string
|
|
48
|
+
/**
|
|
49
|
+
* Recarrega o transcript a cada N ms. Serve a host SEM stream: a resposta do bot é assíncrona, e
|
|
50
|
+
* sem SSE nem polling ela só apareceria no próximo envio — o sintoma é "às vezes ele não
|
|
51
|
+
* responde". Ausente, não faz polling (host com SSE não precisa).
|
|
52
|
+
*/
|
|
53
|
+
pollIntervalMs?: number
|
|
54
|
+
/**
|
|
55
|
+
* Destino alternativo do upload, no canal que sobe a mídia antes de citá-la (o caminho da Meta
|
|
56
|
+
* entrega mídia por `id`). Sem isto, usa o do próprio `client`. Canal que manda os bytes direto
|
|
57
|
+
* ignora esta prop: quem decide referência × bytes é o adaptador do canal.
|
|
58
|
+
*/
|
|
59
|
+
uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type PreviewUploadedMedia = {
|
|
63
|
+
readonly mediaId: string
|
|
64
|
+
readonly mimeType?: string
|
|
65
|
+
readonly filename?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @deprecated Use `mediaKindOf`, que não nomeia canal. Mantido para quem já importa. */
|
|
69
|
+
export function mediaTypeOf(mimeType: string): SimulatorMediaKind {
|
|
70
|
+
return mediaKindOf(mimeType)
|
|
30
71
|
}
|
|
31
72
|
|
|
32
73
|
// Mesma janela usada pelo WhatsApp para colar bolhas do mesmo autor: acima disso, a mensagem
|
|
33
74
|
// recomeça um grupo (com rabicho e espaçamento maior).
|
|
34
75
|
const GROUPING_WINDOW_MS = 5 * 60 * 1000
|
|
35
76
|
|
|
77
|
+
// Escalonado, não um atraso fixo: a maioria das respostas chega em ~300ms, mas fluxo que consulta
|
|
78
|
+
// catálogo ou IA demora mais — e recarregar três vezes é mais barato que a conversa parecer morta.
|
|
79
|
+
const FOLLOW_UP_REFRESH_MS = [400, 1200, 3000]
|
|
80
|
+
|
|
36
81
|
type RenderedMessage = {
|
|
37
82
|
readonly message: MessagePayload
|
|
38
83
|
readonly isFirstInGroup: boolean
|
|
@@ -55,26 +100,88 @@ function decorate(messages: readonly MessagePayload[]): RenderedMessage[] {
|
|
|
55
100
|
})
|
|
56
101
|
}
|
|
57
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Status HTTP do erro, quando o host o preserva.
|
|
105
|
+
*
|
|
106
|
+
* O contrato não exige um tipo de erro — cada host tem o seu —, então a leitura é estrutural: basta
|
|
107
|
+
* carregar `status` (ou `statusCode`) para ser classificável. Host que joga `new Error(texto)` cai no
|
|
108
|
+
* caminho genérico, que ainda é melhor que silêncio.
|
|
109
|
+
*/
|
|
110
|
+
function statusOf(error: unknown): number | undefined {
|
|
111
|
+
if (typeof error !== 'object' || error === null) return undefined
|
|
112
|
+
const candidate = error as { status?: unknown; statusCode?: unknown }
|
|
113
|
+
const value = candidate.status ?? candidate.statusCode
|
|
114
|
+
return typeof value === 'number' ? value : undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isNotFound(error: unknown): boolean {
|
|
118
|
+
return statusOf(error) === 404
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function describeLoadFailure(error: unknown): string {
|
|
122
|
+
const status = statusOf(error)
|
|
123
|
+
// 401/403 no simulador quase sempre é a aba sem sessão: `sessionStorage` é por aba, e link com
|
|
124
|
+
// `rel="noreferrer"` abre contexto novo que não herda o token do painel.
|
|
125
|
+
if (status === 401 || status === 403) {
|
|
126
|
+
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.'
|
|
127
|
+
}
|
|
128
|
+
if (error instanceof Error && error.message) return `Não foi possível ler o transcript: ${error.message}`
|
|
129
|
+
return 'Não foi possível ler o transcript da conversa.'
|
|
130
|
+
}
|
|
131
|
+
|
|
58
132
|
export function ConversationPreview({
|
|
59
133
|
client,
|
|
60
134
|
sse,
|
|
61
135
|
conversationId,
|
|
62
136
|
loadMessages,
|
|
63
137
|
placeholder,
|
|
138
|
+
pollIntervalMs,
|
|
139
|
+
uploadMedia,
|
|
64
140
|
}: ConversationPreviewProps) {
|
|
65
141
|
const [messages, setMessages] = useState<MessagePayload[]>([])
|
|
66
142
|
const [failure, setFailure] = useState<string | undefined>(undefined)
|
|
143
|
+
const [loadFailure, setLoadFailure] = useState<string | undefined>(undefined)
|
|
144
|
+
const [isRecording, setIsRecording] = useState(false)
|
|
145
|
+
// Mensagens que o servidor ainda não devolveu. Sem isto, quem não consegue LER a conversa (sessão
|
|
146
|
+
// ausente, API fora) digita, envia com sucesso e não vê absolutamente nada mudar — o preview fica
|
|
147
|
+
// indistinguível de quebrado. São descartadas assim que uma leitura dá certo: aí quem manda na
|
|
148
|
+
// tela é o servidor, que já tem a mensagem gravada.
|
|
149
|
+
const [pendingLocal, setPendingLocal] = useState<MessagePayload[]>([])
|
|
67
150
|
const loadMessagesRef = useRef(loadMessages)
|
|
68
151
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
69
152
|
loadMessagesRef.current = loadMessages
|
|
70
153
|
|
|
154
|
+
const simulator = useMemo(
|
|
155
|
+
() =>
|
|
156
|
+
isConversationSimulatorClient(client)
|
|
157
|
+
? client
|
|
158
|
+
: toConversationSimulatorClient({ client, ...(uploadMedia ? { uploadMedia } : {}) }),
|
|
159
|
+
[client, uploadMedia],
|
|
160
|
+
)
|
|
161
|
+
|
|
71
162
|
const refresh = useCallback(async (): Promise<void> => {
|
|
72
163
|
try {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
|
|
164
|
+
const loaded = await loadMessagesRef.current(conversationId)
|
|
165
|
+
setMessages(loaded)
|
|
166
|
+
setLoadFailure(undefined)
|
|
167
|
+
// Só descarta o eco quando o servidor de fato devolveu conversa: lista vazia é "não consegui
|
|
168
|
+
// ver nada" (sessão ausente, primeiro contato), e limpar aí apagava da tela a mensagem que o
|
|
169
|
+
// usuário acabou de mandar — o sintoma que este eco existe para evitar.
|
|
170
|
+
if (loaded.length > 0) setPendingLocal([])
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// Conversa que ainda não existe é o estado normal do primeiro contato — transcript vazio, sem
|
|
173
|
+
// alarme. QUALQUER outra falha precisa aparecer: engolir todas era o que transformava sessão
|
|
174
|
+
// expirada (401) em silêncio absoluto, com a thread limpa e o operador concluindo que o envio
|
|
175
|
+
// não funcionou — quando a mensagem tinha sido entregue no webhook.
|
|
176
|
+
if (isNotFound(error)) {
|
|
177
|
+
setMessages([])
|
|
178
|
+
setLoadFailure(undefined)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Não limpa o que já está na tela: perder o histórico por causa de um refresh que falhou é
|
|
183
|
+
// dano maior que o próprio erro.
|
|
184
|
+
setLoadFailure(describeLoadFailure(error))
|
|
78
185
|
}
|
|
79
186
|
}, [conversationId])
|
|
80
187
|
|
|
@@ -96,18 +203,49 @@ export function ConversationPreview({
|
|
|
96
203
|
}, [sse, conversationId, refresh])
|
|
97
204
|
|
|
98
205
|
useEffect(() => {
|
|
99
|
-
|
|
206
|
+
if (!pollIntervalMs) return
|
|
207
|
+
const timer = setInterval(() => void refresh(), pollIntervalMs)
|
|
208
|
+
return () => clearInterval(timer)
|
|
209
|
+
}, [pollIntervalMs, refresh])
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
// `block: 'nearest'` e não o padrão ('start'): o padrão alinha o elemento ao topo da área
|
|
213
|
+
// visível MAIS PRÓXIMA que role — e quando o container do preview não tem altura limitada, essa
|
|
214
|
+
// área é a PÁGINA. O efeito era a tela inteira saltar para baixo ao abrir/usar o simulador.
|
|
215
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
100
216
|
}, [messages])
|
|
101
217
|
|
|
102
|
-
const rendered = useMemo(() => decorate(messages), [messages])
|
|
218
|
+
const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal])
|
|
219
|
+
|
|
220
|
+
async function refreshWithFollowUps(): Promise<void> {
|
|
221
|
+
await refresh()
|
|
222
|
+
for (const atraso of FOLLOW_UP_REFRESH_MS) {
|
|
223
|
+
setTimeout(() => void refresh(), atraso)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
103
226
|
|
|
104
227
|
async function handleSend(text: string): Promise<void> {
|
|
105
228
|
setFailure(undefined)
|
|
106
229
|
try {
|
|
107
|
-
await
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
230
|
+
await simulator.sendText(text)
|
|
231
|
+
setPendingLocal((current) => [
|
|
232
|
+
...current,
|
|
233
|
+
{
|
|
234
|
+
id: `local-${current.length}-${text.length}`,
|
|
235
|
+
type: 'text',
|
|
236
|
+
content: text,
|
|
237
|
+
// Do ponto de vista do servidor, mensagem do cliente é inbound — é assim que ela aparece
|
|
238
|
+
// como "minha" nesta visão.
|
|
239
|
+
direction: 'inbound',
|
|
240
|
+
sender: 'customer',
|
|
241
|
+
timestamp: new Date().toISOString(),
|
|
242
|
+
status: 'sent',
|
|
243
|
+
},
|
|
244
|
+
])
|
|
245
|
+
// O bot responde de forma assíncrona: gravar a resposta leva algumas centenas de ms depois do
|
|
246
|
+
// 200 do webhook. Um refresh único aqui frequentemente chegava ANTES dela, e a conversa ficava
|
|
247
|
+
// com a pergunta sem resposta até o envio seguinte.
|
|
248
|
+
await refreshWithFollowUps()
|
|
111
249
|
} catch (error) {
|
|
112
250
|
// A recusa mais provável é assinatura inválida (segredo divergente do que a API valida), e
|
|
113
251
|
// ela precisa aparecer na tela: silenciada, o sintoma vira "mandei e não aconteceu nada".
|
|
@@ -115,6 +253,47 @@ export function ConversationPreview({
|
|
|
115
253
|
}
|
|
116
254
|
}
|
|
117
255
|
|
|
256
|
+
async function handleInteractiveSelect(selection: InteractiveSelection): Promise<void> {
|
|
257
|
+
setFailure(undefined)
|
|
258
|
+
try {
|
|
259
|
+
// A seleção viaja inteira (botão × lista) porque a forma de fio é do canal: a Meta separa
|
|
260
|
+
// `button_reply` de `list_reply` e o roteador do fluxo lê campos distintos, enquanto o chat do
|
|
261
|
+
// site manda o rótulo como texto. Decidir isso aqui faria o menu responder no simulador e
|
|
262
|
+
// falhar no aparelho do cliente.
|
|
263
|
+
await simulator.sendReply(selection)
|
|
264
|
+
await refreshWithFollowUps()
|
|
265
|
+
} catch (error) {
|
|
266
|
+
setFailure(error instanceof Error ? error.message : 'Falha ao entregar a resposta no webhook.')
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* O transporte do canal diz se aceita mídia do cliente; a tela só pergunta.
|
|
272
|
+
*
|
|
273
|
+
* Antes isto era `uploadMedia` puro, e o microfone só aparecia no produto que lembrasse de montar
|
|
274
|
+
* o upload — de onde veio a divergência entre dois simuladores da mesma casa. Agora quem responde
|
|
275
|
+
* é o adaptador: no WhatsApp ele precisa de um destino de upload, no chat do site manda os bytes.
|
|
276
|
+
*/
|
|
277
|
+
const sendMedia = simulator.sendMedia
|
|
278
|
+
const canAttachFile = SIMULATOR_FILE_MEDIA_KINDS.some((kind) => acceptsMediaKind(simulator, kind))
|
|
279
|
+
const canRecordAudio = acceptsMediaKind(simulator, 'audio')
|
|
280
|
+
|
|
281
|
+
async function handleAttach(file: File): Promise<void> {
|
|
282
|
+
if (!sendMedia) return
|
|
283
|
+
setFailure(undefined)
|
|
284
|
+
try {
|
|
285
|
+
await sendMedia({
|
|
286
|
+
mediaKind: mediaKindOf(file.type),
|
|
287
|
+
file,
|
|
288
|
+
mimeType: file.type,
|
|
289
|
+
filename: file.name,
|
|
290
|
+
})
|
|
291
|
+
await refreshWithFollowUps()
|
|
292
|
+
} catch (error) {
|
|
293
|
+
setFailure(error instanceof Error ? error.message : 'Falha ao enviar o arquivo.')
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
118
297
|
return (
|
|
119
298
|
<div className="flex h-full min-h-0 flex-col">
|
|
120
299
|
<ConversationWallpaper className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
|
|
@@ -127,6 +306,11 @@ export function ConversationPreview({
|
|
|
127
306
|
// vista do servidor.
|
|
128
307
|
isMine={message.direction === 'inbound'}
|
|
129
308
|
isFirstInGroup={isFirstInGroup}
|
|
309
|
+
// Só o que o bot ofereceu é tocável: reoferecer as opções da própria mensagem do
|
|
310
|
+
// cliente deixaria o menu clicável para sempre, o que o WhatsApp não faz.
|
|
311
|
+
onInteractiveSelect={
|
|
312
|
+
message.direction === 'outbound' ? (selection) => void handleInteractiveSelect(selection) : undefined
|
|
313
|
+
}
|
|
130
314
|
/>
|
|
131
315
|
</div>
|
|
132
316
|
))}
|
|
@@ -139,9 +323,33 @@ export function ConversationPreview({
|
|
|
139
323
|
</p>
|
|
140
324
|
) : null}
|
|
141
325
|
|
|
326
|
+
{/* Separado da falha de ENVIO: são causas diferentes e confundi-las manda o operador
|
|
327
|
+
investigar assinatura de webhook quando o problema é sessão. Amarelo, não vermelho — a
|
|
328
|
+
mensagem foi entregue; o que faltou foi poder ler a conversa de volta. */}
|
|
329
|
+
{loadFailure ? (
|
|
330
|
+
<p role="status" className="px-4 py-2 text-sm text-amber-700 dark:text-amber-400">
|
|
331
|
+
{loadFailure}
|
|
332
|
+
</p>
|
|
333
|
+
) : null}
|
|
334
|
+
|
|
142
335
|
<MessageComposer
|
|
143
336
|
onSend={(text) => void handleSend(text)}
|
|
144
|
-
|
|
337
|
+
onAttach={canAttachFile ? (file) => void handleAttach(file) : undefined}
|
|
338
|
+
/* Gravando, o campo diz o que falta fazer: o botão é um interruptor e o segundo toque é
|
|
339
|
+
que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
|
|
340
|
+
microfone está quebrado. */
|
|
341
|
+
placeholder={
|
|
342
|
+
isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
|
|
343
|
+
}
|
|
344
|
+
idleAction={
|
|
345
|
+
canRecordAudio ? (
|
|
346
|
+
<AudioRecorderButton
|
|
347
|
+
onRecorded={(file) => void handleAttach(file)}
|
|
348
|
+
onFailure={(message) => setFailure(message)}
|
|
349
|
+
onRecordingChange={setIsRecording}
|
|
350
|
+
/>
|
|
351
|
+
) : undefined
|
|
352
|
+
}
|
|
145
353
|
/>
|
|
146
354
|
</div>
|
|
147
355
|
)
|
|
@@ -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
|
+
}
|
|
@@ -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,131 @@
|
|
|
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
|
+
* 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 é.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ReactNode } from 'react'
|
|
30
|
+
|
|
31
|
+
import { CONVERSATION_CHANNEL, DEFAULT_CONVERSATION_CHANNEL, type ConversationChannel } from '../conversationChannel'
|
|
32
|
+
import { ConversationPreview, type ConversationPreviewProps } from './ConversationPreview'
|
|
33
|
+
|
|
34
|
+
export type ConversationSimulatorPanelLabels = {
|
|
35
|
+
readonly title: string
|
|
36
|
+
/** Complementa o identificador no subtítulo, explicando para onde a mensagem realmente vai. */
|
|
37
|
+
readonly destinationHint: string
|
|
38
|
+
readonly close: string
|
|
39
|
+
readonly placeholder: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS: ConversationSimulatorPanelLabels = {
|
|
43
|
+
title: 'Simulador do cliente',
|
|
44
|
+
destinationHint: 'entrega no webhook real',
|
|
45
|
+
close: 'Fechar simulador',
|
|
46
|
+
placeholder: 'Escreva como o cliente…',
|
|
47
|
+
}
|
|
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
|
+
|
|
79
|
+
export type ConversationSimulatorPanelProps = Omit<ConversationPreviewProps, 'placeholder'> & {
|
|
80
|
+
readonly onClose: () => void
|
|
81
|
+
/** Ausente = WhatsApp, que era o único canal antes desta prop existir. */
|
|
82
|
+
readonly channel?: ConversationChannel
|
|
83
|
+
/**
|
|
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
|
|
86
|
+
* regional, e o pacote não tem como saber a do produto.
|
|
87
|
+
*/
|
|
88
|
+
readonly displayHandle?: string
|
|
89
|
+
/** @deprecated Use `displayHandle` — o simulador deixou de ser só telefone. */
|
|
90
|
+
readonly displayNumber?: string
|
|
91
|
+
readonly labels?: Partial<ConversationSimulatorPanelLabels>
|
|
92
|
+
/** Ações extras no cabeçalho — roteiro automático, limpar conversa, trocar de contato. */
|
|
93
|
+
readonly headerActions?: ReactNode
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function ConversationSimulatorPanel({
|
|
97
|
+
onClose,
|
|
98
|
+
channel,
|
|
99
|
+
displayHandle,
|
|
100
|
+
displayNumber,
|
|
101
|
+
labels,
|
|
102
|
+
headerActions,
|
|
103
|
+
...previewProps
|
|
104
|
+
}: ConversationSimulatorPanelProps) {
|
|
105
|
+
const text = { ...simulatorPanelLabelsOf(channel), ...labels }
|
|
106
|
+
const subtitle = [displayHandle ?? displayNumber ?? previewProps.conversationId, text.destinationHint].join(' · ')
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<aside className="cv-simulator-panel" aria-label={text.title}>
|
|
110
|
+
<header className="cv-simulator-panel__header">
|
|
111
|
+
<div className="cv-simulator-panel__heading">
|
|
112
|
+
<h2 className="cv-simulator-panel__title">{text.title}</h2>
|
|
113
|
+
<p className="cv-simulator-panel__subtitle">{subtitle}</p>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="cv-simulator-panel__actions">
|
|
116
|
+
{headerActions}
|
|
117
|
+
<button type="button" onClick={onClose} data-cv-tooltip={text.close} aria-label={text.close} className="cv-simulator-panel__close">
|
|
118
|
+
{/* SVG inline em vez de lucide-react: o pacote não impõe biblioteca de ícone ao host. */}
|
|
119
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
120
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
121
|
+
</svg>
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</header>
|
|
125
|
+
|
|
126
|
+
<div className="cv-simulator-panel__body">
|
|
127
|
+
<ConversationPreview {...previewProps} placeholder={text.placeholder} />
|
|
128
|
+
</div>
|
|
129
|
+
</aside>
|
|
130
|
+
)
|
|
131
|
+
}
|