simple-support-chat 0.1.0 → 0.2.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 +302 -3
- package/dist/client/index.cjs +118 -20
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.cts +13 -5
- package/dist/client/index.d.ts +13 -5
- package/dist/client/index.js +119 -21
- package/dist/client/index.js.map +1 -1
- package/dist/server/index.cjs +2176 -9
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +189 -1
- package/dist/server/index.d.ts +189 -1
- package/dist/server/index.js +2166 -10
- package/dist/server/index.js.map +1 -1
- package/package.json +87 -85
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 {
|
|
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
|
package/dist/client/index.cjs
CHANGED
|
@@ -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,8 +120,89 @@ 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
|
}
|
|
163
|
+
function useColorScheme() {
|
|
164
|
+
const [scheme, setScheme] = react.useState(() => {
|
|
165
|
+
if (typeof window === "undefined") return "light";
|
|
166
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
167
|
+
});
|
|
168
|
+
react.useEffect(() => {
|
|
169
|
+
if (typeof window === "undefined") return;
|
|
170
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
171
|
+
const handler = (e) => {
|
|
172
|
+
setScheme(e.matches ? "dark" : "light");
|
|
173
|
+
};
|
|
174
|
+
mq.addEventListener("change", handler);
|
|
175
|
+
return () => mq.removeEventListener("change", handler);
|
|
176
|
+
}, []);
|
|
177
|
+
return scheme;
|
|
178
|
+
}
|
|
179
|
+
var lightTokens = {
|
|
180
|
+
panelBg: "#ffffff",
|
|
181
|
+
panelBorder: "#e5e7eb",
|
|
182
|
+
inputBg: "#ffffff",
|
|
183
|
+
inputBorder: "#d1d5db",
|
|
184
|
+
inputText: "#1f2937",
|
|
185
|
+
inputPlaceholder: "#9ca3af",
|
|
186
|
+
receivedBg: "#f3f4f6",
|
|
187
|
+
receivedText: "#1f2937",
|
|
188
|
+
emptyText: "#9ca3af",
|
|
189
|
+
inputAreaBorder: "#e5e7eb"
|
|
190
|
+
};
|
|
191
|
+
var darkTokens = {
|
|
192
|
+
panelBg: "#1f2937",
|
|
193
|
+
panelBorder: "#374151",
|
|
194
|
+
inputBg: "#111827",
|
|
195
|
+
inputBorder: "#4b5563",
|
|
196
|
+
inputText: "#f9fafb",
|
|
197
|
+
inputPlaceholder: "#9ca3af",
|
|
198
|
+
receivedBg: "#374151",
|
|
199
|
+
receivedText: "#f3f4f6",
|
|
200
|
+
emptyText: "#6b7280",
|
|
201
|
+
inputAreaBorder: "#374151"
|
|
202
|
+
};
|
|
203
|
+
function getThemeTokens(scheme) {
|
|
204
|
+
return scheme === "dark" ? darkTokens : lightTokens;
|
|
205
|
+
}
|
|
120
206
|
function injectKeyframes() {
|
|
121
207
|
if (typeof document === "undefined") return;
|
|
122
208
|
if (document.querySelector("[data-support-chat-keyframes]")) return;
|
|
@@ -141,10 +227,13 @@ function ChatBubble({
|
|
|
141
227
|
title = "Support",
|
|
142
228
|
placeholder = "Type a message...",
|
|
143
229
|
show = true,
|
|
144
|
-
user
|
|
230
|
+
user,
|
|
231
|
+
repliesUrl
|
|
145
232
|
}) {
|
|
146
233
|
const [isOpen, setIsOpen] = react.useState(false);
|
|
147
|
-
const
|
|
234
|
+
const colorScheme = useColorScheme();
|
|
235
|
+
const theme = react.useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
|
|
236
|
+
const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
|
|
148
237
|
const [panelState, setPanelState] = react.useState(
|
|
149
238
|
"closed"
|
|
150
239
|
);
|
|
@@ -301,8 +390,8 @@ function ChatBubble({
|
|
|
301
390
|
boxShadow: "0 8px 30px rgba(0,0,0,0.12)",
|
|
302
391
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
303
392
|
fontSize: "14px",
|
|
304
|
-
backgroundColor:
|
|
305
|
-
border:
|
|
393
|
+
backgroundColor: theme.panelBg,
|
|
394
|
+
border: `1px solid ${theme.panelBorder}`,
|
|
306
395
|
animation: panelState === "open" ? "sc-slide-in 0.25s ease-out forwards" : "sc-slide-out 0.2s ease-in forwards"
|
|
307
396
|
},
|
|
308
397
|
children: [
|
|
@@ -359,7 +448,7 @@ function ChatBubble({
|
|
|
359
448
|
"div",
|
|
360
449
|
{
|
|
361
450
|
style: {
|
|
362
|
-
color:
|
|
451
|
+
color: theme.emptyText,
|
|
363
452
|
textAlign: "center",
|
|
364
453
|
marginTop: "40px"
|
|
365
454
|
},
|
|
@@ -369,13 +458,14 @@ function ChatBubble({
|
|
|
369
458
|
messages.map((msg) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
370
459
|
"div",
|
|
371
460
|
{
|
|
461
|
+
"data-sender": msg.sender,
|
|
372
462
|
style: {
|
|
373
463
|
alignSelf: msg.sender === "user" ? "flex-end" : "flex-start",
|
|
374
464
|
maxWidth: "80%",
|
|
375
465
|
padding: "10px 14px",
|
|
376
466
|
borderRadius: msg.sender === "user" ? "16px 16px 4px 16px" : "16px 16px 16px 4px",
|
|
377
|
-
backgroundColor: msg.sender === "user" ? color :
|
|
378
|
-
color: msg.sender === "user" ? "#fff" :
|
|
467
|
+
backgroundColor: msg.sender === "user" ? color : theme.receivedBg,
|
|
468
|
+
color: msg.sender === "user" ? "#fff" : theme.receivedText,
|
|
379
469
|
wordBreak: "break-word"
|
|
380
470
|
},
|
|
381
471
|
children: msg.text
|
|
@@ -390,7 +480,7 @@ function ChatBubble({
|
|
|
390
480
|
"div",
|
|
391
481
|
{
|
|
392
482
|
style: {
|
|
393
|
-
borderTop:
|
|
483
|
+
borderTop: `1px solid ${theme.inputAreaBorder}`,
|
|
394
484
|
padding: "12px",
|
|
395
485
|
display: "flex",
|
|
396
486
|
gap: "8px",
|
|
@@ -409,12 +499,14 @@ function ChatBubble({
|
|
|
409
499
|
"aria-label": "Type your message",
|
|
410
500
|
style: {
|
|
411
501
|
flex: 1,
|
|
412
|
-
border:
|
|
502
|
+
border: `1px solid ${theme.inputBorder}`,
|
|
413
503
|
borderRadius: "8px",
|
|
414
504
|
padding: "10px 12px",
|
|
415
505
|
fontSize: "14px",
|
|
416
506
|
outline: "none",
|
|
417
|
-
fontFamily: "inherit"
|
|
507
|
+
fontFamily: "inherit",
|
|
508
|
+
backgroundColor: theme.inputBg,
|
|
509
|
+
color: theme.inputText
|
|
418
510
|
}
|
|
419
511
|
}
|
|
420
512
|
),
|
|
@@ -480,9 +572,12 @@ function SupportChatModal({
|
|
|
480
572
|
color = "#2563eb",
|
|
481
573
|
title = "Contact Us",
|
|
482
574
|
placeholder = "Type a message...",
|
|
483
|
-
user
|
|
575
|
+
user,
|
|
576
|
+
repliesUrl
|
|
484
577
|
}) {
|
|
485
|
-
const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user });
|
|
578
|
+
const { messages, input, setInput, sending, sendMessage, handleKeyDown } = useChatEngine({ apiUrl, user, repliesUrl, isOpen });
|
|
579
|
+
const colorScheme = useColorScheme();
|
|
580
|
+
const theme = react.useMemo(() => getThemeTokens(colorScheme), [colorScheme]);
|
|
486
581
|
const modalRef = react.useRef(null);
|
|
487
582
|
const inputRef = react.useRef(null);
|
|
488
583
|
const messagesEndRef = react.useRef(null);
|
|
@@ -600,7 +695,7 @@ function SupportChatModal({
|
|
|
600
695
|
maxWidth: "calc(100vw - 32px)",
|
|
601
696
|
height: "600px",
|
|
602
697
|
maxHeight: "calc(100vh - 64px)",
|
|
603
|
-
backgroundColor:
|
|
698
|
+
backgroundColor: theme.panelBg,
|
|
604
699
|
borderRadius: "16px",
|
|
605
700
|
overflow: "hidden",
|
|
606
701
|
display: "flex",
|
|
@@ -662,7 +757,7 @@ function SupportChatModal({
|
|
|
662
757
|
"div",
|
|
663
758
|
{
|
|
664
759
|
style: {
|
|
665
|
-
color:
|
|
760
|
+
color: theme.emptyText,
|
|
666
761
|
textAlign: "center",
|
|
667
762
|
marginTop: "60px"
|
|
668
763
|
},
|
|
@@ -672,13 +767,14 @@ function SupportChatModal({
|
|
|
672
767
|
messages.map((msg) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
673
768
|
"div",
|
|
674
769
|
{
|
|
770
|
+
"data-sender": msg.sender,
|
|
675
771
|
style: {
|
|
676
772
|
alignSelf: msg.sender === "user" ? "flex-end" : "flex-start",
|
|
677
773
|
maxWidth: "80%",
|
|
678
774
|
padding: "10px 14px",
|
|
679
775
|
borderRadius: msg.sender === "user" ? "16px 16px 4px 16px" : "16px 16px 16px 4px",
|
|
680
|
-
backgroundColor: msg.sender === "user" ? color :
|
|
681
|
-
color: msg.sender === "user" ? "#fff" :
|
|
776
|
+
backgroundColor: msg.sender === "user" ? color : theme.receivedBg,
|
|
777
|
+
color: msg.sender === "user" ? "#fff" : theme.receivedText,
|
|
682
778
|
wordBreak: "break-word"
|
|
683
779
|
},
|
|
684
780
|
children: msg.text
|
|
@@ -693,7 +789,7 @@ function SupportChatModal({
|
|
|
693
789
|
"div",
|
|
694
790
|
{
|
|
695
791
|
style: {
|
|
696
|
-
borderTop:
|
|
792
|
+
borderTop: `1px solid ${theme.inputAreaBorder}`,
|
|
697
793
|
padding: "16px 20px",
|
|
698
794
|
display: "flex",
|
|
699
795
|
gap: "8px",
|
|
@@ -712,12 +808,14 @@ function SupportChatModal({
|
|
|
712
808
|
"aria-label": "Type your message",
|
|
713
809
|
style: {
|
|
714
810
|
flex: 1,
|
|
715
|
-
border:
|
|
811
|
+
border: `1px solid ${theme.inputBorder}`,
|
|
716
812
|
borderRadius: "8px",
|
|
717
813
|
padding: "12px 14px",
|
|
718
814
|
fontSize: "14px",
|
|
719
815
|
outline: "none",
|
|
720
|
-
fontFamily: "inherit"
|
|
816
|
+
fontFamily: "inherit",
|
|
817
|
+
backgroundColor: theme.inputBg,
|
|
818
|
+
color: theme.inputText
|
|
721
819
|
}
|
|
722
820
|
}
|
|
723
821
|
),
|