@v-tilt/browser 1.4.0 → 1.4.2

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.
@@ -21,18 +21,17 @@ const LOGGER_PREFIX = "[Chat]";
21
21
  // ============================================================================
22
22
  const DEFAULT_POSITION = "bottom-right";
23
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",
24
+ primaryColor: "#7B68EE", // Intercom-like purple
25
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
26
+ borderRadius: "16px",
27
+ };
28
+ // API endpoints
29
+ const API = {
30
+ channels: "/api/chat/widget/channels",
31
+ messages: "/api/chat/widget/messages",
32
+ read: "/api/chat/widget/read",
33
+ ablyToken: "/api/chat/ably-token",
30
34
  };
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
35
  // ============================================================================
37
36
  // LazyLoadedChat Implementation
38
37
  // ============================================================================
@@ -58,6 +57,7 @@ class LazyLoadedChat {
58
57
  // Read tracking - initial position when widget opens (to show unread indicators)
59
58
  this._initialUserReadAt = null;
60
59
  this._isMarkingRead = false;
60
+ this._previousView = "list";
61
61
  this._instance = instance;
62
62
  this._config = {
63
63
  enabled: true,
@@ -73,6 +73,10 @@ class LazyLoadedChat {
73
73
  isConnected: false,
74
74
  isLoading: false,
75
75
  unreadCount: 0,
76
+ // Multi-channel support
77
+ currentView: "list",
78
+ channels: [],
79
+ // Current channel (when in conversation view)
76
80
  channel: null,
77
81
  messages: [],
78
82
  isTyping: false,
@@ -102,6 +106,20 @@ class LazyLoadedChat {
102
106
  get channel() {
103
107
  return this._state.channel;
104
108
  }
109
+ get channels() {
110
+ return this._state.channels;
111
+ }
112
+ get currentView() {
113
+ return this._state.currentView;
114
+ }
115
+ // Theme getter to avoid repeated DEFAULT_THEME fallback
116
+ get _theme() {
117
+ return this._config.theme || DEFAULT_THEME;
118
+ }
119
+ // Distinct ID getter for convenience
120
+ get _distinctId() {
121
+ return this._instance.getDistinctId() || "";
122
+ }
105
123
  // ============================================================================
106
124
  // Public API - Widget Control
107
125
  // ============================================================================
@@ -111,34 +129,47 @@ class LazyLoadedChat {
111
129
  return;
112
130
  this._state.isOpen = true;
113
131
  this._updateUI();
132
+ // Add opening animation
133
+ if (this._widget) {
134
+ this._widget.classList.remove("vtilt-closing");
135
+ this._widget.classList.add("vtilt-opening");
136
+ }
114
137
  this._trackEvent(types_1.CHAT_EVENTS.WIDGET_OPENED, {
115
138
  $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
139
  $trigger: "api",
117
140
  });
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();
141
+ // Show channel list view first (multi-channel support)
142
+ // Only fetch channels if not already loaded
143
+ if (this._state.channels.length === 0) {
144
+ this.getChannels();
129
145
  }
146
+ // NOTE: Ably connection is now established only when entering conversation view
147
+ // This saves connection minutes when user is just browsing the channel list
130
148
  }
131
149
  close() {
132
150
  if (!this._state.isOpen)
133
151
  return;
134
152
  const timeOpen = this._getTimeOpen();
135
- this._state.isOpen = false;
136
- this._updateUI();
153
+ // Add closing animation
154
+ if (this._widget) {
155
+ this._widget.classList.remove("vtilt-opening");
156
+ this._widget.classList.add("vtilt-closing");
157
+ // Hide after animation completes
158
+ setTimeout(() => {
159
+ this._state.isOpen = false;
160
+ this._updateUI();
161
+ }, 200);
162
+ }
163
+ else {
164
+ this._state.isOpen = false;
165
+ this._updateUI();
166
+ }
137
167
  this._trackEvent(types_1.CHAT_EVENTS.WIDGET_CLOSED, {
138
168
  $time_open_seconds: timeOpen,
139
- $messages_sent: this._state.messages.filter((m) => m.sender_type === "user")
140
- .length,
169
+ $messages_sent: this._state.messages.filter((m) => m.sender_type === "user").length,
141
170
  });
171
+ // Disconnect Ably when widget closes to save connection minutes
172
+ this._disconnectRealtime();
142
173
  }
143
174
  toggle() {
144
175
  if (this._state.isOpen) {
@@ -157,15 +188,165 @@ class LazyLoadedChat {
157
188
  this._updateUI();
158
189
  }
159
190
  // ============================================================================
191
+ // Public API - Channel Management (Multi-channel support)
192
+ // ============================================================================
193
+ /**
194
+ * Fetch/refresh the list of user's channels
195
+ */
196
+ async getChannels() {
197
+ this._state.isLoading = true;
198
+ this._updateUI();
199
+ try {
200
+ const response = await this._apiRequest(`${API.channels}?distinct_id=${encodeURIComponent(this._distinctId)}`, {
201
+ method: "GET",
202
+ });
203
+ if (response) {
204
+ this._state.channels = response.channels || [];
205
+ // Calculate total unread count from all channels
206
+ this._state.unreadCount = this._state.channels.reduce((sum, ch) => sum + (ch.unread_count || 0), 0);
207
+ }
208
+ }
209
+ catch (error) {
210
+ console.error(`${LOGGER_PREFIX} Failed to fetch channels:`, error);
211
+ }
212
+ finally {
213
+ this._state.isLoading = false;
214
+ this._updateUI();
215
+ }
216
+ }
217
+ /**
218
+ * Select a channel and load its messages
219
+ */
220
+ async selectChannel(channelId) {
221
+ this._state.isLoading = true;
222
+ this._updateUI();
223
+ try {
224
+ const response = await this._apiRequest(`${API.channels}/${channelId}?distinct_id=${encodeURIComponent(this._distinctId)}`, {
225
+ method: "GET",
226
+ });
227
+ if (response) {
228
+ this._state.channel = response.channel;
229
+ this._state.messages = response.messages || [];
230
+ this._state.currentView = "conversation";
231
+ // Initialize read cursors from channel
232
+ this._state.agentLastReadAt =
233
+ response.channel.agent_last_read_at || null;
234
+ this._initialUserReadAt = response.channel.user_last_read_at || null;
235
+ // Connect to Ably for this channel
236
+ this._connectRealtime();
237
+ // Auto-mark unread messages as read if widget is open
238
+ if (this._state.isOpen) {
239
+ this._autoMarkAsRead();
240
+ }
241
+ }
242
+ }
243
+ catch (error) {
244
+ console.error(`${LOGGER_PREFIX} Failed to select channel:`, error);
245
+ }
246
+ finally {
247
+ this._state.isLoading = false;
248
+ this._updateUI();
249
+ }
250
+ }
251
+ /**
252
+ * Create a new channel and enter it
253
+ */
254
+ async createChannel() {
255
+ var _a;
256
+ this._state.isLoading = true;
257
+ this._updateUI();
258
+ try {
259
+ const response = await this._apiRequest(API.channels, {
260
+ method: "POST",
261
+ body: JSON.stringify({
262
+ distinct_id: this._distinctId,
263
+ 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,
264
+ page_title: globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.title,
265
+ }),
266
+ });
267
+ if (response) {
268
+ this._state.channel = response.channel;
269
+ this._state.messages = response.messages || [];
270
+ this._state.currentView = "conversation";
271
+ // Initialize read cursors
272
+ this._state.agentLastReadAt =
273
+ response.channel.agent_last_read_at || null;
274
+ this._initialUserReadAt = response.channel.user_last_read_at || null;
275
+ // Add new channel to the list
276
+ const newChannelSummary = {
277
+ id: response.channel.id,
278
+ status: response.channel.status,
279
+ ai_mode: response.channel.ai_mode,
280
+ last_message_at: response.channel.last_message_at,
281
+ last_message_preview: response.channel.last_message_preview,
282
+ last_message_sender: response.channel.last_message_sender,
283
+ unread_count: response.channel.unread_count,
284
+ user_last_read_at: response.channel.user_last_read_at,
285
+ created_at: response.channel.created_at,
286
+ };
287
+ this._state.channels.unshift(newChannelSummary);
288
+ // Track channel started
289
+ this._trackEvent(types_1.CHAT_EVENTS.STARTED, {
290
+ $channel_id: response.channel.id,
291
+ $initiated_by: "user",
292
+ $ai_mode: response.channel.ai_mode,
293
+ });
294
+ // Connect to Ably for this channel
295
+ this._connectRealtime();
296
+ }
297
+ }
298
+ catch (error) {
299
+ console.error(`${LOGGER_PREFIX} Failed to create channel:`, error);
300
+ }
301
+ finally {
302
+ this._state.isLoading = false;
303
+ this._updateUI();
304
+ }
305
+ }
306
+ /**
307
+ * Go back to channel list from conversation view
308
+ */
309
+ goToChannelList() {
310
+ // Disconnect Ably when leaving conversation to save connection minutes
311
+ this._disconnectRealtime();
312
+ // Update the channel in the list with latest data
313
+ if (this._state.channel) {
314
+ const channelIndex = this._state.channels.findIndex((ch) => { var _a; return ch.id === ((_a = this._state.channel) === null || _a === void 0 ? void 0 : _a.id); });
315
+ if (channelIndex !== -1) {
316
+ this._state.channels[channelIndex] = {
317
+ ...this._state.channels[channelIndex],
318
+ last_message_at: this._state.messages.length > 0
319
+ ? this._state.messages[this._state.messages.length - 1].created_at
320
+ : this._state.channels[channelIndex].last_message_at,
321
+ last_message_preview: this._state.messages.length > 0
322
+ ? this._state.messages[this._state.messages.length - 1].content.substring(0, 100)
323
+ : this._state.channels[channelIndex].last_message_preview,
324
+ last_message_sender: this._state.messages.length > 0
325
+ ? this._state.messages[this._state.messages.length - 1].sender_type
326
+ : this._state.channels[channelIndex].last_message_sender,
327
+ unread_count: 0, // We just viewed it
328
+ };
329
+ }
330
+ }
331
+ // Clear current channel state
332
+ this._state.channel = null;
333
+ this._state.messages = [];
334
+ this._state.currentView = "list";
335
+ this._state.isTyping = false;
336
+ this._state.typingSender = null;
337
+ this._updateUI();
338
+ }
339
+ // ============================================================================
160
340
  // Public API - Messaging
161
341
  // ============================================================================
162
342
  async sendMessage(content) {
163
343
  var _a, _b, _c, _d;
164
344
  if (!content.trim())
165
345
  return;
166
- // Ensure channel exists
167
- if (!this._state.channel) {
168
- await this._initializeChannel();
346
+ // Ensure we're in conversation view with a channel
347
+ if (!this._state.channel || this._state.currentView !== "conversation") {
348
+ console.error(`${LOGGER_PREFIX} Cannot send message: not in conversation view`);
349
+ return;
169
350
  }
170
351
  const channelId = (_a = this._state.channel) === null || _a === void 0 ? void 0 : _a.id;
171
352
  if (!channelId) {
@@ -177,7 +358,7 @@ class LazyLoadedChat {
177
358
  id: `temp-${Date.now()}`,
178
359
  channel_id: channelId,
179
360
  sender_type: "user",
180
- sender_id: this._instance.getDistinctId() || null,
361
+ sender_id: this._distinctId || null,
181
362
  sender_name: null,
182
363
  sender_avatar_url: null,
183
364
  content,
@@ -189,11 +370,11 @@ class LazyLoadedChat {
189
370
  this._updateUI();
190
371
  try {
191
372
  // Send to API
192
- const response = await this._apiRequest(`${API_WIDGET_MESSAGES}`, {
373
+ const response = await this._apiRequest(API.messages, {
193
374
  method: "POST",
194
375
  body: JSON.stringify({
195
376
  channel_id: channelId,
196
- distinct_id: this._instance.getDistinctId(),
377
+ distinct_id: this._distinctId,
197
378
  content,
198
379
  }),
199
380
  });
@@ -239,18 +420,19 @@ class LazyLoadedChat {
239
420
  if (!latestMessage)
240
421
  return;
241
422
  // 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));
423
+ const hasUnreadAgentMessages = this._state.messages.some((m) => (m.sender_type === "agent" || m.sender_type === "ai") &&
424
+ !this._isMessageReadByUser(m.created_at));
243
425
  if (!hasUnreadAgentMessages)
244
426
  return;
245
427
  this._state.unreadCount = 0;
246
428
  this._isMarkingRead = true;
247
429
  this._updateUI();
248
430
  // API call to update read cursor with latest message timestamp
249
- this._apiRequest(API_WIDGET_READ, {
431
+ this._apiRequest(API.read, {
250
432
  method: "POST",
251
433
  body: JSON.stringify({
252
434
  channel_id: this._state.channel.id,
253
- distinct_id: this._instance.getDistinctId(),
435
+ distinct_id: this._distinctId,
254
436
  read_at: latestMessage.created_at,
255
437
  }),
256
438
  })
@@ -323,52 +505,6 @@ class LazyLoadedChat {
323
505
  console.info(`${LOGGER_PREFIX} destroyed`);
324
506
  }
325
507
  // ============================================================================
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
508
  // Private - Ably Realtime Connection
373
509
  // ============================================================================
374
510
  async _connectRealtime() {
@@ -379,10 +515,10 @@ class LazyLoadedChat {
379
515
  this._notifyConnectionChange(false);
380
516
  try {
381
517
  // Get Ably token from server
382
- const tokenResponse = await this._apiRequest(API_ABLY_TOKEN, {
518
+ const tokenResponse = await this._apiRequest(API.ablyToken, {
383
519
  method: "POST",
384
520
  body: JSON.stringify({
385
- distinct_id: this._instance.getDistinctId(),
521
+ distinct_id: this._distinctId,
386
522
  channel_id: this._state.channel.id,
387
523
  }),
388
524
  });
@@ -396,10 +532,10 @@ class LazyLoadedChat {
396
532
  authCallback: async (_, callback) => {
397
533
  var _a;
398
534
  try {
399
- const refreshResponse = await this._apiRequest(API_ABLY_TOKEN, {
535
+ const refreshResponse = await this._apiRequest(API.ablyToken, {
400
536
  method: "POST",
401
537
  body: JSON.stringify({
402
- distinct_id: this._instance.getDistinctId(),
538
+ distinct_id: this._distinctId,
403
539
  channel_id: (_a = this._state.channel) === null || _a === void 0 ? void 0 : _a.id,
404
540
  }),
405
541
  });
@@ -493,9 +629,12 @@ class LazyLoadedChat {
493
629
  return;
494
630
  // Skip user's own messages - we already have them from optimistic updates
495
631
  // The sender_id for user messages is the distinct_id
496
- if (message.sender_type === "user" && message.sender_id === this._instance.getDistinctId()) {
632
+ if (message.sender_type === "user" &&
633
+ message.sender_id === this._distinctId) {
497
634
  // 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");
635
+ const tempIndex = this._state.messages.findIndex((m) => m.id.startsWith("temp-") &&
636
+ m.content === message.content &&
637
+ m.sender_type === "user");
499
638
  if (tempIndex !== -1) {
500
639
  this._state.messages[tempIndex] = message;
501
640
  this._updateUI();
@@ -533,7 +672,8 @@ class LazyLoadedChat {
533
672
  // Only show typing for non-user senders
534
673
  if (event.sender_type === "user")
535
674
  return;
536
- const senderName = event.sender_name || (event.sender_type === "ai" ? "AI Assistant" : "Agent");
675
+ const senderName = event.sender_name ||
676
+ (event.sender_type === "ai" ? "AI Assistant" : "Agent");
537
677
  this._state.isTyping = event.is_typing;
538
678
  this._state.typingSender = event.is_typing ? senderName : null;
539
679
  // Notify callbacks
@@ -578,27 +718,14 @@ class LazyLoadedChat {
578
718
  globals_1.document.body.appendChild(this._container);
579
719
  }
580
720
  _attachEventListeners() {
581
- var _a, _b, _c, _d;
721
+ var _a, _b;
582
722
  // Bubble click
583
723
  (_a = this._bubble) === null || _a === void 0 ? void 0 : _a.addEventListener("click", () => this.toggle());
584
- // Close button
724
+ // Close button (initial attachment - re-attached in _updateHeader)
585
725
  const closeBtn = (_b = this._widget) === null || _b === void 0 ? void 0 : _b.querySelector(".vtilt-chat-close");
586
726
  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
- });
727
+ // Note: Channel list and conversation listeners are attached dynamically
728
+ // in _attachChannelListListeners() and _attachConversationListeners()
602
729
  }
603
730
  _handleUserTyping() {
604
731
  // Don't send typing if not connected to Ably
@@ -625,7 +752,7 @@ class LazyLoadedChat {
625
752
  try {
626
753
  this._typingChannel.publish("typing", {
627
754
  sender_type: "user",
628
- sender_id: this._instance.getDistinctId(),
755
+ sender_id: this._distinctId,
629
756
  sender_name: null,
630
757
  is_typing: isTyping,
631
758
  });
@@ -668,8 +795,39 @@ class LazyLoadedChat {
668
795
  this._state.unreadCount > 0 ? "flex" : "none";
669
796
  badge.textContent = String(this._state.unreadCount);
670
797
  }
671
- // Update messages
672
- this._renderMessages();
798
+ // Detect view change for animation
799
+ const viewChanged = this._previousView !== this._state.currentView;
800
+ const animationClass = viewChanged
801
+ ? this._state.currentView === "conversation"
802
+ ? "vtilt-view-enter-right"
803
+ : "vtilt-view-enter-left"
804
+ : "";
805
+ // Update content based on current view
806
+ const contentContainer = this._widget.querySelector(".vtilt-chat-content");
807
+ if (contentContainer) {
808
+ // Remove previous animation classes
809
+ contentContainer.classList.remove("vtilt-view-enter-right", "vtilt-view-enter-left");
810
+ if (this._state.currentView === "list") {
811
+ contentContainer.innerHTML = this._getChannelListHTML();
812
+ this._attachChannelListListeners();
813
+ }
814
+ else {
815
+ contentContainer.innerHTML = this._getConversationHTML();
816
+ this._attachConversationListeners();
817
+ // Render messages in conversation view
818
+ this._renderMessages();
819
+ }
820
+ // Add animation class if view changed
821
+ if (animationClass) {
822
+ // Force reflow to restart animation
823
+ void contentContainer.offsetWidth;
824
+ contentContainer.classList.add(animationClass);
825
+ }
826
+ }
827
+ // Update previous view
828
+ this._previousView = this._state.currentView;
829
+ // Update header based on view
830
+ this._updateHeader();
673
831
  // Update loading state
674
832
  const loader = this._widget.querySelector(".vtilt-chat-loader");
675
833
  if (loader) {
@@ -677,38 +835,475 @@ class LazyLoadedChat {
677
835
  ? "flex"
678
836
  : "none";
679
837
  }
680
- // Update typing indicator
838
+ // Update typing indicator (only in conversation view)
681
839
  const typing = this._widget.querySelector(".vtilt-chat-typing");
682
840
  if (typing) {
683
- typing.style.display = this._state.isTyping
684
- ? "flex"
685
- : "none";
841
+ typing.style.display =
842
+ this._state.isTyping && this._state.currentView === "conversation"
843
+ ? "flex"
844
+ : "none";
686
845
  const typingText = typing.querySelector("span");
687
846
  if (typingText && this._state.typingSender) {
688
847
  typingText.textContent = `${this._state.typingSender} is typing...`;
689
848
  }
690
849
  }
691
850
  }
851
+ _updateHeader() {
852
+ var _a, _b;
853
+ const header = (_a = this._widget) === null || _a === void 0 ? void 0 : _a.querySelector(".vtilt-chat-header");
854
+ if (!header)
855
+ return;
856
+ const greeting = this._config.greeting || "Messages";
857
+ const primary = this._theme.primaryColor;
858
+ if (this._state.currentView === "list") {
859
+ header.style.cssText = `
860
+ background: #ffffff;
861
+ border-bottom: 1px solid #E5E5E5;
862
+ padding: 18px 16px;
863
+ padding-top: max(18px, env(safe-area-inset-top, 18px));
864
+ display: flex;
865
+ align-items: center;
866
+ justify-content: space-between;
867
+ min-height: 60px;
868
+ box-sizing: border-box;
869
+ flex-shrink: 0;
870
+ `;
871
+ header.innerHTML = `
872
+ <div style="font-weight: 600; font-size: 17px; color: #000000;">${greeting}</div>
873
+ <button class="vtilt-chat-close" style="
874
+ background: transparent;
875
+ border: none;
876
+ color: #666666;
877
+ cursor: pointer;
878
+ padding: 6px;
879
+ margin: -6px;
880
+ border-radius: 4px;
881
+ display: flex;
882
+ align-items: center;
883
+ justify-content: center;
884
+ -webkit-tap-highlight-color: transparent;
885
+ ">
886
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
887
+ <path d="M18 6L6 18M6 6l12 12"></path>
888
+ </svg>
889
+ </button>
890
+ `;
891
+ }
892
+ else {
893
+ const isAiMode = (_b = this._state.channel) === null || _b === void 0 ? void 0 : _b.ai_mode;
894
+ header.style.cssText = `
895
+ background: #ffffff;
896
+ border-bottom: 1px solid #E5E5E5;
897
+ padding: 12px 16px;
898
+ padding-top: max(12px, env(safe-area-inset-top, 12px));
899
+ display: flex;
900
+ align-items: center;
901
+ gap: 12px;
902
+ min-height: 60px;
903
+ box-sizing: border-box;
904
+ flex-shrink: 0;
905
+ `;
906
+ header.innerHTML = `
907
+ <button class="vtilt-chat-back" style="
908
+ background: transparent;
909
+ border: none;
910
+ color: #666666;
911
+ cursor: pointer;
912
+ padding: 6px;
913
+ margin-left: -6px;
914
+ border-radius: 4px;
915
+ display: flex;
916
+ align-items: center;
917
+ justify-content: center;
918
+ -webkit-tap-highlight-color: transparent;
919
+ ">
920
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
921
+ <path d="M15 18l-6-6 6-6"></path>
922
+ </svg>
923
+ </button>
924
+ <div style="
925
+ width: 44px;
926
+ height: 44px;
927
+ border-radius: 50%;
928
+ background: ${isAiMode ? primary : "#DEDEDE"};
929
+ display: flex;
930
+ align-items: center;
931
+ justify-content: center;
932
+ flex-shrink: 0;
933
+ ">
934
+ ${isAiMode
935
+ ? `<svg width="22" height="22" viewBox="0 0 24 24" fill="white"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2M7.5 13A1.5 1.5 0 0 0 6 14.5A1.5 1.5 0 0 0 7.5 16A1.5 1.5 0 0 0 9 14.5A1.5 1.5 0 0 0 7.5 13m9 0a1.5 1.5 0 0 0-1.5 1.5a1.5 1.5 0 0 0 1.5 1.5a1.5 1.5 0 0 0 1.5-1.5a1.5 1.5 0 0 0-1.5-1.5"/></svg>`
936
+ : `<svg width="22" height="22" viewBox="0 0 24 24" fill="#666"><path d="M12 4a4 4 0 0 1 4 4a4 4 0 0 1-4 4a4 4 0 0 1-4-4a4 4 0 0 1 4-4m0 10c4.42 0 8 1.79 8 4v2H4v-2c0-2.21 3.58-4 8-4"/></svg>`}
937
+ </div>
938
+ <div style="flex: 1; min-width: 0;">
939
+ <div style="font-weight: 600; font-size: 16px; color: #000000;">${isAiMode ? "AI Assistant" : "Support"}</div>
940
+ <div style="font-size: 13px; color: #16A34A; display: flex; align-items: center; gap: 5px; margin-top: 1px;">
941
+ <span style="width: 7px; height: 7px; background: #16A34A; border-radius: 50%;"></span>
942
+ Online
943
+ </div>
944
+ </div>
945
+ <button class="vtilt-chat-close" style="
946
+ background: transparent;
947
+ border: none;
948
+ color: #666666;
949
+ cursor: pointer;
950
+ padding: 6px;
951
+ margin-right: -6px;
952
+ border-radius: 4px;
953
+ display: flex;
954
+ align-items: center;
955
+ justify-content: center;
956
+ -webkit-tap-highlight-color: transparent;
957
+ ">
958
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
959
+ <path d="M18 6L6 18M6 6l12 12"></path>
960
+ </svg>
961
+ </button>
962
+ `;
963
+ }
964
+ // Re-attach header event listeners
965
+ const closeBtn = header.querySelector(".vtilt-chat-close");
966
+ closeBtn === null || closeBtn === void 0 ? void 0 : closeBtn.addEventListener("click", () => this.close());
967
+ const backBtn = header.querySelector(".vtilt-chat-back");
968
+ backBtn === null || backBtn === void 0 ? void 0 : backBtn.addEventListener("click", () => this.goToChannelList());
969
+ }
970
+ _getChannelListHTML() {
971
+ const primary = this._theme.primaryColor;
972
+ if (this._state.channels.length === 0 && !this._state.isLoading) {
973
+ return `
974
+ <div style="
975
+ flex: 1;
976
+ display: flex;
977
+ flex-direction: column;
978
+ align-items: center;
979
+ justify-content: center;
980
+ padding: 48px 24px;
981
+ text-align: center;
982
+ animation: vtilt-fadein 0.4s ease;
983
+ ">
984
+ <div style="
985
+ width: 72px;
986
+ height: 72px;
987
+ margin-bottom: 24px;
988
+ background: ${primary};
989
+ border-radius: 50%;
990
+ display: flex;
991
+ align-items: center;
992
+ justify-content: center;
993
+ animation: vtilt-bubble-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.1s both;
994
+ ">
995
+ <svg width="36" height="36" viewBox="0 0 24 24" fill="white">
996
+ <path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/>
997
+ </svg>
998
+ </div>
999
+ <div style="font-size: 18px; font-weight: 600; color: #000000; margin-bottom: 8px; animation: vtilt-fadein 0.4s ease 0.15s both;">No conversations yet</div>
1000
+ <div style="font-size: 15px; color: #666666; margin-bottom: 28px; line-height: 1.5; max-width: 280px; animation: vtilt-fadein 0.4s ease 0.2s both;">Questions? We're here to help. Start a conversation with us.</div>
1001
+ <button class="vtilt-chat-new-channel" style="
1002
+ background: ${primary};
1003
+ color: white;
1004
+ border: none;
1005
+ border-radius: 100px;
1006
+ padding: 14px 28px;
1007
+ cursor: pointer;
1008
+ font-weight: 500;
1009
+ font-size: 15px;
1010
+ -webkit-tap-highlight-color: transparent;
1011
+ touch-action: manipulation;
1012
+ display: flex;
1013
+ align-items: center;
1014
+ gap: 10px;
1015
+ box-shadow: 0 2px 8px rgba(123, 104, 238, 0.3);
1016
+ animation: vtilt-fadein 0.4s ease 0.25s both;
1017
+ ">
1018
+ Send us a message
1019
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="white">
1020
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
1021
+ </svg>
1022
+ </button>
1023
+ </div>
1024
+ `;
1025
+ }
1026
+ const channelsHtml = this._state.channels
1027
+ .map((ch, index) => this._getChannelItemHTML(ch, index))
1028
+ .join("");
1029
+ return `
1030
+ <div style="flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch;">
1031
+ ${channelsHtml}
1032
+ </div>
1033
+ <div style="
1034
+ padding: 16px;
1035
+ padding-bottom: max(16px, env(safe-area-inset-bottom, 16px));
1036
+ border-top: 1px solid #E5E5E5;
1037
+ ">
1038
+ <button class="vtilt-chat-new-channel" style="
1039
+ width: 100%;
1040
+ background: ${primary};
1041
+ color: white;
1042
+ border: none;
1043
+ border-radius: 100px;
1044
+ padding: 14px 24px;
1045
+ cursor: pointer;
1046
+ font-weight: 500;
1047
+ font-size: 15px;
1048
+ display: flex;
1049
+ align-items: center;
1050
+ justify-content: center;
1051
+ gap: 10px;
1052
+ -webkit-tap-highlight-color: transparent;
1053
+ touch-action: manipulation;
1054
+ box-shadow: 0 2px 8px rgba(123, 104, 238, 0.3);
1055
+ ">
1056
+ Send us a message
1057
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="white">
1058
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
1059
+ </svg>
1060
+ </button>
1061
+ </div>
1062
+ `;
1063
+ }
1064
+ _getChannelItemHTML(channel, index = 0) {
1065
+ const hasUnread = channel.unread_count > 0;
1066
+ const timeStr = this._formatRelativeTime(channel.last_message_at || channel.created_at);
1067
+ const preview = channel.last_message_preview || "No messages yet";
1068
+ const primary = this._theme.primaryColor;
1069
+ const senderPrefix = channel.last_message_sender === "user" ? "You: " : "";
1070
+ const animationDelay = Math.min(index * 0.05, 0.3); // Max 300ms total delay
1071
+ return `
1072
+ <div class="vtilt-channel-item" data-channel-id="${channel.id}" style="
1073
+ padding: 14px 16px;
1074
+ cursor: pointer;
1075
+ -webkit-tap-highlight-color: transparent;
1076
+ touch-action: manipulation;
1077
+ display: flex;
1078
+ align-items: center;
1079
+ gap: 12px;
1080
+ background: white;
1081
+ border-bottom: 1px solid #EEEEEE;
1082
+ animation: vtilt-item-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) ${animationDelay}s both;
1083
+ ">
1084
+ <div style="
1085
+ width: 48px;
1086
+ height: 48px;
1087
+ border-radius: 50%;
1088
+ background: ${channel.ai_mode ? primary : "#DEDEDE"};
1089
+ display: flex;
1090
+ align-items: center;
1091
+ justify-content: center;
1092
+ flex-shrink: 0;
1093
+ ">
1094
+ ${channel.ai_mode
1095
+ ? `<svg width="24" height="24" viewBox="0 0 24 24" fill="white"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2M7.5 13A1.5 1.5 0 0 0 6 14.5A1.5 1.5 0 0 0 7.5 16A1.5 1.5 0 0 0 9 14.5A1.5 1.5 0 0 0 7.5 13m9 0a1.5 1.5 0 0 0-1.5 1.5a1.5 1.5 0 0 0 1.5 1.5a1.5 1.5 0 0 0 1.5-1.5a1.5 1.5 0 0 0-1.5-1.5"/></svg>`
1096
+ : `<svg width="24" height="24" viewBox="0 0 24 24" fill="#666"><path d="M12 4a4 4 0 0 1 4 4a4 4 0 0 1-4 4a4 4 0 0 1-4-4a4 4 0 0 1 4-4m0 10c4.42 0 8 1.79 8 4v2H4v-2c0-2.21 3.58-4 8-4"/></svg>`}
1097
+ </div>
1098
+ <div style="flex: 1; min-width: 0;">
1099
+ <div style="display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 4px;">
1100
+ <div style="font-weight: ${hasUnread ? "600" : "500"}; font-size: 15px; color: #000000; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
1101
+ ${channel.ai_mode ? "AI Assistant" : "Support"}
1102
+ </div>
1103
+ <div style="font-size: 13px; color: #888888; white-space: nowrap; flex-shrink: 0;">${timeStr}</div>
1104
+ </div>
1105
+ <div style="display: flex; justify-content: space-between; align-items: center; gap: 8px;">
1106
+ <div style="
1107
+ font-size: 14px;
1108
+ color: ${hasUnread ? "#333333" : "#888888"};
1109
+ font-weight: 400;
1110
+ white-space: nowrap;
1111
+ overflow: hidden;
1112
+ text-overflow: ellipsis;
1113
+ flex: 1;
1114
+ min-width: 0;
1115
+ line-height: 1.4;
1116
+ ">${senderPrefix}${this._escapeHTML(preview)}${channel.status === "closed" ? ' · Closed' : ""}</div>
1117
+ ${hasUnread ? `<div style="
1118
+ min-width: 10px;
1119
+ width: 10px;
1120
+ height: 10px;
1121
+ background: ${primary};
1122
+ border-radius: 50%;
1123
+ flex-shrink: 0;
1124
+ "></div>` : ""}
1125
+ </div>
1126
+ </div>
1127
+ </div>
1128
+ `;
1129
+ }
1130
+ _getConversationHTML() {
1131
+ const primary = this._theme.primaryColor;
1132
+ return `
1133
+ <div class="vtilt-chat-messages" style="
1134
+ flex: 1;
1135
+ overflow-y: auto;
1136
+ -webkit-overflow-scrolling: touch;
1137
+ padding: 20px 16px;
1138
+ display: flex;
1139
+ flex-direction: column;
1140
+ gap: 12px;
1141
+ min-height: 0;
1142
+ background: #FAFAFA;
1143
+ "></div>
1144
+
1145
+ <div class="vtilt-chat-typing" style="
1146
+ display: none;
1147
+ padding: 0 16px 12px;
1148
+ background: #FAFAFA;
1149
+ align-items: center;
1150
+ ">
1151
+ <div style="
1152
+ display: inline-flex;
1153
+ align-items: center;
1154
+ gap: 4px;
1155
+ padding: 12px 16px;
1156
+ background: white;
1157
+ border-radius: 20px;
1158
+ ">
1159
+ <span style="width: 7px; height: 7px; background: #999; border-radius: 50%; animation: vtilt-typing 1.2s infinite 0s;"></span>
1160
+ <span style="width: 7px; height: 7px; background: #999; border-radius: 50%; animation: vtilt-typing 1.2s infinite 0.2s;"></span>
1161
+ <span style="width: 7px; height: 7px; background: #999; border-radius: 50%; animation: vtilt-typing 1.2s infinite 0.4s;"></span>
1162
+ </div>
1163
+ </div>
1164
+ <style>
1165
+ @keyframes vtilt-typing { 0%, 60%, 100% { opacity: 0.35; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-2px); } }
1166
+ </style>
1167
+
1168
+ <div class="vtilt-chat-input-container" style="
1169
+ padding: 12px 16px;
1170
+ padding-bottom: max(12px, env(safe-area-inset-bottom, 12px));
1171
+ border-top: 1px solid #E5E5E5;
1172
+ display: flex;
1173
+ align-items: center;
1174
+ gap: 12px;
1175
+ flex-shrink: 0;
1176
+ background: #ffffff;
1177
+ ">
1178
+ <div style="flex: 1; min-width: 0;">
1179
+ <input
1180
+ type="text"
1181
+ class="vtilt-chat-input"
1182
+ placeholder="Message..."
1183
+ autocomplete="off"
1184
+ autocorrect="on"
1185
+ autocapitalize="sentences"
1186
+ style="
1187
+ width: 100%;
1188
+ box-sizing: border-box;
1189
+ border: 1px solid #DDDDDD;
1190
+ border-radius: 24px;
1191
+ padding: 12px 18px;
1192
+ font-size: 16px;
1193
+ line-height: 1.4;
1194
+ outline: none;
1195
+ background: #ffffff;
1196
+ -webkit-appearance: none;
1197
+ appearance: none;
1198
+ color: #000000;
1199
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
1200
+ "
1201
+ />
1202
+ </div>
1203
+ <button class="vtilt-chat-send" style="
1204
+ background: ${primary};
1205
+ color: white;
1206
+ border: none;
1207
+ border-radius: 50%;
1208
+ padding: 0;
1209
+ width: 44px;
1210
+ height: 44px;
1211
+ cursor: pointer;
1212
+ display: flex;
1213
+ align-items: center;
1214
+ justify-content: center;
1215
+ -webkit-tap-highlight-color: transparent;
1216
+ touch-action: manipulation;
1217
+ flex-shrink: 0;
1218
+ ">
1219
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="white">
1220
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
1221
+ </svg>
1222
+ </button>
1223
+ </div>
1224
+ `;
1225
+ }
1226
+ _attachChannelListListeners() {
1227
+ var _a, _b;
1228
+ // New channel button
1229
+ const newChannelBtn = (_a = this._widget) === null || _a === void 0 ? void 0 : _a.querySelector(".vtilt-chat-new-channel");
1230
+ newChannelBtn === null || newChannelBtn === void 0 ? void 0 : newChannelBtn.addEventListener("click", () => this.createChannel());
1231
+ // Channel items
1232
+ const channelItems = (_b = this._widget) === null || _b === void 0 ? void 0 : _b.querySelectorAll(".vtilt-channel-item");
1233
+ const isTouchDevice = globals_1.window && ("ontouchstart" in globals_1.window || navigator.maxTouchPoints > 0);
1234
+ channelItems === null || channelItems === void 0 ? void 0 : channelItems.forEach((item) => {
1235
+ item.addEventListener("click", () => {
1236
+ const channelId = item.getAttribute("data-channel-id");
1237
+ if (channelId)
1238
+ this.selectChannel(channelId);
1239
+ });
1240
+ // Hover effect (only on non-touch devices)
1241
+ if (!isTouchDevice) {
1242
+ item.addEventListener("mouseenter", () => {
1243
+ item.style.background = "#F5F5F5";
1244
+ });
1245
+ item.addEventListener("mouseleave", () => {
1246
+ item.style.background = "white";
1247
+ });
1248
+ }
1249
+ });
1250
+ }
1251
+ _attachConversationListeners() {
1252
+ var _a, _b;
1253
+ // Send button
1254
+ const sendBtn = (_a = this._widget) === null || _a === void 0 ? void 0 : _a.querySelector(".vtilt-chat-send");
1255
+ sendBtn === null || sendBtn === void 0 ? void 0 : sendBtn.addEventListener("click", () => this._handleSend());
1256
+ // Input enter key and typing indicator
1257
+ const input = (_b = this._widget) === null || _b === void 0 ? void 0 : _b.querySelector(".vtilt-chat-input");
1258
+ input === null || input === void 0 ? void 0 : input.addEventListener("keypress", (e) => {
1259
+ if (e.key === "Enter" && !e.shiftKey) {
1260
+ e.preventDefault();
1261
+ this._handleSend();
1262
+ }
1263
+ });
1264
+ // Send typing indicator on input
1265
+ input === null || input === void 0 ? void 0 : input.addEventListener("input", () => {
1266
+ this._handleUserTyping();
1267
+ });
1268
+ }
1269
+ _formatRelativeTime(isoString) {
1270
+ const date = new Date(isoString);
1271
+ const now = new Date();
1272
+ const diffMs = now.getTime() - date.getTime();
1273
+ const diffMins = Math.floor(diffMs / 60000);
1274
+ const diffHours = Math.floor(diffMs / 3600000);
1275
+ const diffDays = Math.floor(diffMs / 86400000);
1276
+ if (diffMins < 1)
1277
+ return "Just now";
1278
+ if (diffMins < 60)
1279
+ return `${diffMins}m ago`;
1280
+ if (diffHours < 24)
1281
+ return `${diffHours}h ago`;
1282
+ if (diffDays < 7)
1283
+ return `${diffDays}d ago`;
1284
+ return date.toLocaleDateString();
1285
+ }
692
1286
  _renderMessages() {
693
1287
  var _a;
694
1288
  const messagesContainer = (_a = this._widget) === null || _a === void 0 ? void 0 : _a.querySelector(".vtilt-chat-messages");
695
1289
  if (!messagesContainer)
696
1290
  return;
697
- const theme = this._config.theme || DEFAULT_THEME;
1291
+ const primary = this._theme.primaryColor;
698
1292
  // 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));
1293
+ const firstUnreadIndex = this._state.messages.findIndex((m) => (m.sender_type === "agent" || m.sender_type === "ai") &&
1294
+ !this._isMessageReadByUser(m.created_at));
700
1295
  // Build HTML with unread divider
701
1296
  const messagesHtml = this._state.messages
702
1297
  .map((msg, index) => {
703
1298
  let html = "";
704
1299
  // Add unread divider before first unread message
705
1300
  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>
1301
+ html += `
1302
+ <div style="display: flex; align-items: center; gap: 12px; margin: 12px 0;">
1303
+ <div style="flex: 1; height: 1px; background: #DDDDDD;"></div>
1304
+ <span style="font-size: 12px; font-weight: 600; color: ${primary};">New</span>
1305
+ <div style="flex: 1; height: 1px; background: #DDDDDD;"></div>
1306
+ </div>
712
1307
  `;
713
1308
  }
714
1309
  html += this._getMessageHTML(msg);
@@ -716,230 +1311,277 @@ class LazyLoadedChat {
716
1311
  })
717
1312
  .join("");
718
1313
  messagesContainer.innerHTML = messagesHtml;
719
- // Scroll to bottom
720
1314
  messagesContainer.scrollTop = messagesContainer.scrollHeight;
721
1315
  }
722
1316
  // ============================================================================
723
1317
  // Private - Styles & HTML
724
1318
  // ============================================================================
1319
+ _isMobile() {
1320
+ return globals_1.window ? globals_1.window.innerWidth < 480 : false;
1321
+ }
725
1322
  _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};
1323
+ const isRight = (this._config.position || DEFAULT_POSITION) === "bottom-right";
1324
+ return `
1325
+ position: fixed;
1326
+ bottom: 20px;
1327
+ ${isRight ? "right: 20px;" : "left: 20px;"}
1328
+ z-index: 999999;
1329
+ font-family: ${this._theme.fontFamily};
1330
+ -webkit-font-smoothing: antialiased;
1331
+ -moz-osx-font-smoothing: grayscale;
735
1332
  `;
736
1333
  }
737
1334
  _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;
1335
+ const primary = this._theme.primaryColor;
1336
+ return `
1337
+ width: 60px;
1338
+ height: 60px;
1339
+ border-radius: 50%;
1340
+ background: ${primary};
1341
+ cursor: pointer;
1342
+ display: flex;
1343
+ align-items: center;
1344
+ justify-content: center;
1345
+ box-shadow: 0 4px 16px rgba(123, 104, 238, 0.4);
1346
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
1347
+ position: relative;
1348
+ -webkit-tap-highlight-color: transparent;
1349
+ touch-action: manipulation;
751
1350
  `;
752
1351
  }
753
1352
  _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>
1353
+ return `
1354
+ <svg width="28" height="28" viewBox="0 0 28 28" fill="white">
1355
+ <path d="M14 3C7.925 3 3 7.262 3 12.5c0 2.56 1.166 4.884 3.063 6.606L4.5 24l5.25-2.625C11.1 21.79 12.52 22 14 22c6.075 0 11-4.262 11-9.5S20.075 3 14 3z"/>
1356
+ </svg>
1357
+ <div class="vtilt-chat-badge" style="
1358
+ display: none;
1359
+ position: absolute;
1360
+ top: -4px;
1361
+ right: -4px;
1362
+ background: #E53935;
1363
+ color: white;
1364
+ font-size: 11px;
1365
+ font-weight: 700;
1366
+ min-width: 20px;
1367
+ height: 20px;
1368
+ border-radius: 10px;
1369
+ align-items: center;
1370
+ justify-content: center;
1371
+ padding: 0 6px;
1372
+ box-sizing: border-box;
1373
+ border: 2px solid white;
1374
+ ">0</div>
773
1375
  `;
774
1376
  }
775
1377
  _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;
1378
+ return `
1379
+ display: none;
1380
+ flex-direction: column;
1381
+ position: absolute;
1382
+ bottom: 80px;
1383
+ right: 0;
1384
+ width: 380px;
1385
+ max-width: calc(100vw - 40px);
1386
+ height: 600px;
1387
+ max-height: calc(100vh - 120px);
1388
+ max-height: calc(100dvh - 120px);
1389
+ background: #ffffff;
1390
+ border-radius: 16px;
1391
+ box-shadow: 0 5px 40px rgba(0, 0, 0, 0.16);
1392
+ overflow: hidden;
789
1393
  `;
790
1394
  }
791
1395
  _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>
1396
+ const greeting = this._config.greeting || "Messages";
1397
+ const primary = this._theme.primaryColor;
1398
+ return `
1399
+ <div class="vtilt-chat-header" style="
1400
+ background: #ffffff;
1401
+ border-bottom: 1px solid #E5E5E5;
1402
+ padding: 18px 16px;
1403
+ padding-top: max(18px, env(safe-area-inset-top, 18px));
1404
+ display: flex;
1405
+ align-items: center;
1406
+ justify-content: space-between;
1407
+ min-height: 60px;
1408
+ box-sizing: border-box;
1409
+ flex-shrink: 0;
1410
+ ">
1411
+ <div style="font-weight: 600; font-size: 17px; color: #000000;">${greeting}</div>
1412
+ <button class="vtilt-chat-close" style="
1413
+ background: transparent;
1414
+ border: none;
1415
+ color: #666666;
1416
+ cursor: pointer;
1417
+ padding: 6px;
1418
+ margin: -6px;
1419
+ border-radius: 4px;
1420
+ display: flex;
1421
+ align-items: center;
1422
+ justify-content: center;
1423
+ -webkit-tap-highlight-color: transparent;
1424
+ touch-action: manipulation;
1425
+ ">
1426
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1427
+ <path d="M18 6L6 18M6 6l12 12"></path>
1428
+ </svg>
1429
+ </button>
1430
+ </div>
1431
+
1432
+ <div class="vtilt-chat-content" style="
1433
+ flex: 1;
1434
+ display: flex;
1435
+ flex-direction: column;
1436
+ overflow: hidden;
1437
+ min-height: 0;
1438
+ background: #ffffff;
1439
+ ">
1440
+ </div>
1441
+
1442
+ <div class="vtilt-chat-loader" style="
1443
+ display: none;
1444
+ position: absolute;
1445
+ top: 0;
1446
+ left: 0;
1447
+ right: 0;
1448
+ bottom: 0;
1449
+ background: rgba(255, 255, 255, 0.95);
1450
+ align-items: center;
1451
+ justify-content: center;
1452
+ z-index: 10;
1453
+ ">
1454
+ <div style="
1455
+ width: 32px;
1456
+ height: 32px;
1457
+ border: 3px solid #E5E5E5;
1458
+ border-top-color: ${primary};
1459
+ border-radius: 50%;
1460
+ animation: vtilt-spin 0.8s linear infinite;
1461
+ "></div>
1462
+ </div>
1463
+
1464
+ <style>
1465
+ @keyframes vtilt-spin { to { transform: rotate(360deg); } }
1466
+ @keyframes vtilt-fade { from { opacity: 0; } to { opacity: 1; } }
1467
+
1468
+ #vtilt-chat-bubble { transition: transform 0.15s ease, box-shadow 0.15s ease; }
1469
+ #vtilt-chat-bubble:hover { transform: scale(1.05); box-shadow: 0 6px 20px rgba(123, 104, 238, 0.45); }
1470
+ #vtilt-chat-bubble:active { transform: scale(0.95); }
1471
+
1472
+ #vtilt-chat-widget { transition: opacity 0.2s ease; }
1473
+ #vtilt-chat-widget.vtilt-opening { animation: vtilt-fade 0.2s ease forwards; }
1474
+ #vtilt-chat-widget.vtilt-closing { opacity: 0; }
1475
+
1476
+ .vtilt-chat-content { transition: opacity 0.15s ease; }
1477
+
1478
+ .vtilt-chat-close:hover { color: #000 !important; background: #F0F0F0 !important; }
1479
+ .vtilt-chat-back:hover { color: #000 !important; background: #F0F0F0 !important; }
1480
+
1481
+ .vtilt-chat-input { font-size: 16px !important; -webkit-text-size-adjust: 100%; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
1482
+ .vtilt-chat-input:focus { border-color: ${primary} !important; box-shadow: 0 0 0 2px ${primary}20 !important; outline: none !important; }
1483
+ .vtilt-chat-input::placeholder { color: #999999; }
1484
+
1485
+ .vtilt-chat-send { transition: opacity 0.1s ease; }
1486
+ .vtilt-chat-send:hover { opacity: 0.85; }
1487
+ .vtilt-chat-send:active { opacity: 0.7; }
1488
+
1489
+ .vtilt-chat-new-channel { transition: opacity 0.1s ease; }
1490
+ .vtilt-chat-new-channel:hover { opacity: 0.9; }
1491
+ .vtilt-chat-new-channel:active { opacity: 0.8; }
1492
+
1493
+ .vtilt-channel-item { transition: background 0.1s ease; cursor: pointer; }
1494
+ .vtilt-channel-item:hover { background: #F5F5F5 !important; }
1495
+ .vtilt-channel-item:active { background: #EBEBEB !important; }
1496
+
1497
+ @media (max-width: 480px) {
1498
+ #vtilt-chat-container { bottom: 16px !important; right: 16px !important; }
1499
+ #vtilt-chat-bubble { width: 56px !important; height: 56px !important; }
1500
+ #vtilt-chat-bubble svg { width: 24px !important; height: 24px !important; }
1501
+ #vtilt-chat-widget {
1502
+ position: fixed !important;
1503
+ top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important;
1504
+ width: 100% !important; max-width: 100% !important;
1505
+ height: 100% !important; max-height: 100% !important;
1506
+ border-radius: 0 !important;
1507
+ z-index: 1000000 !important;
1508
+ }
1509
+ }
1510
+
1511
+ @media (prefers-reduced-motion: reduce) {
1512
+ * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
1513
+ }
1514
+ </style>
902
1515
  `;
903
1516
  }
904
1517
  _getMessageHTML(message) {
905
- const theme = this._config.theme || DEFAULT_THEME;
906
1518
  const isUser = message.sender_type === "user";
907
- const isAgentOrAI = message.sender_type === "agent" || message.sender_type === "ai";
908
- // Check read status
1519
+ const isAi = message.sender_type === "ai";
909
1520
  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>
1521
+ const primary = this._theme.primaryColor;
1522
+ if (isUser) {
1523
+ return `
1524
+ <div class="vtilt-msg" style="
1525
+ display: flex;
1526
+ flex-direction: column;
1527
+ align-items: flex-end;
1528
+ ">
1529
+ <div style="
1530
+ max-width: 80%;
1531
+ padding: 12px 16px;
1532
+ background: ${primary};
1533
+ color: white;
1534
+ border-radius: 20px 20px 4px 20px;
1535
+ font-size: 15px;
1536
+ line-height: 1.45;
1537
+ word-wrap: break-word;
1538
+ overflow-wrap: break-word;
1539
+ ">${this._escapeHTML(message.content)}</div>
1540
+ <div style="font-size: 12px; color: #888888; margin-top: 6px; display: flex; align-items: center; gap: 4px;">
1541
+ ${this._formatTime(message.created_at)}
1542
+ ${isReadByAgent ? `<svg width="14" height="14" viewBox="0 0 24 24" fill="${primary}"><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>` : ""}
1543
+ </div>
1544
+ </div>
1545
+ `;
1546
+ }
1547
+ const senderLabel = isAi ? "AI Assistant" : (message.sender_name || "Support");
1548
+ return `
1549
+ <div class="vtilt-msg" style="
1550
+ display: flex;
1551
+ gap: 10px;
1552
+ align-items: flex-end;
1553
+ ">
1554
+ <div style="
1555
+ width: 32px;
1556
+ height: 32px;
1557
+ border-radius: 50%;
1558
+ background: ${isAi ? primary : "#DEDEDE"};
1559
+ display: flex;
1560
+ align-items: center;
1561
+ justify-content: center;
1562
+ flex-shrink: 0;
1563
+ ">
1564
+ ${isAi
1565
+ ? `<svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2M7.5 13A1.5 1.5 0 0 0 6 14.5A1.5 1.5 0 0 0 7.5 16A1.5 1.5 0 0 0 9 14.5A1.5 1.5 0 0 0 7.5 13m9 0a1.5 1.5 0 0 0-1.5 1.5a1.5 1.5 0 0 0 1.5 1.5a1.5 1.5 0 0 0 1.5-1.5a1.5 1.5 0 0 0-1.5-1.5"/></svg>`
1566
+ : `<svg width="16" height="16" viewBox="0 0 24 24" fill="#666"><path d="M12 4a4 4 0 0 1 4 4a4 4 0 0 1-4 4a4 4 0 0 1-4-4a4 4 0 0 1 4-4m0 10c4.42 0 8 1.79 8 4v2H4v-2c0-2.21 3.58-4 8-4"/></svg>`}
1567
+ </div>
1568
+ <div style="flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-start;">
1569
+ <div style="
1570
+ max-width: 85%;
1571
+ padding: 12px 16px;
1572
+ background: #ffffff;
1573
+ color: #000000;
1574
+ border-radius: 20px 20px 20px 4px;
1575
+ font-size: 15px;
1576
+ line-height: 1.45;
1577
+ word-wrap: break-word;
1578
+ overflow-wrap: break-word;
1579
+ ">${this._escapeHTML(message.content)}</div>
1580
+ <div style="font-size: 12px; color: #888888; margin-top: 6px; margin-left: 4px;">
1581
+ ${senderLabel} · ${this._formatTime(message.created_at)}
1582
+ </div>
1583
+ </div>
1584
+ </div>
943
1585
  `;
944
1586
  }
945
1587
  /**
@@ -957,7 +1599,9 @@ class LazyLoadedChat {
957
1599
  const config = this._instance.getConfig();
958
1600
  const apiHost = config.api_host || "";
959
1601
  const token = config.token || "";
960
- const url = `${apiHost}${endpoint}?token=${encodeURIComponent(token)}`;
1602
+ // Use & if endpoint already has query params, otherwise use ?
1603
+ const separator = endpoint.includes("?") ? "&" : "?";
1604
+ const url = `${apiHost}${endpoint}${separator}token=${encodeURIComponent(token)}`;
961
1605
  try {
962
1606
  const response = await fetch(url, {
963
1607
  ...options,