@ihoomanai/chat-widget 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,972 @@
1
+ /**
2
+ * @ihoomanai/chat-widget v2.0.0
3
+ * Universal chat support widget for any website - secure Widget ID based initialization
4
+ *
5
+ * @license MIT
6
+ * @copyright Ihooman AI
7
+ */
8
+ /**
9
+ * Ihooman Chat Widget - Core Implementation
10
+ *
11
+ * Secure Widget ID based initialization - no API keys in client code.
12
+ * Fetches configuration from Widget Configuration API using Widget ID.
13
+ *
14
+ * @module widget
15
+ * @version 2.0.0
16
+ * @license MIT
17
+ *
18
+ * Requirements:
19
+ * - 5.2: Export IhoomanChat object with init, open, close, toggle, destroy methods
20
+ * - 5.3: Accept widgetId configuration option for initialization
21
+ * - 10.1: Widget only requires Widget ID, never API key
22
+ * - 10.2: Widget fetches configuration using only Widget ID
23
+ */
24
+ const VERSION = '2.0.0';
25
+ const STORAGE_PREFIX = 'ihooman_chat_';
26
+ /**
27
+ * Default widget configuration
28
+ */
29
+ const defaultConfig = {
30
+ serverUrl: 'https://api.ihooman.ai',
31
+ theme: 'light',
32
+ position: 'bottom-right',
33
+ title: 'Chat Support',
34
+ subtitle: 'We typically reply within minutes',
35
+ welcomeMessage: 'Hi there! 👋 How can we help you today?',
36
+ placeholder: 'Type a message...',
37
+ primaryColor: '#00aeff',
38
+ gradientFrom: '#00aeff',
39
+ gradientTo: '#0066ff',
40
+ showTimestamps: true,
41
+ showTypingIndicator: true,
42
+ enableSounds: true,
43
+ enableFileUpload: true,
44
+ startOpen: false,
45
+ persistSession: true,
46
+ zIndex: 9999,
47
+ width: 380,
48
+ height: 560,
49
+ buttonSize: 60,
50
+ borderRadius: 16,
51
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
52
+ avatarUrl: '',
53
+ poweredBy: true,
54
+ };
55
+ /**
56
+ * SVG icons for the widget UI
57
+ */
58
+ const icons = {
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>`,
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>`,
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>`,
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>`,
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>`,
64
+ 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>`,
65
+ 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>`,
66
+ };
67
+ /**
68
+ * Internal widget state
69
+ */
70
+ let config = { widgetId: '', ...defaultConfig };
71
+ let state = {
72
+ isOpen: false,
73
+ isConnected: false,
74
+ messages: [],
75
+ sessionId: null,
76
+ visitorId: null,
77
+ unreadCount: 0,
78
+ };
79
+ let elements = {};
80
+ const eventListeners = {};
81
+ /**
82
+ * WebSocket and polling state
83
+ */
84
+ let ws = null;
85
+ let pollInterval = null;
86
+ let reconnectAttempts = 0;
87
+ const maxReconnectAttempts = 5;
88
+ // ============================================================================
89
+ // UTILITIES
90
+ // ============================================================================
91
+ /**
92
+ * Generate a unique ID with optional prefix
93
+ */
94
+ function generateId(prefix = '') {
95
+ return prefix + Math.random().toString(36).slice(2, 14) + Date.now().toString(36);
96
+ }
97
+ /**
98
+ * Format a date to time string
99
+ */
100
+ function formatTime(date) {
101
+ return new Date(date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
102
+ }
103
+ /**
104
+ * Escape HTML to prevent XSS
105
+ */
106
+ function escapeHtml(text) {
107
+ const div = document.createElement('div');
108
+ div.textContent = text;
109
+ return div.innerHTML;
110
+ }
111
+ /**
112
+ * Parse basic markdown to HTML
113
+ */
114
+ function parseMarkdown(text) {
115
+ if (!text)
116
+ return '';
117
+ return escapeHtml(text)
118
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
119
+ .replace(/\*(.*?)\*/g, '<em>$1</em>')
120
+ .replace(/`(.*?)`/g, '<code>$1</code>')
121
+ .replace(/\n/g, '<br>');
122
+ }
123
+ /**
124
+ * Local storage helper with prefix
125
+ */
126
+ function storage(key, value) {
127
+ const fullKey = STORAGE_PREFIX + key;
128
+ try {
129
+ if (value === undefined) {
130
+ const item = localStorage.getItem(fullKey);
131
+ return item ? JSON.parse(item) : null;
132
+ }
133
+ else if (value === null) {
134
+ localStorage.removeItem(fullKey);
135
+ return null;
136
+ }
137
+ else {
138
+ localStorage.setItem(fullKey, JSON.stringify(value));
139
+ return value;
140
+ }
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ /**
147
+ * Emit an event to all registered listeners
148
+ */
149
+ function emit(event, data) {
150
+ const listeners = eventListeners[event] || [];
151
+ listeners.forEach((fn) => {
152
+ try {
153
+ fn(data);
154
+ }
155
+ catch (e) {
156
+ console.error(`Error in ${event} event handler:`, e);
157
+ }
158
+ });
159
+ // Also call config callbacks
160
+ const callbackName = `on${event.charAt(0).toUpperCase()}${event.slice(1)}`;
161
+ const callback = config[callbackName];
162
+ if (typeof callback === 'function') {
163
+ try {
164
+ callback(data);
165
+ }
166
+ catch (e) {
167
+ console.error(`Error in ${callbackName} callback:`, e);
168
+ }
169
+ }
170
+ }
171
+ // ============================================================================
172
+ // STYLES
173
+ // ============================================================================
174
+ /**
175
+ * Generate CSS styles for the widget
176
+ */
177
+ function generateStyles() {
178
+ const { primaryColor, gradientFrom, gradientTo, fontFamily, borderRadius, zIndex, width, height, buttonSize } = config;
179
+ const isDark = config.theme === 'dark';
180
+ const bgColor = isDark ? '#1a1a2e' : '#ffffff';
181
+ const textColor = isDark ? '#e4e4e7' : '#1f2937';
182
+ const mutedColor = isDark ? '#71717a' : '#6b7280';
183
+ const borderColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)';
184
+ const inputBg = isDark ? '#16162a' : '#f9fafb';
185
+ const messageBgUser = `linear-gradient(135deg, ${gradientFrom}, ${gradientTo})`;
186
+ const messageBgBot = isDark ? '#252542' : '#f1f5f9';
187
+ const positionRight = config.position?.includes('right') ?? true;
188
+ const positionBottom = config.position?.includes('bottom') ?? true;
189
+ return `
190
+ .ihooman-widget * { box-sizing: border-box; margin: 0; padding: 0; }
191
+ .ihooman-widget { font-family: ${fontFamily}; font-size: 14px; line-height: 1.5; color: ${textColor}; -webkit-font-smoothing: antialiased; }
192
+ .ihooman-toggle { position: fixed; ${positionRight ? 'right: 20px' : 'left: 20px'}; ${positionBottom ? 'bottom: 20px' : 'top: 20px'}; width: ${buttonSize}px; height: ${buttonSize}px; border-radius: 50%; background: linear-gradient(135deg, ${gradientFrom}, ${gradientTo}); border: none; cursor: pointer; z-index: ${zIndex}; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 20px rgba(0, 174, 255, 0.35); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; }
193
+ .ihooman-toggle:hover { transform: scale(1.08); box-shadow: 0 6px 28px rgba(0, 174, 255, 0.45); }
194
+ .ihooman-toggle:active { transform: scale(0.95); }
195
+ .ihooman-toggle::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, rgba(255,255,255,0.2), transparent); border-radius: 50%; }
196
+ .ihooman-toggle svg { width: 26px; height: 26px; color: white; transition: transform 0.3s ease, opacity 0.2s ease; position: relative; z-index: 1; }
197
+ .ihooman-toggle.open svg.chat-icon { transform: rotate(90deg) scale(0); opacity: 0; }
198
+ .ihooman-toggle.open svg.close-icon { transform: rotate(0) scale(1); opacity: 1; }
199
+ .ihooman-toggle svg.close-icon { position: absolute; transform: rotate(-90deg) scale(0); opacity: 0; }
200
+ .ihooman-pulse { position: absolute; inset: 0; border-radius: 50%; background: ${primaryColor}; animation: ihooman-pulse 2s ease-out infinite; }
201
+ @keyframes ihooman-pulse { 0% { transform: scale(1); opacity: 0.5; } 100% { transform: scale(1.6); opacity: 0; } }
202
+ .ihooman-toggle.open .ihooman-pulse { display: none; }
203
+ .ihooman-badge { position: absolute; top: -4px; right: -4px; min-width: 20px; height: 20px; padding: 0 6px; background: #ef4444; color: white; font-size: 11px; font-weight: 600; border-radius: 10px; display: flex; align-items: center; justify-content: center; z-index: 2; }
204
+ .ihooman-badge:empty { display: none; }
205
+ .ihooman-window { position: fixed; ${positionRight ? 'right: 20px' : 'left: 20px'}; ${positionBottom ? 'bottom: 90px' : 'top: 90px'}; width: ${width}px; height: ${height}px; max-height: calc(100vh - 120px); z-index: ${(zIndex ?? 9999) - 1}; display: flex; flex-direction: column; opacity: 0; visibility: hidden; transform: translateY(20px) scale(0.95); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
206
+ .ihooman-window.open { opacity: 1; visibility: visible; transform: translateY(0) scale(1); }
207
+ .ihooman-container { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; border-radius: ${borderRadius}px; overflow: hidden; }
208
+ .ihooman-container::before { content: ''; position: absolute; inset: 0; background: ${bgColor}; opacity: 0.97; backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-radius: ${borderRadius}px; border: 1px solid ${borderColor}; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); }
209
+ .ihooman-container > * { position: relative; z-index: 1; }
210
+ .ihooman-header { padding: 16px 20px; background: linear-gradient(135deg, ${gradientFrom}, ${gradientTo}); color: white; display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
211
+ .ihooman-header-avatar { width: 42px; height: 42px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
212
+ .ihooman-header-avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
213
+ .ihooman-header-avatar svg { width: 22px; height: 22px; }
214
+ .ihooman-header-info { flex: 1; min-width: 0; }
215
+ .ihooman-header-title { font-size: 16px; font-weight: 600; margin-bottom: 2px; }
216
+ .ihooman-header-status { display: flex; align-items: center; gap: 6px; font-size: 12px; opacity: 0.9; }
217
+ .ihooman-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; }
218
+ .ihooman-status-dot.offline { background: #f59e0b; }
219
+ .ihooman-header-actions { display: flex; gap: 8px; }
220
+ .ihooman-header-btn { width: 32px; height: 32px; border-radius: 8px; background: rgba(255,255,255,0.15); border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; color: white; transition: background 0.2s; }
221
+ .ihooman-header-btn:hover { background: rgba(255,255,255,0.25); }
222
+ .ihooman-header-btn svg { width: 16px; height: 16px; }
223
+ .ihooman-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; background: ${bgColor}; }
224
+ .ihooman-messages::-webkit-scrollbar { width: 6px; }
225
+ .ihooman-messages::-webkit-scrollbar-track { background: transparent; }
226
+ .ihooman-messages::-webkit-scrollbar-thumb { background: ${borderColor}; border-radius: 3px; }
227
+ .ihooman-message { display: flex; flex-direction: column; max-width: 85%; animation: ihooman-fadeIn 0.3s ease; }
228
+ @keyframes ihooman-fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
229
+ .ihooman-message.user { align-self: flex-end; align-items: flex-end; }
230
+ .ihooman-message.bot { align-self: flex-start; align-items: flex-start; }
231
+ .ihooman-message-content { padding: 12px 16px; border-radius: 16px; word-wrap: break-word; }
232
+ .ihooman-message.user .ihooman-message-content { background: ${messageBgUser}; color: white; border-bottom-right-radius: 4px; }
233
+ .ihooman-message.bot .ihooman-message-content { background: ${messageBgBot}; color: ${textColor}; border-bottom-left-radius: 4px; }
234
+ .ihooman-message-content code { background: rgba(0,0,0,0.1); padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; }
235
+ .ihooman-message-time { font-size: 11px; color: ${mutedColor}; margin-top: 4px; padding: 0 4px; }
236
+ .ihooman-typing { display: flex; align-items: center; gap: 8px; padding: 12px 16px; background: ${messageBgBot}; border-radius: 16px; border-bottom-left-radius: 4px; max-width: 80px; align-self: flex-start; }
237
+ .ihooman-typing-dots { display: flex; gap: 4px; }
238
+ .ihooman-typing-dot { width: 8px; height: 8px; background: ${mutedColor}; border-radius: 50%; animation: ihooman-typing 1.4s infinite; }
239
+ .ihooman-typing-dot:nth-child(2) { animation-delay: 0.2s; }
240
+ .ihooman-typing-dot:nth-child(3) { animation-delay: 0.4s; }
241
+ @keyframes ihooman-typing { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-4px); opacity: 1; } }
242
+ .ihooman-input-area { padding: 12px 16px; background: ${bgColor}; border-top: 1px solid ${borderColor}; flex-shrink: 0; }
243
+ .ihooman-input-wrapper { display: flex; align-items: flex-end; gap: 8px; background: ${inputBg}; border: 1px solid ${borderColor}; border-radius: 12px; padding: 8px 12px; transition: border-color 0.2s, box-shadow 0.2s; }
244
+ .ihooman-input-wrapper:focus-within { border-color: ${primaryColor}; box-shadow: 0 0 0 3px rgba(0, 174, 255, 0.1); }
245
+ .ihooman-input { flex: 1; border: none; background: transparent; font-family: inherit; font-size: 14px; color: ${textColor}; resize: none; max-height: 100px; outline: none; line-height: 1.4; }
246
+ .ihooman-input::placeholder { color: ${mutedColor}; }
247
+ .ihooman-input-btn { width: 36px; height: 36px; border-radius: 10px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; background: transparent; color: ${mutedColor}; }
248
+ .ihooman-input-btn:hover { background: ${borderColor}; color: ${textColor}; }
249
+ .ihooman-input-btn.send { background: linear-gradient(135deg, ${gradientFrom}, ${gradientTo}); color: white; }
250
+ .ihooman-input-btn.send:hover { transform: scale(1.05); box-shadow: 0 4px 12px rgba(0, 174, 255, 0.3); }
251
+ .ihooman-input-btn.send:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
252
+ .ihooman-input-btn svg { width: 18px; height: 18px; }
253
+ .ihooman-file-input { display: none; }
254
+ .ihooman-powered { text-align: center; padding: 8px; font-size: 11px; color: ${mutedColor}; background: ${bgColor}; }
255
+ .ihooman-powered a { color: ${primaryColor}; text-decoration: none; }
256
+ .ihooman-error { padding: 16px; text-align: center; color: #ef4444; background: ${bgColor}; }
257
+ @media (max-width: 480px) { .ihooman-window { width: calc(100vw - 20px); height: calc(100vh - 100px); left: 10px; right: 10px; bottom: 80px; max-height: none; } .ihooman-toggle { ${positionRight ? 'right: 16px' : 'left: 16px'}; bottom: 16px; } }
258
+ `;
259
+ }
260
+ // ============================================================================
261
+ // DOM CREATION
262
+ // ============================================================================
263
+ /**
264
+ * Create the widget DOM elements
265
+ */
266
+ function createWidget() {
267
+ // Add styles
268
+ const styleEl = document.createElement('style');
269
+ styleEl.id = 'ihooman-widget-styles';
270
+ styleEl.textContent = generateStyles();
271
+ document.head.appendChild(styleEl);
272
+ // Create widget container
273
+ const widget = document.createElement('div');
274
+ widget.className = 'ihooman-widget';
275
+ widget.innerHTML = `
276
+ <button class="ihooman-toggle" aria-label="Open chat">
277
+ <span class="ihooman-pulse"></span>
278
+ <span class="ihooman-badge"></span>
279
+ <span class="chat-icon">${icons.chat}</span>
280
+ <span class="close-icon">${icons.close}</span>
281
+ </button>
282
+ <div class="ihooman-window" role="dialog" aria-label="Chat window">
283
+ <div class="ihooman-container">
284
+ <div class="ihooman-header">
285
+ <div class="ihooman-header-avatar">${config.avatarUrl ? `<img src="${escapeHtml(config.avatarUrl)}" alt="Support">` : icons.bot}</div>
286
+ <div class="ihooman-header-info">
287
+ <div class="ihooman-header-title">${escapeHtml(config.title || 'Chat Support')}</div>
288
+ <div class="ihooman-header-status"><span class="ihooman-status-dot"></span><span class="ihooman-status-text">Online</span></div>
289
+ </div>
290
+ <div class="ihooman-header-actions">
291
+ <button class="ihooman-header-btn" data-action="refresh" title="New conversation">${icons.refresh}</button>
292
+ <button class="ihooman-header-btn" data-action="minimize" title="Minimize">${icons.minimize}</button>
293
+ </div>
294
+ </div>
295
+ <div class="ihooman-messages" role="log" aria-live="polite"></div>
296
+ <div class="ihooman-input-area">
297
+ <div class="ihooman-input-wrapper">
298
+ ${config.enableFileUpload ? `<button class="ihooman-input-btn attach" title="Attach file">${icons.attach}</button><input type="file" class="ihooman-file-input">` : ''}
299
+ <textarea class="ihooman-input" placeholder="${escapeHtml(config.placeholder || 'Type a message...')}" rows="1" aria-label="Message input"></textarea>
300
+ <button class="ihooman-input-btn send" title="Send message" disabled>${icons.send}</button>
301
+ </div>
302
+ </div>
303
+ ${config.poweredBy ? `<div class="ihooman-powered">Powered by <a href="https://ihooman.ai" target="_blank" rel="noopener">Ihooman AI</a></div>` : ''}
304
+ </div>
305
+ </div>
306
+ `;
307
+ document.body.appendChild(widget);
308
+ // Store element references
309
+ elements = {
310
+ widget,
311
+ toggle: widget.querySelector('.ihooman-toggle'),
312
+ badge: widget.querySelector('.ihooman-badge'),
313
+ window: widget.querySelector('.ihooman-window'),
314
+ messages: widget.querySelector('.ihooman-messages'),
315
+ input: widget.querySelector('.ihooman-input'),
316
+ sendBtn: widget.querySelector('.ihooman-input-btn.send'),
317
+ attachBtn: widget.querySelector('.ihooman-input-btn.attach'),
318
+ fileInput: widget.querySelector('.ihooman-file-input'),
319
+ statusDot: widget.querySelector('.ihooman-status-dot'),
320
+ statusText: widget.querySelector('.ihooman-status-text'),
321
+ };
322
+ // Set up event listeners
323
+ setupEventListeners();
324
+ }
325
+ /**
326
+ * Set up DOM event listeners
327
+ */
328
+ function setupEventListeners() {
329
+ if (!elements.toggle || !elements.sendBtn || !elements.input)
330
+ return;
331
+ elements.toggle.addEventListener('click', toggle);
332
+ elements.sendBtn.addEventListener('click', handleSendClick);
333
+ elements.input.addEventListener('input', () => {
334
+ if (elements.sendBtn) {
335
+ elements.sendBtn.disabled = !elements.input?.value.trim();
336
+ }
337
+ if (elements.input) {
338
+ elements.input.style.height = 'auto';
339
+ elements.input.style.height = Math.min(elements.input.scrollHeight, 100) + 'px';
340
+ }
341
+ });
342
+ elements.input.addEventListener('keydown', (e) => {
343
+ if (e.key === 'Enter' && !e.shiftKey) {
344
+ e.preventDefault();
345
+ if (!elements.sendBtn?.disabled)
346
+ handleSendClick();
347
+ }
348
+ });
349
+ if (elements.attachBtn && elements.fileInput) {
350
+ elements.attachBtn.addEventListener('click', () => elements.fileInput?.click());
351
+ elements.fileInput.addEventListener('change', handleFileSelect);
352
+ }
353
+ elements.widget?.querySelector('[data-action="refresh"]')?.addEventListener('click', startNewConversation);
354
+ elements.widget?.querySelector('[data-action="minimize"]')?.addEventListener('click', close);
355
+ }
356
+ // ============================================================================
357
+ // MESSAGING
358
+ // ============================================================================
359
+ /**
360
+ * Add a message to the chat
361
+ */
362
+ function addMessage(content, sender = 'bot', metadata = {}) {
363
+ const message = {
364
+ id: generateId('msg_'),
365
+ content,
366
+ sender,
367
+ timestamp: new Date(),
368
+ metadata,
369
+ };
370
+ state.messages.push(message);
371
+ if (!elements.messages)
372
+ return message;
373
+ const el = document.createElement('div');
374
+ el.className = `ihooman-message ${sender}`;
375
+ el.innerHTML = `
376
+ <div class="ihooman-message-content">${parseMarkdown(content)}</div>
377
+ ${config.showTimestamps ? `<div class="ihooman-message-time">${formatTime(message.timestamp)}</div>` : ''}
378
+ `;
379
+ // Remove typing indicator if present
380
+ const typing = elements.messages.querySelector('.ihooman-typing');
381
+ if (typing)
382
+ typing.remove();
383
+ elements.messages.appendChild(el);
384
+ elements.messages.scrollTop = elements.messages.scrollHeight;
385
+ // Update unread count if widget is closed
386
+ if (sender === 'bot' && !state.isOpen) {
387
+ state.unreadCount++;
388
+ if (elements.badge) {
389
+ elements.badge.textContent = String(state.unreadCount);
390
+ }
391
+ if (config.enableSounds)
392
+ playSound();
393
+ }
394
+ emit('message', message);
395
+ return message;
396
+ }
397
+ /**
398
+ * Show typing indicator
399
+ */
400
+ function showTyping() {
401
+ if (!config.showTypingIndicator || !elements.messages)
402
+ return;
403
+ if (elements.messages.querySelector('.ihooman-typing'))
404
+ return;
405
+ const typing = document.createElement('div');
406
+ typing.className = 'ihooman-typing';
407
+ typing.innerHTML = `<div class="ihooman-typing-dots"><span class="ihooman-typing-dot"></span><span class="ihooman-typing-dot"></span><span class="ihooman-typing-dot"></span></div>`;
408
+ elements.messages.appendChild(typing);
409
+ elements.messages.scrollTop = elements.messages.scrollHeight;
410
+ }
411
+ /**
412
+ * Hide typing indicator
413
+ */
414
+ function hideTyping() {
415
+ const typing = elements.messages?.querySelector('.ihooman-typing');
416
+ if (typing)
417
+ typing.remove();
418
+ }
419
+ /**
420
+ * Handle send button click
421
+ */
422
+ function handleSendClick() {
423
+ const content = elements.input?.value.trim();
424
+ if (!content)
425
+ return;
426
+ if (elements.input) {
427
+ elements.input.value = '';
428
+ elements.input.style.height = 'auto';
429
+ }
430
+ if (elements.sendBtn) {
431
+ elements.sendBtn.disabled = true;
432
+ }
433
+ addMessage(content, 'user');
434
+ showTyping();
435
+ sendMessageToServer(content);
436
+ }
437
+ /**
438
+ * Send message to the server
439
+ */
440
+ async function sendMessageToServer(content) {
441
+ try {
442
+ const response = await fetch(`${config.serverUrl}/api/chat`, {
443
+ method: 'POST',
444
+ headers: { 'Content-Type': 'application/json' },
445
+ body: JSON.stringify({
446
+ message: content,
447
+ session_id: state.sessionId,
448
+ visitor_id: state.visitorId,
449
+ widget_id: config.widgetId,
450
+ }),
451
+ });
452
+ const data = await response.json();
453
+ hideTyping();
454
+ if (data.session_id) {
455
+ state.sessionId = data.session_id;
456
+ if (config.persistSession)
457
+ storage('session_id', state.sessionId);
458
+ }
459
+ if (data.response) {
460
+ addMessage(data.response, 'bot', { sources: data.sources, confidence: data.confidence });
461
+ }
462
+ }
463
+ catch (error) {
464
+ hideTyping();
465
+ addMessage('Sorry, something went wrong. Please try again.', 'bot');
466
+ emit('error', error);
467
+ }
468
+ }
469
+ /**
470
+ * Handle file selection
471
+ */
472
+ function handleFileSelect(e) {
473
+ const target = e.target;
474
+ const file = target.files?.[0];
475
+ if (!file)
476
+ return;
477
+ addMessage(`📎 Uploading: ${file.name}...`, 'user');
478
+ showTyping();
479
+ const formData = new FormData();
480
+ formData.append('file', file);
481
+ formData.append('session_id', state.sessionId || '');
482
+ formData.append('visitor_id', state.visitorId || '');
483
+ formData.append('widget_id', config.widgetId);
484
+ fetch(`${config.serverUrl}/api/upload`, { method: 'POST', body: formData })
485
+ .then((r) => r.json())
486
+ .then((data) => {
487
+ hideTyping();
488
+ if (data.message)
489
+ addMessage(data.message, 'bot');
490
+ })
491
+ .catch(() => {
492
+ hideTyping();
493
+ addMessage('Failed to upload file.', 'bot');
494
+ });
495
+ target.value = '';
496
+ }
497
+ /**
498
+ * Play notification sound
499
+ */
500
+ function playSound() {
501
+ try {
502
+ const audio = new Audio('data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAABhgC7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7//////////////////////////////////////////////////////////////////8AAAAATGF2YzU4LjEzAAAAAAAAAAAAAAAAJAAAAAAAAAAAAYYNBrP/AAAAAAAAAAAAAAAAAAAAAP/7UMQAA8AAAaQAAAAgAAA0gAAABExBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//tQxBKDwAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=');
503
+ audio.volume = 0.3;
504
+ audio.play().catch(() => { });
505
+ }
506
+ catch {
507
+ // Ignore audio errors
508
+ }
509
+ }
510
+ // ============================================================================
511
+ // WEBSOCKET CONNECTION
512
+ // ============================================================================
513
+ /**
514
+ * Connect to WebSocket for real-time messaging
515
+ */
516
+ function connectWebSocket(chatEndpoint) {
517
+ if (!config.serverUrl && !chatEndpoint)
518
+ return;
519
+ const wsUrl = chatEndpoint || config.serverUrl?.replace(/^http/, 'ws') + '/ws';
520
+ try {
521
+ ws = new WebSocket(wsUrl);
522
+ ws.onopen = () => {
523
+ state.isConnected = true;
524
+ reconnectAttempts = 0;
525
+ updateStatus(true);
526
+ // Send initial connection message with widget ID
527
+ if (ws && ws.readyState === WebSocket.OPEN) {
528
+ ws.send(JSON.stringify({
529
+ type: 'connect',
530
+ widget_id: config.widgetId,
531
+ visitor_id: state.visitorId,
532
+ session_id: state.sessionId,
533
+ }));
534
+ }
535
+ };
536
+ ws.onclose = () => {
537
+ state.isConnected = false;
538
+ updateStatus(false);
539
+ // Attempt reconnection with exponential backoff
540
+ if (reconnectAttempts < maxReconnectAttempts) {
541
+ reconnectAttempts++;
542
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
543
+ setTimeout(() => connectWebSocket(chatEndpoint), delay);
544
+ }
545
+ else {
546
+ // Fall back to polling
547
+ startPolling();
548
+ }
549
+ };
550
+ ws.onerror = () => {
551
+ // WebSocket error - will trigger onclose
552
+ };
553
+ ws.onmessage = (e) => {
554
+ try {
555
+ const data = JSON.parse(e.data);
556
+ if (data.type === 'message') {
557
+ hideTyping();
558
+ addMessage(data.content, data.sender_type || 'bot', data.metadata);
559
+ }
560
+ else if (data.type === 'typing') {
561
+ data.is_typing ? showTyping() : hideTyping();
562
+ }
563
+ }
564
+ catch {
565
+ // Ignore parse errors
566
+ }
567
+ };
568
+ }
569
+ catch {
570
+ // WebSocket not supported, fall back to polling
571
+ startPolling();
572
+ }
573
+ }
574
+ /**
575
+ * Start polling for messages (fallback when WebSocket unavailable)
576
+ */
577
+ function startPolling() {
578
+ if (pollInterval)
579
+ return;
580
+ pollInterval = setInterval(async () => {
581
+ if (!state.sessionId)
582
+ return;
583
+ try {
584
+ const response = await fetch(`${config.serverUrl}/api/messages?session_id=${state.sessionId}&since=${Date.now() - 5000}&widget_id=${config.widgetId}`);
585
+ const data = await response.json();
586
+ if (data.messages) {
587
+ data.messages.forEach((msg) => {
588
+ if (!state.messages.find((m) => m.id === msg.id)) {
589
+ addMessage(msg.content, msg.sender_type, msg.metadata);
590
+ }
591
+ });
592
+ }
593
+ }
594
+ catch {
595
+ // Ignore polling errors
596
+ }
597
+ }, 5000);
598
+ }
599
+ /**
600
+ * Update connection status display
601
+ */
602
+ function updateStatus(online) {
603
+ if (elements.statusDot) {
604
+ elements.statusDot.classList.toggle('offline', !online);
605
+ }
606
+ if (elements.statusText) {
607
+ elements.statusText.textContent = online ? 'Online' : 'Offline';
608
+ }
609
+ }
610
+ // ============================================================================
611
+ // PUBLIC API METHODS
612
+ // ============================================================================
613
+ /**
614
+ * Open the chat widget window
615
+ */
616
+ function open() {
617
+ if (state.isOpen)
618
+ return;
619
+ state.isOpen = true;
620
+ state.unreadCount = 0;
621
+ if (elements.badge) {
622
+ elements.badge.textContent = '';
623
+ }
624
+ if (elements.toggle) {
625
+ elements.toggle.classList.add('open');
626
+ }
627
+ if (elements.window) {
628
+ elements.window.classList.add('open');
629
+ }
630
+ setTimeout(() => elements.input?.focus(), 300);
631
+ emit('open');
632
+ }
633
+ /**
634
+ * Close the chat widget window
635
+ */
636
+ function close() {
637
+ if (!state.isOpen)
638
+ return;
639
+ state.isOpen = false;
640
+ if (elements.toggle) {
641
+ elements.toggle.classList.remove('open');
642
+ }
643
+ if (elements.window) {
644
+ elements.window.classList.remove('open');
645
+ }
646
+ emit('close');
647
+ }
648
+ /**
649
+ * Toggle the chat widget window
650
+ */
651
+ function toggle() {
652
+ state.isOpen ? close() : open();
653
+ }
654
+ /**
655
+ * Start a new conversation
656
+ */
657
+ function startNewConversation() {
658
+ state.sessionId = null;
659
+ state.messages = [];
660
+ storage('session_id', null);
661
+ if (elements.messages) {
662
+ elements.messages.innerHTML = '';
663
+ }
664
+ if (config.welcomeMessage) {
665
+ addMessage(config.welcomeMessage, 'bot');
666
+ }
667
+ }
668
+ /**
669
+ * Destroy the widget and clean up resources
670
+ */
671
+ function destroy() {
672
+ // Close WebSocket
673
+ if (ws) {
674
+ ws.close();
675
+ ws = null;
676
+ }
677
+ // Clear polling interval
678
+ if (pollInterval) {
679
+ clearInterval(pollInterval);
680
+ pollInterval = null;
681
+ }
682
+ // Remove DOM elements
683
+ if (elements.widget) {
684
+ elements.widget.remove();
685
+ }
686
+ const styles = document.getElementById('ihooman-widget-styles');
687
+ if (styles) {
688
+ styles.remove();
689
+ }
690
+ // Reset state
691
+ state = {
692
+ isOpen: false,
693
+ isConnected: false,
694
+ messages: [],
695
+ sessionId: null,
696
+ visitorId: null,
697
+ unreadCount: 0,
698
+ };
699
+ elements = {};
700
+ reconnectAttempts = 0;
701
+ }
702
+ /**
703
+ * Send a message programmatically
704
+ */
705
+ function sendMessage(content) {
706
+ if (!content.trim())
707
+ return;
708
+ if (elements.input) {
709
+ elements.input.value = content;
710
+ }
711
+ handleSendClick();
712
+ }
713
+ /**
714
+ * Set user information
715
+ */
716
+ function setUser(user) {
717
+ if (user.name)
718
+ storage('user_name', user.name);
719
+ if (user.email)
720
+ storage('user_email', user.email);
721
+ if (user.metadata)
722
+ storage('user_metadata', user.metadata);
723
+ }
724
+ /**
725
+ * Clear chat history
726
+ */
727
+ function clearHistory() {
728
+ startNewConversation();
729
+ }
730
+ /**
731
+ * Subscribe to widget events
732
+ */
733
+ function on(event, callback) {
734
+ if (!eventListeners[event]) {
735
+ eventListeners[event] = [];
736
+ }
737
+ eventListeners[event].push(callback);
738
+ }
739
+ /**
740
+ * Unsubscribe from widget events
741
+ */
742
+ function off(event, callback) {
743
+ if (eventListeners[event]) {
744
+ eventListeners[event] = eventListeners[event].filter((fn) => fn !== callback);
745
+ }
746
+ }
747
+ /**
748
+ * Get current widget state
749
+ */
750
+ function getState() {
751
+ return { ...state };
752
+ }
753
+ // ============================================================================
754
+ // INITIALIZATION
755
+ // ============================================================================
756
+ /**
757
+ * Fetch widget configuration from the Widget Configuration API
758
+ *
759
+ * Requirements:
760
+ * - 10.1: Widget only requires Widget ID, never API key
761
+ * - 10.2: Widget fetches configuration using only Widget ID
762
+ */
763
+ async function fetchWidgetConfig(widgetId, serverUrl) {
764
+ try {
765
+ const response = await fetch(`${serverUrl}/api/widget/config?widget_id=${encodeURIComponent(widgetId)}`);
766
+ if (!response.ok) {
767
+ const errorData = await response.json().catch(() => ({}));
768
+ return {
769
+ success: false,
770
+ error: errorData.error || `HTTP ${response.status}`,
771
+ };
772
+ }
773
+ return await response.json();
774
+ }
775
+ catch (error) {
776
+ return {
777
+ success: false,
778
+ error: 'Unable to connect. Please check your internet connection.',
779
+ };
780
+ }
781
+ }
782
+ /**
783
+ * Apply server configuration to local config
784
+ */
785
+ function applyServerConfig(serverConfig) {
786
+ // Apply basic settings
787
+ config.title = serverConfig.title || config.title;
788
+ config.subtitle = serverConfig.subtitle || config.subtitle;
789
+ config.welcomeMessage = serverConfig.welcomeMessage || config.welcomeMessage;
790
+ config.placeholder = serverConfig.placeholder || config.placeholder;
791
+ config.position = serverConfig.position || config.position;
792
+ // Apply theme settings
793
+ if (serverConfig.theme) {
794
+ config.primaryColor = serverConfig.theme.primaryColor || config.primaryColor;
795
+ config.gradientFrom = serverConfig.theme.gradientFrom || config.gradientFrom;
796
+ config.gradientTo = serverConfig.theme.gradientTo || config.gradientTo;
797
+ config.fontFamily = serverConfig.theme.fontFamily || config.fontFamily;
798
+ if (serverConfig.theme.borderRadius) {
799
+ config.borderRadius = parseInt(serverConfig.theme.borderRadius, 10) || config.borderRadius;
800
+ }
801
+ }
802
+ // Apply behavior settings
803
+ if (serverConfig.behavior) {
804
+ config.startOpen = serverConfig.behavior.startOpen ?? config.startOpen;
805
+ config.showTypingIndicator = serverConfig.behavior.showTypingIndicator ?? config.showTypingIndicator;
806
+ config.showTimestamps = serverConfig.behavior.showTimestamps ?? config.showTimestamps;
807
+ config.enableSounds = serverConfig.behavior.enableSounds ?? config.enableSounds;
808
+ config.enableFileUpload = serverConfig.behavior.enableFileUpload ?? config.enableFileUpload;
809
+ config.persistSession = serverConfig.behavior.persistSession ?? config.persistSession;
810
+ }
811
+ // Apply branding settings
812
+ if (serverConfig.branding) {
813
+ config.avatarUrl = serverConfig.branding.avatarUrl || config.avatarUrl;
814
+ config.poweredBy = serverConfig.branding.poweredBy ?? config.poweredBy;
815
+ }
816
+ }
817
+ /**
818
+ * Initialize the widget
819
+ *
820
+ * Requirements:
821
+ * - 5.2: Export IhoomanChat object with init, open, close, toggle, destroy methods
822
+ * - 5.3: Accept widgetId configuration option for initialization
823
+ * - 10.1: Widget only requires Widget ID, never API key
824
+ * - 10.2: Widget fetches configuration using only Widget ID
825
+ */
826
+ async function init(userConfig) {
827
+ // Validate required widgetId
828
+ if (!userConfig.widgetId) {
829
+ console.error('IhoomanChat: widgetId is required');
830
+ return null;
831
+ }
832
+ // Merge user config with defaults
833
+ config = { ...defaultConfig, ...userConfig };
834
+ // Initialize visitor ID
835
+ state.visitorId = storage('visitor_id') || generateId('v_');
836
+ storage('visitor_id', state.visitorId);
837
+ // Restore session if persistence is enabled
838
+ if (config.persistSession) {
839
+ state.sessionId = storage('session_id');
840
+ }
841
+ // Fetch configuration from Widget Configuration API using Widget ID
842
+ // This is the secure approach - no API key is ever sent from the client
843
+ const serverUrl = config.serverUrl || 'https://api.ihooman.ai';
844
+ const configResponse = await fetchWidgetConfig(config.widgetId, serverUrl);
845
+ let chatEndpoint;
846
+ if (configResponse.success && configResponse.config) {
847
+ // Apply server configuration
848
+ applyServerConfig(configResponse.config);
849
+ chatEndpoint = configResponse.chatEndpoint;
850
+ }
851
+ else if (configResponse.error) {
852
+ // Log error but continue with local config
853
+ console.warn('IhoomanChat: Could not fetch server config:', configResponse.error);
854
+ // If domain validation failed, show error to user
855
+ if (configResponse.error === 'Widget not authorized for this domain') {
856
+ console.error('IhoomanChat: Widget configuration error. Please contact support.');
857
+ // Still create widget but show error state
858
+ }
859
+ }
860
+ // Create the widget DOM
861
+ createWidget();
862
+ // Restore messages from state (if any)
863
+ state.messages.forEach((msg) => addMessage(msg.content, msg.sender, msg.metadata));
864
+ // Show welcome message if no messages
865
+ if (state.messages.length === 0 && config.welcomeMessage) {
866
+ addMessage(config.welcomeMessage, 'bot');
867
+ }
868
+ // Connect WebSocket for real-time messaging
869
+ connectWebSocket(chatEndpoint);
870
+ // Auto-open if configured
871
+ if (config.startOpen) {
872
+ setTimeout(open, 500);
873
+ }
874
+ emit('ready');
875
+ return publicAPI;
876
+ }
877
+ // ============================================================================
878
+ // PUBLIC API OBJECT
879
+ // ============================================================================
880
+ /**
881
+ * Public API object
882
+ *
883
+ * Requirements:
884
+ * - 5.2: Export IhoomanChat object with init, open, close, toggle, destroy methods
885
+ */
886
+ const publicAPI = {
887
+ init,
888
+ open,
889
+ close,
890
+ toggle,
891
+ destroy,
892
+ sendMessage,
893
+ setUser,
894
+ clearHistory,
895
+ on,
896
+ off,
897
+ getState,
898
+ version: VERSION,
899
+ };
900
+ /**
901
+ * Main IhoomanChat export
902
+ */
903
+ const IhoomanChat = publicAPI;
904
+ /**
905
+ * Factory function to create a new widget instance
906
+ */
907
+ function createWidgetInstance() {
908
+ return { ...publicAPI };
909
+ }
910
+ // ============================================================================
911
+ // AUTO-INITIALIZATION
912
+ // ============================================================================
913
+ /**
914
+ * Auto-initialize from script attributes
915
+ * Supports data-widget-id attribute for easy embedding
916
+ */
917
+ (function autoInit() {
918
+ if (typeof document === 'undefined')
919
+ return;
920
+ let script = document.currentScript;
921
+ if (!script) {
922
+ const scripts = document.getElementsByTagName('script');
923
+ for (let i = 0; i < scripts.length; i++) {
924
+ if (scripts[i].src && scripts[i].src.includes('ihooman')) {
925
+ script = scripts[i];
926
+ break;
927
+ }
928
+ }
929
+ }
930
+ if (!script)
931
+ return;
932
+ const widgetId = script.getAttribute('data-widget-id');
933
+ if (widgetId) {
934
+ const autoConfig = { widgetId };
935
+ // Parse optional attributes
936
+ const serverUrl = script.getAttribute('data-server-url');
937
+ if (serverUrl)
938
+ autoConfig.serverUrl = serverUrl;
939
+ const theme = script.getAttribute('data-theme');
940
+ if (theme === 'light' || theme === 'dark')
941
+ autoConfig.theme = theme;
942
+ const position = script.getAttribute('data-position');
943
+ if (position)
944
+ autoConfig.position = position;
945
+ const startOpen = script.getAttribute('data-start-open');
946
+ if (startOpen === 'true')
947
+ autoConfig.startOpen = true;
948
+ // Initialize when DOM is ready
949
+ if (document.readyState === 'loading') {
950
+ document.addEventListener('DOMContentLoaded', () => init(autoConfig));
951
+ }
952
+ else {
953
+ init(autoConfig);
954
+ }
955
+ }
956
+ })();
957
+ // Export for UMD builds
958
+ if (typeof window !== 'undefined') {
959
+ window.IhoomanChat = IhoomanChat;
960
+ }
961
+
962
+ /**
963
+ * @ihooman/chat-widget
964
+ *
965
+ * Universal chat support widget for any website.
966
+ * Uses secure Widget ID based initialization - no API keys exposed in client code.
967
+ *
968
+ * @packageDocumentation
969
+ */
970
+
971
+ export { IhoomanChat, createWidgetInstance, IhoomanChat as default };
972
+ //# sourceMappingURL=index.esm.js.map