rushchatbot 1.7.1 → 1.7.2

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
@@ -1,6 +1,6 @@
1
1
  # RushChatbot
2
2
 
3
- **Version:** 1.7.1
3
+ **Version:** 1.7.2
4
4
 
5
5
  Embeddable hybrid chatbot plugin that combines a click-based FAQ tree with a free-text query engine backed by n8n. Ships as an npm package with a Node.js/Express server and widgets for React, Vue 3, and Vanilla JS.
6
6
 
package/package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # RushChatbot
2
2
 
3
- **Version:** 1.7.1
3
+ **Version:** 1.7.2
4
4
 
5
5
  Embeddable hybrid chatbot plugin that combines a click-based FAQ tree with a free-text query engine backed by n8n. Ships as an npm package with a Node.js/Express server and widgets for React, Vue 3, and Vanilla JS.
6
6
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rushchatbot",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "description": "Embeddable hybrid chatbot with n8n fallback, license key protection, and configurable FAQ tree",
5
5
  "keywords": [
6
6
  "chatbot",
@@ -142,6 +142,16 @@ export function ChatWidget({ apiBase, width = 380, height = 580 }: ChatWidgetPro
142
142
  </div>
143
143
  )}
144
144
 
145
+ {!state.messages.some(m => m.isEndChat) && (
146
+ <button onClick={() => chat.endChat()} style={{
147
+ textAlign: 'left', padding: '8px 12px', borderRadius: 8,
148
+ border: '1px solid #EF4444', background: 'transparent',
149
+ color: '#EF4444', cursor: 'pointer', fontSize: 14, marginTop: 4, width: '100%',
150
+ }}>
151
+ 👋 I'm done, thanks!
152
+ </button>
153
+ )}
154
+
145
155
  <div ref={bottomRef} />
146
156
  </div>
147
157
 
