@wabbit-dashboard/embed 1.0.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 +383 -0
- package/dist/wabbit-embed.cjs.js +3598 -0
- package/dist/wabbit-embed.cjs.js.map +1 -0
- package/dist/wabbit-embed.d.ts +926 -0
- package/dist/wabbit-embed.esm.js +3570 -0
- package/dist/wabbit-embed.esm.js.map +1 -0
- package/dist/wabbit-embed.umd.js +3604 -0
- package/dist/wabbit-embed.umd.js.map +1 -0
- package/dist/wabbit-embed.umd.min.js +3604 -0
- package/dist/wabbit-embed.umd.min.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,3604 @@
|
|
|
1
|
+
(function (global, factory) {
|
|
2
|
+
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
|
3
|
+
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
|
4
|
+
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Wabbit = {}));
|
|
5
|
+
})(this, (function (exports) { 'use strict';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configuration management utilities
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Validate SDK configuration
|
|
12
|
+
*
|
|
13
|
+
* @param config - Configuration to validate
|
|
14
|
+
* @throws {Error} If configuration is invalid
|
|
15
|
+
*/
|
|
16
|
+
function validateConfig(config) {
|
|
17
|
+
if (!config.apiKey) {
|
|
18
|
+
throw new Error('[Wabbit] API key is required');
|
|
19
|
+
}
|
|
20
|
+
if (typeof config.apiKey !== 'string') {
|
|
21
|
+
throw new Error('[Wabbit] API key must be a string');
|
|
22
|
+
}
|
|
23
|
+
// Validate chat config if enabled
|
|
24
|
+
if (config.chat?.enabled) {
|
|
25
|
+
if (!config.chat.collectionId) {
|
|
26
|
+
throw new Error('[Wabbit] collectionId is required when chat is enabled');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Validate forms config if enabled
|
|
30
|
+
if (config.forms?.enabled) {
|
|
31
|
+
if (!config.forms.formId) {
|
|
32
|
+
throw new Error('[Wabbit] formId is required when forms is enabled');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Detect API URL based on environment
|
|
38
|
+
* Priority: user config > global variable > environment detection > default
|
|
39
|
+
*/
|
|
40
|
+
function detectApiUrl(userConfig) {
|
|
41
|
+
// 1. User explicitly provided
|
|
42
|
+
if (userConfig)
|
|
43
|
+
return userConfig;
|
|
44
|
+
// 2. Global variable (for build-time injection)
|
|
45
|
+
if (typeof window !== 'undefined' && window.WABBIT_API_URL) {
|
|
46
|
+
return window.WABBIT_API_URL;
|
|
47
|
+
}
|
|
48
|
+
// 3. Environment detection (development vs production)
|
|
49
|
+
if (typeof window !== 'undefined') {
|
|
50
|
+
const hostname = window.location.hostname;
|
|
51
|
+
// Development environment detection
|
|
52
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0') {
|
|
53
|
+
return 'http://localhost:3000';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// 4. Production default
|
|
57
|
+
return 'https://api.wabbit.io';
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Detect WebSocket URL based on environment
|
|
61
|
+
* Priority: user config > global variable > derive from apiUrl > default
|
|
62
|
+
*/
|
|
63
|
+
function detectWebSocketUrl(userConfig, apiUrl) {
|
|
64
|
+
// 1. User explicitly provided
|
|
65
|
+
if (userConfig)
|
|
66
|
+
return userConfig;
|
|
67
|
+
// 2. Global variable (for build-time injection)
|
|
68
|
+
if (typeof window !== 'undefined' && window.WABBIT_WS_URL) {
|
|
69
|
+
return window.WABBIT_WS_URL;
|
|
70
|
+
}
|
|
71
|
+
// 3. Derive from apiUrl if it's a development URL
|
|
72
|
+
if (apiUrl && (apiUrl.includes('localhost') || apiUrl.includes('127.0.0.1'))) {
|
|
73
|
+
return 'ws://localhost:8003/ws/chat';
|
|
74
|
+
}
|
|
75
|
+
// 4. Production default
|
|
76
|
+
return 'wss://api.wabbit.io/ws/chat';
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Merge configuration with defaults
|
|
80
|
+
*
|
|
81
|
+
* @param config - User configuration
|
|
82
|
+
* @returns Merged configuration with defaults
|
|
83
|
+
*/
|
|
84
|
+
function mergeConfig(config) {
|
|
85
|
+
// Auto-detect URLs if not provided
|
|
86
|
+
const apiUrl = detectApiUrl(config.apiUrl);
|
|
87
|
+
const wsUrl = detectWebSocketUrl(config.wsUrl, apiUrl);
|
|
88
|
+
return {
|
|
89
|
+
...config,
|
|
90
|
+
apiUrl,
|
|
91
|
+
wsUrl,
|
|
92
|
+
chat: config.chat
|
|
93
|
+
? {
|
|
94
|
+
...config.chat,
|
|
95
|
+
position: config.chat.position || 'bottom-right',
|
|
96
|
+
triggerType: config.chat.triggerType || 'button',
|
|
97
|
+
theme: config.chat.theme || 'auto',
|
|
98
|
+
primaryColor: config.chat.primaryColor || '#6366f1',
|
|
99
|
+
placeholder: config.chat.placeholder || 'Type your message...'
|
|
100
|
+
}
|
|
101
|
+
: undefined,
|
|
102
|
+
forms: config.forms
|
|
103
|
+
? {
|
|
104
|
+
...config.forms,
|
|
105
|
+
theme: config.forms.theme || 'auto'
|
|
106
|
+
}
|
|
107
|
+
: undefined,
|
|
108
|
+
emailCapture: config.emailCapture
|
|
109
|
+
? {
|
|
110
|
+
...config.emailCapture,
|
|
111
|
+
triggerAfterMessages: config.emailCapture.triggerAfterMessages || 3,
|
|
112
|
+
fields: config.emailCapture.fields || ['email']
|
|
113
|
+
}
|
|
114
|
+
: undefined
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Main Wabbit SDK class
|
|
120
|
+
*
|
|
121
|
+
* This is the main entry point for initializing and managing
|
|
122
|
+
* all Wabbit widgets (chat, forms, email capture).
|
|
123
|
+
*/
|
|
124
|
+
/**
|
|
125
|
+
* Wabbit SDK main class
|
|
126
|
+
*/
|
|
127
|
+
class Wabbit {
|
|
128
|
+
constructor(config) {
|
|
129
|
+
// Widget instances
|
|
130
|
+
this.chatWidget = null; // ChatWidget | null
|
|
131
|
+
this.formsWidget = null; // FormWidget | null
|
|
132
|
+
this.emailCaptureWidget = null; // EmailCapture | null
|
|
133
|
+
this.config = config;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Initialize the Wabbit SDK
|
|
137
|
+
*
|
|
138
|
+
* @param config - SDK configuration
|
|
139
|
+
* @returns Wabbit instance
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```typescript
|
|
143
|
+
* const wabbit = Wabbit.init({
|
|
144
|
+
* apiKey: 'pk_live_xxx',
|
|
145
|
+
* chat: { enabled: true, collectionId: 'abc123' }
|
|
146
|
+
* });
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
static init(config) {
|
|
150
|
+
if (Wabbit.instance) {
|
|
151
|
+
console.warn('[Wabbit] SDK already initialized. Returning existing instance.');
|
|
152
|
+
return Wabbit.instance;
|
|
153
|
+
}
|
|
154
|
+
// Validate and merge config with defaults
|
|
155
|
+
validateConfig(config);
|
|
156
|
+
const mergedConfig = mergeConfig(config);
|
|
157
|
+
Wabbit.instance = new Wabbit(mergedConfig);
|
|
158
|
+
// Initialize widgets based on merged config
|
|
159
|
+
if (mergedConfig.chat?.enabled && mergedConfig.chat) {
|
|
160
|
+
// Import ChatWidget dynamically to avoid circular dependencies
|
|
161
|
+
Promise.resolve().then(function () { return ChatWidget$1; }).then(({ ChatWidget }) => {
|
|
162
|
+
const chat = mergedConfig.chat;
|
|
163
|
+
const chatConfig = {
|
|
164
|
+
enabled: chat.enabled,
|
|
165
|
+
collectionId: chat.collectionId,
|
|
166
|
+
// Use chat-specific apiKey/apiUrl if provided, otherwise inherit from WabbitConfig
|
|
167
|
+
apiKey: chat.apiKey || mergedConfig.apiKey,
|
|
168
|
+
apiUrl: chat.apiUrl || mergedConfig.apiUrl,
|
|
169
|
+
wsUrl: mergedConfig.wsUrl || chat.wsUrl, // Use global wsUrl or chat-specific wsUrl
|
|
170
|
+
position: chat.position,
|
|
171
|
+
triggerType: chat.triggerType,
|
|
172
|
+
triggerDelay: chat.triggerDelay,
|
|
173
|
+
theme: chat.theme,
|
|
174
|
+
primaryColor: chat.primaryColor,
|
|
175
|
+
welcomeMessage: chat.welcomeMessage,
|
|
176
|
+
placeholder: chat.placeholder
|
|
177
|
+
};
|
|
178
|
+
const chatWidget = new ChatWidget(chatConfig);
|
|
179
|
+
chatWidget.init();
|
|
180
|
+
Wabbit.instance.chatWidget = chatWidget;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (mergedConfig.forms?.enabled && mergedConfig.forms) {
|
|
184
|
+
// Import FormWidget dynamically to avoid circular dependencies
|
|
185
|
+
Promise.resolve().then(function () { return FormWidget$1; }).then(({ FormWidget }) => {
|
|
186
|
+
const forms = mergedConfig.forms;
|
|
187
|
+
const formConfig = {
|
|
188
|
+
enabled: forms.enabled,
|
|
189
|
+
containerId: forms.containerId,
|
|
190
|
+
formId: forms.formId,
|
|
191
|
+
// Use form-specific apiKey/apiUrl if provided, otherwise inherit from WabbitConfig
|
|
192
|
+
apiKey: forms.apiKey || mergedConfig.apiKey,
|
|
193
|
+
apiUrl: forms.apiUrl || mergedConfig.apiUrl,
|
|
194
|
+
theme: forms.theme,
|
|
195
|
+
primaryColor: forms.primaryColor,
|
|
196
|
+
onSubmit: forms.onSubmit,
|
|
197
|
+
onError: forms.onError,
|
|
198
|
+
onLoad: forms.onLoad
|
|
199
|
+
};
|
|
200
|
+
const formWidget = new FormWidget(formConfig);
|
|
201
|
+
formWidget.init();
|
|
202
|
+
Wabbit.instance.formsWidget = formWidget;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// Initialize email capture widget if enabled
|
|
206
|
+
// Note: We need to wait for chat widget to be initialized first
|
|
207
|
+
if (mergedConfig.emailCapture?.enabled) {
|
|
208
|
+
const emailCapture = mergedConfig.emailCapture;
|
|
209
|
+
Promise.resolve().then(function () { return EmailCaptureWidget$1; }).then(({ EmailCaptureWidget }) => {
|
|
210
|
+
const emailCaptureConfig = {
|
|
211
|
+
enabled: emailCapture.enabled,
|
|
212
|
+
triggerAfterMessages: emailCapture.triggerAfterMessages,
|
|
213
|
+
title: emailCapture.title,
|
|
214
|
+
description: emailCapture.description,
|
|
215
|
+
fields: emailCapture.fields,
|
|
216
|
+
onCapture: emailCapture.onCapture
|
|
217
|
+
};
|
|
218
|
+
const emailCaptureWidget = new EmailCaptureWidget(emailCaptureConfig);
|
|
219
|
+
emailCaptureWidget.init();
|
|
220
|
+
Wabbit.instance.emailCaptureWidget = emailCaptureWidget;
|
|
221
|
+
// Connect to chat widget after it's initialized
|
|
222
|
+
// Use a small delay to ensure chat widget is ready
|
|
223
|
+
setTimeout(() => {
|
|
224
|
+
if (Wabbit.instance.chatWidget) {
|
|
225
|
+
const chatWidget = Wabbit.instance.chatWidget;
|
|
226
|
+
// Set WebSocket client
|
|
227
|
+
if (chatWidget.wsClient) {
|
|
228
|
+
emailCaptureWidget.setWebSocketClient(chatWidget.wsClient);
|
|
229
|
+
}
|
|
230
|
+
// Hook into message sending by intercepting ChatWidget's handleSendMessage
|
|
231
|
+
const originalHandleSendMessage = chatWidget.handleSendMessage;
|
|
232
|
+
if (originalHandleSendMessage) {
|
|
233
|
+
chatWidget.handleSendMessage = function (content) {
|
|
234
|
+
originalHandleSendMessage.call(this, content);
|
|
235
|
+
// Notify email capture widget when user sends a message
|
|
236
|
+
emailCaptureWidget.handleMessage();
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}, 100);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
// Auto-initialize forms with data-wabbit-form-id (backward compatibility)
|
|
244
|
+
Wabbit.instance.initLegacyForms(config);
|
|
245
|
+
// Call onReady callback if provided
|
|
246
|
+
if (config.onReady) {
|
|
247
|
+
// Wait for DOM to be ready
|
|
248
|
+
if (document.readyState === 'loading') {
|
|
249
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
250
|
+
config.onReady?.();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
config.onReady();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return Wabbit.instance;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Get the current Wabbit instance
|
|
261
|
+
*
|
|
262
|
+
* @returns Wabbit instance or null if not initialized
|
|
263
|
+
*/
|
|
264
|
+
static getInstance() {
|
|
265
|
+
return Wabbit.instance;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Destroy the SDK instance and cleanup
|
|
269
|
+
*/
|
|
270
|
+
static destroy() {
|
|
271
|
+
if (Wabbit.instance) {
|
|
272
|
+
// Cleanup widgets
|
|
273
|
+
if (Wabbit.instance.chatWidget) {
|
|
274
|
+
Wabbit.instance.chatWidget.destroy();
|
|
275
|
+
}
|
|
276
|
+
if (Wabbit.instance.formsWidget) {
|
|
277
|
+
Wabbit.instance.formsWidget.destroy();
|
|
278
|
+
}
|
|
279
|
+
// Cleanup legacy forms
|
|
280
|
+
const legacyContainers = document.querySelectorAll('[data-wabbit-form-id]');
|
|
281
|
+
legacyContainers.forEach((container) => {
|
|
282
|
+
const widget = container.__wabbitFormWidget;
|
|
283
|
+
if (widget && typeof widget.destroy === 'function') {
|
|
284
|
+
widget.destroy();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
// Cleanup legacy form observer
|
|
288
|
+
if (Wabbit.instance.legacyFormObserver) {
|
|
289
|
+
Wabbit.instance.legacyFormObserver.disconnect();
|
|
290
|
+
Wabbit.instance.legacyFormObserver = null;
|
|
291
|
+
}
|
|
292
|
+
if (Wabbit.instance.emailCaptureWidget) {
|
|
293
|
+
Wabbit.instance.emailCaptureWidget.destroy();
|
|
294
|
+
}
|
|
295
|
+
Wabbit.instance = null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Get current configuration
|
|
300
|
+
*/
|
|
301
|
+
getConfig() {
|
|
302
|
+
return { ...this.config };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Get chat widget instance
|
|
306
|
+
*/
|
|
307
|
+
get chat() {
|
|
308
|
+
return this.chatWidget;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Get forms widget instance
|
|
312
|
+
*/
|
|
313
|
+
get forms() {
|
|
314
|
+
return this.formsWidget;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Get email capture widget instance
|
|
318
|
+
*/
|
|
319
|
+
get emailCapture() {
|
|
320
|
+
return this.emailCaptureWidget;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Initialize legacy forms (backward compatibility)
|
|
324
|
+
*
|
|
325
|
+
* Automatically initializes forms with data-wabbit-form-id attribute
|
|
326
|
+
* This maintains compatibility with the old embed-form.js script
|
|
327
|
+
*/
|
|
328
|
+
initLegacyForms(config) {
|
|
329
|
+
// Only initialize if SDK is initialized with API key
|
|
330
|
+
if (!config.apiKey) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Wait for DOM to be ready
|
|
334
|
+
if (document.readyState === 'loading') {
|
|
335
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
336
|
+
this.initLegacyFormsOnReady(config);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
this.initLegacyFormsOnReady(config);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Initialize legacy forms when DOM is ready
|
|
345
|
+
*/
|
|
346
|
+
initLegacyFormsOnReady(config) {
|
|
347
|
+
// Find all containers with data-wabbit-form-id
|
|
348
|
+
const containers = document.querySelectorAll('[data-wabbit-form-id]');
|
|
349
|
+
if (containers.length === 0) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
console.log('[Wabbit] Found', containers.length, 'legacy form container(s)');
|
|
353
|
+
// Initialize each form
|
|
354
|
+
containers.forEach((container) => {
|
|
355
|
+
const formId = container.getAttribute('data-wabbit-form-id');
|
|
356
|
+
if (!formId) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
// Skip if already initialized (check if container has form instance)
|
|
360
|
+
if (container.querySelector('[data-wabbit-form-instance="true"]')) {
|
|
361
|
+
console.warn(`[Wabbit] Form ${formId} already initialized`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Import FormWidget dynamically
|
|
365
|
+
Promise.resolve().then(function () { return FormWidget$1; }).then(({ FormWidget }) => {
|
|
366
|
+
const formConfig = {
|
|
367
|
+
enabled: true,
|
|
368
|
+
// For backward compatibility, use the container itself as the container
|
|
369
|
+
// FormWidget.findContainer() will handle data-wabbit-form-id lookup
|
|
370
|
+
containerId: undefined, // Will use data-wabbit-form-id via findContainer()
|
|
371
|
+
formId: formId,
|
|
372
|
+
apiKey: config.apiKey,
|
|
373
|
+
apiUrl: config.apiUrl,
|
|
374
|
+
theme: config.forms?.theme || 'auto',
|
|
375
|
+
primaryColor: config.forms?.primaryColor,
|
|
376
|
+
onSubmit: config.forms?.onSubmit,
|
|
377
|
+
onError: config.forms?.onError,
|
|
378
|
+
onLoad: config.forms?.onLoad,
|
|
379
|
+
};
|
|
380
|
+
const formWidget = new FormWidget(formConfig);
|
|
381
|
+
// Override findContainer to use the specific container element
|
|
382
|
+
// This ensures the form renders in the correct container with data-wabbit-form-id
|
|
383
|
+
formWidget.findContainer = () => {
|
|
384
|
+
return container;
|
|
385
|
+
};
|
|
386
|
+
formWidget.init();
|
|
387
|
+
// Store widget reference on container for cleanup
|
|
388
|
+
container.__wabbitFormWidget = formWidget;
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
// Watch for dynamically added forms (for SPAs)
|
|
392
|
+
this.setupLegacyFormObserver(config);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Setup observer for dynamically added legacy forms
|
|
396
|
+
*/
|
|
397
|
+
setupLegacyFormObserver(config) {
|
|
398
|
+
// Only setup once
|
|
399
|
+
if (this.legacyFormObserver) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const observer = new MutationObserver((mutations) => {
|
|
403
|
+
for (const mutation of mutations) {
|
|
404
|
+
if (mutation.addedNodes.length) {
|
|
405
|
+
const hasFormContainer = Array.from(mutation.addedNodes).some((node) => {
|
|
406
|
+
if (node.nodeType !== 1)
|
|
407
|
+
return false;
|
|
408
|
+
const element = node;
|
|
409
|
+
return (element.hasAttribute?.('data-wabbit-form-id') ||
|
|
410
|
+
element.querySelector?.('[data-wabbit-form-id]'));
|
|
411
|
+
});
|
|
412
|
+
if (hasFormContainer) {
|
|
413
|
+
console.log('[Wabbit] Detected dynamically added legacy form container');
|
|
414
|
+
this.initLegacyFormsOnReady(config);
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
observer.observe(document.body, {
|
|
421
|
+
childList: true,
|
|
422
|
+
subtree: true,
|
|
423
|
+
});
|
|
424
|
+
this.legacyFormObserver = observer;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
Wabbit.instance = null;
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* localStorage wrapper with type safety
|
|
431
|
+
*/
|
|
432
|
+
/**
|
|
433
|
+
* Safe storage wrapper
|
|
434
|
+
*/
|
|
435
|
+
class SafeStorage {
|
|
436
|
+
constructor(storage = localStorage, prefix = 'wabbit_') {
|
|
437
|
+
this.storage = storage;
|
|
438
|
+
this.prefix = prefix;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Get item from storage
|
|
442
|
+
*/
|
|
443
|
+
get(key) {
|
|
444
|
+
try {
|
|
445
|
+
const item = this.storage.getItem(this.prefix + key);
|
|
446
|
+
if (item === null) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
return JSON.parse(item);
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
console.error(`[Wabbit] Failed to get storage item "${key}":`, error);
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Set item in storage
|
|
458
|
+
*/
|
|
459
|
+
set(key, value) {
|
|
460
|
+
try {
|
|
461
|
+
this.storage.setItem(this.prefix + key, JSON.stringify(value));
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
console.error(`[Wabbit] Failed to set storage item "${key}":`, error);
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Remove item from storage
|
|
471
|
+
*/
|
|
472
|
+
remove(key) {
|
|
473
|
+
this.storage.removeItem(this.prefix + key);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Clear all items with prefix
|
|
477
|
+
*/
|
|
478
|
+
clear() {
|
|
479
|
+
if (this.storage.length !== undefined && this.storage.key) {
|
|
480
|
+
const keys = [];
|
|
481
|
+
for (let i = 0; i < this.storage.length; i++) {
|
|
482
|
+
const key = this.storage.key(i);
|
|
483
|
+
if (key && key.startsWith(this.prefix)) {
|
|
484
|
+
keys.push(key);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
keys.forEach((key) => this.storage.removeItem(key));
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
// Fallback: try to clear common keys
|
|
491
|
+
const commonKeys = ['session_id', 'email_capture_dismissed'];
|
|
492
|
+
commonKeys.forEach((key) => this.remove(key));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// Export singleton instance
|
|
497
|
+
const storage = new SafeStorage();
|
|
498
|
+
/**
|
|
499
|
+
* Get item from storage (simple string getter)
|
|
500
|
+
*/
|
|
501
|
+
function getStorageItem(key) {
|
|
502
|
+
try {
|
|
503
|
+
return localStorage.getItem('wabbit_' + key);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Set item in storage (simple string setter)
|
|
511
|
+
*/
|
|
512
|
+
function setStorageItem(key, value) {
|
|
513
|
+
try {
|
|
514
|
+
localStorage.setItem('wabbit_' + key, value);
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
console.error(`[Wabbit] Failed to set storage item "${key}":`, error);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* WebSocket client for chat functionality
|
|
523
|
+
*
|
|
524
|
+
* Based on demo-website/src/lib/websocket.ts but without React dependencies
|
|
525
|
+
*/
|
|
526
|
+
class ChatWebSocketClient {
|
|
527
|
+
constructor(apiKey, wsUrl, sessionId = null) {
|
|
528
|
+
this.ws = null;
|
|
529
|
+
this.status = 'disconnected';
|
|
530
|
+
this.reconnectAttempts = 0;
|
|
531
|
+
this.maxReconnectAttempts = 3;
|
|
532
|
+
this.reconnectDelay = 1000;
|
|
533
|
+
// Event handlers
|
|
534
|
+
this.onStatusChange = null;
|
|
535
|
+
this.onWelcome = null;
|
|
536
|
+
this.onMessage = null;
|
|
537
|
+
this.onMessageHistory = null;
|
|
538
|
+
this.onError = null;
|
|
539
|
+
this.onDisconnect = null;
|
|
540
|
+
this.apiKey = apiKey;
|
|
541
|
+
this.wsUrl = wsUrl;
|
|
542
|
+
this.sessionId = sessionId || this.getStoredSessionId();
|
|
543
|
+
}
|
|
544
|
+
setStatus(status) {
|
|
545
|
+
this.status = status;
|
|
546
|
+
if (this.onStatusChange) {
|
|
547
|
+
this.onStatusChange(status);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
getStatus() {
|
|
551
|
+
return this.status;
|
|
552
|
+
}
|
|
553
|
+
async connect() {
|
|
554
|
+
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
this.setStatus('connecting');
|
|
558
|
+
const params = new URLSearchParams({
|
|
559
|
+
api_key: this.apiKey,
|
|
560
|
+
});
|
|
561
|
+
if (this.sessionId) {
|
|
562
|
+
params.append('session_id', this.sessionId);
|
|
563
|
+
}
|
|
564
|
+
const url = `${this.wsUrl}?${params.toString()}`;
|
|
565
|
+
try {
|
|
566
|
+
this.ws = new WebSocket(url);
|
|
567
|
+
this.ws.onopen = () => {
|
|
568
|
+
this.setStatus('connected');
|
|
569
|
+
this.reconnectAttempts = 0;
|
|
570
|
+
console.log('[Wabbit] WebSocket connected');
|
|
571
|
+
};
|
|
572
|
+
this.ws.onmessage = (event) => {
|
|
573
|
+
try {
|
|
574
|
+
const data = JSON.parse(event.data);
|
|
575
|
+
this.handleMessage(data);
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
console.error('[Wabbit] Failed to parse message:', error);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
this.ws.onerror = (error) => {
|
|
582
|
+
console.error('[Wabbit] WebSocket error:', error);
|
|
583
|
+
if (this.onError) {
|
|
584
|
+
this.onError('Connection error occurred');
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
this.ws.onclose = () => {
|
|
588
|
+
console.log('[Wabbit] WebSocket closed');
|
|
589
|
+
this.setStatus('disconnected');
|
|
590
|
+
if (this.onDisconnect) {
|
|
591
|
+
this.onDisconnect();
|
|
592
|
+
}
|
|
593
|
+
this.attemptReconnect();
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
console.error('[Wabbit] Failed to connect:', error);
|
|
598
|
+
if (this.onError) {
|
|
599
|
+
this.onError('Failed to establish connection');
|
|
600
|
+
}
|
|
601
|
+
this.setStatus('disconnected');
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
handleMessage(data) {
|
|
605
|
+
switch (data.type) {
|
|
606
|
+
case 'welcome':
|
|
607
|
+
this.sessionId = data.session_id;
|
|
608
|
+
// Store session ID in localStorage
|
|
609
|
+
if (this.sessionId) {
|
|
610
|
+
storage.set('session_id', this.sessionId);
|
|
611
|
+
}
|
|
612
|
+
if (this.onWelcome) {
|
|
613
|
+
this.onWelcome(data.session_id, data.collection_id, data.message || 'Connected');
|
|
614
|
+
}
|
|
615
|
+
break;
|
|
616
|
+
case 'message_history':
|
|
617
|
+
// Handle conversation history when reconnecting
|
|
618
|
+
if (this.onMessageHistory && data.messages && Array.isArray(data.messages)) {
|
|
619
|
+
const messages = data.messages.map((msg) => ({
|
|
620
|
+
id: msg.id || crypto.randomUUID(),
|
|
621
|
+
role: msg.role,
|
|
622
|
+
content: msg.content,
|
|
623
|
+
timestamp: new Date(msg.timestamp),
|
|
624
|
+
metadata: msg.metadata,
|
|
625
|
+
}));
|
|
626
|
+
this.onMessageHistory(messages);
|
|
627
|
+
}
|
|
628
|
+
break;
|
|
629
|
+
case 'assistant_message':
|
|
630
|
+
if (this.onMessage) {
|
|
631
|
+
const message = {
|
|
632
|
+
id: data.message_id || crypto.randomUUID(),
|
|
633
|
+
role: 'assistant',
|
|
634
|
+
content: data.content,
|
|
635
|
+
timestamp: new Date(),
|
|
636
|
+
metadata: data.metadata,
|
|
637
|
+
};
|
|
638
|
+
this.onMessage(message);
|
|
639
|
+
}
|
|
640
|
+
break;
|
|
641
|
+
case 'error':
|
|
642
|
+
if (this.onError) {
|
|
643
|
+
this.onError(data.message || 'An error occurred');
|
|
644
|
+
}
|
|
645
|
+
break;
|
|
646
|
+
default:
|
|
647
|
+
console.log('[Wabbit] Unknown message type:', data.type);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
sendMessage(content, metadata) {
|
|
651
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
652
|
+
if (this.onError) {
|
|
653
|
+
this.onError('Not connected to chat service');
|
|
654
|
+
}
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const message = {
|
|
658
|
+
type: 'message',
|
|
659
|
+
content,
|
|
660
|
+
metadata: metadata || {},
|
|
661
|
+
};
|
|
662
|
+
this.ws.send(JSON.stringify(message));
|
|
663
|
+
}
|
|
664
|
+
disconnect() {
|
|
665
|
+
if (this.ws) {
|
|
666
|
+
this.ws.close();
|
|
667
|
+
this.ws = null;
|
|
668
|
+
}
|
|
669
|
+
this.setStatus('disconnected');
|
|
670
|
+
}
|
|
671
|
+
attemptReconnect() {
|
|
672
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
673
|
+
console.log('[Wabbit] Max reconnect attempts reached');
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
this.reconnectAttempts++;
|
|
677
|
+
this.setStatus('reconnecting');
|
|
678
|
+
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
679
|
+
console.log(`[Wabbit] Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
680
|
+
setTimeout(() => {
|
|
681
|
+
this.connect();
|
|
682
|
+
}, delay);
|
|
683
|
+
}
|
|
684
|
+
clearSession() {
|
|
685
|
+
this.sessionId = null;
|
|
686
|
+
storage.remove('session_id');
|
|
687
|
+
}
|
|
688
|
+
getStoredSessionId() {
|
|
689
|
+
return storage.get('session_id') || null;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* DOM utility functions
|
|
695
|
+
*/
|
|
696
|
+
/**
|
|
697
|
+
* Escape HTML to prevent XSS attacks
|
|
698
|
+
*
|
|
699
|
+
* @param text - Text to escape
|
|
700
|
+
* @returns Escaped HTML string
|
|
701
|
+
*/
|
|
702
|
+
function escapeHtml(text) {
|
|
703
|
+
const div = document.createElement('div');
|
|
704
|
+
div.textContent = text;
|
|
705
|
+
return div.innerHTML;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Wait for DOM to be ready
|
|
709
|
+
*
|
|
710
|
+
* @param callback - Callback to execute when DOM is ready
|
|
711
|
+
*/
|
|
712
|
+
function onDOMReady(callback) {
|
|
713
|
+
if (document.readyState === 'loading') {
|
|
714
|
+
document.addEventListener('DOMContentLoaded', callback);
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
callback();
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Create a DOM element with attributes
|
|
722
|
+
*
|
|
723
|
+
* @param tag - HTML tag name
|
|
724
|
+
* @param attributes - Element attributes
|
|
725
|
+
* @param children - Child elements or text
|
|
726
|
+
* @returns Created element
|
|
727
|
+
*/
|
|
728
|
+
function createElement(tag, attributes, children) {
|
|
729
|
+
const element = document.createElement(tag);
|
|
730
|
+
if (attributes) {
|
|
731
|
+
Object.entries(attributes).forEach(([key, value]) => {
|
|
732
|
+
element.setAttribute(key, value);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
if (children) {
|
|
736
|
+
children.forEach((child) => {
|
|
737
|
+
if (typeof child === 'string') {
|
|
738
|
+
element.appendChild(document.createTextNode(child));
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
element.appendChild(child);
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
return element;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Chat Bubble - Floating button component
|
|
750
|
+
*/
|
|
751
|
+
class ChatBubble {
|
|
752
|
+
constructor(options) {
|
|
753
|
+
this.element = null;
|
|
754
|
+
this.options = options;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Create and render the bubble
|
|
758
|
+
*/
|
|
759
|
+
render() {
|
|
760
|
+
if (this.element) {
|
|
761
|
+
return this.element;
|
|
762
|
+
}
|
|
763
|
+
this.element = createElement('button', {
|
|
764
|
+
class: `wabbit-chat-bubble ${this.options.position}`,
|
|
765
|
+
'aria-label': 'Open chat',
|
|
766
|
+
type: 'button'
|
|
767
|
+
});
|
|
768
|
+
// Add chat icon (simple SVG)
|
|
769
|
+
this.element.innerHTML = `
|
|
770
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
771
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
|
772
|
+
</svg>
|
|
773
|
+
`;
|
|
774
|
+
this.element.addEventListener('click', this.options.onClick);
|
|
775
|
+
document.body.appendChild(this.element);
|
|
776
|
+
return this.element;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Show the bubble
|
|
780
|
+
*/
|
|
781
|
+
show() {
|
|
782
|
+
if (this.element) {
|
|
783
|
+
this.element.style.display = 'flex';
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Hide the bubble
|
|
788
|
+
*/
|
|
789
|
+
hide() {
|
|
790
|
+
if (this.element) {
|
|
791
|
+
this.element.style.display = 'none';
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Remove the bubble from DOM
|
|
796
|
+
*/
|
|
797
|
+
destroy() {
|
|
798
|
+
if (this.element) {
|
|
799
|
+
this.element.removeEventListener('click', this.options.onClick);
|
|
800
|
+
this.element.remove();
|
|
801
|
+
this.element = null;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Chat Panel - Expandable chat window component
|
|
808
|
+
*/
|
|
809
|
+
class ChatPanel {
|
|
810
|
+
constructor(options) {
|
|
811
|
+
this.element = null;
|
|
812
|
+
this.messagesContainer = null;
|
|
813
|
+
this.inputElement = null;
|
|
814
|
+
this.sendButton = null;
|
|
815
|
+
this.messages = [];
|
|
816
|
+
this.isWaitingForResponse = false;
|
|
817
|
+
this.options = options;
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Create and render the panel
|
|
821
|
+
*/
|
|
822
|
+
render() {
|
|
823
|
+
if (this.element) {
|
|
824
|
+
return this.element;
|
|
825
|
+
}
|
|
826
|
+
// Main panel
|
|
827
|
+
this.element = createElement('div', {
|
|
828
|
+
class: `wabbit-chat-panel ${this.options.position}`
|
|
829
|
+
});
|
|
830
|
+
// Header
|
|
831
|
+
const header = createElement('div', { class: 'wabbit-chat-panel-header' });
|
|
832
|
+
const headerTitle = createElement('div', { class: 'wabbit-chat-panel-header-title' });
|
|
833
|
+
const headerIcon = createElement('div', { class: 'wabbit-chat-panel-header-icon' });
|
|
834
|
+
headerIcon.textContent = 'AI';
|
|
835
|
+
const headerText = createElement('div', { class: 'wabbit-chat-panel-header-text' });
|
|
836
|
+
const headerH3 = createElement('h3');
|
|
837
|
+
headerH3.textContent = 'AI Assistant';
|
|
838
|
+
const headerP = createElement('p');
|
|
839
|
+
headerP.textContent = 'Powered by Wabbit';
|
|
840
|
+
headerText.appendChild(headerH3);
|
|
841
|
+
headerText.appendChild(headerP);
|
|
842
|
+
headerTitle.appendChild(headerIcon);
|
|
843
|
+
headerTitle.appendChild(headerText);
|
|
844
|
+
const closeButton = createElement('button', {
|
|
845
|
+
class: 'wabbit-chat-panel-close',
|
|
846
|
+
'aria-label': 'Close chat',
|
|
847
|
+
type: 'button'
|
|
848
|
+
});
|
|
849
|
+
closeButton.innerHTML = '×';
|
|
850
|
+
closeButton.addEventListener('click', this.options.onClose);
|
|
851
|
+
header.appendChild(headerTitle);
|
|
852
|
+
header.appendChild(closeButton);
|
|
853
|
+
// Messages area
|
|
854
|
+
this.messagesContainer = createElement('div', { class: 'wabbit-chat-messages' });
|
|
855
|
+
// Input area
|
|
856
|
+
const inputArea = createElement('div', { class: 'wabbit-chat-input-area' });
|
|
857
|
+
const inputWrapper = createElement('div', { class: 'wabbit-chat-input-wrapper' });
|
|
858
|
+
this.inputElement = document.createElement('textarea');
|
|
859
|
+
this.inputElement.className = 'wabbit-chat-input';
|
|
860
|
+
this.inputElement.placeholder = this.options.placeholder || 'Type your message...';
|
|
861
|
+
this.inputElement.rows = 1;
|
|
862
|
+
this.inputElement.disabled = this.options.disabled || false;
|
|
863
|
+
// Auto-resize textarea
|
|
864
|
+
this.inputElement.addEventListener('input', () => {
|
|
865
|
+
this.inputElement.style.height = 'auto';
|
|
866
|
+
this.inputElement.style.height = `${Math.min(this.inputElement.scrollHeight, 120)}px`;
|
|
867
|
+
});
|
|
868
|
+
// Send on Enter (Shift+Enter for new line)
|
|
869
|
+
this.inputElement.addEventListener('keydown', (e) => {
|
|
870
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
871
|
+
e.preventDefault();
|
|
872
|
+
this.handleSend();
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
this.sendButton = createElement('button', {
|
|
876
|
+
class: 'wabbit-chat-send-button',
|
|
877
|
+
type: 'button',
|
|
878
|
+
'aria-label': 'Send message'
|
|
879
|
+
});
|
|
880
|
+
this.sendButton.innerHTML = `
|
|
881
|
+
<svg class="wabbit-chat-send-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
882
|
+
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
883
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
884
|
+
</svg>
|
|
885
|
+
`;
|
|
886
|
+
this.sendButton.addEventListener('click', () => this.handleSend());
|
|
887
|
+
this.updateSendButtonState();
|
|
888
|
+
inputWrapper.appendChild(this.inputElement);
|
|
889
|
+
inputWrapper.appendChild(this.sendButton);
|
|
890
|
+
inputArea.appendChild(inputWrapper);
|
|
891
|
+
// Assemble panel
|
|
892
|
+
this.element.appendChild(header);
|
|
893
|
+
this.element.appendChild(this.messagesContainer);
|
|
894
|
+
this.element.appendChild(inputArea);
|
|
895
|
+
document.body.appendChild(this.element);
|
|
896
|
+
// Initially hide the panel (it will be shown when opened)
|
|
897
|
+
this.element.style.display = 'none';
|
|
898
|
+
// Show welcome message if provided
|
|
899
|
+
if (this.options.welcomeMessage) {
|
|
900
|
+
this.addSystemMessage(this.options.welcomeMessage);
|
|
901
|
+
}
|
|
902
|
+
return this.element;
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Add a message to the panel
|
|
906
|
+
*/
|
|
907
|
+
addMessage(message) {
|
|
908
|
+
this.messages.push(message);
|
|
909
|
+
this.renderMessage(message);
|
|
910
|
+
this.scrollToBottom();
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Add multiple messages
|
|
914
|
+
*/
|
|
915
|
+
setMessages(messages) {
|
|
916
|
+
this.messages = messages;
|
|
917
|
+
if (this.messagesContainer) {
|
|
918
|
+
this.messagesContainer.innerHTML = '';
|
|
919
|
+
messages.forEach((msg) => this.renderMessage(msg));
|
|
920
|
+
this.scrollToBottom();
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Add system message
|
|
925
|
+
*/
|
|
926
|
+
addSystemMessage(content) {
|
|
927
|
+
const message = {
|
|
928
|
+
id: `system-${Date.now()}`,
|
|
929
|
+
role: 'system',
|
|
930
|
+
content,
|
|
931
|
+
timestamp: new Date()
|
|
932
|
+
};
|
|
933
|
+
this.addMessage(message);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Set waiting for response state
|
|
937
|
+
*/
|
|
938
|
+
setWaitingForResponse(waiting) {
|
|
939
|
+
this.isWaitingForResponse = waiting;
|
|
940
|
+
if (this.messagesContainer) {
|
|
941
|
+
// Remove existing typing indicator
|
|
942
|
+
const existing = this.messagesContainer.querySelector('.wabbit-chat-typing');
|
|
943
|
+
if (existing) {
|
|
944
|
+
existing.remove();
|
|
945
|
+
}
|
|
946
|
+
if (waiting) {
|
|
947
|
+
const typing = createElement('div', { class: 'wabbit-chat-typing' });
|
|
948
|
+
typing.innerHTML = `
|
|
949
|
+
<div class="wabbit-chat-typing-dot"></div>
|
|
950
|
+
<div class="wabbit-chat-typing-dot"></div>
|
|
951
|
+
<div class="wabbit-chat-typing-dot"></div>
|
|
952
|
+
`;
|
|
953
|
+
this.messagesContainer.appendChild(typing);
|
|
954
|
+
this.scrollToBottom();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
this.updateSendButtonState();
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Set disabled state
|
|
961
|
+
*/
|
|
962
|
+
setDisabled(disabled) {
|
|
963
|
+
if (this.inputElement) {
|
|
964
|
+
this.inputElement.disabled = disabled;
|
|
965
|
+
this.updateSendButtonState();
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Render a single message
|
|
970
|
+
*/
|
|
971
|
+
renderMessage(message) {
|
|
972
|
+
if (!this.messagesContainer)
|
|
973
|
+
return;
|
|
974
|
+
const messageDiv = createElement('div', {
|
|
975
|
+
class: `wabbit-chat-message wabbit-chat-message-${message.role}`
|
|
976
|
+
});
|
|
977
|
+
const bubble = createElement('div', { class: 'wabbit-chat-message-bubble' });
|
|
978
|
+
const content = createElement('div', { class: 'wabbit-chat-message-content' });
|
|
979
|
+
// Escape HTML for user and system messages, but allow markdown-like formatting for assistant
|
|
980
|
+
if (message.role === 'assistant') {
|
|
981
|
+
// Simple markdown-like rendering (basic)
|
|
982
|
+
content.innerHTML = this.formatMessage(message.content);
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
content.textContent = message.content;
|
|
986
|
+
}
|
|
987
|
+
bubble.appendChild(content);
|
|
988
|
+
messageDiv.appendChild(bubble);
|
|
989
|
+
this.messagesContainer.appendChild(messageDiv);
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Format message content (simple markdown-like)
|
|
993
|
+
*/
|
|
994
|
+
formatMessage(content) {
|
|
995
|
+
// Escape HTML first
|
|
996
|
+
let formatted = escapeHtml(content);
|
|
997
|
+
// Simple markdown-like formatting
|
|
998
|
+
formatted = formatted.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
999
|
+
formatted = formatted.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
1000
|
+
formatted = formatted.replace(/`(.+?)`/g, '<code style="background: rgba(0,0,0,0.1); padding: 2px 4px; border-radius: 3px;">$1</code>');
|
|
1001
|
+
formatted = formatted.replace(/\n/g, '<br>');
|
|
1002
|
+
return formatted;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Handle send message
|
|
1006
|
+
*/
|
|
1007
|
+
handleSend() {
|
|
1008
|
+
if (!this.inputElement || this.isWaitingForResponse)
|
|
1009
|
+
return;
|
|
1010
|
+
const content = this.inputElement.value.trim();
|
|
1011
|
+
if (!content)
|
|
1012
|
+
return;
|
|
1013
|
+
this.inputElement.value = '';
|
|
1014
|
+
this.inputElement.style.height = 'auto';
|
|
1015
|
+
this.options.onSendMessage(content);
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Update send button state
|
|
1019
|
+
*/
|
|
1020
|
+
updateSendButtonState() {
|
|
1021
|
+
if (!this.sendButton || !this.inputElement)
|
|
1022
|
+
return;
|
|
1023
|
+
const hasText = this.inputElement.value.trim().length > 0;
|
|
1024
|
+
const disabled = this.options.disabled || this.isWaitingForResponse || !hasText;
|
|
1025
|
+
this.sendButton.setAttribute('disabled', disabled ? 'true' : 'false');
|
|
1026
|
+
if (this.sendButton instanceof HTMLButtonElement) {
|
|
1027
|
+
this.sendButton.disabled = disabled;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Scroll to bottom of messages
|
|
1032
|
+
*/
|
|
1033
|
+
scrollToBottom() {
|
|
1034
|
+
if (this.messagesContainer) {
|
|
1035
|
+
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Show the panel
|
|
1040
|
+
*/
|
|
1041
|
+
show() {
|
|
1042
|
+
if (this.element) {
|
|
1043
|
+
this.element.style.display = 'flex';
|
|
1044
|
+
// Focus input after a brief delay
|
|
1045
|
+
setTimeout(() => {
|
|
1046
|
+
if (this.inputElement) {
|
|
1047
|
+
this.inputElement.focus();
|
|
1048
|
+
}
|
|
1049
|
+
}, 100);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Hide the panel
|
|
1054
|
+
*/
|
|
1055
|
+
hide() {
|
|
1056
|
+
if (this.element) {
|
|
1057
|
+
this.element.style.display = 'none';
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Remove the panel from DOM
|
|
1062
|
+
*/
|
|
1063
|
+
destroy() {
|
|
1064
|
+
if (this.element) {
|
|
1065
|
+
this.element.remove();
|
|
1066
|
+
this.element = null;
|
|
1067
|
+
this.messagesContainer = null;
|
|
1068
|
+
this.inputElement = null;
|
|
1069
|
+
this.sendButton = null;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Theme detection utilities
|
|
1076
|
+
*/
|
|
1077
|
+
/**
|
|
1078
|
+
* Detect current theme from the page
|
|
1079
|
+
*
|
|
1080
|
+
* Simple detection:
|
|
1081
|
+
* 1. Check data-theme attribute (explicit setting)
|
|
1082
|
+
* 2. Check dark class (common pattern)
|
|
1083
|
+
* 3. Default to light (most pages are light)
|
|
1084
|
+
*
|
|
1085
|
+
* Note: We default to 'light' instead of checking system preference
|
|
1086
|
+
* because most web pages are light-themed, and it's better to have
|
|
1087
|
+
* readable dark text on light background than light text on light background.
|
|
1088
|
+
*
|
|
1089
|
+
* @returns Current theme ('light' or 'dark')
|
|
1090
|
+
*/
|
|
1091
|
+
function detectTheme() {
|
|
1092
|
+
const htmlEl = document.documentElement;
|
|
1093
|
+
// Check data-theme attribute (explicit setting)
|
|
1094
|
+
const dataTheme = htmlEl.getAttribute('data-theme');
|
|
1095
|
+
if (dataTheme === 'light' || dataTheme === 'dark') {
|
|
1096
|
+
return dataTheme;
|
|
1097
|
+
}
|
|
1098
|
+
// Check dark class (common pattern like Tailwind)
|
|
1099
|
+
if (htmlEl.classList.contains('dark')) {
|
|
1100
|
+
return 'dark';
|
|
1101
|
+
}
|
|
1102
|
+
// Default to light (most pages are light-themed)
|
|
1103
|
+
// This ensures readable dark text on light background
|
|
1104
|
+
return 'light';
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Watch for theme changes
|
|
1108
|
+
*
|
|
1109
|
+
* @param callback - Callback to execute when theme changes
|
|
1110
|
+
* @returns Cleanup function
|
|
1111
|
+
*/
|
|
1112
|
+
function watchTheme(callback) {
|
|
1113
|
+
const observer = new MutationObserver(() => {
|
|
1114
|
+
callback(detectTheme());
|
|
1115
|
+
});
|
|
1116
|
+
observer.observe(document.documentElement, {
|
|
1117
|
+
attributes: true,
|
|
1118
|
+
attributeFilter: ['data-theme', 'class']
|
|
1119
|
+
});
|
|
1120
|
+
// Also watch system preference changes
|
|
1121
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
1122
|
+
const handleChange = () => {
|
|
1123
|
+
callback(detectTheme());
|
|
1124
|
+
};
|
|
1125
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
1126
|
+
// Initial call
|
|
1127
|
+
callback(detectTheme());
|
|
1128
|
+
// Return cleanup function
|
|
1129
|
+
return () => {
|
|
1130
|
+
observer.disconnect();
|
|
1131
|
+
mediaQuery.removeEventListener('change', handleChange);
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Chat widget styles
|
|
1137
|
+
*
|
|
1138
|
+
* Based on demo-website styles but adapted for embedded widget
|
|
1139
|
+
*/
|
|
1140
|
+
/**
|
|
1141
|
+
* Inject chat widget styles into the page
|
|
1142
|
+
*/
|
|
1143
|
+
function injectChatStyles(primaryColor = '#6366f1', theme) {
|
|
1144
|
+
const styleId = 'wabbit-chat-styles';
|
|
1145
|
+
// Remove existing styles if any
|
|
1146
|
+
const existing = document.getElementById(styleId);
|
|
1147
|
+
if (existing) {
|
|
1148
|
+
existing.remove();
|
|
1149
|
+
}
|
|
1150
|
+
// Determine actual theme: if 'auto', detect from page; otherwise use specified theme
|
|
1151
|
+
const actualTheme = theme === 'auto' || !theme ? detectTheme() : theme;
|
|
1152
|
+
const isDark = actualTheme === 'dark';
|
|
1153
|
+
// CSS variables based on theme
|
|
1154
|
+
const cssVars = isDark
|
|
1155
|
+
? {
|
|
1156
|
+
'--wabbit-bg-base': '#1A1816',
|
|
1157
|
+
'--wabbit-bg-elevated': '#2D2826',
|
|
1158
|
+
'--wabbit-bg-hover': '#3D3935',
|
|
1159
|
+
'--wabbit-text-primary': '#F5F1ED',
|
|
1160
|
+
'--wabbit-text-secondary': '#A39C94',
|
|
1161
|
+
'--wabbit-text-muted': '#6B6560',
|
|
1162
|
+
'--wabbit-border-subtle': '#3D3935',
|
|
1163
|
+
'--wabbit-primary': primaryColor,
|
|
1164
|
+
}
|
|
1165
|
+
: {
|
|
1166
|
+
'--wabbit-bg-base': '#FAF8F6',
|
|
1167
|
+
'--wabbit-bg-elevated': '#FFFFFF',
|
|
1168
|
+
'--wabbit-bg-hover': '#F2F0EE',
|
|
1169
|
+
'--wabbit-text-primary': '#2D2826',
|
|
1170
|
+
'--wabbit-text-secondary': '#5A5450',
|
|
1171
|
+
'--wabbit-text-muted': '#8A847E',
|
|
1172
|
+
'--wabbit-border-subtle': '#E6E3E0',
|
|
1173
|
+
'--wabbit-primary': primaryColor,
|
|
1174
|
+
};
|
|
1175
|
+
const cssVarsString = Object.entries(cssVars)
|
|
1176
|
+
.map(([key, value]) => `${key}: ${value};`)
|
|
1177
|
+
.join('\n ');
|
|
1178
|
+
const styles = `
|
|
1179
|
+
:root {
|
|
1180
|
+
${cssVarsString}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/* Chat Bubble */
|
|
1184
|
+
.wabbit-chat-bubble {
|
|
1185
|
+
position: fixed;
|
|
1186
|
+
z-index: 9999;
|
|
1187
|
+
width: 60px;
|
|
1188
|
+
height: 60px;
|
|
1189
|
+
border-radius: 50%;
|
|
1190
|
+
background: linear-gradient(135deg, var(--wabbit-primary), ${adjustColor(primaryColor, 0.2)});
|
|
1191
|
+
border: none;
|
|
1192
|
+
cursor: pointer;
|
|
1193
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
1194
|
+
display: flex;
|
|
1195
|
+
align-items: center;
|
|
1196
|
+
justify-content: center;
|
|
1197
|
+
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
1198
|
+
color: white;
|
|
1199
|
+
font-size: 24px;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
.wabbit-chat-bubble:hover {
|
|
1203
|
+
transform: scale(1.1);
|
|
1204
|
+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2), 0 4px 8px rgba(0, 0, 0, 0.15);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
.wabbit-chat-bubble:active {
|
|
1208
|
+
transform: scale(0.95);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
.wabbit-chat-bubble.bottom-right {
|
|
1212
|
+
bottom: 20px;
|
|
1213
|
+
right: 20px;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
.wabbit-chat-bubble.bottom-left {
|
|
1217
|
+
bottom: 20px;
|
|
1218
|
+
left: 20px;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/* Chat Panel */
|
|
1222
|
+
.wabbit-chat-panel {
|
|
1223
|
+
position: fixed;
|
|
1224
|
+
z-index: 9998;
|
|
1225
|
+
width: 380px;
|
|
1226
|
+
max-width: calc(100vw - 40px);
|
|
1227
|
+
height: 600px;
|
|
1228
|
+
max-height: calc(100vh - 100px);
|
|
1229
|
+
background: var(--wabbit-bg-elevated);
|
|
1230
|
+
border: 1px solid var(--wabbit-border-subtle);
|
|
1231
|
+
border-radius: 16px;
|
|
1232
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
1233
|
+
display: flex;
|
|
1234
|
+
flex-direction: column;
|
|
1235
|
+
overflow: hidden;
|
|
1236
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
|
1237
|
+
font-size: 14px;
|
|
1238
|
+
line-height: 1.5;
|
|
1239
|
+
color: var(--wabbit-text-primary);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
.wabbit-chat-panel.bottom-right {
|
|
1243
|
+
bottom: 90px;
|
|
1244
|
+
right: 20px;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
.wabbit-chat-panel.bottom-left {
|
|
1248
|
+
bottom: 90px;
|
|
1249
|
+
left: 20px;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
@media (max-width: 480px) {
|
|
1253
|
+
.wabbit-chat-panel {
|
|
1254
|
+
width: calc(100vw - 20px);
|
|
1255
|
+
height: calc(100vh - 20px);
|
|
1256
|
+
max-height: calc(100vh - 20px);
|
|
1257
|
+
bottom: 10px !important;
|
|
1258
|
+
left: 10px !important;
|
|
1259
|
+
right: 10px !important;
|
|
1260
|
+
border-radius: 12px;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/* Panel Header */
|
|
1265
|
+
.wabbit-chat-panel-header {
|
|
1266
|
+
background: rgba(var(--wabbit-primary-rgb, 99, 102, 241), 0.1);
|
|
1267
|
+
border-bottom: 1px solid var(--wabbit-border-subtle);
|
|
1268
|
+
padding: 16px;
|
|
1269
|
+
display: flex;
|
|
1270
|
+
align-items: center;
|
|
1271
|
+
justify-content: space-between;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
.wabbit-chat-panel-header-title {
|
|
1275
|
+
display: flex;
|
|
1276
|
+
align-items: center;
|
|
1277
|
+
gap: 12px;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
.wabbit-chat-panel-header-icon {
|
|
1281
|
+
width: 32px;
|
|
1282
|
+
height: 32px;
|
|
1283
|
+
border-radius: 50%;
|
|
1284
|
+
background: linear-gradient(135deg, var(--wabbit-primary), ${adjustColor(primaryColor, 0.2)});
|
|
1285
|
+
display: flex;
|
|
1286
|
+
align-items: center;
|
|
1287
|
+
justify-content: center;
|
|
1288
|
+
color: white;
|
|
1289
|
+
font-size: 16px;
|
|
1290
|
+
font-weight: 600;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
.wabbit-chat-panel-header-text h3 {
|
|
1294
|
+
margin: 0;
|
|
1295
|
+
font-size: 16px;
|
|
1296
|
+
font-weight: 600;
|
|
1297
|
+
color: var(--wabbit-text-primary);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
.wabbit-chat-panel-header-text p {
|
|
1301
|
+
margin: 0;
|
|
1302
|
+
font-size: 12px;
|
|
1303
|
+
color: var(--wabbit-text-muted);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
.wabbit-chat-panel-close {
|
|
1307
|
+
background: none;
|
|
1308
|
+
border: none;
|
|
1309
|
+
cursor: pointer;
|
|
1310
|
+
color: var(--wabbit-text-secondary);
|
|
1311
|
+
font-size: 24px;
|
|
1312
|
+
line-height: 1;
|
|
1313
|
+
padding: 4px;
|
|
1314
|
+
transition: color 0.2s;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
.wabbit-chat-panel-close:hover {
|
|
1318
|
+
color: var(--wabbit-text-primary);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/* Messages Area */
|
|
1322
|
+
.wabbit-chat-messages {
|
|
1323
|
+
flex: 1;
|
|
1324
|
+
overflow-y: auto;
|
|
1325
|
+
padding: 16px;
|
|
1326
|
+
display: flex;
|
|
1327
|
+
flex-direction: column;
|
|
1328
|
+
gap: 16px;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
.wabbit-chat-message {
|
|
1332
|
+
display: flex;
|
|
1333
|
+
flex-direction: column;
|
|
1334
|
+
gap: 4px;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
.wabbit-chat-message-user {
|
|
1338
|
+
align-items: flex-end;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
.wabbit-chat-message-assistant {
|
|
1342
|
+
align-items: flex-start;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
.wabbit-chat-message-system {
|
|
1346
|
+
align-items: center;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
.wabbit-chat-message-bubble {
|
|
1350
|
+
max-width: 85%;
|
|
1351
|
+
padding: 12px 16px;
|
|
1352
|
+
border-radius: 16px;
|
|
1353
|
+
word-wrap: break-word;
|
|
1354
|
+
white-space: pre-wrap;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
.wabbit-chat-message-user .wabbit-chat-message-bubble {
|
|
1358
|
+
background: linear-gradient(135deg, var(--wabbit-primary), ${adjustColor(primaryColor, 0.2)});
|
|
1359
|
+
color: white;
|
|
1360
|
+
border-bottom-right-radius: 4px;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
.wabbit-chat-message-assistant .wabbit-chat-message-bubble {
|
|
1364
|
+
background: var(--wabbit-bg-hover);
|
|
1365
|
+
color: var(--wabbit-text-primary);
|
|
1366
|
+
border: 1px solid var(--wabbit-border-subtle);
|
|
1367
|
+
border-bottom-left-radius: 4px;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
.wabbit-chat-message-system .wabbit-chat-message-bubble {
|
|
1371
|
+
background: rgba(var(--wabbit-primary-rgb, 99, 102, 241), 0.1);
|
|
1372
|
+
border: 1px solid rgba(var(--wabbit-primary-rgb, 99, 102, 241), 0.3);
|
|
1373
|
+
color: var(--wabbit-primary);
|
|
1374
|
+
text-align: center;
|
|
1375
|
+
font-size: 12px;
|
|
1376
|
+
padding: 8px 12px;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
.wabbit-chat-message-content {
|
|
1380
|
+
font-size: 14px;
|
|
1381
|
+
line-height: 1.5;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/* Typing Indicator */
|
|
1385
|
+
.wabbit-chat-typing {
|
|
1386
|
+
display: flex;
|
|
1387
|
+
gap: 4px;
|
|
1388
|
+
padding: 12px 16px;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
.wabbit-chat-typing-dot {
|
|
1392
|
+
width: 8px;
|
|
1393
|
+
height: 8px;
|
|
1394
|
+
border-radius: 50%;
|
|
1395
|
+
background: var(--wabbit-text-muted);
|
|
1396
|
+
animation: wabbit-typing 1.4s infinite;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
.wabbit-chat-typing-dot:nth-child(2) {
|
|
1400
|
+
animation-delay: 0.2s;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
.wabbit-chat-typing-dot:nth-child(3) {
|
|
1404
|
+
animation-delay: 0.4s;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
@keyframes wabbit-typing {
|
|
1408
|
+
0%, 60%, 100% {
|
|
1409
|
+
transform: translateY(0);
|
|
1410
|
+
opacity: 0.7;
|
|
1411
|
+
}
|
|
1412
|
+
30% {
|
|
1413
|
+
transform: translateY(-10px);
|
|
1414
|
+
opacity: 1;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/* Input Area */
|
|
1419
|
+
.wabbit-chat-input-area {
|
|
1420
|
+
border-top: 1px solid var(--wabbit-border-subtle);
|
|
1421
|
+
padding: 12px;
|
|
1422
|
+
background: var(--wabbit-bg-elevated);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
.wabbit-chat-input-wrapper {
|
|
1426
|
+
display: flex;
|
|
1427
|
+
gap: 8px;
|
|
1428
|
+
align-items: flex-end;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
.wabbit-chat-input {
|
|
1432
|
+
flex: 1;
|
|
1433
|
+
resize: none;
|
|
1434
|
+
background: var(--wabbit-bg-hover);
|
|
1435
|
+
border: 1px solid var(--wabbit-border-subtle);
|
|
1436
|
+
border-radius: 12px;
|
|
1437
|
+
padding: 10px 12px;
|
|
1438
|
+
color: var(--wabbit-text-primary);
|
|
1439
|
+
font-size: 14px;
|
|
1440
|
+
font-family: inherit;
|
|
1441
|
+
line-height: 1.5;
|
|
1442
|
+
min-height: 44px;
|
|
1443
|
+
max-height: 120px;
|
|
1444
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
.wabbit-chat-input:focus {
|
|
1448
|
+
outline: none;
|
|
1449
|
+
border-color: var(--wabbit-primary);
|
|
1450
|
+
box-shadow: 0 0 0 3px rgba(var(--wabbit-primary-rgb, 99, 102, 241), 0.1);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
.wabbit-chat-input::placeholder {
|
|
1454
|
+
color: var(--wabbit-text-muted);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
.wabbit-chat-input:disabled {
|
|
1458
|
+
opacity: 0.5;
|
|
1459
|
+
cursor: not-allowed;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
.wabbit-chat-send-button {
|
|
1463
|
+
width: 44px;
|
|
1464
|
+
height: 44px;
|
|
1465
|
+
border-radius: 12px;
|
|
1466
|
+
background: linear-gradient(135deg, var(--wabbit-primary), ${adjustColor(primaryColor, 0.2)});
|
|
1467
|
+
border: none;
|
|
1468
|
+
color: white;
|
|
1469
|
+
cursor: pointer;
|
|
1470
|
+
display: flex;
|
|
1471
|
+
align-items: center;
|
|
1472
|
+
justify-content: center;
|
|
1473
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
1474
|
+
flex-shrink: 0;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
.wabbit-chat-send-button:hover:not(:disabled) {
|
|
1478
|
+
opacity: 0.9;
|
|
1479
|
+
transform: scale(1.05);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
.wabbit-chat-send-button:active:not(:disabled) {
|
|
1483
|
+
transform: scale(0.95);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
.wabbit-chat-send-button:disabled {
|
|
1487
|
+
opacity: 0.5;
|
|
1488
|
+
cursor: not-allowed;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
.wabbit-chat-send-icon {
|
|
1492
|
+
width: 20px;
|
|
1493
|
+
height: 20px;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/* Scrollbar */
|
|
1497
|
+
.wabbit-chat-messages::-webkit-scrollbar {
|
|
1498
|
+
width: 6px;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
.wabbit-chat-messages::-webkit-scrollbar-track {
|
|
1502
|
+
background: transparent;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
.wabbit-chat-messages::-webkit-scrollbar-thumb {
|
|
1506
|
+
background: var(--wabbit-border-subtle);
|
|
1507
|
+
border-radius: 3px;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
.wabbit-chat-messages::-webkit-scrollbar-thumb:hover {
|
|
1511
|
+
background: var(--wabbit-text-muted);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/* Animations */
|
|
1515
|
+
@keyframes wabbit-fade-in {
|
|
1516
|
+
from {
|
|
1517
|
+
opacity: 0;
|
|
1518
|
+
transform: translateY(10px);
|
|
1519
|
+
}
|
|
1520
|
+
to {
|
|
1521
|
+
opacity: 1;
|
|
1522
|
+
transform: translateY(0);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
.wabbit-chat-panel {
|
|
1527
|
+
animation: wabbit-fade-in 0.3s ease-out;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
.wabbit-chat-message {
|
|
1531
|
+
animation: wabbit-fade-in 0.2s ease-out;
|
|
1532
|
+
}
|
|
1533
|
+
`;
|
|
1534
|
+
const style = document.createElement('style');
|
|
1535
|
+
style.id = styleId;
|
|
1536
|
+
style.setAttribute('data-wabbit', 'true');
|
|
1537
|
+
style.textContent = styles;
|
|
1538
|
+
document.head.appendChild(style);
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Adjust color brightness
|
|
1542
|
+
*/
|
|
1543
|
+
function adjustColor(color, amount) {
|
|
1544
|
+
// Simple color adjustment - convert hex to RGB and adjust
|
|
1545
|
+
const hex = color.replace('#', '');
|
|
1546
|
+
const r = parseInt(hex.substr(0, 2), 16);
|
|
1547
|
+
const g = parseInt(hex.substr(2, 2), 16);
|
|
1548
|
+
const b = parseInt(hex.substr(4, 2), 16);
|
|
1549
|
+
const newR = Math.max(0, Math.min(255, Math.round(r + (255 - r) * amount)));
|
|
1550
|
+
const newG = Math.max(0, Math.min(255, Math.round(g + (255 - g) * amount)));
|
|
1551
|
+
const newB = Math.max(0, Math.min(255, Math.round(b + (255 - b) * amount)));
|
|
1552
|
+
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* Chat Widget - Main class that integrates all chat components
|
|
1557
|
+
*/
|
|
1558
|
+
class ChatWidget {
|
|
1559
|
+
constructor(config) {
|
|
1560
|
+
this.wsClient = null;
|
|
1561
|
+
this.bubble = null;
|
|
1562
|
+
this.panel = null;
|
|
1563
|
+
this.isOpen = false;
|
|
1564
|
+
this.cleanup = [];
|
|
1565
|
+
this.themeCleanup = null;
|
|
1566
|
+
this.config = config;
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Initialize the chat widget
|
|
1570
|
+
*/
|
|
1571
|
+
async init() {
|
|
1572
|
+
// Wait for DOM to be ready
|
|
1573
|
+
await new Promise((resolve) => {
|
|
1574
|
+
onDOMReady(() => {
|
|
1575
|
+
resolve();
|
|
1576
|
+
});
|
|
1577
|
+
});
|
|
1578
|
+
// Inject styles with theme configuration
|
|
1579
|
+
injectChatStyles(this.config.primaryColor || '#6366f1', this.config.theme);
|
|
1580
|
+
// Setup theme watcher if theme is 'auto'
|
|
1581
|
+
this.setupThemeWatcher();
|
|
1582
|
+
// Create WebSocket client
|
|
1583
|
+
const wsUrl = this.getWebSocketUrl();
|
|
1584
|
+
this.wsClient = new ChatWebSocketClient(this.config.apiKey || '', wsUrl, null // Will use stored session if available
|
|
1585
|
+
);
|
|
1586
|
+
// Set up event handlers
|
|
1587
|
+
this.setupWebSocketHandlers();
|
|
1588
|
+
// Create bubble
|
|
1589
|
+
this.bubble = new ChatBubble({
|
|
1590
|
+
position: this.config.position || 'bottom-right',
|
|
1591
|
+
onClick: () => this.toggle()
|
|
1592
|
+
});
|
|
1593
|
+
// Create panel
|
|
1594
|
+
this.panel = new ChatPanel({
|
|
1595
|
+
position: this.config.position || 'bottom-right',
|
|
1596
|
+
welcomeMessage: this.config.welcomeMessage,
|
|
1597
|
+
placeholder: this.config.placeholder,
|
|
1598
|
+
onSendMessage: (content) => this.handleSendMessage(content),
|
|
1599
|
+
onClose: () => this.close(),
|
|
1600
|
+
disabled: true
|
|
1601
|
+
});
|
|
1602
|
+
// Render components
|
|
1603
|
+
this.bubble.render();
|
|
1604
|
+
this.panel.render();
|
|
1605
|
+
// Ensure initial state: panel hidden, bubble visible
|
|
1606
|
+
// This prevents state mismatch where panel might be visible but isOpen is false
|
|
1607
|
+
if (this.panel) {
|
|
1608
|
+
this.panel.hide();
|
|
1609
|
+
}
|
|
1610
|
+
if (this.bubble) {
|
|
1611
|
+
this.bubble.show();
|
|
1612
|
+
}
|
|
1613
|
+
this.isOpen = false; // Ensure state is consistent
|
|
1614
|
+
// Handle trigger types
|
|
1615
|
+
this.handleTriggerType();
|
|
1616
|
+
// Connect WebSocket
|
|
1617
|
+
await this.wsClient.connect();
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Setup WebSocket event handlers
|
|
1621
|
+
*/
|
|
1622
|
+
setupWebSocketHandlers() {
|
|
1623
|
+
if (!this.wsClient)
|
|
1624
|
+
return;
|
|
1625
|
+
this.wsClient.onWelcome = (sessionId, _collectionId, message) => {
|
|
1626
|
+
console.log('[Wabbit] Chat connected:', sessionId);
|
|
1627
|
+
if (this.panel) {
|
|
1628
|
+
this.panel.setDisabled(false);
|
|
1629
|
+
if (message) {
|
|
1630
|
+
this.panel.addSystemMessage(message);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
this.wsClient.onMessageHistory = (messages) => {
|
|
1635
|
+
console.log('[Wabbit] Loaded message history:', messages.length);
|
|
1636
|
+
if (this.panel) {
|
|
1637
|
+
this.panel.setMessages(messages);
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
this.wsClient.onMessage = (message) => {
|
|
1641
|
+
if (this.panel) {
|
|
1642
|
+
this.panel.addMessage(message);
|
|
1643
|
+
this.panel.setWaitingForResponse(false);
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
this.wsClient.onError = (error) => {
|
|
1647
|
+
console.error('[Wabbit] Chat error:', error);
|
|
1648
|
+
if (this.panel) {
|
|
1649
|
+
this.panel.addSystemMessage(`Error: ${error}`);
|
|
1650
|
+
this.panel.setWaitingForResponse(false);
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
this.wsClient.onStatusChange = (status) => {
|
|
1654
|
+
if (this.panel) {
|
|
1655
|
+
this.panel.setDisabled(status !== 'connected');
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
this.wsClient.onDisconnect = () => {
|
|
1659
|
+
if (this.panel) {
|
|
1660
|
+
this.panel.addSystemMessage('Disconnected from chat service');
|
|
1661
|
+
this.panel.setDisabled(true);
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Handle trigger type configuration
|
|
1667
|
+
*/
|
|
1668
|
+
handleTriggerType() {
|
|
1669
|
+
const triggerType = this.config.triggerType || 'button';
|
|
1670
|
+
switch (triggerType) {
|
|
1671
|
+
case 'auto':
|
|
1672
|
+
// Auto-open after delay
|
|
1673
|
+
const delay = this.config.triggerDelay || 5000;
|
|
1674
|
+
setTimeout(() => {
|
|
1675
|
+
this.open();
|
|
1676
|
+
}, delay);
|
|
1677
|
+
break;
|
|
1678
|
+
case 'scroll':
|
|
1679
|
+
// Open after scrolling
|
|
1680
|
+
let hasScrolled = false;
|
|
1681
|
+
const scrollHandler = () => {
|
|
1682
|
+
if (!hasScrolled) {
|
|
1683
|
+
hasScrolled = true;
|
|
1684
|
+
this.open();
|
|
1685
|
+
window.removeEventListener('scroll', scrollHandler);
|
|
1686
|
+
}
|
|
1687
|
+
};
|
|
1688
|
+
window.addEventListener('scroll', scrollHandler);
|
|
1689
|
+
this.cleanup.push(() => {
|
|
1690
|
+
window.removeEventListener('scroll', scrollHandler);
|
|
1691
|
+
});
|
|
1692
|
+
break;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Get WebSocket URL
|
|
1697
|
+
*/
|
|
1698
|
+
getWebSocketUrl() {
|
|
1699
|
+
// Use wsUrl if explicitly provided
|
|
1700
|
+
if (this.config.wsUrl) {
|
|
1701
|
+
return this.config.wsUrl;
|
|
1702
|
+
}
|
|
1703
|
+
// Otherwise, derive from apiUrl
|
|
1704
|
+
const apiUrl = this.config.apiUrl || 'https://api.wabbit.io';
|
|
1705
|
+
const wsProtocol = apiUrl.startsWith('https') ? 'wss' : 'ws';
|
|
1706
|
+
const wsHost = apiUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
1707
|
+
return `${wsProtocol}://${wsHost}/ws/chat`;
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Handle send message
|
|
1711
|
+
* Made public so EmailCaptureWidget can hook into it
|
|
1712
|
+
*/
|
|
1713
|
+
handleSendMessage(content) {
|
|
1714
|
+
if (!this.wsClient)
|
|
1715
|
+
return;
|
|
1716
|
+
// Add user message to panel immediately
|
|
1717
|
+
const userMessage = {
|
|
1718
|
+
id: `user-${Date.now()}`,
|
|
1719
|
+
role: 'user',
|
|
1720
|
+
content,
|
|
1721
|
+
timestamp: new Date()
|
|
1722
|
+
};
|
|
1723
|
+
if (this.panel) {
|
|
1724
|
+
this.panel.addMessage(userMessage);
|
|
1725
|
+
this.panel.setWaitingForResponse(true);
|
|
1726
|
+
}
|
|
1727
|
+
// Send via WebSocket
|
|
1728
|
+
this.wsClient.sendMessage(content);
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Open the chat panel
|
|
1732
|
+
*/
|
|
1733
|
+
open() {
|
|
1734
|
+
if (this.isOpen)
|
|
1735
|
+
return;
|
|
1736
|
+
this.isOpen = true;
|
|
1737
|
+
if (this.panel) {
|
|
1738
|
+
this.panel.show();
|
|
1739
|
+
}
|
|
1740
|
+
if (this.bubble) {
|
|
1741
|
+
this.bubble.hide();
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Close the chat panel
|
|
1746
|
+
*/
|
|
1747
|
+
close() {
|
|
1748
|
+
if (!this.isOpen)
|
|
1749
|
+
return;
|
|
1750
|
+
this.isOpen = false;
|
|
1751
|
+
if (this.panel) {
|
|
1752
|
+
this.panel.hide();
|
|
1753
|
+
}
|
|
1754
|
+
if (this.bubble) {
|
|
1755
|
+
this.bubble.show();
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Toggle chat panel
|
|
1760
|
+
*/
|
|
1761
|
+
toggle() {
|
|
1762
|
+
if (this.isOpen) {
|
|
1763
|
+
this.close();
|
|
1764
|
+
}
|
|
1765
|
+
else {
|
|
1766
|
+
this.open();
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* Send a message programmatically
|
|
1771
|
+
*/
|
|
1772
|
+
sendMessage(content) {
|
|
1773
|
+
this.handleSendMessage(content);
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Setup theme watcher for 'auto' theme mode
|
|
1777
|
+
*/
|
|
1778
|
+
setupThemeWatcher() {
|
|
1779
|
+
const themeMode = this.config.theme || 'auto';
|
|
1780
|
+
// Only watch for theme changes if theme is 'auto'
|
|
1781
|
+
if (themeMode === 'auto') {
|
|
1782
|
+
this.themeCleanup = watchTheme(() => {
|
|
1783
|
+
// Re-inject styles when theme changes
|
|
1784
|
+
injectChatStyles(this.config.primaryColor || '#6366f1', 'auto');
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Destroy the widget and cleanup
|
|
1790
|
+
*/
|
|
1791
|
+
destroy() {
|
|
1792
|
+
// Run cleanup functions
|
|
1793
|
+
this.cleanup.forEach((fn) => fn());
|
|
1794
|
+
this.cleanup = [];
|
|
1795
|
+
// Cleanup theme watcher
|
|
1796
|
+
if (this.themeCleanup) {
|
|
1797
|
+
this.themeCleanup();
|
|
1798
|
+
this.themeCleanup = null;
|
|
1799
|
+
}
|
|
1800
|
+
// Disconnect WebSocket
|
|
1801
|
+
if (this.wsClient) {
|
|
1802
|
+
this.wsClient.disconnect();
|
|
1803
|
+
this.wsClient = null;
|
|
1804
|
+
}
|
|
1805
|
+
// Remove components
|
|
1806
|
+
if (this.bubble) {
|
|
1807
|
+
this.bubble.destroy();
|
|
1808
|
+
this.bubble = null;
|
|
1809
|
+
}
|
|
1810
|
+
if (this.panel) {
|
|
1811
|
+
this.panel.destroy();
|
|
1812
|
+
this.panel = null;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
var ChatWidget$1 = /*#__PURE__*/Object.freeze({
|
|
1818
|
+
__proto__: null,
|
|
1819
|
+
ChatWidget: ChatWidget
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Form renderer
|
|
1824
|
+
*
|
|
1825
|
+
* Handles rendering form HTML from form definition
|
|
1826
|
+
*/
|
|
1827
|
+
/**
|
|
1828
|
+
* Form renderer class
|
|
1829
|
+
*/
|
|
1830
|
+
class FormRenderer {
|
|
1831
|
+
constructor(styles) {
|
|
1832
|
+
this.styles = styles;
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* Render form HTML
|
|
1836
|
+
*/
|
|
1837
|
+
render(formData, theme) {
|
|
1838
|
+
const colors = this.styles.getColors(theme);
|
|
1839
|
+
const fields = formData.fields;
|
|
1840
|
+
let html = '<form class="wabbit-form" data-wabbit-form-instance="true">';
|
|
1841
|
+
fields.forEach((field) => {
|
|
1842
|
+
html += this.renderField(field, colors);
|
|
1843
|
+
});
|
|
1844
|
+
html += `<button
|
|
1845
|
+
type="submit"
|
|
1846
|
+
class="wabbit-submit"
|
|
1847
|
+
>Submit</button>`;
|
|
1848
|
+
html += `<div class="wabbit-message" style="display: none;"></div>`;
|
|
1849
|
+
html += '</form>';
|
|
1850
|
+
return html;
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* Render a single field
|
|
1854
|
+
*/
|
|
1855
|
+
renderField(field, colors) {
|
|
1856
|
+
let html = '<div class="wabbit-field">';
|
|
1857
|
+
// Generate input ID for accessibility (for text, email, number, textarea, select)
|
|
1858
|
+
const needsLabelFor = ['text', 'email', 'number', 'textarea', 'select'].includes(field.type);
|
|
1859
|
+
const inputId = needsLabelFor ? `wabbit-input-${field.id}` : undefined;
|
|
1860
|
+
const labelForAttr = inputId ? ` for="${escapeHtml(inputId)}"` : '';
|
|
1861
|
+
// Use inline style to ensure label color is applied correctly
|
|
1862
|
+
// This matches the legacy embed-form.js behavior
|
|
1863
|
+
// Use !important to ensure color is not overridden by external CSS
|
|
1864
|
+
html += `<label${labelForAttr} style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: ${colors.labelColor} !important; font-size: 0.875rem;">${escapeHtml(field.label)}${field.required ? ' <span style="color: #E07A5F;">*</span>' : ''}</label>`;
|
|
1865
|
+
switch (field.type) {
|
|
1866
|
+
case 'text':
|
|
1867
|
+
case 'email':
|
|
1868
|
+
case 'number':
|
|
1869
|
+
if (inputId) {
|
|
1870
|
+
html += this.renderTextInput(field, colors, inputId);
|
|
1871
|
+
}
|
|
1872
|
+
break;
|
|
1873
|
+
case 'textarea':
|
|
1874
|
+
if (inputId) {
|
|
1875
|
+
html += this.renderTextarea(field, colors, inputId);
|
|
1876
|
+
}
|
|
1877
|
+
break;
|
|
1878
|
+
case 'select':
|
|
1879
|
+
if (inputId) {
|
|
1880
|
+
html += this.renderSelect(field, colors, inputId);
|
|
1881
|
+
}
|
|
1882
|
+
break;
|
|
1883
|
+
case 'radio':
|
|
1884
|
+
html += this.renderRadio(field, colors);
|
|
1885
|
+
break;
|
|
1886
|
+
case 'checkbox':
|
|
1887
|
+
html += this.renderCheckbox(field, colors);
|
|
1888
|
+
break;
|
|
1889
|
+
case 'rating':
|
|
1890
|
+
html += this.renderRating(field, colors);
|
|
1891
|
+
break;
|
|
1892
|
+
}
|
|
1893
|
+
html += '</div>';
|
|
1894
|
+
return html;
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Render text input field
|
|
1898
|
+
*/
|
|
1899
|
+
renderTextInput(field, _colors, inputId) {
|
|
1900
|
+
return `<input
|
|
1901
|
+
type="${field.type}"
|
|
1902
|
+
id="${escapeHtml(inputId)}"
|
|
1903
|
+
name="${escapeHtml(field.id)}"
|
|
1904
|
+
placeholder="${field.placeholder ? escapeHtml(field.placeholder) : ''}"
|
|
1905
|
+
${field.required ? 'required' : ''}
|
|
1906
|
+
/>`;
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* Render textarea field
|
|
1910
|
+
*/
|
|
1911
|
+
renderTextarea(field, _colors, inputId) {
|
|
1912
|
+
return `<textarea
|
|
1913
|
+
id="${escapeHtml(inputId)}"
|
|
1914
|
+
name="${escapeHtml(field.id)}"
|
|
1915
|
+
placeholder="${field.placeholder ? escapeHtml(field.placeholder) : ''}"
|
|
1916
|
+
${field.required ? 'required' : ''}
|
|
1917
|
+
rows="4"
|
|
1918
|
+
></textarea>`;
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Render select field
|
|
1922
|
+
*/
|
|
1923
|
+
renderSelect(field, _colors, inputId) {
|
|
1924
|
+
if (!field.options || field.options.length === 0) {
|
|
1925
|
+
return '';
|
|
1926
|
+
}
|
|
1927
|
+
let html = `<select id="${escapeHtml(inputId)}" name="${escapeHtml(field.id)}" ${field.required ? 'required' : ''}>`;
|
|
1928
|
+
html += '<option value="">Select...</option>';
|
|
1929
|
+
field.options.forEach((opt) => {
|
|
1930
|
+
html += `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`;
|
|
1931
|
+
});
|
|
1932
|
+
html += '</select>';
|
|
1933
|
+
return html;
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Render radio group
|
|
1937
|
+
*/
|
|
1938
|
+
renderRadio(field, _colors) {
|
|
1939
|
+
if (!field.options || field.options.length === 0) {
|
|
1940
|
+
return '';
|
|
1941
|
+
}
|
|
1942
|
+
let html = '<div class="wabbit-radio-group">';
|
|
1943
|
+
field.options.forEach((opt, idx) => {
|
|
1944
|
+
const radioId = `${field.id}_${idx}`;
|
|
1945
|
+
html += `<div>
|
|
1946
|
+
<label>
|
|
1947
|
+
<input
|
|
1948
|
+
type="radio"
|
|
1949
|
+
name="${escapeHtml(field.id)}"
|
|
1950
|
+
value="${escapeHtml(opt)}"
|
|
1951
|
+
id="${escapeHtml(radioId)}"
|
|
1952
|
+
${field.required && idx === 0 ? 'required' : ''}
|
|
1953
|
+
/>
|
|
1954
|
+
<span>${escapeHtml(opt)}</span>
|
|
1955
|
+
</label>
|
|
1956
|
+
</div>`;
|
|
1957
|
+
});
|
|
1958
|
+
html += '</div>';
|
|
1959
|
+
return html;
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Render checkbox group
|
|
1963
|
+
*/
|
|
1964
|
+
renderCheckbox(field, _colors) {
|
|
1965
|
+
if (!field.options || field.options.length === 0) {
|
|
1966
|
+
return '';
|
|
1967
|
+
}
|
|
1968
|
+
let html = '<div class="wabbit-checkbox-group">';
|
|
1969
|
+
field.options.forEach((opt, idx) => {
|
|
1970
|
+
const checkboxId = `${field.id}_${idx}`;
|
|
1971
|
+
html += `<div>
|
|
1972
|
+
<label>
|
|
1973
|
+
<input
|
|
1974
|
+
type="checkbox"
|
|
1975
|
+
name="${escapeHtml(field.id)}[]"
|
|
1976
|
+
value="${escapeHtml(opt)}"
|
|
1977
|
+
id="${escapeHtml(checkboxId)}"
|
|
1978
|
+
/>
|
|
1979
|
+
<span>${escapeHtml(opt)}</span>
|
|
1980
|
+
</label>
|
|
1981
|
+
</div>`;
|
|
1982
|
+
});
|
|
1983
|
+
html += '</div>';
|
|
1984
|
+
return html;
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Render rating field
|
|
1988
|
+
*/
|
|
1989
|
+
renderRating(field, _colors) {
|
|
1990
|
+
const max = field.max || 5;
|
|
1991
|
+
let html = '<div class="wabbit-rating">';
|
|
1992
|
+
for (let i = 1; i <= max; i++) {
|
|
1993
|
+
const starId = `${field.id}_${i}`;
|
|
1994
|
+
html += `<input
|
|
1995
|
+
type="radio"
|
|
1996
|
+
name="${escapeHtml(field.id)}"
|
|
1997
|
+
value="${i}"
|
|
1998
|
+
id="${escapeHtml(starId)}"
|
|
1999
|
+
${field.required && i === 1 ? 'required' : ''}
|
|
2000
|
+
/>`;
|
|
2001
|
+
html += `<label for="${escapeHtml(starId)}">★</label>`;
|
|
2002
|
+
}
|
|
2003
|
+
html += '</div>';
|
|
2004
|
+
return html;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* Form styles management
|
|
2010
|
+
*
|
|
2011
|
+
* Handles theme-aware styling and dynamic style injection
|
|
2012
|
+
*/
|
|
2013
|
+
/**
|
|
2014
|
+
* Form styles manager
|
|
2015
|
+
*/
|
|
2016
|
+
class FormStyles {
|
|
2017
|
+
constructor(primaryColor, containerId) {
|
|
2018
|
+
this.styleElement = null;
|
|
2019
|
+
this.currentTheme = 'light';
|
|
2020
|
+
// Note: primaryColor is now handled via CSS variables set on container
|
|
2021
|
+
// We keep a reference for fallback/legacy support
|
|
2022
|
+
this._primaryColor = '#C15F3C';
|
|
2023
|
+
/**
|
|
2024
|
+
* Theme color schemes
|
|
2025
|
+
*/
|
|
2026
|
+
this.themes = {
|
|
2027
|
+
dark: {
|
|
2028
|
+
formBg: '#1A1816', // Dark base background (matching chat widget)
|
|
2029
|
+
labelColor: '#F5F1ED',
|
|
2030
|
+
inputBg: '#2D2826', // Elevated surface (matching chat widget bg-elevated)
|
|
2031
|
+
inputText: '#F5F1ED',
|
|
2032
|
+
inputBorder: '#3D3935',
|
|
2033
|
+
inputBorderFocus: '#C15F3C',
|
|
2034
|
+
placeholderColor: '#6B6560',
|
|
2035
|
+
radioColor: '#A39C94',
|
|
2036
|
+
ratingInactive: '#3D3935',
|
|
2037
|
+
errorBg: 'rgba(224, 122, 95, 0.1)',
|
|
2038
|
+
errorColor: '#E07A5F',
|
|
2039
|
+
errorBorder: '#E07A5F',
|
|
2040
|
+
successBg: 'rgba(132, 169, 140, 0.1)',
|
|
2041
|
+
successColor: '#84A98C',
|
|
2042
|
+
successBorder: '#84A98C',
|
|
2043
|
+
buttonBg: '#C15F3C',
|
|
2044
|
+
buttonBgHover: '#AB4E2F',
|
|
2045
|
+
},
|
|
2046
|
+
light: {
|
|
2047
|
+
formBg: '#FAF8F6', // Light base background (matching chat widget)
|
|
2048
|
+
labelColor: '#2D2826', // Darker color for better readability (matching legacy embed-form.js)
|
|
2049
|
+
inputBg: '#FFFFFF',
|
|
2050
|
+
inputText: '#2D2826',
|
|
2051
|
+
inputBorder: '#D4CFCA',
|
|
2052
|
+
inputBorderFocus: '#C15F3C',
|
|
2053
|
+
placeholderColor: '#8A847E',
|
|
2054
|
+
radioColor: '#5A5450',
|
|
2055
|
+
ratingInactive: '#D4CFCA',
|
|
2056
|
+
errorBg: 'rgba(193, 84, 56, 0.1)',
|
|
2057
|
+
errorColor: '#C15438',
|
|
2058
|
+
errorBorder: '#C15438',
|
|
2059
|
+
successBg: 'rgba(92, 122, 99, 0.1)',
|
|
2060
|
+
successColor: '#5C7A63',
|
|
2061
|
+
successBorder: '#5C7A63',
|
|
2062
|
+
buttonBg: '#C15F3C',
|
|
2063
|
+
buttonBgHover: '#AB4E2F',
|
|
2064
|
+
},
|
|
2065
|
+
};
|
|
2066
|
+
// Generate unique style ID based on container ID or random
|
|
2067
|
+
this.styleId = containerId
|
|
2068
|
+
? `wabbit-form-styles-${containerId.replace(/[^a-zA-Z0-9]/g, '-')}`
|
|
2069
|
+
: `wabbit-form-styles-${Math.random().toString(36).substr(2, 9)}`;
|
|
2070
|
+
if (primaryColor) {
|
|
2071
|
+
this._primaryColor = primaryColor;
|
|
2072
|
+
// Update focus colors to use primary color (for fallback)
|
|
2073
|
+
this.themes.dark.inputBorderFocus = primaryColor;
|
|
2074
|
+
this.themes.light.inputBorderFocus = primaryColor;
|
|
2075
|
+
this.themes.dark.buttonBg = primaryColor;
|
|
2076
|
+
this.themes.light.buttonBg = primaryColor;
|
|
2077
|
+
// Calculate hover color (darker by 10%)
|
|
2078
|
+
const hoverColor = this.darkenColor(primaryColor, 10);
|
|
2079
|
+
this.themes.dark.buttonBgHover = hoverColor;
|
|
2080
|
+
this.themes.light.buttonBgHover = hoverColor;
|
|
2081
|
+
}
|
|
2082
|
+
this.injectStyles();
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Get current theme colors
|
|
2086
|
+
*/
|
|
2087
|
+
getColors(theme) {
|
|
2088
|
+
const effectiveTheme = theme || this.currentTheme;
|
|
2089
|
+
return this.themes[effectiveTheme];
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Update theme
|
|
2093
|
+
*/
|
|
2094
|
+
updateTheme(theme) {
|
|
2095
|
+
this.currentTheme = theme;
|
|
2096
|
+
this.injectStyles();
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* Update primary color
|
|
2100
|
+
*/
|
|
2101
|
+
updatePrimaryColor(color) {
|
|
2102
|
+
this._primaryColor = color;
|
|
2103
|
+
this.themes.dark.inputBorderFocus = color;
|
|
2104
|
+
this.themes.light.inputBorderFocus = color;
|
|
2105
|
+
this.themes.dark.buttonBg = color;
|
|
2106
|
+
this.themes.light.buttonBg = color;
|
|
2107
|
+
const hoverColor = this.darkenColor(color, 10);
|
|
2108
|
+
this.themes.dark.buttonBgHover = hoverColor;
|
|
2109
|
+
this.themes.light.buttonBgHover = hoverColor;
|
|
2110
|
+
this.injectStyles();
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* Get primary color (for reference)
|
|
2114
|
+
*/
|
|
2115
|
+
getPrimaryColor() {
|
|
2116
|
+
return this._primaryColor;
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Darken a color by a percentage
|
|
2120
|
+
*/
|
|
2121
|
+
darkenColor(color, percent) {
|
|
2122
|
+
// Simple darken for hex colors
|
|
2123
|
+
if (color.startsWith('#')) {
|
|
2124
|
+
const num = parseInt(color.slice(1), 16);
|
|
2125
|
+
const r = Math.max(0, Math.floor((num >> 16) * (1 - percent / 100)));
|
|
2126
|
+
const g = Math.max(0, Math.floor(((num >> 8) & 0x00FF) * (1 - percent / 100)));
|
|
2127
|
+
const b = Math.max(0, Math.floor((num & 0x0000FF) * (1 - percent / 100)));
|
|
2128
|
+
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
|
2129
|
+
}
|
|
2130
|
+
return color;
|
|
2131
|
+
}
|
|
2132
|
+
/**
|
|
2133
|
+
* Inject styles into document
|
|
2134
|
+
*/
|
|
2135
|
+
injectStyles() {
|
|
2136
|
+
// Check if style element already exists (for this instance)
|
|
2137
|
+
this.styleElement = document.getElementById(this.styleId);
|
|
2138
|
+
if (!this.styleElement) {
|
|
2139
|
+
this.styleElement = document.createElement('style');
|
|
2140
|
+
this.styleElement.id = this.styleId;
|
|
2141
|
+
this.styleElement.setAttribute('data-wabbit', 'form-styles');
|
|
2142
|
+
document.head.appendChild(this.styleElement);
|
|
2143
|
+
}
|
|
2144
|
+
const colors = this.getColors();
|
|
2145
|
+
const css = this.generateCSS(colors);
|
|
2146
|
+
this.styleElement.textContent = css;
|
|
2147
|
+
}
|
|
2148
|
+
/**
|
|
2149
|
+
* Generate CSS for form styles
|
|
2150
|
+
*/
|
|
2151
|
+
generateCSS(colors) {
|
|
2152
|
+
return `
|
|
2153
|
+
/* Wabbit Form Styles */
|
|
2154
|
+
.wabbit-form {
|
|
2155
|
+
max-width: 600px;
|
|
2156
|
+
margin: 0 auto;
|
|
2157
|
+
font-family: Inter, system-ui, sans-serif;
|
|
2158
|
+
background-color: ${colors.formBg};
|
|
2159
|
+
padding: 1.5rem;
|
|
2160
|
+
border-radius: 0.5rem;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
.wabbit-field {
|
|
2164
|
+
margin-bottom: 1.5rem;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
/* Label color is set via inline style in FormRenderer for theme-aware rendering */
|
|
2168
|
+
/* This ensures the correct theme color is applied at render time */
|
|
2169
|
+
|
|
2170
|
+
.wabbit-field input[type="text"],
|
|
2171
|
+
.wabbit-field input[type="email"],
|
|
2172
|
+
.wabbit-field input[type="number"],
|
|
2173
|
+
.wabbit-field textarea,
|
|
2174
|
+
.wabbit-field select {
|
|
2175
|
+
width: 100%;
|
|
2176
|
+
padding: 0.75rem 1rem;
|
|
2177
|
+
border: 1px solid ${colors.inputBorder};
|
|
2178
|
+
border-radius: 0.5rem;
|
|
2179
|
+
font-size: 1rem;
|
|
2180
|
+
background-color: ${colors.inputBg};
|
|
2181
|
+
color: ${colors.inputText};
|
|
2182
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
2183
|
+
box-sizing: border-box;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
.wabbit-field input[type="text"]:focus,
|
|
2187
|
+
.wabbit-field input[type="email"]:focus,
|
|
2188
|
+
.wabbit-field input[type="number"]:focus,
|
|
2189
|
+
.wabbit-field textarea:focus,
|
|
2190
|
+
.wabbit-field select:focus {
|
|
2191
|
+
outline: none;
|
|
2192
|
+
border-color: var(--wabbit-primary-color);
|
|
2193
|
+
box-shadow: 0 0 0 3px rgba(193, 95, 60, 0.1);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
.wabbit-field input::placeholder,
|
|
2197
|
+
.wabbit-field textarea::placeholder {
|
|
2198
|
+
color: ${colors.placeholderColor};
|
|
2199
|
+
opacity: 1;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
.wabbit-field textarea {
|
|
2203
|
+
resize: vertical;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
.wabbit-field .wabbit-radio-group,
|
|
2207
|
+
.wabbit-field .wabbit-checkbox-group {
|
|
2208
|
+
margin-bottom: 0.5rem;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
.wabbit-field .wabbit-radio-group label,
|
|
2212
|
+
.wabbit-field .wabbit-checkbox-group label {
|
|
2213
|
+
display: flex;
|
|
2214
|
+
align-items: center;
|
|
2215
|
+
cursor: pointer;
|
|
2216
|
+
color: ${colors.radioColor};
|
|
2217
|
+
margin-bottom: 0.5rem;
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
.wabbit-field input[type="radio"],
|
|
2221
|
+
.wabbit-field input[type="checkbox"] {
|
|
2222
|
+
margin-right: 0.5rem;
|
|
2223
|
+
accent-color: var(--wabbit-primary-color);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
.wabbit-rating {
|
|
2227
|
+
display: flex;
|
|
2228
|
+
gap: 0.25rem;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
.wabbit-rating input[type="radio"] {
|
|
2232
|
+
display: none;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
.wabbit-rating label {
|
|
2236
|
+
cursor: pointer;
|
|
2237
|
+
font-size: 1.5rem;
|
|
2238
|
+
color: ${colors.ratingInactive};
|
|
2239
|
+
transition: color 0.2s;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
.wabbit-rating label:hover {
|
|
2243
|
+
color: var(--wabbit-primary-color);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
.wabbit-rating input[type="radio"]:checked ~ label,
|
|
2247
|
+
.wabbit-rating label:has(+ input[type="radio"]:checked) {
|
|
2248
|
+
color: var(--wabbit-primary-color);
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
.wabbit-submit {
|
|
2252
|
+
width: 100%;
|
|
2253
|
+
padding: 0.75rem 1.5rem;
|
|
2254
|
+
background-color: var(--wabbit-primary-color);
|
|
2255
|
+
color: white;
|
|
2256
|
+
border: none;
|
|
2257
|
+
border-radius: 0.5rem;
|
|
2258
|
+
font-size: 1rem;
|
|
2259
|
+
font-weight: 600;
|
|
2260
|
+
cursor: pointer;
|
|
2261
|
+
transition: background-color 0.2s, transform 0.15s;
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
.wabbit-submit:hover:not(:disabled) {
|
|
2265
|
+
background-color: var(--wabbit-primary-color-hover);
|
|
2266
|
+
transform: translateY(-1px);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
.wabbit-submit:disabled {
|
|
2270
|
+
opacity: 0.6;
|
|
2271
|
+
cursor: not-allowed;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
.wabbit-message {
|
|
2275
|
+
display: none;
|
|
2276
|
+
margin-top: 1rem;
|
|
2277
|
+
padding: 0.75rem;
|
|
2278
|
+
border-radius: 0.5rem;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
.wabbit-message.wabbit-message-success {
|
|
2282
|
+
background-color: ${colors.successBg};
|
|
2283
|
+
color: ${colors.successColor};
|
|
2284
|
+
border: 1px solid ${colors.successBorder};
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
.wabbit-message.wabbit-message-error {
|
|
2288
|
+
background-color: ${colors.errorBg};
|
|
2289
|
+
color: ${colors.errorColor};
|
|
2290
|
+
border: 1px solid ${colors.errorBorder};
|
|
2291
|
+
}
|
|
2292
|
+
`;
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Cleanup styles
|
|
2296
|
+
*/
|
|
2297
|
+
destroy() {
|
|
2298
|
+
if (this.styleElement) {
|
|
2299
|
+
this.styleElement.remove();
|
|
2300
|
+
this.styleElement = null;
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
/**
|
|
2306
|
+
* API client for making HTTP requests
|
|
2307
|
+
*/
|
|
2308
|
+
/**
|
|
2309
|
+
* API client class
|
|
2310
|
+
*/
|
|
2311
|
+
class ApiClient {
|
|
2312
|
+
constructor(options) {
|
|
2313
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
2314
|
+
this.apiKey = options.apiKey;
|
|
2315
|
+
this.timeout = options.timeout || 30000;
|
|
2316
|
+
}
|
|
2317
|
+
/**
|
|
2318
|
+
* Make a GET request
|
|
2319
|
+
*
|
|
2320
|
+
* @param endpoint - API endpoint path
|
|
2321
|
+
* @param options - Request options (e.g., requireAuth)
|
|
2322
|
+
*/
|
|
2323
|
+
async get(endpoint, options) {
|
|
2324
|
+
return this.request('GET', endpoint, undefined, options);
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Make a POST request
|
|
2328
|
+
*
|
|
2329
|
+
* @param endpoint - API endpoint path
|
|
2330
|
+
* @param data - Request body data
|
|
2331
|
+
* @param options - Request options (e.g., requireAuth)
|
|
2332
|
+
*/
|
|
2333
|
+
async post(endpoint, data, options) {
|
|
2334
|
+
return this.request('POST', endpoint, data, options);
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Make a request
|
|
2338
|
+
*/
|
|
2339
|
+
async request(method, endpoint, data, requestOptions) {
|
|
2340
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
2341
|
+
// Build headers
|
|
2342
|
+
const headers = {
|
|
2343
|
+
'Content-Type': 'application/json',
|
|
2344
|
+
};
|
|
2345
|
+
// Add API key if authentication is required (default: true)
|
|
2346
|
+
// Public endpoints (like form definitions) should set requireAuth: false
|
|
2347
|
+
const requireAuth = requestOptions?.requireAuth !== false; // Default to true
|
|
2348
|
+
if (requireAuth && this.apiKey) {
|
|
2349
|
+
headers['X-API-Key'] = this.apiKey;
|
|
2350
|
+
}
|
|
2351
|
+
// Create abort controller for timeout (more compatible than AbortSignal.timeout)
|
|
2352
|
+
const controller = new AbortController();
|
|
2353
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
2354
|
+
const fetchOptions = {
|
|
2355
|
+
method,
|
|
2356
|
+
headers,
|
|
2357
|
+
mode: 'cors', // Explicitly set CORS mode like the old embed-form.js
|
|
2358
|
+
signal: controller.signal
|
|
2359
|
+
};
|
|
2360
|
+
if (data && method !== 'GET') {
|
|
2361
|
+
fetchOptions.body = JSON.stringify(data);
|
|
2362
|
+
}
|
|
2363
|
+
try {
|
|
2364
|
+
const response = await fetch(url, fetchOptions);
|
|
2365
|
+
// Clear timeout on successful response
|
|
2366
|
+
clearTimeout(timeoutId);
|
|
2367
|
+
if (!response.ok) {
|
|
2368
|
+
let errorText;
|
|
2369
|
+
try {
|
|
2370
|
+
const errorJson = await response.json();
|
|
2371
|
+
// Handle Next.js API route error format
|
|
2372
|
+
if (errorJson.detail) {
|
|
2373
|
+
errorText = errorJson.detail;
|
|
2374
|
+
}
|
|
2375
|
+
else if (errorJson.error) {
|
|
2376
|
+
errorText = errorJson.error;
|
|
2377
|
+
}
|
|
2378
|
+
else if (errorJson.message) {
|
|
2379
|
+
errorText = errorJson.message;
|
|
2380
|
+
}
|
|
2381
|
+
else {
|
|
2382
|
+
errorText = JSON.stringify(errorJson);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
catch {
|
|
2386
|
+
const text = await response.text();
|
|
2387
|
+
// Try to extract meaningful error message from text
|
|
2388
|
+
if (text) {
|
|
2389
|
+
errorText = text;
|
|
2390
|
+
}
|
|
2391
|
+
else {
|
|
2392
|
+
// Fallback to status text only (no HTTP status code)
|
|
2393
|
+
errorText = response.statusText || 'An error occurred';
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
return {
|
|
2397
|
+
success: false,
|
|
2398
|
+
error: errorText // Return only the error message, not HTTP status code
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
const result = await response.json();
|
|
2402
|
+
return {
|
|
2403
|
+
success: true,
|
|
2404
|
+
data: result
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
catch (error) {
|
|
2408
|
+
clearTimeout(timeoutId);
|
|
2409
|
+
// Handle abort (timeout)
|
|
2410
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
2411
|
+
return {
|
|
2412
|
+
success: false,
|
|
2413
|
+
error: `Request timeout after ${this.timeout}ms`
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
if (error instanceof TypeError && error.message.includes('CORS')) {
|
|
2417
|
+
return {
|
|
2418
|
+
success: false,
|
|
2419
|
+
error: 'CORS error: Please check server configuration'
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
return {
|
|
2423
|
+
success: false,
|
|
2424
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
/**
|
|
2431
|
+
* Form Widget
|
|
2432
|
+
*
|
|
2433
|
+
* Main class for managing form widgets
|
|
2434
|
+
*/
|
|
2435
|
+
/**
|
|
2436
|
+
* Form Widget class
|
|
2437
|
+
*/
|
|
2438
|
+
class FormWidget {
|
|
2439
|
+
constructor(config) {
|
|
2440
|
+
this.container = null;
|
|
2441
|
+
this.formElement = null;
|
|
2442
|
+
this.currentTheme = 'light';
|
|
2443
|
+
this.themeCleanup = null;
|
|
2444
|
+
this.formObserver = null;
|
|
2445
|
+
this.config = config;
|
|
2446
|
+
// Validate apiKey (required for API client)
|
|
2447
|
+
if (!config.apiKey) {
|
|
2448
|
+
throw new Error('FormWidget requires apiKey. Provide it in FormConfig or WabbitConfig.');
|
|
2449
|
+
}
|
|
2450
|
+
// Initialize API client
|
|
2451
|
+
const apiUrl = config.apiUrl || this.getApiUrlFromScript();
|
|
2452
|
+
this.apiClient = new ApiClient({
|
|
2453
|
+
baseUrl: apiUrl,
|
|
2454
|
+
apiKey: config.apiKey,
|
|
2455
|
+
});
|
|
2456
|
+
// Initialize styles and renderer
|
|
2457
|
+
// Pass containerId to FormStyles to generate unique style ID
|
|
2458
|
+
this.styles = new FormStyles(config.primaryColor, config.containerId);
|
|
2459
|
+
this.renderer = new FormRenderer(this.styles);
|
|
2460
|
+
}
|
|
2461
|
+
/**
|
|
2462
|
+
* Initialize the form widget
|
|
2463
|
+
*/
|
|
2464
|
+
init() {
|
|
2465
|
+
onDOMReady(() => {
|
|
2466
|
+
// Determine initial theme based on config
|
|
2467
|
+
const themeMode = this.config.theme || 'auto';
|
|
2468
|
+
if (themeMode === 'auto') {
|
|
2469
|
+
this.currentTheme = detectTheme();
|
|
2470
|
+
}
|
|
2471
|
+
else {
|
|
2472
|
+
this.currentTheme = themeMode;
|
|
2473
|
+
}
|
|
2474
|
+
// Update FormStyles theme to match determined theme
|
|
2475
|
+
this.styles.updateTheme(this.currentTheme);
|
|
2476
|
+
// Setup theme watcher (will handle auto mode or explicit theme)
|
|
2477
|
+
this.setupThemeWatcher();
|
|
2478
|
+
// Load and render form
|
|
2479
|
+
this.loadForm();
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Load and render form
|
|
2484
|
+
*/
|
|
2485
|
+
async loadForm() {
|
|
2486
|
+
try {
|
|
2487
|
+
// Find container
|
|
2488
|
+
this.container = this.findContainer();
|
|
2489
|
+
if (!this.container) {
|
|
2490
|
+
throw new Error(`Container not found: ${this.config.containerId || 'data-wabbit-form-id'}`);
|
|
2491
|
+
}
|
|
2492
|
+
// Load form definition (public endpoint, no auth required)
|
|
2493
|
+
const response = await this.apiClient.get(`/api/forms/${this.config.formId}/definition`, { requireAuth: false });
|
|
2494
|
+
if (!response.success || !response.data) {
|
|
2495
|
+
throw new Error(response.error || 'Failed to load form definition');
|
|
2496
|
+
}
|
|
2497
|
+
const formData = response.data;
|
|
2498
|
+
// Render form
|
|
2499
|
+
const html = this.renderer.render(formData, this.currentTheme);
|
|
2500
|
+
this.container.innerHTML = html;
|
|
2501
|
+
// Set CSS variables on container for scoped styling
|
|
2502
|
+
// This allows each form container to have its own primary color
|
|
2503
|
+
// Always set the variables, even if primaryColor is not provided (use default from FormStyles)
|
|
2504
|
+
const primaryColor = this.config.primaryColor || '#C15F3C'; // Default primary color
|
|
2505
|
+
this.container.style.setProperty('--wabbit-primary-color', primaryColor);
|
|
2506
|
+
// Calculate hover color (darker by 10%)
|
|
2507
|
+
const hoverColor = this.calculateHoverColor(primaryColor);
|
|
2508
|
+
this.container.style.setProperty('--wabbit-primary-color-hover', hoverColor);
|
|
2509
|
+
// Debug: Log the color being set (remove in production)
|
|
2510
|
+
console.log(`[Wabbit] Form in container "${this.config.containerId}" using primary color: ${primaryColor}`);
|
|
2511
|
+
// Find form element
|
|
2512
|
+
this.formElement = this.container.querySelector('form');
|
|
2513
|
+
if (!this.formElement) {
|
|
2514
|
+
throw new Error('Form element not found after rendering');
|
|
2515
|
+
}
|
|
2516
|
+
// Attach submit handler
|
|
2517
|
+
this.attachSubmitHandler(formData.settings.successMessage);
|
|
2518
|
+
// Setup rating field interactions
|
|
2519
|
+
this.setupRatingInteractions();
|
|
2520
|
+
// Call onLoad callback
|
|
2521
|
+
if (this.config.onLoad) {
|
|
2522
|
+
this.config.onLoad();
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
catch (error) {
|
|
2526
|
+
console.error('[Wabbit] Form load error:', error);
|
|
2527
|
+
this.showError(error instanceof Error ? error.message : 'Failed to load form');
|
|
2528
|
+
if (this.config.onError) {
|
|
2529
|
+
this.config.onError(error instanceof Error ? error : new Error(String(error)));
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
/**
|
|
2534
|
+
* Find container element
|
|
2535
|
+
*/
|
|
2536
|
+
findContainer() {
|
|
2537
|
+
// Priority 1: containerId from config
|
|
2538
|
+
if (this.config.containerId) {
|
|
2539
|
+
const element = document.getElementById(this.config.containerId) || document.querySelector(this.config.containerId);
|
|
2540
|
+
if (element) {
|
|
2541
|
+
return element;
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
// Priority 2: data-wabbit-form-id attribute (backward compatibility)
|
|
2545
|
+
const dataAttrContainer = document.querySelector(`[data-wabbit-form-id="${this.config.formId}"]`);
|
|
2546
|
+
if (dataAttrContainer) {
|
|
2547
|
+
return dataAttrContainer;
|
|
2548
|
+
}
|
|
2549
|
+
return null;
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* Attach form submit handler
|
|
2553
|
+
*/
|
|
2554
|
+
attachSubmitHandler(successMessage) {
|
|
2555
|
+
if (!this.formElement)
|
|
2556
|
+
return;
|
|
2557
|
+
this.formElement.addEventListener('submit', async (e) => {
|
|
2558
|
+
e.preventDefault();
|
|
2559
|
+
await this.handleSubmit(successMessage);
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
/**
|
|
2563
|
+
* Setup rating field interactions
|
|
2564
|
+
*/
|
|
2565
|
+
setupRatingInteractions() {
|
|
2566
|
+
if (!this.formElement)
|
|
2567
|
+
return;
|
|
2568
|
+
const ratingInputs = this.formElement.querySelectorAll('.wabbit-rating input[type="radio"]');
|
|
2569
|
+
ratingInputs.forEach((input) => {
|
|
2570
|
+
const label = this.formElement.querySelector(`label[for="${input.id}"]`);
|
|
2571
|
+
if (!label)
|
|
2572
|
+
return;
|
|
2573
|
+
const inputElement = input;
|
|
2574
|
+
// Update star colors when clicked
|
|
2575
|
+
inputElement.addEventListener('change', () => {
|
|
2576
|
+
this.updateRatingStars(inputElement);
|
|
2577
|
+
});
|
|
2578
|
+
// Hover effect
|
|
2579
|
+
label.addEventListener('mouseenter', () => {
|
|
2580
|
+
const colors = this.styles.getColors(this.currentTheme);
|
|
2581
|
+
label.style.color = colors.inputBorderFocus;
|
|
2582
|
+
});
|
|
2583
|
+
label.addEventListener('mouseleave', () => {
|
|
2584
|
+
this.updateRatingStars(inputElement);
|
|
2585
|
+
});
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Update rating star colors
|
|
2590
|
+
*/
|
|
2591
|
+
updateRatingStars(checkedInput) {
|
|
2592
|
+
if (!this.formElement)
|
|
2593
|
+
return;
|
|
2594
|
+
const fieldName = checkedInput.name;
|
|
2595
|
+
const colors = this.styles.getColors(this.currentTheme);
|
|
2596
|
+
const allInputs = this.formElement.querySelectorAll(`input[name="${fieldName}"]`);
|
|
2597
|
+
const allLabels = this.formElement.querySelectorAll(`label[for^="${fieldName}_"]`);
|
|
2598
|
+
allInputs.forEach((input) => {
|
|
2599
|
+
const label = Array.from(allLabels).find((l) => l.getAttribute('for') === input.id);
|
|
2600
|
+
if (label) {
|
|
2601
|
+
if (input.checked) {
|
|
2602
|
+
label.style.color = colors.inputBorderFocus;
|
|
2603
|
+
}
|
|
2604
|
+
else {
|
|
2605
|
+
label.style.color = colors.ratingInactive;
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Handle form submission
|
|
2612
|
+
*/
|
|
2613
|
+
async handleSubmit(successMessage) {
|
|
2614
|
+
if (!this.formElement)
|
|
2615
|
+
return;
|
|
2616
|
+
const submitButton = this.formElement.querySelector('.wabbit-submit');
|
|
2617
|
+
const messageDiv = this.formElement.querySelector('.wabbit-message');
|
|
2618
|
+
if (!submitButton || !messageDiv)
|
|
2619
|
+
return;
|
|
2620
|
+
// Disable submit button
|
|
2621
|
+
submitButton.disabled = true;
|
|
2622
|
+
submitButton.textContent = 'Submitting...';
|
|
2623
|
+
// Hide previous messages
|
|
2624
|
+
messageDiv.style.display = 'none';
|
|
2625
|
+
messageDiv.className = 'wabbit-message';
|
|
2626
|
+
try {
|
|
2627
|
+
// Collect form data
|
|
2628
|
+
const formData = new FormData(this.formElement);
|
|
2629
|
+
const responses = {};
|
|
2630
|
+
for (const [key, value] of formData.entries()) {
|
|
2631
|
+
if (key.endsWith('[]')) {
|
|
2632
|
+
// Checkbox group
|
|
2633
|
+
const fieldId = key.replace('[]', '');
|
|
2634
|
+
if (!responses[fieldId]) {
|
|
2635
|
+
responses[fieldId] = [];
|
|
2636
|
+
}
|
|
2637
|
+
responses[fieldId].push(value);
|
|
2638
|
+
}
|
|
2639
|
+
else {
|
|
2640
|
+
responses[key] = value;
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
// Submit form (public endpoint, no auth required)
|
|
2644
|
+
const response = await this.apiClient.post(`/api/forms/${this.config.formId}/submit`, {
|
|
2645
|
+
responses,
|
|
2646
|
+
metadata: {
|
|
2647
|
+
referrer: document.referrer,
|
|
2648
|
+
userAgent: navigator.userAgent,
|
|
2649
|
+
},
|
|
2650
|
+
}, { requireAuth: false });
|
|
2651
|
+
if (response.success && response.data?.success) {
|
|
2652
|
+
// Success
|
|
2653
|
+
const message = response.data.message || successMessage || 'Thank you! Your submission has been received.';
|
|
2654
|
+
this.showSuccess(message, messageDiv);
|
|
2655
|
+
this.formElement.reset();
|
|
2656
|
+
// Call onSubmit callback
|
|
2657
|
+
if (this.config.onSubmit) {
|
|
2658
|
+
this.config.onSubmit(responses, response.data.submissionId);
|
|
2659
|
+
}
|
|
2660
|
+
// Re-enable button after 2 seconds
|
|
2661
|
+
setTimeout(() => {
|
|
2662
|
+
submitButton.disabled = false;
|
|
2663
|
+
submitButton.textContent = 'Submit';
|
|
2664
|
+
}, 2000);
|
|
2665
|
+
}
|
|
2666
|
+
else {
|
|
2667
|
+
// Error - format user-friendly message
|
|
2668
|
+
const rawError = response.data?.error || response.error || 'Failed to submit form';
|
|
2669
|
+
const errorMessage = this.formatErrorMessage(rawError);
|
|
2670
|
+
this.showError(errorMessage, messageDiv);
|
|
2671
|
+
submitButton.disabled = false;
|
|
2672
|
+
submitButton.textContent = 'Submit';
|
|
2673
|
+
if (this.config.onError) {
|
|
2674
|
+
this.config.onError(new Error(rawError));
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
catch (error) {
|
|
2679
|
+
console.error('[Wabbit] Submission error:', error);
|
|
2680
|
+
const rawError = error instanceof Error ? error.message : 'Network error. Please try again.';
|
|
2681
|
+
const errorMessage = this.formatErrorMessage(rawError);
|
|
2682
|
+
this.showError(errorMessage, messageDiv);
|
|
2683
|
+
submitButton.disabled = false;
|
|
2684
|
+
submitButton.textContent = 'Submit';
|
|
2685
|
+
if (this.config.onError) {
|
|
2686
|
+
this.config.onError(error instanceof Error ? error : new Error(String(error)));
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* Format error message to be user-friendly
|
|
2692
|
+
* Removes HTTP status codes and technical details
|
|
2693
|
+
*/
|
|
2694
|
+
formatErrorMessage(error) {
|
|
2695
|
+
if (!error)
|
|
2696
|
+
return 'An error occurred. Please try again.';
|
|
2697
|
+
// Remove HTTP status codes (e.g., "HTTP 400", "400 Bad Request")
|
|
2698
|
+
let message = error
|
|
2699
|
+
.replace(/^HTTP\s+\d+\s*/i, '')
|
|
2700
|
+
.replace(/^\d+\s+[A-Z\s]+:\s*/i, '')
|
|
2701
|
+
.replace(/\b\d{3}\s+[A-Z\s]+\b/gi, '')
|
|
2702
|
+
.trim();
|
|
2703
|
+
// If message is empty after cleaning, provide a friendly default
|
|
2704
|
+
if (!message || message.length === 0) {
|
|
2705
|
+
return 'An error occurred. Please try again.';
|
|
2706
|
+
}
|
|
2707
|
+
// Remove common technical prefixes
|
|
2708
|
+
message = message.replace(/^(Error|Failed|Error:)\s*/i, '');
|
|
2709
|
+
return message;
|
|
2710
|
+
}
|
|
2711
|
+
/**
|
|
2712
|
+
* Show success message
|
|
2713
|
+
*/
|
|
2714
|
+
showSuccess(message, messageDiv) {
|
|
2715
|
+
const div = messageDiv || this.formElement?.querySelector('.wabbit-message');
|
|
2716
|
+
if (!div)
|
|
2717
|
+
return;
|
|
2718
|
+
div.textContent = message;
|
|
2719
|
+
div.className = 'wabbit-message wabbit-message-success';
|
|
2720
|
+
div.style.display = 'block';
|
|
2721
|
+
}
|
|
2722
|
+
/**
|
|
2723
|
+
* Show error message
|
|
2724
|
+
*/
|
|
2725
|
+
showError(message, messageDiv) {
|
|
2726
|
+
if (this.container && !messageDiv) {
|
|
2727
|
+
const colors = this.styles.getColors(this.currentTheme);
|
|
2728
|
+
this.container.innerHTML = `<p style="color: ${colors.errorColor}; padding: 1rem; border: 1px solid ${colors.errorBorder}; border-radius: 0.5rem; background-color: ${colors.errorBg};">${escapeHtml(message)}</p>`;
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
const div = messageDiv || this.formElement?.querySelector('.wabbit-message');
|
|
2732
|
+
if (!div)
|
|
2733
|
+
return;
|
|
2734
|
+
div.textContent = message;
|
|
2735
|
+
div.className = 'wabbit-message wabbit-message-error';
|
|
2736
|
+
div.style.display = 'block';
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Setup theme watcher
|
|
2740
|
+
*/
|
|
2741
|
+
setupThemeWatcher() {
|
|
2742
|
+
const themeMode = this.config.theme || 'auto';
|
|
2743
|
+
if (themeMode === 'auto') {
|
|
2744
|
+
this.themeCleanup = watchTheme((theme) => {
|
|
2745
|
+
this.currentTheme = theme;
|
|
2746
|
+
this.updateFormTheme();
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2749
|
+
else {
|
|
2750
|
+
// Use explicit theme from config
|
|
2751
|
+
this.currentTheme = themeMode;
|
|
2752
|
+
// Update FormStyles to use the explicit theme
|
|
2753
|
+
this.styles.updateTheme(themeMode);
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Update form theme
|
|
2758
|
+
*/
|
|
2759
|
+
updateFormTheme() {
|
|
2760
|
+
// Update styles with new theme
|
|
2761
|
+
this.styles.updateTheme(this.currentTheme);
|
|
2762
|
+
// Update rating stars if form is rendered
|
|
2763
|
+
if (this.formElement) {
|
|
2764
|
+
const ratingInputs = this.formElement.querySelectorAll('.wabbit-rating input[type="radio"]');
|
|
2765
|
+
ratingInputs.forEach((input) => {
|
|
2766
|
+
if (input.checked) {
|
|
2767
|
+
this.updateRatingStars(input);
|
|
2768
|
+
}
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* Calculate hover color (darker by 10%)
|
|
2774
|
+
*/
|
|
2775
|
+
calculateHoverColor(color) {
|
|
2776
|
+
if (color.startsWith('#')) {
|
|
2777
|
+
const num = parseInt(color.slice(1), 16);
|
|
2778
|
+
const r = Math.max(0, Math.floor((num >> 16) * 0.9));
|
|
2779
|
+
const g = Math.max(0, Math.floor(((num >> 8) & 0x00FF) * 0.9));
|
|
2780
|
+
const b = Math.max(0, Math.floor((num & 0x0000FF) * 0.9));
|
|
2781
|
+
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
|
2782
|
+
}
|
|
2783
|
+
return color;
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* Get API URL from script tag (for backward compatibility)
|
|
2787
|
+
*/
|
|
2788
|
+
getApiUrlFromScript() {
|
|
2789
|
+
const scriptTag = document.currentScript ||
|
|
2790
|
+
document.querySelector('script[src*="embed"]');
|
|
2791
|
+
if (scriptTag && scriptTag instanceof HTMLScriptElement && scriptTag.src) {
|
|
2792
|
+
try {
|
|
2793
|
+
return new URL(scriptTag.src).origin;
|
|
2794
|
+
}
|
|
2795
|
+
catch {
|
|
2796
|
+
// Invalid URL
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
return 'http://localhost:3000';
|
|
2800
|
+
}
|
|
2801
|
+
/**
|
|
2802
|
+
* Destroy the form widget
|
|
2803
|
+
*/
|
|
2804
|
+
destroy() {
|
|
2805
|
+
if (this.themeCleanup) {
|
|
2806
|
+
this.themeCleanup();
|
|
2807
|
+
this.themeCleanup = null;
|
|
2808
|
+
}
|
|
2809
|
+
if (this.formObserver) {
|
|
2810
|
+
this.formObserver.disconnect();
|
|
2811
|
+
this.formObserver = null;
|
|
2812
|
+
}
|
|
2813
|
+
if (this.styles) {
|
|
2814
|
+
this.styles.destroy();
|
|
2815
|
+
}
|
|
2816
|
+
// Clear container
|
|
2817
|
+
if (this.container) {
|
|
2818
|
+
this.container.innerHTML = '';
|
|
2819
|
+
}
|
|
2820
|
+
this.container = null;
|
|
2821
|
+
this.formElement = null;
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
var FormWidget$1 = /*#__PURE__*/Object.freeze({
|
|
2826
|
+
__proto__: null,
|
|
2827
|
+
FormWidget: FormWidget
|
|
2828
|
+
});
|
|
2829
|
+
|
|
2830
|
+
/**
|
|
2831
|
+
* Simple event emitter for SDK
|
|
2832
|
+
*/
|
|
2833
|
+
/**
|
|
2834
|
+
* Simple event emitter class
|
|
2835
|
+
*/
|
|
2836
|
+
class EventEmitter {
|
|
2837
|
+
constructor() {
|
|
2838
|
+
this.events = new Map();
|
|
2839
|
+
}
|
|
2840
|
+
/**
|
|
2841
|
+
* Subscribe to an event
|
|
2842
|
+
*/
|
|
2843
|
+
on(event, handler) {
|
|
2844
|
+
if (!this.events.has(event)) {
|
|
2845
|
+
this.events.set(event, []);
|
|
2846
|
+
}
|
|
2847
|
+
this.events.get(event).push(handler);
|
|
2848
|
+
}
|
|
2849
|
+
/**
|
|
2850
|
+
* Unsubscribe from an event
|
|
2851
|
+
*/
|
|
2852
|
+
off(event, handler) {
|
|
2853
|
+
const handlers = this.events.get(event);
|
|
2854
|
+
if (handlers) {
|
|
2855
|
+
const index = handlers.indexOf(handler);
|
|
2856
|
+
if (index > -1) {
|
|
2857
|
+
handlers.splice(index, 1);
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* Emit an event
|
|
2863
|
+
*/
|
|
2864
|
+
emit(event, ...args) {
|
|
2865
|
+
const handlers = this.events.get(event);
|
|
2866
|
+
if (handlers) {
|
|
2867
|
+
handlers.forEach((handler) => {
|
|
2868
|
+
try {
|
|
2869
|
+
handler(...args);
|
|
2870
|
+
}
|
|
2871
|
+
catch (error) {
|
|
2872
|
+
console.error(`[Wabbit] Error in event handler for "${event}":`, error);
|
|
2873
|
+
}
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
/**
|
|
2878
|
+
* Remove all listeners for an event
|
|
2879
|
+
*/
|
|
2880
|
+
removeAllListeners(event) {
|
|
2881
|
+
if (event) {
|
|
2882
|
+
this.events.delete(event);
|
|
2883
|
+
}
|
|
2884
|
+
else {
|
|
2885
|
+
this.events.clear();
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
/**
|
|
2889
|
+
* Get listener count for an event
|
|
2890
|
+
*/
|
|
2891
|
+
listenerCount(event) {
|
|
2892
|
+
return this.events.get(event)?.length || 0;
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
/**
|
|
2897
|
+
* Email Capture Modal Component
|
|
2898
|
+
*
|
|
2899
|
+
* Vanilla JavaScript implementation of the email capture modal
|
|
2900
|
+
*/
|
|
2901
|
+
class EmailCaptureModal {
|
|
2902
|
+
constructor(config) {
|
|
2903
|
+
this.overlay = null;
|
|
2904
|
+
this.modal = null;
|
|
2905
|
+
this.isOpen = false;
|
|
2906
|
+
this.config = config;
|
|
2907
|
+
}
|
|
2908
|
+
/**
|
|
2909
|
+
* Show the email capture modal
|
|
2910
|
+
*/
|
|
2911
|
+
show(onSubmit, onDismiss) {
|
|
2912
|
+
if (this.isOpen) {
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
this.onSubmitCallback = onSubmit;
|
|
2916
|
+
this.onDismissCallback = onDismiss;
|
|
2917
|
+
this.isOpen = true;
|
|
2918
|
+
this.render();
|
|
2919
|
+
}
|
|
2920
|
+
/**
|
|
2921
|
+
* Hide the email capture modal
|
|
2922
|
+
*/
|
|
2923
|
+
hide() {
|
|
2924
|
+
if (!this.isOpen) {
|
|
2925
|
+
return;
|
|
2926
|
+
}
|
|
2927
|
+
this.isOpen = false;
|
|
2928
|
+
this.destroy();
|
|
2929
|
+
}
|
|
2930
|
+
/**
|
|
2931
|
+
* Render the modal
|
|
2932
|
+
*/
|
|
2933
|
+
render() {
|
|
2934
|
+
const theme = detectTheme();
|
|
2935
|
+
const isDark = theme === 'dark';
|
|
2936
|
+
// Create overlay
|
|
2937
|
+
this.overlay = document.createElement('div');
|
|
2938
|
+
this.overlay.className = 'wabbit-email-capture-overlay';
|
|
2939
|
+
this.overlay.addEventListener('click', (e) => {
|
|
2940
|
+
if (e.target === this.overlay) {
|
|
2941
|
+
this.handleDismiss();
|
|
2942
|
+
}
|
|
2943
|
+
});
|
|
2944
|
+
// Create modal
|
|
2945
|
+
this.modal = document.createElement('div');
|
|
2946
|
+
this.modal.className = `wabbit-email-capture-modal ${isDark ? 'dark' : ''}`;
|
|
2947
|
+
// Build form fields based on config
|
|
2948
|
+
const fields = this.config.fields || ['email'];
|
|
2949
|
+
const formFields = fields.map((field) => this.renderField(field)).join('');
|
|
2950
|
+
// Render modal content
|
|
2951
|
+
this.modal.innerHTML = `
|
|
2952
|
+
<button class="wabbit-email-capture-close ${isDark ? 'dark' : ''}" aria-label="Close">
|
|
2953
|
+
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
2954
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
2955
|
+
</svg>
|
|
2956
|
+
</button>
|
|
2957
|
+
<div class="wabbit-email-capture-content">
|
|
2958
|
+
<h2 class="wabbit-email-capture-title">
|
|
2959
|
+
${this.config.title || 'Save this conversation?'}
|
|
2960
|
+
</h2>
|
|
2961
|
+
<p class="wabbit-email-capture-description">
|
|
2962
|
+
${this.config.description || 'Enter your email to continue this chat later'}
|
|
2963
|
+
</p>
|
|
2964
|
+
<form class="wabbit-email-capture-form">
|
|
2965
|
+
${formFields}
|
|
2966
|
+
<div class="wabbit-email-capture-checkbox">
|
|
2967
|
+
<input type="checkbox" id="wabbit-dont-show-again" />
|
|
2968
|
+
<label for="wabbit-dont-show-again">Don't show this again</label>
|
|
2969
|
+
</div>
|
|
2970
|
+
<div class="wabbit-email-capture-buttons">
|
|
2971
|
+
<button type="submit" class="wabbit-email-capture-button wabbit-email-capture-button-primary">
|
|
2972
|
+
Save Conversation
|
|
2973
|
+
</button>
|
|
2974
|
+
<button type="button" class="wabbit-email-capture-button wabbit-email-capture-button-secondary">
|
|
2975
|
+
Maybe Later
|
|
2976
|
+
</button>
|
|
2977
|
+
</div>
|
|
2978
|
+
</form>
|
|
2979
|
+
</div>
|
|
2980
|
+
`;
|
|
2981
|
+
// Attach event listeners
|
|
2982
|
+
this.attachEventListeners();
|
|
2983
|
+
// Append to DOM
|
|
2984
|
+
this.overlay.appendChild(this.modal);
|
|
2985
|
+
document.body.appendChild(this.overlay);
|
|
2986
|
+
}
|
|
2987
|
+
/**
|
|
2988
|
+
* Render a form field
|
|
2989
|
+
*/
|
|
2990
|
+
renderField(field) {
|
|
2991
|
+
const fieldConfig = {
|
|
2992
|
+
email: { label: 'Email', type: 'email', placeholder: 'your@email.com', required: true },
|
|
2993
|
+
name: { label: 'Name', type: 'text', placeholder: 'Your name', required: false },
|
|
2994
|
+
company: { label: 'Company', type: 'text', placeholder: 'Your company', required: false }
|
|
2995
|
+
};
|
|
2996
|
+
const config = fieldConfig[field];
|
|
2997
|
+
return `
|
|
2998
|
+
<div class="wabbit-email-capture-field">
|
|
2999
|
+
<label class="wabbit-email-capture-label" for="wabbit-field-${field}">
|
|
3000
|
+
${config.label}
|
|
3001
|
+
</label>
|
|
3002
|
+
<input
|
|
3003
|
+
type="${config.type}"
|
|
3004
|
+
id="wabbit-field-${field}"
|
|
3005
|
+
name="${field}"
|
|
3006
|
+
placeholder="${config.placeholder}"
|
|
3007
|
+
class="wabbit-email-capture-input"
|
|
3008
|
+
${config.required ? 'required' : ''}
|
|
3009
|
+
/>
|
|
3010
|
+
<div class="wabbit-email-capture-error" id="wabbit-error-${field}" style="display: none;"></div>
|
|
3011
|
+
</div>
|
|
3012
|
+
`;
|
|
3013
|
+
}
|
|
3014
|
+
/**
|
|
3015
|
+
* Attach event listeners
|
|
3016
|
+
*/
|
|
3017
|
+
attachEventListeners() {
|
|
3018
|
+
if (!this.modal)
|
|
3019
|
+
return;
|
|
3020
|
+
// Close button
|
|
3021
|
+
const closeButton = this.modal.querySelector('.wabbit-email-capture-close');
|
|
3022
|
+
if (closeButton) {
|
|
3023
|
+
closeButton.addEventListener('click', () => this.handleDismiss());
|
|
3024
|
+
}
|
|
3025
|
+
// Form submit
|
|
3026
|
+
const form = this.modal.querySelector('form');
|
|
3027
|
+
if (form) {
|
|
3028
|
+
form.addEventListener('submit', (e) => this.handleSubmit(e));
|
|
3029
|
+
}
|
|
3030
|
+
// Maybe Later button
|
|
3031
|
+
const maybeLaterButton = this.modal.querySelector('.wabbit-email-capture-button-secondary');
|
|
3032
|
+
if (maybeLaterButton) {
|
|
3033
|
+
maybeLaterButton.addEventListener('click', () => this.handleDismiss());
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
/**
|
|
3037
|
+
* Handle form submission
|
|
3038
|
+
*/
|
|
3039
|
+
async handleSubmit(e) {
|
|
3040
|
+
e.preventDefault();
|
|
3041
|
+
if (!this.modal || !this.onSubmitCallback)
|
|
3042
|
+
return;
|
|
3043
|
+
// Get form data
|
|
3044
|
+
const form = this.modal.querySelector('form');
|
|
3045
|
+
if (!form)
|
|
3046
|
+
return;
|
|
3047
|
+
const formData = new FormData(form);
|
|
3048
|
+
const data = {
|
|
3049
|
+
email: formData.get('email') || '',
|
|
3050
|
+
name: formData.get('name') || undefined,
|
|
3051
|
+
company: formData.get('company') || undefined
|
|
3052
|
+
};
|
|
3053
|
+
// Validate email
|
|
3054
|
+
if (!data.email || !this.isValidEmail(data.email)) {
|
|
3055
|
+
this.showError('email', 'Please enter a valid email address');
|
|
3056
|
+
return;
|
|
3057
|
+
}
|
|
3058
|
+
// Get "Don't show again" checkbox
|
|
3059
|
+
const dontShowAgain = form.querySelector('#wabbit-dont-show-again')?.checked || false;
|
|
3060
|
+
// Disable form
|
|
3061
|
+
this.setFormDisabled(true);
|
|
3062
|
+
try {
|
|
3063
|
+
await this.onSubmitCallback(data, dontShowAgain);
|
|
3064
|
+
this.hide();
|
|
3065
|
+
}
|
|
3066
|
+
catch (error) {
|
|
3067
|
+
this.showError('email', error instanceof Error ? error.message : 'Failed to save email');
|
|
3068
|
+
this.setFormDisabled(false);
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
/**
|
|
3072
|
+
* Handle dismiss
|
|
3073
|
+
*/
|
|
3074
|
+
handleDismiss() {
|
|
3075
|
+
if (this.onDismissCallback) {
|
|
3076
|
+
this.onDismissCallback();
|
|
3077
|
+
}
|
|
3078
|
+
this.hide();
|
|
3079
|
+
}
|
|
3080
|
+
/**
|
|
3081
|
+
* Set form disabled state
|
|
3082
|
+
*/
|
|
3083
|
+
setFormDisabled(disabled) {
|
|
3084
|
+
if (!this.modal)
|
|
3085
|
+
return;
|
|
3086
|
+
const inputs = this.modal.querySelectorAll('input, button');
|
|
3087
|
+
inputs.forEach((input) => {
|
|
3088
|
+
input.style.opacity = disabled ? '0.5' : '1';
|
|
3089
|
+
input.style.pointerEvents = disabled ? 'none' : 'auto';
|
|
3090
|
+
});
|
|
3091
|
+
const submitButton = this.modal.querySelector('.wabbit-email-capture-button-primary');
|
|
3092
|
+
if (submitButton) {
|
|
3093
|
+
submitButton.disabled = disabled;
|
|
3094
|
+
submitButton.textContent = disabled ? 'Saving...' : 'Save Conversation';
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* Show error message
|
|
3099
|
+
*/
|
|
3100
|
+
showError(field, message) {
|
|
3101
|
+
if (!this.modal)
|
|
3102
|
+
return;
|
|
3103
|
+
const errorElement = this.modal.querySelector(`#wabbit-error-${field}`);
|
|
3104
|
+
if (errorElement) {
|
|
3105
|
+
errorElement.textContent = message;
|
|
3106
|
+
errorElement.style.display = 'block';
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* Validate email format
|
|
3111
|
+
*/
|
|
3112
|
+
isValidEmail(email) {
|
|
3113
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3114
|
+
return emailRegex.test(email);
|
|
3115
|
+
}
|
|
3116
|
+
/**
|
|
3117
|
+
* Destroy the modal
|
|
3118
|
+
*/
|
|
3119
|
+
destroy() {
|
|
3120
|
+
if (this.overlay) {
|
|
3121
|
+
this.overlay.remove();
|
|
3122
|
+
this.overlay = null;
|
|
3123
|
+
}
|
|
3124
|
+
this.modal = null;
|
|
3125
|
+
this.isOpen = false;
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
/**
|
|
3130
|
+
* Email Capture Modal Styles
|
|
3131
|
+
*/
|
|
3132
|
+
/**
|
|
3133
|
+
* Inject email capture modal styles
|
|
3134
|
+
*/
|
|
3135
|
+
function injectEmailCaptureStyles(primaryColor = '#6366f1') {
|
|
3136
|
+
const styleId = 'wabbit-email-capture-styles';
|
|
3137
|
+
// Remove existing styles if any
|
|
3138
|
+
const existing = document.getElementById(styleId);
|
|
3139
|
+
if (existing) {
|
|
3140
|
+
existing.remove();
|
|
3141
|
+
}
|
|
3142
|
+
const style = document.createElement('style');
|
|
3143
|
+
style.id = styleId;
|
|
3144
|
+
style.setAttribute('data-wabbit', 'email-capture-styles');
|
|
3145
|
+
style.textContent = `
|
|
3146
|
+
/* Email Capture Modal Styles */
|
|
3147
|
+
.wabbit-email-capture-overlay {
|
|
3148
|
+
position: fixed;
|
|
3149
|
+
inset: 0;
|
|
3150
|
+
z-index: 9999;
|
|
3151
|
+
display: flex;
|
|
3152
|
+
align-items: center;
|
|
3153
|
+
justify-content: center;
|
|
3154
|
+
background-color: rgba(0, 0, 0, 0.5);
|
|
3155
|
+
backdrop-filter: blur(4px);
|
|
3156
|
+
animation: wabbit-fade-in 0.2s ease-out;
|
|
3157
|
+
padding: 1rem;
|
|
3158
|
+
box-sizing: border-box;
|
|
3159
|
+
overflow-y: auto;
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
.wabbit-email-capture-modal {
|
|
3163
|
+
position: relative;
|
|
3164
|
+
background: white;
|
|
3165
|
+
border-radius: 0.5rem;
|
|
3166
|
+
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
3167
|
+
padding: 1.5rem;
|
|
3168
|
+
width: calc(100% - 2rem);
|
|
3169
|
+
max-width: 28rem;
|
|
3170
|
+
margin: 1rem;
|
|
3171
|
+
animation: wabbit-slide-up 0.3s ease-out;
|
|
3172
|
+
box-sizing: border-box;
|
|
3173
|
+
overflow: hidden;
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
.wabbit-email-capture-modal.dark {
|
|
3177
|
+
background: #1f2937;
|
|
3178
|
+
color: #f9fafb;
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
.wabbit-email-capture-close {
|
|
3182
|
+
position: absolute;
|
|
3183
|
+
top: 1rem;
|
|
3184
|
+
right: 1rem;
|
|
3185
|
+
background: none;
|
|
3186
|
+
border: none;
|
|
3187
|
+
color: #6b7280;
|
|
3188
|
+
cursor: pointer;
|
|
3189
|
+
padding: 0.25rem;
|
|
3190
|
+
display: flex;
|
|
3191
|
+
align-items: center;
|
|
3192
|
+
justify-content: center;
|
|
3193
|
+
border-radius: 0.25rem;
|
|
3194
|
+
transition: all 0.2s;
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
.wabbit-email-capture-close:hover {
|
|
3198
|
+
color: #374151;
|
|
3199
|
+
background-color: #f3f4f6;
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
.wabbit-email-capture-close.dark:hover {
|
|
3203
|
+
color: #d1d5db;
|
|
3204
|
+
background-color: #374151;
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
.wabbit-email-capture-close:disabled {
|
|
3208
|
+
opacity: 0.5;
|
|
3209
|
+
cursor: not-allowed;
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
.wabbit-email-capture-title {
|
|
3213
|
+
font-size: 1.25rem;
|
|
3214
|
+
font-weight: 600;
|
|
3215
|
+
color: #111827;
|
|
3216
|
+
margin-bottom: 0.5rem;
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
.wabbit-email-capture-modal.dark .wabbit-email-capture-title {
|
|
3220
|
+
color: #f9fafb;
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
.wabbit-email-capture-description {
|
|
3224
|
+
font-size: 0.875rem;
|
|
3225
|
+
color: #6b7280;
|
|
3226
|
+
margin-bottom: 1.5rem;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
.wabbit-email-capture-modal.dark .wabbit-email-capture-description {
|
|
3230
|
+
color: #d1d5db;
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
.wabbit-email-capture-form {
|
|
3234
|
+
display: flex;
|
|
3235
|
+
flex-direction: column;
|
|
3236
|
+
gap: 1rem;
|
|
3237
|
+
width: 100%;
|
|
3238
|
+
box-sizing: border-box;
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
.wabbit-email-capture-field {
|
|
3242
|
+
display: flex;
|
|
3243
|
+
flex-direction: column;
|
|
3244
|
+
gap: 0.5rem;
|
|
3245
|
+
width: 100%;
|
|
3246
|
+
box-sizing: border-box;
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
.wabbit-email-capture-label {
|
|
3250
|
+
font-size: 0.875rem;
|
|
3251
|
+
font-weight: 500;
|
|
3252
|
+
color: #374151;
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
.wabbit-email-capture-modal.dark .wabbit-email-capture-label {
|
|
3256
|
+
color: #d1d5db;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
.wabbit-email-capture-input {
|
|
3260
|
+
width: 100%;
|
|
3261
|
+
padding: 0.75rem;
|
|
3262
|
+
border: 1px solid #d1d5db;
|
|
3263
|
+
border-radius: 0.375rem;
|
|
3264
|
+
font-size: 0.875rem;
|
|
3265
|
+
transition: all 0.2s;
|
|
3266
|
+
background: white;
|
|
3267
|
+
color: #111827;
|
|
3268
|
+
box-sizing: border-box;
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
.wabbit-email-capture-modal.dark .wabbit-email-capture-input {
|
|
3272
|
+
background: #374151;
|
|
3273
|
+
border-color: #4b5563;
|
|
3274
|
+
color: #f9fafb;
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
.wabbit-email-capture-input:focus {
|
|
3278
|
+
outline: none;
|
|
3279
|
+
border-color: ${primaryColor};
|
|
3280
|
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
.wabbit-email-capture-input:disabled {
|
|
3284
|
+
opacity: 0.5;
|
|
3285
|
+
cursor: not-allowed;
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
.wabbit-email-capture-error {
|
|
3289
|
+
font-size: 0.875rem;
|
|
3290
|
+
color: #dc2626;
|
|
3291
|
+
margin-top: 0.25rem;
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
.wabbit-email-capture-checkbox {
|
|
3295
|
+
display: flex;
|
|
3296
|
+
align-items: center;
|
|
3297
|
+
gap: 0.5rem;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
.wabbit-email-capture-checkbox input[type="checkbox"] {
|
|
3301
|
+
width: 1rem;
|
|
3302
|
+
height: 1rem;
|
|
3303
|
+
accent-color: ${primaryColor};
|
|
3304
|
+
cursor: pointer;
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
.wabbit-email-capture-checkbox label {
|
|
3308
|
+
font-size: 0.875rem;
|
|
3309
|
+
color: #6b7280;
|
|
3310
|
+
cursor: pointer;
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
.wabbit-email-capture-modal.dark .wabbit-email-capture-checkbox label {
|
|
3314
|
+
color: #d1d5db;
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
.wabbit-email-capture-buttons {
|
|
3318
|
+
display: flex;
|
|
3319
|
+
gap: 0.75rem;
|
|
3320
|
+
margin-top: 0.5rem;
|
|
3321
|
+
width: 100%;
|
|
3322
|
+
box-sizing: border-box;
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
.wabbit-email-capture-button {
|
|
3326
|
+
flex: 1;
|
|
3327
|
+
padding: 0.75rem 1.5rem;
|
|
3328
|
+
border: none;
|
|
3329
|
+
border-radius: 0.375rem;
|
|
3330
|
+
font-size: 0.875rem;
|
|
3331
|
+
font-weight: 500;
|
|
3332
|
+
cursor: pointer;
|
|
3333
|
+
transition: all 0.2s;
|
|
3334
|
+
box-sizing: border-box;
|
|
3335
|
+
min-width: 0;
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
.wabbit-email-capture-button-primary {
|
|
3339
|
+
background-color: ${primaryColor};
|
|
3340
|
+
color: white;
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
.wabbit-email-capture-button-primary:hover:not(:disabled) {
|
|
3344
|
+
opacity: 0.9;
|
|
3345
|
+
transform: translateY(-1px);
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
.wabbit-email-capture-button-primary:disabled {
|
|
3349
|
+
opacity: 0.5;
|
|
3350
|
+
cursor: not-allowed;
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
.wabbit-email-capture-button-secondary {
|
|
3354
|
+
background-color: white;
|
|
3355
|
+
color: #374151;
|
|
3356
|
+
border: 1px solid #d1d5db;
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
.wabbit-email-capture-modal.dark .wabbit-email-capture-button-secondary {
|
|
3360
|
+
background-color: #374151;
|
|
3361
|
+
color: #d1d5db;
|
|
3362
|
+
border-color: #4b5563;
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
.wabbit-email-capture-button-secondary:hover:not(:disabled) {
|
|
3366
|
+
background-color: #f9fafb;
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
.wabbit-email-capture-modal.dark .wabbit-email-capture-button-secondary:hover:not(:disabled) {
|
|
3370
|
+
background-color: #4b5563;
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
@keyframes wabbit-fade-in {
|
|
3374
|
+
from {
|
|
3375
|
+
opacity: 0;
|
|
3376
|
+
}
|
|
3377
|
+
to {
|
|
3378
|
+
opacity: 1;
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
@keyframes wabbit-slide-up {
|
|
3383
|
+
from {
|
|
3384
|
+
opacity: 0;
|
|
3385
|
+
transform: translateY(1rem);
|
|
3386
|
+
}
|
|
3387
|
+
to {
|
|
3388
|
+
opacity: 1;
|
|
3389
|
+
transform: translateY(0);
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
`;
|
|
3393
|
+
document.head.appendChild(style);
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
/**
|
|
3397
|
+
* Email Capture Widget
|
|
3398
|
+
*
|
|
3399
|
+
* Manages email capture modal and integrates with chat widget
|
|
3400
|
+
*/
|
|
3401
|
+
class EmailCaptureWidget {
|
|
3402
|
+
constructor(config) {
|
|
3403
|
+
this.messageCount = 0;
|
|
3404
|
+
this.emailCaptured = false;
|
|
3405
|
+
this.wsClient = null; // WebSocket client from ChatWidget
|
|
3406
|
+
this.config = config;
|
|
3407
|
+
this.modal = new EmailCaptureModal(config);
|
|
3408
|
+
this.onCaptureCallback = config.onCapture;
|
|
3409
|
+
}
|
|
3410
|
+
/**
|
|
3411
|
+
* Initialize the email capture widget
|
|
3412
|
+
*/
|
|
3413
|
+
init() {
|
|
3414
|
+
// Inject styles
|
|
3415
|
+
injectEmailCaptureStyles('#6366f1'); // Default primary color
|
|
3416
|
+
// Check if user has dismissed email capture
|
|
3417
|
+
const dismissed = getStorageItem('wabbit_email_capture_dismissed');
|
|
3418
|
+
if (dismissed === 'true') {
|
|
3419
|
+
console.log('[Wabbit] Email capture dismissed by user');
|
|
3420
|
+
return;
|
|
3421
|
+
}
|
|
3422
|
+
// Check if email already captured
|
|
3423
|
+
const captured = getStorageItem('wabbit_email_captured');
|
|
3424
|
+
if (captured === 'true') {
|
|
3425
|
+
this.emailCaptured = true;
|
|
3426
|
+
console.log('[Wabbit] Email already captured');
|
|
3427
|
+
return;
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
/**
|
|
3431
|
+
* Set WebSocket client (from ChatWidget)
|
|
3432
|
+
*/
|
|
3433
|
+
setWebSocketClient(wsClient) {
|
|
3434
|
+
this.wsClient = wsClient;
|
|
3435
|
+
}
|
|
3436
|
+
/**
|
|
3437
|
+
* Handle new message (called by ChatWidget)
|
|
3438
|
+
*/
|
|
3439
|
+
handleMessage() {
|
|
3440
|
+
if (this.emailCaptured) {
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
// Only count user messages
|
|
3444
|
+
this.messageCount++;
|
|
3445
|
+
const triggerAfter = this.config.triggerAfterMessages || 3;
|
|
3446
|
+
if (this.messageCount >= triggerAfter) {
|
|
3447
|
+
this.showModal();
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
/**
|
|
3451
|
+
* Show the email capture modal
|
|
3452
|
+
*/
|
|
3453
|
+
showModal() {
|
|
3454
|
+
if (this.emailCaptured) {
|
|
3455
|
+
return;
|
|
3456
|
+
}
|
|
3457
|
+
this.modal.show((data, dontShowAgain) => this.handleEmailSubmit(data, dontShowAgain), () => this.handleDismiss());
|
|
3458
|
+
}
|
|
3459
|
+
/**
|
|
3460
|
+
* Handle email submission
|
|
3461
|
+
*/
|
|
3462
|
+
async handleEmailSubmit(data, dontShowAgain) {
|
|
3463
|
+
if (dontShowAgain) {
|
|
3464
|
+
setStorageItem('wabbit_email_capture_dismissed', 'true');
|
|
3465
|
+
}
|
|
3466
|
+
// Send email via WebSocket if available
|
|
3467
|
+
if (this.wsClient && this.wsClient.ws && this.wsClient.ws.readyState === WebSocket.OPEN) {
|
|
3468
|
+
return new Promise((resolve, reject) => {
|
|
3469
|
+
const timeout = setTimeout(() => {
|
|
3470
|
+
reject(new Error('Email capture timeout'));
|
|
3471
|
+
}, 5000);
|
|
3472
|
+
const messageHandler = (event) => {
|
|
3473
|
+
try {
|
|
3474
|
+
const response = JSON.parse(event.data);
|
|
3475
|
+
if (response.type === 'email_confirmed') {
|
|
3476
|
+
clearTimeout(timeout);
|
|
3477
|
+
this.wsClient.ws.removeEventListener('message', messageHandler);
|
|
3478
|
+
if (response.success) {
|
|
3479
|
+
this.emailCaptured = true;
|
|
3480
|
+
setStorageItem('wabbit_email_captured', 'true');
|
|
3481
|
+
// Call callback
|
|
3482
|
+
if (this.onCaptureCallback) {
|
|
3483
|
+
this.onCaptureCallback({
|
|
3484
|
+
email: data.email,
|
|
3485
|
+
name: data.name || '',
|
|
3486
|
+
company: data.company || ''
|
|
3487
|
+
});
|
|
3488
|
+
}
|
|
3489
|
+
resolve();
|
|
3490
|
+
}
|
|
3491
|
+
else {
|
|
3492
|
+
reject(new Error(response.message || 'Failed to save email'));
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
catch (error) {
|
|
3497
|
+
// Not the message we're waiting for, ignore
|
|
3498
|
+
}
|
|
3499
|
+
};
|
|
3500
|
+
this.wsClient.ws.addEventListener('message', messageHandler);
|
|
3501
|
+
// Send email to server
|
|
3502
|
+
this.wsClient.ws.send(JSON.stringify({
|
|
3503
|
+
type: 'provide_email',
|
|
3504
|
+
email: data.email,
|
|
3505
|
+
name: data.name,
|
|
3506
|
+
company: data.company
|
|
3507
|
+
}));
|
|
3508
|
+
});
|
|
3509
|
+
}
|
|
3510
|
+
else {
|
|
3511
|
+
// WebSocket not available, just mark as captured locally
|
|
3512
|
+
this.emailCaptured = true;
|
|
3513
|
+
setStorageItem('wabbit_email_captured', 'true');
|
|
3514
|
+
if (this.onCaptureCallback) {
|
|
3515
|
+
this.onCaptureCallback({
|
|
3516
|
+
email: data.email,
|
|
3517
|
+
name: data.name || '',
|
|
3518
|
+
company: data.company || ''
|
|
3519
|
+
});
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
/**
|
|
3524
|
+
* Handle modal dismiss
|
|
3525
|
+
*/
|
|
3526
|
+
handleDismiss() {
|
|
3527
|
+
// User dismissed, but don't mark as dismissed permanently
|
|
3528
|
+
// They can see it again next time
|
|
3529
|
+
}
|
|
3530
|
+
/**
|
|
3531
|
+
* Destroy the widget
|
|
3532
|
+
*/
|
|
3533
|
+
destroy() {
|
|
3534
|
+
if (this.modal) {
|
|
3535
|
+
this.modal.destroy();
|
|
3536
|
+
}
|
|
3537
|
+
this.wsClient = null;
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
var EmailCaptureWidget$1 = /*#__PURE__*/Object.freeze({
|
|
3542
|
+
__proto__: null,
|
|
3543
|
+
EmailCaptureWidget: EmailCaptureWidget
|
|
3544
|
+
});
|
|
3545
|
+
|
|
3546
|
+
/**
|
|
3547
|
+
* Wabbit Embed SDK
|
|
3548
|
+
*
|
|
3549
|
+
* Unified widget library for embedding chat, forms, and email capture
|
|
3550
|
+
* functionality into customer websites.
|
|
3551
|
+
*
|
|
3552
|
+
* @packageDocumentation
|
|
3553
|
+
*/
|
|
3554
|
+
// Import Wabbit class and types
|
|
3555
|
+
// Export main API functions as named exports
|
|
3556
|
+
// For UMD: These automatically become properties of the global Wabbit object (Wabbit.init, Wabbit.getInstance, Wabbit.destroy)
|
|
3557
|
+
// For ESM/CJS: Can import { init, getInstance, destroy } from '@wabbit/embed'
|
|
3558
|
+
function init(config) {
|
|
3559
|
+
return Wabbit.init(config);
|
|
3560
|
+
}
|
|
3561
|
+
function getInstance() {
|
|
3562
|
+
return Wabbit.getInstance();
|
|
3563
|
+
}
|
|
3564
|
+
function destroy() {
|
|
3565
|
+
return Wabbit.destroy();
|
|
3566
|
+
}
|
|
3567
|
+
// Create an object as default export (for ESM/CJS: import Wabbit from '@wabbit/embed')
|
|
3568
|
+
// Note: For UMD, we don't use default export, but directly use named exports
|
|
3569
|
+
const WabbitSDK = {
|
|
3570
|
+
init,
|
|
3571
|
+
getInstance,
|
|
3572
|
+
destroy
|
|
3573
|
+
};
|
|
3574
|
+
|
|
3575
|
+
exports.ApiClient = ApiClient;
|
|
3576
|
+
exports.ChatBubble = ChatBubble;
|
|
3577
|
+
exports.ChatPanel = ChatPanel;
|
|
3578
|
+
exports.ChatWebSocketClient = ChatWebSocketClient;
|
|
3579
|
+
exports.ChatWidget = ChatWidget;
|
|
3580
|
+
exports.EmailCaptureModal = EmailCaptureModal;
|
|
3581
|
+
exports.EmailCaptureWidget = EmailCaptureWidget;
|
|
3582
|
+
exports.EventEmitter = EventEmitter;
|
|
3583
|
+
exports.FormRenderer = FormRenderer;
|
|
3584
|
+
exports.FormStyles = FormStyles;
|
|
3585
|
+
exports.FormWidget = FormWidget;
|
|
3586
|
+
exports.SafeStorage = SafeStorage;
|
|
3587
|
+
exports.Wabbit = Wabbit;
|
|
3588
|
+
exports.createElement = createElement;
|
|
3589
|
+
exports.default = WabbitSDK;
|
|
3590
|
+
exports.destroy = destroy;
|
|
3591
|
+
exports.detectTheme = detectTheme;
|
|
3592
|
+
exports.escapeHtml = escapeHtml;
|
|
3593
|
+
exports.getInstance = getInstance;
|
|
3594
|
+
exports.init = init;
|
|
3595
|
+
exports.mergeConfig = mergeConfig;
|
|
3596
|
+
exports.onDOMReady = onDOMReady;
|
|
3597
|
+
exports.storage = storage;
|
|
3598
|
+
exports.validateConfig = validateConfig;
|
|
3599
|
+
exports.watchTheme = watchTheme;
|
|
3600
|
+
|
|
3601
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
3602
|
+
|
|
3603
|
+
}));
|
|
3604
|
+
//# sourceMappingURL=wabbit-embed.umd.min.js.map
|