@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/README.md +188 -0
- package/cdn/CDN_DEPLOYMENT.md +199 -0
- package/cdn/SRI_HASHES.md +66 -0
- package/cdn/cloudflare-config.json +203 -0
- package/cdn/cloudfront-config.json +153 -0
- package/cdn/health.json +6 -0
- package/cdn/latest/chat.js +984 -0
- package/cdn/latest/chat.js.map +1 -0
- package/cdn/latest/chat.min.js +2 -0
- package/cdn/latest/chat.min.js.map +1 -0
- package/cdn/manifest.json +42 -0
- package/cdn/nginx.conf +185 -0
- package/cdn/v2/chat.js +984 -0
- package/cdn/v2/chat.js.map +1 -0
- package/cdn/v2/chat.min.js +2 -0
- package/cdn/v2/chat.min.js.map +1 -0
- package/cdn/v2.0.0/chat.js +984 -0
- package/cdn/v2.0.0/chat.js.map +1 -0
- package/cdn/v2.0.0/chat.min.js +2 -0
- package/cdn/v2.0.0/chat.min.js.map +1 -0
- package/dist/index.cjs.js +978 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +972 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.esm.min.js +2 -0
- package/dist/index.esm.min.js.map +1 -0
- package/dist/index.umd.js +984 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/index.umd.min.js +2 -0
- package/dist/index.umd.min.js.map +1 -0
- package/dist/types.d.ts +252 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/widget.d.ts +26 -0
- package/dist/widget.d.ts.map +1 -0
- package/package.json +80 -0
- package/src/index.ts +26 -0
- package/src/types.ts +301 -0
- package/src/widget.ts +1114 -0
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
|
+
}
|