@ihoomanai/chat-widget 3.0.0 → 3.0.1

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/dist/index.umd.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @ihoomanai/chat-widget v3.0.0
2
+ * @ihoomanai/chat-widget v3.0.1
3
3
  * Universal chat support widget for any website - secure Widget ID based initialization
4
4
  *
5
5
  * @license MIT
@@ -13,27 +13,14 @@
13
13
 
14
14
  /**
15
15
  * Ihooman Chat Widget - Core Implementation
16
- *
17
- * Secure Widget ID based initialization - no API keys in client code.
18
- * Fetches configuration from Widget Configuration API using Widget ID.
19
- *
20
- * @module widget
21
- * @version 2.0.0
22
- * @license MIT
23
- *
24
- * Requirements:
25
- * - 5.2: Export IhoomanChat object with init, open, close, toggle, destroy methods
26
- * - 5.3: Accept widgetId configuration option for initialization
27
- * - 10.1: Widget only requires Widget ID, never API key
28
- * - 10.2: Widget fetches configuration using only Widget ID
16
+ * Enhanced with professional features
17
+ * @version 3.0.0
29
18
  */
30
- const VERSION = '2.0.0';
19
+ const VERSION = '3.0.1';
31
20
  const STORAGE_PREFIX = 'ihooman_chat_';
32
- /**
33
- * Default widget configuration
34
- */
21
+ const DEFAULT_SERVER_URL = 'https://api.ihooman.ai';
35
22
  const defaultConfig = {
36
- serverUrl: 'https://api.ihooman.ai',
23
+ serverUrl: DEFAULT_SERVER_URL,
37
24
  theme: 'light',
38
25
  position: 'bottom-right',
39
26
  title: 'Chat Support',
@@ -57,10 +44,12 @@
57
44
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
58
45
  avatarUrl: '',
59
46
  poweredBy: true,
47
+ presetQuestions: [],
48
+ proactiveMessages: [],
49
+ surveyConfig: null,
50
+ locale: 'en',
51
+ allowLocalhost: true,
60
52
  };
61
- /**
62
- * SVG icons for the widget UI
63
- */
64
53
  const icons = {
65
54
  chat: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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></svg>`,
66
55
  close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
@@ -69,60 +58,65 @@
69
58
  minimize: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
70
59
  bot: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="10" rx="2"></rect><circle cx="12" cy="5" r="2"></circle><path d="M12 7v4"></path></svg>`,
71
60
  agent: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`,
72
- ticket: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><line x1="10" y1="9" x2="8" y2="9"></line></svg>`,
61
+ ticket: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
73
62
  history: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>`,
74
63
  plus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
75
- };
76
- /**
77
- * Internal widget state
78
- */
64
+ star: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path></svg>`,
65
+ starEmpty: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path></svg>`,
66
+ thumbUp: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"></path></svg>`,
67
+ thumbDown: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"></path></svg>`};
79
68
  let config = { widgetId: '', ...defaultConfig };
80
69
  let state = {
81
70
  isOpen: false,
82
71
  isConnected: false,
83
72
  messages: [],
73
+ pendingMessages: [],
84
74
  sessionId: null,
85
75
  visitorId: null,
86
76
  unreadCount: 0,
77
+ view: 'chat',
78
+ connectionStatus: 'disconnected',
79
+ typingIndicator: false,
80
+ soundMuted: false,
81
+ escalationStatus: null,
82
+ userInfo: null,
87
83
  };
88
84
  let currentView = 'chat';
89
85
  let isLiveAgentMode = false;
86
+ let shownProactiveIds = [];
87
+ let proactiveCooldowns = {};
88
+ let proactiveCheckInterval = null;
89
+ let presetQuestions = [];
90
90
  let elements = {};
91
91
  const eventListeners = {};
92
- /**
93
- * WebSocket and polling state
94
- */
95
92
  let ws = null;
96
93
  let pollInterval = null;
97
94
  let reconnectAttempts = 0;
98
95
  const maxReconnectAttempts = 5;
99
- let intentionalDisconnect = false; // Flag to prevent reconnection on intentional close
100
- // ============================================================================
101
- // UTILITIES
102
- // ============================================================================
103
- /**
104
- * Generate a unique ID with optional prefix
105
- */
96
+ let intentionalDisconnect = false;
97
+ let heartbeatInterval = null;
98
+ let liveAgentPollInterval = null;
106
99
  function generateId(prefix = '') {
107
100
  return prefix + Math.random().toString(36).slice(2, 14) + Date.now().toString(36);
108
101
  }
109
- /**
110
- * Format a date to time string
111
- */
112
102
  function formatTime(date) {
113
103
  return new Date(date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
114
104
  }
115
- /**
116
- * Escape HTML to prevent XSS
117
- */
105
+ function timeAgo(date) {
106
+ const diff = Date.now() - new Date(date).getTime();
107
+ if (diff < 60000)
108
+ return 'now';
109
+ if (diff < 3600000)
110
+ return Math.floor(diff / 60000) + 'm';
111
+ if (diff < 86400000)
112
+ return Math.floor(diff / 3600000) + 'h';
113
+ return Math.floor(diff / 86400000) + 'd';
114
+ }
118
115
  function escapeHtml(text) {
119
116
  const div = document.createElement('div');
120
117
  div.textContent = text;
121
118
  return div.innerHTML;
122
119
  }
123
- /**
124
- * Parse basic markdown to HTML
125
- */
126
120
  function parseMarkdown(text) {
127
121
  if (!text)
128
122
  return '';
@@ -130,11 +124,9 @@
130
124
  .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
131
125
  .replace(/\*(.*?)\*/g, '<em>$1</em>')
132
126
  .replace(/`(.*?)`/g, '<code>$1</code>')
127
+ .replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
133
128
  .replace(/\n/g, '<br>');
134
129
  }
135
- /**
136
- * Local storage helper with prefix
137
- */
138
130
  function storage(key, value) {
139
131
  const fullKey = STORAGE_PREFIX + key;
140
132
  try {
@@ -155,9 +147,6 @@
155
147
  return null;
156
148
  }
157
149
  }
158
- /**
159
- * Emit an event to all registered listeners
160
- */
161
150
  function emit(event, data) {
162
151
  const listeners = eventListeners[event] || [];
163
152
  listeners.forEach((fn) => {
@@ -165,10 +154,9 @@
165
154
  fn(data);
166
155
  }
167
156
  catch (e) {
168
- console.error(`Error in ${event} event handler:`, e);
157
+ console.error(`Error in ${event} handler:`, e);
169
158
  }
170
159
  });
171
- // Also call config callbacks
172
160
  const callbackName = `on${event.charAt(0).toUpperCase()}${event.slice(1)}`;
173
161
  const callback = config[callbackName];
174
162
  if (typeof callback === 'function') {
@@ -176,16 +164,27 @@
176
164
  callback(data);
177
165
  }
178
166
  catch (e) {
179
- console.error(`Error in ${callbackName} callback:`, e);
167
+ console.error(`Error in ${callbackName}:`, e);
180
168
  }
181
169
  }
182
170
  }
171
+ function getCurrentScrollDepth() {
172
+ const scrollTop = window.scrollY || document.documentElement.scrollTop;
173
+ const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
174
+ return scrollHeight > 0 ? Math.round((scrollTop / scrollHeight) * 100) : 0;
175
+ }
176
+ function matchUrlPattern(pattern) {
177
+ try {
178
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
179
+ return regex.test(window.location.href);
180
+ }
181
+ catch {
182
+ return window.location.href.includes(pattern);
183
+ }
184
+ }
183
185
  // ============================================================================
184
186
  // STYLES
185
187
  // ============================================================================
