/** * Echo Widget SDK * Embeddable AI-powered chat widget for e-commerce * * @version 1.0.0 * @license MIT * @see https://docs.echo.dev */ (() => { // ============================================================================ // SDK URL OVERRIDE (must be first - before any other code) // ============================================================================ try { const lsData = JSON.parse(localStorage.getItem("echo_ls") || "{}"); const customSdkUrl = lsData.debug?.sdkUrl; if (customSdkUrl && !window.__echoSdkLoaded) { window.__echoSdkLoaded = true; const script = document.createElement("script"); script.src = customSdkUrl; script.onerror = () => { console.error("[EchoWidget] Failed to load custom SDK:", customSdkUrl); // Clear the bad URL so next refresh uses production try { const data = JSON.parse(localStorage.getItem("echo_ls") || "{}"); if (data.debug) { data.debug.sdkUrl = undefined; localStorage.setItem("echo_ls", JSON.stringify(data)); } } catch { // localStorage not available } }; document.head.appendChild(script); console.info("[EchoWidget] Loading custom SDK:", customSdkUrl); return; // Stop execution - custom SDK will take over } window.__echoSdkLoaded = true; } catch { // Silent fail - continue with normal SDK window.__echoSdkLoaded = true; } // ============================================================================ // INITIALIZATION GUARD // ============================================================================ if (window.EchoWidget) { console.warn("[EchoWidget] Already initialized"); return; } // ============================================================================ // CONFIGURATION // ============================================================================ /** * Enable debug logging * @type {boolean} * @internal */ const DEBUG = false; // ============================================================================ // REGEX PATTERNS (module-level within IIFE for performance) // ============================================================================ // biome-ignore lint: IIFE module scope is effectively top-level const TABLET_REGEX = /tablet|ipad|playbook|silk/i; // biome-ignore lint: IIFE module scope is effectively top-level const MOBILE_REGEX = /mobile|iphone|ipod|android|blackberry|opera mini|iemobile/i; // ============================================================================ // TYPE DEFINITIONS // ============================================================================ /** * @typedef {Object} EchoWidgetConfig * @property {string} apiKey - Required. Partner API key for tenant identification * @property {string} [apiUrl] - Base URL for the Echo API. Defaults to cloud URL or script origin * @property {string} [userEmail] - Optional. User's email address for identification * @property {string} [userIdentifier] - Optional. Partner's custom user ID for identification * @property {'bottom-right'|'bottom-left'} [position='bottom-right'] - Widget position on screen * @property {boolean} [enableProactiveAgent=false] - Enable proactive suggestions based on browsing * @property {EchoThemeConfig} [theme] - Theme customization options * @property {Function} [onNavigate] - Callback when navigation is triggered * @property {Function} [onOpen] - Callback when widget opens * @property {Function} [onClose] - Callback when widget closes * @property {Function} [onMinimize] - Callback when widget minimizes * @property {Function} [onNewChat] - Callback when new chat starts * @property {Function} [onCustomAction] - Callback for custom actions * @property {Function} [onEvent] - Callback receiving all events when queue flushes. Event types: widget_open, widget_close, widget_minimize, widget_expand, widget_collapse, bubble_load, page_view, product_view, product_click, proactive_click, recommendation_show, recommendation_click, add_to_cart, purchase, new_chat, experiment_exposure */ /** * @typedef {Object} EchoThemeConfig * @property {string} [primaryColor='#b3f745'] - Primary brand color * @property {number} [buttonSize=56] - Size of the chat bubble button in pixels */ /** * @typedef {Object} EchoBranding * @property {string} displayName - Display name shown in widget header * @property {string} logoUrl - URL to logo image * @property {string} [iconUrl] - URL to icon for chat bubble * @property {string} subtitle - Subtitle text shown in header * @property {string} [primaryColor] - Primary brand color (message bubbles, icons) * @property {string} [primaryColorDark] - Primary brand color for dark mode * @property {string} [secondaryColorDark] - Secondary brand color for dark mode * @property {string} [secondaryColor] - Secondary brand color (text color) */ /** * @typedef {Object} AddToCartPayload * @property {string} productId - Product identifier * @property {number} [quantity=1] - Quantity to add */ /** * @typedef {Object} CallbackResult * @property {boolean} success - Whether the operation succeeded * @property {string} [error] - Error message if failed */ /** * @typedef {Object} CartResult * @property {boolean} success - Whether the operation succeeded * @property {Object} [cart] - Cart contents * @property {string} [error] - Error message if failed */ /** * @typedef {Object} CheckoutResult * @property {boolean} success - Whether the operation succeeded * @property {string} [redirectUrl] - URL to redirect to * @property {string} [error] - Error message if failed */ /** * @typedef {Object} OrderStateResult * @property {boolean} success - Whether the operation succeeded * @property {string} [status] - Order status * @property {Object} [details] - Order details * @property {string} [error] - Error message if failed */ /** * @typedef {Object} OrdersResult * @property {boolean} success - Whether the operation succeeded * @property {Array} [orders] - List of orders * @property {string} [error] - Error message if failed */ /** * @typedef {Object} PurchaseData * @property {string} transaction_id - Unique transaction identifier (required) * @property {Array} items - Purchased items (required, each with item_id and price) * @property {number} [value] - Total purchase value * @property {string} [currency] - Currency code (e.g., 'USD') * @property {string} [affiliation] - Store or affiliation name * @property {string} [coupon] - Coupon code used * @property {number} [shipping] - Shipping cost * @property {number} [tax] - Tax amount */ /** * @typedef {Object} PurchaseItem * @property {string} item_id - Product/SKU identifier (required) * @property {number} price - Item price (required) * @property {string} [item_name] - Product name * @property {number} [quantity=1] - Quantity purchased * @property {string} [item_brand] - Brand name * @property {string} [item_category] - Category name * @property {string} [item_variant] - Variant (size, color, etc.) */ /** * @typedef {Object} AddToCartTrackingData * @property {Array} items - Items added to cart (required, each with item_id and price) * @property {string} [currency] - Currency code (e.g., 'USD', 'TRY') * @property {number} [value] - Total value of items added */ /** * @typedef {Object} AddToCartItem * @property {string} item_id - Product/SKU identifier (required) * @property {number} price - Item price (required) * @property {string} [item_name] - Product name * @property {number} [quantity=1] - Quantity added * @property {string} [item_brand] - Brand name * @property {string} [item_category] - Category name * @property {string} [item_variant] - Variant (size, color, etc.) */ /** * @typedef {Object} ProductViewData * @property {string} productId - Product identifier */ /** * @typedef {Object} PageAccessConfig * @property {string[]} whitelistPages - Glob patterns for allowed pages * @property {string[]} blacklistPages - Glob patterns for blocked pages */ // ============================================================================ // CONSTANTS // ============================================================================ /** @internal */ const USER_ID_COOKIE_NAME = "echo_user_id"; /** @internal Cookie lifetime in days (2 years, matching GA4) */ const USER_ID_COOKIE_LIFETIME_DAYS = 730; /** @internal sessionStorage key for session-scoped state */ const ECHO_SS = "echo_ss"; /** @internal localStorage key for persistent state */ const ECHO_LS = "echo_ls"; /** @internal Event batch timeout in milliseconds */ const BATCH_TIMEOUT_MS = 30000; /** @internal Maximum events per batch */ const BATCH_SIZE_LIMIT = 25; /** @internal Regex for matching all paths */ // biome-ignore lint: SDK requires IIFE encapsulation for isolation const MATCH_ALL_REGEX = /^.*$/; /** @internal Regex for escaping special glob characters */ const GLOB_ESCAPE_REGEX = /[.+^${}()|[\]\\]/g; /** @internal Regex for matching double star glob pattern */ const GLOB_STAR_STAR_REGEX = /\*\*/g; /** @internal Regex for matching single star glob pattern */ const GLOB_STAR_REGEX = /\*/g; /** @internal Regex for replacing globstar placeholder */ const GLOBSTAR_PLACEHOLDER_REGEX = /{{GLOBSTAR}}/g; /** * Callback error codes * @readonly * @enum {string} */ const CallbackError = Object.freeze({ CALLBACK_NOT_REGISTERED: "CALLBACK_NOT_REGISTERED", CALLBACK_EXECUTION_FAILED: "CALLBACK_EXECUTION_FAILED", }); /** * Action schema definitions for agent integration * @internal */ const ACTION_SCHEMAS = Object.freeze({ addToCart: { description: "Add a product to the user's cart", params: { productId: { type: "string", required: true, description: "Product ID", }, quantity: { type: "number", required: false, default: 1, description: "Quantity to add", }, }, }, updateCart: { description: "Update item quantity in cart (use quantity=0 to remove)", params: { productId: { type: "string", required: true, description: "Product ID to update", }, quantity: { type: "number", required: true, description: "New quantity (0 to remove item)", }, }, }, getCart: { description: "Get the current cart contents", params: {}, }, getOrders: { description: "Get the user's order history", params: {}, }, redirectToCheckout: { description: "Redirect the user to checkout", params: { cartId: { type: "string", required: false, description: "Cart ID if applicable", }, }, }, trackOrderState: { description: "Track the state of a specific order", params: { orderId: { type: "string", required: true, description: "Order ID to track", }, }, }, }); /** @internal */ const DEFAULT_CONFIG = Object.freeze({ apiUrl: "https://get-echo.ai", position: "bottom-right", theme: Object.freeze({ primaryColor: "#b3f745", buttonSize: 56, }), apiKey: null, enableProactiveAgent: false, }); /** @internal */ const DEFAULT_BRANDING = Object.freeze({ displayName: "Echo", logoUrl: "/images/untethered-logo.svg", subtitle: "We typically reply in a few minutes", }); /** @internal Default z-index for widget elements */ const DEFAULT_Z_INDEX = 10; // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ /** * Debug logger - only logs when DEBUG is enabled * @param {...any} args - Arguments to log * @internal */ function debug(...args) { if (DEBUG) { console.log("[EchoWidget]", ...args); } } /** * Validate items array has required fields (item_id and price) * @param {Array} items - Items to validate * @param {string} methodName - Method name for error messages * @returns {boolean} True if valid, false otherwise * @internal */ function validateItems(items, methodName) { for (const item of items) { if (!item.item_id) { console.error(`[EchoWidget] ${methodName}: each item requires item_id`); return false; } if (typeof item.price !== "number") { console.error( `[EchoWidget] ${methodName}: each item requires numeric price` ); return false; } } return true; } /** * Get a cookie value by name * @param {string} name - Cookie name * @returns {string|null} Cookie value or null if not found * @internal */ function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) { return parts.pop().split(";").shift(); } return null; } /** * Set a cookie with expiration * @param {string} name - Cookie name * @param {string} value - Cookie value * @param {number} days - Days until expiration * @internal */ function setCookie(name, value, days) { const expires = new Date(); expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); // biome-ignore lint/suspicious/noDocumentCookie: SDK requires cookie management for user tracking document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`; } /** * Delete a cookie by name * @param {string} name - Cookie name * @internal */ function deleteCookie(name) { // biome-ignore lint/suspicious/noDocumentCookie: SDK requires cookie management for user tracking document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`; } /** * Resolve the default API URL with fallback chain * @returns {string} Resolved API URL * @internal */ function resolveDefaultApiUrl() { return DEFAULT_CONFIG.apiUrl; } /** * Escape HTML to prevent XSS attacks * @param {string} text - Text to escape * @returns {string} Escaped text * @internal */ function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } // ============================================================================ // ECHO WIDGET CLASS // ============================================================================ /** * Echo Widget - AI-powered chat widget for e-commerce * @class */ class EchoWidgetClass { /** * Create a new EchoWidget instance * @param {EchoWidgetConfig} config - Widget configuration * @private */ constructor(config = {}) { /** @type {EchoWidgetConfig} */ this.config = { ...DEFAULT_CONFIG, ...config, apiUrl: config.apiUrl || resolveDefaultApiUrl(), theme: { ...DEFAULT_CONFIG.theme, ...config.theme }, }; /** @type {EchoBranding} */ this.branding = { ...DEFAULT_BRANDING }; /** @type {boolean} */ this.isOpen = false; /** @type {boolean} */ this.isMinimized = false; /** @type {boolean} */ this.isExpanded = false; /** @type {number} */ this.unreadCount = 0; /** @type {boolean} */ this.isInitialized = false; /** @type {boolean} */ this.iframeReady = false; /** @type {HTMLButtonElement|null} */ this.bubble = null; /** @type {HTMLDivElement|null} */ this.bubbleWrapper = null; /** @type {HTMLDivElement|null} */ this.container = null; /** @type {HTMLIFrameElement|null} */ this.iframe = null; /** @type {HTMLDivElement|null} */ this.bubbleContainer = null; /** @type {string|null} */ this.chatId = null; /** @type {boolean} */ this.bubbleTracked = this._getSessionFlag("initialized"); /** @type {boolean} */ this.analyticsBlocked = false; /** @type {Array} */ this.eventQueue = []; /** @type {number|null} */ this.batchTimer = null; /** @type {string|null} */ this.userId = getCookie(USER_ID_COOKIE_NAME); // Client-side user ID generation (PostHog pattern) // Generate and persist immediately to prevent race conditions if (this.userId) { debug("Found existing user ID:", this.userId); } else { this.userId = crypto.randomUUID(); setCookie( USER_ID_COOKIE_NAME, this.userId, USER_ID_COOKIE_LIFETIME_DAYS ); debug("Generated new user ID:", this.userId); } /** @type {Object} */ this.callbacks = { addToCart: null, updateCart: null, getCart: null, redirectToCheckout: null, trackOrderState: null, getOrders: null, }; /** @type {Array<{name: string, description: string, js: string, executionMode: string}>} */ this.clientTools = []; /** @type {PageAccessConfig} */ this.pageAccess = { whitelistPages: ["*"], blacklistPages: [], }; /** @type {{galleryColumns?: number, position?: string, bottomOffset?: number, sideOffset?: number}} */ this.agentSettings = {}; /** @type {{enabled: boolean, message: string|null, delaySeconds: number}} */ this.welcomeBubble = { enabled: false, message: null, delaySeconds: 3, }; /** @type {string|null} Platform type from partner config (e.g., 'shopify') */ this.platform = null; /** @type {string[]} List of enabled tools from partner config */ this.enabledTools = []; /** @type {string|null} Platform-specific customer ID (set by plugin) */ this.platformCustomerId = null; /** @type {Array} Page change callbacks registered by plugins */ this._pluginPageChangeCallbacks = []; /** @type {Function|null} Resolver for plugin ready promise */ this._pluginReadyResolve = null; /** @type {boolean} */ this.isDeactivated = false; /** @type {boolean} */ this.isHidden = false; // Experiment state /** @type {Array} */ this.experimentAssignments = []; /** @type {Set} */ this.appliedExperiments = new Set(); /** @type {number} */ this.experimentsLastFetched = 0; /** @internal Experiment cache TTL (1 hour) */ this.EXPERIMENTS_CACHE_TTL = 4 * 60 * 60 * 1000; /** @type {boolean} Debug mode - returns all experiments */ this.isDebugMode = this._checkDebugMode(); // Bind event handlers this.handleMessage = this.handleMessage.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleNavigation = this.handleNavigation.bind(this); // Initialize asynchronously this._initialize(); } // ========================================================================== // INITIALIZATION // ========================================================================== /** * Initialize the widget asynchronously * @private */ async _initialize() { if (this.config.apiKey) { try { await this._fetchPartnerConfig(); // Load platform-specific plugin (e.g., Shopify) // Plugin handles logout detection and callback registration await this._loadPlatformPlugin(); this.analyticsBlocked = await this._detectAdBlocker(); await this._initializeUser(); // Skip experiments for deactivated partners in debug mode (API auth would reject) if (!this._isDeactivatedDebug && !this.config.skipExperiments) { await this._initExperiments(); } } catch (err) { console.warn("[EchoWidget] Initialization error:", err.message); } } // Don't render widget if partner is deactivated if (this.isDeactivated) { debug("Widget disabled - partner deactivated"); return; } if (!this._shouldShowWidget()) { debug("Widget hidden on this page due to page access rules"); return; } this._createStyles(); this._createBubble(); this._attachEventListeners(); this.isInitialized = true; debug("Initialized successfully"); this.iframe.addEventListener("load", () => { this.iframeReady = true; this._broadcastAvailableActions(); }); if (!this.bubbleTracked) { this._trackEvent("bubble_load"); this.bubbleTracked = true; } // Show welcome bubble if enabled and not already shown this session this._maybeShowWelcomeBubble(); this.trackPageView({ url: window.location.href, title: document.title, }); // Load debug panel if enabled via query string this._maybeLoadDebugPanel(); } /** * Check if debug mode is enabled via query param * @returns {boolean} * @private */ _checkDebugMode() { try { const params = new URLSearchParams(window.location.search); return params.get("echo_debug") === "true"; } catch { return false; } } /** * Check for debug panel query param and load panel script * @private */ _maybeLoadDebugPanel() { try { if (!this.isDebugMode) { return; } // Don't load twice if (window.__echoDebugPanelLoaded) { return; } window.__echoDebugPanelLoaded = true; // Expose debug API for panel communication this._exposeDebugApi(); // Dynamically load debug panel script const script = document.createElement("script"); script.src = `${this.config.apiUrl}/embed-debug.js`; script.onerror = () => { console.warn("[EchoWidget] Debug panel failed to load"); }; document.head.appendChild(script); debug("Debug panel loading..."); } catch (e) { // Silent fail - don't break widget console.warn("[EchoWidget] Debug panel error:", e.message); } } // ========================================================================== // PLUGIN SYSTEM // ========================================================================== /** * Load platform-specific plugin if applicable * Currently supports Shopify platform * @returns {Promise} * @private */ async _loadPlatformPlugin() { // Only load for Shopify platform if (this.platform !== "shopify") { debug("Platform is not Shopify, skipping plugin load"); return; } // Prevent double-loading if (window.__echoShopifyLoaded) { debug("Shopify plugin already loaded"); return; } window.__echoShopifyLoaded = true; // Expose plugin API before loading script this._exposePluginApi(); // Create promise that resolves when plugin calls onReady() const pluginReady = new Promise((resolve) => { this._pluginReadyResolve = resolve; }); // Dynamically load the plugin script debug("Loading Shopify plugin..."); const script = document.createElement("script"); script.src = `${this.config.apiUrl}/embed-shopify.js`; script.onerror = () => { console.error("[EchoWidget] Shopify plugin failed to load"); // Resolve anyway to not block initialization if (this._pluginReadyResolve) { this._pluginReadyResolve(); } }; document.head.appendChild(script); // Wait for plugin ready with timeout (3 seconds max) const timeout = new Promise((resolve) => setTimeout(resolve, 3000)); await Promise.race([pluginReady, timeout]); debug("Shopify plugin initialization complete"); } /** * Expose plugin API for platform-specific plugins * Allows plugins to register callbacks, manage user state, and access storage * @private */ _exposePluginApi() { const self = this; window.__echoPluginApi = { // ═══════════════════════════════════════════════════════════════ // CALLBACK REGISTRATION // ═══════════════════════════════════════════════════════════════ /** * Register a callback for an action * @param {string} name - Action name (e.g., "addToCart") * @param {Function} handler - Async function that handles the action */ registerCallback(name, handler) { if (typeof handler !== "function") { console.error(`[EchoWidget] Invalid callback handler for ${name}`); return; } // Only register if not already set by partner if (self.callbacks[name] === null) { self.callbacks[name] = handler; debug(`Plugin registered callback: ${name}`); } else { debug(`Callback ${name} already registered, skipping`); } }, /** * Get list of enabled tools from partner config * @returns {string[]} */ getEnabledTools() { return self.enabledTools || []; }, // ═══════════════════════════════════════════════════════════════ // USER STATE MANAGEMENT // ═══════════════════════════════════════════════════════════════ /** * Set platform-specific customer ID (e.g., Shopify customer ID) * This ID is sent to the server with chat requests * @param {string|null} customerId */ setCustomerId(customerId) { self.platformCustomerId = customerId; }, /** * Get current anonymous user ID * @returns {string} */ getUserId() { return self.userId; }, /** * Reset user state - generates new anonymous ID, clears identifiers * Called by plugin when logout/account-switch detected */ resetUserState() { debug("Plugin requested user state reset"); // Generate new anonymous user ID self.userId = crypto.randomUUID(); setCookie( USER_ID_COOKIE_NAME, self.userId, USER_ID_COOKIE_LIFETIME_DAYS ); // Clear email and identifier self.config.userEmail = null; self.config.userIdentifier = null; // Clear platform customer ID self.platformCustomerId = null; debug(`New user ID generated: ${self.userId}`); }, // ═══════════════════════════════════════════════════════════════ // NAMESPACED STORAGE // ═══════════════════════════════════════════════════════════════ storage: { /** * Get a plugin storage value * Keys are auto-prefixed with "echo_shopify_" * @param {string} key * @returns {string|null} */ get(key) { return localStorage.getItem(`echo_shopify_${key}`); }, /** * Set a plugin storage value * Keys are auto-prefixed with "echo_shopify_" * @param {string} key * @param {string} value */ set(key, value) { localStorage.setItem(`echo_shopify_${key}`, value); }, /** * Remove a plugin storage value * @param {string} key */ remove(key) { localStorage.removeItem(`echo_shopify_${key}`); }, }, // ═══════════════════════════════════════════════════════════════ // TRACKING (for auto-tracking by plugins) // ═══════════════════════════════════════════════════════════════ /** * Track an analytics event * Allows plugins to send tracking events through the core SDK * @param {string} eventType - Event type (e.g., "page_view", "product_view") * @param {Object} eventData - Event data */ trackEvent(eventType, eventData = {}) { self._trackEvent(eventType, eventData); }, // ═══════════════════════════════════════════════════════════════ // LIFECYCLE HOOKS // ═══════════════════════════════════════════════════════════════ /** * Signal that plugin is fully initialized * Core SDK waits for this before proceeding */ onReady() { debug("Plugin signaled ready"); if (self._pluginReadyResolve) { self._pluginReadyResolve(); } }, /** * Register callback for page navigation events * Useful for SPA navigation detection * @param {Function} callback */ onPageChange(callback) { if (typeof callback === "function") { self._pluginPageChangeCallbacks.push(callback); } }, }; debug("Plugin API exposed"); } /** * Notify plugins of page change (for SPA navigation) * @private */ _notifyPluginPageChange() { if ( this._pluginPageChangeCallbacks && this._pluginPageChangeCallbacks.length > 0 ) { for (const callback of this._pluginPageChangeCallbacks) { try { callback(); } catch (e) { debug("Plugin page change callback error:", e); } } } } /** * Expose debug API on window for panel communication * @private */ _exposeDebugApi() { window.__echoDebugApi = { // Data access getExperiments: () => this.experimentAssignments, getDebugConfig: () => this._getDebugConfig(), getApiUrl: () => this.config.apiUrl, getUserId: () => this.userId, getDeviceType: () => this._getDeviceType(), // Actions setExperimentOverride: (experimentId, variantId) => { const current = this._getDebugConfig(); const overrides = { ...current.experimentOverrides }; if (variantId === null) { overrides[experimentId] = undefined; } else { overrides[experimentId] = variantId; } // Filter out undefined values const cleanOverrides = Object.fromEntries( Object.entries(overrides).filter(([, v]) => v !== undefined) ); this._setDebugConfig({ experimentOverrides: cleanOverrides }); window.location.reload(); }, clearAllOverrides: () => { this._setDebugConfig({ experimentOverrides: {} }); window.location.reload(); }, setSdkUrl: (url) => { if (url) { this._setDebugConfig({ sdkUrl: url }); } else { // Clear SDK URL by setting to undefined this._setDebugConfig({ sdkUrl: undefined }); } window.location.reload(); }, setSkipTracking: (skip) => { this._setDebugConfig({ skipTracking: !!skip }); }, // Event subscriptions (set by debug panel) onEvent: null, onCallback: null, }; } /** * Invoke a config callback and emit to debug panel * @param {string} name - Callback name (e.g., 'onOpen', 'onNavigate') * @param {*} [payload] - Payload to pass to callback * @private */ _invokeCallback(name, payload) { const handler = this.config[name]; const hasHandler = typeof handler === "function"; // Emit to debug panel (always, even if no handler registered) if (window.__echoDebugApi?.onCallback) { try { window.__echoDebugApi.onCallback({ name, payload, timestamp: new Date().toISOString(), registered: hasHandler, }); } catch { // Silent fail } } // Call the handler if registered if (hasHandler) { try { handler(payload); } catch (err) { console.error(`[EchoWidget] ${name} callback failed:`, err); } } } /** * Handle navigation with callback or fallback * @param {string} url - URL to navigate to * @private */ _handleNavigate(url) { // Emit to debug panel if (window.__echoDebugApi?.onCallback) { try { window.__echoDebugApi.onCallback({ name: "onNavigate", payload: url, timestamp: new Date().toISOString(), registered: typeof this.config.onNavigate === "function", }); } catch { // Silent fail } } // Use callback for SPA mode, or fallback to new tab if (this.config.onNavigate) { try { this.config.onNavigate(url); } catch (err) { console.error("[EchoWidget] onNavigate callback failed:", err); } } else { window.location.href = url; } } /** * Fetch partner configuration from API * @private */ async _fetchPartnerConfig() { const CONFIG_CACHE_TTL = 60 * 60 * 1000; const cacheKey = `echo_config_${this.config.apiKey}`; if (!this.isDebugMode) { try { const raw = localStorage.getItem(cacheKey); if (raw) { const cached = JSON.parse(raw); if (cached.t && Date.now() - cached.t < CONFIG_CACHE_TTL) { this._applyPartnerConfig(cached.d); debug("Partner config loaded from cache"); return; } } } catch { // localStorage not available or corrupt — continue to fetch } } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); try { let configUrl = `${this.config.apiUrl}/api/partner/config?apiKey=${encodeURIComponent(this.config.apiKey)}`; if (this.config.agentSlug) { configUrl += `&agent=${encodeURIComponent(this.config.agentSlug)}`; } if (this.isDebugMode) { configUrl += "&debug=true"; } const response = await fetch(configUrl, { signal: controller.signal }); clearTimeout(timeoutId); if (response.ok) { const data = await response.json(); this._applyPartnerConfig(data); if (data.isActive !== false) { try { localStorage.setItem(cacheKey, JSON.stringify({ t: Date.now(), d: data })); } catch {} } } } catch (err) { clearTimeout(timeoutId); if (err.name === "AbortError") { console.warn("[EchoWidget] Config fetch timed out"); } throw err; } } /** * Apply partner config data to widget state * @param {Object} data - Partner config response data * @private */ _applyPartnerConfig(data) { if (data.isActive === false) { if (!this.isDebugMode) { this.isDeactivated = true; debug("Partner is deactivated, widget will not render"); return; } this._isDeactivatedDebug = true; } this.branding = { displayName: data.displayName || DEFAULT_BRANDING.displayName, logoUrl: data.logoUrl || DEFAULT_BRANDING.logoUrl, iconUrl: data.iconUrl, subtitle: data.subtitle || DEFAULT_BRANDING.subtitle, primaryColor: data.primaryColor, primaryColorDark: data.primaryColorDark, secondaryColor: data.secondaryColor, secondaryColorDark: data.secondaryColorDark, }; this.pageAccess = { whitelistPages: data.whitelistPages || ["*"], blacklistPages: data.blacklistPages || [], }; debug("Page access config:", this.pageAccess); if (data.welcomeBubble) { this.welcomeBubble = { enabled: data.welcomeBubble.enabled || false, message: data.welcomeBubble.message || null, delaySeconds: data.welcomeBubble.delaySeconds || 3, }; debug("Welcome bubble config:", this.welcomeBubble); } if (data.agentSettings) { this.agentSettings = data.agentSettings; debug("Agent settings:", this.agentSettings); } this.platform = data.platform || null; this.enabledTools = data.enabledTools || []; debug("Platform:", this.platform, "Enabled tools:", this.enabledTools); if (Array.isArray(data.clientTools)) { this.clientTools = data.clientTools; this._registerClientToolCallbacks(); debug("Client tools registered:", this.clientTools.length); } } /** * Detect if ad blockers are blocking analytics * @returns {Promise} True if analytics is blocked * @private */ async _detectAdBlocker() { try { const cached = sessionStorage.getItem("echo_adblock"); if (cached !== null) return cached === "1"; } catch { // sessionStorage unavailable } try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); const response = await fetch(`${this.config.apiUrl}/api/activity`, { method: "HEAD", signal: controller.signal, }); clearTimeout(timeoutId); const blocked = !response.ok; try { sessionStorage.setItem("echo_adblock", blocked ? "1" : "0"); } catch {} return blocked; } catch { try { sessionStorage.setItem("echo_adblock", "1"); } catch {} return true; } } /** * Initialize user session with server * User ID is already generated client-side in constructor (PostHog pattern) * POST is idempotent - creates user if new, returns existing if not * Platform-specific customer ID (e.g., Shopify) is set by plugin via platformCustomerId * @private */ async _initializeUser() { if (!this.config.apiKey || !this.userId) { return; } const USER_SYNC_TTL = 24 * 60 * 60 * 1000; const userCacheKey = `echo_user_${this.config.apiKey}`; try { const raw = localStorage.getItem(userCacheKey); if (raw) { const cached = JSON.parse(raw); if ( cached.userId === this.userId && cached.pcid === (this.platformCustomerId || null) && cached.syncedAt && Date.now() - cached.syncedAt < USER_SYNC_TTL ) { debug("User sync skipped (cached)"); await this._identifyFromConfig(); return; } } } catch { // localStorage unavailable } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); try { debug("Syncing user with server:", this.userId); // Include platform customer ID if set by plugin (e.g., Shopify customer ID) const shopifyCustomerId = this.platformCustomerId; const response = await fetch(`${this.config.apiUrl}/api/user`, { method: "POST", headers: { "X-API-Key": this.config.apiKey, "Content-Type": "application/json", }, body: JSON.stringify({ userId: this.userId, ...(shopifyCustomerId && { shopifyCustomerId }), }), signal: controller.signal, }); clearTimeout(timeoutId); if (response.ok) { const data = await response.json(); // Handle userIdChanged (email was already associated with different user) if (data.userIdChanged && data.userId) { this.userId = data.userId; setCookie( USER_ID_COOKIE_NAME, data.userId, USER_ID_COOKIE_LIFETIME_DAYS ); debug("User ID updated to existing user:", data.userId); } debug(data.existing ? "User verified" : "User registered"); try { localStorage.setItem(userCacheKey, JSON.stringify({ userId: this.userId, pcid: this.platformCustomerId || null, syncedAt: Date.now() })); } catch {} } else { debug("User sync response:", response.status); } await this._identifyFromConfig(); } catch (err) { clearTimeout(timeoutId); if (err.name !== "AbortError") { console.warn("[EchoWidget] User initialization error:", err.message); } } } /** * Identify user from config if userIdentifier or userEmail was provided * @private */ async _identifyFromConfig() { const hasEmail = this.config.userEmail && typeof this.config.userEmail === "string"; const hasUserIdentifier = this.config.userIdentifier && typeof this.config.userIdentifier === "string"; if (!hasEmail && !hasUserIdentifier) { return; } if (!this.userId) { debug("Cannot identify from config: no user session"); return; } debug("Identifying user from config:", { email: hasEmail ? this.config.userEmail : undefined, userIdentifier: hasUserIdentifier ? this.config.userIdentifier : undefined, }); try { const response = await fetch( `${this.config.apiUrl}/api/user/identify`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": this.config.apiKey || "", }, body: JSON.stringify({ userId: this.userId, email: hasEmail ? this.config.userEmail : undefined, userIdentifier: hasUserIdentifier ? this.config.userIdentifier : undefined, }), } ); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); if (!data.success) { throw new Error(data.error || "Identification failed"); } // Handle userId change (identifier was already associated with different user) if (data.userIdChanged && data.userId) { this.userId = data.userId; setCookie( USER_ID_COOKIE_NAME, data.userId, USER_ID_COOKIE_LIFETIME_DAYS ); debug("User ID updated to existing user:", data.userId); } debug("User identified from config successfully"); } catch (err) { console.warn("[EchoWidget] Config identification error:", err.message); } } // ========================================================================== // EXPERIMENTATION // ========================================================================== /** * Initialize experiments - fetch assignments from server or cache * @private */ async _initExperiments() { if (!this.config.apiKey || !this.userId) { return; } try { // Check localStorage cache first (bypass in debug mode for fresh data) const cached = this._getCachedExperiments(); if ( !this.isDebugMode && cached && !this._isExperimentsCacheExpired(cached) ) { this.experimentAssignments = cached.assignments; this.experimentsLastFetched = cached.fetchedAt; debug("Experiments loaded from cache:", cached.assignments.length); } else { // Fetch from server this.experimentAssignments = await this._fetchExperiments(); this.experimentsLastFetched = Date.now(); this._cacheExperiments(this.experimentAssignments); debug( "Experiments fetched from server:", this.experimentAssignments.length ); } // Apply experiments for current page this._applyExperiments(); } catch (err) { console.warn("[EchoWidget] Experiments init error:", err.message); } } /** * Get cached experiment assignments from localStorage * @returns {Object|null} Cached data or null * @private */ _getCachedExperiments() { try { const key = `echo_experiments_${this.userId}`; const data = localStorage.getItem(key); return data ? JSON.parse(data) : null; } catch { return null; } } /** * Check if experiments cache is expired * @param {Object} cached - Cached data with fetchedAt timestamp * @returns {boolean} True if expired * @private */ _isExperimentsCacheExpired(cached) { return Date.now() - cached.fetchedAt > this.EXPERIMENTS_CACHE_TTL; } /** * Cache experiment assignments to localStorage * @param {Array} assignments - Experiment assignments * @private */ _cacheExperiments(assignments) { try { const key = `echo_experiments_${this.userId}`; localStorage.setItem( key, JSON.stringify({ fetchedAt: Date.now(), assignments, }) ); } catch (err) { debug("Failed to cache experiments:", err.message); } } /** * Fetch experiment assignments from server * @returns {Promise} Experiment assignments * @private */ async _fetchExperiments() { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); try { const response = await fetch( `${this.config.apiUrl}/api/experiments/evaluate`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": this.config.apiKey, }, body: JSON.stringify({ visitorId: this.userId, deviceType: this._getDeviceType(), ...(this.config.userIdentifier && { userIdentifier: this.config.userIdentifier, }), }), signal: controller.signal, } ); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); return data.assignments || []; } catch (err) { clearTimeout(timeoutId); if (err.name === "AbortError") { console.warn("[EchoWidget] Experiments fetch timed out"); } throw err; } } /** * Get device type for audience targeting * @returns {string} Device type (desktop, mobile, tablet) * @private */ _getDeviceType() { const ua = navigator.userAgent; if (TABLET_REGEX.test(ua)) { return "tablet"; } if (MOBILE_REGEX.test(ua)) { return "mobile"; } return "desktop"; } /** * Apply experiments for the current page * Checks page targeting and executes variant code * @private */ _applyExperiments() { const currentPath = window.location.pathname; for (const assignment of this.experimentAssignments) { // Check page targeting if ( !this._matchesPageTargeting(currentPath, assignment.pageTargeting) ) { continue; } // Skip if already applied for this experiment on this page const executionKey = `${assignment.experimentId}-${currentPath}`; if (this.appliedExperiments.has(executionKey)) { continue; } // Check for debug override - use overridden variant if set let activeVariant = assignment; let isOverride = false; const overrideVariantId = this._getExperimentOverride( assignment.experimentId ); if (overrideVariantId) { // Find the override variant in the assignment's available variants // The variants are included in pageTargeting response const variants = assignment.variants || []; const overrideVariant = variants.find( (v) => v.id === overrideVariantId ); if (overrideVariant) { activeVariant = { ...assignment, variantId: overrideVariant.id, variantName: overrideVariant.name, isControl: overrideVariant.isControl, jsCode: overrideVariant.jsCode, cssCode: overrideVariant.cssCode, }; isOverride = true; } } // Skip if experiment is not running (unless overridden) // Following LaunchDarkly/Optimizely pattern: draft/paused experiments // are returned for debug panel visibility but not applied in production if (assignment.status !== "running" && !isOverride) { continue; } // Skip if visitor is not in traffic sample (unless overridden) // API returns all experiments; client decides whether to apply if (!assignment.inTrafficSample && !isOverride) { continue; } // Mark as override for debug panel display activeVariant._isOverride = isOverride; // Execute variant code (skip for control) if (!activeVariant.isControl && activeVariant.jsCode) { try { // Create function with EchoWidget reference const fn = new Function("EchoWidget", activeVariant.jsCode); fn(window.EchoWidget); } catch (err) { console.error( `[EchoWidget] Experiment ${activeVariant.experimentId} error:`, err ); } } // Inject CSS if provided if (!activeVariant.isControl && activeVariant.cssCode) { try { const style = document.createElement("style"); style.dataset.echoExperiment = activeVariant.experimentId; style.textContent = activeVariant.cssCode; document.head.appendChild(style); } catch (err) { debug("CSS injection failed:", err.message); } } // Track exposure event (deduplicated per session) // Skip tracking only if skipTracking is explicitly true in debug config const exposureKey = `echo_exp_${activeVariant.experimentId}`; const debugConfig = this._getDebugConfig(); // Only skip if skipTracking is explicitly true - undefined means track const skipTracking = debugConfig.skipTracking === true; if (!skipTracking && !sessionStorage.getItem(exposureKey)) { this._trackEvent("experiment_exposure", { experimentId: activeVariant.experimentId, experimentName: activeVariant.experimentName, variantId: activeVariant.variantId, variantName: activeVariant.variantName, visitorId: this.userId, url: window.location.href, }); // Flush immediately - exposure events are critical for experiment metrics // and should not be lost to page navigation or batching delays this._flushEventQueue(); sessionStorage.setItem(exposureKey, activeVariant.variantId); debug( `Experiment exposure tracked: ${activeVariant.experimentName} - ${activeVariant.variantName}` ); } else if (skipTracking) { debug("[Debug] Skipping exposure tracking (skipTracking enabled)"); } this.appliedExperiments.add(executionKey); } } /** * Check if a path matches page targeting rules * @param {string} path - Current URL path * @param {Array} rules - Page targeting rules * @returns {boolean} True if path matches * @private */ _matchesPageTargeting(path, rules) { // No rules = match all pages if (!rules || rules.length === 0) { return true; } return rules.some((rule) => { switch (rule.type) { case "path_exact": return path === rule.value; case "path_contains": return path.includes(rule.value); case "path_starts_with": return path.startsWith(rule.value); case "path_ends_with": return path.endsWith(rule.value); case "path_glob": return this._globMatch(path, rule.value); case "url_exact": return window.location.href === rule.value; case "url_contains": return window.location.href.includes(rule.value); case "url_regex": try { return new RegExp(rule.value).test(path); } catch { return false; } case "js_condition": try { return !!new Function(`return (${rule.value})`)(); } catch { return false; } default: return false; } }); } /** * Simple glob matching for paths * Supports * (single segment) and ** (multiple segments) * @param {string} path - Path to match * @param {string} pattern - Glob pattern * @returns {boolean} True if matches * @private */ _globMatch(path, pattern) { // Convert glob to regex const regexPattern = pattern .replace(/\*\*/g, "{{DOUBLE}}") .replace(/\*/g, "[^/]*") .replace(/{{DOUBLE}}/g, ".*"); try { const regex = new RegExp(`^${regexPattern}$`); return regex.test(path); } catch { return false; } } // ========================================================================== // ANALYTICS & EVENT TRACKING // ========================================================================== /** * Track an analytics event (batched for performance) * @param {string} eventType - Event type identifier * @param {Object} [eventData={}] - Additional event data * @private */ _trackEvent(eventType, eventData = {}) { if (!this.config.apiKey || this.analyticsBlocked) { return; } const event = { chatId: this.chatId, eventType, eventData, timestamp: new Date().toISOString(), }; if (this.config.userEmail) { event.userEmail = this.config.userEmail; } if (this.config.userIdentifier) { event.userIdentifier = this.config.userIdentifier; } this.eventQueue.push(event); debug( `Event queued: ${eventType} (${this.eventQueue.length}/${BATCH_SIZE_LIMIT})` ); if (this.eventQueue.length >= BATCH_SIZE_LIMIT) { this._flushEventQueue(); } else { this._resetBatchTimer(); } } /** * Reset the batch inactivity timer * @private */ _resetBatchTimer() { if (this.batchTimer) { clearTimeout(this.batchTimer); } this.batchTimer = setTimeout(() => { this._flushEventQueue(); }, BATCH_TIMEOUT_MS); } /** * Emit a custom event on the widget element for host page to listen to * @param {string} eventName - Event name (will be prefixed with 'echo:') * @param {Object} detail - Event detail data * @private */ _emitEvent(eventName, detail = {}) { const event = new CustomEvent(`echo:${eventName}`, { detail, bubbles: true, cancelable: false, }); this.container?.dispatchEvent(event); debug(`Event emitted: echo:${eventName}`, detail); } /** * Flush all queued events to the server * @param {boolean} [useBeacon=false] - Use sendBeacon for page unload reliability * @private */ _flushEventQueue(useBeacon = false) { if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null; } if (this.eventQueue.length === 0) { return; } const eventsToSend = [...this.eventQueue]; this.eventQueue = []; // Fire-and-forget: call onEvent callback with all events // Dbug panel receives the exact same payload as user callback // Each callback isolated to prevent one failure from blocking others if (this.config.onEvent) { try { this.config.onEvent(eventsToSend); } catch (err) { console.error("[EchoWidget] onEvent callback failed:", err); } } if (window.__echoDebugApi?.onEvent) { try { window.__echoDebugApi.onEvent(eventsToSend); } catch { // Silent fail for debug panel } } // Skip server send in debug mode (callbacks still fire above) const debugConfig = this._getDebugConfig(); if (debugConfig.skipTracking) { debug( `[Debug] Server send skipped (skipTracking): ${eventsToSend.length} events` ); return; } debug( `Flushing ${eventsToSend.length} events${useBeacon ? " (beacon)" : ""}` ); if (useBeacon && navigator.sendBeacon) { this._sendEventBeacon(eventsToSend); } else { this._sendEventBatch(eventsToSend); } } /** * Send events using sendBeacon (reliable during page unload) * sendBeacon guarantees delivery even when page is closing, unlike fetch * @param {Array} events - Events to send * @private */ _sendEventBeacon(events) { // Guard: need apiKey to send events if (!this.config.apiKey) { return; } const url = `${this.config.apiUrl}/api/activity`; // sendBeacon can't set custom headers, so pass auth via query params const params = new URLSearchParams(); params.set("apiKey", this.config.apiKey); if (this.userId) { params.set("userId", this.userId); } const blob = new Blob([JSON.stringify({ events })], { type: "application/json", }); const queued = navigator.sendBeacon(`${url}?${params.toString()}`, blob); if (!queued) { // Beacon failed to queue (rare - usually due to payload size limit ~64KB) // Fall back to regular fetch (may not complete during unload, but worth trying) this._sendEventBatch(events); } } /** * Send a batch of events to the server * Fire-and-forget with keepalive to survive page navigation * @param {Array} events - Events to send * @param {number} [retryCount=0] - Current retry attempt * @private */ _sendEventBatch(events, retryCount = 0) { const headers = { "Content-Type": "application/json", "X-API-Key": this.config.apiKey, }; if (this.userId) { headers["X-User-Id"] = this.userId; } fetch(`${this.config.apiUrl}/api/activity`, { method: "POST", headers, body: JSON.stringify({ events }), keepalive: true, }) .then((response) => { if (!response.ok && retryCount < 2) { const delay = 2 ** retryCount * 500; setTimeout( () => this._sendEventBatch(events, retryCount + 1), delay ); } }) .catch(() => { if (retryCount < 1) { setTimeout( () => this._sendEventBatch(events, retryCount + 1), 1000 ); } }); } // ========================================================================== // PAGE ACCESS CONTROL // ========================================================================== /** * Convert a glob pattern to a regular expression * @param {string} pattern - Glob pattern * @returns {RegExp} Regular expression for matching * @private */ _globToRegex(pattern) { if (pattern === "*") { return MATCH_ALL_REGEX; } let regexStr = pattern .replace(GLOB_ESCAPE_REGEX, "\\$&") .replace(GLOB_STAR_STAR_REGEX, "{{GLOBSTAR}}") .replace(GLOB_STAR_REGEX, "[^/]*") .replace(GLOBSTAR_PLACEHOLDER_REGEX, ".*"); if (!regexStr.startsWith("^")) { regexStr = `^${regexStr}`; } regexStr += "/?$"; return new RegExp(regexStr); } /** * Check if a path matches a glob pattern * @param {string} path - URL path * @param {string} pattern - Glob pattern * @returns {boolean} True if path matches * @private */ _matchGlob(path, pattern) { return this._globToRegex(pattern).test(path); } /** * Check if current path is whitelisted * @param {string} path - URL path * @returns {boolean} True if whitelisted * @private */ _isPathWhitelisted(path) { if (this.pageAccess.whitelistPages.includes("*")) { return true; } return this.pageAccess.whitelistPages.some((pattern) => this._matchGlob(path, pattern) ); } /** * Check if current path is blacklisted * @param {string} path - URL path * @returns {boolean} True if blacklisted * @private */ _isPathBlacklisted(path) { return this.pageAccess.blacklistPages.some((pattern) => this._matchGlob(path, pattern) ); } /** * Determine if widget should be shown on current page * @returns {boolean} True if widget should be shown * @private */ _shouldShowWidget() { const currentPath = window.location.pathname; if (this._isPathBlacklisted(currentPath)) { debug("Page blacklisted:", currentPath); return false; } if (this._isPathWhitelisted(currentPath)) { return true; } debug("Page not whitelisted:", currentPath); return false; } /** * Update widget visibility based on current page * @private */ _updateWidgetVisibility() { if (this.isHidden) { if (this.bubble) { this.bubble.style.display = "none"; } if (this.container) { this.container.style.display = "none"; } if (this.bubbleContainer) { this.bubbleContainer.style.display = "none"; } debug("Widget hidden programmatically, skipping visibility update"); return; } if (this.isOpen) { debug("Widget is open, staying visible during navigation"); return; } const shouldShow = this._shouldShowWidget(); if (this.bubble) { this.bubble.style.display = shouldShow ? "flex" : "none"; } if (this.container) { this.container.style.display = shouldShow ? "" : "none"; } if (this.bubbleContainer) { this.bubbleContainer.style.display = shouldShow ? "flex" : "none"; } debug("Visibility updated:", shouldShow ? "visible" : "hidden"); } /** * Handle navigation events * @private */ handleNavigation() { if (this.isInitialized) { this._updateWidgetVisibility(); if (this._shouldShowWidget()) { this.trackPageView({ url: window.location.href, title: document.title, }); } // Re-evaluate experiments for the new page (SPA navigation) this._applyExperiments(); // Notify plugins of page change (e.g., Shopify plugin rechecks customer state) this._notifyPluginPageChange(); } } // ========================================================================== // AVAILABLE ACTIONS // ========================================================================== /** * Register callbacks for client tools loaded from partner config. * Partner-registered callbacks take precedence over config-defined ones. * @private */ _registerClientToolCallbacks() { for (const tool of this.clientTools) { // Partner-registered callback takes precedence if (this.callbacks[tool.name] != null) { continue; } this.callbacks[tool.name] = (payload) => { try { const result = new Function("payload", tool.js)(payload); return result ?? { success: true }; } catch (err) { return { success: false, error: err.message }; } }; } } /** * Get list of registered callback action names * @returns {string[]} Array of available action names * @private */ _getAvailableActions() { return Object.keys(this.callbacks).filter( (key) => this.callbacks[key] !== null ); } /** * Get detailed action schemas for available actions * @returns {Object} Map of action names to schemas * @private */ _getAvailableActionSchemas() { const available = this._getAvailableActions(); const schemas = {}; for (const action of available) { if (ACTION_SCHEMAS[action]) { schemas[action] = ACTION_SCHEMAS[action]; } } // Add client tool schemas for (const tool of this.clientTools || []) { if (this.callbacks[tool.name]) { schemas[tool.name] = { description: tool.description || tool.name, params: {}, }; } } return schemas; } /** * Broadcast available actions to the iframe * @private */ _broadcastAvailableActions() { if (!this.iframe?.contentWindow) { debug("Cannot broadcast: iframe not ready"); return; } if (!this.iframeReady) { debug("Cannot broadcast: iframe not loaded yet"); return; } const availableActions = this._getAvailableActions(); const actionSchemas = this._getAvailableActionSchemas(); // Use platform customer ID set by plugin (e.g., Shopify customer ID) const customerId = this.platformCustomerId; this.iframe.contentWindow.postMessage( { type: "echo:available_actions", actions: availableActions, schemas: actionSchemas, customerId, }, "*" ); debug( "Broadcasted available actions:", availableActions, "customerId:", customerId ); } // ========================================================================== // CALLBACK HANDLING // ========================================================================== /** * Validate if a callback can be executed * @param {string} callbackName - Callback name * @returns {{valid: boolean, error?: string}} Validation result * @private */ _validateCallback(callbackName) { if (!this.callbacks[callbackName]) { return { valid: false, error: CallbackError.CALLBACK_NOT_REGISTERED }; } return { valid: true }; } /** * Send a response back to the iframe * @param {Object} options - Response options * @private */ _sendCallbackResponse({ type, success, data = {}, error = null, callbackId = null, }) { const response = { source: "echo-parent", type, success, ...data, ...(error && { error }), ...(callbackId && { callbackId }), }; if (!this.iframe?.contentWindow) { console.error( "[EchoWidget] Cannot send response: iframe not available" ); return; } this.iframe.contentWindow.postMessage(response, "*"); } /** * Execute a callback safely with error handling * @param {Object} options - Execution options * @private */ async _executeCallback({ callbackName, responseType, payload, callbackId = null, }) { debug(`Executing callback: ${callbackName}`, payload); const validation = this._validateCallback(callbackName); const timestamp = new Date().toISOString(); // Emit to debug panel (use "on" prefix for consistency with other callbacks) const debugCallbackName = `on${callbackName.charAt(0).toUpperCase()}${callbackName.slice(1)}`; if (!validation.valid) { // Emit failed validation to debug panel if (window.__echoDebugApi?.onCallback) { try { window.__echoDebugApi.onCallback({ name: debugCallbackName, payload, timestamp, registered: false, result: { success: false, error: validation.error }, }); } catch { // Silent fail } } this._sendCallbackResponse({ type: responseType, success: false, error: validation.error, callbackId, }); return; } try { const result = await this.callbacks[callbackName](payload); // Accept multiple success formats for flexibility: // - { success: true } (canonical) // - { ok: true } (alternative) // - true (simple boolean) const isSuccess = result === true || result?.success === true || result?.ok === true; // Emit result to debug panel if (window.__echoDebugApi?.onCallback) { try { window.__echoDebugApi.onCallback({ name: debugCallbackName, payload, timestamp, registered: true, result: { success: isSuccess, data: result }, }); } catch { // Silent fail } } if (isSuccess) { const responseData = result === true ? { success: true } : { ...result, success: true }; this._sendCallbackResponse({ type: responseType, success: true, data: responseData, callbackId, }); // Handle redirectUrl for checkout - navigate parent window if (callbackName === "redirectToCheckout" && result?.redirectUrl) { debug("Redirecting to checkout:", result.redirectUrl); window.location.href = result.redirectUrl; } } else { const errorMsg = result?.error || (result === undefined || result === null ? "CALLBACK_NO_RETURN_VALUE" : CallbackError.CALLBACK_EXECUTION_FAILED); this._sendCallbackResponse({ type: responseType, success: false, error: errorMsg, callbackId, }); } } catch (err) { console.error(`[EchoWidget] ${callbackName} callback failed:`, err); // Emit error to debug panel if (window.__echoDebugApi?.onCallback) { try { window.__echoDebugApi.onCallback({ name: debugCallbackName, payload, timestamp, registered: true, result: { success: false, error: err.message }, }); } catch { // Silent fail } } this._sendCallbackResponse({ type: responseType, success: false, error: CallbackError.CALLBACK_EXECUTION_FAILED, callbackId, }); } } // ========================================================================== // UI CREATION // ========================================================================== /** * Create widget styles * @private */ _createStyles() { if (document.getElementById("echo-widget-styles")) { return; } const style = document.createElement("style"); style.id = "echo-widget-styles"; style.textContent = this._getStylesheet(); document.head.appendChild(style); } /** * Get the widget stylesheet * @returns {string} CSS stylesheet * @private */ _getStylesheet() { const { position, theme } = this.config; // Server agentSettings override embed.js config position const isRight = this.agentSettings.position ? this.agentSettings.position === "right" : position.includes("right"); // Use server offsets if set, otherwise use defaults const bottomOffset = this.agentSettings.bottomOffset ?? 20; const sideOffset = this.agentSettings.sideOffset ?? 20; const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; const primaryColor = isDarkMode ? this.branding.primaryColorDark || this.branding.primaryColor || theme.primaryColor : this.branding.primaryColor || theme.primaryColor; // Base z-index for widget stacking const baseZIndex = this.agentSettings.zIndex ?? DEFAULT_Z_INDEX; return ` .echo-suggestion-bubble, .echo-suggestion-wrapper, .echo-products-scroll { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; } .echo-widget-bubble-wrapper { position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: ${baseZIndex}; pointer-events: auto; } .echo-widget-bubble { position: fixed; ${isRight ? `right: ${sideOffset}px;` : `left: ${sideOffset}px;`} bottom: ${bottomOffset}px; width: ${theme.buttonSize}px; height: ${theme.buttonSize}px; border-radius: 50%; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.16), 0 8px 24px rgba(0, 0, 0, 0.12); cursor: pointer; z-index: ${baseZIndex}; display: flex; align-items: center; justify-content: center; transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s ease; border: none; padding: 0; overflow: hidden; outline: 1px solid rgba(255, 255, 255, 0.15); outline-offset: -1px; pointer-events: auto; } .echo-widget-bubble:hover { transform: scale(1.04); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 8px 20px rgba(0, 0, 0, 0.18), 0 12px 32px rgba(0, 0, 0, 0.14); } .echo-widget-bubble:active { transform: scale(0.95); } .echo-widget-bubble.open { transform: scale(0.9); opacity: 0; pointer-events: auto; } .echo-widget-bubble-icon { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; } .echo-widget-badge { position: absolute; top: -5px; right: -5px; background: #ef4444; color: white; border-radius: 10px; padding: 2px 6px; font-size: 11px; font-weight: 600; min-width: 20px; text-align: center; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); animation: echo-badge-pop 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @keyframes echo-badge-pop { 0% { transform: scale(0); } 50% { transform: scale(1.2); } 100% { transform: scale(1); } } @keyframes echo-bubble-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } @keyframes echo-bubble-exit { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(8px) scale(0.95); } } .echo-widget-container { position: fixed; ${isRight ? "right: 20px;" : "left: 20px;"} bottom: 20px; width: 460px; max-width: calc(100vw - 40px); height: calc(100vh - 40px); max-height: calc(100vh - 40px); border-radius: 24px; box-shadow: rgba(0, 0, 0, 0.08) 0px 2px 8px, rgba(0, 0, 0, 0.16) 0px 8px 24px, rgba(0, 0, 0, 0.2) 0px 16px 48px; border: 1px solid rgba(0, 0, 0, 0.08); outline: 1px solid rgba(0, 0, 0, 0.06); background: #ffffff; z-index: ${baseZIndex + 10}; overflow: hidden; opacity: 0; transform: translateY(20px) scale(0.95); transition: opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1), transform 0.5s cubic-bezier(0.32, 0.72, 0, 1), width 0.5s cubic-bezier(0.32, 0.72, 0, 1), height 0.5s cubic-bezier(0.32, 0.72, 0, 1), visibility 0s 0.5s; pointer-events: auto; visibility: hidden; will-change: transform, opacity, width, height; } .echo-widget-container.open { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; visibility: visible; transition: opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1), transform 0.5s cubic-bezier(0.32, 0.72, 0, 1), width 0.5s cubic-bezier(0.32, 0.72, 0, 1), height 0.5s cubic-bezier(0.32, 0.72, 0, 1), visibility 0s 0s; } .echo-widget-container.expanded { width: calc(100vw - 40px) !important; height: calc(100vh - 40px) !important; max-width: none !important; max-height: none !important; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35), 0 0 0 100vmax rgba(0, 0, 0, 0.4); } .echo-widget-container.minimized { opacity: 0; transform: translateY(20px) scale(0.95); pointer-events: auto; visibility: hidden; } .echo-widget-iframe { width: 100%; height: 100%; border: none; border-radius: 24px; background: #ffffff; } @media (prefers-color-scheme: dark) { .echo-widget-iframe { background: hsl(240 10% 3.9%); } } @media (max-width: 768px) { .echo-widget-container { position: fixed; left: 0 !important; right: 0 !important; bottom: 0 !important; width: 100%; max-width: 100%; height: 100%; max-height: 100%; border-radius: 0; } .echo-widget-bubble { ${isRight ? `right: ${Math.min(sideOffset, 16)}px;` : `left: ${Math.min(sideOffset, 16)}px;`} bottom: ${Math.min(bottomOffset, 16)}px; } } @media (prefers-color-scheme: dark) { .echo-widget-container { background: hsl(240 10% 3.9%); border: 1px solid rgba(255, 255, 255, 0.1); outline: 1px solid rgba(255, 255, 255, 0.05); } } @keyframes echo-slide-up { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .echo-widget-bubble.loading::after { content: ""; position: absolute; top: -4px; left: -4px; right: -4px; bottom: -4px; border-radius: 50%; border: 2px solid transparent; border-top-color: ${primaryColor}; border-right-color: ${primaryColor}; animation: echo-spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite; box-shadow: 0 0 8px ${primaryColor}40; z-index: -1; transition: all 0.3s ease; } @keyframes echo-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } `; } /** * Create the chat bubble and container * @private */ _createBubble() { // Clean up existing elements document.querySelector(".echo-widget-bubble-wrapper")?.remove(); document.querySelector(".echo-widget-bubble")?.remove(); document.querySelector(".echo-widget-container")?.remove(); const iconSrc = this.branding.iconUrl || this.branding.logoUrl; const bubbleIconUrl = iconSrc.startsWith("/") ? `${this.config.apiUrl}${iconSrc}` : iconSrc; // Create bubble wrapper (contains bubble, suggestion bubbles, welcome card) this.bubbleWrapper = document.createElement("div"); this.bubbleWrapper.className = "echo-widget-bubble-wrapper"; // Create bubble button this.bubble = document.createElement("button"); this.bubble.className = "echo-widget-bubble"; this.bubble.setAttribute("aria-label", "Open chat"); this.bubble.innerHTML = ` ${this.branding.displayName} logo `; // Create container this.container = document.createElement("div"); this.container.className = "echo-widget-container"; // Get active chat from localStorage (persists across sessions) const resumeChatId = this._getActiveChat(); if (resumeChatId) { this.chatId = resumeChatId; debug("Resuming active chat:", resumeChatId); } // Restore widget state from sessionStorage (survives page refresh) const widgetState = this._restoreWidgetState(); if (widgetState?.isOpen) { // Restore widget state after a short delay to allow DOM to be ready setTimeout(() => { this.open(); if (widgetState.isExpanded) { this._expand(); } }, 100); } // Create iframe this.iframe = document.createElement("iframe"); this.iframe.className = "echo-widget-iframe"; this.iframe.setAttribute("title", "Echo Chat Widget"); this.iframe.setAttribute("allow", "clipboard-write; microphone"); this.iframe.src = this._buildIframeUrl(resumeChatId); this.container.appendChild(this.iframe); this.bubbleWrapper.appendChild(this.bubble); document.body.appendChild(this.bubbleWrapper); document.body.appendChild(this.container); } /** * Build the iframe URL with query parameters * @param {string} [resumeChatId] - Optional chat ID to resume * @returns {string} Iframe URL * @private */ _buildIframeUrl(resumeChatId) { const url = new URL("/embed", this.config.apiUrl); if (this.config.apiKey) { url.searchParams.set("apiKey", this.config.apiKey); } if (this.userId) { url.searchParams.set("userId", this.userId); } if (this.config.userEmail) { url.searchParams.set("userEmail", this.config.userEmail); } if (this.config.userIdentifier) { url.searchParams.set("userIdentifier", this.config.userIdentifier); } if (this.config.agentSlug) { url.searchParams.set("agent", this.config.agentSlug); } if (this.config.enableProactiveAgent) { url.searchParams.set("enableProactiveAgent", "true"); } if (resumeChatId) { url.searchParams.set("resumeChatId", resumeChatId); } return url.toString(); } // ========================================================================== // SUGGESTION BUBBLES // ========================================================================== /** * Show welcome bubble if enabled and not already shown this session * @private */ _maybeShowWelcomeBubble() { if (!this.welcomeBubble.enabled || !this.welcomeBubble.message) { debug("Welcome bubble disabled or no message configured"); return; } // Check if already shown this session if (this._getSessionFlag("welcomeShown")) { debug("Welcome bubble already shown this session"); return; } const delayMs = (this.welcomeBubble.delaySeconds || 3) * 1000; debug(`Scheduling welcome bubble in ${delayMs}ms`); // Mark as shown immediately to prevent duplicate timeouts this._setSessionFlag("welcomeShown"); setTimeout(() => { // Don't show if widget is already open if (this.isOpen) { debug("Widget already open, skipping welcome"); return; } // Use Command Card instead of floating bubbles this._showCommandCard(); debug("Command card shown"); }, delayMs); } /** * Show a suggestion bubble in the host page * @param {Object} suggestion - Suggestion data * @private */ _showSuggestionBubble(suggestion) { if (!suggestion?.message) { return; } debug("Showing suggestion bubble:", suggestion.message); // Create container if needed if (!this.bubbleContainer) { document.getElementById("echo-suggestion-bubbles")?.remove(); const { position, theme } = this.config; // Server agentSettings override embed.js config position const isRight = this.agentSettings.position ? this.agentSettings.position === "right" : position.includes("right"); const widgetBottomOffset = this.agentSettings.bottomOffset ?? 20; const widgetSideOffset = this.agentSettings.sideOffset ?? 20; const baseZIndex = this.agentSettings.zIndex ?? DEFAULT_Z_INDEX; // Position closer to the chat button (button is at bottomOffset with buttonSize height) const bubbleBottomOffset = widgetBottomOffset + (theme?.buttonSize || 56) + 16; // 16px gap between button and bubbles this.bubbleContainer = document.createElement("div"); this.bubbleContainer.id = "echo-suggestion-bubbles"; this.bubbleContainer.style.cssText = ` position: fixed; ${isRight ? `right: ${widgetSideOffset + 4}px;` : `left: ${widgetSideOffset + 4}px;`} bottom: ${bubbleBottomOffset}px; z-index: ${baseZIndex + 1}; display: ${this.isOpen ? "none" : "flex"}; flex-direction: column; align-items: ${isRight ? "flex-end" : "flex-start"}; gap: 6px; max-width: 340px; pointer-events: auto; `; // Append to bubble wrapper for consistent z-index stacking if (this.bubbleWrapper) { this.bubbleWrapper.appendChild(this.bubbleContainer); } else { document.body.appendChild(this.bubbleContainer); } } // For welcome messages, split by empty lines (paragraphs) into separate bubbles // Single newlines become
inside the bubble const isWelcome = suggestion.type === "welcome"; if (isWelcome && suggestion.message.includes("\n\n")) { const paragraphs = suggestion.message .split("\n\n") .map((p) => p.trim()) .filter((p) => p.length > 0); // Add all bubbles at once, use CSS animation-delay for stagger for (const [index, paragraph] of paragraphs.entries()) { const isFirst = index === 0; const isLast = index === paragraphs.length - 1; const paragraphSuggestion = { id: `${suggestion.id}-${index}`, message: paragraph, type: "welcome", isFirstInGroup: isFirst, isLastInGroup: isLast, groupId: suggestion.id, animationDelay: index * 0.1, }; this._appendBubble(paragraphSuggestion); } } else { this._appendBubble({ ...suggestion, isFirstInGroup: true, isLastInGroup: true, }); } } /** * Append a single bubble to the container * @param {Object} suggestion - Suggestion data * @private */ _appendBubble(suggestion) { if (!this.bubbleContainer) { return; } const wrapper = this._createSuggestionWrapper(suggestion); this.bubbleContainer.appendChild(wrapper); // Fade old bubbles (keep last 6) const wrappers = this.bubbleContainer.querySelectorAll( ".echo-suggestion-wrapper" ); if (wrappers.length > 6) { for (let i = 0; i < wrappers.length - 6; i++) { wrappers[i].style.opacity = "0.3"; wrappers[i].style.pointerEvents = "none"; } } } /** * Create a suggestion wrapper element * @param {Object} suggestion - Suggestion data * @returns {HTMLDivElement} Wrapper element * @private */ _createSuggestionWrapper(suggestion) { const wrapper = document.createElement("div"); wrapper.id = `echo-bubble-${suggestion.id}`; wrapper.className = "echo-suggestion-wrapper"; const delay = suggestion.animationDelay || 0; wrapper.style.cssText = ` display: flex; flex-direction: column; align-items: flex-end; gap: 6px; pointer-events: auto; animation: echo-bubble-enter 0.3s ease-out ${delay}s both; width: 100%; `; const messageRow = this._createMessageRow(suggestion); wrapper.appendChild(messageRow); if (suggestion.products?.length > 0) { const productsContainer = this._createProductsContainer(suggestion); wrapper.appendChild(productsContainer); } return wrapper; } /** * Create the message row with close button * @param {Object} suggestion - Suggestion data * @returns {HTMLDivElement} Message row element * @private */ _createMessageRow(suggestion) { // Determine position for alignment const { position } = this.config; const isRight = this.agentSettings.position ? this.agentSettings.position === "right" : position.includes("right"); const messageRow = document.createElement("div"); messageRow.style.cssText = ` display: flex; flex-direction: row; align-items: flex-start; justify-content: ${isRight ? "flex-end" : "flex-start"}; width: 100%; `; const bubble = this._createMessageBubble(suggestion); // Only show close button on the first bubble of a group (or single bubbles) const showCloseButton = suggestion.isFirstInGroup === true || suggestion.isFirstInGroup === undefined; if (showCloseButton) { const closeBtn = this._createCloseButton( suggestion.groupId || suggestion.id ); // Position close button inside bubble, on the outer edge (right for right-position, left for left-position) closeBtn.style.cssText = ` position: absolute; top: -6px; ${isRight ? "right: -6px;" : "left: -6px;"} background: #ffffff; border: 1px solid rgba(0, 0, 0, 0.08); border-radius: 50%; width: 20px; height: 20px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; color: #a1a1aa; opacity: 0; padding: 0; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); z-index: 1; `; // Override click to dismiss entire group closeBtn.onclick = (e) => { e.stopPropagation(); if (suggestion.groupId) { this._dismissBubbleGroup(suggestion.groupId); } else { this._dismissSuggestionBubble(suggestion.id); } }; bubble.appendChild(closeBtn); // On mobile (<=768px), always show the close button // On desktop, show/hide on hover const isMobile = window.matchMedia("(max-width: 768px)").matches; if (isMobile) { closeBtn.style.opacity = "1"; } else { bubble.addEventListener("mouseenter", () => { closeBtn.style.opacity = "1"; }); bubble.addEventListener("mouseleave", () => { closeBtn.style.opacity = "0"; }); } } messageRow.appendChild(bubble); return messageRow; } /** * Create the close button for a suggestion * @param {string} suggestionId - Suggestion ID * @returns {HTMLButtonElement} Close button element * @private */ _createCloseButton(suggestionId) { const closeBtn = document.createElement("button"); closeBtn.className = "echo-bubble-dismiss"; closeBtn.ariaLabel = "Dismiss suggestion"; closeBtn.style.cssText = ` background: rgba(255, 255, 255, 0.8); border: 1px solid rgba(0,0,0,0.05); border-radius: 50%; width: 24px; height: 24px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); color: #71717a; opacity: 0; padding: 0; flex-shrink: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.05); `; closeBtn.innerHTML = ` `; closeBtn.addEventListener("mouseenter", () => { closeBtn.style.background = "#ffffff"; closeBtn.style.transform = "scale(1.1)"; closeBtn.style.color = "#52525b"; closeBtn.style.borderColor = "rgba(0, 0, 0, 0.12)"; }); closeBtn.addEventListener("mouseleave", () => { closeBtn.style.background = "#ffffff"; closeBtn.style.transform = "scale(1)"; closeBtn.style.color = "#a1a1aa"; closeBtn.style.borderColor = "rgba(0, 0, 0, 0.08)"; }); closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this._dismissSuggestionBubble(suggestionId); }); return closeBtn; } /** * Create the message bubble element * @param {Object} suggestion - Suggestion data * @returns {HTMLDivElement} Message bubble element * @private */ _createMessageBubble(suggestion) { const isWelcome = suggestion.type === "welcome"; const isLastInGroup = suggestion.isLastInGroup !== false; // Determine position for bubble tail direction const { position } = this.config; const isRight = this.agentSettings.position ? this.agentSettings.position === "right" : position.includes("right"); const bubble = document.createElement("div"); bubble.className = "echo-suggestion-bubble"; // Different border-radius: last bubble has the "tail" corner pointing toward the widget // Right position: tail on bottom-right (18px 18px 4px 18px) // Left position: tail on bottom-left (18px 18px 18px 4px) const borderRadius = isLastInGroup ? isRight ? "18px 18px 4px 18px" : "18px 18px 18px 4px" : "18px"; // Clean, modern bubble design bubble.style.cssText = ` background: #ffffff; color: #18181b; border-radius: ${borderRadius}; padding: ${isWelcome ? "12px 16px" : "10px 14px"}; border: 1px solid rgba(0, 0, 0, 0.06); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.08); position: relative; cursor: pointer; transition: all 0.2s ease; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; width: fit-content; max-width: ${isWelcome ? "300px" : "85%"}; `; // Convert single newlines to
for line breaks within a bubble const formattedMessage = escapeHtml(suggestion.message).replace( /\n/g, "
" ); bubble.innerHTML = `

${formattedMessage}

`; // Clean design - no accent line needed // Hover effects - subtle lift bubble.addEventListener("mouseenter", () => { bubble.style.boxShadow = ` 0 2px 4px rgba(0, 0, 0, 0.08), 0 8px 16px rgba(0, 0, 0, 0.12) `; bubble.style.transform = "translateY(-1px)"; bubble.style.borderColor = "rgba(0, 0, 0, 0.08)"; }); bubble.addEventListener("mouseleave", () => { bubble.style.boxShadow = ` 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.08) `; bubble.style.transform = "translateY(0)"; bubble.style.borderColor = "rgba(0, 0, 0, 0.06)"; }); bubble.addEventListener("click", () => { debug("Bubble clicked:", suggestion.message); this.open(); // Dismiss all bubbles in the group if (suggestion.groupId) { this._dismissBubbleGroup(suggestion.groupId); } else { this._dismissSuggestionBubble(suggestion.id); } }); return bubble; } /** * Dismiss all bubbles in a group * @param {string} groupId - Group ID * @private */ _dismissBubbleGroup(groupId) { if (!this.bubbleContainer) { return; } const bubbles = this.bubbleContainer.querySelectorAll( `[id^="echo-bubble-${groupId}"]` ); for (const bubble of bubbles) { bubble.style.animation = "echo-bubble-exit 0.2s ease-out forwards"; setTimeout(() => bubble.remove(), 200); } } /** * Create the products container for a suggestion * @param {Object} suggestion - Suggestion data * @returns {HTMLDivElement} Products container element * @private */ _createProductsContainer(suggestion) { const container = document.createElement("div"); container.style.cssText = ` display: grid; grid-template-columns: repeat(5, 1fr); direction: rtl; gap: 6px; padding: 2px; width: 100%; `; container.className = "echo-products-grid"; for (const product of suggestion.products) { const productCard = this._createProductCard(product, suggestion.id); container.appendChild(productCard); } return container; } /** * Create a product card element * @param {Object} product - Product data * @param {string} suggestionId - Parent suggestion ID * @returns {HTMLDivElement} Product card element * @private */ _createProductCard(product, suggestionId) { let imageUrl = product.primaryImage || product.images?.[0] || ""; // Fix Vakkorama image URLs with placeholders if (imageUrl?.includes("{x}") && imageUrl?.includes("{y}")) { imageUrl = imageUrl.replace("{x}", "600").replace("{y}", "900"); } const productCard = document.createElement("div"); productCard.style.cssText = ` aspect-ratio: 1; width: 100%; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-radius: 8px; overflow: hidden; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.05); cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; `; productCard.innerHTML = `
${ imageUrl ? ` ${escapeHtml(product.name || ` : "" }
`; productCard.addEventListener("mouseenter", () => { productCard.style.transform = "translateY(-2px)"; productCard.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.08)"; }); productCard.addEventListener("mouseleave", () => { productCard.style.transform = "translateY(0)"; productCard.style.boxShadow = "0 2px 6px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.05)"; }); productCard.addEventListener("click", () => { debug("Product clicked:", product.name); this._trackEvent("proactive_click", { suggestionId, productId: product.id, productName: product.name, }); this._dismissSuggestionBubble(suggestionId); if (product.url) { this._handleNavigate(product.url); } else { this.open(); } }); return productCard; } /** * Dismiss a suggestion bubble * @param {string} suggestionId - ID of suggestion to dismiss * @private */ _dismissSuggestionBubble(suggestionId) { const wrapper = document.getElementById(`echo-bubble-${suggestionId}`); if (wrapper) { wrapper.style.animation = "echo-bubble-exit 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards"; setTimeout(() => wrapper.remove(), 300); } if (this.iframe?.contentWindow) { this.iframe.contentWindow.postMessage( { type: "echo:dismiss_suggestion", suggestionId, }, "*" ); } } // ========================================================================== // COMMAND CARD - Proactive AI Interface (iframe-based) // ========================================================================== /** * Show the command card (renders React UI via iframe) * @private */ _showCommandCard() { // Don't show if already visible or widget is open if (this.commandCardContainer || this.isOpen) { return; } // Also check DOM to prevent duplicates (race condition protection) if (document.getElementById("echo-command-card")) { return; } debug("Showing command card"); const { position } = this.config; const isRight = this.agentSettings.position ? this.agentSettings.position === "right" : position.includes("right"); const widgetBottomOffset = this.agentSettings.bottomOffset ?? 20; const widgetSideOffset = this.agentSettings.sideOffset ?? 20; const baseZIndex = this.agentSettings.zIndex ?? DEFAULT_Z_INDEX; const buttonSize = this.config.theme?.buttonSize || 56; const cardBottomOffset = widgetBottomOffset + buttonSize + 12; // Build iframe URL - just pass apiKey, page loads config internally const url = new URL("/embed/welcome-card", this.config.apiUrl); if (this.config.apiKey) { url.searchParams.set("apiKey", this.config.apiKey); } if (this.config.agentSlug) { url.searchParams.set("agent", this.config.agentSlug); } const iframeUrl = url.toString(); // Check for dark mode preference const isDarkMode = window.matchMedia( "(prefers-color-scheme: dark)" ).matches; // Create container for positioning and shadow const container = document.createElement("div"); container.id = "echo-command-card"; container.style.cssText = ` position: fixed; ${isRight ? `right: ${widgetSideOffset}px;` : `left: ${widgetSideOffset}px;`} bottom: ${cardBottomOffset}px; width: 320px; height: 260px; border-radius: 16px; overflow: hidden; background: ${isDarkMode ? "hsl(240 10% 3.9%)" : "#ffffff"}; border: 1px solid ${isDarkMode ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.08)"}; outline: 1px solid ${isDarkMode ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.06)"}; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.08), 0 12px 32px rgba(0, 0, 0, 0.12); z-index: ${baseZIndex + 2}; opacity: 0; transform: translateY(8px); transition: opacity 0.2s ease, transform 0.2s ease; `; // Create iframe const iframe = document.createElement("iframe"); iframe.src = iframeUrl; iframe.style.cssText = ` width: 100%; height: 100%; border: none; border-radius: 16px; background: ${isDarkMode ? "hsl(240 10% 3.9%)" : "#ffffff"}; `; container.appendChild(iframe); // Append to bubble wrapper for consistent z-index stacking if (this.bubbleWrapper) { this.bubbleWrapper.appendChild(container); } else { document.body.appendChild(container); } this.commandCardContainer = container; this.commandCardIframe = iframe; // Animate in requestAnimationFrame(() => { container.style.opacity = "1"; container.style.transform = "translateY(0)"; }); } /** * Handle messages from command card iframe * @param {MessageEvent} event - Message event * @private */ _handleCommandCardMessage(event) { if (event.data?.source !== "echo-command-card") { return; } if (event.data.type === "close") { this._dismissCommandCard(); return; } if (event.data.type === "submit" && event.data.text) { debug("Command card submit:", event.data.text); // Dismiss the card this._dismissCommandCard(); // Open the widget this.open(); // Send the message to the chat iframe after it loads setTimeout(() => { if (this.iframe?.contentWindow) { this.iframe.contentWindow.postMessage( { type: "echo:prefill", text: event.data.text, submit: true, }, "*" ); } }, 500); } } /** * Dismiss the command card * @private */ _dismissCommandCard() { if (!this.commandCardContainer) { return; } this.commandCardContainer.style.opacity = "0"; this.commandCardContainer.style.transform = "translateY(8px)"; setTimeout(() => { this.commandCardContainer?.remove(); this.commandCardContainer = null; this.commandCardIframe = null; }, 200); } // ========================================================================== // EVENT HANDLING // ========================================================================== /** * Handle messages from iframe * @param {MessageEvent} event - Message event * @private */ handleMessage(event) { if (!event.data || typeof event.data !== "object") { return; } if (event.data.type === "echo:debug") { debug("[Iframe]", event.data.message, event.data.data || ""); return; } // Handle command card messages if (event.data.source === "echo-command-card") { this._handleCommandCardMessage(event); return; } if (event.data.type === "echo:suggestion") { this._showSuggestionBubble(event.data.suggestion); return; } if (event.data.type === "echo:dismiss_suggestion") { this._dismissSuggestionBubble(event.data.id); return; } if (event.data.source !== "echo-widget") { return; } debug("Message from iframe:", event.data.type); // Handle request for available actions (iframe asking for current state) if (event.data.type === "echo:request_actions") { debug("Iframe requested available actions"); // If iframe can request actions, it's definitely ready this.iframeReady = true; this._broadcastAvailableActions(); return; } // Handle request for widget state (iframe asking for current expanded/open state) if (event.data.type === "requestWidgetState") { debug("Iframe requested widget state"); this._broadcastWidgetState(); return; } switch (event.data.type) { case "close": this.close(); break; case "minimize": this.minimize(); break; case "expand": this._expand(); break; case "collapse": this._collapse(); break; case "newChat": this._startNewChat(); break; case "newChatCreated": // Track new chat event when first message is sent this._trackEvent("new_chat", { chatId: event.data.chatId, }); debug("New chat created:", event.data.chatId); break; case "navigate": // Track product click when navigating to product URL if (event.data.url) { this._trackEvent("product_click", { url: event.data.url, }); } this._handleNavigate(event.data.url); break; case "addToCart": this._executeCallback({ callbackName: "addToCart", responseType: "addToCartResponse", payload: event.data.product, callbackId: event.data.callbackId, }); break; case "updateCart": this._executeCallback({ callbackName: "updateCart", responseType: "updateCartResponse", payload: event.data.payload, callbackId: event.data.callbackId, }); break; case "getCart": this._executeCallback({ callbackName: "getCart", responseType: "getCartResponse", payload: event.data.payload || {}, callbackId: event.data.callbackId, }); break; case "redirectToCheckout": this._executeCallback({ callbackName: "redirectToCheckout", responseType: "redirectToCheckoutResponse", payload: event.data.payload || {}, callbackId: event.data.callbackId, }); break; case "trackOrderState": this._executeCallback({ callbackName: "trackOrderState", responseType: "trackOrderStateResponse", payload: event.data.payload, callbackId: event.data.callbackId, }); break; case "getOrders": this._executeCallback({ callbackName: "getOrders", responseType: "getOrdersResponse", payload: event.data.payload || {}, callbackId: event.data.callbackId, }); break; case "customAction": this._invokeCallback("onCustomAction", event.data.payload); break; case "newMessage": if (!this.isOpen) { this._showUnreadBadge(); } break; case "userId": if ( event.data.userId && (!this.userId || this.userId !== event.data.userId) ) { this.userId = event.data.userId; setCookie( USER_ID_COOKIE_NAME, this.userId, USER_ID_COOKIE_LIFETIME_DAYS ); debug("User ID updated:", this.userId); } break; case "productClick": // Track product view from iframe (when user clicks product in gallery to view modal) if (event.data.productId) { this._trackEvent("product_view", { productId: event.data.productId, }); debug("Product view tracked from iframe:", event.data.productId); } break; case "chatId": if (event.data.chatId) { this.chatId = event.data.chatId; // Persist as active chat for session restoration this._setActiveChat(event.data.chatId); debug("Chat ID received and saved:", this.chatId); } break; case "setActiveChat": // iframe requests to set active chat (e.g., when switching conversations) if (event.data.chatId) { this.chatId = event.data.chatId; this._setActiveChat(event.data.chatId); debug("Active chat set by iframe:", this.chatId); } break; case "echo:agent-state": if (event.data.isLoading) { this.bubble.classList.add("loading"); } else { this.bubble.classList.remove("loading"); } break; default: // Handle dynamic client tool actions if (event.data.callbackId && this.callbacks[event.data.type]) { this._executeCallback({ callbackName: event.data.type, responseType: `${event.data.type}Response`, payload: event.data.payload || {}, callbackId: event.data.callbackId, }); } break; } } /** * Handle keyboard events * @param {KeyboardEvent} e - Keyboard event * @private */ handleKeyDown(e) { if (e.key === "Escape" && this.isOpen) { this.close(); } } /** * Attach event listeners * @private */ _attachEventListeners() { this.bubble.addEventListener("click", () => this.toggle()); window.addEventListener("message", this.handleMessage); document.addEventListener("keydown", this.handleKeyDown); window.addEventListener("popstate", this.handleNavigation); // Save widget state and flush events before page unload window.addEventListener("beforeunload", () => { this._flushEventQueue(true); // Use sendBeacon for reliability this._saveWidgetState(); }); // Intercept history navigation const originalPushState = history.pushState; const originalReplaceState = history.replaceState; const self = this; history.pushState = function (...args) { originalPushState.apply(this, args); self.handleNavigation(); }; history.replaceState = function (...args) { originalReplaceState.apply(this, args); self.handleNavigation(); }; } // ========================================================================== // WIDGET STATE MANAGEMENT // ========================================================================== /** * Show unread message badge * @private */ _showUnreadBadge() { this.unreadCount++; let badge = this.bubble.querySelector(".echo-widget-badge"); if (!badge) { badge = document.createElement("div"); badge.className = "echo-widget-badge"; this.bubble.appendChild(badge); } badge.textContent = this.unreadCount > 99 ? "99+" : this.unreadCount; } /** * Clear unread message badge * @private */ _clearUnreadBadge() { this.unreadCount = 0; const badge = this.bubble.querySelector(".echo-widget-badge"); if (badge) { badge.remove(); } } /** * Expand widget to fullscreen * @private */ _expand() { this.isExpanded = true; this.container.classList.add("expanded"); this._saveWidgetState(); this._broadcastWidgetState(); this._trackEvent("widget_expand"); } /** * Collapse widget from fullscreen * @private */ _collapse() { this.isExpanded = false; this.container.classList.remove("expanded"); this._saveWidgetState(); this._broadcastWidgetState(); this._trackEvent("widget_collapse"); } /** * Broadcast widget state to iframe * @private */ _broadcastWidgetState() { if (this.iframe?.contentWindow) { this.iframe.contentWindow.postMessage( { source: "echo-parent", type: "widgetState", isExpanded: this.isExpanded, isOpen: this.isOpen, }, "*" ); debug("Widget state broadcast to iframe:", { isExpanded: this.isExpanded, isOpen: this.isOpen, }); } } /** * Start a new chat session * @private */ _startNewChat() { const url = new URL("/embed", this.config.apiUrl); if (this.config.apiKey) { url.searchParams.set("apiKey", this.config.apiKey); } if (this.userId) { url.searchParams.set("userId", this.userId); } if (this.config.userEmail) { url.searchParams.set("userEmail", this.config.userEmail); } if (this.config.userIdentifier) { url.searchParams.set("userIdentifier", this.config.userIdentifier); } if (this.config.enableProactiveAgent) { url.searchParams.set("enableProactiveAgent", "true"); } url.searchParams.set("_t", Date.now().toString()); this.iframe.src = url.toString(); this._invokeCallback("onNewChat"); } // ========================================================================== // PUBLIC API // ========================================================================== /** * Toggle widget open/closed state * @public */ toggle() { if (this.isOpen || this.isMinimized) { this.close(); } else { this.open(); } } /** * Open the widget * @public */ open() { if (this.isHidden) { this.isHidden = false; debug("Widget auto-shown due to open()"); } this.isOpen = true; this.isMinimized = false; this.bubble.classList.add("open"); this.container.classList.add("open"); this.container.classList.remove("minimized"); if (this.isExpanded) { this.container.classList.add("expanded"); } this._clearUnreadBadge(); if (this.bubbleContainer) { this.bubbleContainer.style.display = "none"; } // Dismiss command card if open this._dismissCommandCard(); this._trackEvent("widget_open"); this._saveWidgetState(); this._broadcastWidgetState(); this._invokeCallback("onOpen"); } /** * Close the widget * @public */ close() { this.isOpen = false; this.isMinimized = false; this.isExpanded = false; this.bubble.classList.remove("open"); this.container.classList.remove("open", "minimized", "expanded"); if (this.bubbleContainer) { this.bubbleContainer.style.display = "flex"; } this._updateWidgetVisibility(); this._saveWidgetState(); this._trackEvent("widget_close"); this._invokeCallback("onClose"); } /** * Minimize the widget * @public */ minimize() { this.isMinimized = true; this.isOpen = false; this.isExpanded = false; this.container.classList.add("minimized"); this.container.classList.remove("open", "expanded"); this.bubble.classList.remove("open"); if (this.bubbleContainer) { this.bubbleContainer.style.display = "flex"; } this._updateWidgetVisibility(); this._saveWidgetState(); this._trackEvent("widget_minimize"); this._invokeCallback("onMinimize"); } /** * Manually trigger a page visibility check * Useful for SPAs after programmatic navigation * @public */ checkCurrentPage() { this.handleNavigation(); } /** * Hide the widget programmatically * The widget remains hidden until show() is called * @public */ hide() { this.isHidden = true; if (this.bubble) { this.bubble.style.display = "none"; } if (this.container) { this.container.style.display = "none"; } if (this.bubbleContainer) { this.bubbleContainer.style.display = "none"; } debug("Widget hidden programmatically"); } /** * Show the widget programmatically * Restores visibility based on current state and page rules * @public */ show() { this.isHidden = false; if (this.isOpen) { if (this.container) { this.container.classList.add("open"); } } else { this._updateWidgetVisibility(); } debug("Widget shown programmatically"); } /** * Send a message to the chat * @param {string} message - Message text * @public */ sendMessage(message) { if (!this.iframe?.contentWindow) { debug("Cannot send message: widget not mounted"); return; } if (!this.isOpen) { this.open(); } this.iframe.contentWindow.postMessage( { source: "echo-parent", type: "sendMessage", message, }, "*" ); } /** * Trigger a product search in the chat * @param {string} query - Search query * @public */ searchProducts(query) { if (!this.iframe?.contentWindow) { debug("Cannot search products: widget not mounted"); return; } if (!this.isOpen) { this.open(); } this.iframe.contentWindow.postMessage( { source: "echo-parent", type: "searchProducts", query, }, "*" ); } /** * Track a page view event * @param {Object} data - Page view data * @param {string} data.url - Page URL * @param {string} [data.title] - Page title * @public */ trackPageView(data) { if (this.iframe?.contentWindow) { this.iframe.contentWindow.postMessage( { type: "echo:page_view", data: { url: data.url, title: data.title, }, }, "*" ); } this._trackEvent("page_view", { url: data.url, title: data.title, }); debug("Page view tracked:", data.url); } /** * Track a product view event * @param {ProductViewData} product - Product data * @public */ viewedProduct(product) { if (!product.productId) { console.error("[EchoWidget] viewedProduct requires productId"); return; } if (this.iframe?.contentWindow) { this.iframe.contentWindow.postMessage( { type: "echo:product_view", data: { productId: product.productId, }, }, "*" ); } this._trackEvent("product_view", { productId: product.productId, }); debug("Product view tracked:", product.productId); } /** * Track when a recommendation is shown to the user * @param {string} productId - Product identifier * @public */ showRecommendation(productId) { if (!productId) { console.error("[EchoWidget] showRecommendation requires productId"); return; } if (this.iframe?.contentWindow) { this.iframe.contentWindow.postMessage( { type: "echo:recommendation_show", data: { productId, }, }, "*" ); } this._trackEvent("recommendation_show", { productId, }); debug("Recommendation show tracked:", productId); } /** * Track when a user clicks on a recommendation * @param {string} productId - Product identifier * @public */ clickRecommendation(productId) { if (!productId) { console.error("[EchoWidget] clickRecommendation requires productId"); return; } if (this.iframe?.contentWindow) { this.iframe.contentWindow.postMessage( { type: "echo:recommendation_click", data: { productId, }, }, "*" ); } this._trackEvent("recommendation_click", { productId, }); debug("Recommendation click tracked:", productId); } /** * Track a purchase event (GA4-compatible) * @param {PurchaseData} purchaseData - Purchase data * @public */ trackPurchase(purchaseData) { if (!purchaseData) { console.error( "[EchoWidget] trackPurchase requires purchaseData object" ); return; } if (!purchaseData.transaction_id) { console.error("[EchoWidget] trackPurchase requires transaction_id"); return; } if ( !purchaseData.items || !Array.isArray(purchaseData.items) || purchaseData.items.length === 0 ) { console.error( "[EchoWidget] trackPurchase requires items array with at least one item" ); return; } if (!validateItems(purchaseData.items, "trackPurchase")) { return; } this._trackEvent("purchase", purchaseData); debug("Purchase tracked:", purchaseData.transaction_id); } /** * Track an add to cart event (GA4-compatible) * @param {AddToCartTrackingData} cartData - Add to cart data * @public */ trackAddToCart(cartData) { if (!cartData) { console.error("[EchoWidget] trackAddToCart requires cartData object"); return; } if ( !cartData.items || !Array.isArray(cartData.items) || cartData.items.length === 0 ) { console.error( "[EchoWidget] trackAddToCart requires items array with at least one item" ); return; } if (!validateItems(cartData.items, "trackAddToCart")) { return; } this._trackEvent("add_to_cart", cartData); debug("Add to cart tracked:", cartData.items.length, "items"); } /** * Identify a user with traits and custom identifier * Supports two calling conventions: * * Segment.io style (recommended): * identify(userId, traits) * identify(userId) * identify({ email: "..." }) // userId inferred from email * * Legacy Echo style (still supported): * identify({ email: "...", userIdentifier: "..." }) * * @param {string|Object} userIdOrData - User ID string OR legacy identity object * @param {Object} [traits] - User traits (when using Segment.io style) * @param {string} [traits.email] - User's email address * @param {string} [traits.firstName] - User's first name * @param {string} [traits.lastName] - User's last name * @param {string} [traits.phone] - User's phone number * @returns {Promise} Promise resolving to identification result * @public */ async identify(userIdOrData, traits = {}) { // Parse arguments - detect calling convention let userIdentifier = null; let email = null; let allTraits = {}; if (typeof userIdOrData === "string") { // Segment.io style: identify(userId, traits) userIdentifier = userIdOrData; email = traits.email; allTraits = { ...traits }; } else if (userIdOrData && typeof userIdOrData === "object") { // Legacy Echo style: identify({ email, userIdentifier, ...traits }) userIdentifier = userIdOrData.userIdentifier; email = userIdOrData.email; const { email: _e, userIdentifier: _u, ...rest } = userIdOrData; allTraits = rest; } else { const error = new Error( "[EchoWidget] identify() requires a userId string or identity object" ); console.error(error.message); return Promise.reject(error); } // Validate at least one identifier is provided const hasEmail = email && typeof email === "string"; const hasUserIdentifier = userIdentifier && typeof userIdentifier === "string"; if (!hasEmail && !hasUserIdentifier) { const error = new Error( "[EchoWidget] identify() requires at least one of userId or email" ); console.error(error.message); return Promise.reject(error); } // Validate email format if provided if (hasEmail && !email.includes("@")) { const error = new Error( "[EchoWidget] identify() requires a valid email address" ); console.error(error.message); return Promise.reject(error); } // Require active user session if (!this.userId) { const error = new Error( "[EchoWidget] identify() requires an active user session" ); console.error(error.message); return Promise.reject(error); } try { // Extract known traits, rest goes to traits object const { firstName, lastName, phone, ...otherTraits } = allTraits; const response = await fetch( `${this.config.apiUrl}/api/user/identify`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": this.config.apiKey || "", }, body: JSON.stringify({ userId: this.userId, email: hasEmail ? email : undefined, userIdentifier: hasUserIdentifier ? userIdentifier : undefined, firstName: firstName || undefined, lastName: lastName || undefined, phone: phone || undefined, traits: Object.keys(otherTraits).length > 0 ? otherTraits : undefined, }), } ); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); if (!data.success) { throw new Error(data.error || "Identification failed"); } // Update internal state if (hasEmail) { this.config.userEmail = email; } if (hasUserIdentifier) { this.config.userIdentifier = userIdentifier; } // Handle userId change (email was already associated with different user) if (data.userIdChanged && data.userId) { this.userId = data.userId; setCookie( USER_ID_COOKIE_NAME, data.userId, USER_ID_COOKIE_LIFETIME_DAYS ); debug("User ID updated to existing user:", data.userId); } // Notify iframe of identification update (no reload needed) if (this.iframe?.contentWindow && this.iframeReady) { this.iframe.contentWindow.postMessage( { type: "echo:runtime:update_identity", email: data.email, userIdentifier: data.userIdentifier, userId: data.userId, }, "*" ); } debug("User identified:", { email: data.email, userIdentifier: data.userIdentifier, }); // Emit event for host page this._emitEvent("identified", { userId: data.userId, email: data.email, userIdentifier: data.userIdentifier, }); const result = { success: true, userId: data.userId, email: data.email, userIdentifier: data.userIdentifier, }; // Notify debug panel if (window.__echoDebugApi?.onCallback) { try { window.__echoDebugApi.onCallback({ name: "identify", payload: { email: hasEmail ? email : undefined, userIdentifier: hasUserIdentifier ? userIdentifier : undefined, }, timestamp: new Date().toISOString(), registered: true, result: { success: true }, }); } catch { // Silent fail for debug panel } } return result; } catch (error) { // Notify debug panel of failure if (window.__echoDebugApi?.onCallback) { try { window.__echoDebugApi.onCallback({ name: "identify", payload: { email: hasEmail ? email : undefined, userIdentifier: hasUserIdentifier ? userIdentifier : undefined, }, timestamp: new Date().toISOString(), registered: true, result: { success: false, error: error.message }, }); } catch { // Silent fail for debug panel } } console.error("[EchoWidget] Error identifying user:", error); throw error; } } /** * Track a custom event (Segment.io compatible) * @param {string} eventName - Name of the event * @param {Object} [properties] - Event properties * @public */ track(eventName, properties = {}) { if (!eventName || typeof eventName !== "string") { console.error("[EchoWidget] track() requires an event name string"); return; } this._trackEvent(eventName, properties); debug("Event tracked:", eventName, properties); } /** * Reset user identification to anonymous state * Clears the user ID cookie and notifies the iframe to reset * @returns {EchoWidgetClass} This instance for chaining * @public */ reset() { // Clear existing user data this.config.userEmail = null; this.config.userIdentifier = null; deleteCookie(USER_ID_COOKIE_NAME); // Generate new anonymous user ID (same as initialization) this.userId = crypto.randomUUID(); setCookie(USER_ID_COOKIE_NAME, this.userId, USER_ID_COOKIE_LIFETIME_DAYS); debug("Generated new anonymous user ID:", this.userId); // Sync new user to database (fire-and-forget, don't block reset) this._initializeUser().catch((err) => { console.warn("[EchoWidget] Failed to sync new user after reset:", err); }); // Guard against iframe not being mounted yet or already destroyed if (!this.iframe?.contentWindow) { debug("Cannot reset iframe: widget not mounted or not ready"); return this; } // Send reset message to iframe instead of reloading // The iframe will update its internal state and start a fresh chat this.iframe.contentWindow.postMessage( { type: "echo:runtime:reset_user", userId: this.userId, }, "*" ); debug("User reset to anonymous state, new userId:", this.userId); return this; } // ========================================================================== // CALLBACK REGISTRATION // ========================================================================== /** * Register a callback for adding items to cart * @param {function(AddToCartPayload): Promise} handler - Callback handler * @returns {EchoWidgetClass} This instance for chaining * @public */ onAddToCart(handler) { if (typeof handler !== "function") { console.error("[EchoWidget] onAddToCart handler must be a function"); return this; } this.callbacks.addToCart = handler; debug("onAddToCart callback registered"); this._broadcastAvailableActions(); return this; } /** * Register a callback for updating cart items (change quantity or remove) * @param {function(UpdateCartPayload): Promise} handler - Callback handler * @returns {EchoWidgetClass} This instance for chaining * @public */ onUpdateCart(handler) { if (typeof handler !== "function") { console.error("[EchoWidget] onUpdateCart handler must be a function"); return this; } this.callbacks.updateCart = handler; debug("onUpdateCart callback registered"); this._broadcastAvailableActions(); return this; } /** * Register a callback for getting cart contents * @param {function(): Promise} handler - Callback handler * @returns {EchoWidgetClass} This instance for chaining * @public */ onGetCart(handler) { if (typeof handler !== "function") { console.error("[EchoWidget] onGetCart handler must be a function"); return this; } this.callbacks.getCart = handler; debug("onGetCart callback registered"); this._broadcastAvailableActions(); return this; } /** * Register a callback for redirecting to checkout * @param {function({cartId?: string}): Promise} handler - Callback handler * @returns {EchoWidgetClass} This instance for chaining * @public */ onRedirectToCheckout(handler) { if (typeof handler !== "function") { console.error( "[EchoWidget] onRedirectToCheckout handler must be a function" ); return this; } this.callbacks.redirectToCheckout = handler; debug("onRedirectToCheckout callback registered"); this._broadcastAvailableActions(); return this; } /** * Register a callback for tracking order state * @param {function({orderId: string}): Promise} handler - Callback handler * @returns {EchoWidgetClass} This instance for chaining * @public */ onTrackOrderState(handler) { if (typeof handler !== "function") { console.error( "[EchoWidget] onTrackOrderState handler must be a function" ); return this; } this.callbacks.trackOrderState = handler; debug("onTrackOrderState callback registered"); this._broadcastAvailableActions(); return this; } /** * Register a callback for getting user's order history * @param {function(): Promise} handler - Callback handler * @returns {EchoWidgetClass} This instance for chaining * @public */ onGetOrders(handler) { if (typeof handler !== "function") { console.error("[EchoWidget] onGetOrders handler must be a function"); return this; } this.callbacks.getOrders = handler; debug("onGetOrders callback registered"); this._broadcastAvailableActions(); return this; } /** * Register a callback to receive events when the queue is flushed * @param {Function} handler - Callback receiving array of events * @returns {EchoWidgetClass} Widget instance for chaining */ onEvent(handler) { if (typeof handler !== "function") { console.error("[EchoWidget] onEvent handler must be a function"); return this; } this.config.onEvent = handler; debug("onEvent callback registered"); return this; } /** * Register a callback for handling navigation (SPA mode) * @param {function(string): void} handler - Callback receiving the URL to navigate to * @returns {EchoWidgetClass} Widget instance for chaining * @public */ onNavigate(handler) { if (typeof handler !== "function") { console.error("[EchoWidget] onNavigate handler must be a function"); return this; } this.config.onNavigate = handler; debug("onNavigate callback registered"); return this; } // ========================================================================== // SESSION STATE PERSISTENCE // ========================================================================== // ========================================================================== // STORAGE HELPERS // ========================================================================== /** * Get session storage data (echo_ss) * @returns {Object} Session data * @private */ _getSessionData() { try { return JSON.parse(sessionStorage.getItem(ECHO_SS) || "{}"); } catch { return {}; } } /** * Update session storage data (echo_ss) * @param {Object} updates - Data to merge into session storage * @private */ _setSessionData(updates) { try { const current = this._getSessionData(); sessionStorage.setItem( ECHO_SS, JSON.stringify({ ...current, ...updates }) ); } catch { // Silently fail - sessionStorage might not be available } } /** * Get local storage data (echo_ls) * @returns {Object} Local data * @private */ _getLocalData() { try { return JSON.parse(localStorage.getItem(ECHO_LS) || "{}"); } catch { return {}; } } /** * Update local storage data (echo_ls) * @param {Object} updates - Data to merge into local storage * @private */ _setLocalData(updates) { try { const current = this._getLocalData(); localStorage.setItem( ECHO_LS, JSON.stringify({ ...current, ...updates }) ); } catch { // Silently fail - localStorage might not be available } } /** * Get debug panel configuration from localStorage * @returns {Object} Debug config with experimentOverrides, sdkUrl, skipTracking * @private */ _getDebugConfig() { try { const data = this._getLocalData(); return data.debug || {}; } catch { return {}; } } /** * Update debug panel configuration in localStorage * @param {Object} updates - Debug config updates to merge * @private */ _setDebugConfig(updates) { try { const current = this._getLocalData(); const currentDebug = current.debug || {}; this._setLocalData({ debug: { ...currentDebug, ...updates }, }); } catch { // Silently fail } } /** * Get experiment override for a specific experiment * @param {string} experimentId - Experiment ID * @returns {string|null} Variant ID override or null * @private */ _getExperimentOverride(experimentId) { const debugConfig = this._getDebugConfig(); return debugConfig.experimentOverrides?.[experimentId] || null; } /** * Get a flag from session data (backward compat helper) * @param {string} flag - Flag name * @returns {boolean} * @private */ _getSessionFlag(flag) { return !!this._getSessionData()[flag]; } /** * Set a flag in session data (backward compat helper) * @param {string} flag - Flag name * @param {boolean} [value=true] - Flag value * @private */ _setSessionFlag(flag, value = true) { this._setSessionData({ [flag]: value }); } /** * Save widget state to sessionStorage (survives page refresh) * @private */ _saveWidgetState() { try { this._setSessionData({ widget: { isOpen: this.isOpen, isExpanded: this.isExpanded, }, }); debug("Widget state saved"); } catch (err) { console.warn("[EchoWidget] Failed to save widget state:", err.message); } } /** * Restore widget state from sessionStorage * @returns {Object|null} Restored widget state or null * @private */ _restoreWidgetState() { try { const data = this._getSessionData(); if (data.widget) { debug("Widget state restored:", data.widget); return data.widget; } return null; } catch (err) { console.warn( "[EchoWidget] Failed to restore widget state:", err.message ); return null; } } /** * Get the active chat ID from localStorage * @returns {string|null} Active chat ID or null * @private */ _getActiveChat() { return this._getLocalData().activeChat || null; } /** * Set the active chat ID in localStorage * @param {string} chatId - Chat ID to persist * @private */ _setActiveChat(chatId) { this._setLocalData({ activeChat: chatId }); debug("Active chat saved:", chatId); } // ========================================================================== // CLEANUP // ========================================================================== /** * Destroy the widget and clean up resources * @public */ destroy() { this._flushEventQueue(); window.removeEventListener("message", this.handleMessage); document.removeEventListener("keydown", this.handleKeyDown); window.removeEventListener("popstate", this.handleNavigation); this.bubble?.remove(); this.bubbleWrapper?.remove(); this.container?.remove(); this.bubbleContainer?.remove(); this.commandCardContainer?.remove(); document.getElementById("echo-suggestion-bubbles")?.remove(); this.bubble = null; this.bubbleWrapper = null; this.container = null; this.bubbleContainer = null; this.iframe = null; this.commandCardContainer = null; this.commandCardIframe = null; } } // ============================================================================ // GLOBAL API EXPORT // ============================================================================ /** * Echo Widget SDK * @namespace EchoWidget * @global */ window.EchoWidget = { /** * Initialize a new Echo Widget instance * @param {EchoWidgetConfig} config - Widget configuration * @returns {EchoWidgetClass} Widget instance * @memberof EchoWidget */ init(config) { if (window.EchoWidget.instance) { debug("Cleaning up existing instance"); try { window.EchoWidget.instance.destroy(); } catch (err) { console.warn( "[EchoWidget] Error destroying existing instance:", err.message ); } } const instance = new EchoWidgetClass(config); window.EchoWidget.instance = instance; return instance; }, /** * Get the current widget instance * @returns {EchoWidgetClass|undefined} Current widget instance * @memberof EchoWidget */ getInstance() { return window.EchoWidget.instance; }, /** * Callback error codes * @type {Object} * @memberof EchoWidget */ CallbackError, }; })();