@adatechnology/conversations-ui 0.1.0-rc.10 → 0.1.0-rc.11

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.
@@ -864,6 +864,12 @@ var EmojiPicker = ({ onSelect, labels, className = "" }) => {
864
864
  // src/MessageComposer.tsx
865
865
  import { useState as useState6, useRef as useRef2, useCallback as useCallback2 } from "react";
866
866
  import { Fragment as Fragment2, jsx as jsx12, jsxs as jsxs8 } from "react/jsx-runtime";
867
+ function applyQuickReplyVariables(template, variables = {}) {
868
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, chave) => variables[chave] ?? "");
869
+ }
870
+ function resolveQuickReply(quickReply, variables = {}) {
871
+ return typeof quickReply.text === "function" ? quickReply.text(variables) : applyQuickReplyVariables(quickReply.text, variables);
872
+ }
867
873
  var DEFAULT_MESSAGE_COMPOSER_LABELS = {
868
874
  emoji: "Emoji",
869
875
  attach: "Anexar",
@@ -884,6 +890,8 @@ var MessageComposer = ({
884
890
  onAttach,
885
891
  value: externalValue,
886
892
  onChange: externalOnChange,
893
+ quickReplies,
894
+ quickReplyVariables,
887
895
  features,
888
896
  placeholder = "Digite uma mensagem...",
889
897
  maxLength,
@@ -994,6 +1002,31 @@ var MessageComposer = ({
994
1002
  ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
995
1003
  descobriam o fundo branco da página, que lia como defeito. */
996
1004
  /* @__PURE__ */ jsxs8("div", { className: cn("bg-[#f0f2f5] px-2 py-2", className), children: [
1005
+ quickReplies && quickReplies.length > 0 && /* @__PURE__ */ jsx12(
1006
+ "div",
1007
+ {
1008
+ className: cn(
1009
+ "mb-2 flex flex-nowrap gap-1 overflow-x-auto px-1 sm:flex-wrap sm:overflow-x-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
1010
+ classNames?.quickReplies
1011
+ ),
1012
+ children: quickReplies.map((quickReply) => /* @__PURE__ */ jsx12(
1013
+ "button",
1014
+ {
1015
+ type: "button",
1016
+ onClick: () => {
1017
+ setText(resolveQuickReply(quickReply, quickReplyVariables));
1018
+ textareaRef.current?.focus();
1019
+ },
1020
+ className: cn(
1021
+ "whitespace-nowrap rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400",
1022
+ classNames?.quickReply
1023
+ ),
1024
+ children: quickReply.label
1025
+ },
1026
+ quickReply.key
1027
+ ))
1028
+ }
1029
+ ),
997
1030
  attachments.length > 0 && /* @__PURE__ */ jsx12("div", { className: "flex gap-2 px-1 pb-2 overflow-x-auto", children: attachments.map((a, i) => /* @__PURE__ */ jsxs8("div", { className: "relative flex-shrink-0", children: [
998
1031
  a.previewUrl ? /* @__PURE__ */ jsx12("img", { src: a.previewUrl, alt: "", className: "w-16 h-16 object-cover rounded-lg border border-gray-200" }) : /* @__PURE__ */ jsx12("div", { className: "w-16 h-16 bg-gray-100 rounded-lg border border-gray-200 flex items-center justify-center", children: /* @__PURE__ */ jsxs8("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "#9ca3af", strokeWidth: "1.5", children: [
999
1032
  /* @__PURE__ */ jsx12("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }),
@@ -1724,6 +1757,8 @@ export {
1724
1757
  searchEmojis,
1725
1758
  DEFAULT_EMOJI_PICKER_LABELS,
1726
1759
  EmojiPicker,
1760
+ applyQuickReplyVariables,
1761
+ resolveQuickReply,
1727
1762
  DEFAULT_MESSAGE_COMPOSER_LABELS,
1728
1763
  DEFAULT_ACCEPTED_FILE_TYPES,
1729
1764
  MessageComposer,
package/dist/index.d.ts CHANGED
@@ -128,6 +128,25 @@ declare const EMOJI_CATEGORIES: readonly EmojiCategory[];
128
128
  */
129
129
  declare function searchEmojis(query: string): readonly EmojiEntry[];
130
130
 
131
+ /**
132
+ * Mensagem pronta que o atendente cola no campo com um clique.
133
+ *
134
+ * `text` aceita string com `{{variavel}}` ou função: a string cobre o caso comum (copy fixa com o
135
+ * nome do cliente no meio) sem o host escrever código, e a função cobre o que precisa de lógica —
136
+ * escolher texto por produto, pluralizar, formatar moeda.
137
+ */
138
+ interface QuickReply {
139
+ key: string;
140
+ /** O que aparece no chip, emoji incluso: `👋 Saudação`. */
141
+ label: string;
142
+ text: string | ((variables: Readonly<Record<string, string>>) => string);
143
+ }
144
+ /**
145
+ * Troca `{{nome}}` pelos valores passados. Variável ausente vira string vazia, e não o literal
146
+ * `{{nome}}`: mandar "Olá {{nome}}!" para o cliente é pior que mandar "Olá !".
147
+ */
148
+ declare function applyQuickReplyVariables(template: string, variables?: Readonly<Record<string, string>>): string;
149
+ declare function resolveQuickReply(quickReply: QuickReply, variables?: Readonly<Record<string, string>>): string;
131
150
  interface MessageComposerLabels {
132
151
  emoji: string;
133
152
  attach: string;
@@ -150,11 +169,17 @@ interface MessageComposerProps {
150
169
  * microfone. Fora do campo, o botão vira um bloco solto ao lado do pill e quebra a barra.
151
170
  */
152
171
  idleAction?: ReactNode;
172
+ /** Mensagens prontas exibidas acima do campo. Vazio ou ausente, a faixa não é renderizada. */
173
+ quickReplies?: readonly QuickReply[];
174
+ /** Valores para `{{variavel}}` — tipicamente nome do cliente, produto, protocolo. */
175
+ quickReplyVariables?: Readonly<Record<string, string>>;
153
176
  className?: string;
154
177
  classNames?: Partial<MessageComposerClassNames>;
155
178
  }
156
179
  interface MessageComposerClassNames {
157
180
  root: string;
181
+ quickReplies: string;
182
+ quickReply: string;
158
183
  field: string;
159
184
  }
160
185
  /**
@@ -164,7 +189,7 @@ interface MessageComposerClassNames {
164
189
  * Produto com regra própria passa `acceptedFileTypes`.
165
190
  */
166
191
  declare const DEFAULT_ACCEPTED_FILE_TYPES: string;
167
- declare const MessageComposer: ({ onSend, onAttach, value: externalValue, onChange: externalOnChange, features, placeholder, maxLength, disabled, acceptedFileTypes, idleAction, className, classNames, labels, }: MessageComposerProps) => react.JSX.Element;
192
+ declare const MessageComposer: ({ onSend, onAttach, value: externalValue, onChange: externalOnChange, quickReplies, quickReplyVariables, features, placeholder, maxLength, disabled, acceptedFileTypes, idleAction, className, classNames, labels, }: MessageComposerProps) => react.JSX.Element;
168
193
 
169
194
  interface WhatsAppMessageEditorLabels {
170
195
  bold: string;
@@ -1022,4 +1047,4 @@ interface AsyncResourceState<T> {
1022
1047
 
1023
1048
  declare function createMediaUrlResolver(api: Pick<ConversationsApi, 'getDocumentUrl' | 'getMediaProxyUrl'>): ResolveMediaUrl;
1024
1049
 
1025
- export { type AsyncResourceState, AudioPlayer, type AudioPlayerProps, Avatar, type AvatarLabels, type AvatarProps, type BuildTranscriptTextParams, CHANNEL_BRAND_COLOR, CONVERSATION_WINDOW, ChannelIcon, type ChannelIconProps, ConversationChannel, type ConversationContextEntry, ConversationContextPanel, type ConversationContextPanelClassNames, type ConversationContextPanelLabels, type ConversationContextPanelProps, ConversationDocument, ConversationDocumentsPanel, type ConversationDocumentsPanelClassNames, type ConversationDocumentsPanelLabels, type ConversationDocumentsPanelProps, ConversationHeader, type ConversationHeaderClassNames, type ConversationHeaderLabels, type ConversationHeaderProps, type ConversationHeaderUtility, ConversationListItem, type ConversationListItemLabels, type ConversationListItemProps, type ConversationLocales, ConversationLocalesProvider, type ConversationLocalesProviderProps, type ConversationRealtimeHandler, ConversationRow, type ConversationRowClassNames, type ConversationRowProps, ConversationSummary, ConversationTemplate, ConversationWallpaper, type ConversationWallpaperProps, type ConversationWindow, ConversationsApi, ConversationsFeatures, ConversationsProvider, DEFAULT_ACCEPTED_FILE_TYPES, DEFAULT_AVATAR_LABELS, DEFAULT_CONVERSATION_CONTEXT_LABELS, DEFAULT_CONVERSATION_DOCUMENTS_LABELS, DEFAULT_CONVERSATION_HEADER_LABELS, DEFAULT_CONVERSATION_LIST_ITEM_LABELS, DEFAULT_DOCUMENTS_LIBRARY_LABELS, DEFAULT_EMOJI_PICKER_LABELS, DEFAULT_INTERACTIVE_MESSAGE_LABELS, DEFAULT_LIGHTBOX_LABELS, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_TEMPLATES_SETTINGS_LABELS, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS, DEFAULT_WINDOW_EXPIRED_LABELS, DOCUMENT_SOURCE_FILTER, DateDivider, type DateDividerClassNames, type DateDividerProps, type DocumentSourceFilter, DocumentsLibrary, type DocumentsLibraryClassNames, type DocumentsLibraryLabels, type DocumentsLibraryProps, EMOJI_CATEGORIES, type EmojiCategory, type EmojiEntry, EmojiPicker, type EmojiPickerLabels, type EmojiPickerProps, FileIcon, type FileIconProps, InteractiveMessage, type InteractiveMessageLabels, type InteractiveMessageProps, InteractivePayload, InteractiveSelection, Lightbox, type LightboxLabels, type LightboxProps, ListConversationsParams, ListDocumentsParams, MessageBubble, type MessageBubbleProps, MessageComposer, type MessageComposerClassNames, type MessageComposerLabels, type MessageComposerProps, MessagePayload, MessageTail, MessageText, type MessageTextProps, MessageTimestamp, NARROW_MAX_WIDTH_PX, ResolveMediaUrl, SSEProvider, SimpleEmojiPicker, type SimpleEmojiPickerProps, StatusTicks, type StatusTicksProps, TEMPLATE_SETTINGS_TAB, type TemplateSettingsTab, ToastProvider, type TopicItem, TopicsForm, type TopicsFormLabels, type TopicsFormProps, type UseConversationActionsResult, type UseConversationContextResult, type UseConversationDocumentsParams, type UseConversationDocumentsResult, type UseConversationListParams, type UseConversationListResult, type UseConversationMessagesResult, type UseInboxActionsResult, type UseWaitingNotificationsLabels, type UseWaitingNotificationsParams, type UseWaitingNotificationsResult, WINDOW_FILTERS, WelcomeFarewellForm, type WelcomeFarewellFormLabels, type WelcomeFarewellFormProps, WhatsAppCreateTemplateForm, type WhatsAppCreateTemplateFormLabels, type WhatsAppCreateTemplateFormProps, type WhatsAppCreateTemplateResult, type WhatsAppCreateTemplateState, WhatsAppMessageEditor, type WhatsAppMessageEditorLabels, type WhatsAppMessageEditorProps, type WhatsAppTemplateHeaderType, WhatsAppTemplateSettingsForm, type WhatsAppTemplateSettingsFormLabels, type WhatsAppTemplateSettingsFormProps, type WhatsAppTemplateSummary, type WhatsAppTemplateVariableSuggestion, WhatsAppTemplatesSettings, type WhatsAppTemplatesSettingsLabels, type WhatsAppTemplatesSettingsProps, WindowExpiredNotice, type WindowExpiredNoticeLabels, type WindowExpiredNoticeProps, type WindowOfParams, buildTranscriptFilename, buildTranscriptText, createMediaUrlResolver, downloadTextFile, formatDateTime, formatFileSize, formatPhone, formatStalledFor, formatTimestamp, htmlToWA, isSameDay, isWindowBlocking, parseWhatsAppFormatting, phoneInitials, searchEmojis, toast, useConversationActions, useConversationContext, useConversationDocuments, useConversationList, useConversationLocales, useConversationMessages, useConversationRealtime, useConversations, useDarkMode, useGlobalRealtime, useInboxActions, useIsDarkTheme, useIsNarrow, useToast, useWaitingNotifications, waToHTML, waToHTMLInline, windowOf };
1050
+ export { type AsyncResourceState, AudioPlayer, type AudioPlayerProps, Avatar, type AvatarLabels, type AvatarProps, type BuildTranscriptTextParams, CHANNEL_BRAND_COLOR, CONVERSATION_WINDOW, ChannelIcon, type ChannelIconProps, ConversationChannel, type ConversationContextEntry, ConversationContextPanel, type ConversationContextPanelClassNames, type ConversationContextPanelLabels, type ConversationContextPanelProps, ConversationDocument, ConversationDocumentsPanel, type ConversationDocumentsPanelClassNames, type ConversationDocumentsPanelLabels, type ConversationDocumentsPanelProps, ConversationHeader, type ConversationHeaderClassNames, type ConversationHeaderLabels, type ConversationHeaderProps, type ConversationHeaderUtility, ConversationListItem, type ConversationListItemLabels, type ConversationListItemProps, type ConversationLocales, ConversationLocalesProvider, type ConversationLocalesProviderProps, type ConversationRealtimeHandler, ConversationRow, type ConversationRowClassNames, type ConversationRowProps, ConversationSummary, ConversationTemplate, ConversationWallpaper, type ConversationWallpaperProps, type ConversationWindow, ConversationsApi, ConversationsFeatures, ConversationsProvider, DEFAULT_ACCEPTED_FILE_TYPES, DEFAULT_AVATAR_LABELS, DEFAULT_CONVERSATION_CONTEXT_LABELS, DEFAULT_CONVERSATION_DOCUMENTS_LABELS, DEFAULT_CONVERSATION_HEADER_LABELS, DEFAULT_CONVERSATION_LIST_ITEM_LABELS, DEFAULT_DOCUMENTS_LIBRARY_LABELS, DEFAULT_EMOJI_PICKER_LABELS, DEFAULT_INTERACTIVE_MESSAGE_LABELS, DEFAULT_LIGHTBOX_LABELS, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_TEMPLATES_SETTINGS_LABELS, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS, DEFAULT_WINDOW_EXPIRED_LABELS, DOCUMENT_SOURCE_FILTER, DateDivider, type DateDividerClassNames, type DateDividerProps, type DocumentSourceFilter, DocumentsLibrary, type DocumentsLibraryClassNames, type DocumentsLibraryLabels, type DocumentsLibraryProps, EMOJI_CATEGORIES, type EmojiCategory, type EmojiEntry, EmojiPicker, type EmojiPickerLabels, type EmojiPickerProps, FileIcon, type FileIconProps, InteractiveMessage, type InteractiveMessageLabels, type InteractiveMessageProps, InteractivePayload, InteractiveSelection, Lightbox, type LightboxLabels, type LightboxProps, ListConversationsParams, ListDocumentsParams, MessageBubble, type MessageBubbleProps, MessageComposer, type MessageComposerClassNames, type MessageComposerLabels, type MessageComposerProps, MessagePayload, MessageTail, MessageText, type MessageTextProps, MessageTimestamp, NARROW_MAX_WIDTH_PX, type QuickReply, ResolveMediaUrl, SSEProvider, SimpleEmojiPicker, type SimpleEmojiPickerProps, StatusTicks, type StatusTicksProps, TEMPLATE_SETTINGS_TAB, type TemplateSettingsTab, ToastProvider, type TopicItem, TopicsForm, type TopicsFormLabels, type TopicsFormProps, type UseConversationActionsResult, type UseConversationContextResult, type UseConversationDocumentsParams, type UseConversationDocumentsResult, type UseConversationListParams, type UseConversationListResult, type UseConversationMessagesResult, type UseInboxActionsResult, type UseWaitingNotificationsLabels, type UseWaitingNotificationsParams, type UseWaitingNotificationsResult, WINDOW_FILTERS, WelcomeFarewellForm, type WelcomeFarewellFormLabels, type WelcomeFarewellFormProps, WhatsAppCreateTemplateForm, type WhatsAppCreateTemplateFormLabels, type WhatsAppCreateTemplateFormProps, type WhatsAppCreateTemplateResult, type WhatsAppCreateTemplateState, WhatsAppMessageEditor, type WhatsAppMessageEditorLabels, type WhatsAppMessageEditorProps, type WhatsAppTemplateHeaderType, WhatsAppTemplateSettingsForm, type WhatsAppTemplateSettingsFormLabels, type WhatsAppTemplateSettingsFormProps, type WhatsAppTemplateSummary, type WhatsAppTemplateVariableSuggestion, WhatsAppTemplatesSettings, type WhatsAppTemplatesSettingsLabels, type WhatsAppTemplatesSettingsProps, WindowExpiredNotice, type WindowExpiredNoticeLabels, type WindowExpiredNoticeProps, type WindowOfParams, applyQuickReplyVariables, buildTranscriptFilename, buildTranscriptText, createMediaUrlResolver, downloadTextFile, formatDateTime, formatFileSize, formatPhone, formatStalledFor, formatTimestamp, htmlToWA, isSameDay, isWindowBlocking, parseWhatsAppFormatting, phoneInitials, resolveQuickReply, searchEmojis, toast, useConversationActions, useConversationContext, useConversationDocuments, useConversationList, useConversationLocales, useConversationMessages, useConversationRealtime, useConversations, useDarkMode, useGlobalRealtime, useInboxActions, useIsDarkTheme, useIsNarrow, useToast, useWaitingNotifications, waToHTML, waToHTMLInline, windowOf };
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  MessageBubble,
24
24
  MessageComposer,
25
25
  StatusTicks,
26
+ applyQuickReplyVariables,
26
27
  cn,
27
28
  conversationsOf,
28
29
  createMediaUrlResolver,
@@ -33,13 +34,14 @@ import {
33
34
  isSameDay,
34
35
  phoneCountryFlag,
35
36
  phoneInitials,
37
+ resolveQuickReply,
36
38
  searchEmojis,
37
39
  totalOf,
38
40
  useAsyncResource,
39
41
  useConversationDocuments,
40
42
  useConversationLocales,
41
43
  useConversations
42
- } from "./chunk-FWNAWAJP.js";
44
+ } from "./chunk-3RKFU46R.js";
43
45
  import {
44
46
  htmlToWA,
45
47
  parseWhatsAppFormatting,
@@ -2076,6 +2078,7 @@ export {
2076
2078
  WhatsAppTemplateSettingsForm,
2077
2079
  WhatsAppTemplatesSettings,
2078
2080
  WindowExpiredNotice,
2081
+ applyQuickReplyVariables,
2079
2082
  buildTranscriptFilename,
2080
2083
  buildTranscriptText,
2081
2084
  capabilitiesOf,
@@ -2094,6 +2097,7 @@ export {
2094
2097
  isWindowBlocking,
2095
2098
  parseWhatsAppFormatting,
2096
2099
  phoneInitials,
2100
+ resolveQuickReply,
2097
2101
  searchEmojis,
2098
2102
  toast,
2099
2103
  useConversationActions,
@@ -224,10 +224,21 @@ declare const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels;
224
224
  interface AudioRecorderButtonProps {
225
225
  onRecorded: (file: File) => void | Promise<void>;
226
226
  onFailure?: (message: string) => void;
227
+ /**
228
+ * Avisa quando a gravação começa e termina. O botão é um interruptor — o segundo toque é que
229
+ * envia — e sem um aviso fora dele o operador grava, não vê nada acontecer e desiste achando
230
+ * que o microfone está quebrado.
231
+ */
232
+ onRecordingChange?: (isRecording: boolean) => void;
233
+ /**
234
+ * Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
235
+ * tem. Produto com limite próprio sobrescreve.
236
+ */
237
+ maxDurationMilliseconds?: number;
227
238
  labels?: Partial<AudioRecorderButtonLabels>;
228
239
  disabled?: boolean;
229
240
  }
230
- declare function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }: AudioRecorderButtonProps): react.JSX.Element;
241
+ declare function AudioRecorderButton({ onRecorded, onFailure, onRecordingChange, maxDurationMilliseconds, labels, disabled, }: AudioRecorderButtonProps): react.JSX.Element;
231
242
 
232
243
  /**
233
244
  * Roteiro que mantém o preview vivo: sem tráfego chegando, a inbox é uma tela estática e as
@@ -6,7 +6,7 @@ import {
6
6
  DocumentsLibrary,
7
7
  MessageBubble,
8
8
  MessageComposer
9
- } from "../chunk-FWNAWAJP.js";
9
+ } from "../chunk-3RKFU46R.js";
10
10
  import "../chunk-2AYDBWNE.js";
11
11
 
12
12
  // src/preview/previewStore.ts
@@ -856,6 +856,7 @@ var DEFAULT_AUDIO_RECORDER_BUTTON_LABELS = {
856
856
  unsupported: "Este navegador n\xE3o grava \xE1udio.",
857
857
  denied: "Sem permiss\xE3o para usar o microfone."
858
858
  };
859
+ var DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1e3;
859
860
  var RECORDING_FORMATS = [
860
861
  { mimeType: "audio/ogg;codecs=opus", uploadMimeType: "audio/ogg", extension: "ogg" },
861
862
  { mimeType: "audio/mp4", uploadMimeType: "audio/mp4", extension: "m4a" },
@@ -866,11 +867,19 @@ function resolveRecordingFormat() {
866
867
  if (typeof MediaRecorder.isTypeSupported !== "function") return RECORDING_FORMATS[0];
867
868
  return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType));
868
869
  }
869
- function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }) {
870
+ function AudioRecorderButton({
871
+ onRecorded,
872
+ onFailure,
873
+ onRecordingChange,
874
+ maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
875
+ labels,
876
+ disabled
877
+ }) {
870
878
  const startLabel = labels?.start ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start;
871
879
  const stopLabel = labels?.stop ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.stop;
872
880
  const [isRecording, setIsRecording] = useState(false);
873
881
  const recorderRef = useRef(null);
882
+ const autoStopRef = useRef(void 0);
874
883
  const stop = useCallback(() => {
875
884
  recorderRef.current?.stop();
876
885
  }, []);
@@ -889,7 +898,9 @@ function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }) {
889
898
  });
890
899
  recorder.addEventListener("stop", () => {
891
900
  stream.getTracks().forEach((track) => track.stop());
901
+ clearTimeout(autoStopRef.current);
892
902
  setIsRecording(false);
903
+ onRecordingChange?.(false);
893
904
  recorderRef.current = null;
894
905
  const blob = new Blob(chunks, { type: format.uploadMimeType });
895
906
  void onRecorded(
@@ -898,11 +909,13 @@ function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }) {
898
909
  });
899
910
  recorderRef.current = recorder;
900
911
  recorder.start();
912
+ autoStopRef.current = setTimeout(() => recorder.stop(), maxDurationMilliseconds);
901
913
  setIsRecording(true);
914
+ onRecordingChange?.(true);
902
915
  } catch {
903
916
  onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied);
904
917
  }
905
- }, [labels?.denied, labels?.unsupported, onFailure, onRecorded]);
918
+ }, [labels?.denied, labels?.unsupported, maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange]);
906
919
  return /* @__PURE__ */ jsx(
907
920
  "button",
908
921
  {
@@ -912,7 +925,7 @@ function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }) {
912
925
  title: isRecording ? stopLabel : startLabel,
913
926
  "aria-label": isRecording ? stopLabel : startLabel,
914
927
  "aria-pressed": isRecording,
915
- className: `flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-colors ${isRecording ? "bg-red-500 text-white hover:bg-red-600" : "text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700"}`,
928
+ className: `flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-colors ${isRecording ? "animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600" : "text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700"}`,
916
929
  children: isRecording ? /* @__PURE__ */ jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ jsx("rect", { x: "6", y: "6", width: "12", height: "12", rx: "2" }) }) : /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", children: [
917
930
  /* @__PURE__ */ jsx("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" }),
918
931
  /* @__PURE__ */ jsx("path", { d: "M19 11a7 7 0 0 1-14 0" }),
@@ -973,6 +986,7 @@ function ConversationPreview({
973
986
  const [messages, setMessages] = useState2([]);
974
987
  const [failure, setFailure] = useState2(void 0);
975
988
  const [loadFailure, setLoadFailure] = useState2(void 0);
989
+ const [isRecording, setIsRecording] = useState2(false);
976
990
  const [pendingLocal, setPendingLocal] = useState2([]);
977
991
  const loadMessagesRef = useRef2(loadMessages);
978
992
  const bottomRef = useRef2(null);
@@ -1093,12 +1107,13 @@ function ConversationPreview({
1093
1107
  {
1094
1108
  onSend: (text) => void handleSend(text),
1095
1109
  onAttach: uploadMedia ? (file) => void handleAttach(file) : void 0,
1096
- placeholder: placeholder ?? "Escreva como o cliente\u2026",
1110
+ placeholder: isRecording ? "Gravando\u2026 toque no quadrado para enviar" : placeholder ?? "Escreva como o cliente\u2026",
1097
1111
  idleAction: uploadMedia ? /* @__PURE__ */ jsx2(
1098
1112
  AudioRecorderButton,
1099
1113
  {
1100
1114
  onRecorded: (file) => void handleAttach(file),
1101
- onFailure: (message) => setFailure(message)
1115
+ onFailure: (message) => setFailure(message),
1116
+ onRecordingChange: setIsRecording
1102
1117
  }
1103
1118
  ) : void 0
1104
1119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.10",
3
+ "version": "0.1.0-rc.11",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -3,6 +3,40 @@ import type { ConversationsFeatures } from './types'
3
3
  import { cn } from './lib/cn'
4
4
  import { EmojiPicker } from './EmojiPicker'
5
5
 
6
+ /**
7
+ * Mensagem pronta que o atendente cola no campo com um clique.
8
+ *
9
+ * `text` aceita string com `{{variavel}}` ou função: a string cobre o caso comum (copy fixa com o
10
+ * nome do cliente no meio) sem o host escrever código, e a função cobre o que precisa de lógica —
11
+ * escolher texto por produto, pluralizar, formatar moeda.
12
+ */
13
+ export interface QuickReply {
14
+ key: string
15
+ /** O que aparece no chip, emoji incluso: `👋 Saudação`. */
16
+ label: string
17
+ text: string | ((variables: Readonly<Record<string, string>>) => string)
18
+ }
19
+
20
+ /**
21
+ * Troca `{{nome}}` pelos valores passados. Variável ausente vira string vazia, e não o literal
22
+ * `{{nome}}`: mandar "Olá {{nome}}!" para o cliente é pior que mandar "Olá !".
23
+ */
24
+ export function applyQuickReplyVariables(
25
+ template: string,
26
+ variables: Readonly<Record<string, string>> = {},
27
+ ): string {
28
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, chave: string) => variables[chave] ?? '')
29
+ }
30
+
31
+ export function resolveQuickReply(
32
+ quickReply: QuickReply,
33
+ variables: Readonly<Record<string, string>> = {},
34
+ ): string {
35
+ return typeof quickReply.text === 'function'
36
+ ? quickReply.text(variables)
37
+ : applyQuickReplyVariables(quickReply.text, variables)
38
+ }
39
+
6
40
  export interface MessageComposerLabels {
7
41
  emoji: string
8
42
  attach: string
@@ -31,12 +65,18 @@ export interface MessageComposerProps {
31
65
  * microfone. Fora do campo, o botão vira um bloco solto ao lado do pill e quebra a barra.
32
66
  */
33
67
  idleAction?: ReactNode
68
+ /** Mensagens prontas exibidas acima do campo. Vazio ou ausente, a faixa não é renderizada. */
69
+ quickReplies?: readonly QuickReply[]
70
+ /** Valores para `{{variavel}}` — tipicamente nome do cliente, produto, protocolo. */
71
+ quickReplyVariables?: Readonly<Record<string, string>>
34
72
  className?: string
35
73
  classNames?: Partial<MessageComposerClassNames>
36
74
  }
37
75
 
38
76
  export interface MessageComposerClassNames {
39
77
  root: string
78
+ quickReplies: string
79
+ quickReply: string
40
80
  field: string
41
81
  }
42
82
 
@@ -67,6 +107,8 @@ export const MessageComposer = ({
67
107
  onAttach,
68
108
  value: externalValue,
69
109
  onChange: externalOnChange,
110
+ quickReplies,
111
+ quickReplyVariables,
70
112
  features,
71
113
  placeholder = 'Digite uma mensagem...',
72
114
  maxLength,
@@ -185,6 +227,37 @@ export const MessageComposer = ({
185
227
  ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
186
228
  descobriam o fundo branco da página, que lia como defeito. */
187
229
  <div className={cn('bg-[#f0f2f5] px-2 py-2', className)}>
230
+ {/* Uma linha com scroll no celular e wrap no desktop: em 375px seis chips em wrap empurrariam
231
+ o campo para fora da tela, e o campo é a razão de a barra existir. */}
232
+ {quickReplies && quickReplies.length > 0 && (
233
+ <div
234
+ className={cn(
235
+ 'mb-2 flex flex-nowrap gap-1 overflow-x-auto px-1 sm:flex-wrap sm:overflow-x-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
236
+ classNames?.quickReplies,
237
+ )}
238
+ >
239
+ {quickReplies.map((quickReply) => (
240
+ <button
241
+ key={quickReply.key}
242
+ type="button"
243
+ // Preenche o campo em vez de enviar: mensagem pronta é ponto de partida, e quem
244
+ // atende quase sempre ajusta uma palavra antes de mandar. Enviar direto no clique
245
+ // transformaria um toque errado em mensagem entregue ao cliente.
246
+ onClick={() => {
247
+ setText(resolveQuickReply(quickReply, quickReplyVariables))
248
+ textareaRef.current?.focus()
249
+ }}
250
+ className={cn(
251
+ 'whitespace-nowrap rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400',
252
+ classNames?.quickReply,
253
+ )}
254
+ >
255
+ {quickReply.label}
256
+ </button>
257
+ ))}
258
+ </div>
259
+ )}
260
+
188
261
  {attachments.length > 0 && (
189
262
  <div className="flex gap-2 px-1 pb-2 overflow-x-auto">
190
263
  {attachments.map((a, i) => (
package/src/index.ts CHANGED
@@ -181,3 +181,5 @@ export type { ConversationRealtimeHandler } from './hooks/useConversationRealtim
181
181
  export type { AsyncResourceState } from './hooks/useAsyncResource'
182
182
  export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
183
183
  export type { ConversationHeaderUtility } from './ConversationHeader'
184
+ export { applyQuickReplyVariables, resolveQuickReply } from './MessageComposer'
185
+ export type { QuickReply } from './MessageComposer'
@@ -27,10 +27,28 @@ export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
27
27
  export interface AudioRecorderButtonProps {
28
28
  onRecorded: (file: File) => void | Promise<void>
29
29
  onFailure?: (message: string) => void
30
+ /**
31
+ * Avisa quando a gravação começa e termina. O botão é um interruptor — o segundo toque é que
32
+ * envia — e sem um aviso fora dele o operador grava, não vê nada acontecer e desiste achando
33
+ * que o microfone está quebrado.
34
+ */
35
+ onRecordingChange?: (isRecording: boolean) => void
36
+ /**
37
+ * Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
38
+ * tem. Produto com limite próprio sobrescreve.
39
+ */
40
+ maxDurationMilliseconds?: number
30
41
  labels?: Partial<AudioRecorderButtonLabels>
31
42
  disabled?: boolean
32
43
  }
33
44
 
45
+ /**
46
+ * Cinco minutos: com o codec de voz do WhatsApp isso dá menos de 3MB, folgado dentro do teto de
47
+ * 16MB que a Meta impõe a áudio, e é mais do que qualquer recado de cliente. O corte automático
48
+ * existe porque gravação esquecida aberta só se descobre no envio, com o arquivo inteiro perdido.
49
+ */
50
+ export const DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1000
51
+
34
52
  /**
35
53
  * Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
36
54
  * saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
@@ -52,11 +70,19 @@ export function resolveRecordingFormat(): RecordingFormat | undefined {
52
70
  return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
53
71
  }
54
72
 
55
- export function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }: AudioRecorderButtonProps) {
73
+ export function AudioRecorderButton({
74
+ onRecorded,
75
+ onFailure,
76
+ onRecordingChange,
77
+ maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
78
+ labels,
79
+ disabled,
80
+ }: AudioRecorderButtonProps) {
56
81
  const startLabel = labels?.start ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start
57
82
  const stopLabel = labels?.stop ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.stop
58
83
  const [isRecording, setIsRecording] = useState(false)
59
84
  const recorderRef = useRef<MediaRecorder | null>(null)
85
+ const autoStopRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
60
86
 
61
87
  const stop = useCallback(() => {
62
88
  recorderRef.current?.stop()
@@ -81,7 +107,9 @@ export function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }:
81
107
  // Solta o microfone assim que para: sem isto o indicador de gravação do navegador fica
82
108
  // aceso depois do envio, e o operador acha que o simulador continua ouvindo.
83
109
  stream.getTracks().forEach((track) => track.stop())
110
+ clearTimeout(autoStopRef.current)
84
111
  setIsRecording(false)
112
+ onRecordingChange?.(false)
85
113
  recorderRef.current = null
86
114
  // O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
87
115
  // gravador, mas quem valida upload compara com `audio/ogg` puro.
@@ -93,11 +121,13 @@ export function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }:
93
121
 
94
122
  recorderRef.current = recorder
95
123
  recorder.start()
124
+ autoStopRef.current = setTimeout(() => recorder.stop(), maxDurationMilliseconds)
96
125
  setIsRecording(true)
126
+ onRecordingChange?.(true)
97
127
  } catch {
98
128
  onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
99
129
  }
100
- }, [labels?.denied, labels?.unsupported, onFailure, onRecorded])
130
+ }, [labels?.denied, labels?.unsupported, maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange])
101
131
 
102
132
  return (
103
133
  <button
@@ -111,7 +141,7 @@ export function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }:
111
141
  vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
112
142
  className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-colors ${
113
143
  isRecording
114
- ? 'bg-red-500 text-white hover:bg-red-600'
144
+ ? 'animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600'
115
145
  : 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
116
146
  }`}
117
147
  >
@@ -128,6 +128,7 @@ export function ConversationPreview({
128
128
  const [messages, setMessages] = useState<MessagePayload[]>([])
129
129
  const [failure, setFailure] = useState<string | undefined>(undefined)
130
130
  const [loadFailure, setLoadFailure] = useState<string | undefined>(undefined)
131
+ const [isRecording, setIsRecording] = useState(false)
131
132
  // Mensagens que o servidor ainda não devolveu. Sem isto, quem não consegue LER a conversa (sessão
132
133
  // ausente, API fora) digita, envia com sucesso e não vê absolutamente nada mudar — o preview fica
133
134
  // indistinguível de quebrado. São descartadas assim que uma leitura dá certo: aí quem manda na
@@ -300,12 +301,18 @@ export function ConversationPreview({
300
301
  <MessageComposer
301
302
  onSend={(text) => void handleSend(text)}
302
303
  onAttach={uploadMedia ? (file) => void handleAttach(file) : undefined}
303
- placeholder={placeholder ?? 'Escreva como o cliente…'}
304
+ /* Gravando, o campo diz o que falta fazer: o botão é um interruptor e o segundo toque é
305
+ que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
306
+ microfone está quebrado. */
307
+ placeholder={
308
+ isRecording ? 'Gravando… toque no quadrado para enviar' : (placeholder ?? 'Escreva como o cliente…')
309
+ }
304
310
  idleAction={
305
311
  uploadMedia ? (
306
312
  <AudioRecorderButton
307
313
  onRecorded={(file) => void handleAttach(file)}
308
314
  onFailure={(message) => setFailure(message)}
315
+ onRecordingChange={setIsRecording}
309
316
  />
310
317
  ) : undefined
311
318
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Guarda a interpolação das mensagens rápidas.
3
+ *
4
+ * O caso que decide o desenho: variável ausente. Deixar `{{nome}}` no texto significa o atendente
5
+ * mandar "Olá {{nome}}!" para o cliente — pior que a saudação sem nome.
6
+ */
7
+
8
+ import { describe, expect, it } from 'bun:test'
9
+
10
+ import { applyQuickReplyVariables, resolveQuickReply } from './MessageComposer'
11
+
12
+ describe('applyQuickReplyVariables', () => {
13
+ it('troca a variável pelo valor', () => {
14
+ expect(applyQuickReplyVariables('Olá {{nome}}!', { nome: 'Marina' })).toBe('Olá Marina!')
15
+ })
16
+
17
+ it('aceita espaço dentro das chaves', () => {
18
+ expect(applyQuickReplyVariables('Olá {{ nome }}!', { nome: 'Rita' })).toBe('Olá Rita!')
19
+ })
20
+
21
+ it('troca todas as ocorrências', () => {
22
+ expect(applyQuickReplyVariables('{{nome}}, confirma? Obrigado, {{nome}}.', { nome: 'Ana' })).toBe(
23
+ 'Ana, confirma? Obrigado, Ana.',
24
+ )
25
+ })
26
+
27
+ // Nunca vaza o literal para o cliente.
28
+ it('apaga a variável que não foi passada', () => {
29
+ expect(applyQuickReplyVariables('Olá {{nome}}!', {})).toBe('Olá !')
30
+ expect(applyQuickReplyVariables('Olá {{nome}}!')).toBe('Olá !')
31
+ })
32
+
33
+ it('não mexe em texto sem variável', () => {
34
+ expect(applyQuickReplyVariables('Bom dia!', { nome: 'X' })).toBe('Bom dia!')
35
+ })
36
+ })
37
+
38
+ describe('resolveQuickReply', () => {
39
+ it('interpola quando o texto é string', () => {
40
+ const resolvido = resolveQuickReply({ key: 'g', label: '👋', text: 'Olá {{nome}}!' }, { nome: 'Rita' })
41
+
42
+ expect(resolvido).toBe('Olá Rita!')
43
+ })
44
+
45
+ // A função existe para o que a string não resolve: escolher copy por produto, pluralizar, formatar.
46
+ it('chama a função com as variáveis', () => {
47
+ const resolvido = resolveQuickReply(
48
+ { key: 's', label: '📋', text: (variables) => `Status de ${variables['produto'] ?? 'seu pedido'}` },
49
+ { produto: 'financiamento' },
50
+ )
51
+
52
+ expect(resolvido).toBe('Status de financiamento')
53
+ })
54
+
55
+ it('função sem variáveis não quebra', () => {
56
+ expect(resolveQuickReply({ key: 'c', label: '📞', text: () => 'Posso ligar?' })).toBe('Posso ligar?')
57
+ })
58
+ })