@waysdrop/chat 1.0.3 → 1.0.5

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
@@ -43,10 +43,12 @@ Mount `<ChatWidget />` once at the root of your app. It renders a floating butto
43
43
 
44
44
  ```ts
45
45
  type ChatConfig = {
46
- serverUrl: string // Socket.IO server URL
47
- apiUrl: string // REST base URL (used for file uploads)
48
- token?: string // JWT for authenticated users — omit for visitor flow
49
- visitorId?: string // Pass a returning visitor's ID to restore chat history
46
+ serverUrl: string // Socket.IO server URL
47
+ apiUrl: string // REST base URL (used for file uploads)
48
+ token?: string // JWT for authenticated users — omit for visitor flow
49
+ visitorId?: string // Pass a returning visitor's ID to restore chat history
50
+ theme?: 'light' | 'dark' | 'system' // defaults to 'system'
51
+ primaryColor?: string // any valid CSS color — defaults to Waysdrop blue
50
52
  }
51
53
  ```
52
54
 
@@ -60,7 +62,20 @@ saveVisitorId(id) // writes to localStorage
60
62
  clearVisitorId() // clears — use on logout
61
63
  ```
62
64
 
63
- **Authenticated flow** — pass a `token` (JWT). The socket server resolves the user from it. No `visitorId` needed.
65
+ **Authenticated flow** — pass a `token` (JWT). The socket server resolves the user from it. No `visitorId` needed. If the token changes at runtime (user logs in after mount), the widget automatically destroys the old socket connection and reconnects with the new token.
66
+
67
+ **Theming** — `theme` controls the color scheme. `system` follows the OS `prefers-color-scheme`. `light` and `dark` force it regardless of the OS setting. `primaryColor` accepts any valid CSS color value:
68
+
69
+ ```tsx
70
+ <ChatWidget
71
+ config={{
72
+ serverUrl: '...',
73
+ apiUrl: '...',
74
+ theme: 'dark',
75
+ primaryColor: '#7c3aed',
76
+ }}
77
+ />
78
+ ```
64
79
 
65
80
  ---
66
81
 
@@ -81,11 +96,11 @@ const {
81
96
  visitorInfo, // VisitorInfo | null
82
97
  setVisitorInfo,
83
98
  sendMessage, // (content: string, info?: VisitorInfo) => void
84
- sendFile, // (file: File, info?: VisitorInfo) => Promise<void>
99
+ sendFile, // (file: File, content?: string, info?: VisitorInfo) => Promise<void>
85
100
  } = useChat(config)
86
101
  ```
87
102
 
88
- `sendMessage` and `sendFile` accept an optional `VisitorInfo` argument for the first message in a visitor session (name, email, phone). After the first message, `visitorInfo` is stored in the Zustand store and reused automatically.
103
+ `sendMessage` and `sendFile` accept an optional `VisitorInfo` argument for the first message in a visitor session. `sendFile` also accepts an optional `content` string to send text alongside the file in a single message.
89
104
 
90
105
  ---
91
106
 
@@ -104,7 +119,7 @@ type ChatMessage = {
104
119
  }
105
120
 
106
121
  type VisitorInfo = {
107
- email: string // required
122
+ email: string
108
123
  name?: string
109
124
  phone?: string
110
125
  }
package/dist/index.cjs CHANGED
@@ -63,12 +63,15 @@ var import_css8 = require("@emotion/css");
63
63
  // src/lib/upload.ts
64
64
  var VISITOR_ID_KEY = "waysdrop_visitor_id";
65
65
  var saveVisitorId = (id) => {
66
+ if (typeof window === "undefined") return;
66
67
  localStorage.setItem(VISITOR_ID_KEY, id);
67
68
  };
68
69
  var loadVisitorId = () => {
70
+ if (typeof window === "undefined") return null;
69
71
  return localStorage.getItem(VISITOR_ID_KEY);
70
72
  };
71
73
  var clearVisitorId = () => {
74
+ if (typeof window === "undefined") return;
72
75
  localStorage.removeItem(VISITOR_ID_KEY);
73
76
  };
