simple-support-chat 0.1.1 → 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.
package/README.md CHANGED
@@ -4,6 +4,8 @@ Embeddable chat widget SDK that routes customer messages to Slack threads. Open-
4
4
 
5
5
  - Zero cost, self-hosted
6
6
  - Slack-native: messages appear as threads in your workspace
7
+ - Bidirectional: team replies in Slack threads show in the chat widget
8
+ - Slack emoji shortcodes (`:heart:`) auto-converted to Unicode (❤️)
7
9
  - Works with any React app (Next.js, Remix, Vite, etc.)
8
10
  - Anonymous and authenticated user support
9
11
  - No external CSS or Tailwind dependency
@@ -70,8 +72,11 @@ Add these to your `.env` file:
70
72
  ```bash
71
73
  SLACK_BOT_TOKEN=xoxb-your-token-here
72
74
  SLACK_CHANNEL_ID=C0123ABCDEF
75
+ SLACK_SIGNING_SECRET=your-signing-secret-here # Required for receiving replies (see "Receiving Replies" section)
73
76
  ```
74
77
 
78
+ The signing secret is found under **Basic Information** > **App Credentials** in your Slack app settings. It is only needed if you want bidirectional messaging (team replies appearing in the widget).
79
+
75
80
  ### Validate Your Token
76
81
 
77
82
  Use the built-in `validateSlackToken` utility to verify your token works before going live:
