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
new file mode 100644
index 000000000..65de87f85
--- /dev/null
+++ b/src/app/Http/Controllers/Api/V1/WebRtcController.php
@@ -0,0 +1,167 @@
+settings = $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
+ */
+ 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
+ *
+ * @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
+ */
+ 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/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/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/Http/Kernel.php b/src/app/Http/Kernel.php
index f79ada31e..3722daa46 100644
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -14,6 +14,7 @@
use App\Http\Middleware\SmsBlackhole;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustProxies;
+use App\Http\Middleware\ValidateTwilioSignature;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
@@ -99,5 +100,6 @@ class Kernel extends HttpKernel
'signed' => ValidateSignature::class,
'throttle' => ThrottleRequests::class,
'verified' => EnsureEmailIsVerified::class,
+ 'twilio.signature' => ValidateTwilioSignature::class,
];
}
diff --git a/src/app/Http/Middleware/TrustProxies.php b/src/app/Http/Middleware/TrustProxies.php
index 0c7d3b6bb..328934207 100644
--- a/src/app/Http/Middleware/TrustProxies.php
+++ b/src/app/Http/Middleware/TrustProxies.php
@@ -10,9 +10,12 @@ class TrustProxies extends Middleware
/**
* The trusted proxies for this application.
*
+ * Set to '*' to trust all proxies (needed for ngrok, load balancers, etc.)
+ * This is safe because we validate Twilio signatures on webhook endpoints.
+ *
* @var array|string|null
*/
- protected $proxies;
+ protected $proxies = '*';
/**
* The headers that should be used to detect proxies.
diff --git a/src/app/Http/Middleware/ValidateTwilioSignature.php b/src/app/Http/Middleware/ValidateTwilioSignature.php
new file mode 100644
index 000000000..e27bc2095
--- /dev/null
+++ b/src/app/Http/Middleware/ValidateTwilioSignature.php
@@ -0,0 +1,106 @@
+settings = $settings;
+ }
+
+ /**
+ * Handle an incoming request.
+ *
+ * Validates that the request is actually from Twilio by checking
+ * the X-Twilio-Signature header against the request body.
+ *
+ * @param Request $request
+ * @param Closure $next
+ * @return mixed
+ */
+ public function handle(Request $request, Closure $next): mixed
+ {
+ $authToken = $this->settings->get('twilio_auth_token');
+
+ // If no auth token configured, skip validation (for development)
+ if (empty($authToken)) {
+ Log::warning('Twilio signature validation skipped: no auth token configured');
+ return $next($request);
+ }
+
+ $validator = new RequestValidator($authToken);
+ $signature = $request->header('X-Twilio-Signature', '');
+
+ // Build the URL that Twilio used to sign the request
+ // When behind a proxy (ngrok, load balancer), we need to use the original URL
+ $url = $this->getSignatureUrl($request);
+
+ // For POST requests, Twilio signs using POST body params only (not query params)
+ // $request->all() merges both, so we need to use post() for POST requests
+ $params = $request->isMethod('POST') ? $request->post() : [];
+
+ Log::debug('Twilio signature validation attempt', [
+ 'url' => $url,
+ 'method' => $request->method(),
+ 'params_count' => count($params),
+ 'has_signature' => !empty($signature),
+ ]);
+
+ if (!$validator->validate($signature, $url, $params)) {
+ Log::warning('Invalid Twilio signature rejected', [
+ 'url' => $url,
+ 'ip' => $request->ip(),
+ ]);
+ return response('Forbidden', 403);
+ }
+
+ return $next($request);
+ }
+
+ /**
+ * Get the URL that Twilio used to sign the request.
+ *
+ * When behind a reverse proxy (ngrok, load balancer, etc.), the URL
+ * Laravel sees may differ from what Twilio sent the request to.
+ * We reconstruct the original URL from forwarded headers.
+ *
+ * @param Request $request
+ * @return string
+ */
+ protected function getSignatureUrl(Request $request): string
+ {
+ // Check for X-Original-Host (set by some proxies) or X-Forwarded-Host
+ $host = $request->header('X-Original-Host')
+ ?? $request->header('X-Forwarded-Host')
+ ?? $request->getHost();
+
+ // Check for X-Forwarded-Proto for the scheme
+ $scheme = $request->header('X-Forwarded-Proto') ?? $request->getScheme();
+
+ // Use the raw REQUEST_URI to preserve exact encoding as Twilio sent it
+ // Laravel's getRequestUri() may re-encode the query string differently
+ $requestUri = $_SERVER['REQUEST_URI'] ?? $request->getRequestUri();
+
+ $url = $scheme . '://' . $host . $requestUri;
+
+ Log::debug('Twilio signature validation URL constructed', [
+ 'constructed_url' => $url,
+ 'scheme' => $scheme,
+ 'host' => $host,
+ 'request_uri' => $requestUri,
+ 'x_forwarded_host' => $request->header('X-Forwarded-Host'),
+ 'x_forwarded_proto' => $request->header('X-Forwarded-Proto'),
+ ]);
+
+ return $url;
+ }
+}
diff --git a/src/app/Providers/RouteServiceProvider.php b/src/app/Providers/RouteServiceProvider.php
index 19664568b..171220dc2 100644
--- a/src/app/Providers/RouteServiceProvider.php
+++ b/src/app/Providers/RouteServiceProvider.php
@@ -7,6 +7,7 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
+use App\Services\SettingsService;
class RouteServiceProvider extends ServiceProvider
{
@@ -59,5 +60,17 @@ protected function configureRateLimiting()
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60);
});
+
+ // WebRTC token endpoint - configurable limit (default: 5/min)
+ RateLimiter::for('webrtc-token', function (Request $request) {
+ $limit = app(SettingsService::class)->get('webrtc_token_rate_limit') ?: 5;
+ return Limit::perMinute($limit)->by($request->ip());
+ });
+
+ // WebRTC call endpoint - configurable limit (default: 3/min)
+ RateLimiter::for('webrtc-call', function (Request $request) {
+ $limit = app(SettingsService::class)->get('webrtc_call_rate_limit') ?: 3;
+ return Limit::perMinute($limit)->by($request->ip());
+ });
}
}
diff --git a/src/app/Services/SettingsService.php b/src/app/Services/SettingsService.php
index 804cb2580..374de8767 100644
--- a/src/app/Services/SettingsService.php
+++ b/src/app/Services/SettingsService.php
@@ -85,7 +85,14 @@ 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],
+ 'webrtc_token_rate_limit' => ['description' => '/webrtc/rate-limiting', 'default' => 5, 'overridable' => false, 'hidden' => false],
+ 'webrtc_call_rate_limit' => ['description' => '/webrtc/rate-limiting', 'default' => 3, 'overridable' => false, 'hidden' => false]
];
public static array $dateCalculationsMap =
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",
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/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
new file mode 100644
index 000000000..f1809675e
--- /dev/null
+++ b/src/public/widget-demo.html
@@ -0,0 +1,401 @@
+
+
+
+
+
+ Yap WebRTC Dial Widget Demo
+
+
+
+
+
Yap WebRTC Dial Widget
+
Enable browser-based calling on your website
+
+
+
+
+
Live Demo
+
+
+
+ Checking WebRTC configuration...
+
+
+
+
WebRTC Configured
+
WebRTC is enabled and ready. Use the widget above to test browser-based calling.
+
+
+
+
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:
+
+
+
+<div
+ data-yap-widget
+ data-yap-api-url="https://your-yap-server.com"
+ data-yap-title="NA Helpline"
+></div>
+
+
+<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:
+
+
+ -
+ Create an API Key:
+ Go to Twilio Console → Account → API keys → Create API Key.
+ Save the SID and Secret.
+
+ -
+ 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
+
+
+ -
+ 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';
+
+
+ -
+ Test the widget:
+ Visit this demo page and try making a call!
+
+
+
+
+
+
+
+
+
+
+
+
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..252155237
--- /dev/null
+++ b/src/resources/js/widget/DialWidget.jsx
@@ -0,0 +1,680 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { Device } from '@twilio/voice-sdk';
+
+// 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',
+ },
+ keypad: {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(3, 1fr)',
+ gap: '8px',
+ marginTop: '12px',
+ padding: '12px',
+ backgroundColor: '#f9fafb',
+ borderRadius: '8px',
+ },
+ keypadButton: {
+ padding: '12px',
+ fontSize: '18px',
+ fontWeight: '600',
+ border: '1px solid #e5e7eb',
+ borderRadius: '8px',
+ backgroundColor: '#fff',
+ cursor: 'pointer',
+ transition: 'all 0.15s',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: '50px',
+ },
+ keypadButtonPressed: {
+ backgroundColor: '#e5e7eb',
+ transform: 'scale(0.95)',
+ },
+ keypadSubtext: {
+ fontSize: '9px',
+ fontWeight: '400',
+ color: '#6b7280',
+ marginTop: '2px',
+ letterSpacing: '1px',
+ },
+};
+
+// Phone icon SVG
+const PhoneIcon = ({ calling = false }) => (
+
+);
+
+// Mute icon SVG
+const MuteIcon = ({ muted }) => (
+
+);
+
+// Keypad icon SVG
+const KeypadIcon = () => (
+
+);
+
+// DTMF keypad data
+const KEYPAD_KEYS = [
+ { digit: '1', letters: '' },
+ { digit: '2', letters: 'ABC' },
+ { digit: '3', letters: 'DEF' },
+ { digit: '4', letters: 'GHI' },
+ { digit: '5', letters: 'JKL' },
+ { digit: '6', letters: 'MNO' },
+ { digit: '7', letters: 'PQRS' },
+ { digit: '8', letters: 'TUV' },
+ { digit: '9', letters: 'WXYZ' },
+ { digit: '*', letters: '' },
+ { digit: '0', letters: '+' },
+ { digit: '#', letters: '' },
+];
+
+// 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 [showKeypad, setShowKeypad] = useState(false);
+ const [pressedKey, setPressedKey] = 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);
+
+ // 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);
+ setShowKeypad(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 keypad toggle
+ const handleKeypadToggle = () => {
+ setShowKeypad(!showKeypad);
+ };
+
+ // Handle DTMF digit press
+ const handleDtmfPress = (digit) => {
+ if (call) {
+ call.sendDigits(digit);
+ setPressedKey(digit);
+ // Visual feedback - clear after 150ms
+ setTimeout(() => setPressedKey(null), 150);
+ }
+ };
+
+ // Handle retry after error
+ const handleRetry = () => {
+ setState(STATES.INITIALIZING);
+ setErrorMessage('');
+ initializeDevice();
+ };
+
+ // Render based on state
+ const renderContent = () => {
+ switch (state) {
+ case STATES.INITIALIZING:
+ return (
+
+ );
+
+ case STATES.DISABLED:
+ return (
+
+
Web calling is not available at this time.
+
+ );
+
+ case STATES.ERROR:
+ return (
+ <>
+
+
+ >
+ );
+
+ case STATES.READY:
+ return (
+ <>
+ {showLocationInput && (
+
+
+ setLocation(e.target.value)}
+ style={styles.input}
+ />
+
+ )}
+
+ {showSearchType && (
+
+
+
+
+ )}
+
+
+ >
+ );
+
+ case STATES.CONNECTING:
+ return (
+ <>
+
+
+ >
+ );
+
+ case STATES.CONNECTED:
+ return (
+ <>
+
+ {formatDuration(duration)}
+
+
+
+
+
+
+ {showKeypad && (
+
+ {KEYPAD_KEYS.map(({ digit, letters }) => (
+
+ ))}
+
+ )}
+
+ >
+ );
+
+ 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..cf72070d0 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,17 @@
Route::get('upgrade', [UpgradeAdvisorController::class, 'index']);
Route::get('/openapi.json', [SwaggerController::class, 'openapi'])->name('openapi');
+ // WebRTC Widget endpoints (public, rate-limited)
+ // Token endpoint uses configurable rate limit (default: 5/min)
+ Route::group(['prefix' => 'webrtc'], function () {
+ Route::get('token', [WebRtcController::class, 'token'])
+ ->middleware('throttle:webrtc-token')
+ ->name('webrtc.token');
+ Route::get('config', [WebRtcController::class, 'config'])
+ ->middleware('throttle:60,1')
+ ->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..b06d78309 100644
--- a/src/routes/web.php
+++ b/src/routes/web.php
@@ -79,4 +79,9 @@
->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
+// Protected by Twilio signature validation and rate limiting
+Route::match(array('GET', 'POST'), "/webrtc-call", 'App\Http\Controllers\WebRtcCallController@handleCall')
+ ->middleware(['twilio.signature', 'throttle:webrtc-call']);
+Route::match(array('GET', 'POST'), "/webrtc-status", 'App\Http\Controllers\WebRtcCallController@statusCallback')
+ ->middleware(['twilio.signature']);
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"
diff --git a/src/webpack.mix.js b/src/webpack.mix.js
index 9845c508f..e86238186 100644
--- a/src/webpack.mix.js
+++ b/src/webpack.mix.js
@@ -11,11 +11,18 @@ 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
+// The widget self-registers to window.YapDialWidget in its source
+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: {