@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.
- package/dist/{chunk-FWNAWAJP.js → chunk-3RKFU46R.js} +35 -0
- package/dist/index.d.ts +27 -2
- package/dist/index.js +5 -1
- package/dist/preview/index.d.ts +12 -1
- package/dist/preview/index.js +21 -6
- package/package.json +1 -1
- package/src/MessageComposer.tsx +73 -0
- package/src/index.ts +2 -0
- package/src/preview/AudioRecorderButton.tsx +33 -3
- package/src/preview/ConversationPreview.tsx +8 -1
- package/src/quickReply.test.ts +58 -0
|
@@ -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-
|
|
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,
|
package/dist/preview/index.d.ts
CHANGED
|
@@ -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
|
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-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({
|
|
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
package/src/MessageComposer.tsx
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
+
})
|