@@ -128,6 +133,280 @@ export default function App() {
128
133
 
129
134
  That's it! Messages from your users will appear as threaded conversations in your Slack channel.
130
135
 
136
+ ## Receiving Replies
137
+
138
+ By default, simple-support-chat is one-way: users send messages that appear in Slack. To make it bidirectional -- so team replies in Slack appear in the user's chat widget -- you need three additional pieces:
139
+
140
+ 1. A **webhook route** that receives events from Slack when someone replies in a thread
141
+ 2. A **replies endpoint** that the client polls for new replies
142
+ 3. The `repliesUrl` prop on the client widget
143
+
144
+ ### Prerequisites
145
+
146
+ - Completed the [Slack App Setup](#slack-app-setup) above
147
+ - Your Slack app's **Signing Secret** (found under **Basic Information** > **App Credentials** in [api.slack.com/apps](https://api.slack.com/apps))
148
+
149
+ Add the signing secret to your environment variables:
150
+
151
+ ```bash
152
+ SLACK_SIGNING_SECRET=your-signing-secret-here
153
+ ```
154
+
155
+ ### Step 1: Enable Event Subscriptions
156
+
157
+ 1. Go to your app at [api.slack.com/apps](https://api.slack.com/apps)
158
+ 2. Navigate to **Event Subscriptions** in the sidebar
159
+ 3. Toggle **Enable Events** to On
160
+ 4. Set the **Request URL** to `https://your-domain.com/api/support/webhook`
161
+ - Slack will send a verification challenge -- your webhook handler responds automatically
162
+ 5. Under **Subscribe to bot events**, add `message.channels`
163
+ 6. Click **Save Changes**
164
+
165
+ A pre-built manifest is included at [`slack-app-manifest.json`](./slack-app-manifest.json). Replace `YOUR_DOMAIN` with your actual domain and use it when creating or reconfiguring your Slack app.
166
+
167
+ > **Local development:** Slack cannot reach `localhost`. Use a tunnel like [ngrok](https://ngrok.com/) (`ngrok http 3000`) and set the tunnel URL as the Request URL. You can skip webhook setup entirely for local dev -- one-way messaging works without it.
168
+
169
+ ### Step 2: Create a Shared Store
170
+
171
+ The webhook handler, replies handler, and message handler must share the same store instance so that thread mappings and replies are consistent.
172
+
173
+ ```ts
174
+ // lib/support-store.ts (or wherever you keep shared singletons)
175
+ import { InMemoryStore } from "simple-support-chat/server";
176
+
177
+ export const store = new InMemoryStore();
178
+ ```
179
+
180
+ `InMemoryStore` works for local development. For production, implement the `SupportChatStore` interface with your database (see [Custom Storage](#custom-storage-supportchatstore) below).
181
+
182
+ ### Step 3: Set Up Routes
183
+
184
+ #### Next.js App Router
185
+
186
+ ```ts
187
+ // app/api/support/route.ts
188
+ import { createSupportHandler } from "simple-support-chat/server";
189
+ import { store } from "@/lib/support-store";
190
+
191
+ export const POST = createSupportHandler({
192
+ slackBotToken: process.env.SLACK_BOT_TOKEN!,
193
+ slackChannel: process.env.SLACK_CHANNEL_ID!,
194
+ store,
195
+ });
196
+ ```
197
+
198
+ ```ts
199
+ // app/api/support/webhook/route.ts
200
+ import { createWebhookHandler } from "simple-support-chat/server";
201
+ import { store } from "@/lib/support-store";
202
+
203
+ export const POST = createWebhookHandler({
204
+ store,
205
+ signingSecret: process.env.SLACK_SIGNING_SECRET!,
206
+ });
207
+ ```
208
+
209
+ ```ts
210
+ // app/api/support/replies/route.ts
211
+ import { createRepliesHandler } from "simple-support-chat/server";
212
+ import { store } from "@/lib/support-store";
213
+
214
+ export const GET = createRepliesHandler({ store });
215
+ ```
216
+
217
+ #### Express
218
+
219
+ ```ts
220
+ import express from "express";
221
+ import {
222
+ createExpressHandler,
223
+ createExpressWebhookHandler,
224
+ createExpressRepliesHandler,
225
+ InMemoryStore,
226
+ } from "simple-support-chat/server";
227
+
228
+ const app = express();
229
+ const store = new InMemoryStore();
230
+
231
+ // Message handler (uses JSON body parser)
232
+ app.post(
233
+ "/api/support",
234
+ express.json(),
235
+ createExpressHandler({
236
+ slackBotToken: process.env.SLACK_BOT_TOKEN!,
237
+ slackChannel: process.env.SLACK_CHANNEL_ID!,
238
+ store,
239
+ }),
240
+ );
241
+
242
+ // Webhook handler (uses raw body parser for signature verification)
243
+ app.post(
244
+ "/api/support/webhook",
245
+ express.raw({ type: "application/json" }),
246
+ createExpressWebhookHandler({
247
+ store,
248
+ signingSecret: process.env.SLACK_SIGNING_SECRET!,
249
+ }),
250
+ );
251
+
252
+ // Replies handler (GET endpoint for client polling)
253
+ app.get(
254
+ "/api/support/replies",
255
+ createExpressRepliesHandler({ store }),
256
+ );
257
+
258
+ app.listen(3000);
259
+ ```
260
+
261
+ > **Important:** The webhook route must use `express.raw()` (not `express.json()`) so that the raw request body is available for Slack signature verification.
262
+
263
+ ### Step 4: Add repliesUrl to the Client
264
+
265
+ Pass the `repliesUrl` prop to enable reply polling:
266
+
267
+ ```tsx
268
+ <ChatBubble
269
+ apiUrl="/api/support"
270
+ repliesUrl="/api/support/replies"
271
+ />
272
+ ```
273
+
274
+ Or with the modal:
275
+
276
+ ```tsx
277
+ <SupportChatModal
278
+ apiUrl="/api/support"
279
+ repliesUrl="/api/support/replies"
280
+ isOpen={isOpen}
281
+ onClose={close}
282
+ />
283
+ ```
284
+
285
+ When `repliesUrl` is set, the widget polls every 4 seconds while the chat panel is open. Replies appear as left-aligned gray bubbles. Polling stops when the chat is closed.
286
+
287
+ ### Custom Storage (SupportChatStore)
288
+
289
+ For production, implement the `SupportChatStore` interface to persist thread mappings and replies to your database. Here is the interface:
290
+
291
+ ```ts
292
+ interface SupportChatStore {
293
+ saveThread(sessionId: string, threadTs: string, metadata?: Record<string, unknown>): Promise<void>;
294
+ getThreadBySession(sessionId: string): Promise<ThreadRecord | null>;
295
+ getThreadByTs(threadTs: string): Promise<ThreadRecord | null>;
296
+ saveReply(sessionId: string, reply: Reply): Promise<void>;
297
+ getReplies(sessionId: string, since?: string): Promise<Reply[]>;
298
+ }
299
+ ```
300
+
301
+ #### Example: Supabase Implementation
302
+
303
+ First, create the tables:
304
+
305
+ ```sql
306
+ -- Supabase migration
307
+ create table support_chat_threads (
308
+ session_id text primary key,
309
+ thread_ts text not null unique,
310
+ metadata jsonb default '{}',
311
+ created_at timestamptz default now()
312
+ );
313
+
314
+ create table support_chat_replies (
315
+ id text primary key,
316
+ session_id text not null references support_chat_threads(session_id),
317
+ text text not null,
318
+ sender text not null,
319
+ timestamp timestamptz not null,
320
+ thread_ts text not null
321
+ );
322
+
323
+ create index idx_replies_session_timestamp
324
+ on support_chat_replies(session_id, timestamp);
325
+ ```
326
+
327
+ Then implement the store:
328
+
329
+ ```ts
330
+ import type { SupportChatStore, ThreadRecord, Reply } from "simple-support-chat/server";
331
+ import { createClient } from "@supabase/supabase-js";
332
+
333
+ const supabase = createClient(
334
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
335
+ process.env.SUPABASE_SERVICE_ROLE_KEY!,
336
+ );
337
+
338
+ export class SupabaseChatStore implements SupportChatStore {
339
+ async saveThread(sessionId: string, threadTs: string, metadata?: Record<string, unknown>) {
340
+ await supabase.from("support_chat_threads").upsert({
341
+ session_id: sessionId,
342
+ thread_ts: threadTs,
343
+ metadata: metadata ?? {},
344
+ });
345
+ }
346
+
347
+ async getThreadBySession(sessionId: string): Promise<ThreadRecord | null> {
348
+ const { data } = await supabase
349
+ .from("support_chat_threads")
350
+ .select("*")
351
+ .eq("session_id", sessionId)
352
+ .single();
353
+ if (!data) return null;
354
+ return { sessionId: data.session_id, threadTs: data.thread_ts, metadata: data.metadata };
355
+ }
356
+
357
+ async getThreadByTs(threadTs: string): Promise<ThreadRecord | null> {
358
+ const { data } = await supabase
359
+ .from("support_chat_threads")
360
+ .select("*")
361
+ .eq("thread_ts", threadTs)
362
+ .single();
363
+ if (!data) return null;
364
+ return { sessionId: data.session_id, threadTs: data.thread_ts, metadata: data.metadata };
365
+ }
366
+
367
+ async saveReply(sessionId: string, reply: Reply) {
368
+ await supabase.from("support_chat_replies").insert({
369
+ id: reply.id,
370
+ session_id: sessionId,
371
+ text: reply.text,
372
+ sender: reply.sender,
373
+ timestamp: reply.timestamp,
374
+ thread_ts: reply.threadTs,
375
+ });
376
+ }
377
+
378
+ async getReplies(sessionId: string, since?: string): Promise<Reply[]> {
379
+ let query = supabase
380
+ .from("support_chat_replies")
381
+ .select("*")
382
+ .eq("session_id", sessionId)
383
+ .order("timestamp", { ascending: true });
384
+
385
+ if (since) {
386
+ query = query.gt("timestamp", since);
387
+ }
388
+
389
+ const { data } = await query;
390
+ return (data ?? []).map((r) => ({
391
+ id: r.id,
392
+ text: r.text,
393
+ sender: r.sender,
394
+ timestamp: r.timestamp,
395
+ threadTs: r.thread_ts,
396
+ }));
397
+ }
398
+ }
399
+ ```
400
+
401
+ Then pass the store to all handlers:
402
+
403
+ ```ts
404
+ import { SupabaseChatStore } from "@/lib/supabase-chat-store";
405
+
406
+ const store = new SupabaseChatStore();
407
+ // Use this store instance in createSupportHandler, createWebhookHandler, and createRepliesHandler
408
+ ```
409
+
131
410
  ## Configuration
132
411
 
133
412
  ### ChatBubble Props
@@ -135,6 +414,7 @@ That's it! Messages from your users will appear as threaded conversations in you
135
414
  | Prop | Type | Default | Description |
136
415
  |------|------|---------|-------------|
137
416
  | `apiUrl` | `string` | (required) | URL of your support API endpoint |
417
+ | `repliesUrl` | `string` | `undefined` | URL of the replies endpoint. Enables bidirectional chat. |
138
418
  | `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left'` | `'bottom-right'` | Bubble position |
139
419
  | `color` | `string` | `'#2563eb'` | Primary color (bubble, header, sent messages) |
140
420
  | `title` | `string` | `'Support'` | Chat panel header title |
@@ -151,6 +431,7 @@ That's it! Messages from your users will appear as threaded conversations in you
151
431
  | `botName` | `string` | Custom bot display name |
152
432
  | `botIcon` | `string` | Bot icon emoji (e.g., `:speech_balloon:`) |
153
433
  | `onMessage` | `(data) => void` | Callback on each message |
434
+ | `store` | `SupportChatStore` | Pluggable storage backend (defaults to `InMemoryStore`) |
154
435
 
155
436
  ## Identity Integration
156
437
 
@@ -211,6 +492,7 @@ The modal renders centered on desktop (max-width 500px) with a backdrop overlay,
211
492
  | Prop | Type | Default | Description |
212
493
  |------|------|---------|-------------|
213
494
  | `apiUrl` | `string` | (required) | URL of your support API endpoint |
495
+ | `repliesUrl` | `string` | `undefined` | URL of the replies endpoint. Enables bidirectional chat. |
214
496
  | `isOpen` | `boolean` | (required) | Whether the modal is open |
215
497
  | `onClose` | `() => void` | (required) | Callback to close the modal |
216
498
  | `color` | `string` | `'#2563eb'` | Primary color |
@@ -264,9 +546,16 @@ The Express handler uses the same threading logic as the Web API handler. The re
264
546
 
265
547
  | Export | Description |
266
548
  |--------|-------------|
267
- | `createSupportHandler(options)` | Returns a Web API `(Request) => Promise<Response>` handler |
268
- | `createExpressHandler(options)` | Returns an Express `(req, res) => Promise<void>` handler |
549
+ | `createSupportHandler(options)` | Returns a Web API `(Request) => Promise<Response>` handler for messages |
550
+ | `createExpressHandler(options)` | Returns an Express `(req, res) => Promise<void>` handler for messages |
551
+ | `createWebhookHandler(options)` | Returns a Web API handler for Slack event webhooks |
552
+ | `createExpressWebhookHandler(options)` | Returns an Express handler for Slack event webhooks |
553
+ | `createRepliesHandler(options)` | Returns a Web API handler for the replies polling endpoint |
554
+ | `createExpressRepliesHandler(options)` | Returns an Express handler for the replies polling endpoint |
555
+ | `verifySlackSignature(secret, sig, ts, body)` | Verifies a Slack request signature |
556
+ | `InMemoryStore` | Default in-memory implementation of `SupportChatStore` |
269
557
  | `validateSlackToken(token)` | Validates a Slack token via `auth.test` |
558
+ | `emojify(text)` | Converts Slack emoji shortcodes (`:heart:`) to Unicode (❤️) |
270
559
 
271
560
  ### Client Exports (`simple-support-chat`)
272
561
 
@@ -288,7 +577,17 @@ All TypeScript types are exported from both entry points:
288
577
  import type { ChatBubbleProps, ChatUser, SupportChatModalProps, ChatMessage } from "simple-support-chat";
289
578
 
290
579
  // Server types
291
- import type { SupportHandlerOptions, IncomingMessage, HandlerResponse } from "simple-support-chat/server";
580
+ import type {
581
+ SupportHandlerOptions,
582
+ IncomingMessage,
583
+ HandlerResponse,
584
+ SupportChatStore,
585
+ Reply,
586
+ ThreadRecord,
587
+ WebhookHandlerOptions,
588
+ RepliesHandlerOptions,
589
+ RepliesResponse,
590
+ } from "simple-support-chat/server";
292
591
  ```
293
592
 
294
593
  ## Development
@@ -50,13 +50,18 @@ function collectAnonymousContext() {
50
50
  }
51
51
 
52
52
  // src/client/useChatEngine.ts
53
+ var POLL_INTERVAL = 4e3;
53
54
  function useChatEngine({
54
55
  apiUrl,
55
- user
56
+ user,
57
+ repliesUrl,
58
+ isOpen = true
56
59
  }) {
57
60
  const [messages, setMessages] = react.useState([]);
58
61
  const [input, setInput] = react.useState("");
59
62
  const [sending, setSending] = react.useState(false);
63
+ const lastReplyTimestampRef = react.useRef(null);
64
+ const knownReplyIdsRef = react.useRef(/* @__PURE__ */ new Set());
60
65
  const sendMessage = react.useCallback(async () => {
61
66
  const text = input.trim();
62
67
  if (!text || sending) return;
@@ -115,6 +120,44 @@ function useChatEngine({
115
120
  },
116
121
  [sendMessage]
117
122
  );
123
+ react.useEffect(() => {
124
+ if (!repliesUrl || !isOpen) return;
125
+ const sessionId = user?.id ?? getSessionId();
126
+ const fetchReplies = async () => {
127
+ try {
128
+ const params = new URLSearchParams({ sessionId });
129
+ if (lastReplyTimestampRef.current) {
130
+ params.set("since", lastReplyTimestampRef.current);
131
+ }
132
+ const response = await fetch(`${repliesUrl}?${params.toString()}`);
133
+ if (!response.ok) return;
134
+ const data = await response.json();
135
+ if (data.replies.length === 0) return;
136
+ const newReplies = data.replies.filter(
137
+ (r) => !knownReplyIdsRef.current.has(r.id)
138
+ );
139
+ if (newReplies.length === 0) return;
140
+ for (const r of newReplies) {
141
+ knownReplyIdsRef.current.add(r.id);
142
+ }
143
+ const latestTimestamp = newReplies.reduce((latest, r) => {
144
+ return r.timestamp > latest ? r.timestamp : latest;
145
+ }, lastReplyTimestampRef.current ?? "");
146
+ lastReplyTimestampRef.current = latestTimestamp;
147
+ const replyMessages = newReplies.map((r) => ({
148
+ id: r.id,
149
+ text: r.text,
150
+ sender: "received",
151
+ timestamp: new Date(r.timestamp).getTime()
152
+ }));
153
+ setMessages((prev) => [...prev, ...replyMessages]);
154
+ } catch {
155
+ }
156
+ };
157
+ void fetchReplies();
158
+ const intervalId = setInterval(() => void fetchReplies(), POLL_INTERVAL);
159
+ return () => clearInterval(intervalId);
160
+ }, [repliesUrl, isOpen, user]);
118
161
  return { messages, input, setInput, sending, sendMessage, handleKeyDown };
119
162
  }
120
163
  function useColorScheme() {
@@ -174,6 +217,10 @@ function injectKeyframes() {
174
217
  from { opacity: 1; transform: translateY(0) scale(1); }
175
218
  to { opacity: 0; transform: translateY(12px) scale(0.96); }
176
219
  }
220
+ @keyframes sc-badge-scale-in {
221
+ from { transform: scale(0); }
222
+ to { transform: scale(1); }
223
+ }
177
224
  `;
178
225
  document.head.appendChild(style);
179
226
  }
@@ -184,12 +231,31 @@ function ChatBubble({
184
231
  title = "Support",
185
232
  placeholder = "Type a message...",
186
233
  show = true,
187
- user
234
+ user,
235
+ repliesUrl,
236
+ isOpen: isOpenProp,
237
+ onOpenChange,
238
+ badgeColor = "#eab308",
239
+ unreadCount = 0
188
240
  }) {
189
- const [isOpen, setIsOpen] = react.useState(false);
241
+ const isControlled = isOpenProp !== void 0;
242
+ const [isOpenInternal, setIsOpenInternal] = react.useState(false);
243
+ const isOpen = isControlled ? isOpenProp : isOpenInternal;
244
+ const setIsOpen = react.useCallback(
245
+ (valueOrUpdater) => {
246
+ const newValue = typeof valueOrUpdater === "function" ? valueOrUpdater(isOpen) : valueOrUpdater;
247
+ if (isControlled) {
248
+ onOpenChange?.(newValue);
249
+ } else {
250
+ setIsOpenInternal(newValue);
251
+ onOpenChange?.(newValue);
252
+ }
253
+ },
254
+ [isControlled, isOpen, onOpenChange]
255
+ );
190
256
  const colorScheme = useColorScheme();
191
257
  const theme = react.useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
192
- const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user });
258
+ const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
193
259
  const [panelState, setPanelState] = react.useState(
194
260
  "closed"
195
261
  );
@@ -248,8 +314,8 @@ function ChatBubble({
248
314
  };
249
315
  document.addEventListener("keydown", handleTrapKeyDown);
250
316
  return () => document.removeEventListener("keydown", handleTrapKeyDown);
251
- }, [panelState]);
252
- const toggle = react.useCallback(() => setIsOpen((o) => !o), []);
317
+ }, [panelState, setIsOpen]);
318
+ const toggle = react.useCallback(() => setIsOpen((o) => !o), [setIsOpen]);
253
319
  const positionStyles = {
254
320
  position: "fixed",
255
321
  zIndex: 99999,
@@ -282,7 +348,7 @@ function ChatBubble({
282
348
  }
283
349
  }
284
350
  ` }),
285
- show && /* @__PURE__ */ jsxRuntime.jsx(
351
+ show && /* @__PURE__ */ jsxRuntime.jsxs(
286
352
  "button",
287
353
  {
288
354
  onClick: toggle,
@@ -301,7 +367,8 @@ function ChatBubble({
301
367
  alignItems: "center",
302
368
  justifyContent: "center",
303
369
  boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
304
- transition: "transform 0.2s ease, box-shadow 0.2s ease"
370
+ transition: "transform 0.2s ease, box-shadow 0.2s ease",
371
+ overflow: "visible"
305
372
  },
306
373
  onMouseEnter: (e) => {
307
374
  e.currentTarget.style.transform = "scale(1.1)";
@@ -311,21 +378,50 @@ function ChatBubble({
311
378
  e.currentTarget.style.transform = "scale(1)";
312
379
  e.currentTarget.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)";
313
380
  },
314
- children: /* @__PURE__ */ jsxRuntime.jsx(
315
- "svg",
316
- {
317
- width: "24",
318
- height: "24",
319
- viewBox: "0 0 24 24",
320
- fill: "none",
321
- stroke: "white",
322
- strokeWidth: "2",
323
- strokeLinecap: "round",
324
- strokeLinejoin: "round",
325
- "aria-hidden": "true",
326
- children: isOpen ? /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M18 6L6 18M6 6l12 12" }) : /* @__PURE__ */ jsxRuntime.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" })
327
- }
328
- )
381
+ children: [
382
+ /* @__PURE__ */ jsxRuntime.jsx(
383
+ "svg",
384
+ {
385
+ width: "24",
386
+ height: "24",
387
+ viewBox: "0 0 24 24",
388
+ fill: "none",
389
+ stroke: "white",
390
+ strokeWidth: "2",
391
+ strokeLinecap: "round",
392
+ strokeLinejoin: "round",
393
+ "aria-hidden": "true",
394
+ children: isOpen ? /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M18 6L6 18M6 6l12 12" }) : /* @__PURE__ */ jsxRuntime.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" })
395
+ }
396
+ ),
397
+ !isOpen && unreadCount > 0 && /* @__PURE__ */ jsxRuntime.jsx(
398
+ "span",
399
+ {
400
+ "data-testid": "unread-badge",
401
+ "aria-label": `${unreadCount > 9 ? "9+" : unreadCount} unread messages`,
402
+ style: {
403
+ position: "absolute",
404
+ top: "-4px",
405
+ right: "-4px",
406
+ minWidth: "20px",
407
+ height: "20px",
408
+ borderRadius: "10px",
409
+ backgroundColor: badgeColor,
410
+ color: "#fff",
411
+ fontSize: "11px",
412
+ fontWeight: 700,
413
+ display: "flex",
414
+ alignItems: "center",
415
+ justifyContent: "center",
416
+ padding: "0 5px",
417
+ lineHeight: 1,
418
+ boxShadow: "0 2px 4px rgba(0,0,0,0.2)",
419
+ animation: "sc-badge-scale-in 0.2s ease-out forwards"
420
+ },
421
+ children: unreadCount > 9 ? "9+" : unreadCount
422
+ }
423
+ )
424
+ ]
329
425
  }
330
426
  ),
331
427
  showPanel && /* @__PURE__ */ jsxRuntime.jsxs(
@@ -414,6 +510,7 @@ function ChatBubble({
414
510
  messages.map((msg) => /* @__PURE__ */ jsxRuntime.jsx(
415
511
  "div",
416
512
  {
513
+ "data-sender": msg.sender,
417
514
  style: {
418
515
  alignSelf: msg.sender === "user" ? "flex-end" : "flex-start",
419
516
  maxWidth: "80%",
@@ -527,9 +624,10 @@ function SupportChatModal({
527
624
  color = "#2563eb",
528
625
  title = "Contact Us",
529
626
  placeholder = "Type a message...",
530
- user
627
+ user,
628
+ repliesUrl
531
629
  }) {
532
- const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user });
630
+ const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
533
631
  const colorScheme = useColorScheme();
534
632
  const theme = react.useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
535
633
  const modalRef = react.useRef(null);
@@ -721,6 +819,7 @@ function SupportChatModal({
721
819
  messages.map((msg) => /* @__PURE__ */ jsxRuntime.jsx(
722
820
  "div",
723
821
  {
822
+ "data-sender": msg.sender,
724
823
  style: {
725
824
  alignSelf: msg.sender === "user" ? "flex-end" : "flex-start",
726
825
  maxWidth: "80%",
@@ -811,6 +910,75 @@ function useSupportChat() {
811
910
  const toggle = react.useCallback(() => setIsOpen((prev) => !prev), []);
812
911
  return { open, close, toggle, isOpen };
813
912
  }
913
+ var POLL_INTERVAL2 = 4e3;
914
+ function useUnreadCount({
915
+ repliesUrl,
916
+ user,
917
+ isOpen
918
+ }) {
919
+ const [pendingReplies, setPendingReplies] = react.useState([]);
920
+ const lastReplyTimestampRef = react.useRef(null);
921
+ const knownReplyIdsRef = react.useRef(/* @__PURE__ */ new Set());
922
+ const prevIsOpenRef = react.useRef(isOpen);
923
+ react.useEffect(() => {
924
+ if (isOpen && !prevIsOpenRef.current) {
925
+ setPendingReplies([]);
926
+ knownReplyIdsRef.current.clear();
927
+ lastReplyTimestampRef.current = null;
928
+ }
929
+ prevIsOpenRef.current = isOpen;
930
+ }, [isOpen]);
931
+ const markAsRead = react.useCallback(() => {
932
+ setPendingReplies([]);
933
+ knownReplyIdsRef.current.clear();
934
+ lastReplyTimestampRef.current = null;
935
+ }, []);
936
+ react.useEffect(() => {
937
+ if (!repliesUrl || isOpen) return;
938
+ const sessionId = user?.id ?? getSessionId();
939
+ const fetchReplies = async () => {
940
+ try {
941
+ const params = new URLSearchParams({ sessionId });
942
+ if (lastReplyTimestampRef.current) {
943
+ params.set("since", lastReplyTimestampRef.current);
944
+ }
945
+ const response = await fetch(`${repliesUrl}?${params.toString()}`);
946
+ if (!response.ok) return;
947
+ const data = await response.json();
948
+ if (data.replies.length === 0) return;
949
+ const newReplies = data.replies.filter(
950
+ (r) => !knownReplyIdsRef.current.has(r.id)
951
+ );
952
+ if (newReplies.length === 0) return;
953
+ for (const r of newReplies) {
954
+ knownReplyIdsRef.current.add(r.id);
955
+ }
956
+ const latestTimestamp = newReplies.reduce((latest, r) => {
957
+ return r.timestamp > latest ? r.timestamp : latest;
958
+ }, lastReplyTimestampRef.current ?? "");
959
+ lastReplyTimestampRef.current = latestTimestamp;
960
+ const replyMessages = newReplies.map((r) => ({
961
+ id: r.id,
962
+ text: r.text,
963
+ sender: "received",
964
+ timestamp: new Date(r.timestamp).getTime()
965
+ }));
966
+ setPendingReplies((prev) => [...prev, ...replyMessages]);
967
+ } catch {
968
+ }
969
+ };
970
+ void fetchReplies();
971
+ const intervalId = setInterval(() => void fetchReplies(), POLL_INTERVAL2);
972
+ return () => clearInterval(intervalId);
973
+ }, [repliesUrl, isOpen, user]);
974
+ const unreadCount = pendingReplies.length;
975
+ return {
976
+ unreadCount,
977
+ hasUnread: unreadCount > 0,
978
+ pendingReplies,
979
+ markAsRead
980
+ };
981
+ }
814
982
 
815
983
  exports.ChatBubble = ChatBubble;
816
984
  exports.SupportChatModal = SupportChatModal;
@@ -818,5 +986,6 @@ exports.collectAnonymousContext = collectAnonymousContext;
818
986
  exports.getSessionId = getSessionId;
819
987
  exports.useChatEngine = useChatEngine;
820
988
  exports.useSupportChat = useSupportChat;
989
+ exports.useUnreadCount = useUnreadCount;
821
990
  //# sourceMappingURL=index.cjs.map
822
991
  //# sourceMappingURL=index.cjs.map