@adatechnology/conversations-ui 0.1.0-rc.6 → 0.1.0-rc.8
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/{chunk-LITPZCWW.js → chunk-73MW5HNT.js} +432 -180
- package/dist/index.d.ts +76 -6
- package/dist/index.js +16 -2
- package/dist/preview/index.d.ts +73 -4
- package/dist/preview/index.js +216 -37
- package/dist/{types-CeixG2Z9.d.ts → types-B5C1DLu1.d.ts} +43 -2
- package/package.json +2 -2
- package/src/ConversationHeader.tsx +18 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +143 -0
- package/src/MessageBubble.tsx +13 -2
- package/src/MessageComposer.tsx +16 -2
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/index.ts +9 -3
- package/src/preview/AudioRecorderButton.tsx +117 -0
- package/src/preview/ConversationPreview.tsx +184 -15
- package/src/preview/audioRecorderFormat.test.ts +67 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createPreviewWebhookClient.ts +28 -1
- package/src/preview/index.ts +11 -2
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/types.ts +38 -1
package/dist/preview/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
DocumentsLibrary,
|
|
7
7
|
MessageBubble,
|
|
8
8
|
MessageComposer
|
|
9
|
-
} from "../chunk-
|
|
9
|
+
} from "../chunk-73MW5HNT.js";
|
|
10
10
|
import "../chunk-OGRRHQQW.js";
|
|
11
11
|
|
|
12
12
|
// src/preview/previewStore.ts
|
|
@@ -845,9 +845,89 @@ function createMockSSEProvider(params) {
|
|
|
845
845
|
}
|
|
846
846
|
|
|
847
847
|
// src/preview/ConversationPreview.tsx
|
|
848
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
849
|
-
|
|
848
|
+
import { useCallback as useCallback2, useEffect, useMemo, useRef as useRef2, useState as useState2 } from "react";
|
|
849
|
+
|
|
850
|
+
// src/preview/AudioRecorderButton.tsx
|
|
851
|
+
import { useCallback, useRef, useState } from "react";
|
|
852
|
+
import { jsx } from "react/jsx-runtime";
|
|
853
|
+
var DEFAULT_AUDIO_RECORDER_BUTTON_LABELS = {
|
|
854
|
+
start: "Gravar \xE1udio",
|
|
855
|
+
stop: "Parar grava\xE7\xE3o",
|
|
856
|
+
unsupported: "Este navegador n\xE3o grava \xE1udio.",
|
|
857
|
+
denied: "Sem permiss\xE3o para usar o microfone."
|
|
858
|
+
};
|
|
859
|
+
var RECORDING_FORMATS = [
|
|
860
|
+
{ mimeType: "audio/ogg;codecs=opus", uploadMimeType: "audio/ogg", extension: "ogg" },
|
|
861
|
+
{ mimeType: "audio/mp4", uploadMimeType: "audio/mp4", extension: "m4a" },
|
|
862
|
+
{ mimeType: "audio/webm", uploadMimeType: "audio/webm", extension: "webm" }
|
|
863
|
+
];
|
|
864
|
+
function resolveRecordingFormat() {
|
|
865
|
+
if (typeof MediaRecorder === "undefined") return void 0;
|
|
866
|
+
if (typeof MediaRecorder.isTypeSupported !== "function") return RECORDING_FORMATS[0];
|
|
867
|
+
return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType));
|
|
868
|
+
}
|
|
869
|
+
function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }) {
|
|
870
|
+
const startLabel = labels?.start ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start;
|
|
871
|
+
const stopLabel = labels?.stop ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.stop;
|
|
872
|
+
const [isRecording, setIsRecording] = useState(false);
|
|
873
|
+
const recorderRef = useRef(null);
|
|
874
|
+
const stop = useCallback(() => {
|
|
875
|
+
recorderRef.current?.stop();
|
|
876
|
+
}, []);
|
|
877
|
+
const start = useCallback(async () => {
|
|
878
|
+
const format = resolveRecordingFormat();
|
|
879
|
+
if (!format || !navigator.mediaDevices?.getUserMedia) {
|
|
880
|
+
onFailure?.(labels?.unsupported ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.unsupported);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
try {
|
|
884
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
885
|
+
const recorder = new MediaRecorder(stream, { mimeType: format.mimeType });
|
|
886
|
+
const chunks = [];
|
|
887
|
+
recorder.addEventListener("dataavailable", (event) => {
|
|
888
|
+
if (event.data.size > 0) chunks.push(event.data);
|
|
889
|
+
});
|
|
890
|
+
recorder.addEventListener("stop", () => {
|
|
891
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
892
|
+
setIsRecording(false);
|
|
893
|
+
recorderRef.current = null;
|
|
894
|
+
const blob = new Blob(chunks, { type: format.uploadMimeType });
|
|
895
|
+
void onRecorded(
|
|
896
|
+
new File([blob], `audio-${Date.now()}.${format.extension}`, { type: format.uploadMimeType })
|
|
897
|
+
);
|
|
898
|
+
});
|
|
899
|
+
recorderRef.current = recorder;
|
|
900
|
+
recorder.start();
|
|
901
|
+
setIsRecording(true);
|
|
902
|
+
} catch {
|
|
903
|
+
onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied);
|
|
904
|
+
}
|
|
905
|
+
}, [labels?.denied, labels?.unsupported, onFailure, onRecorded]);
|
|
906
|
+
return /* @__PURE__ */ jsx(
|
|
907
|
+
"button",
|
|
908
|
+
{
|
|
909
|
+
type: "button",
|
|
910
|
+
disabled,
|
|
911
|
+
onClick: () => isRecording ? stop() : void start(),
|
|
912
|
+
title: isRecording ? stopLabel : startLabel,
|
|
913
|
+
"aria-label": isRecording ? stopLabel : startLabel,
|
|
914
|
+
"aria-pressed": isRecording,
|
|
915
|
+
className: `flex h-9 w-9 items-center justify-center rounded-full transition-colors ${isRecording ? "bg-red-100 text-red-600 dark:bg-red-900/40" : "text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"}`,
|
|
916
|
+
children: isRecording ? "\u25A0" : "\u{1F3A4}"
|
|
917
|
+
}
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// src/preview/ConversationPreview.tsx
|
|
922
|
+
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
923
|
+
function mediaTypeOf(mimeType) {
|
|
924
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
925
|
+
if (mimeType.startsWith("video/")) return "video";
|
|
926
|
+
if (mimeType.startsWith("audio/")) return "audio";
|
|
927
|
+
return "document";
|
|
928
|
+
}
|
|
850
929
|
var GROUPING_WINDOW_MS = 5 * 60 * 1e3;
|
|
930
|
+
var FOLLOW_UP_REFRESH_MS = [400, 1200, 3e3];
|
|
851
931
|
function decorate(messages) {
|
|
852
932
|
return messages.map((message, index) => {
|
|
853
933
|
const previous = index > 0 ? messages[index - 1] : void 0;
|
|
@@ -860,23 +940,52 @@ function decorate(messages) {
|
|
|
860
940
|
};
|
|
861
941
|
});
|
|
862
942
|
}
|
|
943
|
+
function statusOf(error) {
|
|
944
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
945
|
+
const candidate = error;
|
|
946
|
+
const value = candidate.status ?? candidate.statusCode;
|
|
947
|
+
return typeof value === "number" ? value : void 0;
|
|
948
|
+
}
|
|
949
|
+
function isNotFound(error) {
|
|
950
|
+
return statusOf(error) === 404;
|
|
951
|
+
}
|
|
952
|
+
function describeLoadFailure(error) {
|
|
953
|
+
const status = statusOf(error);
|
|
954
|
+
if (status === 401 || status === 403) {
|
|
955
|
+
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.";
|
|
956
|
+
}
|
|
957
|
+
if (error instanceof Error && error.message) return `N\xE3o foi poss\xEDvel ler o transcript: ${error.message}`;
|
|
958
|
+
return "N\xE3o foi poss\xEDvel ler o transcript da conversa.";
|
|
959
|
+
}
|
|
863
960
|
function ConversationPreview({
|
|
864
961
|
client,
|
|
865
962
|
sse,
|
|
866
963
|
conversationId,
|
|
867
964
|
loadMessages,
|
|
868
|
-
placeholder
|
|
965
|
+
placeholder,
|
|
966
|
+
pollIntervalMs,
|
|
967
|
+
uploadMedia
|
|
869
968
|
}) {
|
|
870
|
-
const [messages, setMessages] =
|
|
871
|
-
const [failure, setFailure] =
|
|
872
|
-
const
|
|
873
|
-
const
|
|
969
|
+
const [messages, setMessages] = useState2([]);
|
|
970
|
+
const [failure, setFailure] = useState2(void 0);
|
|
971
|
+
const [loadFailure, setLoadFailure] = useState2(void 0);
|
|
972
|
+
const [pendingLocal, setPendingLocal] = useState2([]);
|
|
973
|
+
const loadMessagesRef = useRef2(loadMessages);
|
|
974
|
+
const bottomRef = useRef2(null);
|
|
874
975
|
loadMessagesRef.current = loadMessages;
|
|
875
|
-
const refresh =
|
|
976
|
+
const refresh = useCallback2(async () => {
|
|
876
977
|
try {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
978
|
+
const loaded = await loadMessagesRef.current(conversationId);
|
|
979
|
+
setMessages(loaded);
|
|
980
|
+
setLoadFailure(void 0);
|
|
981
|
+
if (loaded.length > 0) setPendingLocal([]);
|
|
982
|
+
} catch (error) {
|
|
983
|
+
if (isNotFound(error)) {
|
|
984
|
+
setMessages([]);
|
|
985
|
+
setLoadFailure(void 0);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
setLoadFailure(describeLoadFailure(error));
|
|
880
989
|
}
|
|
881
990
|
}, [conversationId]);
|
|
882
991
|
useEffect(() => {
|
|
@@ -893,42 +1002,105 @@ function ConversationPreview({
|
|
|
893
1002
|
source.close();
|
|
894
1003
|
};
|
|
895
1004
|
}, [sse, conversationId, refresh]);
|
|
1005
|
+
useEffect(() => {
|
|
1006
|
+
if (!pollIntervalMs) return;
|
|
1007
|
+
const timer = setInterval(() => void refresh(), pollIntervalMs);
|
|
1008
|
+
return () => clearInterval(timer);
|
|
1009
|
+
}, [pollIntervalMs, refresh]);
|
|
896
1010
|
useEffect(() => {
|
|
897
1011
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
898
1012
|
}, [messages]);
|
|
899
|
-
const rendered = useMemo(() => decorate(messages), [messages]);
|
|
1013
|
+
const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal]);
|
|
1014
|
+
async function refreshWithFollowUps() {
|
|
1015
|
+
await refresh();
|
|
1016
|
+
for (const atraso of FOLLOW_UP_REFRESH_MS) {
|
|
1017
|
+
setTimeout(() => void refresh(), atraso);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
900
1020
|
async function handleSend(text) {
|
|
901
1021
|
setFailure(void 0);
|
|
902
1022
|
try {
|
|
903
1023
|
await client.sendText(text);
|
|
904
|
-
|
|
1024
|
+
setPendingLocal((current) => [
|
|
1025
|
+
...current,
|
|
1026
|
+
{
|
|
1027
|
+
id: `local-${current.length}-${text.length}`,
|
|
1028
|
+
type: "text",
|
|
1029
|
+
content: text,
|
|
1030
|
+
// Do ponto de vista do servidor, mensagem do cliente é inbound — é assim que ela aparece
|
|
1031
|
+
// como "minha" nesta visão.
|
|
1032
|
+
direction: "inbound",
|
|
1033
|
+
sender: "customer",
|
|
1034
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1035
|
+
status: "sent"
|
|
1036
|
+
}
|
|
1037
|
+
]);
|
|
1038
|
+
await refreshWithFollowUps();
|
|
905
1039
|
} catch (error) {
|
|
906
1040
|
setFailure(error instanceof Error ? error.message : "Falha ao entregar a mensagem no webhook.");
|
|
907
1041
|
}
|
|
908
1042
|
}
|
|
1043
|
+
async function handleInteractiveSelect(selection) {
|
|
1044
|
+
setFailure(void 0);
|
|
1045
|
+
const reply = { id: selection.option.id, title: selection.option.title };
|
|
1046
|
+
try {
|
|
1047
|
+
await (selection.kind === "button" ? client.sendButtonReply(reply) : client.sendListReply(reply));
|
|
1048
|
+
await refreshWithFollowUps();
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
setFailure(error instanceof Error ? error.message : "Falha ao entregar a resposta no webhook.");
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async function handleAttach(file) {
|
|
1054
|
+
if (!uploadMedia) return;
|
|
1055
|
+
setFailure(void 0);
|
|
1056
|
+
try {
|
|
1057
|
+
const uploaded = await uploadMedia(file);
|
|
1058
|
+
await client.sendMedia({
|
|
1059
|
+
mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
|
|
1060
|
+
mediaId: uploaded.mediaId,
|
|
1061
|
+
mimeType: uploaded.mimeType ?? file.type,
|
|
1062
|
+
filename: uploaded.filename ?? file.name
|
|
1063
|
+
});
|
|
1064
|
+
await refreshWithFollowUps();
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
setFailure(error instanceof Error ? error.message : "Falha ao enviar o arquivo.");
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
909
1069
|
return /* @__PURE__ */ jsxs("div", { className: "flex h-full min-h-0 flex-col", children: [
|
|
910
1070
|
/* @__PURE__ */ jsxs(ConversationWallpaper, { className: "flex-1 min-h-0 overflow-y-auto px-4 py-3", children: [
|
|
911
1071
|
rendered.map(({ message, isFirstInGroup, showDateDivider }) => /* @__PURE__ */ jsxs("div", { children: [
|
|
912
|
-
showDateDivider ? /* @__PURE__ */
|
|
913
|
-
/* @__PURE__ */
|
|
1072
|
+
showDateDivider ? /* @__PURE__ */ jsx2(DateDivider, { iso: message.timestamp }) : null,
|
|
1073
|
+
/* @__PURE__ */ jsx2(
|
|
914
1074
|
MessageBubble,
|
|
915
1075
|
{
|
|
916
1076
|
message,
|
|
917
1077
|
isMine: message.direction === "inbound",
|
|
918
|
-
isFirstInGroup
|
|
1078
|
+
isFirstInGroup,
|
|
1079
|
+
onInteractiveSelect: message.direction === "outbound" ? (selection) => void handleInteractiveSelect(selection) : void 0
|
|
919
1080
|
}
|
|
920
1081
|
)
|
|
921
1082
|
] }, message.id)),
|
|
922
|
-
/* @__PURE__ */
|
|
1083
|
+
/* @__PURE__ */ jsx2("div", { ref: bottomRef })
|
|
923
1084
|
] }),
|
|
924
|
-
failure ? /* @__PURE__ */
|
|
925
|
-
/* @__PURE__ */
|
|
926
|
-
|
|
927
|
-
{
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1085
|
+
failure ? /* @__PURE__ */ jsx2("p", { role: "alert", className: "px-4 py-2 text-sm text-red-600 dark:text-red-400", children: failure }) : null,
|
|
1086
|
+
loadFailure ? /* @__PURE__ */ jsx2("p", { role: "status", className: "px-4 py-2 text-sm text-amber-700 dark:text-amber-400", children: loadFailure }) : null,
|
|
1087
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-end gap-1", children: [
|
|
1088
|
+
/* @__PURE__ */ jsx2("div", { className: "min-w-0 flex-1", children: /* @__PURE__ */ jsx2(
|
|
1089
|
+
MessageComposer,
|
|
1090
|
+
{
|
|
1091
|
+
onSend: (text) => void handleSend(text),
|
|
1092
|
+
onAttach: uploadMedia ? (file) => void handleAttach(file) : void 0,
|
|
1093
|
+
placeholder: placeholder ?? "Escreva como o cliente\u2026"
|
|
1094
|
+
}
|
|
1095
|
+
) }),
|
|
1096
|
+
uploadMedia ? /* @__PURE__ */ jsx2(
|
|
1097
|
+
AudioRecorderButton,
|
|
1098
|
+
{
|
|
1099
|
+
onRecorded: (file) => void handleAttach(file),
|
|
1100
|
+
onFailure: (message) => setFailure(message)
|
|
1101
|
+
}
|
|
1102
|
+
) : null
|
|
1103
|
+
] })
|
|
932
1104
|
] });
|
|
933
1105
|
}
|
|
934
1106
|
|
|
@@ -936,6 +1108,7 @@ function ConversationPreview({
|
|
|
936
1108
|
import {
|
|
937
1109
|
buildInboundAudioPayload,
|
|
938
1110
|
buildInboundInteractivePayload,
|
|
1111
|
+
buildInboundMediaPayload,
|
|
939
1112
|
buildInboundTextPayload,
|
|
940
1113
|
serializeWebhookPayload
|
|
941
1114
|
} from "@adatechnology/meta-whatsapp-contracts/testing";
|
|
@@ -955,7 +1128,7 @@ var PreviewWebhookRejectedError = class extends Error {
|
|
|
955
1128
|
function assertPreviewEnvironment(isProduction) {
|
|
956
1129
|
if (isProduction) throw new PreviewInProductionError();
|
|
957
1130
|
}
|
|
958
|
-
async function
|
|
1131
|
+
async function signPreviewPayload(params) {
|
|
959
1132
|
const encoder = new TextEncoder();
|
|
960
1133
|
const key = await globalThis.crypto.subtle.importKey(
|
|
961
1134
|
"raw",
|
|
@@ -967,6 +1140,7 @@ async function signWithWebCrypto(params) {
|
|
|
967
1140
|
const signature = await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(params.rawBody));
|
|
968
1141
|
return `sha256=${[...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
969
1142
|
}
|
|
1143
|
+
var signWithWebCrypto = signPreviewPayload;
|
|
970
1144
|
function createPreviewWebhookClient(params) {
|
|
971
1145
|
const sendPayload = async (payload) => {
|
|
972
1146
|
const rawBody = serializeWebhookPayload(payload);
|
|
@@ -984,7 +1158,8 @@ function createPreviewWebhookClient(params) {
|
|
|
984
1158
|
sendText: (text) => sendPayload(buildInboundTextPayload({ ...envelope, text })),
|
|
985
1159
|
sendButtonReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, buttonReply: reply })),
|
|
986
1160
|
sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
|
|
987
|
-
sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId }))
|
|
1161
|
+
sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
|
|
1162
|
+
sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media }))
|
|
988
1163
|
};
|
|
989
1164
|
}
|
|
990
1165
|
|
|
@@ -1026,7 +1201,7 @@ function startPreviewScript(params) {
|
|
|
1026
1201
|
|
|
1027
1202
|
// src/preview/MediaTypesPreview.tsx
|
|
1028
1203
|
import { useMemo as useMemo2 } from "react";
|
|
1029
|
-
import { jsx as
|
|
1204
|
+
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
1030
1205
|
var MEDIA_TYPES_CONVERSATION_ID = "5511944443333";
|
|
1031
1206
|
function MediaTypesPreview({
|
|
1032
1207
|
conversationId = MEDIA_TYPES_CONVERSATION_ID,
|
|
@@ -1041,9 +1216,9 @@ function MediaTypesPreview({
|
|
|
1041
1216
|
const messages = PREVIEW_MESSAGES[conversationId] ?? [];
|
|
1042
1217
|
const documents = PREVIEW_DOCUMENTS[conversationId] ?? [];
|
|
1043
1218
|
const mimeTypes = [...new Set(documents.map((document) => document.mimeType))];
|
|
1044
|
-
return /* @__PURE__ */
|
|
1219
|
+
return /* @__PURE__ */ jsx3(ConversationsProvider, { api, sse, children: /* @__PURE__ */ jsxs2("div", { className, children: [
|
|
1045
1220
|
/* @__PURE__ */ jsxs2("header", { className: "border-b px-4 py-3 dark:border-gray-700", children: [
|
|
1046
|
-
/* @__PURE__ */
|
|
1221
|
+
/* @__PURE__ */ jsx3("h1", { className: "text-lg font-semibold", children: "Teste manual de m\xEDdia" }),
|
|
1047
1222
|
/* @__PURE__ */ jsxs2("p", { className: "text-sm text-gray-500", children: [
|
|
1048
1223
|
documents.length,
|
|
1049
1224
|
" arquivos, ",
|
|
@@ -1053,14 +1228,14 @@ function MediaTypesPreview({
|
|
|
1053
1228
|
] }),
|
|
1054
1229
|
/* @__PURE__ */ jsxs2("div", { className: "grid gap-4 p-4 lg:grid-cols-2", children: [
|
|
1055
1230
|
/* @__PURE__ */ jsxs2("section", { className: "space-y-3", children: [
|
|
1056
|
-
/* @__PURE__ */
|
|
1057
|
-
/* @__PURE__ */
|
|
1231
|
+
/* @__PURE__ */ jsx3("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Biblioteca da empresa" }),
|
|
1232
|
+
/* @__PURE__ */ jsx3(DocumentsLibrary, { perPage: documents.length || 20 })
|
|
1058
1233
|
] }),
|
|
1059
1234
|
/* @__PURE__ */ jsxs2("section", { className: "space-y-3", children: [
|
|
1060
|
-
/* @__PURE__ */
|
|
1061
|
-
/* @__PURE__ */
|
|
1062
|
-
/* @__PURE__ */
|
|
1063
|
-
/* @__PURE__ */
|
|
1235
|
+
/* @__PURE__ */ jsx3("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Painel da conversa" }),
|
|
1236
|
+
/* @__PURE__ */ jsx3(ConversationDocumentsPanel, { conversationId, open: true, perPage: documents.length || 20 }),
|
|
1237
|
+
/* @__PURE__ */ jsx3("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Bolhas na thread" }),
|
|
1238
|
+
/* @__PURE__ */ jsx3(ConversationWallpaper, { className: "max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2", children: messages.map((message, index) => /* @__PURE__ */ jsx3(
|
|
1064
1239
|
MessageBubble,
|
|
1065
1240
|
{
|
|
1066
1241
|
message,
|
|
@@ -1074,7 +1249,9 @@ function MediaTypesPreview({
|
|
|
1074
1249
|
] }) });
|
|
1075
1250
|
}
|
|
1076
1251
|
export {
|
|
1252
|
+
AudioRecorderButton,
|
|
1077
1253
|
ConversationPreview,
|
|
1254
|
+
DEFAULT_AUDIO_RECORDER_BUTTON_LABELS,
|
|
1078
1255
|
DEFAULT_PREVIEW_SCRIPT,
|
|
1079
1256
|
GLOBAL_CHANNEL,
|
|
1080
1257
|
MEDIA_TYPES_CONVERSATION_ID,
|
|
@@ -1093,8 +1270,10 @@ export {
|
|
|
1093
1270
|
createPreviewMediaResolver,
|
|
1094
1271
|
createPreviewStore,
|
|
1095
1272
|
createPreviewWebhookClient,
|
|
1273
|
+
mediaTypeOf,
|
|
1096
1274
|
previewFileBase64,
|
|
1097
1275
|
previewFileUrl,
|
|
1098
1276
|
resolvePreviewFileSample,
|
|
1277
|
+
signPreviewPayload,
|
|
1099
1278
|
startPreviewScript
|
|
1100
1279
|
};
|
|
@@ -19,9 +19,50 @@ interface ConversationsFeatures {
|
|
|
19
19
|
emoji?: boolean;
|
|
20
20
|
darkMode?: boolean;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Recorte do bloco `interactive` da Meta que a UI precisa para desenhar o menu. Fica solto (e não
|
|
24
|
+
* espelhando o contrato inteiro) porque o que chega do banco é o payload cru já enviado ao
|
|
25
|
+
* WhatsApp: qualquer campo que a UI não conheça é ignorado, nunca causa erro de render.
|
|
26
|
+
*/
|
|
27
|
+
interface InteractiveOption {
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
}
|
|
32
|
+
interface InteractiveSection {
|
|
33
|
+
title?: string;
|
|
34
|
+
rows?: InteractiveOption[];
|
|
35
|
+
}
|
|
36
|
+
interface InteractivePayload {
|
|
37
|
+
type?: 'button' | 'list' | string;
|
|
38
|
+
header?: {
|
|
39
|
+
text?: string;
|
|
40
|
+
};
|
|
41
|
+
body?: {
|
|
42
|
+
text?: string;
|
|
43
|
+
};
|
|
44
|
+
footer?: {
|
|
45
|
+
text?: string;
|
|
46
|
+
};
|
|
47
|
+
action?: {
|
|
48
|
+
/** Rótulo do botão que abre a lista — só existe em `type: 'list'`. */
|
|
49
|
+
button?: string;
|
|
50
|
+
sections?: InteractiveSection[];
|
|
51
|
+
buttons?: {
|
|
52
|
+
reply?: InteractiveOption;
|
|
53
|
+
}[];
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Como o cliente respondeu a um menu: por botão ou por item de lista. */
|
|
57
|
+
type InteractiveSelection = {
|
|
58
|
+
readonly kind: 'button' | 'list';
|
|
59
|
+
readonly option: InteractiveOption;
|
|
60
|
+
};
|
|
22
61
|
interface MessagePayload {
|
|
23
62
|
id: string;
|
|
24
|
-
type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template';
|
|
63
|
+
type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template' | 'interactive';
|
|
64
|
+
/** Payload cru da mensagem. Em `type: 'interactive'`, carrega o menu que o cliente vê. */
|
|
65
|
+
payload?: InteractivePayload | null;
|
|
25
66
|
content?: string;
|
|
26
67
|
caption?: string;
|
|
27
68
|
mediaUrl?: string;
|
|
@@ -321,4 +362,4 @@ interface ConversationDocument {
|
|
|
321
362
|
linkedAt: string;
|
|
322
363
|
}
|
|
323
364
|
|
|
324
|
-
export {
|
|
365
|
+
export { type ResolveMediaUrl as A, capabilitiesOf as B, CHANNEL_CAPABILITIES as C, DEFAULT_CONVERSATION_CHANNEL as D, channelFiltersFor as E, type FormatContactHandleParams as F, contactFlag as G, HANDLE_KIND as H, type InteractiveOption as I, formatContactHandle as J, type ListConversationsParams as L, MediaRenderer as M, REOPEN_MECHANISM as R, type SSEProvider as S, CHANNEL_FILTER_ALL as a, CONVERSATION_CHANNEL as b, type ChannelCapabilities as c, type ChannelFilter as d, type ChannelFilterOption as e, type CompanyDocument as f, type CompanyDocumentPage as g, type ConversationChannel as h, type ConversationDocument as i, type ConversationDocumentPage as j, type ConversationEventSource as k, type ConversationPage as l, type ConversationSummary as m, type ConversationTemplate as n, type ConversationsApi as o, type ConversationsFeatures as p, type ConversationsTheme as q, type ConversationsUIConfig as r, type HandleKind as s, type InteractivePayload as t, type InteractiveSection as u, type InteractiveSelection as v, type ListDocumentsParams as w, type MediaRendererProps as x, type MessagePayload as y, type ReopenMechanism as z };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adatechnology/conversations-ui",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.8",
|
|
4
4
|
"description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"clsx": "^2.1.1",
|
|
32
32
|
"lucide-react": "^1.21.0",
|
|
33
33
|
"tailwind-merge": "^3.6.0",
|
|
34
|
-
"@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.
|
|
34
|
+
"@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.6"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"react": "^18 || ^19",
|
|
@@ -52,6 +52,20 @@ export interface ConversationHeaderClassNames {
|
|
|
52
52
|
mobileMenu: string
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Utilitário extra que o host pendura no cabeçalho — ícone no desktop, item de menu no celular,
|
|
57
|
+
* como os nativos. Entra por aqui, e não por um slot de ReactNode, porque é isso que preserva o
|
|
58
|
+
* comportamento responsivo: um nó solto viraria um quarto ícone em 375px, sem área de toque.
|
|
59
|
+
*/
|
|
60
|
+
export interface ConversationHeaderUtility {
|
|
61
|
+
key: string
|
|
62
|
+
/** Emoji, para casar com os utilitários nativos do cabeçalho. */
|
|
63
|
+
icon: string
|
|
64
|
+
label: string
|
|
65
|
+
run: () => void
|
|
66
|
+
active?: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
export interface ConversationHeaderProps {
|
|
56
70
|
conversation: ConversationSummary
|
|
57
71
|
busy?: boolean
|
|
@@ -61,6 +75,8 @@ export interface ConversationHeaderProps {
|
|
|
61
75
|
onDownload?: () => void
|
|
62
76
|
onOpenDocuments?: () => void
|
|
63
77
|
documentsOpen?: boolean
|
|
78
|
+
/** Ações do produto que não existem no contrato do pacote (ex.: ferramentas de dev). */
|
|
79
|
+
extraUtilities?: readonly ConversationHeaderUtility[]
|
|
64
80
|
onBack?: () => void
|
|
65
81
|
labels?: Partial<ConversationHeaderLabels>
|
|
66
82
|
className?: string
|
|
@@ -76,6 +92,7 @@ export function ConversationHeader({
|
|
|
76
92
|
onDownload,
|
|
77
93
|
onOpenDocuments,
|
|
78
94
|
documentsOpen = false,
|
|
95
|
+
extraUtilities,
|
|
79
96
|
onBack,
|
|
80
97
|
labels: labelsOverride,
|
|
81
98
|
className,
|
|
@@ -96,6 +113,7 @@ export function ConversationHeader({
|
|
|
96
113
|
? { key: 'documents', icon: '📄', label: labels.documents, run: onOpenDocuments, active: documentsOpen }
|
|
97
114
|
: undefined,
|
|
98
115
|
onDownload ? { key: 'download', icon: '⬇️', label: labels.download, run: onDownload, active: false } : undefined,
|
|
116
|
+
...(extraUtilities ?? []).map((utility) => ({ ...utility, active: utility.active ?? false })),
|
|
99
117
|
].filter(
|
|
100
118
|
(utility): utility is { key: string; icon: string; label: string; run: () => void; active: boolean } =>
|
|
101
119
|
Boolean(utility),
|
package/src/EmojiPicker.tsx
CHANGED
|
@@ -1,39 +1,27 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react'
|
|
1
|
+
import { useState, useCallback, useMemo } from 'react'
|
|
2
|
+
import { EMOJI_CATEGORIES, searchEmojis, type EmojiEntry } from './emojiCatalog'
|
|
3
|
+
|
|
4
|
+
export interface EmojiPickerLabels {
|
|
5
|
+
search: string
|
|
6
|
+
noResults: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_EMOJI_PICKER_LABELS: EmojiPickerLabels = {
|
|
10
|
+
search: 'Buscar emoji',
|
|
11
|
+
noResults: 'Nenhum emoji encontrado',
|
|
12
|
+
}
|
|
2
13
|
|
|
3
14
|
export interface EmojiPickerProps {
|
|
4
15
|
onSelect: (emoji: string) => void
|
|
16
|
+
labels?: Partial<EmojiPickerLabels>
|
|
5
17
|
className?: string
|
|
6
18
|
}
|
|
7
19
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
emojis: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌', '😍', '🥰', '😘', '😗', '😋', '😛', '😜', '🤪'],
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
name: 'Gestures',
|
|
15
|
-
emojis: ['👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👋', '🤚', '🖐️', '✋', '🖖', '👏', '🙌', '🤝', '🙏', '✍️', '💅', '🤳'],
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
name: 'Hearts',
|
|
19
|
-
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '♥️'],
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
name: 'Food',
|
|
23
|
-
emojis: ['🍔', '🍟', '🍕', '🌭', '🍿', '🧂', '🥓', '🥚', '🍳', '🧇', '🥞', '🧈', '🍞', '🥐', '🥨', '🥯', '🥖', '🧀', '🥗', '🥙'],
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
name: 'Drinks',
|
|
27
|
-
emojis: ['☕', '🍵', '🍶', '🍾', '🍷', '🍸', '🍹', '🍺', '🍻', '🥂', '🥃', '🥤', '🧋', '🧃', '🧉', '🧊', '🥢', '🍽️', '🍴', '🥄'],
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
name: 'Objects',
|
|
31
|
-
emojis: ['🎁', '🎂', '🎈', '🎉', '🎊', '🎀', '📱', '💻', '⌚', '📷', '🔑', '💰', '💳', '📝', '📌', '📍', '✂️', '🔍', '💡', '🔔'],
|
|
32
|
-
},
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
|
|
20
|
+
export const EmojiPicker = ({ onSelect, labels, className = '' }: EmojiPickerProps) => {
|
|
21
|
+
const searchLabel = labels?.search ?? DEFAULT_EMOJI_PICKER_LABELS.search
|
|
22
|
+
const noResultsLabel = labels?.noResults ?? DEFAULT_EMOJI_PICKER_LABELS.noResults
|
|
36
23
|
const [activeCategory, setActiveCategory] = useState(0)
|
|
24
|
+
const [query, setQuery] = useState('')
|
|
37
25
|
|
|
38
26
|
const handleSelect = useCallback(
|
|
39
27
|
(emoji: string) => {
|
|
@@ -42,36 +30,62 @@ export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
|
|
|
42
30
|
[onSelect],
|
|
43
31
|
)
|
|
44
32
|
|
|
33
|
+
// Buscando, as abas de categoria saem do caminho: o resultado atravessa todas elas, e manter uma
|
|
34
|
+
// aba destacada sugeriria que a busca está restrita àquela categoria.
|
|
35
|
+
const isSearching = query.trim().length > 0
|
|
36
|
+
const visibleEntries: readonly EmojiEntry[] = useMemo(
|
|
37
|
+
() => (isSearching ? searchEmojis(query) : EMOJI_CATEGORIES[activeCategory].entries),
|
|
38
|
+
[isSearching, query, activeCategory],
|
|
39
|
+
)
|
|
40
|
+
|
|
45
41
|
return (
|
|
46
42
|
<div className={`bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden ${className}`}>
|
|
47
|
-
<div className="
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}`}
|
|
57
|
-
>
|
|
58
|
-
{category.emojis[0]} {category.name}
|
|
59
|
-
</button>
|
|
60
|
-
))}
|
|
43
|
+
<div className="p-2 border-b border-gray-200">
|
|
44
|
+
<input
|
|
45
|
+
type="search"
|
|
46
|
+
value={query}
|
|
47
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
48
|
+
placeholder={searchLabel}
|
|
49
|
+
aria-label={searchLabel}
|
|
50
|
+
className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm outline-none focus:border-blue-500"
|
|
51
|
+
/>
|
|
61
52
|
</div>
|
|
62
53
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
54
|
+
{isSearching ? null : (
|
|
55
|
+
<div className="flex border-b border-gray-200 overflow-x-auto">
|
|
56
|
+
{EMOJI_CATEGORIES.map((category, index) => (
|
|
57
|
+
<button
|
|
58
|
+
key={category.name}
|
|
59
|
+
onClick={() => setActiveCategory(index)}
|
|
60
|
+
className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
|
|
61
|
+
activeCategory === index
|
|
62
|
+
? 'border-blue-500 text-blue-600'
|
|
63
|
+
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
64
|
+
}`}
|
|
65
|
+
>
|
|
66
|
+
{category.entries[0].emoji} {category.name}
|
|
67
|
+
</button>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
{visibleEntries.length === 0 ? (
|
|
73
|
+
<p className="px-3 py-6 text-center text-sm text-gray-500">{noResultsLabel}</p>
|
|
74
|
+
) : (
|
|
75
|
+
<div className="grid grid-cols-10 gap-0.5 p-2 max-h-[240px] overflow-y-auto">
|
|
76
|
+
{visibleEntries.map((entry) => (
|
|
77
|
+
<button
|
|
78
|
+
key={entry.emoji}
|
|
79
|
+
onClick={() => handleSelect(entry.emoji)}
|
|
80
|
+
className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
|
|
81
|
+
aria-label={entry.emoji}
|
|
82
|
+
title={entry.keywords[0]}
|
|
83
|
+
>
|
|
84
|
+
{entry.emoji}
|
|
85
|
+
</button>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
75
89
|
</div>
|
|
76
90
|
)
|
|
77
91
|
}
|