@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.
@@ -6,7 +6,7 @@ import {
6
6
  DocumentsLibrary,
7
7
  MessageBubble,
8
8
  MessageComposer
9
- } from "../chunk-LITPZCWW.js";
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
- import { jsx, jsxs } from "react/jsx-runtime";
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] = useState([]);
871
- const [failure, setFailure] = useState(void 0);
872
- const loadMessagesRef = useRef(loadMessages);
873
- const bottomRef = useRef(null);
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 = useCallback(async () => {
976
+ const refresh = useCallback2(async () => {
876
977
  try {
877
- setMessages(await loadMessagesRef.current(conversationId));
878
- } catch {
879
- setMessages([]);
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
- await refresh();
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__ */ jsx(DateDivider, { iso: message.timestamp }) : null,
913
- /* @__PURE__ */ jsx(
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__ */ jsx("div", { ref: bottomRef })
1083
+ /* @__PURE__ */ jsx2("div", { ref: bottomRef })
923
1084
  ] }),
924
- failure ? /* @__PURE__ */ jsx("p", { role: "alert", className: "px-4 py-2 text-sm text-red-600 dark:text-red-400", children: failure }) : null,
925
- /* @__PURE__ */ jsx(
926
- MessageComposer,
927
- {
928
- onSend: (text) => void handleSend(text),
929
- placeholder: placeholder ?? "Escreva como o cliente\u2026"
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 signWithWebCrypto(params) {
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 jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
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__ */ jsx2(ConversationsProvider, { api, sse, children: /* @__PURE__ */ jsxs2("div", { className, children: [
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__ */ jsx2("h1", { className: "text-lg font-semibold", children: "Teste manual de m\xEDdia" }),
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__ */ jsx2("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Biblioteca da empresa" }),
1057
- /* @__PURE__ */ jsx2(DocumentsLibrary, { perPage: documents.length || 20 })
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__ */ jsx2("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Painel da conversa" }),
1061
- /* @__PURE__ */ jsx2(ConversationDocumentsPanel, { conversationId, open: true, perPage: documents.length || 20 }),
1062
- /* @__PURE__ */ jsx2("h2", { className: "text-sm font-semibold uppercase tracking-wide text-gray-500", children: "Bolhas na thread" }),
1063
- /* @__PURE__ */ jsx2(ConversationWallpaper, { className: "max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2", children: messages.map((message, index) => /* @__PURE__ */ jsx2(
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 { contactFlag as A, formatContactHandle as B, CHANNEL_CAPABILITIES as C, DEFAULT_CONVERSATION_CHANNEL as D, type FormatContactHandleParams as F, HANDLE_KIND as H, 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 ListDocumentsParams as t, type MediaRendererProps as u, type MessagePayload as v, type ReopenMechanism as w, type ResolveMediaUrl as x, capabilitiesOf as y, channelFiltersFor as z };
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.6",
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.5"
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),
@@ -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 EMOJI_CATEGORIES: { name: string; emojis: string[] }[] = [
9
- {
10
- name: 'Smileys',
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="flex border-b border-gray-200 overflow-x-auto">
48
- {EMOJI_CATEGORIES.map((category, index) => (
49
- <button
50
- key={category.name}
51
- onClick={() => setActiveCategory(index)}
52
- className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
53
- activeCategory === index
54
- ? 'border-blue-500 text-blue-600'
55
- : 'border-transparent text-gray-500 hover:text-gray-700'
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
- <div className="grid grid-cols-10 gap-0.5 p-2 max-h-[240px] overflow-y-auto">
64
- {EMOJI_CATEGORIES[activeCategory].emojis.map((emoji) => (
65
- <button
66
- key={emoji}
67
- onClick={() => handleSelect(emoji)}
68
- className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
69
- aria-label={emoji}
70
- >
71
- {emoji}
72
- </button>
73
- ))}
74
- </div>
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
  }