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