@ihoomanai/chat-widget 3.0.0 → 3.0.2

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