@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.
@@ -1,14 +1,22 @@
1
1
  import {
2
2
  AudioRecorderButton,
3
3
  ConversationDocumentsPanel,
4
+ ConversationPreview,
5
+ ConversationSimulatorPanel,
4
6
  ConversationWallpaper,
5
7
  ConversationsProvider,
6
8
  DEFAULT_AUDIO_RECORDER_BUTTON_LABELS,
7
- DateDivider,
9
+ DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS,
8
10
  DocumentsLibrary,
9
11
  MessageBubble,
10
- MessageComposer
11
- } from "../chunk-CUYYYZWD.js";
12
+ SIMULATOR_FILE_MEDIA_KINDS,
13
+ acceptsMediaKind,
14
+ isConversationSimulatorClient,
15
+ mediaKindOf,
16
+ mediaTypeOf,
17
+ simulatorPanelLabelsOf,
18
+ toConversationSimulatorClient
19
+ } from "../chunk-BJNRLLDO.js";
12
20
  import "../chunk-WCBDXZ3X.js";
13
21
 
14
22
  // src/preview/previewStore.ts
@@ -846,226 +854,6 @@ function createMockSSEProvider(params) {
846
854
  };
847
855
  }
848
856
 
849
- // src/preview/ConversationPreview.tsx
850
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
851
- import { jsx, jsxs } from "react/jsx-runtime";
852
- function mediaTypeOf(mimeType) {
853
- if (mimeType.startsWith("image/")) return "image";
854
- if (mimeType.startsWith("video/")) return "video";
855
- if (mimeType.startsWith("audio/")) return "audio";
856
- return "document";
857
- }
858
- var GROUPING_WINDOW_MS = 5 * 60 * 1e3;
859
- var FOLLOW_UP_REFRESH_MS = [400, 1200, 3e3];
860
- function decorate(messages) {
861
- return messages.map((message, index) => {
862
- const previous = index > 0 ? messages[index - 1] : void 0;
863
- const currentTime = new Date(message.timestamp).getTime();
864
- const previousTime = previous ? new Date(previous.timestamp).getTime() : 0;
865
- return {
866
- message,
867
- isFirstInGroup: !previous || previous.sender !== message.sender || currentTime - previousTime > GROUPING_WINDOW_MS,
868
- showDateDivider: !previous || new Date(message.timestamp).toDateString() !== new Date(previous.timestamp).toDateString()
869
- };
870
- });
871
- }
872
- function statusOf(error) {
873
- if (typeof error !== "object" || error === null) return void 0;
874
- const candidate = error;
875
- const value = candidate.status ?? candidate.statusCode;
876
- return typeof value === "number" ? value : void 0;
877
- }
878
- function isNotFound(error) {
879
- return statusOf(error) === 404;
880
- }
881
- function describeLoadFailure(error) {
882
- const status = statusOf(error);
883
- if (status === 401 || status === 403) {
884
- return "Sem sess\xE3o de administrador nesta aba: a mensagem \xE9 entregue no webhook, mas o transcript n\xE3o pode ser lido. Entre no painel nesta mesma aba e reabra o simulador.";
885
- }
886
- if (error instanceof Error && error.message) return `N\xE3o foi poss\xEDvel ler o transcript: ${error.message}`;
887
- return "N\xE3o foi poss\xEDvel ler o transcript da conversa.";
888
- }
889
- function ConversationPreview({
890
- client,
891
- sse,
892
- conversationId,
893
- loadMessages,
894
- placeholder,
895
- pollIntervalMs,
896
- uploadMedia
897
- }) {
898
- const [messages, setMessages] = useState([]);
899
- const [failure, setFailure] = useState(void 0);
900
- const [loadFailure, setLoadFailure] = useState(void 0);
901
- const [isRecording, setIsRecording] = useState(false);
902
- const [pendingLocal, setPendingLocal] = useState([]);
903
- const loadMessagesRef = useRef(loadMessages);
904
- const bottomRef = useRef(null);
905
- loadMessagesRef.current = loadMessages;
906
- const refresh = useCallback(async () => {
907
- try {
908
- const loaded = await loadMessagesRef.current(conversationId);
909
- setMessages(loaded);
910
- setLoadFailure(void 0);
911
- if (loaded.length > 0) setPendingLocal([]);
912
- } catch (error) {
913
- if (isNotFound(error)) {
914
- setMessages([]);
915
- setLoadFailure(void 0);
916
- return;
917
- }
918
- setLoadFailure(describeLoadFailure(error));
919
- }
920
- }, [conversationId]);
921
- useEffect(() => {
922
- void refresh();
923
- }, [refresh]);
924
- useEffect(() => {
925
- const source = sse.connectConversationStream(conversationId);
926
- const handler = () => {
927
- void refresh();
928
- };
929
- source.addEventListener("message", handler);
930
- return () => {
931
- source.removeEventListener("message", handler);
932
- source.close();
933
- };
934
- }, [sse, conversationId, refresh]);
935
- useEffect(() => {
936
- if (!pollIntervalMs) return;
937
- const timer = setInterval(() => void refresh(), pollIntervalMs);
938
- return () => clearInterval(timer);
939
- }, [pollIntervalMs, refresh]);
940
- useEffect(() => {
941
- bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
942
- }, [messages]);
943
- const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal]);
944
- async function refreshWithFollowUps() {
945
- await refresh();
946
- for (const atraso of FOLLOW_UP_REFRESH_MS) {
947
- setTimeout(() => void refresh(), atraso);
948
- }
949
- }
950
- async function handleSend(text) {
951
- setFailure(void 0);
952
- try {
953
- await client.sendText(text);
954
- setPendingLocal((current) => [
955
- ...current,
956
- {
957
- id: `local-${current.length}-${text.length}`,
958
- type: "text",
959
- content: text,
960
- // Do ponto de vista do servidor, mensagem do cliente é inbound — é assim que ela aparece
961
- // como "minha" nesta visão.
962
- direction: "inbound",
963
- sender: "customer",
964
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
965
- status: "sent"
966
- }
967
- ]);
968
- await refreshWithFollowUps();
969
- } catch (error) {
970
- setFailure(error instanceof Error ? error.message : "Falha ao entregar a mensagem no webhook.");
971
- }
972
- }
973
- async function handleInteractiveSelect(selection) {
974
- setFailure(void 0);
975
- const reply = { id: selection.option.id, title: selection.option.title };
976
- try {
977
- await (selection.kind === "button" ? client.sendButtonReply(reply) : client.sendListReply(reply));
978
- await refreshWithFollowUps();
979
- } catch (error) {
980
- setFailure(error instanceof Error ? error.message : "Falha ao entregar a resposta no webhook.");
981
- }
982
- }
983
- const uploadFile = uploadMedia ?? client.uploadMedia;
984
- async function handleAttach(file) {
985
- if (!uploadFile) return;
986
- setFailure(void 0);
987
- try {
988
- const uploaded = await uploadFile(file);
989
- await client.sendMedia({
990
- mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
991
- mediaId: uploaded.mediaId,
992
- mimeType: uploaded.mimeType ?? file.type,
993
- filename: uploaded.filename ?? file.name
994
- });
995
- await refreshWithFollowUps();
996
- } catch (error) {
997
- setFailure(error instanceof Error ? error.message : "Falha ao enviar o arquivo.");
998
- }
999
- }
1000
- return /* @__PURE__ */ jsxs("div", { className: "flex h-full min-h-0 flex-col", children: [
1001
- /* @__PURE__ */ jsxs(ConversationWallpaper, { className: "flex-1 min-h-0 overflow-y-auto px-4 py-3", children: [
1002
- rendered.map(({ message, isFirstInGroup, showDateDivider }) => /* @__PURE__ */ jsxs("div", { children: [
1003
- showDateDivider ? /* @__PURE__ */ jsx(DateDivider, { iso: message.timestamp }) : null,
1004
- /* @__PURE__ */ jsx(
1005
- MessageBubble,
1006
- {
1007
- message,
1008
- isMine: message.direction === "inbound",
1009
- isFirstInGroup,
1010
- onInteractiveSelect: message.direction === "outbound" ? (selection) => void handleInteractiveSelect(selection) : void 0
1011
- }
1012
- )
1013
- ] }, message.id)),
1014
- /* @__PURE__ */ jsx("div", { ref: bottomRef })
1015
- ] }),
1016
- failure ? /* @__PURE__ */ jsx("p", { role: "alert", className: "px-4 py-2 text-sm text-red-600 dark:text-red-400", children: failure }) : null,
1017
- loadFailure ? /* @__PURE__ */ jsx("p", { role: "status", className: "px-4 py-2 text-sm text-amber-700 dark:text-amber-400", children: loadFailure }) : null,
1018
- /* @__PURE__ */ jsx(
1019
- MessageComposer,
1020
- {
1021
- onSend: (text) => void handleSend(text),
1022
- onAttach: uploadFile ? (file) => void handleAttach(file) : void 0,
1023
- placeholder: isRecording ? "Gravando\u2026 toque no quadrado para ouvir" : placeholder ?? "Escreva como o cliente\u2026",
1024
- idleAction: uploadFile ? /* @__PURE__ */ jsx(
1025
- AudioRecorderButton,
1026
- {
1027
- onRecorded: (file) => void handleAttach(file),
1028
- onFailure: (message) => setFailure(message),
1029
- onRecordingChange: setIsRecording
1030
- }
1031
- ) : void 0
1032
- }
1033
- )
1034
- ] });
1035
- }
1036
-
1037
- // src/preview/ConversationSimulatorPanel.tsx
1038
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1039
- var DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS = {
1040
- title: "Simulador do cliente",
1041
- destinationHint: "entrega no webhook real",
1042
- close: "Fechar simulador",
1043
- placeholder: "Escreva como o cliente\u2026"
1044
- };
1045
- function ConversationSimulatorPanel({
1046
- onClose,
1047
- displayNumber,
1048
- labels,
1049
- headerActions,
1050
- ...previewProps
1051
- }) {
1052
- const text = { ...DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, ...labels };
1053
- const subtitle = [displayNumber ?? previewProps.conversationId, text.destinationHint].join(" \xB7 ");
1054
- return /* @__PURE__ */ jsxs2("aside", { className: "cv-simulator-panel", "aria-label": text.title, children: [
1055
- /* @__PURE__ */ jsxs2("header", { className: "cv-simulator-panel__header", children: [
1056
- /* @__PURE__ */ jsxs2("div", { className: "cv-simulator-panel__heading", children: [
1057
- /* @__PURE__ */ jsx2("h2", { className: "cv-simulator-panel__title", children: text.title }),
1058
- /* @__PURE__ */ jsx2("p", { className: "cv-simulator-panel__subtitle", children: subtitle })
1059
- ] }),
1060
- /* @__PURE__ */ jsxs2("div", { className: "cv-simulator-panel__actions", children: [
1061
- headerActions,
1062
- /* @__PURE__ */ jsx2("button", { type: "button", onClick: onClose, "data-cv-tooltip": text.close, "aria-label": text.close, className: "cv-simulator-panel__close", children: /* @__PURE__ */ jsx2("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M18 6 6 18M6 6l12 12" }) }) })
1063
- ] })
1064
- ] }),
1065
- /* @__PURE__ */ jsx2("div", { className: "cv-simulator-panel__body", children: /* @__PURE__ */ jsx2(ConversationPreview, { ...previewProps, placeholder: text.placeholder }) })
1066
- ] });
1067
- }
1068
-
1069
857
  // src/preview/createPreviewWebhookClient.ts
