From 1e8e0a90ee28c9a74f5da4c2d8c595b610d0d05c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 00:08:21 +0000 Subject: [PATCH 1/6] Add WebRTC dial widget for browser-based calling This feature allows websites to embed a dial widget that enables browser-based calling using WebRTC, providing the same functionality as calling the phone number. Backend changes: - Add WebRtcController for token generation and config endpoints - Add WebRtcCallController to handle TwiML webhooks for browser calls - Add WebRTC settings to SettingsService allowlist - Add routes for /api/v1/webrtc/token and /api/v1/webrtc/config - Add /webrtc-call and /webrtc-status TwiML webhook endpoints Frontend changes: - Add React DialWidget component with call controls - Add widget entry point for standalone bundle - Add widget-loader.js for easy embedding on external sites - Add widget-demo.html showing usage examples Configuration required: - webrtc_enabled: Enable/disable WebRTC calling - twilio_api_key: Twilio API Key SID - twilio_api_secret: Twilio API Key Secret - twilio_twiml_app_sid: TwiML Application SID Dependencies: - Add @twilio/voice-sdk for browser WebRTC support --- .../Controllers/Api/V1/WebRtcController.php | 103 ++++ .../Http/Controllers/WebRtcCallController.php | 143 +++++ src/app/Services/SettingsService.php | 7 +- src/package.json | 1 + src/public/widget-demo.html | 333 +++++++++++ src/public/widget-loader.js | 205 +++++++ src/resources/js/widget/DialWidget.jsx | 562 ++++++++++++++++++ src/resources/js/widget/index.js | 110 ++++ src/routes/api.php | 7 + src/routes/web.php | 4 +- src/webpack.mix.js | 14 + 11 files changed, 1487 insertions(+), 2 deletions(-) create mode 100644 src/app/Http/Controllers/Api/V1/WebRtcController.php create mode 100644 src/app/Http/Controllers/WebRtcCallController.php create mode 100644 src/public/widget-demo.html create mode 100644 src/public/widget-loader.js create mode 100644 src/resources/js/widget/DialWidget.jsx create mode 100644 src/resources/js/widget/index.js diff --git a/src/app/Http/Controllers/Api/V1/WebRtcController.php b/src/app/Http/Controllers/Api/V1/WebRtcController.php new file mode 100644 index 000000000..21b8f16d1 --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/WebRtcController.php @@ -0,0 +1,103 @@ +settings = $settings; + } + + /** + * Generate a Twilio Access Token for WebRTC calling + * + * @param Request $request + * @return JsonResponse + */ + public function token(Request $request): JsonResponse + { + // Check if WebRTC is enabled + if (!$this->settings->has('webrtc_enabled') || !$this->settings->get('webrtc_enabled')) { + return response()->json([ + 'error' => 'WebRTC calling is not enabled' + ], 403); + } + + // Validate required Twilio settings + $accountSid = $this->settings->get('twilio_account_sid'); + $apiKey = $this->settings->get('twilio_api_key'); + $apiSecret = $this->settings->get('twilio_api_secret'); + $twimlAppSid = $this->settings->get('twilio_twiml_app_sid'); + + if (empty($accountSid) || empty($apiKey) || empty($apiSecret) || empty($twimlAppSid)) { + Log::error('WebRTC token generation failed: Missing Twilio configuration'); + return response()->json([ + 'error' => 'WebRTC is not properly configured' + ], 500); + } + + try { + // Generate a unique identity for this caller + $identity = 'webrtc_' . uniqid(); + + // Create access token + $token = new AccessToken( + $accountSid, + $apiKey, + $apiSecret, + 3600, // Token valid for 1 hour + $identity + ); + + // Create Voice grant + $voiceGrant = new VoiceGrant(); + $voiceGrant->setOutgoingApplicationSid($twimlAppSid); + + // Add grant to token + $token->addGrant($voiceGrant); + + return response()->json([ + 'token' => $token->toJWT(), + 'identity' => $identity, + 'expires_in' => 3600 + ]); + } catch (\Exception $e) { + Log::error('WebRTC token generation failed: ' . $e->getMessage()); + return response()->json([ + 'error' => 'Failed to generate access token' + ], 500); + } + } + + /** + * Get WebRTC widget configuration + * + * @param Request $request + * @return JsonResponse + */ + public function config(Request $request): JsonResponse + { + if (!$this->settings->has('webrtc_enabled') || !$this->settings->get('webrtc_enabled')) { + return response()->json([ + 'error' => 'WebRTC calling is not enabled' + ], 403); + } + + return response()->json([ + 'enabled' => true, + 'title' => $this->settings->get('title') ?: 'Helpline', + 'language' => $this->settings->get('language') ?: 'en-US', + ]); + } +} diff --git a/src/app/Http/Controllers/WebRtcCallController.php b/src/app/Http/Controllers/WebRtcCallController.php new file mode 100644 index 000000000..44e88c35b --- /dev/null +++ b/src/app/Http/Controllers/WebRtcCallController.php @@ -0,0 +1,143 @@ +settings = $settings; + $this->call = $call; + $this->config = $config; + } + + /** + * Handle incoming WebRTC call from browser + * This is the TwiML Application webhook endpoint + * + * @param Request $request + * @return \Illuminate\Http\Response + */ + public function handleCall(Request $request) + { + Log::info('WebRTC call received', [ + 'from' => $request->input('From'), + 'callSid' => $request->input('CallSid'), + 'serviceBodyId' => $request->input('serviceBodyId'), + 'searchType' => $request->input('searchType'), + 'location' => $request->input('location'), + ]); + + $twiml = new VoiceResponse(); + + // Check if WebRTC is enabled + if (!$this->settings->has('webrtc_enabled') || !$this->settings->get('webrtc_enabled')) { + $twiml->say('WebRTC calling is not enabled. Goodbye.') + ->setVoice($this->settings->voice()) + ->setLanguage($this->settings->get('language')); + $twiml->hangup(); + return response($twiml)->header('Content-Type', 'text/xml; charset=utf-8'); + } + + // Get parameters from the browser + $serviceBodyId = $request->input('serviceBodyId'); + $searchType = $request->input('searchType', 'helpline'); + $location = $request->input('location'); + + // Build redirect URL based on search type + $baseUrl = url('/'); + $params = []; + + // Mark this as a WebRTC call + $params['webrtc'] = '1'; + + // If service body is specified, use it + if ($serviceBodyId) { + $params['override_service_body_id'] = $serviceBodyId; + } + + // Handle different search types + switch ($searchType) { + case 'helpline': + // Direct to helpline search with location if provided + if ($location) { + // Store location in session and redirect to address lookup + session()->put('webrtc_location', $location); + $redirectUrl = $baseUrl . '/address-lookup?' . http_build_query($params); + $twiml->redirect($redirectUrl); + } else { + // Go to normal IVR flow + $redirectUrl = $baseUrl . '/?' . http_build_query($params); + $twiml->redirect($redirectUrl); + } + break; + + case 'meeting': + // Direct to meeting search + if ($location) { + session()->put('webrtc_location', $location); + $params['SearchType'] = '2'; // Meeting search + $redirectUrl = $baseUrl . '/address-lookup?' . http_build_query($params); + $twiml->redirect($redirectUrl); + } else { + $params['SearchType'] = '2'; + $redirectUrl = $baseUrl . '/?' . http_build_query($params); + $twiml->redirect($redirectUrl); + } + break; + + case 'jft': + // Direct to Just For Today + $redirectUrl = $baseUrl . '/fetch-jft?' . http_build_query($params); + $twiml->redirect($redirectUrl); + break; + + case 'spad': + // Direct to SPAD + $redirectUrl = $baseUrl . '/fetch-spad?' . http_build_query($params); + $twiml->redirect($redirectUrl); + break; + + default: + // Default: go to main IVR + $redirectUrl = $baseUrl . '/?' . http_build_query($params); + $twiml->redirect($redirectUrl); + break; + } + + return response($twiml)->header('Content-Type', 'text/xml; charset=utf-8'); + } + + /** + * Handle WebRTC call status callbacks + * + * @param Request $request + * @return \Illuminate\Http\Response + */ + public function statusCallback(Request $request) + { + Log::info('WebRTC call status callback', [ + 'callSid' => $request->input('CallSid'), + 'callStatus' => $request->input('CallStatus'), + ]); + + // Return empty TwiML response + $twiml = new VoiceResponse(); + return response($twiml)->header('Content-Type', 'text/xml; charset=utf-8'); + } +} diff --git a/src/app/Services/SettingsService.php b/src/app/Services/SettingsService.php index 804cb2580..bca60020e 100644 --- a/src/app/Services/SettingsService.php +++ b/src/app/Services/SettingsService.php @@ -85,7 +85,12 @@ class SettingsService 'voice' => ['description' => '/general/language-options', 'default' => 'Polly.Kendra', 'overridable' => true, 'hidden' => false], 'voicemail_playback_grace_hours' => ['description' => '', 'default' => 48, 'overridable' => true, 'hidden' => false], 'volunteer_auto_answer' => ['description' => '/helpline/volunteer-auto-answer', 'default'=>false, 'overridable' => true, 'hidden' => false], - 'word_language' => ['description' => '', 'default' => 'en-US', 'overridable' => true, 'hidden' => false] + 'word_language' => ['description' => '', 'default' => 'en-US', 'overridable' => true, 'hidden' => false], + 'webrtc_enabled' => ['description' => '/webrtc/enabling-webrtc', 'default' => false, 'overridable' => false, 'hidden' => false], + 'twilio_api_key' => ['description' => '/webrtc/twilio-api-key', 'default' => '', 'overridable' => false, 'hidden' => true], + 'twilio_api_secret' => ['description' => '/webrtc/twilio-api-secret', 'default' => '', 'overridable' => false, 'hidden' => true], + 'twilio_twiml_app_sid' => ['description' => '/webrtc/twiml-app-sid', 'default' => '', 'overridable' => false, 'hidden' => true], + 'webrtc_allowed_origins' => ['description' => '/webrtc/cors-origins', 'default' => '*', 'overridable' => false, 'hidden' => false] ]; public static array $dateCalculationsMap = diff --git a/src/package.json b/src/package.json index 23c203fed..da9edf4c8 100644 --- a/src/package.json +++ b/src/package.json @@ -18,6 +18,7 @@ "test:e2e:headed": "npx playwright test --headed" }, "dependencies": { + "@twilio/voice-sdk": "^2.12.0", "@bmlt-enabled/croutonjs": "^3.18.3", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/src/public/widget-demo.html b/src/public/widget-demo.html new file mode 100644 index 000000000..db9f2b9d7 --- /dev/null +++ b/src/public/widget-demo.html @@ -0,0 +1,333 @@ + + + + + + Yap WebRTC Dial Widget Demo + + + +
+

Yap WebRTC Dial Widget

+

Enable browser-based calling on your website

+ +
+ +
+

Live Demo

+
+ +
+
+ +
+

Configuration Required

+

+ This demo requires WebRTC to be enabled in your Yap configuration. + Add the following settings to your config.php: +

+
+$webrtc_enabled = true; +$twilio_api_key = 'SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; +$twilio_api_secret = 'your_api_secret'; +$twilio_twiml_app_sid = 'APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; +
+
+
+ + +
+

Simple Embed (Data Attributes)

+

The easiest way to add the widget to your site:

+ +
+<!-- Add the widget container --> +<div + data-yap-widget + data-yap-api-url="https://your-yap-server.com" + data-yap-title="NA Helpline" +></div> + +<!-- Load the widget script --> +<script src="https://your-yap-server.com/widget-loader.js"></script> +
+ +
+

Available Data Attributes

+
    +
  • data-yap-api-url - Your Yap server URL (required)
  • +
  • data-yap-service-body - Service body ID to route calls
  • +
  • data-yap-title - Custom widget title
  • +
  • data-yap-show-location - Show location input (true/false)
  • +
  • data-yap-show-search-type - Show search type selector (true/false)
  • +
  • data-yap-search-type - Default: helpline, meeting, jft, spad
  • +
+
+
+ + +
+

Programmatic Initialization

+

For more control, initialize the widget with JavaScript:

+ +
+<div id="my-widget"></div> + +<script src="https://your-yap-server.com/widget-loader.js"></script> +<script> +YapWidget.load({ + container: '#my-widget', + apiUrl: 'https://your-yap-server.com', + serviceBodyId: '123', + title: 'NA Helpline', + showLocationInput: true, + showSearchType: false, + onCallStart: function() { + console.log('Call started'); + }, + onCallEnd: function(data) { + console.log('Call ended', data); + }, + onError: function(error) { + console.error('Call error', error); + } +}); +</script> +
+
+ + +
+

Custom Styling

+

Override the default styles to match your site:

+ +
+YapWidget.load({ + container: '#my-widget', + apiUrl: 'https://your-yap-server.com', + styles: { + container: { + backgroundColor: '#f5f5f5', + borderRadius: '20px', + boxShadow: 'none' + }, + callButton: { + backgroundColor: '#1976d2' + }, + title: { + color: '#1976d2' + } + } +}); +
+
+
+ + +
+

Twilio Setup Instructions

+

To enable WebRTC calling, you need to configure a few things in Twilio:

+ +
    +
  1. + Create an API Key: + Go to Twilio Console → Account → API keys → Create API Key. + Save the SID and Secret. +
  2. +
  3. + Create a TwiML Application: + Go to Twilio Console → Voice → TwiML Apps → Create new TwiML App. +
      +
    • Set the Voice Request URL to: https://your-yap-server.com/webrtc-call
    • +
    • Set the Status Callback URL to: https://your-yap-server.com/webrtc-status
    • +
    • Save the Application SID
    • +
    +
  4. +
  5. + Add settings to your Yap config.php: +
    +$webrtc_enabled = true; +$twilio_api_key = 'SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; +$twilio_api_secret = 'your_api_secret_here'; +$twilio_twiml_app_sid = 'APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; +
    +
  6. +
  7. + Test the widget: + Visit this demo page and try making a call! +
  8. +
+
+ + +
+ + + + + + diff --git a/src/public/widget-loader.js b/src/public/widget-loader.js new file mode 100644 index 000000000..b439a2153 --- /dev/null +++ b/src/public/widget-loader.js @@ -0,0 +1,205 @@ +/** + * Yap WebRTC Dial Widget Loader + * + * This is a lightweight loader script that external websites can include + * to easily embed the Yap dial widget on their pages. + * + * Usage: + * + * 1. Simple embed with data attributes: + *
+ * + * + * 2. Programmatic initialization: + *
+ * + * + */ + +(function() { + 'use strict'; + + // Get the script URL to determine the base URL + var scripts = document.getElementsByTagName('script'); + var currentScript = scripts[scripts.length - 1]; + var scriptUrl = currentScript.src; + var baseUrl = scriptUrl.substring(0, scriptUrl.lastIndexOf('/')); + + // Configuration + var config = { + widgetScriptUrl: baseUrl + '/js/dial-widget.js', + cssUrl: null, // Optional CSS URL + loaded: false, + loading: false, + queue: [], + }; + + // Check WebRTC support + function isWebRTCSupported() { + return !!( + navigator.mediaDevices && + navigator.mediaDevices.getUserMedia && + window.RTCPeerConnection + ); + } + + // Load the widget script + function loadWidgetScript(callback) { + if (config.loaded) { + callback(); + return; + } + + if (config.loading) { + config.queue.push(callback); + return; + } + + config.loading = true; + + var script = document.createElement('script'); + script.src = config.widgetScriptUrl; + script.async = true; + + script.onload = function() { + config.loaded = true; + config.loading = false; + callback(); + + // Process queued callbacks + while (config.queue.length > 0) { + var queuedCallback = config.queue.shift(); + queuedCallback(); + } + }; + + script.onerror = function() { + config.loading = false; + console.error('YapWidget: Failed to load widget script'); + }; + + document.head.appendChild(script); + + // Load optional CSS + if (config.cssUrl) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = config.cssUrl; + document.head.appendChild(link); + } + } + + // Initialize a widget + function initWidget(options) { + if (!isWebRTCSupported()) { + console.warn('YapWidget: WebRTC is not supported in this browser'); + if (options.container) { + var containerEl = typeof options.container === 'string' + ? document.querySelector(options.container) + : options.container; + if (containerEl) { + containerEl.innerHTML = '
Web calling is not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari.
'; + } + } + return null; + } + + loadWidgetScript(function() { + if (window.YapDialWidget && window.YapDialWidget.init) { + window.YapDialWidget.init(options.container, { + apiUrl: options.apiUrl, + serviceBodyId: options.serviceBodyId, + title: options.title, + showLocationInput: options.showLocationInput, + showSearchType: options.showSearchType, + defaultSearchType: options.defaultSearchType, + styles: options.styles, + onCallStart: options.onCallStart, + onCallEnd: options.onCallEnd, + onError: options.onError, + }); + } + }); + } + + // Auto-initialize widgets with data attributes on DOMContentLoaded + function autoInit() { + var widgets = document.querySelectorAll('[data-yap-widget]'); + + widgets.forEach(function(el) { + var apiUrl = el.getAttribute('data-yap-api-url') || el.getAttribute('data-api-url'); + if (!apiUrl) { + // Try to infer from the loader script URL + apiUrl = baseUrl; + } + + if (!apiUrl) { + console.error('YapWidget: data-yap-api-url is required'); + return; + } + + initWidget({ + container: el, + apiUrl: apiUrl, + serviceBodyId: el.getAttribute('data-yap-service-body') || el.getAttribute('data-service-body-id'), + title: el.getAttribute('data-yap-title') || el.getAttribute('data-title'), + showLocationInput: el.getAttribute('data-yap-show-location') !== 'false', + showSearchType: el.getAttribute('data-yap-show-search-type') === 'true', + defaultSearchType: el.getAttribute('data-yap-search-type') || 'helpline', + }); + }); + } + + // Public API + window.YapWidget = { + /** + * Load and initialize the widget + * @param {Object} options - Configuration options + */ + load: function(options) { + if (!options.apiUrl) { + options.apiUrl = baseUrl; + } + initWidget(options); + }, + + /** + * Check if WebRTC is supported + * @returns {boolean} + */ + isSupported: isWebRTCSupported, + + /** + * Get the base URL + * @returns {string} + */ + getBaseUrl: function() { + return baseUrl; + }, + + /** + * Preload the widget script without rendering + * @param {Function} callback - Called when script is loaded + */ + preload: function(callback) { + loadWidgetScript(callback || function() {}); + } + }; + + // Auto-init on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', autoInit); + } else { + // DOM already loaded + autoInit(); + } + + console.log('YapWidget loader ready'); +})(); diff --git a/src/resources/js/widget/DialWidget.jsx b/src/resources/js/widget/DialWidget.jsx new file mode 100644 index 000000000..9e683eb8c --- /dev/null +++ b/src/resources/js/widget/DialWidget.jsx @@ -0,0 +1,562 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; + +// Widget states +const STATES = { + INITIALIZING: 'initializing', + READY: 'ready', + CONNECTING: 'connecting', + CONNECTED: 'connected', + ERROR: 'error', + DISABLED: 'disabled' +}; + +// Default styles that can be overridden +const defaultStyles = { + container: { + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', + maxWidth: '320px', + padding: '20px', + backgroundColor: '#ffffff', + borderRadius: '12px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + border: '1px solid #e0e0e0', + }, + header: { + textAlign: 'center', + marginBottom: '16px', + }, + title: { + fontSize: '18px', + fontWeight: '600', + color: '#333', + margin: '0 0 4px 0', + }, + subtitle: { + fontSize: '14px', + color: '#666', + margin: 0, + }, + inputGroup: { + marginBottom: '16px', + }, + label: { + display: 'block', + fontSize: '14px', + fontWeight: '500', + color: '#333', + marginBottom: '6px', + }, + input: { + width: '100%', + padding: '12px', + fontSize: '14px', + border: '1px solid #ddd', + borderRadius: '8px', + boxSizing: 'border-box', + outline: 'none', + transition: 'border-color 0.2s', + }, + select: { + width: '100%', + padding: '12px', + fontSize: '14px', + border: '1px solid #ddd', + borderRadius: '8px', + boxSizing: 'border-box', + outline: 'none', + backgroundColor: '#fff', + cursor: 'pointer', + }, + button: { + width: '100%', + padding: '14px', + fontSize: '16px', + fontWeight: '600', + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + transition: 'all 0.2s', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + }, + callButton: { + backgroundColor: '#22c55e', + color: '#fff', + }, + callButtonHover: { + backgroundColor: '#16a34a', + }, + hangupButton: { + backgroundColor: '#ef4444', + color: '#fff', + }, + hangupButtonHover: { + backgroundColor: '#dc2626', + }, + disabledButton: { + backgroundColor: '#ccc', + color: '#666', + cursor: 'not-allowed', + }, + status: { + textAlign: 'center', + marginTop: '12px', + fontSize: '14px', + }, + statusConnecting: { + color: '#f59e0b', + }, + statusConnected: { + color: '#22c55e', + }, + statusError: { + color: '#ef4444', + }, + controls: { + display: 'flex', + gap: '12px', + marginTop: '12px', + }, + controlButton: { + flex: 1, + padding: '10px', + fontSize: '14px', + fontWeight: '500', + border: '1px solid #ddd', + borderRadius: '8px', + backgroundColor: '#fff', + cursor: 'pointer', + transition: 'all 0.2s', + }, + controlButtonActive: { + backgroundColor: '#f3f4f6', + borderColor: '#333', + }, + timer: { + textAlign: 'center', + fontSize: '24px', + fontWeight: '600', + color: '#333', + margin: '16px 0', + }, + errorMessage: { + backgroundColor: '#fef2f2', + border: '1px solid #fecaca', + borderRadius: '8px', + padding: '12px', + marginTop: '12px', + fontSize: '14px', + color: '#991b1b', + }, +}; + +// Phone icon SVG +const PhoneIcon = ({ calling = false }) => ( + + + +); + +// Mute icon SVG +const MuteIcon = ({ muted }) => ( + + {muted ? ( + <> + + + + + + + ) : ( + <> + + + + + + )} + +); + +// Format call duration +const formatDuration = (seconds) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; +}; + +export default function DialWidget({ + apiUrl, + serviceBodyId = null, + title = 'Helpline', + showLocationInput = true, + showSearchType = false, + defaultSearchType = 'helpline', + customStyles = {}, + onCallStart = () => {}, + onCallEnd = () => {}, + onError = () => {}, +}) { + const [state, setState] = useState(STATES.INITIALIZING); + const [device, setDevice] = useState(null); + const [call, setCall] = useState(null); + const [location, setLocation] = useState(''); + const [searchType, setSearchType] = useState(defaultSearchType); + const [isMuted, setIsMuted] = useState(false); + const [duration, setDuration] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); + const [isHovering, setIsHovering] = useState(false); + const [config, setConfig] = useState(null); + + const timerRef = useRef(null); + const deviceRef = useRef(null); + + // Merge custom styles with defaults + const styles = { + ...defaultStyles, + ...customStyles, + }; + + // Initialize Twilio Device + const initializeDevice = useCallback(async () => { + try { + // First, check if WebRTC is enabled and get config + const configResponse = await fetch(`${apiUrl}/api/v1/webrtc/config`); + if (!configResponse.ok) { + const error = await configResponse.json(); + throw new Error(error.error || 'WebRTC is not available'); + } + const configData = await configResponse.json(); + setConfig(configData); + + // Import Twilio Voice SDK dynamically + const { Device } = await import('@twilio/voice-sdk'); + + // Get access token + const tokenResponse = await fetch(`${apiUrl}/api/v1/webrtc/token`); + if (!tokenResponse.ok) { + const error = await tokenResponse.json(); + throw new Error(error.error || 'Failed to get access token'); + } + const tokenData = await tokenResponse.json(); + + // Create and register device + const twilioDevice = new Device(tokenData.token, { + codecPreferences: ['opus', 'pcmu'], + enableRingingState: true, + }); + + twilioDevice.on('registered', () => { + setState(STATES.READY); + }); + + twilioDevice.on('error', (error) => { + console.error('Twilio Device error:', error); + setErrorMessage(error.message || 'An error occurred'); + setState(STATES.ERROR); + onError(error); + }); + + twilioDevice.on('tokenWillExpire', async () => { + // Refresh token before it expires + try { + const response = await fetch(`${apiUrl}/api/v1/webrtc/token`); + const data = await response.json(); + twilioDevice.updateToken(data.token); + } catch (e) { + console.error('Failed to refresh token:', e); + } + }); + + await twilioDevice.register(); + deviceRef.current = twilioDevice; + setDevice(twilioDevice); + } catch (error) { + console.error('Failed to initialize device:', error); + setErrorMessage(error.message || 'Failed to initialize. Please try again.'); + setState(STATES.ERROR); + onError(error); + } + }, [apiUrl, onError]); + + // Initialize on mount + useEffect(() => { + initializeDevice(); + + return () => { + if (deviceRef.current) { + deviceRef.current.destroy(); + } + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; + }, [initializeDevice]); + + // Handle making a call + const handleCall = async () => { + if (!device || state !== STATES.READY) return; + + try { + setState(STATES.CONNECTING); + setErrorMessage(''); + + const params = { + searchType: searchType, + }; + + if (serviceBodyId) { + params.serviceBodyId = serviceBodyId; + } + + if (location.trim()) { + params.location = location.trim(); + } + + const newCall = await device.connect({ params }); + + newCall.on('accept', () => { + setState(STATES.CONNECTED); + setDuration(0); + timerRef.current = setInterval(() => { + setDuration(d => d + 1); + }, 1000); + onCallStart(); + }); + + newCall.on('disconnect', () => { + setState(STATES.READY); + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setCall(null); + setIsMuted(false); + onCallEnd({ duration }); + }); + + newCall.on('cancel', () => { + setState(STATES.READY); + setCall(null); + }); + + newCall.on('error', (error) => { + console.error('Call error:', error); + setErrorMessage(error.message || 'Call failed'); + setState(STATES.ERROR); + onError(error); + }); + + setCall(newCall); + } catch (error) { + console.error('Failed to make call:', error); + setErrorMessage(error.message || 'Failed to connect call'); + setState(STATES.ERROR); + onError(error); + } + }; + + // Handle hanging up + const handleHangup = () => { + if (call) { + call.disconnect(); + } + }; + + // Handle mute toggle + const handleMuteToggle = () => { + if (call) { + if (isMuted) { + call.mute(false); + } else { + call.mute(true); + } + setIsMuted(!isMuted); + } + }; + + // Handle retry after error + const handleRetry = () => { + setState(STATES.INITIALIZING); + setErrorMessage(''); + initializeDevice(); + }; + + // Render based on state + const renderContent = () => { + switch (state) { + case STATES.INITIALIZING: + return ( +
+

Initializing...

+
+ ); + + case STATES.DISABLED: + return ( +
+

Web calling is not available at this time.

+
+ ); + + case STATES.ERROR: + return ( + <> +
+

{errorMessage}

+
+ + + ); + + case STATES.READY: + return ( + <> + {showLocationInput && ( +
+ + setLocation(e.target.value)} + style={styles.input} + /> +
+ )} + + {showSearchType && ( +
+ + +
+ )} + + + + ); + + case STATES.CONNECTING: + return ( + <> +
+

Connecting...

+
+ + + ); + + case STATES.CONNECTED: + return ( + <> +
+ {formatDuration(duration)} +
+
+

Connected

+
+
+ +
+ + + ); + + default: + return null; + } + }; + + return ( +
+
+

{config?.title || title}

+

Click to call from your browser

+
+ {renderContent()} +
+ ); +} diff --git a/src/resources/js/widget/index.js b/src/resources/js/widget/index.js new file mode 100644 index 000000000..271381eeb --- /dev/null +++ b/src/resources/js/widget/index.js @@ -0,0 +1,110 @@ +/** + * Yap WebRTC Dial Widget + * + * This is the entry point for the embeddable dial widget. + * It can be loaded on external websites to allow browser-based calling. + */ + +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import DialWidget from './DialWidget'; + +// Export for programmatic use +export { DialWidget }; + +// Global initialization function +window.YapDialWidget = { + /** + * Initialize the dial widget in a container element + * + * @param {HTMLElement|string} container - DOM element or selector + * @param {Object} options - Configuration options + * @param {string} options.apiUrl - The base URL of your Yap installation + * @param {string} [options.serviceBodyId] - Optional service body ID to route calls + * @param {string} [options.title] - Custom title for the widget + * @param {boolean} [options.showLocationInput=true] - Show location input field + * @param {boolean} [options.showSearchType=false] - Show search type selector + * @param {string} [options.defaultSearchType='helpline'] - Default search type + * @param {Object} [options.styles] - Custom styles to override defaults + * @param {Function} [options.onCallStart] - Callback when call starts + * @param {Function} [options.onCallEnd] - Callback when call ends + * @param {Function} [options.onError] - Callback on error + */ + init: function(container, options = {}) { + // Get container element + let containerEl = container; + if (typeof container === 'string') { + containerEl = document.querySelector(container); + } + + if (!containerEl) { + console.error('YapDialWidget: Container element not found'); + return null; + } + + // Validate required options + if (!options.apiUrl) { + console.error('YapDialWidget: apiUrl is required'); + return null; + } + + // Create React root and render + const root = createRoot(containerEl); + root.render( + {})} + onCallEnd={options.onCallEnd || (() => {})} + onError={options.onError || (() => {})} + /> + ); + + return { + destroy: () => { + root.unmount(); + } + }; + }, + + /** + * Check if WebRTC is supported in this browser + */ + isSupported: function() { + return !!( + navigator.mediaDevices && + navigator.mediaDevices.getUserMedia && + window.RTCPeerConnection + ); + } +}; + +// Auto-initialize widgets with data attributes +document.addEventListener('DOMContentLoaded', function() { + const widgets = document.querySelectorAll('[data-yap-widget]'); + + widgets.forEach(function(el) { + const apiUrl = el.dataset.yapApiUrl || el.dataset.apiUrl; + if (!apiUrl) { + console.error('YapDialWidget: data-yap-api-url is required'); + return; + } + + window.YapDialWidget.init(el, { + apiUrl: apiUrl, + serviceBodyId: el.dataset.yapServiceBody || el.dataset.serviceBodyId, + title: el.dataset.yapTitle || el.dataset.title, + showLocationInput: el.dataset.yapShowLocation !== 'false', + showSearchType: el.dataset.yapShowSearchType === 'true', + defaultSearchType: el.dataset.yapSearchType || 'helpline', + }); + }); +}); + +// Log version +console.log('YapDialWidget loaded'); diff --git a/src/routes/api.php b/src/routes/api.php index d6631a611..9d1f07b8d 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\V1\Admin\AuthController; use App\Http\Controllers\Api\V1\Admin\SwaggerController; use App\Http\Controllers\Api\V1\Admin\VoicemailController; +use App\Http\Controllers\Api\V1\WebRtcController; use App\Http\Controllers\UpgradeAdvisorController; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Route; @@ -20,6 +21,12 @@ Route::get('upgrade', [UpgradeAdvisorController::class, 'index']); Route::get('/openapi.json', [SwaggerController::class, 'openapi'])->name('openapi'); + // WebRTC Widget endpoints (public, rate-limited) + Route::group(['prefix' => 'webrtc', 'middleware' => ['throttle:30,1']], function () { + Route::get('token', [WebRtcController::class, 'token'])->name('webrtc.token'); + Route::get('config', [WebRtcController::class, 'config'])->name('webrtc.config'); + }); + Route::group(['middleware' => ['auth:sanctum']], function () { Route::resource('user', 'AuthController')->only(['index']); Route::resource('volunteers', 'ConfigureVolunteersController')->only(['index', 'store']); diff --git a/src/routes/web.php b/src/routes/web.php index 8a8adc250..08d7dd78a 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -79,4 +79,6 @@ ->where('ext', $ext); Route::match(array('GET', 'POST'), "/helpline-dialer{ext}", 'App\Http\Controllers\HelplineController@dial') ->where('ext', $ext); -//Route::get("/callWidget", 'App\Http\Controllers\CallWidgetController@index'); +// WebRTC Call Handler - TwiML Application webhook endpoint +Route::match(array('GET', 'POST'), "/webrtc-call", 'App\Http\Controllers\WebRtcCallController@handleCall'); +Route::match(array('GET', 'POST'), "/webrtc-status", 'App\Http\Controllers\WebRtcCallController@statusCallback'); diff --git a/src/webpack.mix.js b/src/webpack.mix.js index 9845c508f..367b8d18d 100644 --- a/src/webpack.mix.js +++ b/src/webpack.mix.js @@ -11,11 +11,17 @@ const mix = require('laravel-mix'); | */ +// Main admin application mix.js('resources/js/index.js', 'public/js') .react() .sourceMaps() .css('resources/css/app.css', 'public/css'); +// WebRTC Dial Widget - standalone bundle for embedding +mix.js('resources/js/widget/index.js', 'public/js/dial-widget.js') + .react() + .sourceMaps(); + // Webpack 5 no longer includes Node.js polyfills by default mix.webpackConfig({ resolve: { @@ -23,4 +29,12 @@ mix.webpackConfig({ buffer: false, }, }, + output: { + // Ensure the widget exports are available globally + library: { + name: 'YapDialWidget', + type: 'umd', + export: 'default', + }, + }, }); From 7545ac4fcb74e08f9b0715dab62ffd10498ce122 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Mon, 29 Dec 2025 23:21:57 -0500 Subject: [PATCH 2/6] update package.lcok --- src/package-lock.json | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/package-lock.json b/src/package-lock.json index e710f4d7c..c5f7521ee 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -20,6 +20,7 @@ "@mui/material": "^7.0.0", "@mui/x-date-pickers": "^8.19.0", "@toolpad/core": "^0.16.0", + "@twilio/voice-sdk": "^2.12.0", "dayjs": "^1.11.19", "leaflet": "^1.9.3", "leaflet-fullscreen": "^1.0.2", @@ -3717,6 +3718,28 @@ "node": ">=10.13.0" } }, + "node_modules/@twilio/voice-errors": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@twilio/voice-errors/-/voice-errors-1.7.0.tgz", + "integrity": "sha512-9TvniWpzU0iy6SYFAcDP+HG+/mNz2yAHSs7+m0DZk86lE+LoTB6J/ZONTPuxXrXWi4tso/DulSHuA0w7nIQtGg==", + "license": "BSD-3-Clause" + }, + "node_modules/@twilio/voice-sdk": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/@twilio/voice-sdk/-/voice-sdk-2.17.0.tgz", + "integrity": "sha512-dAqAfQ59xexKdVi6U1TJAKlf6aDySAinjMvXrNdAFJDzSWJ5SNh49ITxdaKR2vaUdxTc7ncFgGVeI72W2dWHjg==", + "license": "Apache-2.0", + "dependencies": { + "@twilio/voice-errors": "1.7.0", + "@types/events": "^3.0.3", + "events": "3.3.0", + "loglevel": "1.9.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3853,6 +3876,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/events": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", + "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -6827,7 +6856,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -8664,6 +8692,19 @@ "dev": true, "license": "MIT" }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", From da827434e5cdc1b9a5a5222a02210b2e92eb1cc4 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Wed, 31 Dec 2025 00:06:22 -0500 Subject: [PATCH 3/6] fix missing api docs for webtrc --- TODO.md | 1 - .../Controllers/Api/V1/WebRtcController.php | 64 +++++++++ src/storage/api-docs/api-docs.json | 128 ++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 246b3961b..9c9147db0 100644 --- a/TODO.md +++ b/TODO.md @@ -2,4 +2,3 @@ - [ ] Report buttons not working - [ ] Text not getting localized across several pages -- [ ] Automerge renovates \ No newline at end of file diff --git a/src/app/Http/Controllers/Api/V1/WebRtcController.php b/src/app/Http/Controllers/Api/V1/WebRtcController.php index 21b8f16d1..65de87f85 100644 --- a/src/app/Http/Controllers/Api/V1/WebRtcController.php +++ b/src/app/Http/Controllers/Api/V1/WebRtcController.php @@ -10,6 +10,12 @@ use Twilio\Jwt\AccessToken; use Twilio\Jwt\Grants\VoiceGrant; +/** + * @OA\Tag( + * name="WebRTC", + * description="WebRTC calling endpoints for browser-based voice calls" + * ) + */ class WebRtcController extends Controller { protected SettingsService $settings; @@ -22,6 +28,39 @@ public function __construct(SettingsService $settings) /** * Generate a Twilio Access Token for WebRTC calling * + * @OA\Get( + * path="/api/v1/webrtc/token", + * tags={"WebRTC"}, + * summary="Generate a Twilio access token for WebRTC calling", + * description="Returns a JWT token that can be used to initialize a Twilio Voice SDK client for browser-based calling", + * @OA\Response( + * response=200, + * description="Token generated successfully", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="token", type="string", description="JWT access token for Twilio Voice SDK"), + * @OA\Property(property="identity", type="string", description="Unique identity for this caller"), + * @OA\Property(property="expires_in", type="integer", description="Token validity in seconds", example=3600) + * ) + * ), + * @OA\Response( + * response=403, + * description="WebRTC calling is not enabled", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="error", type="string", example="WebRTC calling is not enabled") + * ) + * ), + * @OA\Response( + * response=500, + * description="WebRTC is not properly configured or token generation failed", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="error", type="string", example="WebRTC is not properly configured") + * ) + * ) + * ) + * * @param Request $request * @return JsonResponse */ @@ -83,6 +122,31 @@ public function token(Request $request): JsonResponse /** * Get WebRTC widget configuration * + * @OA\Get( + * path="/api/v1/webrtc/config", + * tags={"WebRTC"}, + * summary="Get WebRTC widget configuration", + * description="Returns configuration settings for the WebRTC dial widget", + * @OA\Response( + * response=200, + * description="Configuration retrieved successfully", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="enabled", type="boolean", description="Whether WebRTC is enabled", example=true), + * @OA\Property(property="title", type="string", description="Helpline title", example="Helpline"), + * @OA\Property(property="language", type="string", description="Default language", example="en-US") + * ) + * ), + * @OA\Response( + * response=403, + * description="WebRTC calling is not enabled", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="error", type="string", example="WebRTC calling is not enabled") + * ) + * ) + * ) + * * @param Request $request * @return JsonResponse */ diff --git a/src/storage/api-docs/api-docs.json b/src/storage/api-docs/api-docs.json index 619e1a2e1..c3fb7e0bb 100644 --- a/src/storage/api-docs/api-docs.json +++ b/src/storage/api-docs/api-docs.json @@ -2300,6 +2300,130 @@ } } } + }, + "/api/v1/webrtc/token": { + "get": { + "tags": [ + "WebRTC" + ], + "summary": "Generate a Twilio access token for WebRTC calling", + "description": "Returns a JWT token that can be used to initialize a Twilio Voice SDK client for browser-based calling", + "operationId": "084176bd51bccd434ac83fda2c67fd20", + "responses": { + "200": { + "description": "Token generated successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "token": { + "description": "JWT access token for Twilio Voice SDK", + "type": "string" + }, + "identity": { + "description": "Unique identity for this caller", + "type": "string" + }, + "expires_in": { + "description": "Token validity in seconds", + "type": "integer", + "example": 3600 + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "WebRTC calling is not enabled", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "WebRTC calling is not enabled" + } + }, + "type": "object" + } + } + } + }, + "500": { + "description": "WebRTC is not properly configured or token generation failed", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "WebRTC is not properly configured" + } + }, + "type": "object" + } + } + } + } + } + } + }, + "/api/v1/webrtc/config": { + "get": { + "tags": [ + "WebRTC" + ], + "summary": "Get WebRTC widget configuration", + "description": "Returns configuration settings for the WebRTC dial widget", + "operationId": "ad01183f92677a7d17226bcb72456576", + "responses": { + "200": { + "description": "Configuration retrieved successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "enabled": { + "description": "Whether WebRTC is enabled", + "type": "boolean", + "example": true + }, + "title": { + "description": "Helpline title", + "type": "string", + "example": "Helpline" + }, + "language": { + "description": "Default language", + "type": "string", + "example": "en-US" + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "WebRTC calling is not enabled", + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string", + "example": "WebRTC calling is not enabled" + } + }, + "type": "object" + } + } + } + } + } + } } }, "components": { @@ -2348,6 +2472,10 @@ "name": "Voicemails", "description": "API Endpoints for managing voicemails" }, + { + "name": "WebRTC", + "description": "WebRTC calling endpoints for browser-based voice calls" + }, { "name": "Authentication", "description": "Authentication" From f998003da9dc19dcf75ecef1731577f9a379df28 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Sat, 3 Jan 2026 10:14:38 -0500 Subject: [PATCH 4/6] some tweaks to make the demo work --- .../Http/Controllers/CallFlowController.php | 31 +++-- src/public/mix-manifest.json | 2 + src/public/widget-demo.html | 76 ++++++++++- src/resources/js/widget/DialWidget.jsx | 124 +++++++++++++++++- src/webpack.mix.js | 9 +- 5 files changed, 214 insertions(+), 28 deletions(-) diff --git a/src/app/Http/Controllers/CallFlowController.php b/src/app/Http/Controllers/CallFlowController.php index b0918f911..98364aa58 100644 --- a/src/app/Http/Controllers/CallFlowController.php +++ b/src/app/Http/Controllers/CallFlowController.php @@ -60,20 +60,25 @@ public function __construct( public function index(Request $request) { - if ($request->has('CallSid')) { + // Check for misconfigured phone numbers, but skip for WebRTC calls + // WebRTC calls have no phoneNumberSid since they originate from a browser client + $isWebRtcCall = str_starts_with($request->get('From', ''), 'client:'); + if ($request->has('CallSid') && !$isWebRtcCall) { $phoneNumberSid = $this->twilio->client()->calls($request->get('CallSid'))->fetch()->phoneNumberSid; - $incomingPhoneNumber = $this->twilio->client()->incomingPhoneNumbers($phoneNumberSid)->fetch(); - - Log::debug(sprintf( - "Alert debugging:: phoneNumberSid:%s, incomingPhoneNumber:%s, statusCallback:%s", - $phoneNumberSid, - $incomingPhoneNumber, - $incomingPhoneNumber->statusCallback - )); - - if ($incomingPhoneNumber->statusCallback == null - || !str_contains($incomingPhoneNumber->statusCallback, "status.php")) { - $this->call->createMisconfiguredPhoneNumberAlert($incomingPhoneNumber->phoneNumber); + if ($phoneNumberSid) { + $incomingPhoneNumber = $this->twilio->client()->incomingPhoneNumbers($phoneNumberSid)->fetch(); + + Log::debug(sprintf( + "Alert debugging:: phoneNumberSid:%s, incomingPhoneNumber:%s, statusCallback:%s", + $phoneNumberSid, + $incomingPhoneNumber, + $incomingPhoneNumber->statusCallback + )); + + if ($incomingPhoneNumber->statusCallback == null + || !str_contains($incomingPhoneNumber->statusCallback, "status.php")) { + $this->call->createMisconfiguredPhoneNumberAlert($incomingPhoneNumber->phoneNumber); + } } } diff --git a/src/public/mix-manifest.json b/src/public/mix-manifest.json index 3aef3aba0..afc85c53f 100644 --- a/src/public/mix-manifest.json +++ b/src/public/mix-manifest.json @@ -4,6 +4,8 @@ "/images/vendor/leaflet/dist/layers-2x.png?8f2c4d11474275fbc1614b9098334eae": "/images/vendor/leaflet/dist/layers-2x.png?8f2c4d11474275fbc1614b9098334eae", "/images/vendor/leaflet/dist/marker-icon.png?2b3e1faf89f94a4835397e7a43b4f77d": "/images/vendor/leaflet/dist/marker-icon.png?2b3e1faf89f94a4835397e7a43b4f77d", "/js/index.js.map": "/js/index.js.map", + "/js/dial-widget.js": "/js/dial-widget.js", + "/js/dial-widget.js.map": "/js/dial-widget.js.map", "/css/app.css": "/css/app.css", "/css/app.css.map": "/css/app.css.map" } diff --git a/src/public/widget-demo.html b/src/public/widget-demo.html index db9f2b9d7..f1809675e 100644 --- a/src/public/widget-demo.html +++ b/src/public/widget-demo.html @@ -133,6 +133,32 @@ color: #92400e; } + .success-box { + background: #f0fdf4; + border: 1px solid #86efac; + border-radius: 8px; + padding: 16px; + margin-top: 20px; + display: none; + } + + .success-box h3 { + margin: 0 0 10px 0; + color: #166534; + font-size: 1rem; + } + + .success-box p { + margin: 0; + color: #15803d; + } + + .status-checking { + text-align: center; + color: #666; + padding: 10px; + } + .footer { text-align: center; color: rgba(255, 255, 255, 0.8); @@ -170,7 +196,16 @@

Live Demo

> -
+
+ Checking WebRTC configuration... +
+ +
+

WebRTC Configured

+

WebRTC is enabled and ready. Use the widget above to test browser-based calling.

+
+ + + {showKeypad && ( +
+ {KEYPAD_KEYS.map(({ digit, letters }) => ( + + ))} +
+ )}