simple-support-chat 0.2.0 → 0.3.0

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.
@@ -31,6 +31,36 @@ interface ChatBubbleProps {
31
31
  user?: ChatUser;
32
32
  /** URL to poll for team replies (e.g., '/api/support/replies'). Enables bidirectional chat. */
33
33
  repliesUrl?: string;
34
+ /**
35
+ * Controlled mode: when provided, overrides internal open/close state.
36
+ * The chat panel is open when `isOpen` is true, closed when false.
37
+ * Use together with `onOpenChange` to control the panel externally.
38
+ *
39
+ * When not provided, ChatBubble manages its own open/close state (uncontrolled mode).
40
+ *
41
+ * Note: `show` controls visibility of the floating button, `isOpen` controls the panel state.
42
+ * Both can be used together (e.g., `show={false}` + `isOpen={true}` = no bubble button but panel is open).
43
+ */
44
+ isOpen?: boolean;
45
+ /**
46
+ * Callback fired when the user attempts to change the open/close state.
47
+ * Called when the user clicks the bubble button, close button, or presses Escape.
48
+ * In controlled mode, you must update your state in response to this callback.
49
+ * In uncontrolled mode, this is called as a notification after the state changes internally.
50
+ */
51
+ onOpenChange?: (isOpen: boolean) => void;
52
+ /**
53
+ * Color for the unread badge on the floating bubble button.
54
+ * Only visible when there are unread replies and the chat is closed.
55
+ * Default: '#eab308' (yellow).
56
+ */
57
+ badgeColor?: string;
58
+ /**
59
+ * Number of unread replies to display on the badge.
60
+ * When provided, a circular badge is shown on the floating bubble button.
61
+ * Typically driven by the `useUnreadCount` hook.
62
+ */
63
+ unreadCount?: number;
34
64
  }
35
65
  /** A single chat message */
