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