UNPKG

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
/** * 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