@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
package/dist/preview/index.js
CHANGED
|
@@ -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
|
-
|
|
9
|
+
DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS,
|
|
8
10
|
DocumentsLibrary,
|
|
9
11
|
MessageBubble,
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
1279
|
-
import { jsx
|
|
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 =
|
|
1073
|
+
const store = useMemo(
|
|
1286
1074
|
() => createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES }),
|
|
1287
1075
|
[]
|
|
1288
1076
|
);
|
|
1289
|
-
const api =
|
|
1290
|
-
const sse =
|
|
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__ */
|
|
1295
|
-
/* @__PURE__ */
|
|
1296
|
-
/* @__PURE__ */
|
|
1297
|
-
/* @__PURE__ */
|
|
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__ */
|
|
1305
|
-
/* @__PURE__ */
|
|
1306
|
-
/* @__PURE__ */
|
|
1307
|
-
/* @__PURE__ */
|
|
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__ */
|
|
1310
|
-
/* @__PURE__ */
|
|
1311
|
-
/* @__PURE__ */
|
|
1312
|
-
/* @__PURE__ */
|
|
1313
|
-
/* @__PURE__ */
|
|
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
|
-
|
|
1154
|
+
simulatorPanelLabelsOf,
|
|
1155
|
+
startPreviewScript,
|
|
1156
|
+
toConversationSimulatorClient
|
|
1363
1157
|
};
|
package/package.json
CHANGED
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
|
|
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ê
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
/**
|
|
54
|
-
export function mediaTypeOf(mimeType: string):
|
|
55
|
-
|
|
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
|
|
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
|
-
//
|
|
241
|
-
// roteador do fluxo lê campos distintos
|
|
242
|
-
//
|
|
243
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
282
|
+
if (!sendMedia) return
|
|
260
283
|
setFailure(undefined)
|
|
261
284
|
try {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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={
|
|
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
|
-
|
|
345
|
+
canRecordAudio ? (
|
|
324
346
|
<AudioRecorderButton
|
|
325
347
|
onRecorded={(file) => void handleAttach(file)}
|
|
326
348
|
onFailure={(message) => setFailure(message)}
|