74
77
  var uploadFile = async (file, config) => {
@@ -102,8 +105,11 @@ var import_react = require("react");
102
105
  // src/lib/socket.ts
103
106
  var import_socket = require("socket.io-client");
104
107
  var socket = null;
105
- var getSocket = (config) => {
106
- if (socket) return socket;
108
+ var createSocket = (config) => {
109
+ if (socket) {
110
+ socket.disconnect();
111
+ socket = null;
112
+ }
107
113
  const { serverUrl, token, visitorId } = config;
108
114
  socket = (0, import_socket.io)(serverUrl, {
109
115
  transports: ["websocket", "polling"],
@@ -112,6 +118,10 @@ var getSocket = (config) => {
112
118
  });
113
119
  return socket;
114
120
  };
121
+ var getSocket = (config) => {
122
+ if (socket) return socket;
123
+ return createSocket(config);
124
+ };
115
125
  var destroySocket = () => {
116
126
  if (socket) {
117
127
  socket.disconnect();
@@ -163,7 +173,7 @@ var useChat = (config) => {
163
173
  reset
164
174
  } = useChatStore();
165
175
  (0, import_react.useEffect)(() => {
166
- const socket2 = getSocket(config);
176
+ const socket2 = createSocket(config);
167
177
  setStatus("connecting");
168
178
  socket2.connect();
169
179
  socket2.on("connected", (payload) => {
@@ -190,7 +200,7 @@ var useChat = (config) => {
190
200
  destroySocket();
191
201
  reset();
192
202
  };
193
- }, []);
203
+ }, [config.token]);
194
204
  const sendMessage = (0, import_react.useCallback)(
195
205
  (content, info) => {
196
206
  var _a, _b;
@@ -243,7 +253,7 @@ var useChat = (config) => {
243
253
  var import_react2 = require("react");
244
254
  var import_css = require("@emotion/css");
245
255
  var import_jsx_runtime = require("react/jsx-runtime");
246
- var BUTTON_SIZE = 56;
256
+ var BUTTON_SIZE = 58;
247
257
  var FloatingButton = ({ isOpen, onToggle }) => {
248
258
  const [isMobile, setIsMobile] = (0, import_react2.useState)(false);
249
259
  (0, import_react2.useEffect)(() => {
@@ -253,49 +263,17 @@ var FloatingButton = ({ isOpen, onToggle }) => {
253
263
  return () => window.removeEventListener("resize", check);
254
264
  }, []);
255
265
  if (isMobile && isOpen) {
256
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
257
- "button",
258
- {
259
- className: styles.mobileCloseBtn,
260
- onClick: onToggle,
261
- type: "button",
262
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
263
- "svg",
264
- {
265
- width: "22",
266
- height: "22",
267
- viewBox: "0 0 24 24",
268
- fill: "none",
269
- stroke: "currentColor",
270
- strokeWidth: "2.5",
271
- strokeLinecap: "round",
272
- children: [
273
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
274
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
275
- ]
276
- }
277
- )
278
- }
279
- );
266
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: styles.mobileCloseBtn, onClick: onToggle, type: "button", children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "#fff", strokeWidth: "2.5", strokeLinecap: "round", children: [
267
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
268
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
269
+ ] }) });
280
270
  }
281
271
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { className: styles.btn, onClick: onToggle, type: "button", children: [
282
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: styles.icon(!isOpen), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M20 2H4a2 2 0 0 0-2 2v18l4-4h14a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z" }) }) }),
283
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: styles.icon(isOpen), children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
284
- "svg",
285
- {
286
- width: "20",
287
- height: "20",
288
- viewBox: "0 0 24 24",
289
- fill: "none",
290
- stroke: "currentColor",
291
- strokeWidth: "2.5",
292
- strokeLinecap: "round",
293
- children: [
294
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
295
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
296
- ]
297
- }
298
- ) })
272
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: styles.icon(!isOpen), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "#fff", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M20 2H4a2 2 0 0 0-2 2v18l4-4h14a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z" }) }) }),
273
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: styles.icon(isOpen), children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "#fff", strokeWidth: "2.5", strokeLinecap: "round", children: [
274
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
275
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
276
+ ] }) })
299
277
  ] });
300
278
  };