36
66
  interface ChatMessage {
@@ -90,7 +120,7 @@ interface AnonymousContext {
90
120
  * <ChatBubble apiUrl="/api/support" />
91
121
  * ```
92
122
  */
93
- declare function ChatBubble({ apiUrl, position, color, title, placeholder, show, user, repliesUrl, }: ChatBubbleProps): react_jsx_runtime.JSX.Element;
123
+ declare function ChatBubble({ apiUrl, position, color, title, placeholder, show, user, repliesUrl, isOpen: isOpenProp, onOpenChange, badgeColor, unreadCount, }: ChatBubbleProps): react_jsx_runtime.JSX.Element;
94
124
 
95
125
  /**
96
126
  * SupportChatModal -- modal-based support chat.
@@ -168,9 +198,56 @@ interface ChatEngineState {
168
198
  */
169
199
  declare function useChatEngine({ apiUrl, user, repliesUrl, isOpen, }: ChatEngineOptions): ChatEngineState;
170
200
 
201
+ /** Options for the useUnreadCount hook */
202
+ interface UnreadCountOptions {
203
+ /** URL to poll for team replies (e.g., '/api/support/replies') */
204
+ repliesUrl: string;
205
+ /** Authenticated user info (optional). When provided, user.id is used as sessionId. */
206
+ user?: ChatUser;
207
+ /** Whether the chat panel is currently open. Polling pauses when true. */
208
+ isOpen: boolean;
209
+ }
210
+ /** Return value from useUnreadCount */
211
+ interface UnreadCountState {
212
+ /** Number of unread reply messages */
213
+ unreadCount: number;
214
+ /** Convenience boolean: true when unreadCount > 0 */
215
+ hasUnread: boolean;
216
+ /** Pre-fetched reply messages ready to display instantly when chat opens */
217
+ pendingReplies: ChatMessage[];
218
+ /** Reset unreadCount to 0 and clear pendingReplies */
219
+ markAsRead: () => void;
220
+ }
221
+ /**
222
+ * Hook that tracks unread reply count in the background via polling.
223
+ *
224
+ * Polls the replies endpoint every 4 seconds when `isOpen` is false.
225
+ * Stops background polling when `isOpen` is true (useChatEngine handles it at that point).
226
+ * Pre-fetches reply messages so they appear instantly when the chat opens.
227
+ *
228
+ * Can be used independently of ChatBubble -- any component can call useUnreadCount
229
+ * to build custom unread UX.
230
+ *
231
+ * @example
232
+ * ```tsx
233
+ * const { unreadCount, hasUnread, pendingReplies, markAsRead } = useUnreadCount({
234
+ * repliesUrl: '/api/support/replies',
235
+ * user: currentUser,
236
+ * isOpen: false,
237
+ * });
238
+ *
239
+ * return (
240
+ * <button onClick={openChat}>
241
+ * Support {hasUnread && <span className="badge">{unreadCount}</span>}
242
+ * </button>
243
+ * );
244
+ * ```
245
+ */
246
+ declare function useUnreadCount({ repliesUrl, user, isOpen, }: UnreadCountOptions): UnreadCountState;
247
+
171
248
  /** Get or create a persistent session ID from localStorage */
172
249
  declare function getSessionId(): string;
173
250
  /** Collect anonymous browser context */
174
251
  declare function collectAnonymousContext(): AnonymousContext;
175
252
 
176
- export { type AnonymousContext, type BubblePosition, ChatBubble, type ChatBubbleProps, type ChatEngineOptions, type ChatEngineState, type ChatMessage, type ChatUser, SupportChatModal, type SupportChatModalProps, type SupportChatState, collectAnonymousContext, getSessionId, useChatEngine, useSupportChat };
253
+ export { type AnonymousContext, type BubblePosition, ChatBubble, type ChatBubbleProps, type ChatEngineOptions, type ChatEngineState, type ChatMessage, type ChatUser, SupportChatModal, type SupportChatModalProps, type SupportChatState, type UnreadCountOptions, type UnreadCountState, collectAnonymousContext, getSessionId, useChatEngine, useSupportChat, useUnreadCount };
@@ -31,6 +31,36 @@ interface ChatBubbleProps {
31
31
  user?: ChatUser;
32
32
  /** URL to poll for team replies (e.g., '/api/support/replies'). Enables bidirectional chat. */
33
33
  repliesUrl?: string;
34
+ /**
35
+ * Controlled mode: when provided, overrides internal open/close state.
36
+ * The chat panel is open when `isOpen` is true, closed when false.
37
+ * Use together with `onOpenChange` to control the panel externally.
38
+ *
39
+ * When not provided, ChatBubble manages its own open/close state (uncontrolled mode).
40
+ *
41
+ * Note: `show` controls visibility of the floating button, `isOpen` controls the panel state.
42
+ * Both can be used together (e.g., `show={false}` + `isOpen={true}` = no bubble button but panel is open).
43
+ */
44
+ isOpen?: boolean;
45
+ /**
46
+ * Callback fired when the user attempts to change the open/close state.
47
+ * Called when the user clicks the bubble button, close button, or presses Escape.
48
+ * In controlled mode, you must update your state in response to this callback.
49
+ * In uncontrolled mode, this is called as a notification after the state changes internally.
50
+ */
51
+ onOpenChange?: (isOpen: boolean) => void;
52
+ /**
53
+ * Color for the unread badge on the floating bubble button.
54
+ * Only visible when there are unread replies and the chat is closed.
55
+ * Default: '#eab308' (yellow).
56
+ */
57
+ badgeColor?: string;
58
+ /**
59
+ * Number of unread replies to display on the badge.
60
+ * When provided, a circular badge is shown on the floating bubble button.
61
+ * Typically driven by the `useUnreadCount` hook.
62
+ */
63
+ unreadCount?: number;
34
64
  }
35
65
  /** A single chat message */
36
66
  interface ChatMessage {
@@ -90,7 +120,7 @@ interface AnonymousContext {
90
120
  * <ChatBubble apiUrl="/api/support" />
91
121
  * ```
92
122
  */
93
- declare function ChatBubble({ apiUrl, position, color, title, placeholder, show, user, repliesUrl, }: ChatBubbleProps): react_jsx_runtime.JSX.Element;
123
+ declare function ChatBubble({ apiUrl, position, color, title, placeholder, show, user, repliesUrl, isOpen: isOpenProp, onOpenChange, badgeColor, unreadCount, }: ChatBubbleProps): react_jsx_runtime.JSX.Element;
94
124
 
95
125
  /**
96
126
  * SupportChatModal -- modal-based support chat.
@@ -168,9 +198,56 @@ interface ChatEngineState {
168
198
  */
169
199
  declare function useChatEngine({ apiUrl, user, repliesUrl, isOpen, }: ChatEngineOptions): ChatEngineState;
170
200
 
201
+ /** Options for the useUnreadCount hook */
202
+ interface UnreadCountOptions {
203
+ /** URL to poll for team replies (e.g., '/api/support/replies') */
204
+ repliesUrl: string;
205
+ /** Authenticated user info (optional). When provided, user.id is used as sessionId. */
206
+ user?: ChatUser;
207
+ /** Whether the chat panel is currently open. Polling pauses when true. */
208
+ isOpen: boolean;
209
+ }
210
+ /** Return value from useUnreadCount */
211
+ interface UnreadCountState {
212
+ /** Number of unread reply messages */
213
+ unreadCount: number;
214
+ /** Convenience boolean: true when unreadCount > 0 */
215
+ hasUnread: boolean;
216
+ /** Pre-fetched reply messages ready to display instantly when chat opens */
217
+ pendingReplies: ChatMessage[];
218
+ /** Reset unreadCount to 0 and clear pendingReplies */
219
+ markAsRead: () => void;
220
+ }
221
+ /**
222
+ * Hook that tracks unread reply count in the background via polling.
223
+ *
224
+ * Polls the replies endpoint every 4 seconds when `isOpen` is false.
225
+ * Stops background polling when `isOpen` is true (useChatEngine handles it at that point).
226
+ * Pre-fetches reply messages so they appear instantly when the chat opens.
227
+ *
228
+ * Can be used independently of ChatBubble -- any component can call useUnreadCount
229
+ * to build custom unread UX.
230
+ *
231
+ * @example
232
+ * ```tsx
233
+ * const { unreadCount, hasUnread, pendingReplies, markAsRead } = useUnreadCount({
234
+ * repliesUrl: '/api/support/replies',
235
+ * user: currentUser,
236
+ * isOpen: false,
237
+ * });
238
+ *
239
+ * return (
240
+ * <button onClick={openChat}>
241
+ * Support {hasUnread && <span className="badge">{unreadCount}</span>}
242
+ * </button>
243
+ * );
244
+ * ```
245
+ */
246
+ declare function useUnreadCount({ repliesUrl, user, isOpen, }: UnreadCountOptions): UnreadCountState;
247
+
171
248
  /** Get or create a persistent session ID from localStorage */
172
249
  declare function getSessionId(): string;
173
250
  /** Collect anonymous browser context */
174
251
  declare function collectAnonymousContext(): AnonymousContext;
175
252
 
176
- export { type AnonymousContext, type BubblePosition, ChatBubble, type ChatBubbleProps, type ChatEngineOptions, type ChatEngineState, type ChatMessage, type ChatUser, SupportChatModal, type SupportChatModalProps, type SupportChatState, collectAnonymousContext, getSessionId, useChatEngine, useSupportChat };
253
+ export { type AnonymousContext, type BubblePosition, ChatBubble, type ChatBubbleProps, type ChatEngineOptions, type ChatEngineState, type ChatMessage, type ChatUser, SupportChatModal, type SupportChatModalProps, type SupportChatState, type UnreadCountOptions, type UnreadCountState, collectAnonymousContext, getSessionId, useChatEngine, useSupportChat, useUnreadCount };
@@ -215,6 +215,10 @@ function injectKeyframes() {
215
215
  from { opacity: 1; transform: translateY(0) scale(1); }
216
216
  to { opacity: 0; transform: translateY(12px) scale(0.96); }
217
217
  }
218
+ @keyframes sc-badge-scale-in {
219
+ from { transform: scale(0); }
220
+ to { transform: scale(1); }
221
+ }
218
222
  `;
219
223
  document.head.appendChild(style);
220
224
  }
@@ -226,9 +230,27 @@ function ChatBubble({
226
230
  placeholder = "Type a message...",
227
231
  show = true,
228
232
  user,
229
- repliesUrl
233
+ repliesUrl,
234
+ isOpen: isOpenProp,
235
+ onOpenChange,
236
+ badgeColor = "#eab308",
237
+ unreadCount = 0
230
238
  }) {
231
- const [isOpen, setIsOpen] = useState(false);
239
+ const isControlled = isOpenProp !== void 0;
240
+ const [isOpenInternal, setIsOpenInternal] = useState(false);
241
+ const isOpen = isControlled ? isOpenProp : isOpenInternal;
242
+ const setIsOpen = useCallback(
243
+ (valueOrUpdater) => {
244
+ const newValue = typeof valueOrUpdater === "function" ? valueOrUpdater(isOpen) : valueOrUpdater;
245
+ if (isControlled) {
246
+ onOpenChange?.(newValue);
247
+ } else {
248
+ setIsOpenInternal(newValue);
249
+ onOpenChange?.(newValue);
250
+ }
251
+ },
252
+ [isControlled, isOpen, onOpenChange]
253
+ );
232
254
  const colorScheme = useColorScheme();
233
255
  const theme = useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
234
256
  const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
@@ -290,8 +312,8 @@ function ChatBubble({
290
312
  };
291
313
  document.addEventListener("keydown", handleTrapKeyDown);
292
314
  return () => document.removeEventListener("keydown", handleTrapKeyDown);
293
- }, [panelState]);
294
- const toggle = useCallback(() => setIsOpen((o) => !o), []);
315
+ }, [panelState, setIsOpen]);
316
+ const toggle = useCallback(() => setIsOpen((o) => !o), [setIsOpen]);
295
317
  const positionStyles = {
296
318
  position: "fixed",
297
319
  zIndex: 99999,
@@ -324,7 +346,7 @@ function ChatBubble({
324
346
  }
325
347
  }
326
348
  ` }),
