Plugin Directory

Changeset 3435658


Ignore:
Timestamp:
01/09/2026 06:49:22 AM (3 months ago)
Author:
appwavedev
Message:

Release version 1.0.19: Added delay message display during AI response processing, improved user experience with typing during message sending, fixed spinner animation

Location:
bettercx-widget
Files:
214 added
12 edited

Legend:

Unmodified
Added
Removed
  • bettercx-widget/trunk/assets/bettercx-widget.esm.js

    r3423258 r3435658  
    1 import{p as e,g as a,b as t}from"./p-BnsX22WT.js";export{s as setNonce}from"./p-BnsX22WT.js";(()=>{const a=import.meta.url,s={};return""!==a&&(s.resourcesUrl=new URL(".",a).href),e(s)})().then((async e=>(await a(),t([["p-0018ac87",[[257,"bcx-chat-list",{apiService:[16,"api-service"],language:[1],theme:[1],chats:[32],selectedChatId:[32],messages:[32],isLoading:[32],isLoadingMore:[32],hasMore:[32],currentPage:[32],error:[32]},null,{language:["onLanguageChange"]}],[257,"bcx-message-composer",{disabled:[4],loading:[4],placeholder:[1],maxLength:[2,"max-length"],theme:[1],isAttachmentsDisabled:[4,"is-attachments-disabled"],message:[32],images:[32]}],[257,"bcx-product-slider",{products:[16],language:[1],showAfterStreaming:[4,"show-after-streaming"],currentIndex:[32],isVisible:[32]},null,{products:["onProductsChange"],showAfterStreaming:["onShowAfterStreamingChange"]}]]],["p-150b7110",[[257,"bettercx-widget",{publicKey:[1,"public-key"],theme:[1],debug:[4],baseUrl:[1,"base-url"],aiServiceUrl:[1,"ai-service-url"],autoInit:[4,"auto-init"],position:[1],language:[1],embedded:[4],embeddedSize:[1,"embedded-size"],embeddedPlacement:[1,"embedded-placement"],isAttachmentsDisabled:[4,"is-attachments-disabled"],state:[32],timeUpdateTrigger:[32],isDropdownOpen:[32],isFullscreen:[32],showChatList:[32],open:[64],close:[64],toggle:[64],sendMessage:[64]},null,{publicKey:["onPublicKeyChange"],theme:["onThemeChange"],language:["onLanguageChange"],"state.messages":["onMessagesChange"],embedded:["onEmbeddedChange"],embeddedSize:["onEmbeddedSizeChange"],embeddedPlacement:["onEmbeddedPlacementChange"]}]]]],e))));
     1import{p as e,g as a,b as t}from"./p-BnsX22WT.js";export{s as setNonce}from"./p-BnsX22WT.js";(()=>{const a=import.meta.url,s={};return""!==a&&(s.resourcesUrl=new URL(".",a).href),e(s)})().then((async e=>(await a(),t([["p-2b7d273b",[[257,"bcx-chat-list",{apiService:[16,"api-service"],language:[1],theme:[1],chats:[32],selectedChatId:[32],messages:[32],isLoading:[32],isLoadingMore:[32],hasMore:[32],currentPage:[32],error:[32]},null,{language:["onLanguageChange"]}],[257,"bcx-message-composer",{disabled:[4],loading:[4],placeholder:[1],maxLength:[2,"max-length"],theme:[1],isAttachmentsDisabled:[4,"is-attachments-disabled"],message:[32],images:[32]}],[257,"bcx-product-slider",{products:[16],language:[1],showAfterStreaming:[4,"show-after-streaming"],currentIndex:[32],isVisible:[32]},null,{products:["onProductsChange"],showAfterStreaming:["onShowAfterStreamingChange"]}]]],["p-d51d9897",[[257,"bettercx-widget",{publicKey:[1,"public-key"],theme:[1],debug:[4],baseUrl:[1,"base-url"],aiServiceUrl:[1,"ai-service-url"],autoInit:[4,"auto-init"],position:[1],language:[1],embedded:[4],embeddedSize:[1,"embedded-size"],embeddedPlacement:[1,"embedded-placement"],isAttachmentsDisabled:[4,"is-attachments-disabled"],state:[32],timeUpdateTrigger:[32],isDropdownOpen:[32],isFullscreen:[32],showChatList:[32],showDelayMessage:[32],delayMessageType:[32],open:[64],close:[64],toggle:[64],sendMessage:[64]},null,{publicKey:["onPublicKeyChange"],theme:["onThemeChange"],language:["onLanguageChange"],"state.messages":["onMessagesChange"],embedded:["onEmbeddedChange"],embeddedSize:["onEmbeddedSizeChange"],embeddedPlacement:["onEmbeddedPlacementChange"]}]]]],e))));
  • bettercx-widget/trunk/assets/index.esm.js

    r3423258 r3435658  
    1 export{a as ApiService,A as AuthService,B as BetterCXWidget,T as ThemeService}from"./p-DdHK2Ifq.js";import"./p-BnsX22WT.js";import"./p-BU1bGO0l.js";function e(e,r,t){return(e||"")+(r?` ${r}`:"")+(t?` ${t}`:"")}export{e as format}
     1export{a as ApiService,A as AuthService,B as BetterCXWidget,T as ThemeService}from"./p-Bm5fqHLr.js";import"./p-BnsX22WT.js";import"./p-BU1bGO0l.js";function e(e,r,t){return(e||"")+(r?` ${r}`:"")+(t?` ${t}`:"")}export{e as format}
  • bettercx-widget/trunk/assets/p-V8up-zPo.system.js

    r3423258 r3435658  
    1 var __awaiter=this&&this.__awaiter||function(e,t,n,a){function i(e){return e instanceof n?e:new n((function(t){t(e)}))}return new(n||(n=Promise))((function(n,r){function s(e){try{c(a.next(e))}catch(e){r(e)}}function o(e){try{c(a["throw"](e))}catch(e){r(e)}}function c(e){e.done?n(e.value):i(e.value).then(s,o)}c((a=a.apply(e,t||[])).next())}))};var __generator=this&&this.__generator||function(e,t){var n={label:0,sent:function(){if(r[0]&1)throw r[1];return r[1]},trys:[],ops:[]},a,i,r,s;return s={next:o(0),throw:o(1),return:o(2)},typeof Symbol==="function"&&(s[Symbol.iterator]=function(){return this}),s;function o(e){return function(t){return c([e,t])}}function c(o){if(a)throw new TypeError("Generator is already executing.");while(s&&(s=0,o[0]&&(n=0)),n)try{if(a=1,i&&(r=o[0]&2?i["return"]:o[0]?i["throw"]||((r=i["return"])&&r.call(i),0):i.next)&&!(r=r.call(i,o[1])).done)return r;if(i=0,r)o=[o[0]&2,r.value];switch(o[0]){case 0:case 1:r=o;break;case 4:n.label++;return{value:o[1],done:false};case 5:n.label++;i=o[1];o=[0];continue;case 7:o=n.ops.pop();n.trys.pop();continue;default:if(!(r=n.trys,r=r.length>0&&r[r.length-1])&&(o[0]===6||o[0]===2)){n=0;continue}if(o[0]===3&&(!r||o[1]>r[0]&&o[1]<r[3])){n.label=o[1];break}if(o[0]===6&&n.label<r[1]){n.label=r[1];r=o;break}if(r&&n.label<r[2]){n.label=r[2];n.ops.push(o);break}if(r[2])n.ops.pop();n.trys.pop();continue}o=t.call(e,n)}catch(e){o=[6,e];i=0}finally{a=r=0}if(o[0]&5)throw o[1];return{value:o[0]?o[1]:void 0,done:true}}};System.register(["./p-Cbgoi924.system.js"],(function(e,t){"use strict";var n,a,i;return{setters:[function(t){n=t.p;a=t.g;i=t.b;e("setNonce",t.s)}],execute:function(){var e=this;var r=function(){var e=t.meta.url;var a={};if(e!==""){a.resourcesUrl=new URL(".",e).href}return n(a)};r().then((function(t){return __awaiter(e,void 0,void 0,(function(){return __generator(this,(function(e){switch(e.label){case 0:return[4,a()];case 1:e.sent();return[2,i([["p-31a11738.system",[[257,"bcx-chat-list",{apiService:[16,"api-service"],language:[1],theme:[1],chats:[32],selectedChatId:[32],messages:[32],isLoading:[32],isLoadingMore:[32],hasMore:[32],currentPage:[32],error:[32]},null,{language:["onLanguageChange"]}],[257,"bcx-message-composer",{disabled:[4],loading:[4],placeholder:[1],maxLength:[2,"max-length"],theme:[1],isAttachmentsDisabled:[4,"is-attachments-disabled"],message:[32],images:[32]}],[257,"bcx-product-slider",{products:[16],language:[1],showAfterStreaming:[4,"show-after-streaming"],currentIndex:[32],isVisible:[32]},null,{products:["onProductsChange"],showAfterStreaming:["onShowAfterStreamingChange"]}]]],["p-d434cc1f.system",[[257,"bettercx-widget",{publicKey:[1,"public-key"],theme:[1],debug:[4],baseUrl:[1,"base-url"],aiServiceUrl:[1,"ai-service-url"],autoInit:[4,"auto-init"],position:[1],language:[1],embedded:[4],embeddedSize:[1,"embedded-size"],embeddedPlacement:[1,"embedded-placement"],isAttachmentsDisabled:[4,"is-attachments-disabled"],state:[32],timeUpdateTrigger:[32],isDropdownOpen:[32],isFullscreen:[32],showChatList:[32],open:[64],close:[64],toggle:[64],sendMessage:[64]},null,{publicKey:["onPublicKeyChange"],theme:["onThemeChange"],language:["onLanguageChange"],"state.messages":["onMessagesChange"],embedded:["onEmbeddedChange"],embeddedSize:["onEmbeddedSizeChange"],embeddedPlacement:["onEmbeddedPlacementChange"]}]]]],t)]}}))}))}))}}}));
     1var __awaiter=this&&this.__awaiter||function(e,t,n,a){function i(e){return e instanceof n?e:new n((function(t){t(e)}))}return new(n||(n=Promise))((function(n,r){function s(e){try{c(a.next(e))}catch(e){r(e)}}function o(e){try{c(a["throw"](e))}catch(e){r(e)}}function c(e){e.done?n(e.value):i(e.value).then(s,o)}c((a=a.apply(e,t||[])).next())}))};var __generator=this&&this.__generator||function(e,t){var n={label:0,sent:function(){if(r[0]&1)throw r[1];return r[1]},trys:[],ops:[]},a,i,r,s;return s={next:o(0),throw:o(1),return:o(2)},typeof Symbol==="function"&&(s[Symbol.iterator]=function(){return this}),s;function o(e){return function(t){return c([e,t])}}function c(o){if(a)throw new TypeError("Generator is already executing.");while(s&&(s=0,o[0]&&(n=0)),n)try{if(a=1,i&&(r=o[0]&2?i["return"]:o[0]?i["throw"]||((r=i["return"])&&r.call(i),0):i.next)&&!(r=r.call(i,o[1])).done)return r;if(i=0,r)o=[o[0]&2,r.value];switch(o[0]){case 0:case 1:r=o;break;case 4:n.label++;return{value:o[1],done:false};case 5:n.label++;i=o[1];o=[0];continue;case 7:o=n.ops.pop();n.trys.pop();continue;default:if(!(r=n.trys,r=r.length>0&&r[r.length-1])&&(o[0]===6||o[0]===2)){n=0;continue}if(o[0]===3&&(!r||o[1]>r[0]&&o[1]<r[3])){n.label=o[1];break}if(o[0]===6&&n.label<r[1]){n.label=r[1];r=o;break}if(r&&n.label<r[2]){n.label=r[2];n.ops.push(o);break}if(r[2])n.ops.pop();n.trys.pop();continue}o=t.call(e,n)}catch(e){o=[6,e];i=0}finally{a=r=0}if(o[0]&5)throw o[1];return{value:o[0]?o[1]:void 0,done:true}}};System.register(["./p-Cbgoi924.system.js"],(function(e,t){"use strict";var n,a,i;return{setters:[function(t){n=t.p;a=t.g;i=t.b;e("setNonce",t.s)}],execute:function(){var e=this;var r=function(){var e=t.meta.url;var a={};if(e!==""){a.resourcesUrl=new URL(".",e).href}return n(a)};r().then((function(t){return __awaiter(e,void 0,void 0,(function(){return __generator(this,(function(e){switch(e.label){case 0:return[4,a()];case 1:e.sent();return[2,i([["p-5d4e27c5.system",[[257,"bcx-chat-list",{apiService:[16,"api-service"],language:[1],theme:[1],chats:[32],selectedChatId:[32],messages:[32],isLoading:[32],isLoadingMore:[32],hasMore:[32],currentPage:[32],error:[32]},null,{language:["onLanguageChange"]}],[257,"bcx-message-composer",{disabled:[4],loading:[4],placeholder:[1],maxLength:[2,"max-length"],theme:[1],isAttachmentsDisabled:[4,"is-attachments-disabled"],message:[32],images:[32]}],[257,"bcx-product-slider",{products:[16],language:[1],showAfterStreaming:[4,"show-after-streaming"],currentIndex:[32],isVisible:[32]},null,{products:["onProductsChange"],showAfterStreaming:["onShowAfterStreamingChange"]}]]],["p-bbbb0d6a.system",[[257,"bettercx-widget",{publicKey:[1,"public-key"],theme:[1],debug:[4],baseUrl:[1,"base-url"],aiServiceUrl:[1,"ai-service-url"],autoInit:[4,"auto-init"],position:[1],language:[1],embedded:[4],embeddedSize:[1,"embedded-size"],embeddedPlacement:[1,"embedded-placement"],isAttachmentsDisabled:[4,"is-attachments-disabled"],state:[32],timeUpdateTrigger:[32],isDropdownOpen:[32],isFullscreen:[32],showChatList:[32],showDelayMessage:[32],delayMessageType:[32],open:[64],close:[64],toggle:[64],sendMessage:[64]},null,{publicKey:["onPublicKeyChange"],theme:["onThemeChange"],language:["onLanguageChange"],"state.messages":["onMessagesChange"],embedded:["onEmbeddedChange"],embeddedSize:["onEmbeddedSizeChange"],embeddedPlacement:["onEmbeddedPlacementChange"]}]]]],t)]}}))}))}))}}}));
  • bettercx-widget/trunk/bettercx-widget.php

    r3423258 r3435658  
    44 * Plugin URI: https://wordpress.org/plugins/bettercx-widget/
    55 * Description: Professional AI-powered chat widget for BetterCX platform. Seamlessly integrate intelligent customer support into any website with full WordPress compatibility. Fully functional out of the box with no trial limitations.
    6  * Version: 1.0.18
     6 * Version: 1.0.19
    77 * Author: BetterCX
    88 * Author URI: https://bettercx.ai
     
    1616 *
    1717 * @package BetterCX_Widget
    18  * @version 1.0.18
     18 * @version 1.0.19
    1919 * @author BetterCX
    2020 * @license GPLv2+
     
    3737
    3838// Define plugin constants
    39 define('BETTERCX_WIDGET_VERSION', '1.0.18');
     39define('BETTERCX_WIDGET_VERSION', '1.0.19');
    4040define('BETTERCX_WIDGET_PLUGIN_FILE', __FILE__);
    4141define('BETTERCX_WIDGET_PLUGIN_DIR', plugin_dir_path(__FILE__));
  • bettercx-widget/trunk/readme.txt

    r3423258 r3435658  
    55Tested up to: 6.8
    66Requires PHP: 7.4
    7 Stable tag: 1.0.18
     7Stable tag: 1.0.19
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    248248== Changelog ==
    249249
     250= 1.0.19 =
     251* Added delay message display when waiting for AI response (after 5 seconds)
     252* Delay message shows "Connecting to assistant" for first-time users and "Thinking..." for returning users
     253* Delay message includes animated dots and disappears when streaming starts
     254* Improved user experience during message processing with visual feedback
     255* Users can now type and add attachments while message is being sent/generated
     256* Fixed spinner animation in send button during message processing
     257
    250258= 1.0.18 =
    251259* Updated AI service URL to new endpoint
     
    379387== Upgrade Notice ==
    380388
     389= 1.0.19 =
     390Update: Enhanced user experience with delay message display during AI response processing. Users can now type and add attachments while messages are being sent. Improved visual feedback with animated status messages and fixed spinner animation.
     391
    381392= 1.0.18 =
    382393Update: Updated AI service URL to new endpoint for improved service reliability and performance.
     
    658669
    659670= Last Updated =
    660 2025-12-11
     6712025-01-09
    661672
    662673= Version =
    663 1.0.18
     6741.0.19
    664675
    665676= Minimum WordPress Version =
     
    676687
    677688= Stable Tag =
    678 1.0.18
     6891.0.19
    679690
    680691= Development Version =
    681 1.0.18
     6921.0.19
    682693
    683694= Requires at least =
  • bettercx-widget/trunk/src/components/bcx-message-composer/bcx-message-composer.scss

    r3417078 r3435658  
    260260}
    261261
     262.bcx-composer__spinner {
     263  animation: bcx-composer-spin 1s linear infinite;
     264  transform-origin: center;
     265}
     266
     267@keyframes bcx-composer-spin {
     268  from {
     269    transform: rotate(0deg);
     270  }
     271  to {
     272    transform: rotate(360deg);
     273  }
     274}
     275
    262276@media (max-width: 480px) {
    263277  .bcx-composer__media-btn {
  • bettercx-widget/trunk/src/components/bettercx-widget/bettercx-widget.scss

    r3420908 r3435658  
    13551355/* Keep messages and composer aligned horizontally across all modes */
    13561356.bcx-widget__messages,
    1357 .bcx-widget__composer {
     1357.bcx-widget__composer,
     1358.bcx-widget__delay-status {
    13581359  width: 100% !important;
    13591360  box-sizing: border-box !important;
     
    22512252}
    22522253
     2254/* Delay status message - shown above composer (inspired by ChatGPT) */
     2255.bcx-widget__delay-status {
     2256  display: flex;
     2257  align-items: baseline;
     2258  justify-content: flex-start;
     2259  gap: 4px;
     2260  padding: var(--bcx-space-1) var(--bcx-space-4);
     2261  margin-bottom: var(--bcx-space-2);
     2262  font-size: var(--bcx-text-sm);
     2263  font-weight: 500;
     2264  font-style: italic;
     2265  line-height: 1.5;
     2266  background: transparent;
     2267  border: none;
     2268  animation: bcx-delay-status-appear 0.3s ease-out;
     2269}
     2270
     2271.bcx-widget__delay-status-text {
     2272  /* Gradient text color - inspired by ping message button */
     2273  background: linear-gradient(135deg, color-mix(in srgb, var(--bcx-primary) 95%, rgba(0, 0, 0, 0.7)) 0%, color-mix(in srgb, var(--bcx-primary) 80%, var(--bcx-white)) 100%);
     2274  -webkit-background-clip: text;
     2275  margin-left: 4px;
     2276  background-clip: text;
     2277  -webkit-text-fill-color: transparent;
     2278  color: var(--bcx-primary); /* Fallback for browsers that don't support background-clip */
     2279  font-style: italic;
     2280}
     2281
     2282.bcx-widget__delay-status-dots {
     2283  display: inline-flex;
     2284  gap: 4px;
     2285  align-items: flex-end;
     2286  margin-left: 2px;
     2287}
     2288
     2289.bcx-widget__delay-dot {
     2290  width: 4px;
     2291  height: 4px;
     2292  border-radius: var(--bcx-radius-full);
     2293  background: linear-gradient(135deg, color-mix(in srgb, var(--bcx-primary) 95%, rgba(0, 0, 0, 0.7)) 0%, color-mix(in srgb, var(--bcx-primary) 80%, var(--bcx-white)) 100%);
     2294  opacity: 0.8;
     2295  animation: bcx-delay-dot-pulse 1.4s ease-in-out infinite both;
     2296
     2297  &:nth-child(1) {
     2298    animation-delay: -0.4s;
     2299  }
     2300  &:nth-child(2) {
     2301    animation-delay: -0.2s;
     2302  }
     2303  &:nth-child(3) {
     2304    animation-delay: 0s;
     2305  }
     2306}
     2307
     2308@keyframes bcx-delay-dot-pulse {
     2309  0%,
     2310  80%,
     2311  100% {
     2312    opacity: 0.4;
     2313    transform: scale(0.8);
     2314  }
     2315  40% {
     2316    opacity: 1;
     2317    transform: scale(1);
     2318  }
     2319}
     2320
     2321@keyframes bcx-delay-status-appear {
     2322  from {
     2323    opacity: 0;
     2324    transform: translateY(-4px);
     2325  }
     2326  to {
     2327    opacity: 1;
     2328    transform: translateY(0);
     2329  }
     2330}
     2331
    22532332.bcx-widget__terms-agreement {
    22542333  padding: var(--bcx-space-3) var(--bcx-space-4) var(--bcx-space-2);
     
    29663045
    29673046  .bcx-widget__spinner,
    2968   .bcx-widget__typing-indicator span {
     3047  .bcx-widget__typing-indicator span,
     3048  .bcx-widget__delay-dot {
    29693049    animation: none;
    29703050  }
  • bettercx-widget/trunk/src/components/bettercx-widget/bettercx-widget.tsx

    r3423258 r3435658  
    8686  // Track programmatic scrolling to prevent handleScroll from triggering during auto-scroll
    8787  private isProgrammaticScroll: boolean = false;
     88
     89  // Delay message handling
     90  private delayMessageTimeout: ReturnType<typeof setTimeout> | null = null;
     91  @State() private showDelayMessage: boolean = false;
     92  @State() private delayMessageType: 'selecting' | 'thinking' = null; // 'selecting' = no AI messages, 'thinking' = has AI messages
    8893
    8994  // Events
     
    184189    // Remove click outside listener
    185190    document.removeEventListener('click', this.handleClickOutside);
     191
     192    // Clean up delay message timeout
     193    if (this.delayMessageTimeout) {
     194      clearTimeout(this.delayMessageTimeout);
     195      this.delayMessageTimeout = null;
     196    }
    186197  }
    187198
     
    430441    this.emitEvent('message-sent', userMessage as unknown as Record<string, unknown>);
    431442
     443    // Check if there are any AI messages (excluding welcome message)
     444    const hasAiMessages = this.state.messages.some(msg => msg.author === 'assistant' && !msg.id?.startsWith('welcome-'));
     445
     446    // Set delay message type based on whether there are AI messages
     447    const delayMessageType: 'selecting' | 'thinking' = hasAiMessages ? 'thinking' : 'selecting';
     448
     449    // Clear any existing delay message timeout
     450    if (this.delayMessageTimeout) {
     451      clearTimeout(this.delayMessageTimeout);
     452      this.delayMessageTimeout = null;
     453    }
     454
     455    // Show delay message after 5 seconds if streaming hasn't started
     456    this.delayMessageTimeout = setTimeout(() => {
     457      if (this.state.isTyping && !this.showDelayMessage) {
     458        this.showDelayMessage = true;
     459        this.delayMessageType = delayMessageType;
     460        this.setState({});
     461        // Scroll to bottom when delay message appears
     462        this.forceScrollToBottom();
     463      }
     464    }, 5000);
     465
    432466    try {
    433467      const stream = await this.apiService.sendMessage(content, images);
     
    442476          if (chunk.type === 'streaming_output') {
    443477            if (!isStreamingStarted) {
     478              // Clear delay message when streaming starts
     479              if (this.delayMessageTimeout) {
     480                clearTimeout(this.delayMessageTimeout);
     481                this.delayMessageTimeout = null;
     482              }
     483              this.showDelayMessage = false;
     484              this.delayMessageType = null;
     485
    444486              assistantMessage = {
    445487                content: '',
     
    555597      this.emitEvent('error', { error: error.message });
    556598    } finally {
     599      // Clear delay message timeout on error or completion
     600      if (this.delayMessageTimeout) {
     601        clearTimeout(this.delayMessageTimeout);
     602        this.delayMessageTimeout = null;
     603      }
     604      this.showDelayMessage = false;
     605      this.delayMessageType = null;
    557606      this.setState({ isTyping: false });
    558607    }
     
    865914        en: 'Instant response',
    866915        pl: 'Natychmiastowa odpowiedź',
     916      },
     917      selecting_assistant: {
     918        en: 'Connecting to assistant',
     919        pl: 'Łączenie z asystentem',
     920      },
     921      thinking: {
     922        en: 'Working on the answer',
     923        pl: 'Pracuję nad odpowiedzią',
    867924      },
    868925    };
     
    15561613            </div>
    15571614
     1615            {/* Delay status message - shown above composer after 5 seconds */}
     1616            {this.showDelayMessage && this.delayMessageType && this.state.isTyping && (
     1617              <div class="bcx-widget__delay-status">
     1618                <span class="bcx-widget__delay-status-text">
     1619                  {this.delayMessageType === 'selecting' ? this.getTranslation('selecting_assistant') : this.getTranslation('thinking')}
     1620                </span>
     1621                <span class="bcx-widget__delay-status-dots">
     1622                  <span class="bcx-widget__delay-dot"></span>
     1623                  <span class="bcx-widget__delay-dot"></span>
     1624                  <span class="bcx-widget__delay-dot"></span>
     1625                </span>
     1626              </div>
     1627            )}
     1628
    15581629            <div class="bcx-widget__composer" data-adblock-bypass="true" role="form" aria-label="Message composer">
    15591630              <bcx-message-composer
    15601631                onMessageSubmit={this.handleMessageSubmit}
    1561                 disabled={this.state.isTyping}
     1632                disabled={false}
    15621633                loading={this.state.isTyping}
    15631634                placeholder={this.getTranslation('message_placeholder')}
  • bettercx-widget/trunk/src/services/__tests__/api.service.spec.ts

    r3423258 r3435658  
    2424  describe('getWidgetConfig', () => {
    2525    it('should fetch config successfully', async () => {
    26       (mockAuthService.getToken as jest.Mock).mockReturnValue('fake-token');
    27       (mockAuthService.getAuthHeader as jest.Mock).mockReturnValue({ Authorization: 'Bearer fake-token' });
     26      (mockAuthService.getToken as jest.Mock).mockResolvedValue('fake-token');
     27      (mockAuthService.getAuthHeader as jest.Mock).mockResolvedValue({ Authorization: 'Bearer fake-token' });
    2828
    2929      const mockResponse = { attrs: { light_mode: {} } };
     
    4040
    4141    it('should throw error if no token', async () => {
    42       (mockAuthService.getToken as jest.Mock).mockReturnValue(null);
     42      (mockAuthService.getToken as jest.Mock).mockResolvedValue(undefined);
    4343
    4444      await expect(service.getWidgetConfig(123)).rejects.toThrow('No valid session token available');
     
    4848  describe('sendMessage', () => {
    4949    it('should send message as JSON when no images', async () => {
    50       (mockAuthService.getToken as jest.Mock).mockReturnValue('fake-token');
    51       (mockAuthService.getAllHeaders as jest.Mock).mockReturnValue({ Authorization: 'Bearer fake-token' });
     50      (mockAuthService.getToken as jest.Mock).mockResolvedValue('fake-token');
     51      (mockAuthService.getAllHeaders as jest.Mock).mockResolvedValue({ Authorization: 'Bearer fake-token' });
    5252
    5353      const mockStream = new ReadableStream();
     
    6666          headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
    6767          body: JSON.stringify({ content: 'Hello' }),
     68          credentials: 'omit',
    6869        }),
    6970      );
     
    7273
    7374    it('should send message as FormData when images are present', async () => {
    74       (mockAuthService.getToken as jest.Mock).mockReturnValue('fake-token');
     75      (mockAuthService.getToken as jest.Mock).mockResolvedValue('fake-token');
     76      (mockAuthService.getAllHeaders as jest.Mock).mockResolvedValue({ Authorization: 'Bearer fake-token' });
    7577
    7678      const mockStream = new ReadableStream();
     
    9496  describe('getChatMessages', () => {
    9597    it('should fetch paginated messages', async () => {
    96       (mockAuthService.getToken as jest.Mock).mockReturnValue('fake-token');
     98      (mockAuthService.getToken as jest.Mock).mockResolvedValue('fake-token');
     99      (mockAuthService.getAuthHeader as jest.Mock).mockResolvedValue({ Authorization: 'Bearer fake-token' });
    97100
    98101      const mockData = {
  • bettercx-widget/trunk/src/services/__tests__/auth.service.spec.ts

    r3417078 r3435658  
    4949      expect(result).toEqual(mockResponse.data);
    5050      // The token should be stored and valid after successful session creation
    51       expect(authService.getToken()).toBe('mock-jwt-token');
     51      expect(await authService.getToken()).toBe('mock-jwt-token');
    5252      expect(authService.isTokenValid()).toBe(true);
    5353    });
     
    7474
    7575  describe('token management', () => {
    76     it('should return undefined for invalid token', () => {
    77       expect(authService.getToken()).toBeUndefined();
     76    it('should return undefined for invalid token', async () => {
     77      expect(await authService.getToken()).toBeUndefined();
    7878      expect(authService.isTokenValid()).toBe(false);
    7979    });
    8080
    81     it('should clear session', () => {
     81    it('should clear session', async () => {
    8282      // Set a mock token
    8383      const authServiceWithPrivate = authService as unknown as { sessionToken?: string; sessionExpiresAt?: Date };
     
    8787      authService.clearSession();
    8888
    89       expect(authService.getToken()).toBeUndefined();
     89      expect(await authService.getToken()).toBeUndefined();
    9090      expect(authService.isTokenValid()).toBe(false);
    9191    });
    9292
    93     it('should return auth header when token is valid', () => {
     93    it('should return auth header when token is valid', async () => {
    9494      const authServiceWithPrivate = authService as unknown as { sessionToken?: string; sessionExpiresAt?: Date };
    9595      authServiceWithPrivate.sessionToken = 'mock-token';
    9696      authServiceWithPrivate.sessionExpiresAt = new Date(Date.now() + 3600000);
    9797
    98       expect(authService.getAuthHeader()).toEqual({
     98      expect(await authService.getAuthHeader()).toEqual({
    9999        Authorization: 'Bearer mock-token',
    100100      });
    101101    });
    102102
    103     it('should return empty object when token is invalid', () => {
    104       expect(authService.getAuthHeader()).toEqual({});
    105     });
    106 
    107     it('should return undefined when token is expired', () => {
     103    it('should return empty object when token is invalid', async () => {
     104      expect(await authService.getAuthHeader()).toEqual({});
     105    });
     106
     107    it('should return undefined when token is expired', async () => {
    108108      // Set a mock token with past expiration
    109109      const authServiceWithPrivate = authService as unknown as { sessionToken?: string; sessionExpiresAt?: Date };
     
    111111      authServiceWithPrivate.sessionExpiresAt = new Date(Date.now() - 3600000);
    112112
    113       expect(authService.getToken()).toBeUndefined();
     113      expect(await authService.getToken()).toBeUndefined();
    114114      expect(authService.isTokenValid()).toBe(false);
    115115    });
     
    148148      expect(result).toBe(true);
    149149      expect(mockFetch).toHaveBeenCalled();
    150       expect(authService.getToken()).toBe('new-mock-token');
     150      expect(await authService.getToken()).toBe('new-mock-token');
    151151    });
    152152
     
    186186    });
    187187
    188     it('should return all headers including chat ID', () => {
     188    it('should return all headers including chat ID', async () => {
    189189      const authServiceWithPrivate = authService as unknown as { sessionToken?: string; sessionExpiresAt?: Date };
    190190      authServiceWithPrivate.sessionToken = 'mock-token';
     
    193193      authService.setChatId(chatId);
    194194
    195       expect(authService.getAllHeaders()).toEqual({
     195      expect(await authService.getAllHeaders()).toEqual({
    196196        'Authorization': 'Bearer mock-token',
    197197        'X-BCX-Chat-ID': chatId,
     
    210210    });
    211211  });
     212
     213  describe('automatic token refresh', () => {
     214    it('should automatically refresh token when expired in getToken()', async () => {
     215      // Set expired token
     216      const authServiceWithPrivate = authService as unknown as {
     217        sessionToken?: string;
     218        sessionExpiresAt?: Date;
     219        widgetKey?: string;
     220        origin?: string;
     221      };
     222      authServiceWithPrivate.sessionToken = 'expired-token';
     223      authServiceWithPrivate.sessionExpiresAt = new Date(Date.now() - 3600000); // 1 hour ago
     224      authServiceWithPrivate.widgetKey = 'pk_test_key';
     225      authServiceWithPrivate.origin = 'https://example.com';
     226
     227      // Mock refresh response
     228      const mockResponse = {
     229        status: 'success',
     230        message: 'Session created successfully',
     231        data: {
     232          token: 'new-refreshed-token',
     233          expires_at: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now
     234          session_id: 'new-session-id',
     235        },
     236      };
     237
     238      mockFetch.mockResolvedValueOnce({
     239        ok: true,
     240        json: async () => mockResponse,
     241      } as Response);
     242
     243      // getToken() should automatically refresh
     244      const token = await authService.getToken();
     245
     246      expect(token).toBe('new-refreshed-token');
     247      expect(mockFetch).toHaveBeenCalled();
     248      expect(authService.isTokenValid()).toBe(true);
     249    });
     250
     251    it('should not refresh if token is still valid', async () => {
     252      // Set valid token
     253      const authServiceWithPrivate = authService as unknown as {
     254        sessionToken?: string;
     255        sessionExpiresAt?: Date;
     256        widgetKey?: string;
     257        origin?: string;
     258      };
     259      authServiceWithPrivate.sessionToken = 'valid-token';
     260      authServiceWithPrivate.sessionExpiresAt = new Date(Date.now() + 3600000); // 1 hour from now
     261      authServiceWithPrivate.widgetKey = 'pk_test_key';
     262      authServiceWithPrivate.origin = 'https://example.com';
     263
     264      const token = await authService.getToken();
     265
     266      expect(token).toBe('valid-token');
     267      expect(mockFetch).not.toHaveBeenCalled();
     268    });
     269
     270    it('should return undefined if refresh fails', async () => {
     271      // Set expired token
     272      const authServiceWithPrivate = authService as unknown as {
     273        sessionToken?: string;
     274        sessionExpiresAt?: Date;
     275        widgetKey?: string;
     276        origin?: string;
     277      };
     278      authServiceWithPrivate.sessionToken = 'expired-token';
     279      authServiceWithPrivate.sessionExpiresAt = new Date(Date.now() - 3600000);
     280      authServiceWithPrivate.widgetKey = 'pk_test_key';
     281      authServiceWithPrivate.origin = 'https://example.com';
     282
     283      // Mock failed refresh
     284      mockFetch.mockRejectedValueOnce(new Error('Network error'));
     285
     286      const token = await authService.getToken();
     287
     288      expect(token).toBeUndefined();
     289      expect(mockFetch).toHaveBeenCalled();
     290    });
     291
     292    it('should prevent concurrent refresh attempts', async () => {
     293      // Set expired token
     294      const authServiceWithPrivate = authService as unknown as {
     295        sessionToken?: string;
     296        sessionExpiresAt?: Date;
     297        widgetKey?: string;
     298        origin?: string;
     299      };
     300      authServiceWithPrivate.sessionToken = 'expired-token';
     301      authServiceWithPrivate.sessionExpiresAt = new Date(Date.now() - 3600000);
     302      authServiceWithPrivate.widgetKey = 'pk_test_key';
     303      authServiceWithPrivate.origin = 'https://example.com';
     304
     305      // Mock slow refresh response
     306      const mockResponse = {
     307        status: 'success',
     308        message: 'Session created successfully',
     309        data: {
     310          token: 'new-refreshed-token',
     311          expires_at: new Date(Date.now() + 3600000).toISOString(),
     312          session_id: 'new-session-id',
     313        },
     314      };
     315
     316      let resolveFetch: (value: Response) => void;
     317      const slowFetchPromise = new Promise<Response>(resolve => {
     318        resolveFetch = resolve;
     319      });
     320
     321      mockFetch.mockReturnValueOnce(slowFetchPromise as Promise<Response>);
     322
     323      // Start multiple concurrent getToken() calls
     324      const tokenPromises = [
     325        authService.getToken(),
     326        authService.getToken(),
     327        authService.getToken(),
     328      ];
     329
     330      // Resolve fetch after a delay
     331      setTimeout(() => {
     332        resolveFetch!({
     333          ok: true,
     334          json: async () => mockResponse,
     335        } as Response);
     336      }, 100);
     337
     338      const tokens = await Promise.all(tokenPromises);
     339
     340      // All should return the same refreshed token
     341      expect(tokens.every(t => t === 'new-refreshed-token')).toBe(true);
     342      // Fetch should only be called once (not 3 times)
     343      expect(mockFetch).toHaveBeenCalledTimes(1);
     344    });
     345  });
    212346});
  • bettercx-widget/trunk/src/services/api.service.ts

    r3423258 r3435658  
    2222   */
    2323  async getWidgetConfig(organizationId: number): Promise<OrganizationWidgetConfiguration> {
    24     const token = this.authService.getToken();
     24    const token = await this.authService.getToken();
    2525    if (!token) {
    2626      throw new Error('No valid session token available');
     
    3131      headers: {
    3232        'Content-Type': 'application/json',
    33         ...this.authService.getAuthHeader(),
     33        ...(await this.authService.getAuthHeader()),
    3434      },
    3535    });
     
    4747   */
    4848  async sendMessage(message: string, images?: File[]): Promise<ReadableStream<Uint8Array> | null> {
    49     const token = this.authService.getToken();
     49    const token = await this.authService.getToken();
    5050
    5151    if (!token) {
     
    5454
    5555    const headers = {
    56       ...this.authService.getAllHeaders(), // Use all headers (auth + chat ID)
     56      ...(await this.authService.getAllHeaders()), // Use all headers (auth + chat ID)
    5757    };
    5858
     
    162162   */
    163163  async getChatMessages(chatId: string, page: number = 1, pageSize: number = 20): Promise<PaginatedMessagesResponse> {
    164     const token = this.authService.getToken();
     164    const token = await this.authService.getToken();
    165165    if (!token) {
    166166      throw new Error('No valid session token available');
     
    177177      headers: {
    178178        'Content-Type': 'application/json',
    179         ...this.authService.getAuthHeader(),
     179        ...(await this.authService.getAuthHeader()),
    180180      },
    181181    });
  • bettercx-widget/trunk/src/services/auth.service.ts

    r3417078 r3435658  
    1111  private sessionExpiresAt?: Date;
    1212  private chatId?: string; // Store chat ID for conversation context
     13  private widgetKey?: string; // Store widget key for token refresh
     14  private origin?: string; // Store origin for token refresh
     15  private refreshPromise?: Promise<boolean>; // Promise for ongoing refresh to prevent concurrent refreshes
    1316
    1417  constructor(baseUrl: string = 'http://localhost:8000') {
     
    2023   */
    2124  async createSession(widgetKey: string, origin: string): Promise<Partial<WidgetSessionResponse>> {
     25    // Store widget key and origin for automatic token refresh
     26    this.widgetKey = widgetKey;
     27    this.origin = origin;
     28
    2229    const request: WidgetSessionRequest = {
    2330      widget_key: widgetKey,
     
    6370  /**
    6471   * Get current session token
     72   * Automatically refreshes token if expired (seamless token management)
     73   * This ensures the widget continues to work even after long periods of inactivity
    6574   */
    66   getToken(): string | undefined {
     75  async getToken(): Promise<string | undefined> {
     76    // If token is valid, return it immediately
    6777    if (this.isTokenValid()) {
    6878      return this.sessionToken;
    6979    }
     80
     81    // If token expired and we have credentials, try to refresh
     82    if (this.widgetKey && this.origin) {
     83      // If refresh is already in progress, wait for it
     84      if (this.refreshPromise) {
     85        const refreshed = await this.refreshPromise;
     86        if (refreshed && this.isTokenValid()) {
     87          return this.sessionToken;
     88        }
     89        return undefined;
     90      }
     91
     92      // Start refresh process
     93      this.refreshPromise = this.refreshSessionIfNeeded(this.widgetKey, this.origin);
     94
     95      try {
     96        const refreshed = await this.refreshPromise;
     97        this.refreshPromise = undefined; // Clear promise after completion
     98
     99        if (refreshed && this.isTokenValid()) {
     100          return this.sessionToken;
     101        }
     102      } catch {
     103        this.refreshPromise = undefined; // Clear promise on error
     104        // Refresh failed, return undefined
     105        // Error will be handled by calling code
     106      }
     107    }
     108
     109    // No credentials available or refresh failed
    70110    return undefined;
    71111  }
     
    88128    this.sessionExpiresAt = undefined;
    89129    this.chatId = undefined; // Clear chat ID as well
     130    this.widgetKey = undefined; // Clear widget key
     131    this.origin = undefined; // Clear origin
     132    this.refreshPromise = undefined; // Clear any pending refresh
    90133  }
    91134
    92135  /**
    93136   * Get authorization header for API requests
     137   * Automatically refreshes token if expired
    94138   */
    95   getAuthHeader(): { Authorization: string } | {} {
    96     const token = this.getToken();
     139  async getAuthHeader(): Promise<{ Authorization: string } | {}> {
     140    const token = await this.getToken();
    97141    const headers = token ? { Authorization: `Bearer ${token}` } : {};
    98142    return headers;
     
    109153  /**
    110154   * Get all headers for API requests (auth + chat ID)
     155   * Automatically refreshes token if expired
    111156   */
    112   getAllHeaders(): Record<string, string> {
     157  async getAllHeaders(): Promise<Record<string, string>> {
    113158    const headers = {
    114       ...this.getAuthHeader(),
     159      ...(await this.getAuthHeader()),
    115160      ...this.getChatIdHeader(),
    116161    };
Note: See TracChangeset for help on using the changeset viewer.