1070
858
  import {
1071
859
  buildInboundAudioPayload,
@@ -1275,42 +1063,42 @@ function startPreviewScript(params) {
1275
1063
  }
1276
1064
 
1277
1065
  // src/preview/MediaTypesPreview.tsx
1278
- import { useMemo as useMemo2 } from "react";
1279
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1066
+ import { useMemo } from "react";
1067
+ import { jsx, jsxs } from "react/jsx-runtime";
1280
1068
  var MEDIA_TYPES_CONVERSATION_ID = "5511944443333";
1281
1069
  function MediaTypesPreview({
1282
1070
  conversationId = MEDIA_TYPES_CONVERSATION_ID,
1283
1071
  className
1284
1072
  }) {
1285
- const store = useMemo2(
1073
+ const store = useMemo(
1286
1074
  () => createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES }),
1287
1075
  []
1288
1076
  );
1289
- const api = useMemo2(() => createMockConversationsApi({ store }), [store]);
1290
- const sse = useMemo2(() => createMockSSEProvider({ store }), [store]);
1077
+ const api = useMemo(() => createMockConversationsApi({ store }), [store]);
1078
+ const sse = useMemo(() => createMockSSEProvider({ store }), [store]);
1291
1079
  const messages = PREVIEW_MESSAGES[conversationId] ?? [];
1292
1080
  const documents = PREVIEW_DOCUMENTS[conversationId] ?? [];
1293
1081
  const mimeTypes = [...new Set(documents.map((document) => document.mimeType))];
1294
- return /* @__PURE__ */ jsx3(ConversationsProvider, { api, sse, children: /* @__PURE__ */ jsxs3("div", { className, children: [
1295
- /* @__PURE__ */ jsxs3("header", { className: "border-b px-4 py-3 dark:border-gray-700", children: [
1296
- /* @__PURE__ */ jsx3("h1", { className: "text-lg font-semibold", children: "Teste manual de m\xEDdia" }),
1297
- /* @__PURE__ */ jsxs3("p", { className: "text-sm text-gray-500", children: [
1082
+ return /* @__PURE__ */ jsx(ConversationsProvider, { api, sse, children: /* @__PURE__ */ jsxs("div", { className, children: [
1083
+ /* @__PURE__ */ jsxs("header", { className: "border-b px-4 py-3 dark:border-gray-700", children: [
1084
+ /* @__PURE__ */ jsx("h1", { className: "text-lg font-semibold", children: "Teste manual de m\xEDdia" }),
1085
+ /* @__PURE__ */ jsxs("p", { className: "text-sm text-gray-500", children: [
1298
1086
  documents.length,
1299
1087
  " arquivos, ",
1300
1088
  mimeTypes.length,
1301
1089
  " tipos. Clique no olho para abrir em aba nova e no bot\xE3o da bolha para carregar a m\xEDdia na thread \u2014 \xE9 o que teste automatizado n\xE3o v\xEA."
1302
1090
  ] })
1303
1091
  ] }),
1304
- /* @__PURE__ */ jsxs3("div", { className: "grid gap-4 p-4 lg:grid-cols-2", children: [
1305
- /* @__PURE__ */ jsxs3("section", { className: "space-y-3", children: [
1306
- /* @__PURE__ */ jsx3("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Biblioteca da empresa" }),
1307
- /* @__PURE__ */ jsx3(DocumentsLibrary, { perPage: documents.length || 20 })
1092
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-4 p-4 lg:grid-cols-2", children: [
1093
+ /* @__PURE__ */ jsxs("section", { className: "space-y-3", children: [
1094
+ /* @__PURE__ */ jsx("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Biblioteca da empresa" }),
1095
+ /* @__PURE__ */ jsx(DocumentsLibrary, { perPage: documents.length || 20 })
1308
1096
  ] }),
1309
- /* @__PURE__ */ jsxs3("section", { className: "space-y-3", children: [
1310
- /* @__PURE__ */ jsx3("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Painel da conversa" }),
1311
- /* @__PURE__ */ jsx3(ConversationDocumentsPanel, { conversationId, open: true, perPage: documents.length || 20 }),
1312
- /* @__PURE__ */ jsx3("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Bolhas na thread" }),
1313
- /* @__PURE__ */ jsx3(ConversationWallpaper, { className: "max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2", children: messages.map((message, index) => /* @__PURE__ */ jsx3(
1097
+ /* @__PURE__ */ jsxs("section", { className: "space-y-3", children: [
1098
+ /* @__PURE__ */ jsx("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Painel da conversa" }),
1099
+ /* @__PURE__ */ jsx(ConversationDocumentsPanel, { conversationId, open: true, perPage: documents.length || 20 }),
1100
+ /* @__PURE__ */ jsx("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Bolhas na thread" }),
1101
+ /* @__PURE__ */ jsx(ConversationWallpaper, { className: "max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2", children: messages.map((message, index) => /* @__PURE__ */ jsx(
1314
1102
  MessageBubble,
1315
1103
  {
1316
1104
  message,
@@ -1343,6 +1131,8 @@ export {
1343
1131
  PreviewInProductionError,
1344
1132
  PreviewMediaUploadRejectedError,
1345
1133
  PreviewWebhookRejectedError,
1134
+ SIMULATOR_FILE_MEDIA_KINDS,
1135
+ acceptsMediaKind,
1346
1136
  assertPreviewEnvironment,
1347
1137
  conversationChannel,
1348
1138
  createMockConversationsApi,
@@ -1354,10 +1144,14 @@ export {
1354
1144
  createPreviewMediaUploader,
1355
1145
  createPreviewStore,
1356
1146
  createPreviewWebhookClient,
1147
+ isConversationSimulatorClient,
1148
+ mediaKindOf,
1357
1149
  mediaTypeOf,
1358
1150
  previewFileBase64,
1359
1151
  previewFileUrl,
1360
1152
  resolvePreviewFileSample,
1361
1153
  signPreviewPayload,
1362
- startPreviewScript
1154
+ simulatorPanelLabelsOf,
1155
+ startPreviewScript,
1156
+ toConversationSimulatorClient
1363
1157
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.34",
3
+ "version": "0.1.0-rc.35",
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
@@ -251,9 +251,21 @@ export { useConversationsInbox, CONVERSATIONS_PER_PAGE, DEFAULT_CONVERSATIONS_WO
251
251
  export type {
252
252
  ConversationsWorkspaceProps,
253
253
  ConversationsWorkspaceSimulator,
254
+ SimulatorTransportFactory,
255
+ SimulatorTransportParams,
254
256
  ConversationsWorkspaceLabels,
255
257
  ConversationPaneProps,
256
258
  ConversationsInboxListProps,
257
259
  UseConversationsInboxParams,
258
260
  UseConversationsInboxResult,
259
261
  } from './workspace'
262
+
263
+ // A porta do simulador entra no export principal como TIPO: `simulator.transports` é tipado por ela,
264
+ // e obrigar o host a abrir o subpath `/preview` só para declarar a fábrica seria pedir que ele
265
+ // importasse fixtures para escrever uma assinatura. `export type` é apagado no build — o `preview/`
266
+ // continua fora do bundle de quem só declara o transporte.
267
+ export type {
268
+ ConversationSimulatorClient,
269
+ SendSimulatorMediaParams,
270
+ SimulatorMediaKind,
271
+ } from './preview/ConversationSimulatorClient'
@@ -1,7 +1,11 @@
1
1
  /**
2
- * Visão lado-cliente: você digita como se fosse o cliente no WhatsApp e vê o bot responder. A
3
- * mensagem sai assinada para o webhook real, então o que roda aqui é o mesmo caminho de staging e
4
- * produção — webhook, parser, motor de conversa.
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ê
@@ -19,11 +23,24 @@ import { MessageBubble } from '../MessageBubble'
19
23
  import { MessageComposer } from '../MessageComposer'
20
24
  import { DateDivider } from '../DateDivider'
21
25
  import { ConversationWallpaper } from '../Wallpaper'
22
- import type { PreviewWebhookClient, SendPreviewMediaParams } from './createPreviewWebhookClient'
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'
23
36
  import { AudioRecorderButton } from '../AudioRecorderButton'
24
37
 
25
38
  export type ConversationPreviewProps = {
26
- client: PreviewWebhookClient
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
27
44
  sse: SSEProvider
28
45
  conversationId: string
29
46
  loadMessages: (conversationId: string) => Promise<MessagePayload[]>
@@ -35,12 +52,10 @@ export type ConversationPreviewProps = {
35
52
  */
36
53
  pollIntervalMs?: number
37
54
  /**
38
- * Como transformar um arquivo do disco (ou o áudio gravado) na referência que o webhook carrega.
39
- * O caminho da Meta entrega mídia por `id`, e quem sabe hospedar o arquivo é o host — a SDK não
40
- * inventa um endpoint de upload. Ausente, o compositor não oferece anexo nem gravação: melhor um
41
- * botão que não existe do que um que falha ao ser tocado.
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.
42
58
  */
43
- /** Destino alternativo do áudio gravado. Sem isto, usa o do próprio `client`. */
44
59
  uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
45
60
  }
46
61
 
@@ -50,12 +65,9 @@ export type PreviewUploadedMedia = {
50
65
  readonly filename?: string
51
66
  }
52
67
 
53
- /** Deriva o tipo de mídia do WhatsApp a partir do MIME do arquivo escolhido. */
54
- export function mediaTypeOf(mimeType: string): SendPreviewMediaParams['mediaType'] {
55
- if (mimeType.startsWith('image/')) return 'image'
56
- if (mimeType.startsWith('video/')) return 'video'
57
- if (mimeType.startsWith('audio/')) return 'audio'
58
- return 'document'
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)
59
71
  }
60
72
 
61
73
  // Mesma janela usada pelo WhatsApp para colar bolhas do mesmo autor: acima disso, a mensagem
@@ -139,6 +151,14 @@ export function ConversationPreview({
139
151
  const bottomRef = useRef<HTMLDivElement>(null)
140
152
  loadMessagesRef.current = loadMessages
141
153
 
154
+ const simulator = useMemo(
155
+ () =>
156
+ isConversationSimulatorClient(client)
157
+ ? client
158
+ : toConversationSimulatorClient({ client, ...(uploadMedia ? { uploadMedia } : {}) }),
159
+ [client, uploadMedia],
160
+ )
161
+
142
162
  const refresh = useCallback(async (): Promise<void> => {
143
163
  try {
144
164
  const loaded = await loadMessagesRef.current(conversationId)
@@ -207,7 +227,7 @@ export function ConversationPreview({
207
227
  async function handleSend(text: string): Promise<void> {
208
228
  setFailure(undefined)
209
229
  try {
210
- await client.sendText(text)
230
+ await simulator.sendText(text)
211
231
  setPendingLocal((current) => [
212
232
  ...current,
213
233
  {
@@ -235,12 +255,12 @@ export function ConversationPreview({
235
255
 
236
256
  async function handleInteractiveSelect(selection: InteractiveSelection): Promise<void> {
237
257
  setFailure(undefined)
238
- const reply = { id: selection.option.id, title: selection.option.title }
239
258
  try {
240
- // Botão e lista são payloads diferentes para a Meta (`button_reply` × `list_reply`), e o
241
- // roteador do fluxo lê campos distintos: tratar os dois como um só faria o menu responder no
242
- // simulador e falhar no aparelho do cliente.
243
- await (selection.kind === 'button' ? client.sendButtonReply(reply) : client.sendListReply(reply))
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)
244
264
  await refreshWithFollowUps()
245
265
  } catch (error) {
246
266
  setFailure(error instanceof Error ? error.message : 'Falha ao entregar a resposta no webhook.')
@@ -248,23 +268,25 @@ export function ConversationPreview({
248
268
  }
249
269
 
250
270
  /**
251
- * O cliente do preview sabe subir mídia sozinho; a prop é só para quem quer outro destino.
271
+ * O transporte do canal diz se aceita mídia do cliente; a tela só pergunta.
252
272
  *
253
273
  * Antes isto era `uploadMedia` puro, e o microfone só aparecia no produto que lembrasse de montar
254
- * o upload — de onde veio a divergência entre dois simuladores da mesma casa.
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.
255
276
  */
256
- const uploadFile = uploadMedia ?? client.uploadMedia
277
+ const sendMedia = simulator.sendMedia
278
+ const canAttachFile = SIMULATOR_FILE_MEDIA_KINDS.some((kind) => acceptsMediaKind(simulator, kind))
279
+ const canRecordAudio = acceptsMediaKind(simulator, 'audio')
257
280
 
258
281
  async function handleAttach(file: File): Promise<void> {
259
- if (!uploadFile) return
282
+ if (!sendMedia) return
260
283
  setFailure(undefined)
261
284
  try {
262
- const uploaded = await uploadFile(file)
263
- await client.sendMedia({
264
- mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
265
- mediaId: uploaded.mediaId,
266
- mimeType: uploaded.mimeType ?? file.type,
267
- filename: uploaded.filename ?? file.name,
285
+ await sendMedia({
286
+ mediaKind: mediaKindOf(file.type),
287
+ file,
288
+ mimeType: file.type,
289
+ filename: file.name,
268
290
  })
269
291
  await refreshWithFollowUps()
270
292
  } catch (error) {
@@ -312,7 +334,7 @@ export function ConversationPreview({
312
334
 
313
335
  <MessageComposer
314
336
  onSend={(text) => void handleSend(text)}
315
- onAttach={uploadFile ? (file) => void handleAttach(file) : undefined}
337
+ onAttach={canAttachFile ? (file) => void handleAttach(file) : undefined}
316
338
  /* Gravando, o campo diz o que falta fazer: o botão é um interruptor e o segundo toque é
317
339
  que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
318
340
  microfone está quebrado. */
@@ -320,7 +342,7 @@ export function ConversationPreview({
320
342
  isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
321
343
  }
322
344
  idleAction={
323
- uploadFile ? (
345
+ canRecordAudio ? (
324
346
  <AudioRecorderButton
325
347
  onRecorded={(file) => void handleAttach(file)}
326
348
  onFailure={(message) => setFailure(message)}