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