327
- show && /* @__PURE__ */ jsx(
349
+ show && /* @__PURE__ */ jsxs(
328
350
  "button",
329
351
  {
330
352
  onClick: toggle,
@@ -343,7 +365,8 @@ function ChatBubble({
343
365
  alignItems: "center",
344
366
  justifyContent: "center",
345
367
  boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
346
- transition: "transform 0.2s ease, box-shadow 0.2s ease"
368
+ transition: "transform 0.2s ease, box-shadow 0.2s ease",
369
+ overflow: "visible"
347
370
  },
348
371
  onMouseEnter: (e) => {
349
372
  e.currentTarget.style.transform = "scale(1.1)";
@@ -353,21 +376,50 @@ function ChatBubble({
353
376
  e.currentTarget.style.transform = "scale(1)";
354
377
  e.currentTarget.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)";
355
378
  },
356
- children: /* @__PURE__ */ jsx(
357
- "svg",
358
- {
359
- width: "24",
360
- height: "24",
361
- viewBox: "0 0 24 24",
362
- fill: "none",
363
- stroke: "white",
364
- strokeWidth: "2",
365
- strokeLinecap: "round",
366
- strokeLinejoin: "round",
367
- "aria-hidden": "true",
368
- children: isOpen ? /* @__PURE__ */ jsx("path", { d: "M18 6L6 18M6 6l12 12" }) : /* @__PURE__ */ jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" })
369
- }
370
- )
379
+ children: [
380
+ /* @__PURE__ */ jsx(
381
+ "svg",
382
+ {
383
+ width: "24",
384
+ height: "24",
385
+ viewBox: "0 0 24 24",
386
+ fill: "none",
387
+ stroke: "white",
388
+ strokeWidth: "2",
389
+ strokeLinecap: "round",
390
+ strokeLinejoin: "round",
391
+ "aria-hidden": "true",
392
+ children: isOpen ? /* @__PURE__ */ jsx("path", { d: "M18 6L6 18M6 6l12 12" }) : /* @__PURE__ */ jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" })
393
+ }
394
+ ),
395
+ !isOpen && unreadCount > 0 && /* @__PURE__ */ jsx(
396
+ "span",
397
+ {
398
+ "data-testid": "unread-badge",
399
+ "aria-label": `${unreadCount > 9 ? "9+" : unreadCount} unread messages`,
400
+ style: {
401
+ position: "absolute",
402
+ top: "-4px",
403
+ right: "-4px",
404
+ minWidth: "20px",
405
+ height: "20px",
406
+ borderRadius: "10px",
407
+ backgroundColor: badgeColor,
408
+ color: "#fff",
409
+ fontSize: "11px",
410
+ fontWeight: 700,
411
+ display: "flex",
412
+ alignItems: "center",
413
+ justifyContent: "center",
414
+ padding: "0 5px",
415
+ lineHeight: 1,
416
+ boxShadow: "0 2px 4px rgba(0,0,0,0.2)",
417
+ animation: "sc-badge-scale-in 0.2s ease-out forwards"
418
+ },
419
+ children: unreadCount > 9 ? "9+" : unreadCount
420
+ }
421
+ )
422
+ ]
371
423
  }
372
424
  ),
373
425
  showPanel && /* @__PURE__ */ jsxs(
@@ -856,7 +908,76 @@ function useSupportChat() {
856
908
  const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
857
909
  return { open, close, toggle, isOpen };
858
910
  }
911
+ var POLL_INTERVAL2 = 4e3;
912
+ function useUnreadCount({
913
+ repliesUrl,
914
+ user,
915
+ isOpen
916
+ }) {
917
+ const [pendingReplies, setPendingReplies] = useState([]);
918
+ const lastReplyTimestampRef = useRef(null);
919
+ const knownReplyIdsRef = useRef(/* @__PURE__ */ new Set());
920
+ const prevIsOpenRef = useRef(isOpen);
921
+ useEffect(() => {
922
+ if (isOpen && !prevIsOpenRef.current) {
923
+ setPendingReplies([]);
924
+ knownReplyIdsRef.current.clear();
925
+ lastReplyTimestampRef.current = null;
926
+ }
927
+ prevIsOpenRef.current = isOpen;
928
+ }, [isOpen]);
929
+ const markAsRead = useCallback(() => {
930
+ setPendingReplies([]);
931
+ knownReplyIdsRef.current.clear();
932
+ lastReplyTimestampRef.current = null;
933
+ }, []);
934
+ useEffect(() => {
935
+ if (!repliesUrl || isOpen) return;
936
+ const sessionId = user?.id ?? getSessionId();
937
+ const fetchReplies = async () => {
938
+ try {
939
+ const params = new URLSearchParams({ sessionId });
940
+ if (lastReplyTimestampRef.current) {
941
+ params.set("since", lastReplyTimestampRef.current);
942
+ }
943
+ const response = await fetch(`${repliesUrl}?${params.toString()}`);
944
+ if (!response.ok) return;
945
+ const data = await response.json();
946
+ if (data.replies.length === 0) return;
947
+ const newReplies = data.replies.filter(
948
+ (r) => !knownReplyIdsRef.current.has(r.id)
949
+ );
950
+ if (newReplies.length === 0) return;
951
+ for (const r of newReplies) {
952
+ knownReplyIdsRef.current.add(r.id);
953
+ }
954
+ const latestTimestamp = newReplies.reduce((latest, r) => {
955
+ return r.timestamp > latest ? r.timestamp : latest;
956
+ }, lastReplyTimestampRef.current ?? "");
957
+ lastReplyTimestampRef.current = latestTimestamp;
958
+ const replyMessages = newReplies.map((r) => ({
959
+ id: r.id,
960
+ text: r.text,
961
+ sender: "received",
962
+ timestamp: new Date(r.timestamp).getTime()
963
+ }));
964
+ setPendingReplies((prev) => [...prev, ...replyMessages]);
965
+ } catch {
966
+ }
967
+ };
968
+ void fetchReplies();
969
+ const intervalId = setInterval(() => void fetchReplies(), POLL_INTERVAL2);
970
+ return () => clearInterval(intervalId);
971
+ }, [repliesUrl, isOpen, user]);
972
+ const unreadCount = pendingReplies.length;
973
+ return {
974
+ unreadCount,
975
+ hasUnread: unreadCount > 0,
976
+ pendingReplies,
977
+ markAsRead
978
+ };
979
+ }
859
980
 
860
- export { ChatBubble, SupportChatModal, collectAnonymousContext, getSessionId, useChatEngine, useSupportChat };
981
+ export { ChatBubble, SupportChatModal, collectAnonymousContext, getSessionId, useChatEngine, useSupportChat, useUnreadCount };
861
982
  //# sourceMappingURL=index.js.map
862
983
  //# sourceMappingURL=index.js.map