bettercx-widget
Version:
Professional AI-powered chat widget for BetterCX platform. Seamlessly integrate intelligent customer support into any website.
676 lines (675 loc) • 23.5 kB
JavaScript
/**
* Theme Service
* Handles widget theming and CSS custom properties
*/
export class ThemeService {
hostElement;
currentTheme = 'auto';
constructor(hostElement) {
this.hostElement = hostElement;
}
/**
* Detect the language of the website the widget is embedded on
* Returns 'pl' for Polish, 'en' for English (default)
*/
async detectWebsiteLanguage() {
try {
// Method 1: Check meta http-equiv="content-language"
const metaLang = document.querySelector('meta[http-equiv="content-language"]');
if (metaLang) {
const content = metaLang.getAttribute('content')?.toLowerCase();
if (content?.includes('pl'))
return 'pl';
if (content?.includes('en'))
return 'en';
}
// Method 2: Check meta name="language"
const metaNameLang = document.querySelector('meta[name="language"]');
if (metaNameLang) {
const content = metaNameLang.getAttribute('content')?.toLowerCase();
if (content?.includes('pl'))
return 'pl';
if (content?.includes('en'))
return 'en';
}
// Method 3: Check for Polish text patterns in the page (improved)
const bodyText = document.body.textContent?.toLowerCase() || '';
// More comprehensive Polish word detection
const polishWords = [
// Common Polish words
'i',
'w',
'na',
'z',
'do',
'od',
'po',
'przy',
'dla',
'przez',
'bez',
'pod',
'nad',
'między',
'przed',
'za',
// Polish articles and pronouns
'jest',
'są',
'ma',
'mają',
'być',
'może',
'można',
'możemy',
'można',
'możemy',
// Polish common verbs
'jest',
'są',
'ma',
'mają',
'być',
'może',
'można',
'możemy',
'można',
'możemy',
// Polish common nouns
'strona',
'stronie',
'strony',
'stron',
'stroną',
'stronę',
'stronie',
'strony',
'stron',
'stroną',
'stronę',
'informacje',
'informacji',
'informacjami',
'informacjami',
'informacjami',
'informacjami',
'informacjami',
'kontakt',
'kontakcie',
'kontaktu',
'kontaktem',
'kontaktem',
'kontaktem',
'kontaktem',
'kontaktem',
// Polish common adjectives
'nowy',
'nowa',
'nowe',
'nowego',
'nowej',
'nowego',
'nowym',
'nową',
'nowym',
'nowym',
'dobry',
'dobra',
'dobre',
'dobrego',
'dobrej',
'dobrego',
'dobrym',
'dobrą',
'dobrym',
'dobrym',
// Polish common conjunctions
'oraz',
'lub',
'ale',
'jednak',
'więc',
'dlatego',
'ponieważ',
'gdy',
'gdyż',
'jeśli',
'jeżeli',
// Polish common prepositions
'w',
'na',
'z',
'do',
'od',
'po',
'przy',
'dla',
'przez',
'bez',
'pod',
'nad',
'między',
'przed',
'za',
// Polish common particles
'nie',
'już',
'jeszcze',
'tylko',
'także',
'również',
'też',
'tak',
'nie',
'już',
'jeszcze',
'tylko',
'także',
'również',
'też',
'tak',
];
// Count Polish words with better matching
const polishWordCount = polishWords.filter(word => {
// Look for word boundaries to avoid false positives
const regex = new RegExp(`\\b${word}\\b`, 'gi');
return regex.test(bodyText);
}).length;
// Lower threshold for detection
if (polishWordCount >= 2) {
return 'pl';
}
// Method 4: Check for Polish characters (ą, ć, ę, ł, ń, ó, ś, ź, ż)
const polishChars = /[ąćęłńóśźż]/gi;
const polishCharCount = (bodyText.match(polishChars) || []).length;
if (polishCharCount >= 5) {
return 'pl';
}
// Method 5: Check navigator.language as fallback
const browserLang = navigator.language.toLowerCase().split('-')[0];
if (browserLang === 'pl')
return 'pl';
// Method 6: Check for common Polish website patterns
const polishPatterns = [
'www.',
'.pl',
'strona główna',
'o nas',
'kontakt',
'oferta',
'usługi',
'produkty',
'cennik',
'galeria',
'aktualności',
'news',
'blog',
'pomoc',
'faq',
'regulamin',
'polityka prywatności',
'cookies',
];
const polishPatternCount = polishPatterns.filter(pattern => bodyText.includes(pattern.toLowerCase())).length;
if (polishPatternCount >= 2) {
return 'pl';
}
// Method 7: Try to use Google Translate API for detection (optional)
try {
const detectedLang = await this.detectLanguageWithGoogleTranslate(bodyText.substring(0, 1000));
if (detectedLang) {
if (detectedLang === 'pl')
return 'pl';
if (detectedLang === 'en')
return 'en';
}
}
catch (error) {
console.error('Error detecting language with online translation:', error);
}
// Method 8: Check HTML lang attribute (highest priority)
const htmlLang = document.documentElement.lang;
if (htmlLang) {
const langCode = htmlLang.toLowerCase().split('-')[0];
if (langCode === 'pl')
return 'pl';
if (langCode === 'en')
return 'en';
}
return 'en';
}
catch {
return 'en';
}
}
/**
* Detect language using Google Translate API (fallback method)
*/
async detectLanguageWithGoogleTranslate(text) {
try {
// Use Google Translate's language detection API
const response = await fetch(`https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=en&dt=t&q=${encodeURIComponent(text)}`);
if (!response.ok) {
throw new Error('Google Translate API request failed');
}
const data = await response.json();
const detectedLang = data[2]; // The detected language code
return detectedLang;
}
catch {
return null;
}
}
/**
* Apply widget configuration to CSS custom properties
*/
applyConfig(config, theme = 'auto') {
this.currentTheme = theme;
const resolvedTheme = this.resolveTheme(theme);
const colorConfig = resolvedTheme === 'dark' ? config.dark_mode : config.light_mode;
if (colorConfig) {
this.applyColorConfig(colorConfig);
}
}
/**
* Apply color configuration to CSS custom properties
*/
applyColorConfig(config) {
const properties = {
'--bcx-primary': config.primary_color,
'--bcx-secondary': config.secondary_color,
'--bcx-background': config.background_color,
'--bcx-text': config.text_color,
};
Object.entries(properties).forEach(([property, value]) => {
if (value) {
this.hostElement.style.setProperty(property, value);
}
});
}
/**
* Resolve theme based on preference and system settings
*/
resolveTheme(theme) {
if (theme === 'auto') {
return this.detectWebsiteColorScheme();
}
return theme;
}
/**
* Universal color scheme detection for any website
* Checks multiple methods in order of reliability
*/
detectWebsiteColorScheme() {
try {
// Method 1: Check CSS custom properties (most reliable for modern sites)
const cssTheme = this.detectCSSTheme();
if (cssTheme)
return cssTheme;
// Method 2: Check data attributes (common in frameworks)
const dataTheme = this.detectDataTheme();
if (dataTheme)
return dataTheme;
// Method 3: Check class names on html/body
const classTheme = this.detectClassTheme();
if (classTheme)
return classTheme;
// Method 4: Check meta theme-color
const metaTheme = this.detectMetaTheme();
if (metaTheme)
return metaTheme;
// Method 5: Check computed styles of body/html
const computedTheme = this.detectComputedTheme();
if (computedTheme)
return computedTheme;
// No system preference fallback - default to light mode
return 'light';
}
catch {
return 'light';
}
}
/**
* Method 1: Detect CSS custom properties
* Checks for common CSS variables used by frameworks
*/
detectCSSTheme() {
const root = document.documentElement;
const computedStyle = window.getComputedStyle(root);
// Check common CSS custom properties
const cssVars = ['--color-scheme', '--theme', '--mode', '--color-mode', '--dark-mode', '--light-mode', '--app-theme', '--ui-theme'];
for (const varName of cssVars) {
const value = computedStyle.getPropertyValue(varName).trim();
if (value) {
if (value.includes('dark') || value === 'dark')
return 'dark';
if (value.includes('light') || value === 'light')
return 'light';
}
}
// Check for dark mode indicators in CSS variables
const darkIndicators = ['--bg-color', '--background-color', '--primary-bg', '--surface-color'];
for (const varName of darkIndicators) {
const value = computedStyle.getPropertyValue(varName).trim();
if (value) {
// Check if it's a dark color (basic heuristic)
if (this.isDarkColor(value))
return 'dark';
if (this.isLightColor(value))
return 'light';
}
}
return null;
}
/**
* Method 2: Detect data attributes
* Common in frameworks like Next.js, Nuxt.js, etc.
*/
detectDataTheme() {
const elements = [document.documentElement, document.body];
const dataAttrs = ['data-theme', 'data-mode', 'data-color-scheme', 'data-color-mode'];
for (const element of elements) {
if (!element)
continue;
for (const attr of dataAttrs) {
const value = element.getAttribute(attr);
if (value) {
if (value.includes('dark') || value === 'dark')
return 'dark';
if (value.includes('light') || value === 'light')
return 'light';
}
}
}
return null;
}
/**
* Method 3: Detect class names
* Common in Tailwind, Bootstrap, and other frameworks
*/
detectClassTheme() {
const elements = [document.documentElement, document.body];
const darkClasses = ['dark', 'dark-mode', 'theme-dark', 'dark-theme', 'is-dark', 'dark-theme', 'night-mode'];
for (const element of elements) {
if (!element)
continue;
const classList = element.classList;
for (const className of darkClasses) {
if (classList.contains(className))
return 'dark';
}
}
return null;
}
/**
* Method 4: Detect meta theme-color
* Some sites use meta theme-color to indicate dark mode
*/
detectMetaTheme() {
const metaTheme = document.querySelector('meta[name="theme-color"]');
if (metaTheme) {
const content = metaTheme.getAttribute('content');
if (content && this.isDarkColor(content))
return 'dark';
if (content && this.isLightColor(content))
return 'light';
}
return null;
}
/**
* Method 5: Detect computed styles
* Analyze background and text colors of the page
*/
detectComputedTheme() {
try {
const body = document.body;
if (!body)
return null;
const computedStyle = window.getComputedStyle(body);
const bgColor = computedStyle.backgroundColor;
const textColor = computedStyle.color;
// If we can't get colors, skip this method
if (!bgColor || bgColor === 'rgba(0, 0, 0, 0)' || !textColor)
return null;
const isDarkBg = this.isDarkColor(bgColor);
const isDarkText = this.isDarkColor(textColor);
// If background is dark and text is light, it's dark mode
if (isDarkBg && !isDarkText)
return 'dark';
// If background is light and text is dark, it's light mode
if (!isDarkBg && isDarkText)
return 'light';
return null;
}
catch {
return null;
}
}
/**
* Helper: Check if a color is dark
*/
isDarkColor(color) {
try {
// Convert to RGB values
const rgb = this.parseColor(color);
if (!rgb)
return false;
// Calculate luminance
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
return luminance < 0.5;
}
catch {
return false;
}
}
/**
* Helper: Check if a color is light
*/
isLightColor(color) {
try {
const rgb = this.parseColor(color);
if (!rgb)
return false;
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
return luminance > 0.7;
}
catch {
return false;
}
}
/**
* Helper: Parse color string to RGB
*/
parseColor(color) {
// Remove whitespace
color = color.trim();
// Handle hex colors
if (color.startsWith('#')) {
const hex = color.slice(1);
if (hex.length === 3) {
return {
r: parseInt(hex[0] + hex[0], 16),
g: parseInt(hex[1] + hex[1], 16),
b: parseInt(hex[2] + hex[2], 16),
};
}
else if (hex.length === 6) {
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}
}
// Handle rgb/rgba colors
const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbMatch) {
return {
r: parseInt(rgbMatch[1]),
g: parseInt(rgbMatch[2]),
b: parseInt(rgbMatch[3]),
};
}
// Handle hsl colors (basic conversion)
const hslMatch = color.match(/hsla?\((\d+),\s*(\d+)%,\s*(\d+)%/);
if (hslMatch) {
const h = parseInt(hslMatch[1]) / 360;
const s = parseInt(hslMatch[2]) / 100;
const l = parseInt(hslMatch[3]) / 100;
const hue2rgb = (p, q, t) => {
if (t < 0)
t += 1;
if (t > 1)
t -= 1;
if (t < 1 / 6)
return p + (q - p) * 6 * t;
if (t < 1 / 2)
return q;
if (t < 2 / 3)
return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return {
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
g: Math.round(hue2rgb(p, q, h) * 255),
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
};
}
return null;
}
/**
* Set default theme colors
*/
setDefaultTheme() {
const defaultColors = {
'--bcx-primary': '#007bff',
'--bcx-secondary': '#6c757d',
'--bcx-background': '#ffffff',
'--bcx-text': '#212529',
'--bcx-border': '#dee2e6',
'--bcx-shadow': 'rgba(0, 0, 0, 0.1)',
'--bcx-success': '#28a745',
'--bcx-warning': '#ffc107',
'--bcx-error': '#dc3545',
'--bcx-info': '#17a2b8',
};
Object.entries(defaultColors).forEach(([property, value]) => {
this.hostElement.style.setProperty(property, value);
});
// Also apply to document root for inheritance
Object.entries(defaultColors).forEach(([property, value]) => {
document.documentElement.style.setProperty(property, value);
});
}
/**
* Apply custom CSS properties from host page
*/
applyCustomProperties(customProperties) {
Object.entries(customProperties).forEach(([property, value]) => {
this.hostElement.style.setProperty(property, value);
});
}
/**
* Listen for system theme changes
*/
watchSystemTheme(callback) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => {
callback(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
// Return cleanup function
return () => {
mediaQuery.removeEventListener('change', handler);
};
}
/**
* Watch for website theme changes dynamically
* This is useful for sites that change themes without page reload
*/
watchWebsiteTheme(callback) {
// Check if MutationObserver is available (not available in test environment)
if (typeof MutationObserver === 'undefined') {
return () => { };
}
const observers = [];
// Watch for class changes on html and body
const watchClassChanges = () => {
const elements = [document.documentElement, document.body];
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const newTheme = this.detectWebsiteColorScheme();
callback(newTheme);
}
});
});
elements.forEach(element => {
if (element) {
observer.observe(element, { attributes: true, attributeFilter: ['class'] });
}
});
observers.push(() => observer.disconnect());
};
// Watch for data attribute changes
const watchDataChanges = () => {
const elements = [document.documentElement, document.body];
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && ['data-theme', 'data-mode', 'data-color-scheme'].includes(mutation.attributeName)) {
const newTheme = this.detectWebsiteColorScheme();
callback(newTheme);
}
});
});
elements.forEach(element => {
if (element) {
observer.observe(element, {
attributes: true,
attributeFilter: ['data-theme', 'data-mode', 'data-color-scheme'],
});
}
});
observers.push(() => observer.disconnect());
};
// Watch for CSS custom property changes
const watchCSSChanges = () => {
const root = document.documentElement;
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
const newTheme = this.detectWebsiteColorScheme();
callback(newTheme);
}
});
});
if (root) {
observer.observe(root, { attributes: true, attributeFilter: ['style'] });
}
observers.push(() => observer.disconnect());
};
// Start all watchers
watchClassChanges();
watchDataChanges();
watchCSSChanges();
// Return cleanup function
return () => {
observers.forEach(cleanup => cleanup());
};
}
/**
* Get current detected theme
*/
getCurrentDetectedTheme() {
return this.detectWebsiteColorScheme();
}
/**
* Get current applied theme
*/
getCurrentTheme() {
return this.currentTheme === 'auto' ? this.detectWebsiteColorScheme() : this.currentTheme;
}
}
//# sourceMappingURL=theme.service.js.map