@@ -74,11 +74,19 @@ class RushChatbotElement extends HTMLElement {
74
74
  private state: RushChatbotState | null = null
75
75
  private showScrollBtn = false
76
76
  private resizeObserver: ResizeObserver | null = null
77
+ private isOpen = false
78
+ private btnX = -1
79
+ private btnY = -1
80
+ private dragging = false
81
+ private dragOffX = 0
82
+ private dragOffY = 0
83
+ private dragMoved = false
77
84
 
78
85
  static get observedAttributes() { return OBSERVED }
79
86
 
80
87
  connectedCallback() {
81
88
  this.shadow = this.attachShadow({ mode: 'open' })
89
+ this.isOpen = this.readSettings().autoOpen ?? false
82
90
  this.chat = new RushChatbot({ apiBase: this.getAttribute('api-base') ?? undefined, settings: this.readSettings() })
83
91
  this.chat.subscribe(state => { this.state = state; this.render() })
84
92
  this.chat.init()
@@ -128,23 +136,35 @@ class RushChatbotElement extends HTMLElement {
128
136
  }
129
137
  }
130
138
 
131
- private get w() {
132
- const attr = this.getAttribute('width')
133
- return attr ? `${attr}px` : '100%'
134
- }
135
- private get h() {
136
- const attr = this.getAttribute('height')
137
- return attr ? `${attr}px` : '100%'
139
+ private initBtnPos() {
140
+ const pos = this.settings.position ?? 'bottom-right'
141
+ const margin = 24
142
+ const btnSize = 56
143
+ if (this.btnX === -1) {
144
+ this.btnX = pos === 'bottom-left'
145
+ ? margin
146
+ : window.innerWidth - btnSize - margin
147
+ this.btnY = window.innerHeight - btnSize - margin
148
+ }
138
149
  }
139
150
 
140
151
  private render() {
141
152
  if (!this.state || !this.state.config) return
142
153
  const { theme, state, settings } = this
143
154
 
155
+ this.initBtnPos()
156
+
144
157
  const borderRadius = settings.borderRadius ?? 16
145
158
  const shadow = settings.showShadow !== false ? '0 8px 32px rgba(0,0,0,0.15)' : 'none'
146
159
  const placeholder = settings.placeholder ?? 'Type a message...'
147
160
  const sendLabel = settings.sendLabel ?? 'Send'
161
+ const chatW = this.getAttribute('width') ? Number(this.getAttribute('width')) : 380
162
+ const chatH = this.getAttribute('height') ? Number(this.getAttribute('height')) : 580
163
+ const btnSize = 56
164
+
165
+ // Clamp chat box so it stays in viewport
166
+ const boxLeft = Math.min(Math.max(this.btnX + btnSize / 2 - chatW / 2, 8), window.innerWidth - chatW - 8)
167
+ const boxBottom = window.innerHeight - this.btnY + 12
148
168
 
149
169
  const msgsEl = this.shadow.querySelector('.messages') as HTMLElement | null
150
170
  const wasAtBottom = msgsEl ? msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < 60 : true
@@ -152,32 +172,46 @@ class RushChatbotElement extends HTMLElement {
152
172
  // Build static shell with no user data in innerHTML
153
173
  const shellHtml = `
154
174
  <style>${STYLES}</style>
155
- <div class="container" id="container">
156
- <div class="header" id="header">
157
- <div class="header-title" id="header-title"></div>
158
- <div class="header-subtitle" id="header-subtitle"></div>
159
- </div>
160
- <div class="messages-wrap">
161
- <div class="messages" id="msgs">
162
- <div id="msgs-content"></div>
163
- <div id="bottom"></div>
175
+ <div id="chat-box" style="display:${this.isOpen ? 'flex' : 'none'};position:fixed;left:${boxLeft}px;bottom:${boxBottom}px;z-index:2147483646;flex-direction:column;width:${chatW}px;height:${chatH}px">
176
+ <div class="container" id="container">
177
+ <div class="header" id="header">
178
+ <div style="display:flex;align-items:center;justify-content:space-between">
179
+ <div>
180
+ <div class="header-title" id="header-title"></div>
181
+ <div class="header-subtitle" id="header-subtitle"></div>
182
+ </div>
183
+ <button id="close-btn" style="background:none;border:none;color:#fff;cursor:pointer;font-size:20px;line-height:1;padding:0 4px">&times;</button>
184
+ </div>
185
+ </div>
186
+ <div class="messages-wrap">
187
+ <div class="messages" id="msgs">
188
+ <div id="msgs-content"></div>
189
+ <div id="bottom"></div>
190
+ </div>
164
191
  </div>
192
+ ${settings.hideInput ? '' : `
193
+ <form class="input-row" id="form">
194
+ <input id="input" />
195
+ <button type="submit" id="send-btn"></button>
196
+ </form>`}
165
197
  </div>
166
- ${settings.hideInput ? '' : `
167
- <form class="input-row" id="form">
168
- <input id="input" />
169
- <button type="submit" id="send-btn"></button>
170
- </form>`}
171
198
  </div>
199
+ <button id="fab" style="position:fixed;left:${this.btnX}px;top:${this.btnY}px;z-index:2147483647;width:${btnSize}px;height:${btnSize}px;border-radius:50%;border:none;cursor:grab;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 16px rgba(0,0,0,0.25);touch-action:none">
200
+ <svg id="fab-icon-chat" width="26" height="26" viewBox="0 0 24 24" fill="none" style="display:${this.isOpen ? 'none' : 'block'}"><path d="M20 2H4a2 2 0 0 0-2 2v18l4-4h14a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z" fill="#fff"/></svg>
201
+ <svg id="fab-icon-close" width="22" height="22" viewBox="0 0 24 24" fill="none" style="display:${this.isOpen ? 'block' : 'none'}"><path d="M18 6L6 18M6 6l12 12" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/></svg>
202
+ </button>
172
203
  `
173
204
  this.shadow.innerHTML = shellHtml
174
205
 
175
206
  // Populate dynamic values via DOM (safe from XSS)
176
207
  const container = this.shadow.getElementById('container') as HTMLElement
177
- container.style.cssText = `background:${escapeHtml(theme.backgroundColor)};color:${escapeHtml(theme.textColor)};width:${this.w};height:${this.h};font-family:${escapeHtml(theme.fontFamily)};border-radius:${borderRadius}px;box-shadow:${shadow}`
208
+ container.style.cssText = `background:${escapeHtml(theme.backgroundColor)};color:${escapeHtml(theme.textColor)};width:100%;height:100%;font-family:${escapeHtml(theme.fontFamily)};border-radius:${borderRadius}px;box-shadow:${shadow};display:flex;flex-direction:column;overflow:hidden`
209
+
210
+ const fab = this.shadow.getElementById('fab') as HTMLElement
211
+ fab.style.background = escapeHtml(theme.primaryColor)
178
212
 
179
213
  const header = this.shadow.getElementById('header') as HTMLElement
180
- header.style.background = escapeHtml(theme.primaryColor)
214
+ header.style.cssText = `background:${escapeHtml(theme.primaryColor)};padding:16px 20px;color:#fff`
181
215
  ;(this.shadow.getElementById('header-title') as HTMLElement).textContent = state.config!.branding.title
182
216
  ;(this.shadow.getElementById('header-subtitle') as HTMLElement).textContent = state.config!.branding.subtitle
183
217
 
@@ -213,16 +247,17 @@ class RushChatbotElement extends HTMLElement {
213
247
  btn.textContent = q.question
214
248
  mainList.appendChild(btn)
215
249
  })
216
- if (!settings.hideEndChat) {
217
- const endBtn = document.createElement('button')
218
- endBtn.className = 'end-btn'
219
- endBtn.dataset.action = 'endchat'
220
- endBtn.textContent = "\uD83D\uDC4B I'm done, thanks!"
221
- mainList.appendChild(endBtn)
222
- }
223
250
  msgsContent.appendChild(mainList)
224
251
  }
225
252
 
253
+ if (!settings.hideEndChat && !state.messages.some(m => m.isEndChat)) {
254
+ const endBtn = document.createElement('button')
255
+ endBtn.className = 'end-btn'
256
+ endBtn.dataset.action = 'endchat'
257
+ endBtn.textContent = "\uD83D\uDC4B I'm done, thanks!"
258
+ msgsContent.appendChild(endBtn)
259
+ }
260
+
226
261
  if (this.showScrollBtn) {
227
262
  const scrollBtn = document.createElement('button')
228
263
  scrollBtn.className = 'scroll-btn'
@@ -245,6 +280,7 @@ class RushChatbotElement extends HTMLElement {
245
280
  }
246
281
 
247
282
  const newMsgsEl = this.shadow.getElementById('msgs') as HTMLElement
283
+ if (!this.isOpen) return
248
284
  if (wasAtBottom) {
249
285
  newMsgsEl.scrollTop = newMsgsEl.scrollHeight
250
286
  this.showScrollBtn = false
@@ -265,6 +301,55 @@ class RushChatbotElement extends HTMLElement {
265
301
  }
266
302
 
267
303
  this.bindEvents()
304
+ this.bindDrag()
305
+ }
306
+
307
+ private bindDrag() {
308
+ const fab = this.shadow.getElementById('fab') as HTMLElement
309
+ if (!fab) return
310
+
311
+ fab.addEventListener('pointerdown', (e: PointerEvent) => {
312
+ this.dragging = true
313
+ this.dragMoved = false
314
+ this.dragOffX = e.clientX - this.btnX
315
+ this.dragOffY = e.clientY - this.btnY
316
+ fab.setPointerCapture(e.pointerId)
317
+ fab.style.cursor = 'grabbing'
318
+ })
319
+
320
+ fab.addEventListener('pointermove', (e: PointerEvent) => {
321
+ if (!this.dragging) return
322
+ const newX = e.clientX - this.dragOffX
323
+ const newY = e.clientY - this.dragOffY
324
+ if (Math.abs(newX - this.btnX) > 4 || Math.abs(newY - this.btnY) > 4) this.dragMoved = true
325
+ this.btnX = Math.min(Math.max(newX, 0), window.innerWidth - 56)
326
+ this.btnY = Math.min(Math.max(newY, 0), window.innerHeight - 56)
327
+ fab.style.left = `${this.btnX}px`
328
+ fab.style.top = `${this.btnY}px`
329
+ const chatBox = this.shadow.getElementById('chat-box') as HTMLElement
330
+ if (chatBox && this.isOpen) {
331
+ const chatW = chatBox.offsetWidth || 380
332
+ const chatH = chatBox.offsetHeight || 580
333
+ const boxLeft = Math.min(Math.max(this.btnX + 28 - chatW / 2, 8), window.innerWidth - chatW - 8)
334
+ const boxBottom = window.innerHeight - this.btnY + 12
335
+ chatBox.style.left = `${boxLeft}px`
336
+ chatBox.style.bottom = `${boxBottom}px`
337
+ }
338
+ })
339
+
340
+ fab.addEventListener('pointerup', () => {
341
+ if (!this.dragMoved) {
342
+ this.isOpen = !this.isOpen
343
+ this.render()
344
+ }
345
+ this.dragging = false
346
+ fab.style.cursor = 'grab'
347
+ })
348
+
349
+ this.shadow.getElementById('close-btn')?.addEventListener('click', () => {
350
+ this.isOpen = false
351
+ this.render()
352
+ })
268
353
  }
269
354
 
270
355
  private bindEvents() {
@@ -42,9 +42,10 @@
42
42
  :style="mainBtnStyle">
43
43
  {{ q.question }}
44
44
  </button>
45
- <button v-if="!hideEndChat" @click="chat.endChat()" :style="endBtnStyle">👋 I'm done, thanks!</button>
46
45
  </div>
47
46
 
47
+ <button v-if="!hideEndChat && !state.messages.some(m => m.isEndChat)" @click="chat.endChat()" :style="endBtnStyle">👋 I'm done, thanks!</button>
48
+
48
49
  <div ref="bottom" />
49
50
  </div>
50
51
 
@@ -44,9 +44,10 @@
44
44
  :style="mainBtnStyle">
45
45
  {{ q.question }}
46
46
  </button>
47
- <button v-if="!hideEndChat" @click="chat.endChat()" :style="endBtnStyle">👋 I'm done, thanks!</button>
48
47
  </div>
49
48
 
49
+ <button v-if="!hideEndChat && !state.messages.some(function(m) { return m.isEndChat })" @click="chat.endChat()" :style="endBtnStyle">👋 I'm done, thanks!</button>
50
+
50
51
  <div ref="bottom" />
51
52
  </div>
52
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rushchatbot",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "scripts": {
5
5
  "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
6
6
  "dev:client": "vite",
@@ -827,5 +827,41 @@
827
827
  "questionId": "q8c",
828
828
  "timestamp": "2026-09-25T07:32:01.577Z",
829
829
  "type": "click"
830
+ },
831
+ {
832
+ "sessionId": "sess_mugn4uve_9yrh57",
833
+ "questionId": "q8",
834
+ "timestamp": "2026-09-25T08:03:16.219Z",
835
+ "type": "click"
836
+ },
837
+ {
838
+ "sessionId": "sess_mugn4uve_9yrh57",
839
+ "questionId": "q8c",
840
+ "timestamp": "2026-09-25T08:03:18.364Z",
841
+ "type": "click"
842
+ },
843
+ {
844
+ "sessionId": "sess_mugn4uve_9yrh57",
845
+ "questionId": "q8b",
846
+ "timestamp": "2026-09-25T08:03:21.319Z",
847
+ "type": "click"
848
+ },
849
+ {
850
+ "sessionId": "sess_mugn4uve_9yrh57",
851
+ "questionId": "q8a",
852
+ "timestamp": "2026-09-25T08:03:24.033Z",
853
+ "type": "click"
854
+ },
855
+ {
856
+ "sessionId": "sess_mugn4uve_9yrh57",
857
+ "questionId": "q1",
858
+ "timestamp": "2026-09-25T08:03:29.705Z",
859
+ "type": "click"
860
+ },
861
+ {
862
+ "sessionId": "sess_mugn4uve_9yrh57",
863
+ "questionId": "q1b",
864
+ "timestamp": "2026-09-25T08:03:33.517Z",
865
+ "type": "click"
830
866
  }
831
867
  ]