@ihoomanai/chat-widget 2.5.14 → 3.0.1

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