186
- /**
187
- * Generate CSS styles for the widget
188
- */
189
188
  function generateStyles() {
190
189
  const { primaryColor, gradientFrom, gradientTo, fontFamily, borderRadius, zIndex, width, height, buttonSize } = config;
191
190
  const isDark = config.theme === 'dark';
@@ -267,7 +266,6 @@
267
266
  .ihooman-file-input { display: none; }
268
267
  .ihooman-powered { text-align: center; padding: 8px; font-size: 11px; color: ${mutedColor}; background: ${bgColor}; }
269
268
  .ihooman-powered a { color: ${primaryColor}; text-decoration: none; }
270
- .ihooman-error { padding: 16px; text-align: center; color: #ef4444; background: ${bgColor}; }
271
269
  .ihooman-escalation-actions { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
272
270
  .ihooman-widget .ihooman-escalation-btn { display: inline-flex !important; align-items: center !important; gap: 8px !important; padding: 10px 18px !important; border-radius: 8px !important; border: none !important; cursor: pointer !important; font-family: inherit !important; font-size: 13px !important; font-weight: 500 !important; transition: all 0.2s ease !important; width: auto !important; height: auto !important; min-width: 120px !important; }
273
271
  .ihooman-widget .ihooman-escalation-btn svg { width: 16px !important; height: 16px !important; flex-shrink: 0 !important; }
@@ -275,7 +273,6 @@
275
273
  .ihooman-widget .ihooman-escalation-btn.primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 174, 255, 0.3); }
276
274
  .ihooman-widget .ihooman-escalation-btn.secondary { background: ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'} !important; color: ${textColor} !important; border: 1px solid ${borderColor} !important; }
277
275
  .ihooman-widget .ihooman-escalation-btn.secondary:hover { background: ${isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.08)'} !important; }
278
- .ihooman-widget .ihooman-escalation-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
279
276
  .ihooman-status-bar { padding: 10px 16px; text-align: center; font-size: 13px; display: none; }
280
277
  .ihooman-status-bar.show { display: block; }
281
278
  .ihooman-status-bar.waiting { background: #fef3c7; color: #92400e; }
@@ -284,7 +281,7 @@
284
281
  .ihooman-chat-view.hidden { display: none; }
285
282
  .ihooman-ticket-view { display: none; flex-direction: column; padding: 20px; gap: 16px; background: ${bgColor}; flex: 1; overflow-y: auto; min-height: 0; }
286
283
  .ihooman-ticket-view.show { display: flex; }
287
- .ihooman-ticket-title { font-size: 18px; font-weight: 600; color: ${textColor}; margin: 0; display: flex; align-items: center; gap: 8px; }
284
+ .ihooman-ticket-title { font-size: 18px; font-weight: 600; color: ${textColor}; margin: 0; }
288
285
  .ihooman-ticket-subtitle { font-size: 13px; color: ${mutedColor}; margin: 0; }
289
286
  .ihooman-ticket-input { padding: 12px 14px; border: 1px solid ${borderColor}; border-radius: 10px; font-size: 14px; font-family: inherit; background: ${inputBg}; color: ${textColor}; outline: none; transition: border-color 0.2s; }
290
287
  .ihooman-ticket-input:focus { border-color: ${primaryColor}; }
@@ -294,7 +291,6 @@
294
291
  .ihooman-ticket-submit:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 174, 255, 0.3); }
295
292
  .ihooman-ticket-submit:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
296
293
  .ihooman-ticket-back { padding: 10px; background: transparent; color: ${mutedColor}; border: 1px solid ${borderColor}; border-radius: 10px; font-size: 13px; cursor: pointer; transition: all 0.2s; }
297
- .ihooman-ticket-back:hover { background: ${isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'}; }
298
294
  .ihooman-history-view { display: none; flex-direction: column; flex: 1; overflow: hidden; background: ${bgColor}; }
299
295
  .ihooman-history-view.show { display: flex; }
300
296
  .ihooman-history-header { padding: 12px 16px; border-bottom: 1px solid ${borderColor}; display: flex; justify-content: space-between; align-items: center; }
@@ -312,25 +308,54 @@
312
308
  .ihooman-preset-questions { padding: 10px 16px; display: flex; flex-wrap: wrap; gap: 6px; background: ${bgColor}; border-top: 1px solid ${borderColor}; }
313
309
  .ihooman-preset-questions:empty { display: none; }
314
310
  .ihooman-preset-questions.hidden { display: none !important; }
315
- .ihooman-widget .ihooman-preset-btn { display: inline-flex !important; align-items: center !important; gap: 4px !important; padding: 6px 12px !important; border-radius: 6px !important; border: 1px solid ${borderColor} !important; background: ${isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)'} !important; color: ${textColor} !important; font-size: 12px !important; font-weight: 500 !important; cursor: pointer !important; transition: all 0.2s !important; white-space: nowrap !important; width: auto !important; height: auto !important; min-width: 0 !important; aspect-ratio: auto !important; }
311
+ .ihooman-widget .ihooman-preset-btn { display: inline-flex !important; align-items: center !important; gap: 4px !important; padding: 6px 12px !important; border-radius: 6px !important; border: 1px solid ${borderColor} !important; background: ${isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)'} !important; color: ${textColor} !important; font-size: 12px !important; font-weight: 500 !important; cursor: pointer !important; transition: all 0.2s !important; white-space: nowrap !important; width: auto !important; height: auto !important; }
316
312
  .ihooman-widget .ihooman-preset-btn:hover { background: ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'} !important; border-color: ${primaryColor} !important; }
317
- .ihooman-widget .ihooman-preset-btn .icon { font-size: 12px !important; flex-shrink: 0 !important; }
318
- @media (max-width: 480px) { .ihooman-window { width: calc(100vw - 20px); height: calc(100vh - 100px); min-height: 400px; max-height: calc(100vh - 100px); left: 10px; right: 10px; bottom: 80px; } .ihooman-toggle { ${positionRight ? 'right: 16px' : 'left: 16px'}; bottom: 16px; } }
313
+ .ihooman-proactive-toast { position: fixed; ${positionRight ? 'right: 20px' : 'left: 20px'}; ${positionBottom ? 'bottom: 90px' : 'top: 90px'}; max-width: 300px; padding: 16px; background: ${bgColor}; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.15); z-index: ${(zIndex ?? 9999) - 2}; opacity: 0; visibility: hidden; transform: translateY(10px); transition: all 0.3s ease; border: 1px solid ${borderColor}; }
314
+ .ihooman-proactive-toast.show { opacity: 1; visibility: visible; transform: translateY(0); }
315
+ .ihooman-proactive-toast-content { font-size: 14px; color: ${textColor}; margin-bottom: 12px; }
316
+ .ihooman-proactive-toast-actions { display: flex; gap: 8px; }
317
+ .ihooman-proactive-toast-btn { padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
318
+ .ihooman-proactive-toast-btn.primary { background: linear-gradient(135deg, ${gradientFrom}, ${gradientTo}); color: white; border: none; }
319
+ .ihooman-proactive-toast-btn.secondary { background: transparent; color: ${mutedColor}; border: 1px solid ${borderColor}; }
320
+ .ihooman-survey-view { display: none; flex-direction: column; padding: 20px; gap: 16px; background: ${bgColor}; flex: 1; align-items: center; justify-content: center; }
321
+ .ihooman-survey-view.show { display: flex; }
322
+ .ihooman-survey-question { font-size: 16px; font-weight: 600; color: ${textColor}; text-align: center; }
323
+ .ihooman-survey-stars { display: flex; gap: 8px; }
324
+ .ihooman-survey-star { width: 40px; height: 40px; cursor: pointer; color: ${mutedColor}; transition: all 0.2s; }
325
+ .ihooman-survey-star:hover, .ihooman-survey-star.active { color: #fbbf24; transform: scale(1.1); }
326
+ .ihooman-survey-star svg { width: 100%; height: 100%; }
327
+ .ihooman-survey-comment { width: 100%; padding: 12px; border: 1px solid ${borderColor}; border-radius: 8px; font-size: 14px; resize: none; min-height: 80px; background: ${inputBg}; color: ${textColor}; }
328
+ .ihooman-survey-submit { padding: 12px 24px; background: linear-gradient(135deg, ${gradientFrom}, ${gradientTo}); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
329
+ .ihooman-survey-skip { padding: 8px 16px; background: transparent; color: ${mutedColor}; border: none; font-size: 13px; cursor: pointer; }
330
+ .ihooman-feedback-btns { display: flex; gap: 8px; margin-top: 8px; }
331
+ .ihooman-feedback-btn { padding: 4px 8px; border: 1px solid ${borderColor}; border-radius: 4px; background: transparent; cursor: pointer; color: ${mutedColor}; transition: all 0.2s; }
332
+ .ihooman-feedback-btn:hover { background: ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}; }
333
+ .ihooman-feedback-btn.active { background: ${primaryColor}; color: white; border-color: ${primaryColor}; }
334
+ .ihooman-feedback-btn svg { width: 14px; height: 14px; }
335
+ .ihooman-carousel { display: flex; gap: 12px; overflow-x: auto; padding: 8px 0; scroll-snap-type: x mandatory; }
336
+ .ihooman-carousel::-webkit-scrollbar { height: 4px; }
337
+ .ihooman-carousel-card { min-width: 200px; max-width: 250px; border: 1px solid ${borderColor}; border-radius: 12px; overflow: hidden; scroll-snap-align: start; background: ${bgColor}; }
338
+ .ihooman-carousel-card img { width: 100%; height: 120px; object-fit: cover; }
339
+ .ihooman-carousel-card-content { padding: 12px; }
340
+ .ihooman-carousel-card-title { font-size: 14px; font-weight: 600; color: ${textColor}; margin-bottom: 4px; }
341
+ .ihooman-carousel-card-desc { font-size: 12px; color: ${mutedColor}; margin-bottom: 8px; }
342
+ .ihooman-carousel-card-btns { display: flex; flex-direction: column; gap: 6px; }
343
+ .ihooman-carousel-card-btn { padding: 8px 12px; border: 1px solid ${borderColor}; border-radius: 6px; background: transparent; color: ${textColor}; font-size: 12px; cursor: pointer; transition: all 0.2s; text-align: center; }
344
+ .ihooman-carousel-card-btn:hover { background: ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}; border-color: ${primaryColor}; }
345
+ .ihooman-quick-replies { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
346
+ .ihooman-quick-reply { padding: 6px 12px; border: 1px solid ${primaryColor}; border-radius: 16px; background: transparent; color: ${primaryColor}; font-size: 12px; cursor: pointer; transition: all 0.2s; }
347
+ .ihooman-quick-reply:hover { background: ${primaryColor}; color: white; }
348
+ @media (max-width: 480px) { .ihooman-window { width: calc(100vw - 20px); height: calc(100vh - 100px); min-height: 400px; max-height: calc(100vh - 100px); left: 10px; right: 10px; bottom: 80px; } .ihooman-toggle { ${positionRight ? 'right: 16px' : 'left: 16px'}; bottom: 16px; } .ihooman-proactive-toast { left: 10px; right: 10px; max-width: none; } }
319
349
  `;
320
350
  }
321
351
  // ============================================================================
322
352
  // DOM CREATION
323
353
  // ============================================================================
324
- /**
325
- * Create the widget DOM elements
326
- */
327
354
  function createWidget() {
328
- // Add styles
329
355
  const styleEl = document.createElement('style');
330
356
  styleEl.id = 'ihooman-widget-styles';
331
357
  styleEl.textContent = generateStyles();
332
358
  document.head.appendChild(styleEl);
333
- // Create widget container
334
359
  const widget = document.createElement('div');
335
360
  widget.className = 'ihooman-widget';
336
361
  widget.innerHTML = `
@@ -340,6 +365,13 @@
340
365
  <span class="chat-icon">${icons.chat}</span>
341
366
  <span class="close-icon">${icons.close}</span>
342
367
  </button>
368
+ <div class="ihooman-proactive-toast">
369
+ <div class="ihooman-proactive-toast-content"></div>
370
+ <div class="ihooman-proactive-toast-actions">
371
+ <button class="ihooman-proactive-toast-btn primary">Chat Now</button>
372
+ <button class="ihooman-proactive-toast-btn secondary">Dismiss</button>
373
+ </div>
374
+ </div>
343
375
  <div class="ihooman-window" role="dialog" aria-label="Chat window">
344
376
  <div class="ihooman-container">
345
377
  <div class="ihooman-header">
@@ -354,8 +386,6 @@
354
386
  <button class="ihooman-header-btn" data-action="minimize" title="Minimize">${icons.minimize}</button>
355
387
  </div>
356
388
  </div>
357
-
358
- <!-- Chat View -->
359
389
  <div class="ihooman-chat-view">
360
390
  <div class="ihooman-status-bar"></div>
361
391
  <div class="ihooman-messages" role="log" aria-live="polite"></div>
@@ -368,8 +398,6 @@
368
398
  </div>
369
399
  </div>
370
400
  </div>
371
-
372
- <!-- Ticket Form View -->
373
401
  <div class="ihooman-ticket-view">
374
402
  <h4 class="ihooman-ticket-title">📝 Submit a Ticket</h4>
375
403
  <p class="ihooman-ticket-subtitle">We'll get back to you via email</p>
@@ -379,22 +407,25 @@
379
407
  <button class="ihooman-ticket-submit" id="ihooman-ticket-submit">Submit Ticket</button>
380
408
  <button class="ihooman-ticket-back" id="ihooman-ticket-back">← Back to Chat</button>
381
409
  </div>
382
-
383
- <!-- History View -->
384
410
  <div class="ihooman-history-view">
385
411
  <div class="ihooman-history-header">
386
412
  <span class="ihooman-history-title">Your Conversations</span>
387
- <button class="ihooman-history-new" style="all: revert; display: inline-flex !important; align-items: center !important; gap: 4px !important; background: linear-gradient(135deg, ${config.gradientFrom}, ${config.gradientTo}) !important; color: white !important; border: none !important; padding: 6px 10px !important; border-radius: 6px !important; font-size: 12px !important; font-weight: 500 !important; cursor: pointer !important; width: auto !important; height: auto !important; min-width: 0 !important; aspect-ratio: auto !important;">${icons.plus} New Chat</button>
413
+ <button class="ihooman-history-new">${icons.plus} New Chat</button>
388
414
  </div>
389
415
  <div class="ihooman-history-list"></div>
390
416
  </div>
391
-
417
+ <div class="ihooman-survey-view">
418
+ <div class="ihooman-survey-question">${config.surveyConfig?.question || 'How was your experience?'}</div>
419
+ <div class="ihooman-survey-stars"></div>
420
+ <textarea class="ihooman-survey-comment" placeholder="Any additional feedback? (optional)"></textarea>
421
+ <button class="ihooman-survey-submit">Submit Feedback</button>
422
+ <button class="ihooman-survey-skip">Skip</button>
423
+ </div>
392
424
  ${config.poweredBy ? `<div class="ihooman-powered">Powered by <a href="https://ihooman.ai" target="_blank" rel="noopener">Ihooman AI</a></div>` : ''}
393
425
  </div>
394
426
  </div>
395
427
  `;
396
428
  document.body.appendChild(widget);
397
- // Store element references
398
429
  elements = {
399
430
  widget,
400
431
  toggle: widget.querySelector('.ihooman-toggle'),
@@ -403,8 +434,8 @@
403
434
  chatView: widget.querySelector('.ihooman-chat-view'),
404
435
  ticketView: widget.querySelector('.ihooman-ticket-view'),
405
436
  historyView: widget.querySelector('.ihooman-history-view'),
437
+ surveyView: widget.querySelector('.ihooman-survey-view'),
406
438
  historyList: widget.querySelector('.ihooman-history-list'),
407
- historyNewBtn: widget.querySelector('.ihooman-history-new'),
408
439
  messages: widget.querySelector('.ihooman-messages'),
409
440
  presetQuestions: widget.querySelector('.ihooman-preset-questions'),
410
441
  input: widget.querySelector('.ihooman-input'),
@@ -419,22 +450,18 @@
419
450
  ticketIssue: widget.querySelector('#ihooman-ticket-issue'),
420
451
  ticketSubmitBtn: widget.querySelector('#ihooman-ticket-submit'),
421
452
  ticketBackBtn: widget.querySelector('#ihooman-ticket-back'),
453
+ proactiveToast: widget.querySelector('.ihooman-proactive-toast'),
422
454
  };
423
- // Set up event listeners
424
455
  setupEventListeners();
425
456
  }
426
- /**
427
- * Set up DOM event listeners
428
- */
429
457
  function setupEventListeners() {
430
458
  if (!elements.toggle || !elements.sendBtn || !elements.input)
431
459
  return;
432
460
  elements.toggle.addEventListener('click', toggle);
433
461
  elements.sendBtn.addEventListener('click', handleSendClick);
434
462
  elements.input.addEventListener('input', () => {
435
- if (elements.sendBtn) {
463
+ if (elements.sendBtn)
436
464
  elements.sendBtn.disabled = !elements.input?.value.trim();
437
- }
438
465
  if (elements.input) {
439
466
  elements.input.style.height = 'auto';
440
467
  elements.input.style.height = Math.min(elements.input.scrollHeight, 100) + 'px';
@@ -454,27 +481,79 @@
454
481
  elements.widget?.querySelector('[data-action="refresh"]')?.addEventListener('click', startNewConversation);
455
482
  elements.widget?.querySelector('[data-action="minimize"]')?.addEventListener('click', close);
456
483
  elements.widget?.querySelector('[data-action="history"]')?.addEventListener('click', toggleHistoryView);
457
- // Ticket form buttons
458
- if (elements.ticketSubmitBtn) {
484
+ if (elements.ticketSubmitBtn)
459
485
  elements.ticketSubmitBtn.addEventListener('click', handleSubmitTicket);
460
- }
461
- if (elements.ticketBackBtn) {
486
+ if (elements.ticketBackBtn)
462
487
  elements.ticketBackBtn.addEventListener('click', () => showView('chat'));
463
- }
464
- // History view buttons
465
- if (elements.historyNewBtn) {
466
- elements.historyNewBtn.addEventListener('click', () => {
488
+ const historyNewBtn = elements.widget?.querySelector('.ihooman-history-new');
489
+ if (historyNewBtn) {
490
+ historyNewBtn.addEventListener('click', () => {
467
491
  startNewConversation();
468
492
  showView('chat');
469
493
  });
470
494
  }
495
+ // Proactive toast buttons
496
+ const proactivePrimaryBtn = elements.proactiveToast?.querySelector('.ihooman-proactive-toast-btn.primary');
497
+ const proactiveSecondaryBtn = elements.proactiveToast?.querySelector('.ihooman-proactive-toast-btn.secondary');
498
+ if (proactivePrimaryBtn) {
499
+ proactivePrimaryBtn.addEventListener('click', () => {
500
+ hideProactiveToast();
501
+ open();
502
+ emit('proactive:clicked');
503
+ });
504
+ }
505
+ if (proactiveSecondaryBtn) {
506
+ proactiveSecondaryBtn.addEventListener('click', () => {
507
+ hideProactiveToast();
508
+ });
509
+ }
510
+ // Survey buttons
511
+ const surveySubmitBtn = elements.surveyView?.querySelector('.ihooman-survey-submit');
512
+ const surveySkipBtn = elements.surveyView?.querySelector('.ihooman-survey-skip');
513
+ if (surveySubmitBtn)
514
+ surveySubmitBtn.addEventListener('click', handleSurveySubmit);
515
+ if (surveySkipBtn)
516
+ surveySkipBtn.addEventListener('click', () => showView('chat'));
517
+ // Initialize survey stars
518
+ initializeSurveyStars();
519
+ }
520
+ function initializeSurveyStars() {
521
+ const starsContainer = elements.surveyView?.querySelector('.ihooman-survey-stars');
522
+ if (!starsContainer)
523
+ return;
524
+ let selectedRating = 0;
525
+ starsContainer.innerHTML = '';
526
+ for (let i = 1; i <= 5; i++) {
527
+ const star = document.createElement('div');
528
+ star.className = 'ihooman-survey-star';
529
+ star.innerHTML = icons.starEmpty;
530
+ star.dataset.rating = String(i);
531
+ star.addEventListener('click', () => {
532
+ selectedRating = i;
533
+ updateStars(starsContainer, i);
534
+ });
535
+ star.addEventListener('mouseenter', () => updateStars(starsContainer, i));
536
+ star.addEventListener('mouseleave', () => updateStars(starsContainer, selectedRating));
537
+ starsContainer.appendChild(star);
538
+ }
539
+ }
540
+ function updateStars(container, rating) {
541
+ const stars = container.querySelectorAll('.ihooman-survey-star');
542
+ stars.forEach((star, index) => {
543
+ const starEl = star;
544
+ if (index < rating) {
545
+ starEl.innerHTML = icons.star;
546
+ starEl.classList.add('active');
547
+ }
548
+ else {
549
+ starEl.innerHTML = icons.starEmpty;
550
+ starEl.classList.remove('active');
551
+ }
552
+ });
471
553
  }
472
554
  // ============================================================================
473
555
  // MESSAGING
474
556
  // ============================================================================
475
- /**
476
- * Add a message to the chat
477
- */
478
557
  function addMessage(content, sender = 'bot', metadata = {}) {
479
558
  const message = {
480
559
  id: generateId('msg_'),
@@ -488,175 +567,148 @@
488
567
  return message;
489
568
  const el = document.createElement('div');
490
569
  el.className = `ihooman-message ${sender}`;
491
- // Check if this message should show escalation buttons
492
570
  const showEscalationButtons = sender === 'bot' && metadata?.escalation_offered === true;
493
571
  let escalationButtonsHtml = '';
494
572
  if (showEscalationButtons) {
495
- // Use inline styles with !important to override any external CSS
496
- const btnBaseStyle = 'all: revert; display: inline-flex !important; align-items: center !important; justify-content: center !important; gap: 6px !important; padding: 8px 12px !important; border-radius: 6px !important; border: none !important; cursor: pointer !important; font-size: 12px !important; font-weight: 500 !important; line-height: 1.2 !important; width: auto !important; height: auto !important; min-width: 0 !important; min-height: 0 !important; max-width: none !important; max-height: none !important; aspect-ratio: auto !important; box-sizing: border-box !important;';
573
+ const btnBaseStyle = 'all: revert; display: inline-flex !important; align-items: center !important; justify-content: center !important; gap: 6px !important; padding: 8px 12px !important; border-radius: 6px !important; border: none !important; cursor: pointer !important; font-size: 12px !important; font-weight: 500 !important;';
497
574
  const primaryStyle = `${btnBaseStyle} background: linear-gradient(135deg, ${config.gradientFrom}, ${config.gradientTo}) !important; color: white !important;`;
498
575
  const secondaryStyle = `${btnBaseStyle} background: rgba(0,0,0,0.05) !important; color: inherit !important; border: 1px solid rgba(0,0,0,0.1) !important;`;
499
576
  escalationButtonsHtml = `
500
- <div class="ihooman-escalation-actions" style="display: flex !important; gap: 8px !important; margin-top: 10px !important; flex-wrap: wrap !important;">
501
- <button class="ihooman-escalation-btn primary" data-action="live-agent" style="${primaryStyle}">
502
- ${icons.agent}
503
- <span>Talk to Agent</span>
504
- </button>
505
- <button class="ihooman-escalation-btn secondary" data-action="create-ticket" style="${secondaryStyle}">
506
- ${icons.ticket}
507
- <span>Create Ticket</span>
508
- </button>
577
+ <div class="ihooman-escalation-actions">
578
+ <button class="ihooman-escalation-btn primary" data-action="live-agent" style="${primaryStyle}">${icons.agent}<span>Talk to Agent</span></button>
579
+ <button class="ihooman-escalation-btn secondary" data-action="create-ticket" style="${secondaryStyle}">${icons.ticket}<span>Create Ticket</span></button>
580
+ </div>
581
+ `;
582
+ }
583
+ // Quick replies
584
+ let quickRepliesHtml = '';
585
+ if (metadata?.quick_replies && metadata.quick_replies.length > 0) {
586
+ quickRepliesHtml = `<div class="ihooman-quick-replies">${metadata.quick_replies.map(qr => `<button class="ihooman-quick-reply" data-text="${escapeHtml(qr.text)}">${escapeHtml(qr.text)}</button>`).join('')}</div>`;
587
+ }
588
+ // Feedback buttons for bot messages
589
+ let feedbackHtml = '';
590
+ if (sender === 'bot' && !metadata?.is_system_message) {
591
+ feedbackHtml = `
592
+ <div class="ihooman-feedback-btns">
593
+ <button class="ihooman-feedback-btn" data-feedback="up" title="Helpful">${icons.thumbUp}</button>
594
+ <button class="ihooman-feedback-btn" data-feedback="down" title="Not helpful">${icons.thumbDown}</button>
509
595
  </div>
510
596
  `;
511
597
  }
512
598
  el.innerHTML = `
513
599
  <div class="ihooman-message-content">${parseMarkdown(content)}</div>
514
600
  ${escalationButtonsHtml}
601
+ ${quickRepliesHtml}
602
+ ${feedbackHtml}
515
603
  ${config.showTimestamps ? `<div class="ihooman-message-time">${formatTime(message.timestamp)}</div>` : ''}
516
604
  `;
517
- // Add event listeners for escalation buttons
605
+ // Event listeners
518
606
  if (showEscalationButtons) {
519
- const liveAgentBtn = el.querySelector('[data-action="live-agent"]');
520
- const ticketBtn = el.querySelector('[data-action="create-ticket"]');
521
- if (liveAgentBtn) {
522
- liveAgentBtn.addEventListener('click', () => handleEscalationAction('live-agent'));
523
- }
524
- if (ticketBtn) {
525
- ticketBtn.addEventListener('click', () => handleEscalationAction('create-ticket'));
526
- }
607
+ el.querySelector('[data-action="live-agent"]')?.addEventListener('click', () => handleEscalationAction('live-agent'));
608
+ el.querySelector('[data-action="create-ticket"]')?.addEventListener('click', () => handleEscalationAction('create-ticket'));
527
609
  }
528
- // Remove typing indicator if present
610
+ // Quick reply listeners
611
+ el.querySelectorAll('.ihooman-quick-reply').forEach(btn => {
612
+ btn.addEventListener('click', () => {
613
+ const text = btn.dataset.text;
614
+ if (text) {
615
+ hidePresetQuestions();
616
+ addMessage(text, 'user');
617
+ showTyping();
618
+ sendMessageToServer(text);
619
+ }
620
+ });
621
+ });
622
+ // Feedback listeners
623
+ el.querySelectorAll('.ihooman-feedback-btn').forEach(btn => {
624
+ btn.addEventListener('click', () => {
625
+ const feedback = btn.dataset.feedback;
626
+ el.querySelectorAll('.ihooman-feedback-btn').forEach(b => b.classList.remove('active'));
627
+ btn.classList.add('active');
628
+ submitFeedback(message.id, feedback === 'up' ? 'positive' : 'negative');
629
+ });
630
+ });
529
631
  const typing = elements.messages.querySelector('.ihooman-typing');
530
632
  if (typing)
531
633
  typing.remove();
532
634
  elements.messages.appendChild(el);
533
635
  elements.messages.scrollTop = elements.messages.scrollHeight;
534
- // Update unread count if widget is closed
535
636
  if (sender === 'bot' && !state.isOpen) {
536
637
  state.unreadCount++;
537
- if (elements.badge) {
638
+ if (elements.badge)
538
639
  elements.badge.textContent = String(state.unreadCount);
539
- }
540
640
  if (config.enableSounds)
541
641
  playSound();
542
642
  }
543
643
  emit('message', message);
544
644
  return message;
545
645
  }
546
- /**
547
- * Render preset questions in the widget
548
- */
549
646
  function renderPresetQuestions() {
647
+ console.debug('[IhoomanChat] Rendering preset questions:', presetQuestions.length);
550
648
  if (!elements.presetQuestions || presetQuestions.length === 0) {
551
- if (elements.presetQuestions) {
649
+ if (elements.presetQuestions)
552
650
  elements.presetQuestions.innerHTML = '';
553
- }
554
651
  return;
555
652
  }
556
653
  elements.presetQuestions.innerHTML = presetQuestions.map(q => `
557
654
  <button class="ihooman-preset-btn" data-question-id="${escapeHtml(q.id)}" data-question-text="${escapeHtml(q.text)}">
558
- ${q.icon ? `<span class="icon">${escapeHtml(q.icon)}</span>` : ''}
655
+ ${q.icon || q.emoji ? `<span class="icon">${escapeHtml(q.icon || q.emoji || '')}</span>` : ''}
559
656
  <span>${escapeHtml(q.text)}</span>
560
657
  </button>
561
658
  `).join('');
562
- // Add click handlers
563
659
  elements.presetQuestions.querySelectorAll('.ihooman-preset-btn').forEach(btn => {
564
660
  btn.addEventListener('click', () => {
565
661
  const questionText = btn.dataset.questionText;
566
- if (questionText) {
662
+ if (questionText)
567
663
  handlePresetQuestionClick(questionText);
568
- }
569
664
  });
570
665
  });
571
666
  }
572
- /**
573
- * Handle preset question click - send the question as a message
574
- */
575
667
  function handlePresetQuestionClick(questionText) {
576
- // Hide preset questions immediately
577
668
  hidePresetQuestions();
578
- // Add the question as a user message
579
669
  addMessage(questionText, 'user');
580
- // Show typing indicator
581
670
  showTyping();
582
- // Send to server
583
671
  sendMessageToServer(questionText);
584
672
  }
585
- /**
586
- * Handle escalation action button clicks
587
- */
588
673
  function handleEscalationAction(action) {
589
- // Disable all escalation buttons to prevent double-clicks
590
674
  const buttons = document.querySelectorAll('.ihooman-escalation-btn');
591
675
  buttons.forEach(btn => btn.disabled = true);
592
- if (action === 'live-agent') {
676
+ if (action === 'live-agent')
593
677
  handleRequestLiveAgent();
594
- }
595
- else if (action === 'create-ticket') {
596
- handleShowTicketForm();
597
- }
678
+ else if (action === 'create-ticket')
679
+ showView('ticket');
598
680
  }
599
- /**
600
- * Switch between chat, ticket, and history views
601
- */
602
681
  function showView(view) {
603
682
  currentView = view;
604
- if (elements.chatView) {
683
+ state.view = view;
684
+ if (elements.chatView)
605
685
  elements.chatView.classList.toggle('hidden', view !== 'chat');
606
- }
607
- if (elements.ticketView) {
686
+ if (elements.ticketView)
608
687
  elements.ticketView.classList.toggle('show', view === 'ticket');
609
- }
610
- if (elements.historyView) {
688
+ if (elements.historyView)
611
689
  elements.historyView.classList.toggle('show', view === 'history');
612
- }
613
- // Load history when showing history view
614
- if (view === 'history') {
690
+ if (elements.surveyView)
691
+ elements.surveyView.classList.toggle('show', view === 'survey');
692
+ if (view === 'history')
615
693
  loadConversationHistory();
616
- }
617
694
  }
618
- /**
619
- * Toggle between chat and history views
620
- */
621
695
  function toggleHistoryView() {
622
- if (currentView === 'history') {
623
- showView('chat');
624
- }
625
- else {
626
- showView('history');
627
- }
628
- }
629
- /**
630
- * Format time ago string
631
- */
632
- function timeAgo(date) {
633
- const diff = Date.now() - new Date(date).getTime();
634
- if (diff < 60000)
635
- return 'now';
636
- if (diff < 3600000)
637
- return Math.floor(diff / 60000) + 'm';
638
- if (diff < 86400000)
639
- return Math.floor(diff / 3600000) + 'h';
640
- return Math.floor(diff / 86400000) + 'd';
696
+ showView(currentView === 'history' ? 'chat' : 'history');
641
697
  }
642
- /**
643
- * Load conversation history from the server
644
- */
645
698
  async function loadConversationHistory() {
646
699
  if (!elements.historyList || !state.visitorId)
647
700
  return;
648
701
  elements.historyList.innerHTML = '<div class="ihooman-history-empty">Loading...</div>';
649
702
  try {
650
703
  const response = await fetch(`${config.serverUrl}/api/widget/conversations?widget_id=${config.widgetId}&visitor_id=${state.visitorId}&limit=20`);
651
- if (!response.ok) {
704
+ if (!response.ok)
652
705
  throw new Error('Failed to load history');
653
- }
654
706
  const conversations = await response.json();
655
707
  if (!conversations.length) {
656
708
  elements.historyList.innerHTML = '<div class="ihooman-history-empty">No conversations yet</div>';
657
709
  return;
658
710
  }
659
- elements.historyList.innerHTML = conversations.map(conv => `
711
+ elements.historyList.innerHTML = conversations.map((conv) => `
660
712
  <div class="ihooman-history-item ${conv.session_id === state.sessionId ? 'active' : ''}" data-session-id="${conv.session_id}">
661
713
  <div class="ihooman-history-preview">${escapeHtml(conv.preview || 'New conversation')}</div>
662
714
  <div class="ihooman-history-meta">
@@ -665,13 +717,11 @@
665
717
  </div>
666
718
  </div>
667
719
  `).join('');
668
- // Add click handlers to history items
669
720
  elements.historyList.querySelectorAll('.ihooman-history-item').forEach(item => {
670
721
  item.addEventListener('click', () => {
671
722
  const sessionId = item.dataset.sessionId;
672
- if (sessionId) {
723
+ if (sessionId)
673
724
  switchToConversation(sessionId);
674
- }
675
725
  });
676
726
  });
677
727
  }
@@ -679,49 +729,35 @@
679
729
  console.error('Error loading conversation history:', error);
680
730
  elements.historyList.innerHTML = '<div class="ihooman-history-empty">Failed to load history</div>';
681
731
  }
732
+ finally {
733
+ }
682
734
  }
683
- /**
684
- * Switch to a specific conversation
685
- */
686
735
  async function switchToConversation(sessionId) {
687
736
  state.sessionId = sessionId;
688
- if (config.persistSession) {
737
+ if (config.persistSession)
689
738
  storage('session_id', sessionId);
690
- }
691
- // Clear current messages
692
- if (elements.messages) {
739
+ if (elements.messages)
693
740
  elements.messages.innerHTML = '';
694
- }
695
- // Reset live agent mode
696
741
  isLiveAgentMode = false;
697
742
  stopLiveAgentPolling();
698
743
  updateStatusBar('hidden');
699
- // Reconnect WebSocket with new session
700
744
  intentionalDisconnect = true;
701
745
  if (ws) {
702
746
  ws.close();
703
747
  ws = null;
704
748
  }
705
749
  connectWebSocket();
706
- // Switch to chat view
707
750
  showView('chat');
708
- // Load conversation messages
709
751
  await loadConversationMessages(sessionId);
710
752
  }
711
- /**
712
- * Load messages for a specific conversation
713
- */
714
753
  async function loadConversationMessages(sessionId) {
715
754
  try {
716
755
  const response = await fetch(`${config.serverUrl}/api/widget/transcript/${sessionId}?widget_id=${config.widgetId}`);
717
- if (!response.ok) {
756
+ if (!response.ok)
718
757
  throw new Error('Failed to load messages');
719
- }
720
758
  const data = await response.json();
721
- if (elements.messages) {
759
+ if (elements.messages)
722
760
  elements.messages.innerHTML = '';
723
- }
724
- // Add messages to the chat
725
761
  if (data.messages && data.messages.length > 0) {
726
762
  data.messages.forEach((msg) => {
727
763
  const sender = msg.sender_type === 'user' ? 'user' : 'bot';
@@ -731,34 +767,18 @@
731
767
  else if (config.welcomeMessage) {
732
768
  addMessage(config.welcomeMessage, 'bot');
733
769
  }
734
- // Check conversation status
735
770
  if (data.status === 'pending') {
736
771
  isLiveAgentMode = true;
737
772
  startLiveAgentPolling();
738
773
  updateStatusBar('waiting', '⏳ Waiting for agent...');
739
774
  }
740
- else if (data.status === 'closed') {
741
- updateStatusBar('hidden');
742
- }
743
775
  }
744
776
  catch (error) {
745
777
  console.error('Error loading conversation messages:', error);
746
- if (config.welcomeMessage) {
778
+ if (config.welcomeMessage)
747
779
  addMessage(config.welcomeMessage, 'bot');
748
- }
749
780
  }
750
781
  }
751
- /**
752
- * Show the ticket form
753
- */
754
- function handleShowTicketForm() {
755
- showView('ticket');
756
- // Focus on name input
757
- setTimeout(() => elements.ticketName?.focus(), 100);
758
- }
759
- /**
760
- * Update the status bar display
761
- */
762
782
  function updateStatusBar(status, message) {
763
783
  if (!elements.statusBar)
764
784
  return;
@@ -769,13 +789,9 @@
769
789
  elements.statusBar.classList.add('show');
770
790
  elements.statusBar.classList.remove('waiting', 'connected');
771
791
  elements.statusBar.classList.add(status);
772
- if (message) {
792
+ if (message)
773
793
  elements.statusBar.textContent = message;
774
- }
775
794
  }
776
- /**
777
- * Submit a ticket via the API
778
- */
779
795
  async function handleSubmitTicket() {
780
796
  const name = elements.ticketName?.value.trim();
781
797
  const email = elements.ticketEmail?.value.trim();
@@ -789,7 +805,6 @@
789
805
  elements.ticketSubmitBtn.textContent = 'Submitting...';
790
806
  }
791
807
  try {
792
- // Use Widget ID authenticated endpoint
793
808
  const response = await fetch(`${config.serverUrl}/api/widget/submit-ticket`, {
794
809
  method: 'POST',
795
810
  headers: { 'Content-Type': 'application/json' },
@@ -802,16 +817,13 @@
802
817
  }),
803
818
  });
804
819
  const data = await response.json();
805
- // Clear form
806
820
  if (elements.ticketName)
807
821
  elements.ticketName.value = '';
808
822
  if (elements.ticketEmail)
809
823
  elements.ticketEmail.value = '';
810
824
  if (elements.ticketIssue)
811
825
  elements.ticketIssue.value = '';
812
- // Switch back to chat view
813
826
  showView('chat');
814
- // Show success message
815
827
  const ticketRef = data.ticket_id ? data.ticket_id.slice(0, 8) : 'submitted';
816
828
  addMessage(`✅ Ticket submitted! We'll contact you at ${email}. Reference: #${ticketRef}`, 'bot', { is_system_message: true });
817
829
  }
@@ -824,22 +836,12 @@
824
836
  elements.ticketSubmitBtn.textContent = 'Submit Ticket';
825
837
  }
826
838
  }
827
- /**
828
- * Request a live agent via the API
829
- */
830
839
  async function handleRequestLiveAgent() {
831
840
  if (!state.sessionId) {
832
841
  addMessage('Please send a message first to start a conversation.', 'bot', { is_system_message: true });
833
842
  return;
834
843
  }
835
- // Disable live agent button
836
- const liveBtn = elements.widget?.querySelector('[data-action="live-agent"]');
837
- if (liveBtn) {
838
- liveBtn.disabled = true;
839
- liveBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg> Connecting...';
840
- }
841
844
  try {
842
- // Use Widget ID authenticated endpoint
843
845
  const response = await fetch(`${config.serverUrl}/api/widget/live-agent`, {
844
846
  method: 'POST',
845
847
  headers: { 'Content-Type': 'application/json' },
@@ -851,33 +853,21 @@
851
853
  });
852
854
  const data = await response.json();
853
855
  isLiveAgentMode = true;
854
- // Show status bar - always start with waiting since agent hasn't accepted yet
856
+ state.escalationStatus = { active: true, type: 'live_agent', queuePosition: data.position_in_queue };
855
857
  if (data.position_in_queue && data.position_in_queue > 1) {
856
858
  updateStatusBar('waiting', `⏳ Waiting for agent (Position: #${data.position_in_queue})`);
857
859
  }
858
860
  else {
859
861
  updateStatusBar('waiting', '⏳ Connecting to live support...');
860
862
  }
861
- // Start polling for agent messages
862
863
  startLiveAgentPolling();
864
+ emit('escalation:start', { type: 'live_agent' });
863
865
  }
864
866
  catch (error) {
865
867
  console.error('Error requesting live agent:', error);
866
868
  addMessage('❌ Unable to connect to live support. Please try again.', 'bot', { is_system_message: true });
867
869
  }
868
- // Re-enable button
869
- if (liveBtn) {
870
- liveBtn.disabled = false;
871
- liveBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg> Live Agent';
872
- }
873
870
  }
874
- /**
875
- * Live agent polling interval
876
- */
877
- let liveAgentPollInterval = null;
878
- /**
879
- * Start polling for live agent messages
880
- */
881
871
  function startLiveAgentPolling() {
882
872
  if (liveAgentPollInterval)
883
873
  return;
@@ -887,58 +877,102 @@
887
877
  return;
888
878
  }
889
879
  try {
890
- // Note: transcript endpoint still uses API key auth, but we can use the WebSocket
891
- // for real-time updates. For now, poll the escalation status endpoint.
892
- // Update queue position and connection status using Widget ID auth
893
- try {
894
- const escResponse = await fetch(`${config.serverUrl}/api/widget/escalation-status/${state.sessionId}?widget_id=${config.widgetId}`);
895
- if (escResponse.ok) {
896
- const escData = await escResponse.json();
897
- if (escData.escalated) {
898
- if (escData.ticket_status === 'in_progress') {
899
- // Agent has accepted - show connected status
900
- updateStatusBar('connected', '🟢 Connected to live agent');
901
- }
902
- else if (escData.ticket_status === 'open') {
903
- // Still waiting in queue
904
- if (escData.position_in_queue && escData.position_in_queue > 1) {
905
- updateStatusBar('waiting', `⏳ Waiting for agent (Position: #${escData.position_in_queue})`);
906
- }
907
- else {
908
- updateStatusBar('waiting', '⏳ Waiting for agent...');
909
- }
910
- }
911
- else if (escData.ticket_status === 'closed' || escData.ticket_status === 'resolved') {
912
- // Conversation closed
913
- isLiveAgentMode = false;
914
- stopLiveAgentPolling();
915
- updateStatusBar('hidden');
916
- addMessage('This conversation has been closed. Thank you for contacting us!', 'bot', { is_system_message: true });
917
- }
880
+ const response = await fetch(`${config.serverUrl}/api/widget/escalation-status/${state.sessionId}?widget_id=${config.widgetId}`);
881
+ if (response.ok) {
882
+ const data = await response.json();
883
+ if (data.escalated) {
884
+ if (data.ticket_status === 'in_progress') {
885
+ updateStatusBar('connected', '🟢 Connected to live agent');
886
+ }
887
+ else if (data.ticket_status === 'open') {
888
+ updateStatusBar('waiting', data.position_in_queue > 1
889
+ ? `⏳ Waiting for agent (Position: #${data.position_in_queue})`
890
+ : ' Waiting for agent...');
891
+ }
892
+ else if (data.ticket_status === 'closed' || data.ticket_status === 'resolved') {
893
+ isLiveAgentMode = false;
894
+ stopLiveAgentPolling();
895
+ updateStatusBar('hidden');
896
+ addMessage('This conversation has been closed. Thank you for contacting us!', 'bot', { is_system_message: true });
897
+ emit('escalation:end', { type: 'live_agent' });
918
898
  }
919
899
  }
920
900
  }
921
- catch {
922
- // Ignore escalation status errors
923
- }
924
- }
925
- catch (error) {
926
- console.error('Error polling for messages:', error);
927
901
  }
902
+ catch { /* ignore */ }
928
903
  }, 3000);
929
904
  }
930
- /**
931
- * Stop live agent polling
932
- */
933
905
  function stopLiveAgentPolling() {
934
906
  if (liveAgentPollInterval) {
935
907
  clearInterval(liveAgentPollInterval);
936
908
  liveAgentPollInterval = null;
937
909
  }
938
910
  }
911
+ async function submitFeedback(messageId, feedbackType) {
912
+ try {
913
+ await fetch(`${config.serverUrl}/api/widget/feedback`, {
914
+ method: 'POST',
915
+ headers: { 'Content-Type': 'application/json' },
916
+ body: JSON.stringify({
917
+ widget_id: config.widgetId,
918
+ message_id: messageId,
919
+ session_id: state.sessionId,
920
+ feedback_type: feedbackType,
921
+ }),
922
+ });
923
+ }
924
+ catch (error) {
925
+ console.error('Error submitting feedback:', error);
926
+ }
927
+ }
928
+ async function handleSurveySubmit() {
929
+ const starsContainer = elements.surveyView?.querySelector('.ihooman-survey-stars');
930
+ const activeStars = starsContainer?.querySelectorAll('.ihooman-survey-star.active');
931
+ const rating = activeStars?.length || 0;
932
+ const comment = elements.surveyView?.querySelector('.ihooman-survey-comment')?.value;
933
+ if (rating === 0) {
934
+ alert('Please select a rating');
935
+ return;
936
+ }
937
+ try {
938
+ await fetch(`${config.serverUrl}/api/widget/survey-response`, {
939
+ method: 'POST',
940
+ headers: { 'Content-Type': 'application/json' },
941
+ body: JSON.stringify({
942
+ widget_id: config.widgetId,
943
+ survey_id: config.surveyConfig?.id,
944
+ session_id: state.sessionId,
945
+ rating,
946
+ comment,
947
+ }),
948
+ });
949
+ // Mark survey as completed for this session
950
+ storage('survey_completed_' + state.sessionId, true);
951
+ emit('survey:submitted', { rating, comment });
952
+ addMessage(config.surveyConfig?.thankYouMessage || 'Thank you for your feedback!', 'bot', { is_system_message: true });
953
+ showView('chat');
954
+ }
955
+ catch (error) {
956
+ console.error('Error submitting survey:', error);
957
+ }
958
+ }
939
959
  /**
940
- * Show typing indicator
960
+ * Check if survey should be shown and show it
941
961
  */
962
+ function checkAndShowSurvey() {
963
+ // Don't show if no survey configured
964
+ if (!config.surveyConfig)
965
+ return;
966
+ // Don't show if already completed for this session
967
+ if (state.sessionId && storage('survey_completed_' + state.sessionId))
968
+ return;
969
+ // Don't show if not enough messages (at least 2 exchanges)
970
+ if (state.messages.length < 4)
971
+ return;
972
+ // Show the survey
973
+ showView('survey');
974
+ emit('survey:shown', config.surveyConfig);
975
+ }
942
976
  function showTyping() {
943
977
  if (!config.showTypingIndicator || !elements.messages)
944
978
  return;
@@ -950,56 +984,35 @@
950
984
  elements.messages.appendChild(typing);
951
985
  elements.messages.scrollTop = elements.messages.scrollHeight;
952
986
  }
953
- /**
954
- * Hide typing indicator
955
- */
956
987
  function hideTyping() {
957
988
  const typing = elements.messages?.querySelector('.ihooman-typing');
958
989
  if (typing)
959
990
  typing.remove();
960
991
  }
961
- /**
962
- * Handle send button click
963
- */
964
992
  function handleSendClick() {
965
993
  const content = elements.input?.value.trim();
966
994
  if (!content)
967
995
  return;
968
- // Hide preset questions after user sends any message
969
996
  hidePresetQuestions();
970
997
  if (elements.input) {
971
998
  elements.input.value = '';
972
999
  elements.input.style.height = 'auto';
973
1000
  }
974
- if (elements.sendBtn) {
1001
+ if (elements.sendBtn)
975
1002
  elements.sendBtn.disabled = true;
976
- }
977
1003
  addMessage(content, 'user');
978
1004
  showTyping();
979
1005
  sendMessageToServer(content);
980
1006
  }
981
- /**
982
- * Hide preset questions
983
- */
984
1007
  function hidePresetQuestions() {
985
- if (elements.presetQuestions) {
1008
+ if (elements.presetQuestions)
986
1009
  elements.presetQuestions.classList.add('hidden');
987
- }
988
1010
  }
989
- /**
990
- * Send message to the server
991
- * Uses WebSocket if connected, otherwise falls back to REST API
992
- */
993
1011
  async function sendMessageToServer(content) {
994
- // If WebSocket is connected, send via WebSocket
995
1012
  if (ws && ws.readyState === WebSocket.OPEN) {
996
- ws.send(JSON.stringify({
997
- type: 'message',
998
- content: content,
999
- }));
1013
+ ws.send(JSON.stringify({ type: 'message', content }));
1000
1014
  return;
1001
1015
  }
1002
- // Fallback to REST API
1003
1016
  try {
1004
1017
  const response = await fetch(`${config.serverUrl}/api/v1/public/chat`, {
1005
1018
  method: 'POST',
@@ -1019,7 +1032,12 @@
1019
1032
  storage('session_id', state.sessionId);
1020
1033
  }
1021
1034
  if (data.response) {
1022
- addMessage(data.response, 'bot', { sources: data.sources, confidence: data.confidence });
1035
+ addMessage(data.response, 'bot', {
1036
+ sources: data.sources,
1037
+ confidence: data.confidence,
1038
+ escalation_offered: data.escalation_offered,
1039
+ quick_replies: data.quick_replies,
1040
+ });
1023
1041
  }
1024
1042
  }
1025
1043
  catch (error) {
@@ -1028,9 +1046,6 @@
1028
1046
  emit('error', error);
1029
1047
  }
1030
1048
  }
1031
- /**
1032
- * Handle file selection
1033
- */
1034
1049
  function handleFileSelect(e) {
1035
1050
  const target = e.target;
1036
1051
  const file = target.files?.[0];
@@ -1056,102 +1071,179 @@
1056
1071
  });
1057
1072
  target.value = '';
1058
1073
  }
1059
- /**
1060
- * Play notification sound
1061
- */
1062
1074
  function playSound() {
1063
1075
  try {
1064
1076
  const audio = new Audio('data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAABhgC7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7//////////////////////////////////////////////////////////////////8AAAAATGF2YzU4LjEzAAAAAAAAAAAAAAAAJAAAAAAAAAAAAYYNBrP/AAAAAAAAAAAAAAAAAAAAAP/7UMQAA8AAAaQAAAAgAAA0gAAABExBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//tQxBKDwAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=');
1065
1077
  audio.volume = 0.3;
1066
1078
  audio.play().catch(() => { });
1067
1079
  }
1068
- catch {
1069
- // Ignore audio errors
1080
+ catch { /* ignore */ }
1081
+ }
1082
+ // ============================================================================
1083
+ // PROACTIVE MESSAGES
1084
+ // ============================================================================
1085
+ function initializeProactiveMessages() {
1086
+ if (!config.proactiveMessages || config.proactiveMessages.length === 0) {
1087
+ console.debug('[IhoomanChat] No proactive messages configured');
1088
+ return;
1089
+ }
1090
+ console.debug('[IhoomanChat] Initializing proactive messages:', config.proactiveMessages.length);
1091
+ // Load shown proactive IDs from storage
1092
+ shownProactiveIds = storage('shown_proactive') || [];
1093
+ proactiveCooldowns = storage('proactive_cooldowns') || {};
1094
+ // Start checking for triggers
1095
+ proactiveCheckInterval = setInterval(checkProactiveTriggers, 5000);
1096
+ // Also check on scroll
1097
+ window.addEventListener('scroll', checkProactiveTriggers);
1098
+ // Exit intent detection
1099
+ document.addEventListener('mouseout', (e) => {
1100
+ if (e.clientY <= 0)
1101
+ checkExitIntentTrigger();
1102
+ });
1103
+ }
1104
+ function checkProactiveTriggers() {
1105
+ if (state.isOpen || !config.proactiveMessages)
1106
+ return;
1107
+ const now = Date.now();
1108
+ for (const pm of config.proactiveMessages) {
1109
+ // Skip if already shown in this session
1110
+ if (shownProactiveIds.includes(pm.id))
1111
+ continue;
1112
+ // Check cooldown
1113
+ const lastShown = proactiveCooldowns[pm.id];
1114
+ if (lastShown && now - lastShown < pm.cooldownMinutes * 60 * 1000)
1115
+ continue;
1116
+ // Check date range
1117
+ if (pm.startDate && new Date(pm.startDate) > new Date())
1118
+ continue;
1119
+ if (pm.endDate && new Date(pm.endDate) < new Date())
1120
+ continue;
1121
+ // Check trigger
1122
+ let triggered = false;
1123
+ switch (pm.trigger.type) {
1124
+ case 'time':
1125
+ const timeValue = typeof pm.trigger.value === 'number' ? pm.trigger.value : parseInt(String(pm.trigger.value), 10);
1126
+ const pageLoadTime = storage('page_load_time') || now;
1127
+ if (now - pageLoadTime >= timeValue * 1000)
1128
+ triggered = true;
1129
+ break;
1130
+ case 'scroll':
1131
+ const scrollValue = typeof pm.trigger.value === 'number' ? pm.trigger.value : parseInt(String(pm.trigger.value), 10);
1132
+ const currentScroll = getCurrentScrollDepth();
1133
+ if (currentScroll >= scrollValue)
1134
+ triggered = true;
1135
+ break;
1136
+ case 'url_pattern':
1137
+ if (matchUrlPattern(String(pm.trigger.value)))
1138
+ triggered = true;
1139
+ break;
1140
+ }
1141
+ if (triggered) {
1142
+ showProactiveMessage(pm);
1143
+ break;
1144
+ }
1070
1145
  }
1071
1146
  }
1147
+ function checkExitIntentTrigger() {
1148
+ if (state.isOpen || !config.proactiveMessages)
1149
+ return;
1150
+ const exitIntentMessages = config.proactiveMessages.filter(pm => pm.trigger.type === 'exit_intent' && !shownProactiveIds.includes(pm.id));
1151
+ if (exitIntentMessages.length > 0) {
1152
+ showProactiveMessage(exitIntentMessages[0]);
1153
+ }
1154
+ }
1155
+ function showProactiveMessage(pm) {
1156
+ // Mark as shown
1157
+ shownProactiveIds.push(pm.id);
1158
+ proactiveCooldowns[pm.id] = Date.now();
1159
+ storage('shown_proactive', shownProactiveIds);
1160
+ storage('proactive_cooldowns', proactiveCooldowns);
1161
+ // Show toast
1162
+ if (elements.proactiveToast) {
1163
+ const content = elements.proactiveToast.querySelector('.ihooman-proactive-toast-content');
1164
+ if (content)
1165
+ content.textContent = pm.message;
1166
+ elements.proactiveToast.classList.add('show');
1167
+ emit('proactive:shown', pm);
1168
+ // Auto-open if configured
1169
+ if (pm.autoOpen) {
1170
+ setTimeout(() => {
1171
+ hideProactiveToast();
1172
+ open();
1173
+ }, 3000);
1174
+ }
1175
+ }
1176
+ }
1177
+ function hideProactiveToast() {
1178
+ if (elements.proactiveToast) {
1179
+ elements.proactiveToast.classList.remove('show');
1180
+ }
1181
+ }
1182
+ function cleanupProactiveMessages() {
1183
+ if (proactiveCheckInterval) {
1184
+ clearInterval(proactiveCheckInterval);
1185
+ proactiveCheckInterval = null;
1186
+ }
1187
+ window.removeEventListener('scroll', checkProactiveTriggers);
1188
+ }
1072
1189
  // ============================================================================
1073
1190
  // WEBSOCKET CONNECTION
1074
1191
  // ============================================================================
1075
- /**
1076
- * Connect to WebSocket for real-time messaging.
1077
- *
1078
- * For public widgets (using Widget ID), connects to /public/ws endpoint
1079
- * which authenticates via Widget ID + Domain validation.
1080
- *
1081
- * For authenticated users (dashboard), connects to /ws endpoint with JWT.
1082
- */
1083
1192
  function connectWebSocket(chatEndpoint) {
1084
1193
  if (!config.serverUrl && !chatEndpoint)
1085
1194
  return;
1086
1195
  let wsUrl;
1087
1196
  if (config.widgetId) {
1088
- // Public widget - use /public/ws endpoint with widget_id auth
1089
1197
  const baseWsUrl = config.serverUrl?.replace(/^http/, 'ws');
1090
- const params = new URLSearchParams({
1091
- widget_id: config.widgetId,
1092
- });
1093
- if (state.visitorId) {
1198
+ const params = new URLSearchParams({ widget_id: config.widgetId });
1199
+ if (state.visitorId)
1094
1200
  params.append('visitor_id', state.visitorId);
1095
- }
1096
- if (state.sessionId) {
1201
+ if (state.sessionId)
1097
1202
  params.append('session_id', state.sessionId);
1098
- }
1099
1203
  wsUrl = `${baseWsUrl}/public/ws?${params.toString()}`;
1100
1204
  }
1101
1205
  else {
1102
- // Authenticated user - use /ws endpoint
1103
1206
  wsUrl = chatEndpoint || config.serverUrl?.replace(/^http/, 'ws') + '/ws';
1104
1207
  }
1105
1208
  try {
1106
- // Stop any existing heartbeat before creating new connection
1107
1209
  stopHeartbeat();
1108
1210
  ws = new WebSocket(wsUrl);
1109
1211
  ws.onopen = () => {
1110
1212
  state.isConnected = true;
1213
+ state.connectionStatus = 'connected';
1111
1214
  reconnectAttempts = 0;
1112
1215
  updateStatus(true);
1113
1216
  emit('connected');
1114
- // Start heartbeat after connection is established
1115
1217
  startHeartbeat();
1116
1218
  };
1117
1219
  ws.onclose = (event) => {
1118
1220
  state.isConnected = false;
1221
+ state.connectionStatus = 'disconnected';
1119
1222
  updateStatus(false);
1120
1223
  stopHeartbeat();
1121
1224
  emit('disconnected');
1122
- // Log close reason for debugging
1123
- console.log(`WebSocket closed: code=${event.code}, reason=${event.reason || 'none'}, wasClean=${event.wasClean}`);
1124
- // Don't reconnect if this was an intentional disconnect (e.g., starting new conversation)
1125
1225
  if (intentionalDisconnect) {
1126
1226
  intentionalDisconnect = false;
1127
1227
  return;
1128
1228
  }
1129
- // Don't reconnect if the widget is closed
1130
- if (!state.isOpen) {
1229
+ if (!state.isOpen)
1131
1230
  return;
1132
- }
1133
- // Attempt reconnection with exponential backoff
1134
1231
  if (reconnectAttempts < maxReconnectAttempts) {
1135
1232
  reconnectAttempts++;
1136
1233
  const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
1137
- console.log(`WebSocket reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`);
1138
1234
  setTimeout(() => connectWebSocket(chatEndpoint), delay);
1139
1235
  }
1140
1236
  else {
1141
- // Fall back to polling after max reconnect attempts
1142
- console.warn('WebSocket reconnection failed, falling back to polling');
1143
1237
  startPolling();
1144
1238
  }
1145
1239
  };
1146
1240
  ws.onerror = (error) => {
1147
1241
  console.error('WebSocket error:', error);
1148
- // WebSocket error - will trigger onclose
1149
1242
  };
1150
1243
  ws.onmessage = (e) => {
1151
1244
  try {
1152
1245
  const data = JSON.parse(e.data);
1153
1246
  if (data.type === 'connected') {
1154
- // Server confirmed connection - update session info
1155
1247
  if (data.session_id) {
1156
1248
  state.sessionId = data.session_id;
1157
1249
  if (config.persistSession)
@@ -1162,7 +1254,6 @@
1162
1254
  if (config.persistSession)
1163
1255
  storage('visitor_id', state.visitorId);
1164
1256
  }
1165
- // Show welcome message if provided
1166
1257
  if (data.welcome_message && state.messages.length === 0) {
1167
1258
  addMessage(data.welcome_message, 'bot');
1168
1259
  }
@@ -1175,64 +1266,44 @@
1175
1266
  agent_name: data.agent_name,
1176
1267
  escalation_offered: data.escalation_offered,
1177
1268
  escalated: data.escalated,
1269
+ quick_replies: data.quick_replies,
1178
1270
  });
1179
1271
  }
1180
1272
  else if (data.type === 'typing') {
1181
1273
  data.is_typing ? showTyping() : hideTyping();
1182
1274
  }
1183
1275
  else if (data.type === 'pong') {
1184
- // Heartbeat response - connection is alive
1276
+ // Heartbeat response
1185
1277
  }
1186
1278
  else if (data.type === 'error') {
1187
1279
  console.error('WebSocket server error:', data.message);
1188
1280
  emit('error', { message: data.message });
1189
1281
  }
1190
1282
  }
1191
- catch {
1192
- // Ignore parse errors
1193
- }
1283
+ catch { /* ignore */ }
1194
1284
  };
1195
1285
  }
1196
1286
  catch {
1197
- // WebSocket not supported, fall back to polling
1198
- console.warn('WebSocket not supported, using polling');
1199
1287
  startPolling();
1200
1288
  }
1201
1289
  }
1202
- /**
1203
- * Heartbeat interval reference
1204
- */
1205
- let heartbeatInterval = null;
1206
- /**
1207
- * Start heartbeat to keep WebSocket connection alive
1208
- * Sends ping every 25 seconds to prevent Cloudflare/proxy timeouts
1209
- */
1210
1290
  function startHeartbeat() {
1211
- // Clear any existing heartbeat first
1212
1291
  stopHeartbeat();
1213
1292
  heartbeatInterval = setInterval(() => {
1214
1293
  if (ws && ws.readyState === WebSocket.OPEN) {
1215
1294
  try {
1216
1295
  ws.send(JSON.stringify({ type: 'ping' }));
1217
1296
  }
1218
- catch (e) {
1219
- console.warn('Failed to send heartbeat ping:', e);
1220
- }
1297
+ catch { /* ignore */ }
1221
1298
  }
1222
- }, 25000); // Ping every 25 seconds (Cloudflare timeout is 100s, but some proxies are shorter)
1299
+ }, 25000);
1223
1300
  }
