@erlancarreira/evolution-chat 0.1.2 → 0.1.3

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/widget/index.cjs CHANGED
@@ -58,7 +58,8 @@ var WIDGET_KEYS = [
58
58
  "retry",
59
59
  "sessionClosed",
60
60
  "newConversation",
61
- "poweredBy"
61
+ "poweredBy",
62
+ "typing"
62
63
  ];
63
64
  var pt = {
64
65
  openChat: "Abrir chat",
@@ -75,7 +76,8 @@ var pt = {
75
76
  retry: "Tentar novamente",
76
77
  sessionClosed: "Esta conversa foi encerrada. Abra uma nova para continuar.",
77
78
  newConversation: "Iniciar nova conversa",
78
- poweredBy: "Powered by"
79
+ poweredBy: "Powered by",
80
+ typing: "digitando\u2026"
79
81
  };
80
82
  var en = {
81
83
  openChat: "Open chat",
@@ -92,7 +94,8 @@ var en = {
92
94
  retry: "Try again",
93
95
  sessionClosed: "This conversation was closed. Start a new one to continue.",
94
96
  newConversation: "Start a new conversation",
95
- poweredBy: "Powered by"
97
+ poweredBy: "Powered by",
98
+ typing: "typing\u2026"
96
99
  };
97
100
  var es = {
98
101
  openChat: "Abrir chat",
@@ -109,7 +112,8 @@ var es = {
109
112
  retry: "Reintentar",
110
113
  sessionClosed: "Esta conversaci\xF3n fue cerrada. Abre una nueva para continuar.",
111
114
  newConversation: "Iniciar nueva conversaci\xF3n",
112
- poweredBy: "Powered by"
115
+ poweredBy: "Powered by",
116
+ typing: "escribiendo\u2026"
113
117
  };
114
118
  var dictionaries = { pt, en, es };
115
119
  function t(locale, key, overrides) {
@@ -358,6 +362,31 @@ var WIDGET_CSS = `/*
358
362
  .ecw-send:disabled { opacity: 0.55; cursor: not-allowed; }
359
363
  .ecw-send svg { width: 18px; height: 18px; }
360
364
 
365
+ /* \u2500\u2500 indicador "digitando\u2026" (3 pontinhos) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
366
+ .ecw-typing {
367
+ display: inline-flex;
368
+ align-items: center;
369
+ gap: 4px;
370
+ padding: 10px 14px;
371
+ border-radius: 12px;
372
+ border-bottom-left-radius: 4px;
373
+ background: var(--ecw-surface);
374
+ border: 1px solid var(--ecw-border);
375
+ }
376
+ .ecw-typing-dot {
377
+ width: 7px;
378
+ height: 7px;
379
+ border-radius: 50%;
380
+ background: var(--ecw-muted);
381
+ animation: ecw-typing-bounce 1.2s infinite ease-in-out;
382
+ }
383
+ .ecw-typing-dot:nth-child(2) { animation-delay: 0.18s; }
384
+ .ecw-typing-dot:nth-child(3) { animation-delay: 0.36s; }
385
+ @keyframes ecw-typing-bounce {
386
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
387
+ 30% { transform: translateY(-4px); opacity: 1; }
388
+ }
389
+
361
390
  /* \u2500\u2500 sess\xE3o encerrada / falha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
362
391
  .ecw-closed { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--ecw-border); background: var(--ecw-surface); }
363
392
 
@@ -422,17 +451,23 @@ var initialState = {
422
451
  messages: [],
423
452
  unread: 0,
424
453
  sending: false,
425
- error: null
454
+ error: null,
455
+ ownerTyping: false,
456
+ visitorTyping: false
426
457
  };
427
458
  function withMessage(state, message) {
428
459
  const index = state.messages.findIndex((m) => m.id === message.id);
460
+ let messages;
429
461
  if (index >= 0) {
430
- const messages = [...state.messages];
462
+ messages = [...state.messages];
431
463
  messages[index] = message;
432
- return { ...state, messages };
464
+ } else {
465
+ messages = [...state.messages, message];
433
466
  }
434
467
  const bump = state.open || message.direction !== "owner" ? 0 : 1;
435
- return { ...state, messages: [...state.messages, message], unread: state.unread + bump };
468
+ const next = { ...state, messages, unread: state.unread + bump };
469
+ if (message.direction === "owner") next.ownerTyping = false;
470
+ return next;
436
471
  }
437
472
  function chatReducer(state, action) {
438
473
  switch (action.type) {
@@ -496,6 +531,8 @@ function chatReducer(state, action) {
496
531
  return { ...state, sending: action.value };
497
532
  case "error":
498
533
  return { ...state, error: action.value };
534
+ case "typing":
535
+ return action.from === "owner" ? state.ownerTyping === action.isTyping ? state : { ...state, ownerTyping: action.isTyping } : state.visitorTyping === action.isTyping ? state : { ...state, visitorTyping: action.isTyping };
499
536
  }
500
537
  }
501
538
  function normalizePhone(value) {
@@ -566,8 +603,9 @@ function tmpMessage(body) {
566
603
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
567
604
  };
568
605
  }
569
- function useChat({ endpoint, realtime }) {
606
+ function useChat({ endpoint, realtime, typingEndpoint }) {
570
607
  const [state, dispatch] = (0, import_react.useReducer)(chatReducer, initialState);
608
+ const typingUrl = typingEndpoint ?? `${endpoint.replace(/\/+$/, "")}/typing`;
571
609
  const stateRef = (0, import_react.useRef)(state);
572
610
  (0, import_react.useEffect)(() => {
573
611
  stateRef.current = state;
@@ -622,6 +660,8 @@ function useChat({ endpoint, realtime }) {
622
660
  dispatch({ type: "append", message: e.message });
623
661
  } else if (e.type === "session" && e.status !== void 0) {
624
662
  dispatch({ type: "session-status", status: e.status });
663
+ } else if (e.type === "typing" && e.from !== void 0) {
664
+ dispatch({ type: "typing", from: e.from, isTyping: e.isTyping });
625
665
  }
626
666
  }, []);
627
667
  (0, import_react.useEffect)(() => {
@@ -728,6 +768,21 @@ function useChat({ endpoint, realtime }) {
728
768
  clearStoredSession();
729
769
  dispatch({ type: "reset-form" });
730
770
  }, []);
771
+ const notifyTyping = (0, import_react.useCallback)(
772
+ async (isTyping) => {
773
+ const token = stateRef.current.token;
774
+ if (token === null) return;
775
+ try {
776
+ await fetch(typingUrl, {
777
+ method: "POST",
778
+ headers: { "content-type": "application/json" },
779
+ body: JSON.stringify({ token, isTyping })
780
+ });
781
+ } catch {
782
+ }
783
+ },
784
+ [typingUrl]
785
+ );
731
786
  const submitForm = (0, import_react.useCallback)(
732
787
  async ({ name, phone, message, honeypot }) => {
733
788
  if (honeypot.trim() !== "") return;
@@ -775,7 +830,7 @@ function useChat({ endpoint, realtime }) {
775
830
  },
776
831
  [endpoint]
777
832
  );
778
- return { state, openPanel, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation };
833
+ return { state, openPanel, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation, notifyTyping };
779
834
  }
780
835
 
781
836
  // src/widget/chat-widget.tsx
@@ -799,6 +854,14 @@ function StatusMark({ status }) {
799
854
  const glyph = status === "pending" ? "\u2713" : status === "sent" ? "\u2713\u2713" : "\u26A0";
800
855
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: status === "failed" ? "ecw-status ecw-status--failed" : "ecw-status", "aria-hidden": "true", children: glyph });
801
856
  }
857
+ function TypingIndicator({ label }) {
858
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { className: "ecw-item ecw-item--owner", "aria-live": "polite", children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ecw-typing", role: "status", "aria-label": label, children: [
859
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ecw-typing-dot" }),
860
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ecw-typing-dot" }),
861
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ecw-typing-dot" }),
862
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "ecw-sr-only", children: label })
863
+ ] }) });
864
+ }
802
865
  function PreChatForm({ welcome, tr, error, sending, firstFieldRef, onSubmit }) {
803
866
  const uid = (0, import_react2.useId)();
804
867
  const [name, setName] = (0, import_react2.useState)("");
@@ -898,29 +961,53 @@ function PreChatForm({ welcome, tr, error, sending, firstFieldRef, onSubmit }) {
898
961
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "ecw-notice", children: tr("privacyNote") })
899
962
  ] });
900
963
  }
901
- function ChatPanel({ messages, canCompose, sending, locale, tr, listRef, firstFieldRef, onSend, onRetry, onNewConversation }) {
964
+ function ChatPanel({ messages, canCompose, sending, locale, tr, listRef, firstFieldRef, ownerTyping, onTyping, onSend, onRetry, onNewConversation }) {
902
965
  const uid = (0, import_react2.useId)();
903
966
  const [draft, setDraft] = (0, import_react2.useState)("");
967
+ const typingTimer = (0, import_react2.useRef)(null);
968
+ (0, import_react2.useEffect)(() => {
969
+ return () => {
970
+ if (typingTimer.current !== null) clearTimeout(typingTimer.current);
971
+ };
972
+ }, []);
973
+ const handleDraftChange = (e) => {
974
+ setDraft(e.target.value);
975
+ if (!canCompose) return;
976
+ void onTyping(true);
977
+ if (typingTimer.current !== null) clearTimeout(typingTimer.current);
978
+ typingTimer.current = setTimeout(() => {
979
+ void onTyping(false);
980
+ typingTimer.current = null;
981
+ }, 2500);
982
+ };
904
983
  const submit = (e) => {
905
984
  e.preventDefault();
906
985
  const text = draft.trim();
907
986
  if (text === "" || !canCompose) return;
987
+ if (typingTimer.current !== null) {
988
+ clearTimeout(typingTimer.current);
989
+ typingTimer.current = null;
990
+ }
991
+ void onTyping(false);
908
992
  setDraft("");
909
993
  void onSend(text);
910
994
  };
911
995
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
912
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { ref: listRef, className: "ecw-list", children: messages.map(
913
- (m) => m.direction === "system" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { className: "ecw-item ecw-item--system", role: "status", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "ecw-system", children: m.body }) }, m.id) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { className: `ecw-item ecw-item--${m.direction}`, children: [
914
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `ecw-bubble ecw-bubble--${m.direction}`, children: m.body }),
915
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ecw-meta", children: [
916
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("time", { dateTime: m.createdAt, children: formatTime(m.createdAt, locale) }),
917
- m.direction === "visitor" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusMark, { status: m.status }),
918
- m.status === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "ecw-retry", onClick: () => {
919
- void onRetry(m.id);
920
- }, children: tr("retry") })
921
- ] })
922
- ] }, m.id)
923
- ) }),
996
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("ul", { ref: listRef, className: "ecw-list", children: [
997
+ messages.map(
998
+ (m) => m.direction === "system" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { className: "ecw-item ecw-item--system", role: "status", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "ecw-system", children: m.body }) }, m.id) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { className: `ecw-item ecw-item--${m.direction}`, children: [
999
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: `ecw-bubble ecw-bubble--${m.direction}`, children: m.body }),
1000
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ecw-meta", children: [
1001
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("time", { dateTime: m.createdAt, children: formatTime(m.createdAt, locale) }),
1002
+ m.direction === "visitor" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusMark, { status: m.status }),
1003
+ m.status === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "ecw-retry", onClick: () => {
1004
+ void onRetry(m.id);
1005
+ }, children: tr("retry") })
1006
+ ] })
1007
+ ] }, m.id)
1008
+ ),
1009
+ ownerTyping && canCompose && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TypingIndicator, { label: tr("typing") })
1010
+ ] }),
924
1011
  !canCompose && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "ecw-closed", children: [
925
1012
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "ecw-notice", role: "status", children: tr("sessionClosed") }),
926
1013
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "ecw-submit", onClick: onNewConversation, children: tr("newConversation") })
@@ -937,9 +1024,7 @@ function ChatPanel({ messages, canCompose, sending, locale, tr, listRef, firstFi
937
1024
  autoComplete: "off",
938
1025
  placeholder: tr("message"),
939
1026
  value: draft,
940
- onChange: (e) => {
941
- setDraft(e.target.value);
942
- },
1027
+ onChange: handleDraftChange,
943
1028
  disabled: !canCompose
944
1029
  }
945
1030
  ),
@@ -951,7 +1036,7 @@ function ChatWidget(props) {
951
1036
  const { endpoint, locale, welcome, projectName, realtime, labels } = props;
952
1037
  const accentColor = props.accentColor ?? DEFAULT_ACCENT;
953
1038
  const tr = (0, import_react2.useCallback)((key) => t(locale, key, labels), [locale, labels]);
954
- const { state, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation } = useChat({ endpoint, realtime });
1039
+ const { state, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation, notifyTyping } = useChat({ endpoint, realtime });
955
1040
  const bubbleRef = (0, import_react2.useRef)(null);
956
1041
  const listRef = (0, import_react2.useRef)(null);
957
1042
  const firstFieldRef = (0, import_react2.useRef)(null);
@@ -976,7 +1061,7 @@ function ChatWidget(props) {
976
1061
  (0, import_react2.useEffect)(() => {
977
1062
  const list = listRef.current;
978
1063
  if (list !== null) list.scrollTop = list.scrollHeight;
979
- }, [state.messages, state.open]);
1064
+ }, [state.messages, state.ownerTyping, state.open]);
980
1065
  const handleClose = (0, import_react2.useCallback)(() => {
981
1066
  closePanel();
982
1067
  bubbleRef.current?.focus();
@@ -1019,6 +1104,8 @@ function ChatWidget(props) {
1019
1104
  tr,
1020
1105
  listRef,
1021
1106
  firstFieldRef,
1107
+ ownerTyping: state.ownerTyping,
1108
+ onTyping: notifyTyping,
1022
1109
  onSend: sendMessage,
1023
1110
  onRetry: retryMessage,
1024
1111
  onNewConversation: startNewConversation