301
279
  var styles = {
@@ -306,8 +284,7 @@ var styles = {
306
284
  width: ${BUTTON_SIZE}px;
307
285
  height: ${BUTTON_SIZE}px;
308
286
  border-radius: 50%;
309
- background: var(--wds-primary);
310
- color: #fff;
287
+ background: var(--wds-primary, oklch(0.5811 0.2268 259.15));
311
288
  border: none;
312
289
  cursor: pointer;
313
290
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.18);
@@ -328,8 +305,7 @@ var styles = {
328
305
  width: ${BUTTON_SIZE}px;
329
306
  height: ${BUTTON_SIZE}px;
330
307
  border-radius: 50%;
331
- background: var(--wds-primary);
332
- color: #fff;
308
+ background: var(--wds-primary, oklch(0.5811 0.2268 259.15));
333
309
  border: none;
334
310
  cursor: pointer;
335
311
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
@@ -343,9 +319,7 @@ var styles = {
343
319
  display: flex;
344
320
  align-items: center;
345
321
  justify-content: center;
346
- transition:
347
- opacity 0.2s,
348
- transform 0.2s;
322
+ transition: opacity 0.2s, transform 0.2s;
349
323
  opacity: ${visible ? 1 : 0};
350
324
  transform: ${visible ? "scale(1)" : "scale(0.6) rotate(30deg)"};
351
325
  pointer-events: ${visible ? "auto" : "none"};
@@ -1071,7 +1045,6 @@ var styles5 = {
1071
1045
  margin: 0;
1072
1046
  text-align: center;
1073
1047
  opacity: 0.7;
1074
- text-align: center;
1075
1048
  `
1076
1049
  };
1077
1050
 
@@ -1092,7 +1065,7 @@ var ChatScreen = ({
1092
1065
  const connected = status === "connected";
1093
1066
  const [showScrollBtn, setShowScrollBtn] = (0, import_react4.useState)(false);
1094
1067
  const lastMsg = messages[messages.length - 1];
1095
- const isThinking = connected && (lastMsg == null ? void 0 : lastMsg.direction) === "INBOUND";
1068
+ const isThinking = connected && (lastMsg == null ? void 0 : lastMsg.direction) === "OUTBOUND";
1096
1069
  (0, import_react4.useEffect)(() => {
1097
1070
  var _a;
1098
1071
  (_a = bottomRef.current) == null ? void 0 : _a.scrollIntoView({ behavior: "smooth" });
@@ -1356,16 +1329,74 @@ var styles6 = {
1356
1329
 
1357
1330
  // src/components/ChatWidget.tsx
1358
1331
  var import_jsx_runtime8 = require("react/jsx-runtime");
1332
+ var LIGHT_VARS = `
1333
+ --wds-bg: oklch(1 0 0);
1334
+ --wds-fg: oklch(0.145 0 0);
1335
+ --wds-muted: oklch(0.556 0 0);
1336
+ --wds-muted-bg: oklch(0.97 0 0);
1337
+ --wds-border: oklch(0.922 0 0);
1338
+ `;
1339
+ var DARK_VARS = `
1340
+ --wds-bg: oklch(0.145 0 0);
1341
+ --wds-fg: oklch(0.985 0 0);
1342
+ --wds-muted: oklch(0.708 0 0);
1343
+ --wds-muted-bg: oklch(0.205 0 0);
1344
+ --wds-border: oklch(1 0 0 / 10%);
1345
+ `;
1346
+ function buildPrimaryVars(color) {
1347
+ return `
1348
+ --wds-primary: ${color};
1349
+ --wds-primary-soft: color-mix(in oklch, ${color} 12%, transparent);
1350
+ --wds-primary-border: color-mix(in oklch, ${color} 40%, transparent);
1351
+ `;
1352
+ }
1353
+ var DEFAULT_PRIMARY = "oklch(0.5811 0.2268 259.15)";
1354
+ function buildThemeStyle(theme, primaryColor) {
1355
+ const primary = buildPrimaryVars(primaryColor);
1356
+ if (theme === "light") {
1357
+ return `
1358
+ [data-wds-root] {
1359
+ ${LIGHT_VARS}
1360
+ ${primary}
1361
+ }
1362
+ `;
1363
+ }
1364
+ if (theme === "dark") {
1365
+ return `
1366
+ [data-wds-root] {
1367
+ ${DARK_VARS}
1368
+ ${primary}
1369
+ }
1370
+ `;
1371
+ }
1372
+ return `
1373
+ [data-wds-root] {
1374
+ ${LIGHT_VARS}
1375
+ ${primary}
1376
+ }
1377
+ @media (prefers-color-scheme: dark) {
1378
+ [data-wds-root] {
1379
+ ${DARK_VARS}
1380
+ --wds-primary-soft: color-mix(in oklch, ${primaryColor} 15%, transparent);
1381
+ }
1382
+ }
1383
+ `;
1384
+ }
1359
1385
  var ChatWidget = ({ config }) => {
1360
- var _a, _b;
1361
1386
  const [isOpen, setIsOpen] = (0, import_react5.useState)(false);
1362
1387
  const [isExpanded, setIsExpanded] = (0, import_react5.useState)(false);
1363
1388
  const [view, setView] = (0, import_react5.useState)("home");
1389
+ const [visitorId] = (0, import_react5.useState)(
1390
+ () => {
1391
+ var _a, _b;
1392
+ return (_b = (_a = config.visitorId) != null ? _a : loadVisitorId()) != null ? _b : void 0;
1393
+ }
1394
+ );
1364
1395
  const reset = useChatStore((s) => s.reset);
1365
1396
  const resolvedConfig = __spreadProps(__spreadValues({}, config), {
1366
- visitorId: (_b = (_a = config.visitorId) != null ? _a : loadVisitorId()) != null ? _b : void 0
1397
+ visitorId
1367
1398
  });
1368
- const hasHistory = !!resolvedConfig.visitorId;
1399
+ const hasHistory = !!visitorId;
1369
1400
  const { status, messages, error, sendMessage, sendFile } = useChat(resolvedConfig);
1370
1401
  const handleNewConversation = () => {
1371
1402
  reset();
@@ -1380,6 +1411,19 @@ var ChatWidget = ({ config }) => {
1380
1411
  document.body.style.overflow = "";
1381
1412
  };
1382
1413
  }, [isOpen]);
1414
+ (0, import_react5.useEffect)(() => {
1415
+ var _a, _b;
1416
+ const theme = (_a = config.theme) != null ? _a : "system";
1417
+ const primaryColor = (_b = config.primaryColor) != null ? _b : DEFAULT_PRIMARY;
1418
+ const style = buildThemeStyle(theme, primaryColor);
1419
+ const el = document.createElement("style");
1420
+ el.setAttribute("data-wds-theme", "");
1421
+ el.textContent = style;
1422
+ document.head.appendChild(el);
1423
+ return () => {
1424
+ document.head.removeChild(el);
1425
+ };
1426
+ }, [config.theme, config.primaryColor]);
1383
1427
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_jsx_runtime8.Fragment, { children: [
1384
1428
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
1385
1429
  FloatingButton,
@@ -1420,28 +1464,6 @@ var ChatWidget = ({ config }) => {
1420
1464
  ] });
1421
1465
  };
1422
1466
  import_css8.injectGlobal`
1423
- :root {
1424
- --wds-primary: oklch(0.5811 0.2268 259.15);
1425
- --wds-primary-soft: oklch(0.5811 0.2268 259.15 / 0.12);
1426
- --wds-primary-border: oklch(0.7695 0.1177 255.22 / 0.4);
1427
- --wds-bg: oklch(1 0 0);
1428
- --wds-fg: oklch(0.145 0 0);
1429
- --wds-muted: oklch(0.556 0 0);
1430
- --wds-muted-bg: oklch(0.97 0 0);
1431
- --wds-border: oklch(0.922 0 0);
1432
- }
1433
-
1434
- @media (prefers-color-scheme: dark) {
1435
- :root {
1436
- --wds-bg: oklch(0.145 0 0);
1437
- --wds-fg: oklch(0.985 0 0);
1438
- --wds-muted: oklch(0.708 0 0);
1439
- --wds-muted-bg: oklch(0.205 0 0);
1440
- --wds-border: oklch(1 0 0 / 10%);
1441
- --wds-primary-soft: oklch(0.5811 0.2268 259.15 / 0.15);
1442
- }
1443
- }
1444
-
1445
1467
  [data-wds-root] * {
1446
1468
  box-sizing: border-box;
1447
1469
  text-align: left;