1224
- /**
1225
- * Stop heartbeat
1226
- */
1227
1301
  function stopHeartbeat() {
1228
1302
  if (heartbeatInterval) {
1229
1303
  clearInterval(heartbeatInterval);
1230
1304
  heartbeatInterval = null;
1231
1305
  }
1232
1306
  }
1233
- /**
1234
- * Start polling for messages (fallback when WebSocket unavailable)
1235
- */
1236
1307
  function startPolling() {
1237
1308
  if (pollInterval)
1238
1309
  return;
@@ -1250,132 +1321,107 @@
1250
1321
  });
1251
1322
  }
1252
1323
  }
1253
- catch {
1254
- // Ignore polling errors
1255
- }
1324
+ catch { /* ignore */ }
1256
1325
  }, 5000);
1257
1326
  }
1258
- /**
1259
- * Update connection status display
1260
- */
1261
1327
  function updateStatus(online) {
1262
- if (elements.statusDot) {
1328
+ if (elements.statusDot)
1263
1329
  elements.statusDot.classList.toggle('offline', !online);
1264
- }
1265
- if (elements.statusText) {
1330
+ if (elements.statusText)
1266
1331
  elements.statusText.textContent = online ? 'Online' : 'Offline';
1267
- }
1268
1332
  }
1269
1333
  // ============================================================================
