@v-tilt/browser 1.3.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.
Files changed (43) 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.map +1 -1
  16. package/dist/main.js +1 -1
  17. package/dist/main.js.map +1 -1
  18. package/dist/module.d.ts +279 -2
  19. package/dist/module.js +1 -1
  20. package/dist/module.js.map +1 -1
  21. package/dist/module.no-external.d.ts +279 -2
  22. package/dist/module.no-external.js +1 -1
  23. package/dist/module.no-external.js.map +1 -1
  24. package/dist/recorder.js.map +1 -1
  25. package/dist/types.d.ts +27 -0
  26. package/dist/utils/globals.d.ts +111 -1
  27. package/dist/vtilt.d.ts +11 -1
  28. package/dist/web-vitals.js.map +1 -1
  29. package/lib/entrypoints/chat.d.ts +22 -0
  30. package/lib/entrypoints/chat.js +32 -0
  31. package/lib/extensions/chat/chat-wrapper.d.ts +172 -0
  32. package/lib/extensions/chat/chat-wrapper.js +497 -0
  33. package/lib/extensions/chat/chat.d.ts +87 -0
  34. package/lib/extensions/chat/chat.js +998 -0
  35. package/lib/extensions/chat/index.d.ts +10 -0
  36. package/lib/extensions/chat/index.js +27 -0
  37. package/lib/extensions/chat/types.d.ts +156 -0
  38. package/lib/extensions/chat/types.js +22 -0
  39. package/lib/types.d.ts +27 -0
  40. package/lib/utils/globals.d.ts +111 -1
  41. package/lib/vtilt.d.ts +11 -1
  42. package/lib/vtilt.js +42 -1
  43. 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;