@v-tilt/browser 1.3.0 → 1.4.1

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