/** * Dograh Voice Widget * Embeddable voice call widget for Dograh workflows * Version: 1.0.0 */ (function() { 'use strict'; // Widget configuration defaults const DEFAULT_CONFIG = { position: 'bottom-right', autoStart: false, apiBaseUrl: window.location.hostname === 'localhost' ? 'http://localhost:8000' : 'https://api.dograh.com' }; // Widget state const state = { config: {}, isInitialized: false, isOpen: false, pc: null, ws: null, stream: null, sessionToken: null, workflowRunId: null, connectionStatus: 'idle', // idle, connecting, connected, failed audioElement: null, callbacks: { onReady: null, onCallStart: null, onCallEnd: null, onError: null, onStatusChange: null } }; /** * Initialize the widget */ async function init() { if (state.isInitialized) return; // Get token from script URL const script = document.currentScript || document.querySelector('script[src*="dograh-widget.js"]'); if (!script) { console.error('Dograh Widget: Script not found'); return; } // Extract parameters from URL const scriptUrl = new URL(script.src); const token = scriptUrl.searchParams.get('token'); const apiEndpoint = scriptUrl.searchParams.get('apiEndpoint'); const environment = scriptUrl.searchParams.get('environment'); if (!token) { console.error('Dograh Widget: No token found in script URL'); return; } // Determine API base URL let apiBaseUrl = DEFAULT_CONFIG.apiBaseUrl; if (apiEndpoint) { // Use the apiEndpoint from URL parameter if provided // Ensure it has a protocol if (!apiEndpoint.startsWith('http://') && !apiEndpoint.startsWith('https://')) { // Default to https for production endpoints apiBaseUrl = 'https://' + apiEndpoint.replace(/\/+$/, ''); } else { apiBaseUrl = apiEndpoint.replace(/\/+$/, ''); // Remove trailing slashes } } else if (scriptUrl.origin.includes('localhost')) { apiBaseUrl = 'http://localhost:8000'; } else { apiBaseUrl = scriptUrl.origin.replace(/:\d+$/, ':8000'); } // Store base configuration state.config = { ...DEFAULT_CONFIG, token: token, apiBaseUrl: apiBaseUrl, environment: environment || 'production', // Allow data attributes to override fetched config contextVariables: parseContextVariables(script.getAttribute('data-dograh-context')) }; try { // Fetch configuration from API const configResponse = await fetch(`${state.config.apiBaseUrl}/api/v1/public/embed/config/${token}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'Origin': window.location.origin } }); if (!configResponse.ok) { throw new Error(`Failed to fetch config: ${configResponse.status}`); } const configData = await configResponse.json(); // Merge fetched configuration with defaults state.config = { ...state.config, workflowId: configData.workflow_id, embedMode: configData.settings?.embedMode || 'floating', containerId: configData.settings?.containerId || 'dograh-inline-container', position: configData.position || DEFAULT_CONFIG.position, autoStart: configData.auto_start || false }; } catch (error) { console.error('Dograh Widget: Failed to fetch configuration', error); return; } state.isInitialized = true; // Load styles injectStyles(); // Create widget UI based on mode if (state.config.embedMode === 'inline') { createInlineWidget(); } else { createFloatingWidget(); } // Trigger ready callback if (state.callbacks.onReady) { state.callbacks.onReady(); } // Auto-start if configured if (state.config.autoStart) { setTimeout(() => startCall(), 1000); } } /** * Parse context variables from JSON string */ function parseContextVariables(contextStr) { if (!contextStr) return {}; try { return JSON.parse(contextStr); } catch (e) { console.warn('Dograh Widget: Invalid context variables', e); return {}; } } /** * Inject widget styles */ function injectStyles() { if (document.getElementById('dograh-widget-styles')) return; const styles = ` .dograh-widget-container { position: fixed; z-index: 999999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } .dograh-widget-container.bottom-right { bottom: 20px; right: 20px; } .dograh-widget-container.bottom-left { bottom: 20px; left: 20px; } .dograh-widget-container.top-right { top: 20px; right: 20px; } .dograh-widget-container.top-left { top: 20px; left: 20px; } .dograh-widget-button { color: white; border: none; border-radius: 50%; width: 60px; height: 60px; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); transition: all 0.3s ease; } .dograh-widget-button:hover { transform: scale(1.1); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); } .dograh-widget-button:active { transform: scale(0.95); } /* Green button for idle/ready state */ .dograh-widget-button-idle { background: #10b981; } .dograh-widget-button-idle:hover { background: #059669; } /* Orange button for connecting state */ .dograh-widget-button-connecting { background: #f59e0b; animation: pulse 2s infinite; } /* Red button for connected state (to end call) */ .dograh-widget-button-connected { background: #ef4444; } .dograh-widget-button-connected:hover { background: #dc2626; } /* Red button for failed state */ .dograh-widget-button-failed { background: #ef4444; opacity: 0.8; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } `; const styleSheet = document.createElement('style'); styleSheet.id = 'dograh-widget-styles'; styleSheet.textContent = styles; document.head.appendChild(styleSheet); } /** * Create floating widget UI (simplified - no modal) */ function createFloatingWidget() { // Create container const container = document.createElement('div'); container.className = `dograh-widget-container ${state.config.position}`; container.id = 'dograh-widget'; // Create button (green to start, red to end) const button = document.createElement('button'); button.className = 'dograh-widget-button dograh-widget-button-idle'; button.id = 'dograh-widget-button'; button.innerHTML = ` `; button.onclick = toggleCall; // Create hidden audio element const audio = document.createElement('audio'); audio.id = 'dograh-widget-audio'; audio.autoplay = true; audio.style.display = 'none'; // Append elements container.appendChild(button); container.appendChild(audio); document.body.appendChild(container); // Store audio element reference state.audioElement = audio; } /** * Toggle call (start or stop based on current state) */ function toggleCall() { if (state.connectionStatus === 'idle' || state.connectionStatus === 'failed') { startCall(); } else { stopCall(); } } /** * Update floating widget button appearance */ function updateFloatingButton(status) { const button = document.getElementById('dograh-widget-button'); if (!button) return; // Remove all status classes button.classList.remove('dograh-widget-button-idle', 'dograh-widget-button-connecting', 'dograh-widget-button-connected', 'dograh-widget-button-failed'); // Add current status class button.classList.add(`dograh-widget-button-${status}`); // Update title attribute for tooltip const titles = { idle: 'Start Call', connecting: 'Connecting...', connected: 'End Call', failed: 'Retry Call' }; button.title = titles[status] || 'Voice Call'; } /** * Create inline widget UI */ function createInlineWidget() { // Find container element const container = document.getElementById(state.config.containerId); if (!container) { console.error(`Dograh Widget: Container element with id "${state.config.containerId}" not found`); if (state.callbacks.onError) { state.callbacks.onError(new Error('Container element not found')); } return; } // Clear container container.innerHTML = ''; container.className = 'dograh-inline-container'; // Add minimal inline styles const inlineStyles = ` .dograh-inline-container { min-height: 200px; padding: 20px; display: flex; align-items: center; justify-content: center; } .dograh-inline-status { text-align: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } .dograh-inline-status-icon { width: 64px; height: 64px; margin: 0 auto 20px; } .dograh-inline-status-text { font-size: 18px; font-weight: 500; margin: 0 0 8px; color: #111827; } .dograh-inline-status-subtext { font-size: 14px; color: #6b7280; margin: 0 0 20px; } .dograh-inline-button-container { display: flex; gap: 12px; justify-content: center; margin-top: 20px; } .dograh-inline-btn { padding: 12px 32px; border-radius: 8px; border: none; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.3s; color: white; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .dograh-inline-btn:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } .dograh-inline-btn:active { transform: translateY(0); } .dograh-inline-btn-start { background: #10b981; } .dograh-inline-btn-start:hover { background: #059669; } .dograh-inline-btn-end { background: #ef4444; } .dograh-inline-btn-end:hover { background: #dc2626; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .dograh-inline-pulse { animation: pulse 2s infinite; } `; // Add inline styles if not already added if (!document.getElementById('dograh-inline-styles')) { const styleSheet = document.createElement('style'); styleSheet.id = 'dograh-inline-styles'; styleSheet.textContent = inlineStyles; document.head.appendChild(styleSheet); } // Create initial status display updateInlineStatus('idle'); // Store audio element (hidden) state.audioElement = document.createElement('audio'); state.audioElement.autoplay = true; state.audioElement.style.display = 'none'; container.appendChild(state.audioElement); // Mark widget as open (for inline mode, it's always "open") state.isOpen = true; } /** * Update inline widget status */ function updateInlineStatus(status, text, subtext) { const container = document.getElementById(state.config.containerId); if (!container) return; // Update state state.connectionStatus = status; // Determine display text const displayText = text || { idle: 'Ready to Connect', connecting: 'Connecting...', connected: 'Call Active', failed: 'Connection Failed' }[status]; const displaySubtext = subtext || { idle: 'Click to start voice conversation', connecting: 'Please wait while we establish connection', connected: 'You can speak now', failed: 'Please check your microphone and try again' }[status]; // Simple button design: green to start, red to end let buttonHTML = ''; if (status === 'idle' || status === 'failed') { // Green button to start buttonHTML = ` `; } else if (status === 'connecting' || status === 'connected') { // Red button to end buttonHTML = ` `; } // Update container content (preserve audio element) const audioElement = state.audioElement; container.innerHTML = `
${displayText}
${displaySubtext}