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