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