bettercx-widget
Version:
Professional AI-powered chat widget for BetterCX platform. Seamlessly integrate intelligent customer support into any website.
584 lines (583 loc) • 23.1 kB
JavaScript
import { Host, h } from "@stencil/core";
import { AuthService } from "../../services/auth.service";
import { ApiService } from "../../services/api.service";
import { ThemeService } from "../../services/theme.service";
export class BetterCXWidget {
el;
// Simplified public properties - only essential ones
publicKey;
theme = 'auto';
debug = false;
baseUrl = 'https://dev-api.bettercx.ai';
aiServiceUrl = 'https://dev-ai.bettercx.ai';
autoInit = true;
position = 'right';
// Internal state
state = {
isOpen: false,
isAuthenticated: false,
isLoading: true,
messages: [],
isTyping: false,
};
// Language state
language = 'en';
// Services
authService;
apiService;
themeService;
// Refs
messagesContainerRef;
// Events
widgetEvent;
async onPublicKeyChange() {
if (this.publicKey) {
await this.initialize();
}
}
async componentWillLoad() {
if (this.publicKey && this.autoInit) {
await this.initialize();
}
}
async componentDidLoad() {
if (this.themeService) {
this.themeService.watchWebsiteTheme(() => {
this.themeService.setDefaultTheme();
});
}
}
async initialize() {
if (!this.publicKey) {
this.setState({ isLoading: false, isAuthenticated: false });
return;
}
try {
this.setState({ isLoading: true, error: undefined });
this.authService = new AuthService(this.baseUrl);
const origin = window.location.origin;
const sessionData = await this.authService.createSession(this.publicKey, origin);
this.apiService = new ApiService(this.baseUrl, this.aiServiceUrl, this.authService);
this.themeService = new ThemeService(this.el);
this.language = await this.themeService.detectWebsiteLanguage();
this.themeService.setDefaultTheme();
if ('attrs' in sessionData && sessionData.attrs) {
this.applyCustomColors(sessionData.attrs);
this.applyColorsToMessageComposer();
}
this.setState({
isAuthenticated: true,
exampleQuestions: ('example_questions' in sessionData ? sessionData.example_questions : []),
});
this.emitEvent('session-created', { origin });
this.setState({ isLoading: false });
this.setState({ isOpen: false });
}
catch (error) {
this.setState({
isLoading: false,
isAuthenticated: false,
});
this.emitEvent('error', { error: error.message });
}
}
async open() {
if (this.state.isAuthenticated) {
this.setState({ isOpen: true });
this.emitEvent('opened');
this.applyColorsToMessageComposer();
setTimeout(() => {
this.scrollToBottom(false);
}, 100);
}
}
async close() {
this.setState({ isOpen: false });
this.emitEvent('closed');
}
async toggle() {
if (this.state.isOpen) {
await this.close();
}
else {
await this.open();
}
}
async sendMessage(content) {
if (!this.state.isAuthenticated || !content.trim()) {
return;
}
const userMessage = {
content: content.trim(),
author: 'user',
timestamp: new Date().toISOString(),
id: this.generateId(),
};
this.setState({
messages: [...this.state.messages, userMessage],
isTyping: true,
});
setTimeout(() => {
this.scrollToBottom(true);
}, 50);
this.emitEvent('message-sent', userMessage);
try {
const stream = await this.apiService.sendMessage(content);
if (stream) {
let assistantMessage = null;
let isStreamingStarted = false;
const streamParser = await this.apiService.parseStreamResponse(stream);
for await (const chunk of streamParser) {
if (chunk.type === 'streaming_output') {
if (!isStreamingStarted) {
assistantMessage = {
content: '',
author: 'assistant',
timestamp: new Date().toISOString(),
id: this.generateId(),
};
this.setState({
messages: [...this.state.messages, assistantMessage],
isTyping: false,
});
isStreamingStarted = true;
}
if (assistantMessage) {
assistantMessage.content += chunk.content;
this.setState({
messages: [...this.state.messages.slice(0, -1), { ...assistantMessage }],
});
}
}
else {
if (!isStreamingStarted) {
this.setState({ isTyping: true });
}
}
}
if (assistantMessage) {
this.emitEvent('message-received', assistantMessage);
}
}
}
catch (error) {
this.setState({ error: 'Failed to send message' });
this.emitEvent('error', { error: error.message });
}
finally {
this.setState({ isTyping: false });
}
}
setState(updates) {
const previousMessages = this.state.messages;
this.state = { ...this.state, ...updates };
if (updates.messages && updates.messages !== previousMessages) {
setTimeout(() => {
this.scrollToBottom(true);
}, 0);
}
}
emitEvent(type, data) {
const event = {
type,
data,
timestamp: new Date().toISOString(),
};
this.widgetEvent.emit(event);
}
generateId() {
return Math.random().toString(36).substr(2, 9);
}
getTranslation(key) {
const translations = {
common_questions: {
en: 'Common questions',
pl: 'Często zadawane pytania',
},
message_placeholder: {
en: 'Type your message...',
pl: 'Wpisz swoją wiadomość...',
},
};
return translations[key]?.[this.language] || translations[key]?.['en'] || key;
}
scrollToBottom(smooth = true) {
if (this.messagesContainerRef) {
this.messagesContainerRef.scrollTo({
top: this.messagesContainerRef.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
}
}
handleToggleClick = () => {
if (this.state.isOpen) {
this.close();
}
else {
this.open();
}
};
handleMessageSubmit = (event) => {
this.sendMessage(event.detail);
};
applyCustomColors(attrs) {
const currentTheme = this.themeService.getCurrentTheme();
const colorMode = currentTheme === 'dark' ? attrs.dark_mode : attrs.light_mode;
if (colorMode && typeof colorMode === 'object') {
this.el.style.setProperty('--bcx-primary', String(colorMode.primary_color || ''));
this.el.style.setProperty('--bcx-secondary', String(colorMode.secondary_color || ''));
this.el.style.setProperty('--bcx-background', String(colorMode.background_color || ''));
this.el.style.setProperty('--bcx-text', String(colorMode.text_color || ''));
document.documentElement.style.setProperty('--bcx-primary', String(colorMode.primary_color || ''));
document.documentElement.style.setProperty('--bcx-secondary', String(colorMode.secondary_color || ''));
document.documentElement.style.setProperty('--bcx-background', String(colorMode.background_color || ''));
document.documentElement.style.setProperty('--bcx-text', String(colorMode.text_color || ''));
}
}
applyColorsToMessageComposer() {
setTimeout(() => {
const messageComposer = this.el.querySelector('bcx-message-composer');
if (messageComposer) {
const primaryColor = this.el.style.getPropertyValue('--bcx-primary') || '#007bff';
const secondaryColor = this.el.style.getPropertyValue('--bcx-secondary') || '#6c757d';
const backgroundColor = this.el.style.getPropertyValue('--bcx-background') || '#ffffff';
const textColor = this.el.style.getPropertyValue('--bcx-text') || '#212529';
messageComposer.style.setProperty('--bcx-primary', primaryColor);
messageComposer.style.setProperty('--bcx-secondary', secondaryColor);
messageComposer.style.setProperty('--bcx-background', backgroundColor);
messageComposer.style.setProperty('--bcx-text', textColor);
}
}, 100);
}
handleExampleQuestionClick = (question) => {
const userMessage = {
content: question.question_text,
author: 'user',
timestamp: new Date().toISOString(),
id: this.generateId(),
};
this.setState({
messages: [...this.state.messages, userMessage],
});
this.simulateTypingResponse(question.answer_text);
};
simulateTypingResponse(fullText) {
const assistantMessage = {
content: '',
author: 'assistant',
timestamp: new Date().toISOString(),
id: this.generateId(),
};
this.setState({
messages: [...this.state.messages, assistantMessage],
isTyping: false,
});
let currentText = '';
let index = 0;
const typingSpeed = 25;
const typeNextCharacter = () => {
if (index < fullText.length) {
currentText += fullText[index];
index++;
const updatedMessage = { ...assistantMessage, content: currentText };
const updatedMessages = [...this.state.messages];
updatedMessages[updatedMessages.length - 1] = updatedMessage;
this.setState({
messages: updatedMessages,
});
setTimeout(typeNextCharacter, typingSpeed);
}
else {
this.emitEvent('message-received', assistantMessage);
}
};
setTimeout(typeNextCharacter, 200);
}
render() {
if (!this.state.isAuthenticated) {
return null;
}
if (this.state.isLoading) {
return (h(Host, { class: "bcx-widget bcx-widget--loading" }, h("div", { class: "bcx-widget__loading" }, h("div", { class: "bcx-widget__spinner" }), h("span", null, "Loading..."))));
}
return (h(Host, { class: `bcx-widget ${this.state.isOpen ? 'bcx-widget--open' : ''} bcx-widget--${this.position}` }, h("button", { class: "bcx-widget__toggle", onClick: this.handleToggleClick, "aria-label": this.state.isOpen ? 'Close chat' : 'Open chat' }, h("span", { class: "bcx-widget__toggle-icon" }, this.state.isOpen ? (h("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), h("line", { x1: "6", y1: "6", x2: "18", y2: "18" }))) : (h("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" }))))), this.state.isOpen && (h("div", { class: "bcx-widget__chat" }, h("div", { class: "bcx-widget__header" }, h("h3", null, "Chat AI"), h("button", { class: "bcx-widget__close", onClick: () => this.close(), "aria-label": "Close chat" }, h("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }, h("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), h("line", { x1: "6", y1: "6", x2: "18", y2: "18" })))), h("div", { class: "bcx-widget__messages", ref: el => (this.messagesContainerRef = el) }, this.state.messages.map(message => (h("div", { key: message.id, class: `bcx-widget__message bcx-widget__message--${message.author}` }, h("div", { class: "bcx-widget__message-content" }, message.content), h("div", { class: "bcx-widget__message-time" }, new Date(message.timestamp).toLocaleTimeString())))), this.state.messages.length === 0 && this.state.exampleQuestions && this.state.exampleQuestions.length > 0 && (h("div", { class: "bcx-widget__example-questions" }, h("div", { class: "bcx-widget__example-questions-title" }, this.getTranslation('common_questions')), this.state.exampleQuestions.slice(0, 3).map(question => (h("button", { key: question.id, class: "bcx-widget__example-question", onClick: () => this.handleExampleQuestionClick(question) }, question.question_text))))), this.state.isTyping && (h("div", { class: "bcx-widget__typing" }, h("div", { class: "bcx-widget__typing-indicator" }, h("span", null), h("span", null), h("span", null))))), h("div", { class: "bcx-widget__composer" }, h("bcx-message-composer", { onMessageSubmit: this.handleMessageSubmit, disabled: this.state.isTyping, loading: this.state.isTyping, placeholder: this.getTranslation('message_placeholder') }))))));
}
static get is() { return "bettercx-widget"; }
static get encapsulation() { return "shadow"; }
static get originalStyleUrls() {
return {
"$": ["bettercx-widget.scss"]
};
}
static get styleUrls() {
return {
"$": ["bettercx-widget.css"]
};
}
static get properties() {
return {
"publicKey": {
"type": "string",
"attribute": "public-key",
"mutable": false,
"complexType": {
"original": "string",
"resolved": "string",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": ""
},
"getter": false,
"setter": false,
"reflect": false
},
"theme": {
"type": "string",
"attribute": "theme",
"mutable": false,
"complexType": {
"original": "'light' | 'dark' | 'auto'",
"resolved": "\"auto\" | \"dark\" | \"light\"",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": ""
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'auto'"
},
"debug": {
"type": "boolean",
"attribute": "debug",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": ""
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "false"
},
"baseUrl": {
"type": "string",
"attribute": "base-url",
"mutable": false,
"complexType": {
"original": "string",
"resolved": "string",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": ""
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'https://dev-api.bettercx.ai'"
},
"aiServiceUrl": {
"type": "string",
"attribute": "ai-service-url",
"mutable": false,
"complexType": {
"original": "string",
"resolved": "string",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": ""
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'https://dev-ai.bettercx.ai'"
},
"autoInit": {
"type": "boolean",
"attribute": "auto-init",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": ""
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "true"
},
"position": {
"type": "string",
"attribute": "position",
"mutable": false,
"complexType": {
"original": "'left' | 'right'",
"resolved": "\"left\" | \"right\"",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": ""
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'right'"
}
};
}
static get states() {
return {
"state": {},
"language": {}
};
}
static get events() {
return [{
"method": "widgetEvent",
"name": "widgetEvent",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [],
"text": ""
},
"complexType": {
"original": "WidgetEvent",
"resolved": "WidgetEvent",
"references": {
"WidgetEvent": {
"location": "import",
"path": "../../types/api",
"id": "src/types/api.ts::WidgetEvent"
}
}
}
}];
}
static get methods() {
return {
"open": {
"complexType": {
"signature": "() => Promise<void>",
"parameters": [],
"references": {
"Promise": {
"location": "global",
"id": "global::Promise"
}
},
"return": "Promise<void>"
},
"docs": {
"text": "",
"tags": []
}
},
"close": {
"complexType": {
"signature": "() => Promise<void>",
"parameters": [],
"references": {
"Promise": {
"location": "global",
"id": "global::Promise"
}
},
"return": "Promise<void>"
},
"docs": {
"text": "",
"tags": []
}
},
"toggle": {
"complexType": {
"signature": "() => Promise<void>",
"parameters": [],
"references": {
"Promise": {
"location": "global",
"id": "global::Promise"
}
},
"return": "Promise<void>"
},
"docs": {
"text": "",
"tags": []
}
},
"sendMessage": {
"complexType": {
"signature": "(content: string) => Promise<void>",
"parameters": [{
"name": "content",
"type": "string",
"docs": ""
}],
"references": {
"Promise": {
"location": "global",
"id": "global::Promise"
},
"ChatMessage": {
"location": "import",
"path": "../../types/api",
"id": "src/types/api.ts::ChatMessage"
},
"Record": {
"location": "global",
"id": "global::Record"
}
},
"return": "Promise<void>"
},
"docs": {
"text": "",
"tags": []
}
}
};
}
static get elementRef() { return "el"; }
static get watchers() {
return [{
"propName": "publicKey",
"methodName": "onPublicKeyChange"
}];
}
}
//# sourceMappingURL=bettercx-widget.js.map