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

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.
@@ -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.12",
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) => (
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { RichMessageComposer, type RichComposerQuickReply, type RichComposerVariable } from './RichMessageComposer'
5
+
6
+ const noop = () => {}
7
+
8
+ const QUICK_REPLIES: RichComposerQuickReply[] = [
9
+ { id: 'greeting', label: 'Saudação', text: 'Olá!', tooltip: 'Abre o atendimento' },
10
+ ]
11
+
12
+ const VARIABLES: RichComposerVariable[] = [
13
+ { id: 'name', label: 'Nome do cliente', value: '{{nome}}' },
14
+ ]
15
+
16
+ describe('RichMessageComposer', () => {
17
+ it('mostra as dicas padrão em todas as ações da barra', () => {
18
+ const markup = renderToStaticMarkup(
19
+ <RichMessageComposer value="" onChange={noop} onSend={noop} onAttachFiles={noop} />,
20
+ )
21
+
22
+ for (const tooltip of ['Negrito', 'Itálico', 'Tachado', 'Monoespaçado', 'Anexar arquivo', 'Enviar']) {
23
+ expect(markup).toContain(`title="${tooltip}"`)
24
+ }
25
+ })
26
+
27
+ it('deixa o host trocar o texto de uma dica sem mexer nas outras', () => {
28
+ const markup = renderToStaticMarkup(
29
+ <RichMessageComposer value="" onChange={noop} onSend={noop} tooltips={{ bold: 'Deixar em negrito' }} />,
30
+ )
31
+
32
+ expect(markup).toContain('title="Deixar em negrito"')
33
+ expect(markup).not.toContain('title="Negrito"')
34
+ expect(markup).toContain('title="Itálico"')
35
+ })
36
+
37
+ it('esconde só a ação que o host desligou', () => {
38
+ const markup = renderToStaticMarkup(
39
+ <RichMessageComposer value="" onChange={noop} onSend={noop} toolbar={{ monospace: false }} />,
40
+ )
41
+
42
+ expect(markup).not.toContain('title="Monoespaçado"')
43
+ expect(markup).toContain('title="Negrito"')
44
+ })
45
+
46
+ it('não desenha o anexo quando não há como anexar', () => {
47
+ const markup = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
48
+
49
+ expect(markup).not.toContain('title="Anexar arquivo"')
50
+ })
51
+
52
+ it('desenha os chips de resposta rápida com o rótulo e a dica do host', () => {
53
+ const markup = renderToStaticMarkup(
54
+ <RichMessageComposer value="" onChange={noop} onSend={noop} quickReplies={QUICK_REPLIES} />,
55
+ )
56
+
57
+ expect(markup).toContain('Saudação')
58
+ expect(markup).toContain('title="Abre o atendimento"')
59
+ })
60
+
61
+ it('só oferece variáveis quando o host manda alguma', () => {
62
+ const semVariaveis = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
63
+ const comVariaveis = renderToStaticMarkup(
64
+ <RichMessageComposer value="" onChange={noop} onSend={noop} variables={VARIABLES} />,
65
+ )
66
+
67
+ expect(semVariaveis).not.toContain('title="Variáveis disponíveis"')
68
+ expect(comVariaveis).toContain('title="Variáveis disponíveis"')
69
+ })
70
+
71
+ it('cede o lugar do enviar à ação ociosa enquanto não há texto', () => {
72
+ const vazio = renderToStaticMarkup(
73
+ <RichMessageComposer value="" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
74
+ )
75
+ const comTexto = renderToStaticMarkup(
76
+ <RichMessageComposer value="oi" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
77
+ )
78
+
79
+ expect(vazio).toContain('aria-label="Gravar áudio"')
80
+ expect(vazio).not.toContain('title="Enviar"')
81
+ expect(comTexto).toContain('title="Enviar"')
82
+ })
83
+ })