1270
1334
  // PUBLIC API METHODS
1271
1335
  // ============================================================================
1272
- /**
1273
- * Open the chat widget window
1274
- */
1275
1336
  function open() {
1276
1337
  if (state.isOpen)
1277
1338
  return;
1278
1339
  state.isOpen = true;
1279
1340
  state.unreadCount = 0;
1280
- if (elements.badge) {
1341
+ if (elements.badge)
1281
1342
  elements.badge.textContent = '';
1282
- }
1283
- if (elements.toggle) {
1343
+ if (elements.toggle)
1284
1344
  elements.toggle.classList.add('open');
1285
- }
1286
- if (elements.window) {
1345
+ if (elements.window)
1287
1346
  elements.window.classList.add('open');
1288
- }
1347
+ hideProactiveToast();
1289
1348
  setTimeout(() => elements.input?.focus(), 300);
1290
1349
  emit('open');
1291
1350
  }
1292
- /**
1293
- * Close the chat widget window
1294
- */
1295
1351
  function close() {
1296
1352
  if (!state.isOpen)
1297
1353
  return;
1298
1354
  state.isOpen = false;
1299
- if (elements.toggle) {
1355
+ if (elements.toggle)
1300
1356
  elements.toggle.classList.remove('open');
1301
- }
1302
- if (elements.window) {
1357
+ if (elements.window)
1303
1358
  elements.window.classList.remove('open');
1359
+ // Check if we should show survey after closing (if conversation had enough messages)
1360
+ if (config.surveyConfig && state.messages.length >= 4) {
1361
+ const surveyCompleted = state.sessionId && storage('survey_completed_' + state.sessionId);
1362
+ if (!surveyCompleted) {
1363
+ // Show survey after a short delay
1364
+ setTimeout(() => {
1365
+ if (!state.isOpen) {
1366
+ checkAndShowSurvey();
1367
+ open(); // Re-open to show survey
1368
+ }
1369
+ }, 500);
1370
+ }
1304
1371
  }
1305
1372
  emit('close');
1306
1373
  }
1307
- /**
1308
- * Toggle the chat widget window
1309
- */
1310
1374
  function toggle() {
1311
1375
  state.isOpen ? close() : open();
1312
1376
  }
1313
- /**
1314
- * Start a new conversation
1315
- */
1377
+ function isOpenFn() {
1378
+ return state.isOpen;
1379
+ }
1316
1380
  function startNewConversation() {
1317
- // Clear session and visitor to force a completely new conversation
1318
1381
  state.sessionId = null;
1319
1382
  state.visitorId = null;
1320
1383
  state.messages = [];
1321
1384
  isLiveAgentMode = false;
1322
- // Clear stored session
1323
1385
  storage('session_id', null);
1324
- // Generate new visitor ID
1325
1386
  state.visitorId = generateId('v_');
1326
1387
  storage('visitor_id', state.visitorId);
1327
- // Clear UI
1328
- if (elements.messages) {
1388
+ if (elements.messages)
1329
1389
  elements.messages.innerHTML = '';
1330
- }
1331
- // Hide status bar
1332
1390
  updateStatusBar('hidden');
1333
- // Stop any live agent polling
1334
1391
  stopLiveAgentPolling();
1335
- // Reset reconnect attempts for fresh connection
1336
1392
  reconnectAttempts = 0;
1337
- // Close existing WebSocket with intentional flag to prevent auto-reconnect
1338
1393
  if (ws) {
1339
1394
  intentionalDisconnect = true;
1340
1395
  ws.close();
1341
1396
  ws = null;
1342
1397
  }
1343
- // Connect with new visitor ID
1344
1398
  connectWebSocket();
1345
- // Show welcome message
1346
- if (config.welcomeMessage) {
1399
+ if (config.welcomeMessage)
1347
1400
  addMessage(config.welcomeMessage, 'bot');
1348
- }
1401
+ // Show preset questions again
1402
+ if (elements.presetQuestions)
1403
+ elements.presetQuestions.classList.remove('hidden');
1404
+ renderPresetQuestions();
1349
1405
  emit('newConversation');
1350
1406
  }
1351
- /**
1352
- * Destroy the widget and clean up resources
1353
- */
1354
1407
  function destroy() {
1355
- // Stop heartbeat
1356
1408
  stopHeartbeat();
1357
- // Stop live agent polling
1358
1409
  stopLiveAgentPolling();
1359
- // Close WebSocket (intentionally, don't reconnect)
1410
+ cleanupProactiveMessages();
1360
1411
  intentionalDisconnect = true;
1361
1412
  if (ws) {
1362
1413
  ws.close();
1363
1414
  ws = null;
1364
1415
  }
1365
- // Clear polling interval
1366
1416
  if (pollInterval) {
1367
1417
  clearInterval(pollInterval);
1368
1418
  pollInterval = null;
1369
1419
  }
1370
- // Remove DOM elements
1371
- if (elements.widget) {
1420
+ if (elements.widget)
1372
1421
  elements.widget.remove();
1373
- }
1374
1422
  const styles = document.getElementById('ihooman-widget-styles');
1375
- if (styles) {
1423
+ if (styles)
1376
1424
  styles.remove();
1377
- }
1378
- // Reset state
1379
1425
  state = {
1380
1426
  isOpen: false,
1381
1427
  isConnected: false,
@@ -1388,21 +1434,15 @@
1388
1434
  reconnectAttempts = 0;
1389
1435
  intentionalDisconnect = false;
1390
1436
  }
1391
- /**
1392
- * Send a message programmatically
1393
- */
1394
1437
  function sendMessage(content) {
1395
1438
  if (!content.trim())
1396
1439
  return;
1397
- if (elements.input) {
1440
+ if (elements.input)
1398
1441
  elements.input.value = content;
1399
- }
1400
1442
  handleSendClick();
1401
1443
  }
1402
- /**
1403
- * Set user information
1404
- */
1405
1444
  function setUser(user) {
1445
+ state.userInfo = user;
1406
1446
  if (user.name)
1407
1447
  storage('user_name', user.name);
1408
1448
  if (user.email)
@@ -1410,79 +1450,53 @@
1410
1450
  if (user.metadata)
1411
1451
  storage('user_metadata', user.metadata);
1412
1452
  }
1413
- /**
1414
- * Clear chat history
1415
- */
1453
+ function clearUser() {
1454
+ state.userInfo = null;
1455
+ storage('user_name', null);
1456
+ storage('user_email', null);
1457
+ storage('user_metadata', null);
1458
+ }
1416
1459
  function clearHistory() {
1417
1460
  startNewConversation();
1418
1461
  }
1419
- /**
1420
- * Subscribe to widget events
1421
- */
1422
1462
  function on(event, callback) {
1423
- if (!eventListeners[event]) {
1463
+ if (!eventListeners[event])
1424
1464
  eventListeners[event] = [];
1425
- }
1426
1465
  eventListeners[event].push(callback);
1427
1466
  }
1428
- /**
1429
- * Unsubscribe from widget events
1430
- */
1431
1467
  function off(event, callback) {
1432
1468
  if (eventListeners[event]) {
1433
1469
  eventListeners[event] = eventListeners[event].filter((fn) => fn !== callback);
1434
1470
  }
1435
1471
  }
1436
- /**
1437
- * Get current widget state
1438
- */
1439
1472
  function getState() {
1440
1473
  return { ...state };
1441
1474
  }
1475
+ function getConfig() {
1476
+ return { ...config };
1477
+ }
1442
1478
  // ============================================================================
1443
1479
  // INITIALIZATION
1444
1480
  // ============================================================================
1445
- /**
1446
- * Fetch widget configuration from the Widget Configuration API
1447
- *
1448
- * Requirements:
1449
- * - 10.1: Widget only requires Widget ID, never API key
1450
- * - 10.2: Widget fetches configuration using only Widget ID
1451
- */
1452
1481
  async function fetchWidgetConfig(widgetId, serverUrl) {
1453
1482
  try {
1454
1483
  const response = await fetch(`${serverUrl}/api/widget/config?widget_id=${encodeURIComponent(widgetId)}`);
1455
1484
  if (!response.ok) {
1456
1485
  const errorData = await response.json().catch(() => ({}));
1457
- return {
1458
- success: false,
1459
- error: errorData.error || `HTTP ${response.status}`,
1460
- };
1486
+ return { success: false, error: errorData.error || `HTTP ${response.status}` };
1461
1487
  }
1462
1488
  return await response.json();
1463
1489
  }
1464
1490
  catch (error) {
1465
- return {
1466
- success: false,
1467
- error: 'Unable to connect. Please check your internet connection.',
1468
- };
1491
+ return { success: false, error: 'Unable to connect. Please check your internet connection.' };
1469
1492
  }
1470
1493
  }
1471
- /**
1472
- * Preset questions from server config
1473
- */
1474
- let presetQuestions = [];
1475
- /**
1476
- * Apply server configuration to local config
1477
- */
1478
1494
  function applyServerConfig(serverConfig) {
1479
- // Apply basic settings
1480
1495
  config.title = serverConfig.title || config.title;
1481
1496
  config.subtitle = serverConfig.subtitle || config.subtitle;
1482
1497
  config.welcomeMessage = serverConfig.welcomeMessage || config.welcomeMessage;
1483
1498
  config.placeholder = serverConfig.placeholder || config.placeholder;
1484
1499
  config.position = serverConfig.position || config.position;
1485
- // Apply theme settings
1486
1500
  if (serverConfig.theme) {
1487
1501
  config.primaryColor = serverConfig.theme.primaryColor || config.primaryColor;
1488
1502
  config.gradientFrom = serverConfig.theme.gradientFrom || config.gradientFrom;
@@ -1492,7 +1506,6 @@
1492
1506
  config.borderRadius = parseInt(serverConfig.theme.borderRadius, 10) || config.borderRadius;
1493
1507
  }
1494
1508
  }
1495
- // Apply behavior settings
1496
1509
  if (serverConfig.behavior) {
1497
1510
  config.startOpen = serverConfig.behavior.startOpen ?? config.startOpen;
1498
1511
  config.showTypingIndicator = serverConfig.behavior.showTypingIndicator ?? config.showTypingIndicator;
@@ -1501,72 +1514,91 @@
1501
1514
  config.enableFileUpload = serverConfig.behavior.enableFileUpload ?? config.enableFileUpload;
1502
1515
  config.persistSession = serverConfig.behavior.persistSession ?? config.persistSession;
1503
1516
  }
1504
- // Apply branding settings
1505
1517
  if (serverConfig.branding) {
1506
1518
  config.avatarUrl = serverConfig.branding.avatarUrl || config.avatarUrl;
1519
+ config.logoUrl = serverConfig.branding.logoUrl || config.logoUrl;
1507
1520
  config.poweredBy = serverConfig.branding.poweredBy ?? config.poweredBy;
1521
+ config.customCss = serverConfig.branding.customCss || config.customCss;
1522
+ }
1523
+ if (serverConfig.size) {
1524
+ config.width = serverConfig.size.width || config.width;
1525
+ config.height = serverConfig.size.height || config.height;
1526
+ config.buttonSize = serverConfig.size.buttonSize || config.buttonSize;
1508
1527
  }
1509
- // Store preset questions
1510
1528
  if (serverConfig.presetQuestions && Array.isArray(serverConfig.presetQuestions)) {
1511
- presetQuestions = serverConfig.presetQuestions;
1529
+ console.debug('[IhoomanChat] Server config presetQuestions:', serverConfig.presetQuestions.length);
1530
+ presetQuestions = serverConfig.presetQuestions.map(q => ({
1531
+ id: q.id,
1532
+ text: q.text,
1533
+ icon: q.icon,
1534
+ emoji: q.icon,
1535
+ }));
1536
+ config.presetQuestions = presetQuestions;
1537
+ }
1538
+ if (serverConfig.proactiveMessages && Array.isArray(serverConfig.proactiveMessages)) {
1539
+ console.debug('[IhoomanChat] Server config proactiveMessages:', serverConfig.proactiveMessages.length);
1540
+ config.proactiveMessages = serverConfig.proactiveMessages
1541
+ .filter(pm => pm.isActive !== false)
1542
+ .map(pm => ({
1543
+ id: pm.id,
1544
+ message: pm.message,
1545
+ trigger: {
1546
+ type: pm.trigger.type,
1547
+ value: pm.trigger.type === 'time' || pm.trigger.type === 'scroll'
1548
+ ? parseInt(pm.trigger.value, 10) || 30
1549
+ : pm.trigger.value,
1550
+ },
1551
+ autoOpen: pm.autoOpen || false,
1552
+ cooldownMinutes: pm.cooldownMinutes || 60,
1553
+ }));
1554
+ }
1555
+ if (serverConfig.surveyConfig && serverConfig.surveyConfig.isActive !== false) {
1556
+ console.debug('[IhoomanChat] Server config surveyConfig:', serverConfig.surveyConfig.id);
1557
+ config.surveyConfig = {
1558
+ id: serverConfig.surveyConfig.id,
1559
+ type: serverConfig.surveyConfig.type,
1560
+ question: serverConfig.surveyConfig.question,
1561
+ followUpQuestion: serverConfig.surveyConfig.followUpQuestion,
1562
+ thankYouMessage: serverConfig.surveyConfig.thankYouMessage,
1563
+ };
1512
1564
  }
1513
1565
  }
1514
- /**
1515
- * Initialize the widget
1516
- *
1517
- * Requirements:
1518
- * - 5.2: Export IhoomanChat object with init, open, close, toggle, destroy methods
1519
- * - 5.3: Accept widgetId configuration option for initialization
1520
- * - 10.1: Widget only requires Widget ID, never API key
1521
- * - 10.2: Widget fetches configuration using only Widget ID
1522
- */
1523
1566
  async function init(userConfig) {
1524
- // Validate required widgetId
1525
1567
  if (!userConfig.widgetId) {
1526
1568
  console.error('IhoomanChat: widgetId is required');
1527
1569
  return null;
1528
1570
  }
1529
- // Merge user config with defaults
1530
1571
  config = { ...defaultConfig, ...userConfig };
1531
- // Initialize visitor ID
1532
1572
  state.visitorId = storage('visitor_id') || generateId('v_');
1533
1573
  storage('visitor_id', state.visitorId);
1534
- // Restore session if persistence is enabled
1535
1574
  if (config.persistSession) {
1536
1575
  state.sessionId = storage('session_id');
1537
1576
  }
1538
- // Fetch configuration from Widget Configuration API using Widget ID
1539
- // This is the secure approach - no API key is ever sent from the client
1540
- const serverUrl = config.serverUrl || 'https://api.ihooman.ai';
1577
+ // Store page load time for proactive messages
1578
+ if (!storage('page_load_time')) {
1579
+ storage('page_load_time', Date.now());
1580
+ }
1581
+ const serverUrl = config.serverUrl || DEFAULT_SERVER_URL;
1541
1582
  const configResponse = await fetchWidgetConfig(config.widgetId, serverUrl);
1542
1583
  let chatEndpoint;
1543
1584
  if (configResponse.success && configResponse.config) {
1544
- // Apply server configuration
1545
1585
  applyServerConfig(configResponse.config);
1546
1586
  chatEndpoint = configResponse.chatEndpoint;
1547
1587
  }
1548
1588
  else if (configResponse.error) {
1549
- // Log error but continue with local config
1550
1589
  console.warn('IhoomanChat: Could not fetch server config:', configResponse.error);
1551
- // If domain validation failed, show error to user
1552
1590
  if (configResponse.error === 'Widget not authorized for this domain') {
1553
1591
  console.error('IhoomanChat: Widget configuration error. Please contact support.');
1554
- // Still create widget but show error state
1555
1592
  }
1556
1593
  }
1557
- // Create the widget DOM
1558
1594
  createWidget();
1559
- // Render preset questions (after config is loaded and widget is created)
1560
1595
  renderPresetQuestions();
1561
- // Restore messages from state (if any)
1562
- state.messages.forEach((msg) => addMessage(msg.content, msg.sender, msg.metadata));
1563
- // Show welcome message if no messages
1596
+ state.messages.forEach((msg) => addMessage(msg.content, msg.sender === 'user' ? 'user' : 'bot', msg.metadata));
1564
1597
  if (state.messages.length === 0 && config.welcomeMessage) {
1565
1598
  addMessage(config.welcomeMessage, 'bot');
1566
1599
  }
1567
- // Connect WebSocket for real-time messaging
1568
1600
  connectWebSocket(chatEndpoint);
1569
- // Auto-open if configured
1601
+ initializeProactiveMessages();
1570
1602
  if (config.startOpen) {
1571
1603
  setTimeout(open, 500);
1572
1604
  }
@@ -1576,43 +1608,30 @@
1576
1608
  // ============================================================================
1577
1609
  // PUBLIC API OBJECT
1578
1610
  // ============================================================================
1579
- /**
1580
- * Public API object
1581
- *
1582
- * Requirements:
1583
- * - 5.2: Export IhoomanChat object with init, open, close, toggle, destroy methods
1584
- */
1585
1611
  const publicAPI = {
1586
1612
  init,
1587
1613
  open,
1588
1614
  close,
1589
1615
  toggle,
1616
+ isOpen: isOpenFn,
1590
1617
  destroy,
1591
1618
  sendMessage,
1592
1619
  setUser,
1620
+ clearUser,
1593
1621
  clearHistory,
1594
1622
  on,
1595
1623
  off,
1596
1624
  getState,
1625
+ getConfig,
1597
1626
  version: VERSION,
1598
1627
  };
1599
- /**
1600
- * Main IhoomanChat export
1601
- */
1602
1628
  const IhoomanChat = publicAPI;
1603
- /**
1604
- * Factory function to create a new widget instance
1605
- */
1606
1629
  function createWidgetInstance() {
1607
1630
  return { ...publicAPI };
1608
1631
  }
1609
1632
  // ============================================================================
1610
1633
  // AUTO-INITIALIZATION
1611
1634
  // ============================================================================
1612
- /**
1613
- * Auto-initialize from script attributes
1614
- * Supports data-widget-id attribute for easy embedding
1615
- */
1616
1635
  (function autoInit() {
1617
1636
  if (typeof document === 'undefined')
1618
1637
  return;
@@ -1631,7 +1650,6 @@
1631
1650
  const widgetId = script.getAttribute('data-widget-id');
1632
1651
  if (widgetId) {
1633
1652
  const autoConfig = { widgetId };
1634
- // Parse optional attributes
1635
1653
  const serverUrl = script.getAttribute('data-server-url');
1636
1654
  if (serverUrl)
1637
1655
  autoConfig.serverUrl = serverUrl;
@@ -1644,7 +1662,6 @@
1644
1662
  const startOpen = script.getAttribute('data-start-open');
1645
1663
  if (startOpen === 'true')
1646
1664
  autoConfig.startOpen = true;
1647
- // Initialize when DOM is ready
1648
1665
  if (document.readyState === 'loading') {
1649
1666
  document.addEventListener('DOMContentLoaded', () => init(autoConfig));
1650
1667
  }
@@ -1653,7 +1670,6 @@
1653
1670
  }
1654
1671
  }
1655
1672
  })();
1656
- // Export for UMD builds
1657
1673
  if (typeof window !== 'undefined') {
1658
1674
  window.IhoomanChat = IhoomanChat;
1659
1675
  }