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
JavaScript
/**
* 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);
}
}
}