@v-tilt/browser 1.2.0 → 1.4.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/dist/all-external-dependencies.js +2 -0
- package/dist/all-external-dependencies.js.map +1 -0
- package/dist/array.full.js +2 -0
- package/dist/array.full.js.map +1 -0
- package/dist/array.js +1 -1
- package/dist/array.js.map +1 -1
- package/dist/array.no-external.js +1 -1
- package/dist/array.no-external.js.map +1 -1
- package/dist/chat.js +2 -0
- package/dist/chat.js.map +1 -0
- package/dist/entrypoints/all-external-dependencies.d.ts +8 -0
- package/dist/entrypoints/array.full.d.ts +17 -0
- package/dist/entrypoints/chat.d.ts +22 -0
- package/dist/entrypoints/web-vitals.d.ts +14 -0
- package/dist/extensions/chat/chat-wrapper.d.ts +172 -0
- package/dist/extensions/chat/chat.d.ts +87 -0
- package/dist/extensions/chat/index.d.ts +10 -0
- package/dist/extensions/chat/types.d.ts +156 -0
- package/dist/external-scripts-loader.js +1 -1
- package/dist/external-scripts-loader.js.map +1 -1
- package/dist/main.js +1 -1
- package/dist/main.js.map +1 -1
- package/dist/module.d.ts +312 -5
- package/dist/module.js +1 -1
- package/dist/module.js.map +1 -1
- package/dist/module.no-external.d.ts +312 -5
- package/dist/module.no-external.js +1 -1
- package/dist/module.no-external.js.map +1 -1
- package/dist/recorder.js.map +1 -1
- package/dist/types.d.ts +59 -2
- package/dist/utils/globals.d.ts +138 -1
- package/dist/vtilt.d.ts +11 -1
- package/dist/web-vitals.d.ts +89 -5
- package/dist/web-vitals.js +2 -0
- package/dist/web-vitals.js.map +1 -0
- package/lib/config.js +5 -3
- package/lib/entrypoints/all-external-dependencies.d.ts +8 -0
- package/lib/entrypoints/all-external-dependencies.js +10 -0
- package/lib/entrypoints/array.full.d.ts +17 -0
- package/lib/entrypoints/array.full.js +19 -0
- package/lib/entrypoints/chat.d.ts +22 -0
- package/lib/entrypoints/chat.js +32 -0
- package/lib/entrypoints/external-scripts-loader.js +1 -1
- package/lib/entrypoints/web-vitals.d.ts +14 -0
- package/lib/entrypoints/web-vitals.js +29 -0
- package/lib/extensions/chat/chat-wrapper.d.ts +172 -0
- package/lib/extensions/chat/chat-wrapper.js +497 -0
- package/lib/extensions/chat/chat.d.ts +87 -0
- package/lib/extensions/chat/chat.js +998 -0
- package/lib/extensions/chat/index.d.ts +10 -0
- package/lib/extensions/chat/index.js +27 -0
- package/lib/extensions/chat/types.d.ts +156 -0
- package/lib/extensions/chat/types.js +22 -0
- package/lib/types.d.ts +59 -2
- package/lib/types.js +16 -0
- package/lib/utils/globals.d.ts +138 -1
- package/lib/vtilt.d.ts +11 -1
- package/lib/vtilt.js +42 -1
- package/lib/web-vitals.d.ts +89 -5
- package/lib/web-vitals.js +354 -46
- package/package.json +66 -65
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Lazy Loaded Chat Implementation
|
|
4
|
+
*
|
|
5
|
+
* The actual chat widget implementation that is loaded on demand.
|
|
6
|
+
* This file is bundled into chat.js and loaded when chat is enabled.
|
|
7
|
+
*
|
|
8
|
+
* Uses Ably for real-time messaging.
|
|
9
|
+
*/
|
|
10
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
11
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
12
|
+
};
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.LazyLoadedChat = void 0;
|
|
15
|
+
const globals_1 = require("../../utils/globals");
|
|
16
|
+
const types_1 = require("./types");
|
|
17
|
+
const ably_1 = __importDefault(require("ably"));
|
|
18
|
+
const LOGGER_PREFIX = "[Chat]";
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Constants
|
|
21
|
+
// ============================================================================
|
|
22
|
+
const DEFAULT_POSITION = "bottom-right";
|
|
23
|
+
const DEFAULT_THEME = {
|
|
24
|
+
primaryColor: "#6366f1",
|
|
25
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
26
|
+
borderRadius: "12px",
|
|
27
|
+
headerBgColor: "#6366f1",
|
|
28
|
+
userBubbleColor: "#6366f1",
|
|
29
|
+
agentBubbleColor: "#f3f4f6",
|
|
30
|
+
};
|
|
31
|
+
// API endpoints (widget endpoints for SDK use)
|
|
32
|
+
const API_WIDGET = "/api/chat/widget";
|
|
33
|
+
const API_WIDGET_MESSAGES = "/api/chat/widget/messages";
|
|
34
|
+
const API_WIDGET_READ = "/api/chat/widget/read";
|
|
35
|
+
const API_ABLY_TOKEN = "/api/chat/ably-token";
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// LazyLoadedChat Implementation
|
|
38
|
+
// ============================================================================
|
|
39
|
+
class LazyLoadedChat {
|
|
40
|
+
constructor(instance, config = {}) {
|
|
41
|
+
// DOM elements
|
|
42
|
+
this._container = null;
|
|
43
|
+
this._widget = null;
|
|
44
|
+
this._bubble = null;
|
|
45
|
+
// Ably Realtime
|
|
46
|
+
this._ably = null;
|
|
47
|
+
this._ablyChannel = null;
|
|
48
|
+
this._typingChannel = null;
|
|
49
|
+
this._connectionState = "disconnected";
|
|
50
|
+
// Callbacks
|
|
51
|
+
this._messageCallbacks = [];
|
|
52
|
+
this._typingCallbacks = [];
|
|
53
|
+
this._connectionCallbacks = [];
|
|
54
|
+
// Timers
|
|
55
|
+
this._typingTimeout = null;
|
|
56
|
+
this._typingDebounce = null;
|
|
57
|
+
this._isUserTyping = false;
|
|
58
|
+
// Read tracking - initial position when widget opens (to show unread indicators)
|
|
59
|
+
this._initialUserReadAt = null;
|
|
60
|
+
this._isMarkingRead = false;
|
|
61
|
+
this._instance = instance;
|
|
62
|
+
this._config = {
|
|
63
|
+
enabled: true,
|
|
64
|
+
position: DEFAULT_POSITION,
|
|
65
|
+
aiMode: true,
|
|
66
|
+
preload: false,
|
|
67
|
+
...config,
|
|
68
|
+
theme: { ...DEFAULT_THEME, ...config.theme },
|
|
69
|
+
};
|
|
70
|
+
this._state = {
|
|
71
|
+
isOpen: false,
|
|
72
|
+
isVisible: true,
|
|
73
|
+
isConnected: false,
|
|
74
|
+
isLoading: false,
|
|
75
|
+
unreadCount: 0,
|
|
76
|
+
channel: null,
|
|
77
|
+
messages: [],
|
|
78
|
+
isTyping: false,
|
|
79
|
+
typingSender: null,
|
|
80
|
+
agentLastReadAt: null, // Read cursor from agent
|
|
81
|
+
};
|
|
82
|
+
// Initialize UI
|
|
83
|
+
this._createUI();
|
|
84
|
+
this._attachEventListeners();
|
|
85
|
+
console.info(`${LOGGER_PREFIX} initialized`);
|
|
86
|
+
}
|
|
87
|
+
// ============================================================================
|
|
88
|
+
// Public API - State (LazyLoadedChatInterface)
|
|
89
|
+
// ============================================================================
|
|
90
|
+
get isOpen() {
|
|
91
|
+
return this._state.isOpen;
|
|
92
|
+
}
|
|
93
|
+
get isConnected() {
|
|
94
|
+
return this._state.isConnected;
|
|
95
|
+
}
|
|
96
|
+
get isLoading() {
|
|
97
|
+
return this._state.isLoading;
|
|
98
|
+
}
|
|
99
|
+
get unreadCount() {
|
|
100
|
+
return this._state.unreadCount;
|
|
101
|
+
}
|
|
102
|
+
get channel() {
|
|
103
|
+
return this._state.channel;
|
|
104
|
+
}
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// Public API - Widget Control
|
|
107
|
+
// ============================================================================
|
|
108
|
+
open() {
|
|
109
|
+
var _a;
|
|
110
|
+
if (this._state.isOpen)
|
|
111
|
+
return;
|
|
112
|
+
this._state.isOpen = true;
|
|
113
|
+
this._updateUI();
|
|
114
|
+
this._trackEvent(types_1.CHAT_EVENTS.WIDGET_OPENED, {
|
|
115
|
+
$page_url: (_a = globals_1.window === null || globals_1.window === void 0 ? void 0 : globals_1.window.location) === null || _a === void 0 ? void 0 : _a.href,
|
|
116
|
+
$trigger: "api",
|
|
117
|
+
});
|
|
118
|
+
// Initialize channel if needed
|
|
119
|
+
if (!this._state.channel) {
|
|
120
|
+
this._initializeChannel();
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// Channel exists, mark any unread messages as read
|
|
124
|
+
this._autoMarkAsRead();
|
|
125
|
+
}
|
|
126
|
+
// Connect to Ably
|
|
127
|
+
if (!this._state.isConnected) {
|
|
128
|
+
this._connectRealtime();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
close() {
|
|
132
|
+
if (!this._state.isOpen)
|
|
133
|
+
return;
|
|
134
|
+
const timeOpen = this._getTimeOpen();
|
|
135
|
+
this._state.isOpen = false;
|
|
136
|
+
this._updateUI();
|
|
137
|
+
this._trackEvent(types_1.CHAT_EVENTS.WIDGET_CLOSED, {
|
|
138
|
+
$time_open_seconds: timeOpen,
|
|
139
|
+
$messages_sent: this._state.messages.filter((m) => m.sender_type === "user")
|
|
140
|
+
.length,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
toggle() {
|
|
144
|
+
if (this._state.isOpen) {
|
|
145
|
+
this.close();
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
this.open();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
show() {
|
|
152
|
+
this._state.isVisible = true;
|
|
153
|
+
this._updateUI();
|
|
154
|
+
}
|
|
155
|
+
hide() {
|
|
156
|
+
this._state.isVisible = false;
|
|
157
|
+
this._updateUI();
|
|
158
|
+
}
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// Public API - Messaging
|
|
161
|
+
// ============================================================================
|
|
162
|
+
async sendMessage(content) {
|
|
163
|
+
var _a, _b, _c, _d;
|
|
164
|
+
if (!content.trim())
|
|
165
|
+
return;
|
|
166
|
+
// Ensure channel exists
|
|
167
|
+
if (!this._state.channel) {
|
|
168
|
+
await this._initializeChannel();
|
|
169
|
+
}
|
|
170
|
+
const channelId = (_a = this._state.channel) === null || _a === void 0 ? void 0 : _a.id;
|
|
171
|
+
if (!channelId) {
|
|
172
|
+
console.error(`${LOGGER_PREFIX} No channel to send message to`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Optimistic update
|
|
176
|
+
const tempMessage = {
|
|
177
|
+
id: `temp-${Date.now()}`,
|
|
178
|
+
channel_id: channelId,
|
|
179
|
+
sender_type: "user",
|
|
180
|
+
sender_id: this._instance.getDistinctId() || null,
|
|
181
|
+
sender_name: null,
|
|
182
|
+
sender_avatar_url: null,
|
|
183
|
+
content,
|
|
184
|
+
content_type: "text",
|
|
185
|
+
metadata: {},
|
|
186
|
+
created_at: new Date().toISOString(),
|
|
187
|
+
};
|
|
188
|
+
this._state.messages.push(tempMessage);
|
|
189
|
+
this._updateUI();
|
|
190
|
+
try {
|
|
191
|
+
// Send to API
|
|
192
|
+
const response = await this._apiRequest(`${API_WIDGET_MESSAGES}`, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
channel_id: channelId,
|
|
196
|
+
distinct_id: this._instance.getDistinctId(),
|
|
197
|
+
content,
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
// Replace temp message with real one
|
|
201
|
+
const index = this._state.messages.findIndex((m) => m.id === tempMessage.id);
|
|
202
|
+
if (index !== -1 && (response === null || response === void 0 ? void 0 : response.message)) {
|
|
203
|
+
this._state.messages[index] = response.message;
|
|
204
|
+
}
|
|
205
|
+
// Note: AI response will come through Ably channel if AI mode is enabled
|
|
206
|
+
// No need to handle it here since server publishes to Ably
|
|
207
|
+
// Track event
|
|
208
|
+
this._trackEvent(types_1.CHAT_EVENTS.MESSAGE_SENT, {
|
|
209
|
+
$channel_id: channelId,
|
|
210
|
+
$message_id: (_b = response === null || response === void 0 ? void 0 : response.message) === null || _b === void 0 ? void 0 : _b.id,
|
|
211
|
+
$content_preview: content.substring(0, 100),
|
|
212
|
+
$sender_type: "user",
|
|
213
|
+
$ai_mode: (_d = (_c = this._state.channel) === null || _c === void 0 ? void 0 : _c.ai_mode) !== null && _d !== void 0 ? _d : true,
|
|
214
|
+
$word_count: content.split(/\s+/).length,
|
|
215
|
+
});
|
|
216
|
+
this._updateUI();
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
console.error(`${LOGGER_PREFIX} Failed to send message:`, error);
|
|
220
|
+
// Remove temp message on error
|
|
221
|
+
this._state.messages = this._state.messages.filter((m) => m.id !== tempMessage.id);
|
|
222
|
+
this._updateUI();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
markAsRead() {
|
|
226
|
+
this._autoMarkAsRead();
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Automatically mark unread agent/AI messages as read
|
|
230
|
+
* Called when widget opens or new messages arrive while open
|
|
231
|
+
*/
|
|
232
|
+
_autoMarkAsRead() {
|
|
233
|
+
if (!this._state.channel)
|
|
234
|
+
return;
|
|
235
|
+
if (this._isMarkingRead)
|
|
236
|
+
return; // Already in progress
|
|
237
|
+
// Get the latest message timestamp
|
|
238
|
+
const latestMessage = this._state.messages[this._state.messages.length - 1];
|
|
239
|
+
if (!latestMessage)
|
|
240
|
+
return;
|
|
241
|
+
// Check if there are unread agent messages
|
|
242
|
+
const hasUnreadAgentMessages = this._state.messages.some((m) => (m.sender_type === "agent" || m.sender_type === "ai") && !this._isMessageReadByUser(m.created_at));
|
|
243
|
+
if (!hasUnreadAgentMessages)
|
|
244
|
+
return;
|
|
245
|
+
this._state.unreadCount = 0;
|
|
246
|
+
this._isMarkingRead = true;
|
|
247
|
+
this._updateUI();
|
|
248
|
+
// API call to update read cursor with latest message timestamp
|
|
249
|
+
this._apiRequest(API_WIDGET_READ, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
body: JSON.stringify({
|
|
252
|
+
channel_id: this._state.channel.id,
|
|
253
|
+
distinct_id: this._instance.getDistinctId(),
|
|
254
|
+
read_at: latestMessage.created_at,
|
|
255
|
+
}),
|
|
256
|
+
})
|
|
257
|
+
.then(() => {
|
|
258
|
+
// Update initial read position after successful mark
|
|
259
|
+
this._initialUserReadAt = latestMessage.created_at;
|
|
260
|
+
this._isMarkingRead = false;
|
|
261
|
+
this._updateUI();
|
|
262
|
+
})
|
|
263
|
+
.catch((err) => {
|
|
264
|
+
console.error(`${LOGGER_PREFIX} Failed to mark as read:`, err);
|
|
265
|
+
this._isMarkingRead = false;
|
|
266
|
+
this._updateUI();
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Check if a message has been read by the user (using initial cursor)
|
|
271
|
+
*/
|
|
272
|
+
_isMessageReadByUser(messageCreatedAt) {
|
|
273
|
+
if (!this._initialUserReadAt)
|
|
274
|
+
return false;
|
|
275
|
+
return new Date(messageCreatedAt) <= new Date(this._initialUserReadAt);
|
|
276
|
+
}
|
|
277
|
+
// ============================================================================
|
|
278
|
+
// Public API - Events
|
|
279
|
+
// ============================================================================
|
|
280
|
+
onMessage(callback) {
|
|
281
|
+
this._messageCallbacks.push(callback);
|
|
282
|
+
return () => {
|
|
283
|
+
const index = this._messageCallbacks.indexOf(callback);
|
|
284
|
+
if (index > -1)
|
|
285
|
+
this._messageCallbacks.splice(index, 1);
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
onTyping(callback) {
|
|
289
|
+
this._typingCallbacks.push(callback);
|
|
290
|
+
return () => {
|
|
291
|
+
const index = this._typingCallbacks.indexOf(callback);
|
|
292
|
+
if (index > -1)
|
|
293
|
+
this._typingCallbacks.splice(index, 1);
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
onConnectionChange(callback) {
|
|
297
|
+
this._connectionCallbacks.push(callback);
|
|
298
|
+
return () => {
|
|
299
|
+
const index = this._connectionCallbacks.indexOf(callback);
|
|
300
|
+
if (index > -1)
|
|
301
|
+
this._connectionCallbacks.splice(index, 1);
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
// ============================================================================
|
|
305
|
+
// Public API - Lifecycle
|
|
306
|
+
// ============================================================================
|
|
307
|
+
destroy() {
|
|
308
|
+
// Disconnect Ably
|
|
309
|
+
this._disconnectRealtime();
|
|
310
|
+
// Clear timers
|
|
311
|
+
if (this._typingTimeout)
|
|
312
|
+
clearTimeout(this._typingTimeout);
|
|
313
|
+
if (this._typingDebounce)
|
|
314
|
+
clearTimeout(this._typingDebounce);
|
|
315
|
+
// Remove DOM elements
|
|
316
|
+
if (this._container && this._container.parentNode) {
|
|
317
|
+
this._container.parentNode.removeChild(this._container);
|
|
318
|
+
}
|
|
319
|
+
// Clear callbacks
|
|
320
|
+
this._messageCallbacks = [];
|
|
321
|
+
this._typingCallbacks = [];
|
|
322
|
+
this._connectionCallbacks = [];
|
|
323
|
+
console.info(`${LOGGER_PREFIX} destroyed`);
|
|
324
|
+
}
|
|
325
|
+
// ============================================================================
|
|
326
|
+
// Private - Channel Management
|
|
327
|
+
// ============================================================================
|
|
328
|
+
async _initializeChannel() {
|
|
329
|
+
var _a;
|
|
330
|
+
this._state.isLoading = true;
|
|
331
|
+
this._updateUI();
|
|
332
|
+
try {
|
|
333
|
+
const response = await this._apiRequest(`${API_WIDGET}`, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
body: JSON.stringify({
|
|
336
|
+
distinct_id: this._instance.getDistinctId(),
|
|
337
|
+
page_url: (_a = globals_1.window === null || globals_1.window === void 0 ? void 0 : globals_1.window.location) === null || _a === void 0 ? void 0 : _a.href,
|
|
338
|
+
page_title: globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.title,
|
|
339
|
+
}),
|
|
340
|
+
});
|
|
341
|
+
if (response) {
|
|
342
|
+
this._state.channel = response.channel;
|
|
343
|
+
this._state.messages = response.messages || [];
|
|
344
|
+
// Initialize read cursors from channel
|
|
345
|
+
this._state.agentLastReadAt = response.channel.agent_last_read_at || null;
|
|
346
|
+
this._initialUserReadAt = response.channel.user_last_read_at || null;
|
|
347
|
+
// Track channel started (only if new)
|
|
348
|
+
if (response.messages.length === 0 || response.messages.length === 1) {
|
|
349
|
+
this._trackEvent(types_1.CHAT_EVENTS.STARTED, {
|
|
350
|
+
$channel_id: response.channel.id,
|
|
351
|
+
$initiated_by: "user",
|
|
352
|
+
$ai_mode: response.channel.ai_mode,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// Connect to Ably now that we have channel ID
|
|
356
|
+
this._connectRealtime();
|
|
357
|
+
// Auto-mark unread messages as read if widget is open
|
|
358
|
+
if (this._state.isOpen) {
|
|
359
|
+
this._autoMarkAsRead();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
console.error(`${LOGGER_PREFIX} Failed to initialize channel:`, error);
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
this._state.isLoading = false;
|
|
368
|
+
this._updateUI();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// ============================================================================
|
|
372
|
+
// Private - Ably Realtime Connection
|
|
373
|
+
// ============================================================================
|
|
374
|
+
async _connectRealtime() {
|
|
375
|
+
if (this._ably || !this._state.channel) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
this._connectionState = "connecting";
|
|
379
|
+
this._notifyConnectionChange(false);
|
|
380
|
+
try {
|
|
381
|
+
// Get Ably token from server
|
|
382
|
+
const tokenResponse = await this._apiRequest(API_ABLY_TOKEN, {
|
|
383
|
+
method: "POST",
|
|
384
|
+
body: JSON.stringify({
|
|
385
|
+
distinct_id: this._instance.getDistinctId(),
|
|
386
|
+
channel_id: this._state.channel.id,
|
|
387
|
+
}),
|
|
388
|
+
});
|
|
389
|
+
if (!(tokenResponse === null || tokenResponse === void 0 ? void 0 : tokenResponse.tokenRequest)) {
|
|
390
|
+
console.warn(`${LOGGER_PREFIX} Failed to get Ably token`);
|
|
391
|
+
this._connectionState = "error";
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// Create Ably client with token auth
|
|
395
|
+
this._ably = new ably_1.default.Realtime({
|
|
396
|
+
authCallback: async (_, callback) => {
|
|
397
|
+
var _a;
|
|
398
|
+
try {
|
|
399
|
+
const refreshResponse = await this._apiRequest(API_ABLY_TOKEN, {
|
|
400
|
+
method: "POST",
|
|
401
|
+
body: JSON.stringify({
|
|
402
|
+
distinct_id: this._instance.getDistinctId(),
|
|
403
|
+
channel_id: (_a = this._state.channel) === null || _a === void 0 ? void 0 : _a.id,
|
|
404
|
+
}),
|
|
405
|
+
});
|
|
406
|
+
if (refreshResponse === null || refreshResponse === void 0 ? void 0 : refreshResponse.tokenRequest) {
|
|
407
|
+
callback(null, refreshResponse.tokenRequest);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
callback("Failed to refresh token", null);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
callback(String(err), null);
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
authMethod: "POST",
|
|
418
|
+
});
|
|
419
|
+
// Authenticate with initial token
|
|
420
|
+
await this._ably.auth.authorize(tokenResponse.tokenRequest);
|
|
421
|
+
// Get project ID from instance config
|
|
422
|
+
const config = this._instance.getConfig();
|
|
423
|
+
const projectId = config.projectId || this._extractProjectId(config.token || "");
|
|
424
|
+
// Subscribe to chat channel (Ably channel)
|
|
425
|
+
const ablyChannelName = `chat:${projectId}:${this._state.channel.id}`;
|
|
426
|
+
this._ablyChannel = this._ably.channels.get(ablyChannelName);
|
|
427
|
+
// Listen for new messages
|
|
428
|
+
this._ablyChannel.subscribe("message", (msg) => {
|
|
429
|
+
const message = msg.data;
|
|
430
|
+
this._handleNewMessage(message);
|
|
431
|
+
});
|
|
432
|
+
// Subscribe to typing channel
|
|
433
|
+
const typingChannelName = `${ablyChannelName}:typing`;
|
|
434
|
+
this._typingChannel = this._ably.channels.get(typingChannelName);
|
|
435
|
+
this._typingChannel.subscribe("typing", (msg) => {
|
|
436
|
+
const event = msg.data;
|
|
437
|
+
this._handleTypingEvent(event);
|
|
438
|
+
});
|
|
439
|
+
// Subscribe to read cursor events
|
|
440
|
+
this._ablyChannel.subscribe("read", (msg) => {
|
|
441
|
+
const event = msg.data;
|
|
442
|
+
this._handleReadCursorEvent(event);
|
|
443
|
+
});
|
|
444
|
+
// Handle connection state changes
|
|
445
|
+
this._ably.connection.on("connected", () => {
|
|
446
|
+
this._connectionState = "connected";
|
|
447
|
+
this._state.isConnected = true;
|
|
448
|
+
this._notifyConnectionChange(true);
|
|
449
|
+
console.info(`${LOGGER_PREFIX} Connected to Ably`);
|
|
450
|
+
});
|
|
451
|
+
this._ably.connection.on("disconnected", () => {
|
|
452
|
+
this._connectionState = "disconnected";
|
|
453
|
+
this._state.isConnected = false;
|
|
454
|
+
this._notifyConnectionChange(false);
|
|
455
|
+
});
|
|
456
|
+
this._ably.connection.on("failed", () => {
|
|
457
|
+
this._connectionState = "error";
|
|
458
|
+
this._state.isConnected = false;
|
|
459
|
+
this._notifyConnectionChange(false);
|
|
460
|
+
});
|
|
461
|
+
// Initial connection
|
|
462
|
+
this._ably.connect();
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
console.error(`${LOGGER_PREFIX} Failed to connect to Ably:`, error);
|
|
466
|
+
this._connectionState = "error";
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
_disconnectRealtime() {
|
|
470
|
+
if (this._ablyChannel) {
|
|
471
|
+
this._ablyChannel.unsubscribe();
|
|
472
|
+
this._ablyChannel = null;
|
|
473
|
+
}
|
|
474
|
+
if (this._typingChannel) {
|
|
475
|
+
this._typingChannel.unsubscribe();
|
|
476
|
+
this._typingChannel = null;
|
|
477
|
+
}
|
|
478
|
+
if (this._ably) {
|
|
479
|
+
this._ably.close();
|
|
480
|
+
this._ably = null;
|
|
481
|
+
}
|
|
482
|
+
this._connectionState = "disconnected";
|
|
483
|
+
this._state.isConnected = false;
|
|
484
|
+
}
|
|
485
|
+
_extractProjectId(token) {
|
|
486
|
+
// Token format: project_id.hash
|
|
487
|
+
const parts = token.split(".");
|
|
488
|
+
return parts[0] || "";
|
|
489
|
+
}
|
|
490
|
+
_handleNewMessage(message) {
|
|
491
|
+
// Avoid duplicates by ID
|
|
492
|
+
if (this._state.messages.some((m) => m.id === message.id))
|
|
493
|
+
return;
|
|
494
|
+
// Skip user's own messages - we already have them from optimistic updates
|
|
495
|
+
// The sender_id for user messages is the distinct_id
|
|
496
|
+
if (message.sender_type === "user" && message.sender_id === this._instance.getDistinctId()) {
|
|
497
|
+
// But DO replace temp message with real one if present
|
|
498
|
+
const tempIndex = this._state.messages.findIndex((m) => m.id.startsWith("temp-") && m.content === message.content && m.sender_type === "user");
|
|
499
|
+
if (tempIndex !== -1) {
|
|
500
|
+
this._state.messages[tempIndex] = message;
|
|
501
|
+
this._updateUI();
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// Add to messages
|
|
506
|
+
this._state.messages.push(message);
|
|
507
|
+
// Update unread count if from agent/AI
|
|
508
|
+
if (message.sender_type !== "user") {
|
|
509
|
+
if (!this._state.isOpen) {
|
|
510
|
+
this._state.unreadCount++;
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
// Widget is open, auto-mark as read after a short delay
|
|
514
|
+
// to ensure UI updates first
|
|
515
|
+
setTimeout(() => this._autoMarkAsRead(), 100);
|
|
516
|
+
}
|
|
517
|
+
// Track received event
|
|
518
|
+
this._trackEvent(types_1.CHAT_EVENTS.MESSAGE_RECEIVED, {
|
|
519
|
+
$channel_id: message.channel_id,
|
|
520
|
+
$message_id: message.id,
|
|
521
|
+
$content_preview: message.content.substring(0, 100),
|
|
522
|
+
$sender_type: message.sender_type,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
// Clear typing indicator when message arrives
|
|
526
|
+
this._state.isTyping = false;
|
|
527
|
+
this._state.typingSender = null;
|
|
528
|
+
// Notify callbacks
|
|
529
|
+
this._messageCallbacks.forEach((cb) => cb(message));
|
|
530
|
+
this._updateUI();
|
|
531
|
+
}
|
|
532
|
+
_handleTypingEvent(event) {
|
|
533
|
+
// Only show typing for non-user senders
|
|
534
|
+
if (event.sender_type === "user")
|
|
535
|
+
return;
|
|
536
|
+
const senderName = event.sender_name || (event.sender_type === "ai" ? "AI Assistant" : "Agent");
|
|
537
|
+
this._state.isTyping = event.is_typing;
|
|
538
|
+
this._state.typingSender = event.is_typing ? senderName : null;
|
|
539
|
+
// Notify callbacks
|
|
540
|
+
this._typingCallbacks.forEach((cb) => cb(event.is_typing, senderName));
|
|
541
|
+
this._updateUI();
|
|
542
|
+
}
|
|
543
|
+
_handleReadCursorEvent(event) {
|
|
544
|
+
// Only handle agent read events (user's own reads are local)
|
|
545
|
+
if (event.reader_type !== "agent")
|
|
546
|
+
return;
|
|
547
|
+
// Update the agent read cursor
|
|
548
|
+
this._state.agentLastReadAt = event.read_at;
|
|
549
|
+
// Update UI to show read status on user messages
|
|
550
|
+
this._updateUI();
|
|
551
|
+
}
|
|
552
|
+
_notifyConnectionChange(connected) {
|
|
553
|
+
this._connectionCallbacks.forEach((cb) => cb(connected));
|
|
554
|
+
}
|
|
555
|
+
// ============================================================================
|
|
556
|
+
// Private - UI
|
|
557
|
+
// ============================================================================
|
|
558
|
+
_createUI() {
|
|
559
|
+
if (!globals_1.document)
|
|
560
|
+
return;
|
|
561
|
+
// Create container
|
|
562
|
+
this._container = globals_1.document.createElement("div");
|
|
563
|
+
this._container.id = "vtilt-chat-container";
|
|
564
|
+
this._container.setAttribute("style", this._getContainerStyles());
|
|
565
|
+
// Create bubble (launcher button)
|
|
566
|
+
this._bubble = globals_1.document.createElement("div");
|
|
567
|
+
this._bubble.id = "vtilt-chat-bubble";
|
|
568
|
+
this._bubble.innerHTML = this._getBubbleHTML();
|
|
569
|
+
this._bubble.setAttribute("style", this._getBubbleStyles());
|
|
570
|
+
this._container.appendChild(this._bubble);
|
|
571
|
+
// Create widget (chat window)
|
|
572
|
+
this._widget = globals_1.document.createElement("div");
|
|
573
|
+
this._widget.id = "vtilt-chat-widget";
|
|
574
|
+
this._widget.innerHTML = this._getWidgetHTML();
|
|
575
|
+
this._widget.setAttribute("style", this._getWidgetStyles());
|
|
576
|
+
this._container.appendChild(this._widget);
|
|
577
|
+
// Add to DOM
|
|
578
|
+
globals_1.document.body.appendChild(this._container);
|
|
579
|
+
}
|
|
580
|
+
_attachEventListeners() {
|
|
581
|
+
var _a, _b, _c, _d;
|
|
582
|
+
// Bubble click
|
|
583
|
+
(_a = this._bubble) === null || _a === void 0 ? void 0 : _a.addEventListener("click", () => this.toggle());
|
|
584
|
+
// Close button
|
|
585
|
+
const closeBtn = (_b = this._widget) === null || _b === void 0 ? void 0 : _b.querySelector(".vtilt-chat-close");
|
|
586
|
+
closeBtn === null || closeBtn === void 0 ? void 0 : closeBtn.addEventListener("click", () => this.close());
|
|
587
|
+
// Send button
|
|
588
|
+
const sendBtn = (_c = this._widget) === null || _c === void 0 ? void 0 : _c.querySelector(".vtilt-chat-send");
|
|
589
|
+
sendBtn === null || sendBtn === void 0 ? void 0 : sendBtn.addEventListener("click", () => this._handleSend());
|
|
590
|
+
// Input enter key and typing indicator
|
|
591
|
+
const input = (_d = this._widget) === null || _d === void 0 ? void 0 : _d.querySelector(".vtilt-chat-input");
|
|
592
|
+
input === null || input === void 0 ? void 0 : input.addEventListener("keypress", (e) => {
|
|
593
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
594
|
+
e.preventDefault();
|
|
595
|
+
this._handleSend();
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
// Send typing indicator on input
|
|
599
|
+
input === null || input === void 0 ? void 0 : input.addEventListener("input", () => {
|
|
600
|
+
this._handleUserTyping();
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
_handleUserTyping() {
|
|
604
|
+
// Don't send typing if not connected to Ably
|
|
605
|
+
if (!this._typingChannel)
|
|
606
|
+
return;
|
|
607
|
+
// Send typing started if not already typing
|
|
608
|
+
if (!this._isUserTyping) {
|
|
609
|
+
this._isUserTyping = true;
|
|
610
|
+
this._sendTypingIndicator(true);
|
|
611
|
+
}
|
|
612
|
+
// Clear existing debounce timer
|
|
613
|
+
if (this._typingDebounce) {
|
|
614
|
+
clearTimeout(this._typingDebounce);
|
|
615
|
+
}
|
|
616
|
+
// Set timer to send typing stopped after 2 seconds of no input
|
|
617
|
+
this._typingDebounce = setTimeout(() => {
|
|
618
|
+
this._isUserTyping = false;
|
|
619
|
+
this._sendTypingIndicator(false);
|
|
620
|
+
}, 2000);
|
|
621
|
+
}
|
|
622
|
+
_sendTypingIndicator(isTyping) {
|
|
623
|
+
if (!this._typingChannel)
|
|
624
|
+
return;
|
|
625
|
+
try {
|
|
626
|
+
this._typingChannel.publish("typing", {
|
|
627
|
+
sender_type: "user",
|
|
628
|
+
sender_id: this._instance.getDistinctId(),
|
|
629
|
+
sender_name: null,
|
|
630
|
+
is_typing: isTyping,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
catch (err) {
|
|
634
|
+
console.error(`${LOGGER_PREFIX} Failed to send typing indicator:`, err);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
_handleSend() {
|
|
638
|
+
var _a;
|
|
639
|
+
const input = (_a = this._widget) === null || _a === void 0 ? void 0 : _a.querySelector(".vtilt-chat-input");
|
|
640
|
+
if (!input)
|
|
641
|
+
return;
|
|
642
|
+
const content = input.value.trim();
|
|
643
|
+
if (content) {
|
|
644
|
+
// Stop typing indicator
|
|
645
|
+
if (this._isUserTyping) {
|
|
646
|
+
this._isUserTyping = false;
|
|
647
|
+
this._sendTypingIndicator(false);
|
|
648
|
+
}
|
|
649
|
+
if (this._typingDebounce) {
|
|
650
|
+
clearTimeout(this._typingDebounce);
|
|
651
|
+
this._typingDebounce = null;
|
|
652
|
+
}
|
|
653
|
+
this.sendMessage(content);
|
|
654
|
+
input.value = "";
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
_updateUI() {
|
|
658
|
+
if (!this._container || !this._widget || !this._bubble)
|
|
659
|
+
return;
|
|
660
|
+
// Update visibility
|
|
661
|
+
this._container.style.display = this._state.isVisible ? "block" : "none";
|
|
662
|
+
// Update widget open state
|
|
663
|
+
this._widget.style.display = this._state.isOpen ? "flex" : "none";
|
|
664
|
+
// Update bubble badge
|
|
665
|
+
const badge = this._bubble.querySelector(".vtilt-chat-badge");
|
|
666
|
+
if (badge) {
|
|
667
|
+
badge.style.display =
|
|
668
|
+
this._state.unreadCount > 0 ? "flex" : "none";
|
|
669
|
+
badge.textContent = String(this._state.unreadCount);
|
|
670
|
+
}
|
|
671
|
+
// Update messages
|
|
672
|
+
this._renderMessages();
|
|
673
|
+
// Update loading state
|
|
674
|
+
const loader = this._widget.querySelector(".vtilt-chat-loader");
|
|
675
|
+
if (loader) {
|
|
676
|
+
loader.style.display = this._state.isLoading
|
|
677
|
+
? "flex"
|
|
678
|
+
: "none";
|
|
679
|
+
}
|
|
680
|
+
// Update typing indicator
|
|
681
|
+
const typing = this._widget.querySelector(".vtilt-chat-typing");
|
|
682
|
+
if (typing) {
|
|
683
|
+
typing.style.display = this._state.isTyping
|
|
684
|
+
? "flex"
|
|
685
|
+
: "none";
|
|
686
|
+
const typingText = typing.querySelector("span");
|
|
687
|
+
if (typingText && this._state.typingSender) {
|
|
688
|
+
typingText.textContent = `${this._state.typingSender} is typing...`;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
_renderMessages() {
|
|
693
|
+
var _a;
|
|
694
|
+
const messagesContainer = (_a = this._widget) === null || _a === void 0 ? void 0 : _a.querySelector(".vtilt-chat-messages");
|
|
695
|
+
if (!messagesContainer)
|
|
696
|
+
return;
|
|
697
|
+
const theme = this._config.theme || DEFAULT_THEME;
|
|
698
|
+
// Find first unread agent message index
|
|
699
|
+
const firstUnreadIndex = this._state.messages.findIndex((m) => (m.sender_type === "agent" || m.sender_type === "ai") && !this._isMessageReadByUser(m.created_at));
|
|
700
|
+
// Build HTML with unread divider
|
|
701
|
+
const messagesHtml = this._state.messages
|
|
702
|
+
.map((msg, index) => {
|
|
703
|
+
let html = "";
|
|
704
|
+
// Add unread divider before first unread message
|
|
705
|
+
if (index === firstUnreadIndex && firstUnreadIndex > 0) {
|
|
706
|
+
html += `
|
|
707
|
+
<div style="display: flex; align-items: center; gap: 12px; margin: 8px 0;">
|
|
708
|
+
<div style="flex: 1; height: 1px; background: ${theme.primaryColor}40;"></div>
|
|
709
|
+
<span style="font-size: 11px; font-weight: 500; color: ${theme.primaryColor}; padding: 0 8px;">New messages</span>
|
|
710
|
+
<div style="flex: 1; height: 1px; background: ${theme.primaryColor}40;"></div>
|
|
711
|
+
</div>
|
|
712
|
+
`;
|
|
713
|
+
}
|
|
714
|
+
html += this._getMessageHTML(msg);
|
|
715
|
+
return html;
|
|
716
|
+
})
|
|
717
|
+
.join("");
|
|
718
|
+
messagesContainer.innerHTML = messagesHtml;
|
|
719
|
+
// Scroll to bottom
|
|
720
|
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
721
|
+
}
|
|
722
|
+
// ============================================================================
|
|
723
|
+
// Private - Styles & HTML
|
|
724
|
+
// ============================================================================
|
|
725
|
+
_getContainerStyles() {
|
|
726
|
+
var _a;
|
|
727
|
+
const position = this._config.position || DEFAULT_POSITION;
|
|
728
|
+
const isRight = position === "bottom-right";
|
|
729
|
+
return `
|
|
730
|
+
position: fixed;
|
|
731
|
+
bottom: 20px;
|
|
732
|
+
${isRight ? "right: 20px;" : "left: 20px;"}
|
|
733
|
+
z-index: 999999;
|
|
734
|
+
font-family: ${((_a = this._config.theme) === null || _a === void 0 ? void 0 : _a.fontFamily) || DEFAULT_THEME.fontFamily};
|
|
735
|
+
`;
|
|
736
|
+
}
|
|
737
|
+
_getBubbleStyles() {
|
|
738
|
+
const theme = this._config.theme || DEFAULT_THEME;
|
|
739
|
+
return `
|
|
740
|
+
width: 60px;
|
|
741
|
+
height: 60px;
|
|
742
|
+
border-radius: 50%;
|
|
743
|
+
background: ${theme.primaryColor};
|
|
744
|
+
cursor: pointer;
|
|
745
|
+
display: flex;
|
|
746
|
+
align-items: center;
|
|
747
|
+
justify-content: center;
|
|
748
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
749
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
750
|
+
position: relative;
|
|
751
|
+
`;
|
|
752
|
+
}
|
|
753
|
+
_getBubbleHTML() {
|
|
754
|
+
return `
|
|
755
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
|
756
|
+
<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>
|
|
757
|
+
</svg>
|
|
758
|
+
<div class="vtilt-chat-badge" style="
|
|
759
|
+
display: none;
|
|
760
|
+
position: absolute;
|
|
761
|
+
top: -5px;
|
|
762
|
+
right: -5px;
|
|
763
|
+
background: #ef4444;
|
|
764
|
+
color: white;
|
|
765
|
+
font-size: 12px;
|
|
766
|
+
font-weight: 600;
|
|
767
|
+
min-width: 20px;
|
|
768
|
+
height: 20px;
|
|
769
|
+
border-radius: 10px;
|
|
770
|
+
align-items: center;
|
|
771
|
+
justify-content: center;
|
|
772
|
+
">0</div>
|
|
773
|
+
`;
|
|
774
|
+
}
|
|
775
|
+
_getWidgetStyles() {
|
|
776
|
+
const theme = this._config.theme || DEFAULT_THEME;
|
|
777
|
+
return `
|
|
778
|
+
display: none;
|
|
779
|
+
flex-direction: column;
|
|
780
|
+
position: absolute;
|
|
781
|
+
bottom: 80px;
|
|
782
|
+
right: 0;
|
|
783
|
+
width: 380px;
|
|
784
|
+
height: 520px;
|
|
785
|
+
background: white;
|
|
786
|
+
border-radius: ${theme.borderRadius};
|
|
787
|
+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
|
788
|
+
overflow: hidden;
|
|
789
|
+
`;
|
|
790
|
+
}
|
|
791
|
+
_getWidgetHTML() {
|
|
792
|
+
const theme = this._config.theme || DEFAULT_THEME;
|
|
793
|
+
const greeting = this._config.greeting || "How can we help you?";
|
|
794
|
+
return `
|
|
795
|
+
<div class="vtilt-chat-header" style="
|
|
796
|
+
background: ${theme.headerBgColor};
|
|
797
|
+
color: white;
|
|
798
|
+
padding: 16px;
|
|
799
|
+
display: flex;
|
|
800
|
+
align-items: center;
|
|
801
|
+
justify-content: space-between;
|
|
802
|
+
">
|
|
803
|
+
<div style="font-weight: 600; font-size: 16px;">${greeting}</div>
|
|
804
|
+
<button class="vtilt-chat-close" style="
|
|
805
|
+
background: none;
|
|
806
|
+
border: none;
|
|
807
|
+
color: white;
|
|
808
|
+
cursor: pointer;
|
|
809
|
+
padding: 4px;
|
|
810
|
+
">
|
|
811
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
812
|
+
<path d="M18 6L6 18M6 6l12 12"></path>
|
|
813
|
+
</svg>
|
|
814
|
+
</button>
|
|
815
|
+
</div>
|
|
816
|
+
|
|
817
|
+
<div class="vtilt-chat-messages" style="
|
|
818
|
+
flex: 1;
|
|
819
|
+
overflow-y: auto;
|
|
820
|
+
padding: 16px;
|
|
821
|
+
display: flex;
|
|
822
|
+
flex-direction: column;
|
|
823
|
+
gap: 12px;
|
|
824
|
+
"></div>
|
|
825
|
+
|
|
826
|
+
<div class="vtilt-chat-loader" style="
|
|
827
|
+
display: none;
|
|
828
|
+
align-items: center;
|
|
829
|
+
justify-content: center;
|
|
830
|
+
padding: 20px;
|
|
831
|
+
">
|
|
832
|
+
<div style="
|
|
833
|
+
width: 24px;
|
|
834
|
+
height: 24px;
|
|
835
|
+
border: 2px solid #e5e7eb;
|
|
836
|
+
border-top-color: ${theme.primaryColor};
|
|
837
|
+
border-radius: 50%;
|
|
838
|
+
animation: vtilt-spin 0.8s linear infinite;
|
|
839
|
+
"></div>
|
|
840
|
+
</div>
|
|
841
|
+
|
|
842
|
+
<div class="vtilt-chat-typing" style="
|
|
843
|
+
display: none;
|
|
844
|
+
padding: 8px 16px;
|
|
845
|
+
color: #6b7280;
|
|
846
|
+
font-size: 14px;
|
|
847
|
+
">
|
|
848
|
+
<span style="animation: vtilt-pulse 1.5s infinite;">Agent is typing...</span>
|
|
849
|
+
</div>
|
|
850
|
+
|
|
851
|
+
<div class="vtilt-chat-input-container" style="
|
|
852
|
+
padding: 16px;
|
|
853
|
+
border-top: 1px solid #e5e7eb;
|
|
854
|
+
display: flex;
|
|
855
|
+
gap: 8px;
|
|
856
|
+
">
|
|
857
|
+
<input
|
|
858
|
+
type="text"
|
|
859
|
+
class="vtilt-chat-input"
|
|
860
|
+
placeholder="Type a message..."
|
|
861
|
+
style="
|
|
862
|
+
flex: 1;
|
|
863
|
+
border: 1px solid #e5e7eb;
|
|
864
|
+
border-radius: 8px;
|
|
865
|
+
padding: 10px 14px;
|
|
866
|
+
font-size: 14px;
|
|
867
|
+
outline: none;
|
|
868
|
+
transition: border-color 0.2s;
|
|
869
|
+
"
|
|
870
|
+
/>
|
|
871
|
+
<button class="vtilt-chat-send" style="
|
|
872
|
+
background: ${theme.primaryColor};
|
|
873
|
+
color: white;
|
|
874
|
+
border: none;
|
|
875
|
+
border-radius: 8px;
|
|
876
|
+
padding: 10px 16px;
|
|
877
|
+
cursor: pointer;
|
|
878
|
+
font-weight: 500;
|
|
879
|
+
transition: opacity 0.2s;
|
|
880
|
+
">Send</button>
|
|
881
|
+
</div>
|
|
882
|
+
|
|
883
|
+
<style>
|
|
884
|
+
@keyframes vtilt-spin {
|
|
885
|
+
to { transform: rotate(360deg); }
|
|
886
|
+
}
|
|
887
|
+
@keyframes vtilt-pulse {
|
|
888
|
+
0%, 100% { opacity: 1; }
|
|
889
|
+
50% { opacity: 0.5; }
|
|
890
|
+
}
|
|
891
|
+
#vtilt-chat-bubble:hover {
|
|
892
|
+
transform: scale(1.05);
|
|
893
|
+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
|
894
|
+
}
|
|
895
|
+
.vtilt-chat-input:focus {
|
|
896
|
+
border-color: ${theme.primaryColor} !important;
|
|
897
|
+
}
|
|
898
|
+
.vtilt-chat-send:hover {
|
|
899
|
+
opacity: 0.9;
|
|
900
|
+
}
|
|
901
|
+
</style>
|
|
902
|
+
`;
|
|
903
|
+
}
|
|
904
|
+
_getMessageHTML(message) {
|
|
905
|
+
const theme = this._config.theme || DEFAULT_THEME;
|
|
906
|
+
const isUser = message.sender_type === "user";
|
|
907
|
+
const isAgentOrAI = message.sender_type === "agent" || message.sender_type === "ai";
|
|
908
|
+
// Check read status
|
|
909
|
+
const isReadByAgent = isUser && this._isMessageReadByAgent(message.created_at);
|
|
910
|
+
const isUnread = isAgentOrAI && !this._isMessageReadByUser(message.created_at);
|
|
911
|
+
const bubbleStyle = isUser
|
|
912
|
+
? `background: ${theme.userBubbleColor}; color: white; margin-left: auto;`
|
|
913
|
+
: `background: ${theme.agentBubbleColor}; color: #1f2937; margin-right: auto;${isUnread && !this._isMarkingRead ? " box-shadow: 0 0 0 2px " + theme.primaryColor + "40;" : ""}`;
|
|
914
|
+
const senderLabel = message.sender_type === "ai"
|
|
915
|
+
? "AI Assistant"
|
|
916
|
+
: message.sender_type === "agent"
|
|
917
|
+
? message.sender_name || "Agent"
|
|
918
|
+
: "";
|
|
919
|
+
// Read receipt SVG icons
|
|
920
|
+
const singleCheckSvg = `<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor" style="color: #9ca3af;"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>`;
|
|
921
|
+
const doubleCheckSvg = `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="color: ${theme.primaryColor};"><path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z"/></svg>`;
|
|
922
|
+
// Unread badge for agent messages
|
|
923
|
+
const unreadBadge = isUnread && !this._isMarkingRead
|
|
924
|
+
? `<span style="font-size: 10px; font-weight: 600; color: ${theme.primaryColor}; background: ${theme.primaryColor}20; padding: 2px 6px; border-radius: 9999px;">NEW</span>`
|
|
925
|
+
: "";
|
|
926
|
+
return `
|
|
927
|
+
<div style="display: flex; flex-direction: column; ${isUser ? "align-items: flex-end;" : "align-items: flex-start;"}${isUnread && !this._isMarkingRead ? " position: relative;" : ""}">
|
|
928
|
+
${!isUser && senderLabel ? `<div style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">${senderLabel}</div>` : ""}
|
|
929
|
+
<div style="
|
|
930
|
+
max-width: 80%;
|
|
931
|
+
padding: 10px 14px;
|
|
932
|
+
border-radius: 12px;
|
|
933
|
+
font-size: 14px;
|
|
934
|
+
line-height: 1.4;
|
|
935
|
+
transition: opacity 0.3s, box-shadow 0.3s;
|
|
936
|
+
${bubbleStyle}${this._isMarkingRead && isUnread ? " opacity: 0.7;" : ""}
|
|
937
|
+
">${this._escapeHTML(message.content)}</div>
|
|
938
|
+
<div style="font-size: 11px; color: #9ca3af; margin-top: 4px; display: flex; align-items: center; gap: 4px;">
|
|
939
|
+
${this._formatTime(message.created_at)}
|
|
940
|
+
${isUser ? (isReadByAgent ? doubleCheckSvg : singleCheckSvg) : unreadBadge}
|
|
941
|
+
</div>
|
|
942
|
+
</div>
|
|
943
|
+
`;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Check if a message has been read by the agent using cursor comparison
|
|
947
|
+
*/
|
|
948
|
+
_isMessageReadByAgent(messageCreatedAt) {
|
|
949
|
+
if (!this._state.agentLastReadAt)
|
|
950
|
+
return false;
|
|
951
|
+
return new Date(messageCreatedAt) <= new Date(this._state.agentLastReadAt);
|
|
952
|
+
}
|
|
953
|
+
// ============================================================================
|
|
954
|
+
// Private - Utilities
|
|
955
|
+
// ============================================================================
|
|
956
|
+
async _apiRequest(endpoint, options = {}) {
|
|
957
|
+
const config = this._instance.getConfig();
|
|
958
|
+
const apiHost = config.api_host || "";
|
|
959
|
+
const token = config.token || "";
|
|
960
|
+
const url = `${apiHost}${endpoint}?token=${encodeURIComponent(token)}`;
|
|
961
|
+
try {
|
|
962
|
+
const response = await fetch(url, {
|
|
963
|
+
...options,
|
|
964
|
+
headers: {
|
|
965
|
+
"Content-Type": "application/json",
|
|
966
|
+
...options.headers,
|
|
967
|
+
},
|
|
968
|
+
});
|
|
969
|
+
if (!response.ok) {
|
|
970
|
+
throw new Error(`API error: ${response.status}`);
|
|
971
|
+
}
|
|
972
|
+
return await response.json();
|
|
973
|
+
}
|
|
974
|
+
catch (error) {
|
|
975
|
+
console.error(`${LOGGER_PREFIX} API request failed:`, error);
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
_trackEvent(event, properties) {
|
|
980
|
+
this._instance.capture(event, properties);
|
|
981
|
+
}
|
|
982
|
+
_getTimeOpen() {
|
|
983
|
+
// TODO: Track actual open time
|
|
984
|
+
return 0;
|
|
985
|
+
}
|
|
986
|
+
_escapeHTML(text) {
|
|
987
|
+
if (!globals_1.document)
|
|
988
|
+
return text;
|
|
989
|
+
const div = globals_1.document.createElement("div");
|
|
990
|
+
div.textContent = text;
|
|
991
|
+
return div.innerHTML;
|
|
992
|
+
}
|
|
993
|
+
_formatTime(isoString) {
|
|
994
|
+
const date = new Date(isoString);
|
|
995
|
+
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
exports.LazyLoadedChat = LazyLoadedChat;
|