UNPKG

besper-frontend-site-dev-main

Version:

Professional B-esper Frontend Site - Site-wide integration toolkit for full website bot deployment

717 lines (609 loc) 20.8 kB
/** * Professional Chat Widget with Dynamic Styling - Modular Version * Main chat widget component that orchestrates all sub-components */ import { getBotOperationsEndpoint, operationsApiCall, } from '../../services/centralizedApi.js'; import { getBrowserLanguage, getLocalizedCategory } from '../../utils/i18n.js'; import { StyleSanitizer } from '../../utils/sanitizer.js'; // Import chat components import { ChatHeader } from './ChatHeader.js'; import { ChatMessages } from './ChatMessages.js'; import { ChatInput } from './ChatInput.js'; import { TypingIndicator } from './TypingIndicator.js'; /** * Professional Chat Widget with Dynamic Styling */ export class BesperChatWidget { constructor(botId, options = {}) { this.botId = botId; this.options = { environment: 'prod', position: 'bottom-right', ...options, }; this.state = { isOpen: false, isMinimized: true, threadId: null, isConnected: false, sessionData: null, styling: null, botConfig: null, typing: false, viewState: 'normal', // Can be 'normal' or 'expanded' userLanguage: getBrowserLanguage(), }; this.widget = null; this.apiEndpoint = getBotOperationsEndpoint(); this.isInitialized = false; this.autoExpandTimeouts = new Map(); this.outsideClickHandler = null; this.customMessageTemplate = null; this.sanitizer = new StyleSanitizer(); // Initialize components this.chatHeader = null; this.chatMessages = null; this.chatInput = null; this.typingIndicator = null; } async init() { try { console.log('[LOADING] Initializing B-esper chat widget...'); // Create widget container first this.createWidget(); // Initialize components after widget container exists this.initializeComponents(); // Try to create session to get styling data and configuration BEFORE HTML generation try { await this.createSession(); // Apply configuration and styling from payload if session was successful this.applySessionConfiguration(); } catch (sessionError) { console.warn( '[WARN] Session creation failed, using default configuration:', sessionError ); // Use default configuration if session creation fails this.applyDefaultConfiguration(sessionError); } // Update widget with proper HTML AFTER configuration is loaded this.updateWidgetHTML(); // Add welcome/offline messages after HTML is generated if (this.state.isConnected) { this.chatMessages.addWelcomeMessage(); } else { this.chatMessages.addOfflineMessage(); } // Setup event handlers this.setupEventHandlers(); // Setup outside click handler if enabled this.setupOutsideClickHandler(); // Mobile layout initialization if (this.isMobileDevice()) { // Only listen for orientation changes on mobile window.addEventListener('orientationchange', () => { setTimeout(() => { this.adjustMobileLayout(); }, 500); }); } // Mark as initialized this.isInitialized = true; console.log('[SUCCESS] B-esper chat widget initialized successfully'); return this; } catch (error) { console.error('[ERROR] Failed to initialize B-esper chat widget:', error); throw error; } } /** * Initialize all sub-components */ initializeComponents() { this.chatHeader = new ChatHeader(this.widget, this.state); this.chatMessages = new ChatMessages(this.widget, this.state, { onExpandView: () => this.toggleExpandedView(), }); this.chatInput = new ChatInput(this.widget, this.state); this.typingIndicator = new TypingIndicator(this.widget, this.state); } async createSession() { try { // Get browser language using the enhanced detection function const browserLanguage = getBrowserLanguage(); const data = await operationsApiCall( 'create_session', 'POST', { bot_id: this.botId, language: browserLanguage, }, this.options.environment ); // Debug log the full response structure console.log('🔍 Session API response:', data); if (!data.success) { throw new Error( `Session creation failed: ${data.error || 'Unknown error'}` ); } // Check if thread_id exists in response (API returns thread_id with underscore) if (!data.thread_id) { console.error('[ERROR] API response missing thread_id:', data); throw new Error( 'Session creation failed: API response missing thread_id' ); } // Store all session data from response - API returns data directly, not in payload this.state.threadId = data.thread_id; this.state.isConnected = true; this.state.sessionData = data; this.state.styling = data.styling || {}; // Construct botConfig from API response fields (matching original implementation) this.state.botConfig = { botName: data.bot_name || data.bot_title || 'B-esper Assistant', botTitle: data.bot_title || 'AI Assistant', welcomeMessage: data.welcome_message, dataPolicyUrl: data.data_policy_url, logoUrl: data.logo || (data.styling && data.styling.logo_url), // Use logo from response (base64 or URL) }; this.state.userLanguage = data.userLanguage || browserLanguage; console.log( '[SUCCESS] Session created successfully:', this.state.threadId ); } catch (error) { console.error('[ERROR] Session creation failed:', error); throw error; } } /** * Retry session creation (useful for reconnection) */ async retrySession() { try { console.log('[LOADING] Retrying session creation...'); await this.createSession(); // Apply configuration and styling from payload this.applySessionConfiguration(); // Add welcome message if available this.chatMessages.addWelcomeMessage(); // Clear any offline messages this.chatMessages.clearMessages(); this.chatMessages.addWelcomeMessage(); console.log('[SUCCESS] Session retry successful'); return true; } catch (error) { console.error('[ERROR] Session retry failed:', error); return false; } } createWidget() { // Set container if (this.options.container) { this.createEmbeddedWidget(); return; } else { this.createFloatingWidget(); return; } } createFloatingWidget() { // Create a new container for the floating widget const widgetContainer = document.createElement('div'); widgetContainer.className = 'besper-widget-container'; // Position the widget this.positionWidget(widgetContainer); // Add to body document.body.appendChild(widgetContainer); this.widget = widgetContainer; } updateWidgetHTML() { if (this.widget) { if (this.options.container) { this.widget.innerHTML = this.getEmbeddedWidgetHTML(); } else { this.widget.innerHTML = this.getWidgetHTML(); } } } createEmbeddedWidget() { // Get target container let targetContainer; if (typeof this.options.container === 'string') { targetContainer = document.querySelector(this.options.container); } else { targetContainer = this.options.container; } if (!targetContainer) { throw new Error( `Container not found: ${this.options.container}. Please ensure the container exists in the DOM before initializing the widget.` ); } // Create embedded widget container const widgetContainer = document.createElement('div'); widgetContainer.className = 'besper-widget-container besper-embedded'; targetContainer.appendChild(widgetContainer); this.widget = widgetContainer; } positionWidget(container) { container.style.position = 'fixed'; container.style.zIndex = '999999'; const position = this.options.position.toLowerCase(); switch (position) { case 'bottom-left': container.style.bottom = '20px'; container.style.left = '20px'; break; case 'top-right': container.style.top = '20px'; container.style.right = '20px'; break; case 'top-left': container.style.top = '20px'; container.style.left = '20px'; break; default: // bottom-right container.style.bottom = '20px'; container.style.right = '20px'; } } getWidgetHTML() { const config = this.state.botConfig || {}; return ` <div class="besper-widget ${this.state.isOpen ? 'open' : 'minimized'}"> <!-- Minimized Widget Button --> <div class="besper-widget-button" id="besper-widget-btn"> <div class="besper-widget-icon"> ${ config.logoUrl ? `<img src="${config.logoUrl}" alt="Bot Logo" class="besper-widget-logo">` : `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"></path> </svg>` } </div> </div> <!-- Chat Interface --> <div class="besper-chat-container" id="besper-chat-container"> <!-- Header --> ${this.chatHeader ? this.chatHeader.getHTML() : ''} <!-- Messages --> ${this.chatMessages ? this.chatMessages.getHTML() : ''} <!-- Input Area --> ${this.chatInput ? this.chatInput.getHTML() : ''} </div> </div> ${this.getStyles()} `; } getEmbeddedWidgetHTML() { return ` <div class="besper-widget embedded open"> <!-- Chat Interface (no floating button for embedded mode) --> <div class="besper-chat-container" id="besper-chat-container"> <!-- Header --> ${this.getEmbeddedHeaderHTML()} <!-- Messages --> ${this.chatMessages ? this.chatMessages.getHTML() : ''} <!-- Input Area --> ${this.chatInput ? this.chatInput.getHTML() : ''} </div> </div> ${this.getStyles()} `; } getEmbeddedHeaderHTML() { const config = this.state.botConfig || {}; const tooltips = getLocalizedCategory('tooltips', this.state.userLanguage); return ` <div class="besper-chat-header"> <div class="besper-chat-header-left"> <div class="besper-chat-title-group"> ${ config.logoUrl ? `<div class="besper-chat-logo"> <img src="${config.logoUrl}" alt="Bot Logo" class="besper-chat-logo-img"> </div>` : '' } <div class="besper-chat-title">${config.botTitle || 'Chat Bot'}</div> </div> </div> <div class="besper-chat-header-right"> <div class="besper-chat-actions"> ${this.chatHeader.getDataPolicyButton(config, tooltips)} ${this.chatHeader.getExpandButton(tooltips)} ${this.chatHeader.getDownloadButton(tooltips)} ${this.chatHeader.getRestartButton(tooltips)} ${this.chatHeader.getDeleteButton(tooltips)} </div> </div> </div> `; } setupEventHandlers() { const widgetBtn = this.widget.querySelector('#besper-widget-btn'); // Widget toggle (only for floating mode) if (widgetBtn) { widgetBtn.addEventListener('click', () => this.toggleWidget()); } // Setup header event listeners if (this.chatHeader) { this.chatHeader.setupEventListeners({ onDataPolicy: () => this.handleDataPolicy(), onExpand: () => this.handleExpand(), onDownload: () => this.handleDownload(), onRestart: () => this.handleRestart(), onDelete: () => this.handleDelete(), onMinimize: () => this.minimizeWidget(), }); } // Setup input event listeners if (this.chatInput) { this.chatInput.setupEventListeners({ onSend: () => this.sendMessage(), onKeyPress: e => this.handleKeyPress(e), }); } // Setup resize listener for table overflow rechecking this.setupResizeListener(); // Setup outside click handler for closing widget this.setupOutsideClickHandler(); } toggleWidget() { const widget = this.widget.querySelector('.besper-widget'); this.state.isOpen = !this.state.isOpen; if (this.state.isOpen) { widget.classList.remove('minimized'); widget.classList.add('open'); this.state.isMinimized = false; // Focus input when opening if (this.chatInput) { setTimeout(() => this.chatInput.focus(), 100); } } else { widget.classList.remove('open'); widget.classList.add('minimized'); this.state.isMinimized = true; } } minimizeWidget() { const widget = this.widget.querySelector('.besper-widget'); this.state.isOpen = false; this.state.isMinimized = true; widget.classList.remove('open'); widget.classList.add('minimized'); } async sendMessage() { if (!this.chatInput) return; const message = this.chatInput.getMessage(); if (!message || this.state.typing) return; // Add user message this.chatMessages.addMessage(message, 'user'); this.chatInput.clearInput(); // Show typing indicator this.state.typing = true; this.typingIndicator.show(); try { const response = await this.generateResponse(message); this.typingIndicator.hide(); this.chatMessages.addMessage(response, 'bot'); } catch (error) { this.typingIndicator.hide(); console.error('Message send error:', error); this.chatMessages.addMessage( 'Sorry, I encountered an error. Please try again.', 'bot' ); } finally { this.state.typing = false; } } async generateResponse(message) { if (!this.state.isConnected) { throw new Error('Chat widget is not connected'); } const data = await operationsApiCall( 'generate_response', 'POST', { message, threadId: this.state.threadId, language: this.state.userLanguage, }, this.options.environment ); if (!data.success) { throw new Error(`Response generation failed: ${data.error}`); } return data.payload.response; } // Handler methods handleDataPolicy() { if (this.state.botConfig?.dataPolicyUrl) { window.open(this.state.botConfig.dataPolicyUrl, '_blank'); } } handleExpand() { this.toggleExpandedView(); } handleDownload() { if (this.chatMessages) { const conversation = this.chatMessages.exportConversation(); const blob = new Blob([conversation], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `conversation-${new Date().toISOString().split('T')[0]}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } } handleRestart() { if ( confirm( 'Are you sure you want to restart the conversation? This will clear all messages.' ) ) { this.restartConversation(); } } handleDelete() { if ( confirm( 'Are you sure you want to delete this conversation? This action cannot be undone.' ) ) { this.deleteConversation(); } } toggleExpandedView() { this.state.viewState = this.state.viewState === 'normal' ? 'expanded' : 'normal'; if (this.chatMessages) { this.chatMessages.updateViewState(this.state.viewState); } if (this.chatHeader) { this.chatHeader.updateExpandButton(); } } restartConversation() { if (this.chatMessages) { this.chatMessages.clearMessages(); this.chatMessages.addWelcomeMessage(); } // Note: You might want to also reset the thread ID here } deleteConversation() { if (this.chatMessages) { this.chatMessages.clearMessages(); } // Note: You might want to call an API to delete the conversation on the server } // Utility methods isMobileDevice() { return ( window.innerWidth <= 480 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent ) ); } setupResizeListener() { // Setup resize listener for table overflow rechecking - implementation would be here } setupOutsideClickHandler() { // Setup outside click handler - implementation would be here } adjustMobileLayout() { // Mobile layout adjustments - implementation would be here } applySessionConfiguration() { // Apply styling and configuration after session is created console.log('📋 Applying session configuration...'); // Update widget HTML with the new configuration this.updateWidgetWithConfig(); // Apply any custom styling if (this.state.styling) { // Additional styling logic could go here } } updateWidgetWithConfig() { if (!this.widget) { return; } // Update the widget logo if it exists const widgetIcon = this.widget.querySelector('.besper-widget-icon'); if (widgetIcon && this.state.botConfig?.logoUrl) { widgetIcon.innerHTML = `<img src="${this.state.botConfig.logoUrl}" alt="Bot Logo" class="besper-widget-logo">`; } // Update chat header logo and title if they exist const chatLogo = this.widget.querySelector('.besper-chat-logo'); const chatTitle = this.widget.querySelector('.besper-chat-title'); if (this.state.botConfig?.logoUrl && !chatLogo) { // Add logo if it doesn't exist const titleGroup = this.widget.querySelector('.besper-chat-title-group'); if (titleGroup) { const logoDiv = document.createElement('div'); logoDiv.className = 'besper-chat-logo'; logoDiv.innerHTML = `<img src="${this.state.botConfig.logoUrl}" alt="Bot Logo" class="besper-chat-logo-img">`; titleGroup.insertBefore(logoDiv, titleGroup.firstChild); } } else if (chatLogo && this.state.botConfig?.logoUrl) { // Update existing logo chatLogo.innerHTML = `<img src="${this.state.botConfig.logoUrl}" alt="Bot Logo" class="besper-chat-logo-img">`; } if (chatTitle && this.state.botConfig?.botTitle) { chatTitle.textContent = this.state.botConfig.botTitle; } } applyDefaultConfiguration(sessionError = null) { // Apply default configuration when session creation fails console.log('📋 Applying default configuration...'); // Set fallback thread ID for demo mode this.state.threadId = 'demo-' + Date.now(); this.state.isConnected = false; // Apply default styling matching original implementation this.state.styling = { primary_color: '#5897de', secondary_color: '#022d54', user_message_color: '#5897de', bot_message_color: '#f0f4f8', font_family: 'Segoe UI', primaryColor: '#5897de', // Also set camelCase for compatibility }; // Determine welcome message based on error type let welcomeMessage = 'Demo mode: API connection failed. This is a demo interface.'; // Check if the error is a 403 (origin not allowed) if ( sessionError && sessionError.message && sessionError.message.includes('403') ) { welcomeMessage = 'This origin is not allowed to access this bot.'; } // Apply default botConfig matching original implementation this.state.botConfig = { botName: 'Demo Assistant', botTitle: 'Demo Mode', welcomeMessage, dataPolicyUrl: null, logoUrl: null, }; this.state.userLanguage = this.state.userLanguage || 'en'; console.warn('[WARN] Running in demo mode'); } getStyles() { // Return embedded styles - would include all the CSS from the original file return `<style>/* Styles would be included here */</style>`; } // Public API methods show() { if (!this.state.isOpen) { this.toggleWidget(); } } hide() { if (this.state.isOpen) { this.minimizeWidget(); } } destroy() { if (this.chatInput) { this.chatInput.destroy(); } if (this.typingIndicator) { this.typingIndicator.destroy(); } if (this.widget && this.widget.parentNode) { this.widget.parentNode.removeChild(this.widget); } } }