@v-tilt/browser 1.4.1 → 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,33 +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
169
  $messages_sent: this._state.messages.filter((m) => m.sender_type === "user").length,
140
170
  });
171
+ // Disconnect Ably when widget closes to save connection minutes
172
+ this._disconnectRealtime();
141
173
  }
142
174
  toggle() {
143
175
  if (this._state.isOpen) {
@@ -156,15 +188,165 @@ class LazyLoadedChat {
156
188
  this._updateUI();
157
189
  }
158
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
+ // ============================================================================
159
340
  // Public API - Messaging
160
341
  // ============================================================================
161
342
  async sendMessage(content) {
162
343
  var _a, _b, _c, _d;
163
344
  if (!content.trim())
164
345
  return;
165
- // Ensure channel exists
166
- if (!this._state.channel) {
167
- 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;
168
350
  }
169
351
  const channelId = (_a = this._state.channel) === null || _a === void 0 ? void 0 : _a.id;
170
352
  if (!channelId) {
@@ -176,7 +358,7 @@ class LazyLoadedChat {
176
358
  id: `temp-${Date.now()}`,
177
359
  channel_id: channelId,
178
360
  sender_type: "user",
179
- sender_id: this._instance.getDistinctId() || null,
361
+ sender_id: this._distinctId || null,
180
362
  sender_name: null,
181
363
  sender_avatar_url: null,
182
364
  content,
@@ -188,11 +370,11 @@ class LazyLoadedChat {
188
370
  this._updateUI();
189
371
  try {
190
372
  // Send to API
191
- const response = await this._apiRequest(`${API_WIDGET_MESSAGES}`, {
373
+ const response = await this._apiRequest(API.messages, {
192
374
  method: "POST",
193
375
  body: JSON.stringify({
194
376
  channel_id: channelId,
195
- distinct_id: this._instance.getDistinctId(),
377
+ distinct_id: this._distinctId,
196
378
  content,
197
379
  }),
198
380
  });
@@ -246,11 +428,11 @@ class LazyLoadedChat {
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,53 +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 =
346
- response.channel.agent_last_read_at || null;
347
- this._initialUserReadAt = response.channel.user_last_read_at || null;
348
- // Track channel started (only if new)
349
- if (response.messages.length === 0 || response.messages.length === 1) {
350
- this._trackEvent(types_1.CHAT_EVENTS.STARTED, {
351
- $channel_id: response.channel.id,
352
- $initiated_by: "user",
353
- $ai_mode: response.channel.ai_mode,
354
- });
355
- }
356
- // Connect to Ably now that we have channel ID
357
- this._connectRealtime();
358
- // Auto-mark unread messages as read if widget is open
359
- if (this._state.isOpen) {
360
- this._autoMarkAsRead();
361
- }
362
- }
363
- }
364
- catch (error) {
365
- console.error(`${LOGGER_PREFIX} Failed to initialize channel:`, error);
366
- }
367
- finally {
368
- this._state.isLoading = false;
369
- this._updateUI();
370
- }
371
- }
372
- // ============================================================================
373
508
  // Private - Ably Realtime Connection
374
509
  // ============================================================================
375
510
  async _connectRealtime() {
@@ -380,10 +515,10 @@ class LazyLoadedChat {
380
515
  this._notifyConnectionChange(false);
381
516
  try {
382
517
  // Get Ably token from server
383
- const tokenResponse = await this._apiRequest(API_ABLY_TOKEN, {
518
+ const tokenResponse = await this._apiRequest(API.ablyToken, {
384
519
  method: "POST",
385
520
  body: JSON.stringify({
386
- distinct_id: this._instance.getDistinctId(),
521
+ distinct_id: this._distinctId,
387
522
  channel_id: this._state.channel.id,
388
523
  }),
389
524
  });
@@ -397,10 +532,10 @@ class LazyLoadedChat {
397
532
  authCallback: async (_, callback) => {
398
533
  var _a;
399
534
  try {
400
- const refreshResponse = await this._apiRequest(API_ABLY_TOKEN, {
535
+ const refreshResponse = await this._apiRequest(API.ablyToken, {
401
536
  method: "POST",
402
537
  body: JSON.stringify({
403
- distinct_id: this._instance.getDistinctId(),
538
+ distinct_id: this._distinctId,
404
539
  channel_id: (_a = this._state.channel) === null || _a === void 0 ? void 0 : _a.id,
405
540
  }),
406
541
  });
@@ -495,7 +630,7 @@ class LazyLoadedChat {
495
630
  // Skip user's own messages - we already have them from optimistic updates
496
631
  // The sender_id for user messages is the distinct_id
497
632
  if (message.sender_type === "user" &&
498
- message.sender_id === this._instance.getDistinctId()) {
633
+ message.sender_id === this._distinctId) {
499
634
  // But DO replace temp message with real one if present
500
635
  const tempIndex = this._state.messages.findIndex((m) => m.id.startsWith("temp-") &&
501
636
  m.content === message.content &&
@@ -583,27 +718,14 @@ class LazyLoadedChat {
583
718
  globals_1.document.body.appendChild(this._container);
584
719
  }
585
720
  _attachEventListeners() {
586
- var _a, _b, _c, _d;
721
+ var _a, _b;
587
722
  // Bubble click
588
723
  (_a = this._bubble) === null || _a === void 0 ? void 0 : _a.addEventListener("click", () => this.toggle());
589
- // Close button
724
+ // Close button (initial attachment - re-attached in _updateHeader)
590
725
  const closeBtn = (_b = this._widget) === null || _b === void 0 ? void 0 : _b.querySelector(".vtilt-chat-close");
591
726
  closeBtn === null || closeBtn === void 0 ? void 0 : closeBtn.addEventListener("click", () => this.close());
592
- // Send button
593
- const sendBtn = (_c = this._widget) === null || _c === void 0 ? void 0 : _c.querySelector(".vtilt-chat-send");
594
- sendBtn === null || sendBtn === void 0 ? void 0 : sendBtn.addEventListener("click", () => this._handleSend());
595
- // Input enter key and typing indicator
596
- const input = (_d = this._widget) === null || _d === void 0 ? void 0 : _d.querySelector(".vtilt-chat-input");
597
- input === null || input === void 0 ? void 0 : input.addEventListener("keypress", (e) => {
598
- if (e.key === "Enter" && !e.shiftKey) {
599
- e.preventDefault();
600
- this._handleSend();
601
- }
602
- });
603
- // Send typing indicator on input
604
- input === null || input === void 0 ? void 0 : input.addEventListener("input", () => {
605
- this._handleUserTyping();
606
- });
727
+ // Note: Channel list and conversation listeners are attached dynamically
728
+ // in _attachChannelListListeners() and _attachConversationListeners()
607
729
  }
608
730
  _handleUserTyping() {
609
731
  // Don't send typing if not connected to Ably
@@ -630,7 +752,7 @@ class LazyLoadedChat {
630
752
  try {
631
753
  this._typingChannel.publish("typing", {
632
754
  sender_type: "user",
633
- sender_id: this._instance.getDistinctId(),
755
+ sender_id: this._distinctId,
634
756
  sender_name: null,
635
757
  is_typing: isTyping,
636
758
  });
@@ -673,8 +795,39 @@ class LazyLoadedChat {
673
795
  this._state.unreadCount > 0 ? "flex" : "none";
674
796
  badge.textContent = String(this._state.unreadCount);
675
797
  }
676
- // Update messages
677
- 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();
678
831
  // Update loading state
679
832
  const loader = this._widget.querySelector(".vtilt-chat-loader");
680
833
  if (loader) {
@@ -682,24 +835,460 @@ class LazyLoadedChat {
682
835
  ? "flex"
683
836
  : "none";
684
837
  }
685
- // Update typing indicator
838
+ // Update typing indicator (only in conversation view)
686
839
  const typing = this._widget.querySelector(".vtilt-chat-typing");
687
840
  if (typing) {
688
- typing.style.display = this._state.isTyping
689
- ? "flex"
690
- : "none";
841
+ typing.style.display =
842
+ this._state.isTyping && this._state.currentView === "conversation"
843
+ ? "flex"
844
+ : "none";
691
845
  const typingText = typing.querySelector("span");
692
846
  if (typingText && this._state.typingSender) {
693
847
  typingText.textContent = `${this._state.typingSender} is typing...`;
694
848
  }
695
849
  }
696
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
+ }
697
1286
  _renderMessages() {
698
1287
  var _a;
699
1288
  const messagesContainer = (_a = this._widget) === null || _a === void 0 ? void 0 : _a.querySelector(".vtilt-chat-messages");
700
1289
  if (!messagesContainer)
701
1290
  return;
702
- const theme = this._config.theme || DEFAULT_THEME;
1291
+ const primary = this._theme.primaryColor;
703
1292
  // Find first unread agent message index
704
1293
  const firstUnreadIndex = this._state.messages.findIndex((m) => (m.sender_type === "agent" || m.sender_type === "ai") &&
705
1294
  !this._isMessageReadByUser(m.created_at));
@@ -710,10 +1299,10 @@ class LazyLoadedChat {
710
1299
  // Add unread divider before first unread message
711
1300
  if (index === firstUnreadIndex && firstUnreadIndex > 0) {
712
1301
  html += `
713
- <div style="display: flex; align-items: center; gap: 12px; margin: 8px 0;">
714
- <div style="flex: 1; height: 1px; background: ${theme.primaryColor}40;"></div>
715
- <span style="font-size: 11px; font-weight: 500; color: ${theme.primaryColor}; padding: 0 8px;">New messages</span>
716
- <div style="flex: 1; height: 1px; background: ${theme.primaryColor}40;"></div>
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>
717
1306
  </div>
718
1307
  `;
719
1308
  }
@@ -722,64 +1311,70 @@ class LazyLoadedChat {
722
1311
  })
723
1312
  .join("");
724
1313
  messagesContainer.innerHTML = messagesHtml;
725
- // Scroll to bottom
726
1314
  messagesContainer.scrollTop = messagesContainer.scrollHeight;
727
1315
  }
728
1316
  // ============================================================================
729
1317
  // Private - Styles & HTML
730
1318
  // ============================================================================
1319
+ _isMobile() {
1320
+ return globals_1.window ? globals_1.window.innerWidth < 480 : false;
1321
+ }
731
1322
  _getContainerStyles() {
732
- var _a;
733
- const position = this._config.position || DEFAULT_POSITION;
734
- const isRight = position === "bottom-right";
1323
+ const isRight = (this._config.position || DEFAULT_POSITION) === "bottom-right";
735
1324
  return `
736
1325
  position: fixed;
737
1326
  bottom: 20px;
738
1327
  ${isRight ? "right: 20px;" : "left: 20px;"}
739
1328
  z-index: 999999;
740
- font-family: ${((_a = this._config.theme) === null || _a === void 0 ? void 0 : _a.fontFamily) || DEFAULT_THEME.fontFamily};
1329
+ font-family: ${this._theme.fontFamily};
1330
+ -webkit-font-smoothing: antialiased;
1331
+ -moz-osx-font-smoothing: grayscale;
741
1332
  `;
742
1333
  }
743
1334
  _getBubbleStyles() {
744
- const theme = this._config.theme || DEFAULT_THEME;
1335
+ const primary = this._theme.primaryColor;
745
1336
  return `
746
1337
  width: 60px;
747
1338
  height: 60px;
748
1339
  border-radius: 50%;
749
- background: ${theme.primaryColor};
1340
+ background: ${primary};
750
1341
  cursor: pointer;
751
1342
  display: flex;
752
1343
  align-items: center;
753
1344
  justify-content: center;
754
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
755
- transition: transform 0.2s, box-shadow 0.2s;
1345
+ box-shadow: 0 4px 16px rgba(123, 104, 238, 0.4);
1346
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
756
1347
  position: relative;
1348
+ -webkit-tap-highlight-color: transparent;
1349
+ touch-action: manipulation;
757
1350
  `;
758
1351
  }
759
1352
  _getBubbleHTML() {
760
1353
  return `
761
- <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
762
- <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
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"/>
763
1356
  </svg>
764
1357
  <div class="vtilt-chat-badge" style="
765
1358
  display: none;
766
1359
  position: absolute;
767
- top: -5px;
768
- right: -5px;
769
- background: #ef4444;
1360
+ top: -4px;
1361
+ right: -4px;
1362
+ background: #E53935;
770
1363
  color: white;
771
- font-size: 12px;
772
- font-weight: 600;
1364
+ font-size: 11px;
1365
+ font-weight: 700;
773
1366
  min-width: 20px;
774
1367
  height: 20px;
775
1368
  border-radius: 10px;
776
1369
  align-items: center;
777
1370
  justify-content: center;
1371
+ padding: 0 6px;
1372
+ box-sizing: border-box;
1373
+ border: 2px solid white;
778
1374
  ">0</div>
779
1375
  `;
780
1376
  }
781
1377
  _getWidgetStyles() {
782
- const theme = this._config.theme || DEFAULT_THEME;
783
1378
  return `
784
1379
  display: none;
785
1380
  flex-direction: column;
@@ -787,163 +1382,204 @@ class LazyLoadedChat {
787
1382
  bottom: 80px;
788
1383
  right: 0;
789
1384
  width: 380px;
790
- height: 520px;
791
- background: white;
792
- border-radius: ${theme.borderRadius};
793
- box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
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);
794
1392
  overflow: hidden;
795
1393
  `;
796
1394
  }
797
1395
  _getWidgetHTML() {
798
- const theme = this._config.theme || DEFAULT_THEME;
799
- const greeting = this._config.greeting || "How can we help you?";
1396
+ const greeting = this._config.greeting || "Messages";
1397
+ const primary = this._theme.primaryColor;
800
1398
  return `
801
1399
  <div class="vtilt-chat-header" style="
802
- background: ${theme.headerBgColor};
803
- color: white;
804
- padding: 16px;
1400
+ background: #ffffff;
1401
+ border-bottom: 1px solid #E5E5E5;
1402
+ padding: 18px 16px;
1403
+ padding-top: max(18px, env(safe-area-inset-top, 18px));
805
1404
  display: flex;
806
1405
  align-items: center;
807
1406
  justify-content: space-between;
1407
+ min-height: 60px;
1408
+ box-sizing: border-box;
1409
+ flex-shrink: 0;
808
1410
  ">
809
- <div style="font-weight: 600; font-size: 16px;">${greeting}</div>
1411
+ <div style="font-weight: 600; font-size: 17px; color: #000000;">${greeting}</div>
810
1412
  <button class="vtilt-chat-close" style="
811
- background: none;
1413
+ background: transparent;
812
1414
  border: none;
813
- color: white;
1415
+ color: #666666;
814
1416
  cursor: pointer;
815
- padding: 4px;
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;
816
1425
  ">
817
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1426
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
818
1427
  <path d="M18 6L6 18M6 6l12 12"></path>
819
1428
  </svg>
820
1429
  </button>
821
1430
  </div>
822
1431
 
823
- <div class="vtilt-chat-messages" style="
1432
+ <div class="vtilt-chat-content" style="
824
1433
  flex: 1;
825
- overflow-y: auto;
826
- padding: 16px;
827
1434
  display: flex;
828
1435
  flex-direction: column;
829
- gap: 12px;
830
- "></div>
1436
+ overflow: hidden;
1437
+ min-height: 0;
1438
+ background: #ffffff;
1439
+ ">
1440
+ </div>
831
1441
 
832
1442
  <div class="vtilt-chat-loader" style="
833
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);
834
1450
  align-items: center;
835
1451
  justify-content: center;
836
- padding: 20px;
1452
+ z-index: 10;
837
1453
  ">
838
1454
  <div style="
839
- width: 24px;
840
- height: 24px;
841
- border: 2px solid #e5e7eb;
842
- border-top-color: ${theme.primaryColor};
1455
+ width: 32px;
1456
+ height: 32px;
1457
+ border: 3px solid #E5E5E5;
1458
+ border-top-color: ${primary};
843
1459
  border-radius: 50%;
844
1460
  animation: vtilt-spin 0.8s linear infinite;
845
1461
  "></div>
846
1462
  </div>
847
1463
 
848
- <div class="vtilt-chat-typing" style="
849
- display: none;
850
- padding: 8px 16px;
851
- color: #6b7280;
852
- font-size: 14px;
853
- ">
854
- <span style="animation: vtilt-pulse 1.5s infinite;">Agent is typing...</span>
855
- </div>
856
-
857
- <div class="vtilt-chat-input-container" style="
858
- padding: 16px;
859
- border-top: 1px solid #e5e7eb;
860
- display: flex;
861
- gap: 8px;
862
- ">
863
- <input
864
- type="text"
865
- class="vtilt-chat-input"
866
- placeholder="Type a message..."
867
- style="
868
- flex: 1;
869
- border: 1px solid #e5e7eb;
870
- border-radius: 8px;
871
- padding: 10px 14px;
872
- font-size: 14px;
873
- outline: none;
874
- transition: border-color 0.2s;
875
- "
876
- />
877
- <button class="vtilt-chat-send" style="
878
- background: ${theme.primaryColor};
879
- color: white;
880
- border: none;
881
- border-radius: 8px;
882
- padding: 10px 16px;
883
- cursor: pointer;
884
- font-weight: 500;
885
- transition: opacity 0.2s;
886
- ">Send</button>
887
- </div>
888
-
889
1464
  <style>
890
- @keyframes vtilt-spin {
891
- to { transform: rotate(360deg); }
892
- }
893
- @keyframes vtilt-pulse {
894
- 0%, 100% { opacity: 1; }
895
- 50% { opacity: 0.5; }
896
- }
897
- #vtilt-chat-bubble:hover {
898
- transform: scale(1.05);
899
- box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
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
+ }
900
1509
  }
901
- .vtilt-chat-input:focus {
902
- border-color: ${theme.primaryColor} !important;
903
- }
904
- .vtilt-chat-send:hover {
905
- opacity: 0.9;
1510
+
1511
+ @media (prefers-reduced-motion: reduce) {
1512
+ * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
906
1513
  }
907
1514
  </style>
908
1515
  `;
909
1516
  }
910
1517
  _getMessageHTML(message) {
911
- const theme = this._config.theme || DEFAULT_THEME;
912
1518
  const isUser = message.sender_type === "user";
913
- const isAgentOrAI = message.sender_type === "agent" || message.sender_type === "ai";
914
- // Check read status
1519
+ const isAi = message.sender_type === "ai";
915
1520
  const isReadByAgent = isUser && this._isMessageReadByAgent(message.created_at);
916
- const isUnread = isAgentOrAI && !this._isMessageReadByUser(message.created_at);
917
- const bubbleStyle = isUser
918
- ? `background: ${theme.userBubbleColor}; color: white; margin-left: auto;`
919
- : `background: ${theme.agentBubbleColor}; color: #1f2937; margin-right: auto;${isUnread && !this._isMarkingRead ? " box-shadow: 0 0 0 2px " + theme.primaryColor + "40;" : ""}`;
920
- const senderLabel = message.sender_type === "ai"
921
- ? "AI Assistant"
922
- : message.sender_type === "agent"
923
- ? message.sender_name || "Agent"
924
- : "";
925
- // Read receipt SVG icons
926
- const singleCheckSvg = `<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor" style="color: #9ca3af;"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>`;
927
- const doubleCheckSvg = `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="color: ${theme.primaryColor};"><path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z"/></svg>`;
928
- // Unread badge for agent messages
929
- const unreadBadge = isUnread && !this._isMarkingRead
930
- ? `<span style="font-size: 10px; font-weight: 600; color: ${theme.primaryColor}; background: ${theme.primaryColor}20; padding: 2px 6px; border-radius: 9999px;">NEW</span>`
931
- : "";
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");
932
1548
  return `
933
- <div style="display: flex; flex-direction: column; ${isUser ? "align-items: flex-end;" : "align-items: flex-start;"}${isUnread && !this._isMarkingRead ? " position: relative;" : ""}">
934
- ${!isUser && senderLabel ? `<div style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">${senderLabel}</div>` : ""}
1549
+ <div class="vtilt-msg" style="
1550
+ display: flex;
1551
+ gap: 10px;
1552
+ align-items: flex-end;
1553
+ ">
935
1554
  <div style="
936
- max-width: 80%;
937
- padding: 10px 14px;
938
- border-radius: 12px;
939
- font-size: 14px;
940
- line-height: 1.4;
941
- transition: opacity 0.3s, box-shadow 0.3s;
942
- ${bubbleStyle}${this._isMarkingRead && isUnread ? " opacity: 0.7;" : ""}
943
- ">${this._escapeHTML(message.content)}</div>
944
- <div style="font-size: 11px; color: #9ca3af; margin-top: 4px; display: flex; align-items: center; gap: 4px;">
945
- ${this._formatTime(message.created_at)}
946
- ${isUser ? (isReadByAgent ? doubleCheckSvg : singleCheckSvg) : unreadBadge}
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>
947
1583
  </div>
948
1584
  </div>
949
1585
  `;
@@ -963,7 +1599,9 @@ class LazyLoadedChat {
963
1599
  const config = this._instance.getConfig();
964
1600
  const apiHost = config.api_host || "";
965
1601
  const token = config.token || "";
966
- 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)}`;
967
1605
  try {
968
1606
  const response = await fetch(url, {
969
1607
  ...options,