@pindai-ai/chat-widget 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/main.js ADDED
@@ -0,0 +1,796 @@
1
+ import './style.css';
2
+ import { I18n } from './i18n.js';
3
+
4
+ /**
5
+ * Pindai Chat Widget - Modern, Accessible, Indonesian-focused
6
+ * Version 2.0.0
7
+ */
8
+ class PindaiChatWidget {
9
+ constructor(options) {
10
+ // Backward compatibility: support both webhookUrl and n8nUrl
11
+ const apiEndpoint = options.webhookUrl || options.n8nUrl;
12
+
13
+ if (!apiEndpoint) {
14
+ throw new Error('PindaiChatWidget: "webhookUrl" option is required.');
15
+ }
16
+
17
+ // Core configuration
18
+ this.webhookUrl = apiEndpoint;
19
+ this.mode = options.mode || 'widget';
20
+
21
+ // Internationalization
22
+ this.locale = options.locale || 'id'; // Default to Indonesian
23
+ this.i18n = new I18n(this.locale);
24
+
25
+ // UI customization
26
+ this.title = options.title || this.i18n.t('title');
27
+ this.initialMessage = options.initialMessage || this.i18n.t('initialMessage');
28
+ this.launcherIconUrl = options.launcherIconUrl || this.getDefaultIcon();
29
+
30
+ // Branding
31
+ this.logoUrl = options.logoUrl || 'https://pindai.ai/logo.png';
32
+ this.showLogo = options.showLogo !== false;
33
+ this.launcherColor = options.launcherColor || '#0066FF';
34
+ this.sendButtonColor = options.sendButtonColor || '#0066FF';
35
+ this.accentColor = options.accentColor || '#00C896';
36
+
37
+ // File upload configuration
38
+ this.enableFileUpload = options.enableFileUpload !== false;
39
+ this.allowedFileTypes = options.allowedFileTypes || [
40
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp',
41
+ 'application/pdf',
42
+ 'application/msword',
43
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
44
+ 'application/vnd.ms-excel',
45
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
46
+ ];
47
+ this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB
48
+ this.maxFiles = options.maxFiles || 5;
49
+ this.uploadedFiles = [];
50
+
51
+ // Notifications
52
+ this.enableNotifications = options.enableNotifications !== false;
53
+ this.enableSound = options.enableSound === true;
54
+ this.unreadCount = 0;
55
+
56
+ // Quick replies
57
+ this.showQuickReplies = options.showQuickReplies !== false;
58
+ this.quickReplies = options.quickReplies || [
59
+ this.i18n.t('quickReply1'),
60
+ this.i18n.t('quickReply2'),
61
+ this.i18n.t('quickReply3'),
62
+ this.i18n.t('quickReply4')
63
+ ];
64
+
65
+ // Message history
66
+ this.enableHistory = options.enableHistory !== false;
67
+ this.maxHistoryItems = options.maxHistoryItems || 50;
68
+ this.historyKey = `pindai-chat-history-${this.webhookUrl}`;
69
+ this.stateKey = `pindai-chat-state-${this.webhookUrl}`;
70
+
71
+ // Error handling & retry logic
72
+ this.maxRetries = options.maxRetries || 3;
73
+ this.retryDelay = options.retryDelay || 1000;
74
+ this.requestTimeout = options.requestTimeout || 30000;
75
+
76
+ // Rate limiting
77
+ this.rateLimit = options.rateLimit || 5;
78
+ this.rateLimitWindow = options.rateLimitWindow || 60000;
79
+ this.messageTimes = [];
80
+
81
+ // DOM references
82
+ this.container = null;
83
+ this.launcher = null;
84
+ this.chatWindow = null;
85
+ this.messageList = null;
86
+ this.input = null;
87
+ this.button = null;
88
+ this.closeButton = null;
89
+
90
+ // State
91
+ this.sessionId = `web-session-${Date.now()}-${Math.random()}`;
92
+ this.isLoading = false;
93
+ this.isOpen = false;
94
+ this.isOnline = navigator.onLine;
95
+
96
+ // Initialize
97
+ this.loadState();
98
+ this.setupOfflineDetection();
99
+
100
+ if (this.mode === 'fullscreen') {
101
+ this.initChatWindow();
102
+ } else {
103
+ this.initLauncher();
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Initialize launcher button
109
+ */
110
+ initLauncher() {
111
+ this.launcher = document.createElement('div');
112
+ this.launcher.className = 'n8n-chat-launcher';
113
+ this.launcher.style.backgroundColor = this.launcherColor;
114
+ this.launcher.setAttribute('role', 'button');
115
+ this.launcher.setAttribute('aria-label', this.i18n.t('ariaOpenChat'));
116
+ this.launcher.setAttribute('tabindex', '0');
117
+ this.launcher.innerHTML = `
118
+ <img src="${this.launcherIconUrl}" alt="">
119
+ <span class="n8n-chat-unread-badge" style="display: none;">0</span>
120
+ `;
121
+ document.body.appendChild(this.launcher);
122
+
123
+ this.launcher.addEventListener('click', () => this.toggleChatWindow());
124
+ this.launcher.addEventListener('keydown', (e) => {
125
+ if (e.key === 'Enter' || e.key === ' ') {
126
+ e.preventDefault();
127
+ this.toggleChatWindow();
128
+ }
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Initialize chat window
134
+ */
135
+ initChatWindow() {
136
+ this.container = document.createElement('div');
137
+ this.container.className = `n8n-chat-widget ${this.mode === 'fullscreen' ? 'n8n-chat-widget--fullscreen' : ''}`;
138
+ this.container.setAttribute('role', 'dialog');
139
+ this.container.setAttribute('aria-modal', 'true');
140
+ this.container.setAttribute('aria-label', this.title);
141
+
142
+ this.container.innerHTML = `
143
+ <div class="n8n-chat-header">
144
+ <div class="n8n-chat-header-content">
145
+ ${this.showLogo ? `<img src="${this.logoUrl}" alt="Pindai Logo" class="n8n-chat-logo">` : ''}
146
+ <span class="n8n-chat-title">${this.title}</span>
147
+ </div>
148
+ <button class="n8n-chat-close-btn" aria-label="${this.i18n.t('ariaCloseChat')}">&times;</button>
149
+ </div>
150
+ <div class="n8n-chat-messages" role="log" aria-live="polite" aria-atomic="false"></div>
151
+ <div class="n8n-chat-watermark">
152
+ <span>Powered by</span>
153
+ <a href="https://pindai.ai" target="_blank" rel="noopener noreferrer">Pindai.ai</a>
154
+ </div>
155
+ <div class="n8n-chat-input-area">
156
+ ${this.enableFileUpload ? `
157
+ <label class="n8n-chat-file-upload-btn" aria-label="${this.i18n.t('ariaUploadFile')}">
158
+ <input type="file" multiple accept="${this.allowedFileTypes.join(',')}" hidden>
159
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
160
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
161
+ </svg>
162
+ </label>
163
+ ` : ''}
164
+ <input type="text" placeholder="${this.i18n.t('placeholder')}" aria-label="${this.i18n.t('ariaMessageInput')}" />
165
+ <button class="n8n-chat-send-btn" style="background-color: ${this.sendButtonColor}" aria-label="${this.i18n.t('ariaSendMessage')}">
166
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
167
+ <line x1="22" y1="2" x2="11" y2="13"></line>
168
+ <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
169
+ </svg>
170
+ </button>
171
+ </div>
172
+ ${this.enableFileUpload ? '<div class="n8n-chat-file-preview" style="display: none;"></div>' : ''}
173
+ `;
174
+
175
+ if (this.mode === 'widget') {
176
+ document.body.appendChild(this.container);
177
+ } else {
178
+ document.body.innerHTML = '';
179
+ document.body.appendChild(this.container);
180
+ document.body.style.margin = '0';
181
+ }
182
+
183
+ // Get DOM references
184
+ this.messageList = this.container.querySelector('.n8n-chat-messages');
185
+ this.input = this.container.querySelector('input[type="text"]');
186
+ this.button = this.container.querySelector('.n8n-chat-send-btn');
187
+ this.closeButton = this.container.querySelector('.n8n-chat-close-btn');
188
+
189
+ // Event listeners
190
+ this.button.addEventListener('click', (e) => {
191
+ e.preventDefault();
192
+ this.sendMessage();
193
+ });
194
+
195
+ this.input.addEventListener('keypress', (e) => {
196
+ if (e.key === 'Enter') {
197
+ e.preventDefault();
198
+ this.sendMessage();
199
+ }
200
+ });
201
+
202
+ if (this.mode === 'fullscreen') {
203
+ this.closeButton.style.display = 'none';
204
+ } else {
205
+ this.closeButton.addEventListener('click', () => this.toggleChatWindow());
206
+ }
207
+
208
+ // File upload handler
209
+ if (this.enableFileUpload) {
210
+ const fileInput = this.container.querySelector('input[type="file"]');
211
+ fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
212
+ }
213
+
214
+ // Setup keyboard navigation
215
+ this.setupKeyboardNavigation();
216
+
217
+ // Load history and show initial message
218
+ this.loadHistory();
219
+ if (this.messageList.children.length === 0) {
220
+ this.addMessage(this.initialMessage, 'ai');
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Toggle chat window open/close
226
+ */
227
+ toggleChatWindow() {
228
+ if (!this.isOpen) {
229
+ if (!this.container) {
230
+ this.initChatWindow();
231
+ }
232
+
233
+ setTimeout(() => {
234
+ this.container.classList.add('n8n-chat-widget--open');
235
+ if (this.launcher) {
236
+ this.launcher.classList.add('n8n-chat-launcher--hidden');
237
+ }
238
+ this.input.focus();
239
+ this.clearUnreadCount();
240
+ }, 10);
241
+ } else {
242
+ this.container.classList.remove('n8n-chat-widget--open');
243
+ if (this.launcher) {
244
+ this.launcher.classList.remove('n8n-chat-launcher--hidden');
245
+ }
246
+ }
247
+ this.isOpen = !this.isOpen;
248
+ this.saveState();
249
+ }
250
+
251
+ /**
252
+ * Get default chat icon
253
+ */
254
+ getDefaultIcon() {
255
+ const svgIcon = `
256
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
257
+ <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>
258
+ </svg>
259
+ `;
260
+ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgIcon)}`;
261
+ }
262
+
263
+ /**
264
+ * Format timestamp for display
265
+ */
266
+ formatTimestamp(date) {
267
+ const now = new Date();
268
+ const diff = now - date;
269
+
270
+ if (diff < 60000) {
271
+ return this.i18n.t('justNow');
272
+ }
273
+ if (diff < 3600000) {
274
+ const minutes = Math.floor(diff / 60000);
275
+ return this.i18n.t('minutesAgo', { minutes });
276
+ }
277
+
278
+ return date.toLocaleTimeString(this.locale === 'id' ? 'id-ID' : 'en-US', {
279
+ hour: '2-digit',
280
+ minute: '2-digit'
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Add message to chat
286
+ */
287
+ addMessage(text, sender, timestamp = new Date()) {
288
+ const messageBubble = document.createElement('div');
289
+ messageBubble.className = `n8n-chat-bubble n8n-chat-${sender}-message`;
290
+
291
+ const textNode = document.createElement('div');
292
+ textNode.className = 'n8n-chat-message-text';
293
+ textNode.textContent = text;
294
+
295
+ const timeNode = document.createElement('div');
296
+ timeNode.className = 'n8n-chat-message-timestamp';
297
+ timeNode.textContent = this.formatTimestamp(timestamp);
298
+ timeNode.setAttribute('data-timestamp', timestamp.toISOString());
299
+
300
+ messageBubble.appendChild(textNode);
301
+ messageBubble.appendChild(timeNode);
302
+ this.messageList.appendChild(messageBubble);
303
+
304
+ this.messageList.scrollTop = this.messageList.scrollHeight;
305
+
306
+ // Save to history
307
+ this.saveToHistory(text, sender, timestamp);
308
+
309
+ // Increment unread if chat closed and AI message
310
+ if (!this.isOpen && sender === 'ai') {
311
+ this.incrementUnread();
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Show/hide typing indicator
317
+ */
318
+ showTypingIndicator(show) {
319
+ let indicator = this.messageList.querySelector('.n8n-chat-typing-indicator');
320
+ if (show) {
321
+ if (!indicator) {
322
+ indicator = document.createElement('div');
323
+ indicator.className = 'n8n-chat-bubble n8n-chat-ai-message n8n-chat-typing-indicator';
324
+ indicator.innerHTML = '<span></span><span></span><span></span>';
325
+ indicator.setAttribute('aria-label', this.i18n.t('typingIndicator'));
326
+ this.messageList.appendChild(indicator);
327
+ this.messageList.scrollTop = this.messageList.scrollHeight;
328
+ }
329
+ } else {
330
+ if (indicator) {
331
+ indicator.remove();
332
+ }
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Handle file selection
338
+ */
339
+ handleFileSelect(event) {
340
+ const files = Array.from(event.target.files);
341
+
342
+ files.forEach(file => {
343
+ // Check file type
344
+ if (!this.allowedFileTypes.includes(file.type)) {
345
+ this.addMessage(
346
+ this.i18n.t('fileTypeNotSupported', { filename: file.name }),
347
+ 'ai'
348
+ );
349
+ return;
350
+ }
351
+
352
+ // Check file size
353
+ if (file.size > this.maxFileSize) {
354
+ const maxSizeMB = this.maxFileSize / 1024 / 1024;
355
+ this.addMessage(
356
+ this.i18n.t('fileTooLarge', { filename: file.name, maxSize: maxSizeMB }),
357
+ 'ai'
358
+ );
359
+ return;
360
+ }
361
+
362
+ // Check max files
363
+ if (this.uploadedFiles.length >= this.maxFiles) {
364
+ this.addMessage(
365
+ this.i18n.t('maxFilesExceeded', { maxFiles: this.maxFiles }),
366
+ 'ai'
367
+ );
368
+ return;
369
+ }
370
+
371
+ this.uploadedFiles.push(file);
372
+ this.renderFilePreview(file);
373
+ });
374
+
375
+ event.target.value = ''; // Reset input
376
+ }
377
+
378
+ /**
379
+ * Render file preview
380
+ */
381
+ renderFilePreview(file) {
382
+ const preview = this.container.querySelector('.n8n-chat-file-preview');
383
+ if (!preview) return;
384
+
385
+ preview.style.display = 'flex';
386
+
387
+ const fileItem = document.createElement('div');
388
+ fileItem.className = 'n8n-chat-file-item';
389
+ fileItem.innerHTML = `
390
+ <span class="n8n-chat-file-name">${file.name}</span>
391
+ <button class="n8n-chat-file-remove" data-file="${file.name}" aria-label="${this.i18n.t('ariaRemoveFile')}">&times;</button>
392
+ `;
393
+
394
+ const removeBtn = fileItem.querySelector('.n8n-chat-file-remove');
395
+ removeBtn.addEventListener('click', () => {
396
+ this.uploadedFiles = this.uploadedFiles.filter(f => f.name !== file.name);
397
+ fileItem.remove();
398
+ if (this.uploadedFiles.length === 0) {
399
+ preview.style.display = 'none';
400
+ }
401
+ });
402
+
403
+ preview.appendChild(fileItem);
404
+ }
405
+
406
+ /**
407
+ * Send message with files
408
+ */
409
+ async sendMessage() {
410
+ const messageText = this.input.value.trim();
411
+ if ((!messageText && this.uploadedFiles.length === 0) || this.isLoading) return;
412
+
413
+ // Check rate limit
414
+ try {
415
+ this.checkRateLimit();
416
+ } catch (error) {
417
+ this.addMessage(error.message, 'ai');
418
+ return;
419
+ }
420
+
421
+ // Check online status
422
+ if (!this.isOnline) {
423
+ this.addMessage(this.i18n.t('connectionLost'), 'ai');
424
+ return;
425
+ }
426
+
427
+ this.isLoading = true;
428
+ this.button.disabled = true;
429
+ this.input.disabled = true;
430
+
431
+ if (messageText) {
432
+ this.addMessage(messageText, 'user');
433
+ }
434
+
435
+ this.input.value = '';
436
+ this.showTypingIndicator(true);
437
+
438
+ try {
439
+ const response = await this.sendMessageWithRetry(messageText, this.uploadedFiles);
440
+ this.addMessage(response, 'ai');
441
+
442
+ // Show quick replies after AI response
443
+ if (this.showQuickReplies && this.quickReplies.length > 0) {
444
+ this.renderQuickReplies();
445
+ }
446
+ } catch (error) {
447
+ const errorMessage = this.getErrorMessage(error);
448
+ this.addMessage(errorMessage, 'ai');
449
+ } finally {
450
+ this.isLoading = false;
451
+ this.button.disabled = false;
452
+ this.input.disabled = false;
453
+ this.showTypingIndicator(false);
454
+ this.input.focus();
455
+
456
+ // Clear uploaded files
457
+ if (this.uploadedFiles.length > 0) {
458
+ this.uploadedFiles = [];
459
+ const preview = this.container.querySelector('.n8n-chat-file-preview');
460
+ if (preview) {
461
+ preview.innerHTML = '';
462
+ preview.style.display = 'none';
463
+ }
464
+ }
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Send message with retry logic
470
+ */
471
+ async sendMessageWithRetry(messageText, files = [], retryCount = 0) {
472
+ try {
473
+ const controller = new AbortController();
474
+ const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
475
+
476
+ const formData = new FormData();
477
+ formData.append('sessionId', this.sessionId);
478
+ formData.append('message', messageText);
479
+
480
+ // Append files
481
+ files.forEach((file, index) => {
482
+ formData.append(`file${index}`, file);
483
+ });
484
+
485
+ const response = await fetch(this.webhookUrl, {
486
+ method: 'POST',
487
+ body: formData,
488
+ signal: controller.signal
489
+ });
490
+
491
+ clearTimeout(timeoutId);
492
+
493
+ if (!response.ok) {
494
+ // Retry on 5xx errors
495
+ if (response.status >= 500 && retryCount < this.maxRetries) {
496
+ await this.delay(this.retryDelay * (retryCount + 1));
497
+ return this.sendMessageWithRetry(messageText, files, retryCount + 1);
498
+ }
499
+
500
+ const errorData = await response.json().catch(() => ({}));
501
+ throw new Error(errorData.message || `Network error: ${response.statusText}`);
502
+ }
503
+
504
+ const data = await response.json();
505
+ if (!data.response) {
506
+ throw new Error(this.i18n.t('errorInvalidResponse'));
507
+ }
508
+
509
+ return data.response;
510
+
511
+ } catch (error) {
512
+ if (error.name === 'AbortError') {
513
+ throw new Error(this.i18n.t('errorTimeout'));
514
+ }
515
+
516
+ // Retry on network errors
517
+ if (error.message.includes('NetworkError') && retryCount < this.maxRetries) {
518
+ await this.delay(this.retryDelay * (retryCount + 1));
519
+ return this.sendMessageWithRetry(messageText, files, retryCount + 1);
520
+ }
521
+
522
+ throw error;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Delay helper
528
+ */
529
+ delay(ms) {
530
+ return new Promise(resolve => setTimeout(resolve, ms));
531
+ }
532
+
533
+ /**
534
+ * Get user-friendly error message
535
+ */
536
+ getErrorMessage(error) {
537
+ if (error.message.includes('timeout') || error.message.includes('Timeout')) {
538
+ return this.i18n.t('errorTimeout');
539
+ }
540
+ if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) {
541
+ return this.i18n.t('errorNetwork');
542
+ }
543
+ if (error.message.includes('500') || error.message.includes('503')) {
544
+ return this.i18n.t('errorServer');
545
+ }
546
+ return this.i18n.t('errorGeneric');
547
+ }
548
+
549
+ /**
550
+ * Check rate limit
551
+ */
552
+ checkRateLimit() {
553
+ const now = Date.now();
554
+ this.messageTimes = this.messageTimes.filter(
555
+ time => now - time < this.rateLimitWindow
556
+ );
557
+
558
+ if (this.messageTimes.length >= this.rateLimit) {
559
+ const oldestTime = this.messageTimes[0];
560
+ const waitTime = Math.ceil((this.rateLimitWindow - (now - oldestTime)) / 1000);
561
+ throw new Error(this.i18n.t('errorRateLimit', { seconds: waitTime }));
562
+ }
563
+
564
+ this.messageTimes.push(now);
565
+ }
566
+
567
+ /**
568
+ * Render quick reply buttons
569
+ */
570
+ renderQuickReplies(replies = this.quickReplies) {
571
+ if (!this.showQuickReplies || replies.length === 0) return;
572
+
573
+ // Remove existing quick replies
574
+ const existingReplies = this.messageList.querySelector('.n8n-chat-quick-replies');
575
+ if (existingReplies) existingReplies.remove();
576
+
577
+ const repliesContainer = document.createElement('div');
578
+ repliesContainer.className = 'n8n-chat-quick-replies';
579
+
580
+ replies.forEach(reply => {
581
+ const button = document.createElement('button');
582
+ button.className = 'n8n-chat-quick-reply-btn';
583
+ button.textContent = reply;
584
+ button.addEventListener('click', () => {
585
+ this.input.value = reply;
586
+ this.sendMessage();
587
+ repliesContainer.remove();
588
+ });
589
+ repliesContainer.appendChild(button);
590
+ });
591
+
592
+ this.messageList.appendChild(repliesContainer);
593
+ this.messageList.scrollTop = this.messageList.scrollHeight;
594
+ }
595
+
596
+ /**
597
+ * Notification badge management
598
+ */
599
+ incrementUnread() {
600
+ if (!this.isOpen) {
601
+ this.unreadCount++;
602
+ this.updateUnreadBadge();
603
+ }
604
+ }
605
+
606
+ updateUnreadBadge() {
607
+ if (!this.launcher) return;
608
+ const badge = this.launcher.querySelector('.n8n-chat-unread-badge');
609
+ if (badge) {
610
+ badge.textContent = this.unreadCount;
611
+ badge.style.display = this.unreadCount > 0 ? 'flex' : 'none';
612
+ }
613
+ }
614
+
615
+ clearUnreadCount() {
616
+ this.unreadCount = 0;
617
+ this.updateUnreadBadge();
618
+ }
619
+
620
+ /**
621
+ * Message history persistence
622
+ */
623
+ loadHistory() {
624
+ if (!this.enableHistory) return;
625
+
626
+ try {
627
+ const stored = localStorage.getItem(this.historyKey);
628
+ if (!stored) return;
629
+
630
+ const history = JSON.parse(stored);
631
+ history.forEach(item => {
632
+ this.addMessageWithoutSaving(item.text, item.sender, new Date(item.timestamp));
633
+ });
634
+ } catch (error) {
635
+ console.warn('Failed to load chat history:', error);
636
+ }
637
+ }
638
+
639
+ addMessageWithoutSaving(text, sender, timestamp) {
640
+ const messageBubble = document.createElement('div');
641
+ messageBubble.className = `n8n-chat-bubble n8n-chat-${sender}-message`;
642
+
643
+ const textNode = document.createElement('div');
644
+ textNode.className = 'n8n-chat-message-text';
645
+ textNode.textContent = text;
646
+
647
+ const timeNode = document.createElement('div');
648
+ timeNode.className = 'n8n-chat-message-timestamp';
649
+ timeNode.textContent = this.formatTimestamp(timestamp);
650
+ timeNode.setAttribute('data-timestamp', timestamp.toISOString());
651
+
652
+ messageBubble.appendChild(textNode);
653
+ messageBubble.appendChild(timeNode);
654
+ this.messageList.appendChild(messageBubble);
655
+
656
+ this.messageList.scrollTop = this.messageList.scrollHeight;
657
+ }
658
+
659
+ saveToHistory(text, sender, timestamp = new Date()) {
660
+ if (!this.enableHistory) return;
661
+
662
+ try {
663
+ const stored = localStorage.getItem(this.historyKey);
664
+ let history = stored ? JSON.parse(stored) : [];
665
+
666
+ history.push({
667
+ text,
668
+ sender,
669
+ timestamp: timestamp.toISOString()
670
+ });
671
+
672
+ // Keep only last N messages
673
+ if (history.length > this.maxHistoryItems) {
674
+ history = history.slice(-this.maxHistoryItems);
675
+ }
676
+
677
+ localStorage.setItem(this.historyKey, JSON.stringify(history));
678
+ } catch (error) {
679
+ console.warn('Failed to save chat history:', error);
680
+ }
681
+ }
682
+
683
+ /**
684
+ * State persistence
685
+ */
686
+ loadState() {
687
+ try {
688
+ const stored = localStorage.getItem(this.stateKey);
689
+ if (stored) {
690
+ // State loaded but not used for auto-open
691
+ // User needs to explicitly open the chat
692
+ }
693
+ } catch (error) {
694
+ console.warn('Failed to load chat state:', error);
695
+ }
696
+ }
697
+
698
+ saveState() {
699
+ try {
700
+ localStorage.setItem(this.stateKey, JSON.stringify({
701
+ isOpen: this.isOpen,
702
+ timestamp: new Date().toISOString()
703
+ }));
704
+ } catch (error) {
705
+ console.warn('Failed to save chat state:', error);
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Offline detection
711
+ */
712
+ setupOfflineDetection() {
713
+ window.addEventListener('online', () => {
714
+ this.isOnline = true;
715
+ this.updateOnlineStatus();
716
+ });
717
+
718
+ window.addEventListener('offline', () => {
719
+ this.isOnline = false;
720
+ this.updateOnlineStatus();
721
+ });
722
+ }
723
+
724
+ updateOnlineStatus() {
725
+ if (!this.container) return;
726
+
727
+ const statusBar = this.container.querySelector('.n8n-chat-offline-indicator');
728
+ if (!this.isOnline && !statusBar) {
729
+ const indicator = document.createElement('div');
730
+ indicator.className = 'n8n-chat-offline-indicator';
731
+ indicator.innerHTML = `
732
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
733
+ <line x1="1" y1="1" x2="23" y2="23"></line>
734
+ <path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"></path>
735
+ <path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"></path>
736
+ <path d="M10.71 5.05A16 16 0 0 1 22.58 9"></path>
737
+ <path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"></path>
738
+ <path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
739
+ <line x1="12" y1="20" x2="12.01" y2="20"></line>
740
+ </svg>
741
+ <span>${this.i18n.t('offline')}</span>
742
+ `;
743
+ this.container.insertBefore(indicator, this.messageList);
744
+ } else if (this.isOnline && statusBar) {
745
+ statusBar.remove();
746
+ }
747
+
748
+ // Update button state
749
+ if (this.button) {
750
+ this.button.disabled = !this.isOnline || this.isLoading;
751
+ }
752
+ }
753
+
754
+ /**
755
+ * Keyboard navigation setup
756
+ */
757
+ setupKeyboardNavigation() {
758
+ // ESC to close widget
759
+ document.addEventListener('keydown', (e) => {
760
+ if (e.key === 'Escape' && this.isOpen && this.mode === 'widget') {
761
+ this.toggleChatWindow();
762
+ }
763
+ });
764
+
765
+ // Tab trap within modal
766
+ this.container.addEventListener('keydown', (e) => {
767
+ if (e.key === 'Tab') {
768
+ const focusableElements = this.container.querySelectorAll(
769
+ 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
770
+ );
771
+ const firstElement = focusableElements[0];
772
+ const lastElement = focusableElements[focusableElements.length - 1];
773
+
774
+ if (e.shiftKey && document.activeElement === firstElement) {
775
+ e.preventDefault();
776
+ lastElement.focus();
777
+ } else if (!e.shiftKey && document.activeElement === lastElement) {
778
+ e.preventDefault();
779
+ firstElement.focus();
780
+ }
781
+ }
782
+ });
783
+ }
784
+ }
785
+
786
+ // Export both class names for backward compatibility
787
+ window.PindaiChatWidget = {
788
+ init: (options) => {
789
+ if (!document.querySelector('.n8n-chat-widget') && !document.querySelector('.n8n-chat-launcher')) {
790
+ return new PindaiChatWidget(options);
791
+ }
792
+ }
793
+ };
794
+
795
+ // Backward compatibility with old name
796
+ window.N8nChatWidget = window.PindaiChatWidget;