@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.js CHANGED
@@ -23,7 +23,8 @@ var WIDGET_KEYS = [
23
23
  "retry",
24
24
  "sessionClosed",
25
25
  "newConversation",
26
- "poweredBy"
26
+ "poweredBy",
27
+ "typing"
27
28
  ];
28
29
  var pt = {
29
30
  openChat: "Abrir chat",
@@ -40,7 +41,8 @@ var pt = {
40
41
  retry: "Tentar novamente",
41
42
  sessionClosed: "Esta conversa foi encerrada. Abra uma nova para continuar.",
42
43
  newConversation: "Iniciar nova conversa",
43
- poweredBy: "Powered by"
44
+ poweredBy: "Powered by",
45
+ typing: "digitando\u2026"
44
46
  };
45
47
  var en = {
46
48
  openChat: "Open chat",
@@ -57,7 +59,8 @@ var en = {
57
59
  retry: "Try again",
58
60
  sessionClosed: "This conversation was closed. Start a new one to continue.",
59
61
  newConversation: "Start a new conversation",
60
- poweredBy: "Powered by"
62
+ poweredBy: "Powered by",
63
+ typing: "typing\u2026"
61
64
  };
62
65
  var es = {
63
66
  openChat: "Abrir chat",
@@ -74,7 +77,8 @@ var es = {
74
77
  retry: "Reintentar",
75
78
  sessionClosed: "Esta conversaci\xF3n fue cerrada. Abre una nueva para continuar.",
76
79
  newConversation: "Iniciar nueva conversaci\xF3n",
77
- poweredBy: "Powered by"
80
+ poweredBy: "Powered by",
81
+ typing: "escribiendo\u2026"
78
82
  };
79
83
  var dictionaries = { pt, en, es };
80
84
  function t(locale, key, overrides) {
@@ -323,6 +327,31 @@ var WIDGET_CSS = `/*
323
327
  .ecw-send:disabled { opacity: 0.55; cursor: not-allowed; }
324
328
  .ecw-send svg { width: 18px; height: 18px; }
325
329
 
330
+ /* \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 */
331
+ .ecw-typing {
332
+ display: inline-flex;
333
+ align-items: center;
334
+ gap: 4px;
335
+ padding: 10px 14px;
336
+ border-radius: 12px;
337
+ border-bottom-left-radius: 4px;
338
+ background: var(--ecw-surface);
339
+ border: 1px solid var(--ecw-border);
340
+ }
341
+ .ecw-typing-dot {
342
+ width: 7px;
343
+ height: 7px;
344
+ border-radius: 50%;
345
+ background: var(--ecw-muted);
346
+ animation: ecw-typing-bounce 1.2s infinite ease-in-out;
347
+ }
348
+ .ecw-typing-dot:nth-child(2) { animation-delay: 0.18s; }
349
+ .ecw-typing-dot:nth-child(3) { animation-delay: 0.36s; }
350
+ @keyframes ecw-typing-bounce {
351
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
352
+ 30% { transform: translateY(-4px); opacity: 1; }
353
+ }
354
+
326
355
  /* \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 */
327
356
  .ecw-closed { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--ecw-border); background: var(--ecw-surface); }
328
357
 
@@ -387,17 +416,23 @@ var initialState = {
387
416
  messages: [],
388
417
  unread: 0,
389
418
  sending: false,
390
- error: null
419
+ error: null,
420
+ ownerTyping: false,
421
+ visitorTyping: false
391
422
  };
392
423
  function withMessage(state, message) {
393
424
  const index = state.messages.findIndex((m) => m.id === message.id);
425
+ let messages;
394
426
  if (index >= 0) {
395
- const messages = [...state.messages];
427
+ messages = [...state.messages];
396
428
  messages[index] = message;
397
- return { ...state, messages };
429
+ } else {
430
+ messages = [...state.messages, message];
398
431
  }
399
432
  const bump = state.open || message.direction !== "owner" ? 0 : 1;
400
- return { ...state, messages: [...state.messages, message], unread: state.unread + bump };
433
+ const next = { ...state, messages, unread: state.unread + bump };
434
+ if (message.direction === "owner") next.ownerTyping = false;
435
+ return next;
401
436
  }
402
437
  function chatReducer(state, action) {
403
438
  switch (action.type) {
@@ -461,6 +496,8 @@ function chatReducer(state, action) {
461
496
  return { ...state, sending: action.value };
462
497
  case "error":
463
498
  return { ...state, error: action.value };
499
+ case "typing":
500
+ return action.from === "owner" ? state.ownerTyping === action.isTyping ? state : { ...state, ownerTyping: action.isTyping } : state.visitorTyping === action.isTyping ? state : { ...state, visitorTyping: action.isTyping };
464
501
  }
465
502
  }
466
503
  function normalizePhone(value) {
@@ -531,8 +568,9 @@ function tmpMessage(body) {
531
568
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
532
569
  };
533
570
  }
534
- function useChat({ endpoint, realtime }) {
571
+ function useChat({ endpoint, realtime, typingEndpoint }) {
535
572
  const [state, dispatch] = useReducer(chatReducer, initialState);
573
+ const typingUrl = typingEndpoint ?? `${endpoint.replace(/\/+$/, "")}/typing`;
536
574
  const stateRef = useRef(state);
537
575
  useEffect(() => {
538
576
  stateRef.current = state;
@@ -587,6 +625,8 @@ function useChat({ endpoint, realtime }) {
587
625
  dispatch({ type: "append", message: e.message });
588
626
  } else if (e.type === "session" && e.status !== void 0) {
589
627
  dispatch({ type: "session-status", status: e.status });
628
+ } else if (e.type === "typing" && e.from !== void 0) {
629
+ dispatch({ type: "typing", from: e.from, isTyping: e.isTyping });
590
630
  }
591
631
  }, []);
592
632
  useEffect(() => {
@@ -693,6 +733,21 @@ function useChat({ endpoint, realtime }) {
693
733
  clearStoredSession();
694
734
  dispatch({ type: "reset-form" });
695
735
  }, []);
736
+ const notifyTyping = useCallback(
737
+ async (isTyping) => {
738
+ const token = stateRef.current.token;
739
+ if (token === null) return;
740
+ try {
741
+ await fetch(typingUrl, {
742
+ method: "POST",
743
+ headers: { "content-type": "application/json" },
744
+ body: JSON.stringify({ token, isTyping })
745
+ });
746
+ } catch {
747
+ }
748
+ },
749
+ [typingUrl]
750
+ );
696
751
  const submitForm = useCallback(
697
752
  async ({ name, phone, message, honeypot }) => {
698
753
  if (honeypot.trim() !== "") return;
@@ -740,7 +795,7 @@ function useChat({ endpoint, realtime }) {
740
795
  },
741
796
  [endpoint]
742
797
  );
743
- return { state, openPanel, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation };
798
+ return { state, openPanel, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation, notifyTyping };
744
799
  }
745
800
 
746
801
  // src/widget/chat-widget.tsx
@@ -764,6 +819,14 @@ function StatusMark({ status }) {
764
819
  const glyph = status === "pending" ? "\u2713" : status === "sent" ? "\u2713\u2713" : "\u26A0";
765
820
  return /* @__PURE__ */ jsx("span", { className: status === "failed" ? "ecw-status ecw-status--failed" : "ecw-status", "aria-hidden": "true", children: glyph });
766
821
  }
822
+ function TypingIndicator({ label }) {
823
+ return /* @__PURE__ */ jsx("li", { className: "ecw-item ecw-item--owner", "aria-live": "polite", children: /* @__PURE__ */ jsxs("div", { className: "ecw-typing", role: "status", "aria-label": label, children: [
824
+ /* @__PURE__ */ jsx("span", { className: "ecw-typing-dot" }),
825
+ /* @__PURE__ */ jsx("span", { className: "ecw-typing-dot" }),
826
+ /* @__PURE__ */ jsx("span", { className: "ecw-typing-dot" }),
827
+ /* @__PURE__ */ jsx("span", { className: "ecw-sr-only", children: label })
828
+ ] }) });
829
+ }
767
830
  function PreChatForm({ welcome, tr, error, sending, firstFieldRef, onSubmit }) {
768
831
  const uid = useId();
769
832
  const [name, setName] = useState("");
@@ -863,29 +926,53 @@ function PreChatForm({ welcome, tr, error, sending, firstFieldRef, onSubmit }) {
863
926
  /* @__PURE__ */ jsx("p", { className: "ecw-notice", children: tr("privacyNote") })
864
927
  ] });
865
928
  }
866
- function ChatPanel({ messages, canCompose, sending, locale, tr, listRef, firstFieldRef, onSend, onRetry, onNewConversation }) {
929
+ function ChatPanel({ messages, canCompose, sending, locale, tr, listRef, firstFieldRef, ownerTyping, onTyping, onSend, onRetry, onNewConversation }) {
867
930
  const uid = useId();
868
931
  const [draft, setDraft] = useState("");
932
+ const typingTimer = useRef2(null);
933
+ useEffect2(() => {
934
+ return () => {
935
+ if (typingTimer.current !== null) clearTimeout(typingTimer.current);
936
+ };
937
+ }, []);
938
+ const handleDraftChange = (e) => {
939
+ setDraft(e.target.value);
940
+ if (!canCompose) return;
941
+ void onTyping(true);
942
+ if (typingTimer.current !== null) clearTimeout(typingTimer.current);
943
+ typingTimer.current = setTimeout(() => {
944
+ void onTyping(false);
945
+ typingTimer.current = null;
946
+ }, 2500);
947
+ };
869
948
  const submit = (e) => {
870
949
  e.preventDefault();
871
950
  const text = draft.trim();
872
951
  if (text === "" || !canCompose) return;
952
+ if (typingTimer.current !== null) {
953
+ clearTimeout(typingTimer.current);
954
+ typingTimer.current = null;
955
+ }
956
+ void onTyping(false);
873
957
  setDraft("");
874
958
  void onSend(text);
875
959
  };
876
960
  return /* @__PURE__ */ jsxs(Fragment, { children: [
877
- /* @__PURE__ */ jsx("ul", { ref: listRef, className: "ecw-list", children: messages.map(
878
- (m) => m.direction === "system" ? /* @__PURE__ */ jsx("li", { className: "ecw-item ecw-item--system", role: "status", children: /* @__PURE__ */ jsx("div", { className: "ecw-system", children: m.body }) }, m.id) : /* @__PURE__ */ jsxs("li", { className: `ecw-item ecw-item--${m.direction}`, children: [
879
- /* @__PURE__ */ jsx("div", { className: `ecw-bubble ecw-bubble--${m.direction}`, children: m.body }),
880
- /* @__PURE__ */ jsxs("div", { className: "ecw-meta", children: [
881
- /* @__PURE__ */ jsx("time", { dateTime: m.createdAt, children: formatTime(m.createdAt, locale) }),
882
- m.direction === "visitor" && /* @__PURE__ */ jsx(StatusMark, { status: m.status }),
883
- m.status === "failed" && /* @__PURE__ */ jsx("button", { type: "button", className: "ecw-retry", onClick: () => {
884
- void onRetry(m.id);
885
- }, children: tr("retry") })
886
- ] })
887
- ] }, m.id)
888
- ) }),
961
+ /* @__PURE__ */ jsxs("ul", { ref: listRef, className: "ecw-list", children: [
962
+ messages.map(
963
+ (m) => m.direction === "system" ? /* @__PURE__ */ jsx("li", { className: "ecw-item ecw-item--system", role: "status", children: /* @__PURE__ */ jsx("div", { className: "ecw-system", children: m.body }) }, m.id) : /* @__PURE__ */ jsxs("li", { className: `ecw-item ecw-item--${m.direction}`, children: [
964
+ /* @__PURE__ */ jsx("div", { className: `ecw-bubble ecw-bubble--${m.direction}`, children: m.body }),
965
+ /* @__PURE__ */ jsxs("div", { className: "ecw-meta", children: [
966
+ /* @__PURE__ */ jsx("time", { dateTime: m.createdAt, children: formatTime(m.createdAt, locale) }),
967
+ m.direction === "visitor" && /* @__PURE__ */ jsx(StatusMark, { status: m.status }),
968
+ m.status === "failed" && /* @__PURE__ */ jsx("button", { type: "button", className: "ecw-retry", onClick: () => {
969
+ void onRetry(m.id);
970
+ }, children: tr("retry") })
971
+ ] })
972
+ ] }, m.id)
973
+ ),
974
+ ownerTyping && canCompose && /* @__PURE__ */ jsx(TypingIndicator, { label: tr("typing") })
975
+ ] }),
889
976
  !canCompose && /* @__PURE__ */ jsxs("div", { className: "ecw-closed", children: [
890
977
  /* @__PURE__ */ jsx("p", { className: "ecw-notice", role: "status", children: tr("sessionClosed") }),
891
978
  /* @__PURE__ */ jsx("button", { type: "button", className: "ecw-submit", onClick: onNewConversation, children: tr("newConversation") })
@@ -902,9 +989,7 @@ function ChatPanel({ messages, canCompose, sending, locale, tr, listRef, firstFi
902
989
  autoComplete: "off",
903
990
  placeholder: tr("message"),
904
991
  value: draft,
905
- onChange: (e) => {
906
- setDraft(e.target.value);
907
- },
992
+ onChange: handleDraftChange,
908
993
  disabled: !canCompose
909
994
  }
910
995
  ),
@@ -916,7 +1001,7 @@ function ChatWidget(props) {
916
1001
  const { endpoint, locale, welcome, projectName, realtime, labels } = props;
917
1002
  const accentColor = props.accentColor ?? DEFAULT_ACCENT;
918
1003
  const tr = useCallback2((key) => t(locale, key, labels), [locale, labels]);
919
- const { state, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation } = useChat({ endpoint, realtime });
1004
+ const { state, closePanel, togglePanel, submitForm, sendMessage, retryMessage, startNewConversation, notifyTyping } = useChat({ endpoint, realtime });
920
1005
  const bubbleRef = useRef2(null);
921
1006
  const listRef = useRef2(null);
922
1007
  const firstFieldRef = useRef2(null);
@@ -941,7 +1026,7 @@ function ChatWidget(props) {
941
1026
  useEffect2(() => {
942
1027
  const list = listRef.current;
943
1028
  if (list !== null) list.scrollTop = list.scrollHeight;
944
- }, [state.messages, state.open]);
1029
+ }, [state.messages, state.ownerTyping, state.open]);
945
1030
  const handleClose = useCallback2(() => {
946
1031
  closePanel();
947
1032
  bubbleRef.current?.focus();
@@ -984,6 +1069,8 @@ function ChatWidget(props) {
984
1069
  tr,
985
1070
  listRef,
986
1071
  firstFieldRef,
1072
+ ownerTyping: state.ownerTyping,
1073
+ onTyping: notifyTyping,
987
1074
  onSend: sendMessage,
988
1075
  onRetry: retryMessage,
989
1076
  onNewConversation: startNewConversation