Files
twilio-wp-plugin/includes/class-twp-mobile-phone-page.php
Claude 621b0890a9 Replace native Twilio Voice SDK with WebView-based softphone
Rewrites the mobile app from a native Twilio Voice SDK integration
(Android Telecom/ConnectionService) to a thin WebView shell that loads
a standalone browser phone page from WordPress. This eliminates the
buggy Android phone account registration, fixes frequent logouts by
using 7-day WP session cookies instead of JWT tokens, and maintains
all existing call features (dialpad, queues, hold, transfer, requeue,
recording, caller ID, agent status).

Server-side:
- Add class-twp-mobile-phone-page.php: standalone /twp-phone/ endpoint
  with mobile-optimized UI, dark mode, tab navigation, and Flutter
  WebView JS bridge
- Extend auth cookie to 7 days for phone agents
- Add WP AJAX handler for FCM token registration (cookie auth)

Flutter app (v2.0.0):
- Replace 18 native files with 5-file WebView shell
- Login via wp-login.php in WebView (auto-detect redirect on success)
- Full-screen WebView with auto microphone grant for WebRTC
- FCM push notifications preserved for queue alerts
- Remove: twilio_voice, dio, provider, JWT auth, SSE, native call UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:11:25 -07:00

1997 lines
72 KiB
PHP

<?php
/**
* Standalone Mobile Phone Page
*
* Registers a front-end endpoint at /twp-phone/ that serves the browser phone UI
* without any wp-admin chrome. Designed for mobile WebView usage.
*
* @package Twilio_WP_Plugin
*/
class TWP_Mobile_Phone_Page {
/**
* The endpoint slug.
*/
const ENDPOINT = 'twp-phone';
/**
* Constructor — wire up hooks.
*/
public function __construct() {
add_action('init', array($this, 'register_rewrite'));
add_action('template_redirect', array($this, 'handle_request'));
add_filter('query_vars', array($this, 'add_query_var'));
// Extend session cookie for phone agents.
add_filter('auth_cookie_expiration', array($this, 'extend_agent_cookie'), 10, 3);
// AJAX action for FCM token registration (uses WP cookie auth).
add_action('wp_ajax_twp_register_fcm_token', array($this, 'ajax_register_fcm_token'));
}
/**
* AJAX handler: register FCM token for the current user.
*/
public function ajax_register_fcm_token() {
check_ajax_referer('twp_ajax_nonce', 'nonce');
$fcm_token = sanitize_text_field($_POST['fcm_token'] ?? '');
if (empty($fcm_token)) {
wp_send_json_error('Missing FCM token');
}
$user_id = get_current_user_id();
if (!$user_id) {
wp_send_json_error('Not authenticated');
}
// Store FCM token (same as TWP_Mobile_API::register_fcm_token)
global $wpdb;
$table = $wpdb->prefix . 'twp_mobile_sessions';
// Update existing session or insert new one
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT id FROM $table WHERE user_id = %d AND fcm_token = %s AND is_active = 1",
$user_id, $fcm_token
));
if (!$existing) {
$wpdb->insert($table, array(
'user_id' => $user_id,
'fcm_token' => $fcm_token,
'device_info' => 'WebView Mobile App',
'is_active' => 1,
'created_at' => current_time('mysql'),
'expires_at' => date('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS),
));
}
wp_send_json_success('FCM token registered');
}
/**
* Register custom rewrite rule.
*/
public function register_rewrite() {
add_rewrite_rule(
'^' . self::ENDPOINT . '/?$',
'index.php?twp_phone_page=1',
'top'
);
}
/**
* Expose query variable.
*
* @param array $vars Existing query vars.
* @return array
*/
public function add_query_var($vars) {
$vars[] = 'twp_phone_page';
return $vars;
}
/**
* Handle the request on template_redirect.
*/
public function handle_request() {
if (!get_query_var('twp_phone_page')) {
return;
}
// Authentication check — redirect to login if not authenticated.
if (!is_user_logged_in()) {
$redirect_url = home_url('/' . self::ENDPOINT . '/');
wp_redirect(wp_login_url($redirect_url));
exit;
}
// Capability check.
if (!current_user_can('twp_access_browser_phone')) {
wp_die(
'You do not have permission to access the browser phone.',
'Access Denied',
array('response' => 403)
);
}
// Render the standalone page and exit.
$this->render_page();
exit;
}
/**
* Extend auth cookie to 7 days for phone agents.
*
* @param int $expiration Default expiration in seconds.
* @param int $user_id User ID.
* @param bool $remember Whether "Remember Me" was checked.
* @return int
*/
public function extend_agent_cookie($expiration, $user_id, $remember) {
$user = get_userdata($user_id);
if ($user && $user->has_cap('twp_access_browser_phone')) {
return 7 * DAY_IN_SECONDS;
}
return $expiration;
}
// ------------------------------------------------------------------
// Rendering
// ------------------------------------------------------------------
/**
* Output the complete standalone HTML page.
*/
private function render_page() {
// Gather data needed by the template (same as display_browser_phone_page).
$current_user_id = get_current_user_id();
global $wpdb;
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
$extension_data = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$current_user_id
));
if (!$extension_data) {
TWP_User_Queue_Manager::create_user_queues($current_user_id);
$extension_data = $wpdb->get_row($wpdb->prepare(
"SELECT extension FROM $extensions_table WHERE user_id = %d",
$current_user_id
));
}
$agent_status = TWP_Agent_Manager::get_agent_status($current_user_id);
$agent_stats = TWP_Agent_Manager::get_agent_stats($current_user_id);
$is_logged_in = TWP_Agent_Manager::is_agent_logged_in($current_user_id);
$current_mode = get_user_meta($current_user_id, 'twp_call_mode', true);
if (empty($current_mode)) {
$current_mode = 'cell';
}
$user_phone = get_user_meta($current_user_id, 'twp_phone_number', true);
// Smart routing check (for admin-only setup notice).
$smart_routing_configured = false;
try {
$twilio = new TWP_Twilio_API();
$phone_numbers = $twilio->get_phone_numbers();
if ($phone_numbers['success']) {
$smart_routing_url = home_url('/wp-json/twilio-webhook/v1/smart-routing');
foreach ($phone_numbers['data']['incoming_phone_numbers'] as $number) {
if (isset($number['voice_url']) && strpos($number['voice_url'], 'smart-routing') !== false) {
$smart_routing_configured = true;
break;
}
}
}
} catch (Exception $e) {
// Silently continue.
}
// Nonce for AJAX.
$nonce = wp_create_nonce('twp_ajax_nonce');
// URLs.
$ajax_url = admin_url('admin-ajax.php');
$ringtone_url = plugins_url('assets/sounds/ringtone.mp3', dirname(__FILE__));
$phone_icon_url = plugins_url('assets/images/phone-icon.png', dirname(__FILE__));
$sw_url = plugins_url('assets/js/twp-service-worker.js', dirname(__FILE__));
$twilio_edge = esc_js(get_option('twp_twilio_edge', 'roaming'));
$smart_routing_webhook = home_url('/wp-json/twilio-webhook/v1/smart-routing');
// Begin output.
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1a2e">
<title>Phone - <?php echo esc_html(get_bloginfo('name')); ?></title>
<!-- jQuery (WordPress bundled) -->
<script src="<?php echo includes_url('js/jquery/jquery.min.js'); ?>"></script>
<!-- Preload Twilio SDK -->
<link rel="preload" href="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js" as="script">
<link rel="dns-prefetch" href="//unpkg.com">
<link rel="dns-prefetch" href="//chunderw-vpc-gll.twilio.com">
<link rel="preconnect" href="https://chunderw-vpc-gll.twilio.com" crossorigin>
<style>
/* ===================================================================
CSS Reset & Base
=================================================================== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #f5f6fa;
--bg-secondary: #ffffff;
--bg-phone: #1a1a2e;
--bg-display: #16213e;
--text-primary: #2c3e50;
--text-secondary: #7f8c8d;
--text-light: #ffffff;
--accent: #2196F3;
--accent-dark: #1976D2;
--success: #4CAF50;
--warning: #FF9800;
--danger: #f44336;
--border: #e0e0e0;
--shadow: 0 2px 8px rgba(0,0,0,0.1);
--radius: 12px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #0f0f23;
--bg-secondary: #1a1a2e;
--bg-phone: #16213e;
--bg-display: #0a0a1a;
--text-primary: #ecf0f1;
--text-secondary: #95a5a6;
--border: #2c3e50;
--shadow: 0 2px 8px rgba(0,0,0,0.4);
}
}
html, body {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
/* ===================================================================
Layout — full-screen flex column
=================================================================== */
.twp-app {
display: flex;
flex-direction: column;
height: 100%;
max-width: 500px;
margin: 0 auto;
padding-top: var(--safe-top);
padding-bottom: var(--safe-bottom);
}
/* ===================================================================
Agent Status Bar (compact)
=================================================================== */
.agent-status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 8px;
flex-wrap: wrap;
}
.status-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.extension-badge {
background: var(--accent);
color: #fff;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.agent-stats {
display: flex;
gap: 10px;
font-size: 11px;
color: var(--text-secondary);
}
#login-toggle-btn, #agent-status-select {
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
}
#login-toggle-btn.logged-in {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
/* ===================================================================
Notices
=================================================================== */
.twp-notice {
padding: 8px 12px;
margin: 6px 10px;
border-radius: 6px;
font-size: 13px;
animation: fadeIn 0.2s ease;
}
.twp-notice-success { background: #e8f5e9; color: #2e7d32; }
.twp-notice-error { background: #ffebee; color: #c62828; }
.twp-notice-info { background: #e3f2fd; color: #1565c0; }
@media (prefers-color-scheme: dark) {
.twp-notice-success { background: #1b5e20; color: #a5d6a7; }
.twp-notice-error { background: #b71c1c; color: #ef9a9a; }
.twp-notice-info { background: #0d47a1; color: #90caf9; }
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
/* ===================================================================
Tab Navigation
=================================================================== */
.tab-nav {
display: flex;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tab-btn {
flex: 1;
padding: 10px 0;
text-align: center;
font-size: 13px;
font-weight: 600;
background: none;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ===================================================================
Tab Content
=================================================================== */
.tab-content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.tab-pane { display: none; height: 100%; }
.tab-pane.active { display: flex; flex-direction: column; }
/* ===================================================================
Phone Interface
=================================================================== */
.phone-interface {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px;
gap: 12px;
}
/* Display */
.phone-display {
background: var(--bg-display);
color: var(--text-light);
padding: 16px;
border-radius: var(--radius);
text-align: center;
}
#phone-status {
font-size: 14px;
color: var(--success);
margin-bottom: 4px;
}
#device-connection-status {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
}
#phone-number-display {
font-size: 20px;
min-height: 28px;
font-weight: 600;
letter-spacing: 1px;
}
#call-timer {
font-size: 16px;
margin-top: 6px;
font-variant-numeric: tabular-nums;
}
/* Input */
#phone-number-input {
width: 100%;
padding: 12px;
font-size: 20px;
text-align: center;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text-primary);
outline: none;
}
#phone-number-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(33,150,243,0.2);
}
/* Dialpad */
.dialpad-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.dialpad-btn {
padding: 14px 0;
font-size: 22px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: var(--radius);
cursor: pointer;
-webkit-user-select: none;
user-select: none;
position: relative;
transition: background 0.1s;
min-height: 54px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.dialpad-btn:active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.dialpad-btn span {
display: block;
font-size: 9px;
color: var(--text-secondary);
margin-top: 1px;
letter-spacing: 2px;
}
.dialpad-btn:active span { color: rgba(255,255,255,0.8); }
/* Phone Controls */
.phone-controls {
display: flex;
gap: 8px;
}
.phone-controls .btn-phone {
flex: 1;
height: 50px;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: var(--radius);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.2s;
}
.btn-call {
background: var(--success);
color: #fff;
}
.btn-hangup {
background: var(--danger);
color: #fff;
}
.btn-answer {
background: var(--success);
color: #fff;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(76,175,80,0.4); }
50% { box-shadow: 0 0 0 10px rgba(76,175,80,0); }
}
/* Call Controls (hold/transfer/requeue/record) */
.call-controls-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.call-controls-grid .btn-ctrl {
padding: 10px 8px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.1s;
}
.btn-ctrl:active { background: #e3f2fd; }
.btn-ctrl.btn-active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* Error display */
#browser-phone-error {
background: #ffebee;
color: #c62828;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
margin: 0 12px;
}
@media (prefers-color-scheme: dark) {
#browser-phone-error { background: #b71c1c; color: #ef9a9a; }
}
/* ===================================================================
Settings Tab
=================================================================== */
.settings-panel {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.settings-section {
background: var(--bg-secondary);
padding: 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.settings-section h4 {
font-size: 14px;
margin-bottom: 10px;
color: var(--accent-dark);
}
.settings-section label {
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
.settings-section select {
width: 100%;
padding: 8px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
margin-top: 6px;
}
/* Call mode radio cards */
.mode-selection {
display: flex;
gap: 10px;
margin: 10px 0;
}
.mode-option {
display: flex;
align-items: center;
padding: 12px;
border: 2px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
flex: 1;
background: var(--bg-primary);
}
.mode-option.active {
border-color: var(--accent);
background: #e3f2fd;
}
@media (prefers-color-scheme: dark) {
.mode-option.active { background: #0d47a1; }
}
.mode-option input[type="radio"] { margin: 0 8px 0 0; }
.mode-icon { font-size: 20px; margin-right: 8px; }
.mode-details strong { display: block; font-size: 13px; }
.mode-details small { font-size: 11px; color: var(--text-secondary); }
.mode-status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
background: var(--bg-primary);
border-radius: 6px;
font-size: 13px;
}
.mode-info { margin-top: 8px; font-size: 12px; color: var(--text-secondary); }
#save-mode-btn {
padding: 6px 16px;
border: none;
background: var(--accent);
color: #fff;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
/* Setup info box */
.setup-info {
background: #fff3cd;
padding: 12px;
border-radius: 8px;
border-left: 4px solid #ffc107;
font-size: 12px;
}
@media (prefers-color-scheme: dark) {
.setup-info { background: #4a3800; color: #ffe082; }
}
.setup-info h4 { margin-bottom: 6px; color: #856404; font-size: 13px; }
.setup-info code {
display: block;
padding: 6px 8px;
background: rgba(0,0,0,0.05);
border-radius: 4px;
font-size: 11px;
word-break: break-all;
margin: 6px 0;
}
.btn-copy {
font-size: 11px;
padding: 2px 8px;
border: 1px solid var(--border);
background: var(--bg-secondary);
border-radius: 4px;
cursor: pointer;
}
/* ===================================================================
Queue Tab
=================================================================== */
.queue-panel {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.queue-header h4 { font-size: 15px; }
.user-extension-admin {
background: var(--bg-secondary);
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
color: var(--accent-dark);
border: 1px solid var(--border);
}
.queue-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
}
.queue-item.queue-type-personal { border-left: 4px solid var(--success); }
.queue-item.queue-type-hold { border-left: 4px solid var(--warning); }
.queue-item.queue-type-general { border-left: 4px solid var(--accent); }
.queue-item.has-calls { background: #fff3cd; }
@media (prefers-color-scheme: dark) {
.queue-item.has-calls { background: #4a3800; }
}
.queue-info { flex: 1; }
.queue-name {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 14px;
}
.queue-details {
font-size: 12px;
color: var(--text-secondary);
margin-top: 3px;
display: flex;
gap: 10px;
}
.queue-waiting.has-calls {
color: var(--danger);
font-weight: bold;
}
.queue-loading {
text-align: center;
color: var(--text-secondary);
font-style: italic;
padding: 20px;
}
.queue-actions { text-align: center; }
.btn-sm {
font-size: 12px;
padding: 6px 12px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
}
.btn-sm:active { background: #e3f2fd; }
.btn-refresh {
padding: 8px 16px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
/* ===================================================================
Dialog / Overlay (transfer, requeue)
=================================================================== */
.twp-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 9999;
}
.twp-dialog {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: var(--bg-secondary);
padding: 20px;
border-radius: var(--radius);
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 10000;
width: 90%;
max-width: 420px;
max-height: 80vh;
overflow-y: auto;
color: var(--text-primary);
}
.twp-dialog h3 { margin: 0 0 12px 0; font-size: 16px; }
.twp-dialog input[type="text"],
.twp-dialog input[type="tel"] {
width: 100%;
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
margin: 8px 0;
}
.twp-dialog .dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.btn-primary {
padding: 8px 18px;
border: none;
background: var(--accent);
color: #fff;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
padding: 8px 18px;
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.agent-option, .queue-option {
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 6px;
cursor: pointer;
background: var(--bg-primary);
display: flex;
justify-content: space-between;
align-items: center;
}
.agent-option.selected, .queue-option.selected {
background: #e3f2fd;
border-color: var(--accent);
}
@media (prefers-color-scheme: dark) {
.agent-option.selected, .queue-option.selected { background: #0d47a1; }
}
</style>
</head>
<body>
<div class="twp-app">
<!-- Agent Status Bar -->
<div class="agent-status-bar">
<div class="status-info">
<span class="extension-badge"><?php echo $extension_data ? esc_html($extension_data->extension) : '—'; ?></span>
<button id="login-toggle-btn" class="<?php echo $is_logged_in ? 'logged-in' : ''; ?>" onclick="toggleAgentLogin()">
<?php echo $is_logged_in ? 'Log Out' : 'Log In'; ?>
</button>
<select id="agent-status-select" onchange="updateAgentStatus(this.value)" <?php echo !$is_logged_in ? 'disabled' : ''; ?>>
<option value="available" <?php selected($agent_status->status ?? '', 'available'); ?>>Available</option>
<option value="busy" <?php selected($agent_status->status ?? '', 'busy'); ?>>Busy</option>
<option value="offline" <?php selected($agent_status->status ?? 'offline', 'offline'); ?>>Offline</option>
</select>
</div>
<div class="agent-stats">
<span>Today: <strong><?php echo esc_html($agent_stats['calls_today']); ?></strong></span>
<span>Total: <strong><?php echo esc_html($agent_stats['total_calls']); ?></strong></span>
<span>Avg: <strong><?php echo round($agent_stats['avg_duration'] ?? 0); ?>s</strong></span>
</div>
</div>
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="tab-btn active" data-tab="phone">Phone</button>
<button class="tab-btn" data-tab="queues">Queues</button>
<button class="tab-btn" data-tab="settings">Settings</button>
</div>
<!-- Notices container -->
<div id="twp-notices"></div>
<!-- Error display -->
<div id="browser-phone-error" style="display:none;"></div>
<!-- Tab Content -->
<div class="tab-content">
<!-- Phone Tab -->
<div class="tab-pane active" id="tab-phone">
<div class="phone-interface">
<div class="phone-display">
<div id="phone-status">Ready</div>
<div id="device-connection-status">Loading...</div>
<div id="phone-number-display"></div>
<div id="call-timer" style="display:none;">00:00</div>
</div>
<input type="tel" id="phone-number-input" placeholder="Enter phone number" />
<div class="dialpad-grid">
<button class="dialpad-btn" data-digit="1">1</button>
<button class="dialpad-btn" data-digit="2">2<span>ABC</span></button>
<button class="dialpad-btn" data-digit="3">3<span>DEF</span></button>
<button class="dialpad-btn" data-digit="4">4<span>GHI</span></button>
<button class="dialpad-btn" data-digit="5">5<span>JKL</span></button>
<button class="dialpad-btn" data-digit="6">6<span>MNO</span></button>
<button class="dialpad-btn" data-digit="7">7<span>PQRS</span></button>
<button class="dialpad-btn" data-digit="8">8<span>TUV</span></button>
<button class="dialpad-btn" data-digit="9">9<span>WXYZ</span></button>
<button class="dialpad-btn" data-digit="*">*</button>
<button class="dialpad-btn" data-digit="0">0<span>+</span></button>
<button class="dialpad-btn" data-digit="#">#</button>
</div>
<div class="phone-controls">
<button id="call-btn" class="btn-phone btn-call">
&#128222; Call
</button>
<button id="hangup-btn" class="btn-phone btn-hangup" style="display:none;">
&#10060; Hang Up
</button>
<button id="answer-btn" class="btn-phone btn-answer" style="display:none;">
&#128222; Answer
</button>
</div>
<div id="admin-call-controls-panel" style="display:none;">
<div class="call-controls-grid">
<button id="admin-hold-btn" class="btn-ctrl" title="Hold">
&#9208; Hold
</button>
<button id="admin-transfer-btn" class="btn-ctrl" title="Transfer">
&#8618; Transfer
</button>
<button id="admin-requeue-btn" class="btn-ctrl" title="Requeue">
&#128260; Requeue
</button>
<button id="admin-record-btn" class="btn-ctrl" title="Record">
&#9210; Record
</button>
</div>
</div>
</div>
</div>
<!-- Queues Tab -->
<div class="tab-pane" id="tab-queues">
<div class="queue-panel">
<div class="queue-header">
<h4>Your Queues</h4>
<?php if ($extension_data): ?>
<div class="user-extension-admin">Ext: <strong><?php echo esc_html($extension_data->extension); ?></strong></div>
<?php endif; ?>
</div>
<div id="admin-queue-list">
<div class="queue-loading">Loading your queues...</div>
</div>
<div class="queue-actions">
<button type="button" id="admin-refresh-queues" class="btn-refresh">Refresh Queues</button>
</div>
</div>
</div>
<!-- Settings Tab -->
<div class="tab-pane" id="tab-settings">
<div class="settings-panel">
<!-- Caller ID -->
<div class="settings-section">
<h4>Outbound Caller ID</h4>
<select id="caller-id-select">
<option value="">Loading numbers...</option>
</select>
</div>
<!-- Auto-answer -->
<div class="settings-section">
<label><input type="checkbox" id="auto-answer" /> Auto-answer incoming calls</label>
</div>
<!-- Call Mode -->
<div class="settings-section">
<h4>Call Reception Mode</h4>
<div class="mode-selection">
<label class="mode-option <?php echo $current_mode === 'browser' ? 'active' : ''; ?>">
<input type="radio" name="call_mode" value="browser" <?php checked($current_mode, 'browser'); ?>>
<div class="mode-icon">&#128187;</div>
<div class="mode-details">
<strong>Browser Phone</strong>
<small>Calls ring in this browser</small>
</div>
</label>
<label class="mode-option <?php echo $current_mode === 'cell' ? 'active' : ''; ?>">
<input type="radio" name="call_mode" value="cell" <?php checked($current_mode, 'cell'); ?>>
<div class="mode-icon">&#128241;</div>
<div class="mode-details">
<strong>Cell Phone</strong>
<small>Forward to your mobile</small>
</div>
</label>
</div>
<div class="mode-status">
<div id="current-mode-display">
<strong>Current:</strong>
<span id="mode-text"><?php echo $current_mode === 'browser' ? 'Browser Phone' : 'Cell Phone'; ?></span>
</div>
<button type="button" id="save-mode-btn" style="display:none;">Save</button>
</div>
<div class="mode-info">
<div class="browser-mode-info" style="display:<?php echo $current_mode === 'browser' ? 'block' : 'none'; ?>;">
<p>Keep this page open to receive calls.</p>
</div>
<div class="cell-mode-info" style="display:<?php echo $current_mode === 'cell' ? 'block' : 'none'; ?>;">
<p>Calls forwarded to: <?php echo $user_phone ? esc_html($user_phone) : '<em>Not configured</em>'; ?></p>
</div>
</div>
</div>
<?php if (!$smart_routing_configured && current_user_can('manage_options')): ?>
<div class="setup-info">
<h4>Setup Required</h4>
<p>Update your phone number webhook to:</p>
<code><?php echo esc_html($smart_routing_webhook); ?></code>
<button type="button" class="btn-copy" onclick="copyToClipboard('<?php echo esc_js($smart_routing_webhook); ?>')">Copy</button>
</div>
<?php endif; ?>
</div>
</div>
</div><!-- .tab-content -->
</div><!-- .twp-app -->
<!-- Twilio Voice SDK v2.11.0 -->
<script src="https://unpkg.com/@twilio/voice-sdk@2.11.0/dist/twilio.min.js"></script>
<script>
(function($) {
// ============================================================
// Config — injected from PHP
// ============================================================
var ajaxurl = <?php echo wp_json_encode($ajax_url); ?>;
var twpNonce = <?php echo wp_json_encode($nonce); ?>;
var twpRingtoneUrl = <?php echo wp_json_encode($ringtone_url); ?>;
var twpPhoneIconUrl = <?php echo wp_json_encode($phone_icon_url); ?>;
var twpSwUrl = <?php echo wp_json_encode($sw_url); ?>;
var twpTwilioEdge = <?php echo wp_json_encode($twilio_edge); ?>;
// ============================================================
// Flutter WebView Bridge
// ============================================================
window.TwpMobile = window.TwpMobile || {};
/** Flutter injects FCM token via this method. */
window.TwpMobile.getFcmToken = function() {
return window.TwpMobile._fcmToken || null;
};
window.TwpMobile.setFcmToken = function(token) {
window.TwpMobile._fcmToken = token;
// Register FCM token with server via WP AJAX (uses cookie auth)
$.post(ajaxurl, {
action: 'twp_register_fcm_token',
nonce: twpNonce,
fcm_token: token
}).fail(function() { console.warn('TWP: FCM token registration failed'); });
};
/** Flutter calls this when a notification is tapped. */
window.TwpMobile.onNotificationTap = function(data) {
// Switch to phone tab and focus.
switchTab('phone');
if (data && data.caller) {
$('#phone-number-input').val(data.caller);
}
};
/** Notify Flutter that page is ready (via webview_flutter JavaScriptChannel). */
function notifyFlutterReady() {
try {
if (window.TwpMobile && window.TwpMobile.postMessage) {
window.TwpMobile.postMessage('onPageReady');
}
} catch (e) { /* not in WebView */ }
}
/** Notify Flutter that session has expired. */
function notifyFlutterSessionExpired() {
try {
if (window.TwpMobile && window.TwpMobile.postMessage) {
window.TwpMobile.postMessage('onSessionExpired');
}
} catch (e) { /* not in WebView */ }
}
/**
* Wrapper around $.post that detects session expiration.
*/
function twpPost(data, successCb, failCb) {
return $.post(ajaxurl, data, function(response) {
if (successCb) successCb(response);
}).fail(function(xhr) {
// Detect login redirect / 403
if (xhr.status === 403 || (xhr.responseText && xhr.responseText.indexOf('wp-login') !== -1)) {
notifyFlutterSessionExpired();
}
if (failCb) failCb(xhr);
});
}
// ============================================================
// Tab Navigation
// ============================================================
function switchTab(name) {
$('.tab-btn').removeClass('active');
$('.tab-btn[data-tab="' + name + '"]').addClass('active');
$('.tab-pane').removeClass('active');
$('#tab-' + name).addClass('active');
}
$('.tab-btn').on('click', function() { switchTab($(this).data('tab')); });
// ============================================================
// Notices
// ============================================================
function showNotice(message, type) {
var cls = 'twp-notice twp-notice-' + (type || 'info');
var $el = $('<div class="' + cls + '">' + message + '</div>');
$('#twp-notices').append($el);
setTimeout(function() { $el.fadeOut(300, function() { $el.remove(); }); }, 4000);
}
function showError(message) {
$('#browser-phone-error').html('<p><strong>Error:</strong> ' + message + '</p>').show();
$('#phone-status').text('Error').css('color', 'var(--danger)');
}
// ============================================================
// Core phone state
// ============================================================
var device = null;
var currentCall = null;
var callTimer = null;
var callStartTime = null;
var tokenRefreshTimer = null;
var tokenExpiry = null;
var audioContext = null;
var ringtoneAudio = null;
var isPageVisible = true;
var deviceConnectionState = 'disconnected';
var serviceWorkerRegistration = null;
// ============================================================
// AudioContext & Ringtone
// ============================================================
function initializeAudioContext() {
try {
if (!audioContext) {
var AC = window.AudioContext || window.webkitAudioContext;
audioContext = new AC();
}
if (audioContext.state === 'suspended') {
audioContext.resume().catch(function() {});
}
return true;
} catch (e) { return false; }
}
function setupRingtone() {
if (!ringtoneAudio) {
ringtoneAudio = new Audio();
ringtoneAudio.loop = true;
ringtoneAudio.volume = 0.7;
ringtoneAudio.src = twpRingtoneUrl;
ringtoneAudio.addEventListener('error', function() {}, { once: true });
ringtoneAudio.load();
}
}
function playRingtone() {
try {
initializeAudioContext();
if (ringtoneAudio) {
var p = ringtoneAudio.play();
if (p !== undefined) p.catch(function() { vibrateDevice([300,200,300,200,300]); });
}
vibrateDevice([300,200,300,200,300]);
} catch (e) {}
}
function stopRingtone() {
try { if (ringtoneAudio) { ringtoneAudio.pause(); ringtoneAudio.currentTime = 0; } } catch (e) {}
}
function vibrateDevice(pattern) {
if ('vibrate' in navigator) navigator.vibrate(pattern);
}
// ============================================================
// Service Worker & Notifications
// ============================================================
function registerServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register(twpSwUrl).then(function(reg) {
serviceWorkerRegistration = reg;
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
}).catch(function() {});
}
}
function sendIncomingCallNotification(callerNumber) {
if ('Notification' in window && Notification.permission === 'granted') {
if (serviceWorkerRegistration && serviceWorkerRegistration.active) {
serviceWorkerRegistration.active.postMessage({
type: 'SHOW_NOTIFICATION',
title: 'Incoming Call',
body: 'Call from ' + (callerNumber || 'Unknown Number'),
icon: twpPhoneIconUrl,
tag: 'incoming-call',
requireInteraction: true
});
} else {
new Notification('Incoming Call', {
body: 'Call from ' + (callerNumber || 'Unknown Number'),
icon: twpPhoneIconUrl,
tag: 'incoming-call',
requireInteraction: true
});
}
}
}
// ============================================================
// Page Visibility
// ============================================================
function setupPageVisibility() {
document.addEventListener('visibilitychange', function() {
isPageVisible = !document.hidden;
if (isPageVisible && audioContext) initializeAudioContext();
});
}
// ============================================================
// Connection Status
// ============================================================
function updateConnectionStatus(state) {
deviceConnectionState = state;
var text = '', color = '';
switch (state) {
case 'connected': text = 'Connected'; color = 'var(--success)'; break;
case 'connecting': text = 'Connecting...'; color = 'var(--warning)'; break;
case 'disconnected': text = 'Disconnected'; color = 'var(--danger)'; break;
default: text = 'Unknown'; color = 'var(--text-secondary)';
}
$('#device-connection-status').text(text).css('color', color);
}
// ============================================================
// Twilio Device Setup
// ============================================================
function waitForTwilioSDK(cb) {
if (typeof Twilio !== 'undefined' && Twilio.Device) { cb(); }
else { setTimeout(function() { waitForTwilioSDK(cb); }, 100); }
}
function initializeBrowserPhone() {
$('#phone-status').text('Initializing...');
updateConnectionStatus('connecting');
setupRingtone();
registerServiceWorker();
setupPageVisibility();
$(document).one('click touchstart', function() { initializeAudioContext(); });
waitForTwilioSDK(function() {
twpPost({
action: 'twp_generate_capability_token',
nonce: twpNonce
}, function(response) {
if (response.success) {
$('#browser-phone-error').hide();
setupTwilioDevice(response.data.token);
tokenExpiry = Date.now() + (response.data.expires_in || 3600) * 1000;
scheduleTokenRefresh();
} else {
var msg = response.data || 'Unknown error';
showError('Failed to initialize: ' + msg);
updateConnectionStatus('disconnected');
}
}, function() {
showError('Failed to connect to server');
updateConnectionStatus('disconnected');
});
});
}
async function requestMediaPermissions() {
try {
var stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
stream.getTracks().forEach(function(t) { t.stop(); });
return true;
} catch (error) {
var msg = 'Microphone access is required. ';
if (error.name === 'NotAllowedError') msg += 'Please allow microphone access.';
else if (error.name === 'NotFoundError') msg += 'No microphone found.';
else msg += 'Check browser settings.';
showError(msg);
return false;
}
}
async function setupTwilioDevice(token) {
try {
if (typeof Twilio === 'undefined' || !Twilio.Device) throw new Error('Twilio Voice SDK not loaded');
updateConnectionStatus('connecting');
var hasPerms = await requestMediaPermissions();
if (!hasPerms) { updateConnectionStatus('disconnected'); return; }
if (device) { await device.destroy(); }
var isAndroid = /Android/i.test(navigator.userAgent);
var audioConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
};
if (isAndroid) {
audioConstraints.googEchoCancellation = true;
audioConstraints.googNoiseSuppression = true;
audioConstraints.googAutoGainControl = true;
audioConstraints.googHighpassFilter = true;
}
device = new Twilio.Device(token, {
logLevel: 1,
codecPreferences: ['opus', 'pcmu'],
edge: twpTwilioEdge,
enableIceRestart: true,
audioConstraints: audioConstraints,
maxCallSignalingTimeoutMs: 30000,
closeProtection: true
});
device.on('registered', function() {
$('#phone-status').text('Ready').css('color', 'var(--success)');
$('#call-btn').prop('disabled', false);
updateConnectionStatus('connected');
});
device.on('unregistered', function() { updateConnectionStatus('disconnected'); });
device.on('error', function(error) {
updateConnectionStatus('disconnected');
var msg = error.message || error.toString();
if (msg.includes('valid callerId')) {
msg = 'Select a verified Twilio phone number as Caller ID.';
} else if (msg.includes('token') || msg.includes('Token')) {
msg = 'Token error: ' + msg;
setTimeout(initializeBrowserPhone, 5000);
} else if (msg.includes('31005') || msg.includes('Connection error')) {
msg = 'Connection error. Check internet connection.';
setTimeout(function() { if (device) device.register(); }, 3000);
}
showError(msg);
});
device.on('incoming', function(call) {
currentCall = call;
var caller = call.parameters.From || 'Unknown';
$('#phone-status').text('Incoming Call').css('color', 'var(--warning)');
$('#phone-number-display').text(caller);
$('#call-btn').hide();
$('#answer-btn').show();
playRingtone();
if (!isPageVisible) sendIncomingCallNotification(caller);
setupCallHandlers(call);
// Switch to phone tab on incoming call
switchTab('phone');
if ($('#auto-answer').is(':checked')) call.accept();
});
device.on('tokenWillExpire', function() { refreshToken(); });
await device.register();
} catch (error) {
showError('Failed to setup device: ' + error.message);
}
}
// ============================================================
// Call Handlers
// ============================================================
function setupCallHandlers(call) {
call.on('accept', function() {
stopRingtone();
$('#phone-status').text('Connected').css('color', 'var(--accent)');
$('#call-btn').hide();
$('#answer-btn').hide();
$('#hangup-btn').show();
$('#admin-call-controls-panel').show();
startCallTimer();
});
call.on('disconnect', function() {
stopRingtone();
currentCall = null;
$('#phone-status').text('Ready').css('color', 'var(--success)');
$('#hangup-btn').hide();
$('#answer-btn').hide();
$('#call-btn').show();
$('#admin-call-controls-panel').hide();
$('#call-timer').hide();
stopCallTimer();
$('#admin-hold-btn').html('&#9208; Hold').removeClass('btn-active');
$('#admin-record-btn').html('&#9210; Record').removeClass('btn-active');
adminIsOnHold = false;
adminIsRecording = false;
adminRecordingSid = null;
});
call.on('reject', function() {
stopRingtone();
currentCall = null;
$('#phone-status').text('Ready').css('color', 'var(--success)');
$('#answer-btn').hide();
$('#call-btn').show();
});
call.on('cancel', function() {
stopRingtone();
currentCall = null;
$('#phone-status').text('Missed Call').css('color', 'var(--warning)');
$('#answer-btn').hide();
$('#call-btn').show();
setTimeout(function() { $('#phone-status').text('Ready').css('color', 'var(--success)'); }, 3000);
});
call.on('error', function(error) {
stopRingtone();
var msg = error.message || error.toString();
if (error.code === 31005) msg = 'Connection failed. Check network.';
else if (error.code === 31201 || error.code === 31204) msg = 'Call setup failed. Try again.';
else if (error.code === 31208) msg = 'Media failed. Check microphone.';
showError('Call error: ' + msg);
});
}
// ============================================================
// Token Refresh
// ============================================================
function refreshToken() {
if (currentCall) { setTimeout(refreshToken, 60000); return; }
twpPost({
action: 'twp_generate_capability_token',
nonce: twpNonce
}, function(response) {
if (response.success && device) {
device.updateToken(response.data.token);
tokenExpiry = Date.now() + (response.data.expires_in || 3600) * 1000;
scheduleTokenRefresh();
} else {
showError('Failed to refresh connection. Please reload.');
}
}, function() {
setTimeout(refreshToken, 30000);
});
}
function scheduleTokenRefresh() {
if (tokenRefreshTimer) clearTimeout(tokenRefreshTimer);
if (!tokenExpiry) return;
var ms = tokenExpiry - Date.now() - 5 * 60 * 1000;
if (ms <= 0) refreshToken();
else tokenRefreshTimer = setTimeout(refreshToken, ms);
}
// ============================================================
// Timer
// ============================================================
function startCallTimer() {
callStartTime = new Date();
$('#call-timer').show();
callTimer = setInterval(function() {
var s = Math.floor((new Date() - callStartTime) / 1000);
var m = Math.floor(s / 60);
s = s % 60;
$('#call-timer').text((m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s);
}, 1000);
}
function stopCallTimer() {
if (callTimer) { clearInterval(callTimer); callTimer = null; }
$('#call-timer').text('00:00');
}
// ============================================================
// Caller ID loading
// ============================================================
twpPost({
action: 'twp_get_phone_numbers',
nonce: twpNonce
}, function(response) {
if (response.success) {
var opts = '<option value="">Select caller ID...</option>';
response.data.forEach(function(n) { opts += '<option value="' + n.phone_number + '">' + n.phone_number + '</option>'; });
$('#caller-id-select').html(opts);
} else {
$('#caller-id-select').html('<option value="">Error loading numbers</option>');
}
}, function() {
$('#caller-id-select').html('<option value="">Error loading numbers</option>');
});
// ============================================================
// Dialpad
// ============================================================
$('.dialpad-btn').on('click touchend', function(e) {
e.preventDefault();
var digit = $(this).data('digit');
$('#phone-number-input').val($('#phone-number-input').val() + digit);
initializeAudioContext();
// Send DTMF during active call
if (currentCall) {
currentCall.sendDigits(String(digit));
}
});
// ============================================================
// Call / Hangup / Answer
// ============================================================
$('#call-btn').on('click', async function() {
var num = $('#phone-number-input').val().trim();
var cid = $('#caller-id-select').val();
if (!num) { showNotice('Enter a phone number', 'error'); return; }
if (!cid) { showNotice('Select a caller ID', 'error'); return; }
if (!device) { showNotice('Phone not initialized. Reload page.', 'error'); return; }
num = num.replace(/\D/g, '');
if (num.length === 10) num = '+1' + num;
else if (num.length === 11 && num.charAt(0) === '1') num = '+' + num;
else if (!num.startsWith('+')) num = '+' + num;
$('#phone-number-display').text(num);
$('#phone-status').text('Calling...').css('color', 'var(--warning)');
try {
currentCall = await device.connect({ params: { To: num, From: cid } });
setupCallHandlers(currentCall);
} catch (err) {
showError('Failed to call: ' + err.message);
$('#phone-status').text('Ready').css('color', 'var(--success)');
}
});
$('#hangup-btn').on('click', function() { if (currentCall) currentCall.disconnect(); });
$('#answer-btn').on('click', function() {
if (!currentCall) { showError('No incoming call'); return; }
if (deviceConnectionState !== 'connected') {
showError('Phone not connected. Reconnecting...');
if (device) device.register().then(function() { if (currentCall) currentCall.accept(); }).catch(function() { showError('Reconnect failed. Reload page.'); });
return;
}
initializeAudioContext();
try { currentCall.accept(); } catch (e) { showError('Failed to answer: ' + e.message); }
});
// ============================================================
// Call Controls: Hold / Transfer / Requeue / Record
// ============================================================
var adminIsOnHold = false;
var adminIsRecording = false;
var adminRecordingSid = null;
function getCallSid() {
if (!currentCall) return null;
return currentCall.parameters.CallSid ||
(currentCall.customParameters && currentCall.customParameters.CallSid) ||
currentCall.outgoingConnectionId ||
currentCall.sid;
}
$('#admin-hold-btn').on('click', function() {
var sid = getCallSid();
if (!sid) return;
var $btn = $(this);
twpPost({
action: 'twp_toggle_hold',
call_sid: sid,
hold: !adminIsOnHold,
nonce: twpNonce
}, function(r) {
if (r.success) {
adminIsOnHold = !adminIsOnHold;
$btn.html(adminIsOnHold ? '&#9654; Unhold' : '&#9208; Hold').toggleClass('btn-active', adminIsOnHold);
showNotice(adminIsOnHold ? 'Call on hold' : 'Call resumed', 'info');
} else { showNotice('Hold failed: ' + (r.data || ''), 'error'); }
});
});
$('#admin-transfer-btn').on('click', function() {
if (!currentCall) return;
twpPost({
action: 'twp_get_transfer_targets',
nonce: twpNonce
}, function(r) {
if (r.success && r.data && (r.data.users || r.data.queues)) {
showEnhancedTransferDialog(r.data);
} else {
twpPost({ action: 'twp_get_online_agents', nonce: twpNonce }, function(lr) {
if (lr.success && lr.data.length > 0) showAgentTransferDialog(lr.data);
else showManualTransferDialog();
}, function() { showManualTransferDialog(); });
}
}, function() { showManualTransferDialog(); });
});
$('#admin-requeue-btn').on('click', function() {
if (!currentCall) return;
twpPost({ action: 'twp_get_all_queues', nonce: twpNonce }, function(r) {
if (r.success && r.data.length > 0) showRequeueDialog(r.data);
else showNotice('No queues available', 'error');
}, function() { showNotice('Failed to load queues', 'error'); });
});
$('#admin-record-btn').on('click', function() {
if (!currentCall) return;
if (adminIsRecording) stopRecording();
else startRecording();
});
function startRecording() {
var sid = getCallSid();
if (!sid) { showNotice('Cannot determine call SID', 'error'); return; }
twpPost({ action: 'twp_start_recording', call_sid: sid, nonce: twpNonce }, function(r) {
if (r.success) {
adminIsRecording = true;
adminRecordingSid = r.data.recording_sid;
$('#admin-record-btn').html('&#9209; Stop Rec').addClass('btn-active');
showNotice('Recording started', 'success');
} else { showNotice('Recording failed: ' + (r.data || ''), 'error'); }
});
}
function stopRecording() {
if (!adminRecordingSid) return;
var sid = getCallSid() || '';
twpPost({ action: 'twp_stop_recording', call_sid: sid, recording_sid: adminRecordingSid, nonce: twpNonce }, function(r) {
if (r.success) {
adminIsRecording = false;
adminRecordingSid = null;
$('#admin-record-btn').html('&#9210; Record').removeClass('btn-active');
showNotice('Recording stopped', 'info');
} else { showNotice('Stop recording failed: ' + (r.data || ''), 'error'); }
});
}
// ============================================================
// Transfer Dialogs
// ============================================================
function closeDialog() { $('.twp-overlay, .twp-dialog').remove(); }
function showEnhancedTransferDialog(data) {
var html = '<div class="twp-overlay"></div><div class="twp-dialog"><h3>Transfer Call</h3>';
if (data.users && data.users.length > 0) {
html += '<p style="font-weight:600;margin-bottom:6px;">Agents</p>';
data.users.forEach(function(u) {
var status = u.is_logged_in ? '&#128994; Online' : '&#128308; Offline';
html += '<div class="agent-option" data-type="extension" data-target="' + u.extension + '" data-agent-id="' + u.user_id + '">';
html += '<div><strong>' + u.display_name + '</strong><br><small>Ext: ' + u.extension + '</small></div>';
html += '<div>' + status + '</div></div>';
});
}
if (data.queues && data.queues.length > 0) {
html += '<p style="font-weight:600;margin:10px 0 6px;">Queues</p>';
data.queues.forEach(function(q) {
html += '<div class="queue-option" data-type="queue" data-target="' + q.id + '">';
html += '<div><strong>' + q.queue_name + '</strong></div>';
html += '<div>' + q.waiting_calls + ' waiting</div></div>';
});
}
html += '<p style="font-weight:600;margin:10px 0 6px;">Manual</p>';
html += '<input type="text" id="transfer-manual-input" placeholder="Extension (100) or Phone (+1234567890)" />';
html += '<div class="dialog-actions">';
html += '<button id="confirm-transfer" class="btn-primary" disabled>Transfer</button>';
html += '<button class="btn-secondary close-dialog">Cancel</button>';
html += '</div></div>';
$('body').append(html);
var selected = null;
$('.twp-dialog .agent-option, .twp-dialog .queue-option').on('click', function() {
$('.agent-option, .queue-option').removeClass('selected');
$(this).addClass('selected');
selected = { type: $(this).data('type'), target: $(this).data('target') };
$('#transfer-manual-input').val('');
$('#confirm-transfer').prop('disabled', false);
});
$('#transfer-manual-input').on('input', function() {
var v = $(this).val().trim();
if (v) {
$('.agent-option, .queue-option').removeClass('selected');
selected = { type: /^\d{3,4}$/.test(v) ? 'extension' : 'phone', target: v };
$('#confirm-transfer').prop('disabled', false);
}
});
$('#confirm-transfer').on('click', function() { if (selected) executeTransfer(selected.type, selected.target); });
$('.close-dialog, .twp-overlay').on('click', closeDialog);
}
function showAgentTransferDialog(agents) {
var html = '<div class="twp-overlay"></div><div class="twp-dialog"><h3>Transfer to Agent</h3>';
agents.forEach(function(a) {
var st = a.is_available ? '&#128994;' : '&#128308;';
html += '<div class="agent-option" data-agent-id="' + a.id + '" data-method="' + a.transfer_method + '" data-value="' + a.transfer_value + '">';
html += '<strong>' + a.name + '</strong><div>' + st + '</div></div>';
});
html += '<p style="margin-top:10px;">Or enter number:</p>';
html += '<input type="tel" id="transfer-manual-input" placeholder="+1234567890" />';
html += '<div class="dialog-actions">';
html += '<button id="confirm-transfer" class="btn-primary" disabled>Transfer</button>';
html += '<button class="btn-secondary close-dialog">Cancel</button>';
html += '</div></div>';
$('body').append(html);
var sel = null;
$('.agent-option').on('click', function() {
$('.agent-option').removeClass('selected');
$(this).addClass('selected');
sel = { id: $(this).data('agent-id'), method: $(this).data('method'), value: $(this).data('value') };
$('#transfer-manual-input').val('');
$('#confirm-transfer').prop('disabled', false);
});
$('#transfer-manual-input').on('input', function() {
if ($(this).val().trim()) { sel = null; $('#confirm-transfer').prop('disabled', false); }
});
$('#confirm-transfer').on('click', function() {
var manual = $('#transfer-manual-input').val().trim();
if (manual) transferToNumber(manual);
else if (sel) transferToAgent(sel);
});
$('.close-dialog, .twp-overlay').on('click', closeDialog);
}
function showManualTransferDialog() {
var html = '<div class="twp-overlay"></div><div class="twp-dialog"><h3>Transfer Call</h3>';
html += '<p>Enter phone number:</p>';
html += '<input type="tel" id="transfer-manual-input" placeholder="+1234567890" />';
html += '<div class="dialog-actions">';
html += '<button id="confirm-transfer" class="btn-primary">Transfer</button>';
html += '<button class="btn-secondary close-dialog">Cancel</button>';
html += '</div></div>';
$('body').append(html);
$('#confirm-transfer').on('click', function() {
var n = $('#transfer-manual-input').val().trim();
if (n) transferToNumber(n);
});
$('.close-dialog, .twp-overlay').on('click', closeDialog);
}
function executeTransfer(type, target) {
var sid = getCallSid();
if (!sid) { showNotice('No call SID', 'error'); return; }
var data = { action: 'twp_transfer_call', call_sid: sid, nonce: twpNonce };
if (/^\d{3,4}$/.test(target)) data.target_queue_id = target;
else { data.transfer_type = 'phone'; data.transfer_target = target; }
twpPost(data, function(r) {
if (r.success) { showNotice('Call transferred', 'success'); closeDialog(); }
else showNotice('Transfer failed: ' + (r.data || ''), 'error');
}, function() { showNotice('Transfer failed', 'error'); });
}
function transferToNumber(num) {
var sid = getCallSid();
if (!sid) return;
twpPost({ action: 'twp_transfer_call', call_sid: sid, agent_number: num, nonce: twpNonce }, function(r) {
if (r.success) { showNotice('Call transferred', 'success'); closeDialog(); if (currentCall) currentCall.disconnect(); }
else showNotice('Transfer failed: ' + (r.data || ''), 'error');
}, function() { showNotice('Transfer failed', 'error'); });
}
function transferToAgent(agent) {
var sid = getCallSid();
if (!sid) return;
twpPost({ action: 'twp_transfer_to_agent_queue', call_sid: sid, agent_id: agent.id, transfer_method: agent.method, transfer_value: agent.value, nonce: twpNonce }, function(r) {
if (r.success) { showNotice('Call transferred', 'success'); closeDialog(); if (currentCall) currentCall.disconnect(); }
else showNotice('Transfer failed: ' + (r.data || ''), 'error');
}, function() { showNotice('Transfer failed', 'error'); });
}
// ============================================================
// Requeue Dialog
// ============================================================
function showRequeueDialog(queues) {
var html = '<div class="twp-overlay"></div><div class="twp-dialog"><h3>Requeue Call</h3>';
html += '<p>Select a queue:</p>';
queues.forEach(function(q) {
html += '<div class="queue-option" data-queue-id="' + q.id + '"><strong>' + q.queue_name + '</strong></div>';
});
html += '<div class="dialog-actions">';
html += '<button id="confirm-requeue" class="btn-primary" disabled>Requeue</button>';
html += '<button class="btn-secondary close-dialog">Cancel</button>';
html += '</div></div>';
$('body').append(html);
var selQ = null;
$('.twp-dialog .queue-option').on('click', function() {
$('.queue-option').removeClass('selected');
$(this).addClass('selected');
selQ = $(this).data('queue-id');
$('#confirm-requeue').prop('disabled', false);
});
$('#confirm-requeue').on('click', function() {
if (!selQ) return;
var sid = getCallSid();
if (!sid) return;
twpPost({ action: 'twp_requeue_call', call_sid: sid, queue_id: selQ, nonce: twpNonce }, function(r) {
if (r.success) { showNotice('Call requeued', 'success'); closeDialog(); if (currentCall) currentCall.disconnect(); }
else showNotice('Requeue failed: ' + (r.data || ''), 'error');
}, function() { showNotice('Requeue failed', 'error'); });
});
$('.close-dialog, .twp-overlay').on('click', closeDialog);
}
// ============================================================
// Queue Management
// ============================================================
var adminUserQueues = [];
function loadAdminQueues() {
twpPost({ action: 'twp_get_agent_queues', nonce: twpNonce }, function(r) {
if (r.success) { adminUserQueues = r.data; displayAdminQueues(); }
else { $('#admin-queue-list').html('<div class="queue-loading">Failed to load queues</div>'); }
}, function() { $('#admin-queue-list').html('<div class="queue-loading">Failed to load queues</div>'); });
}
function displayAdminQueues() {
var $list = $('#admin-queue-list');
if (adminUserQueues.length === 0) { $list.html('<div class="queue-loading">No queues assigned.</div>'); return; }
var h = '';
adminUserQueues.forEach(function(q) {
var hasW = parseInt(q.current_waiting) > 0;
var wc = q.current_waiting || 0;
var qt = q.queue_type || 'general';
var icon = qt === 'personal' ? '&#128100;' : qt === 'hold' ? '&#9208;' : '&#128203;';
var desc = qt === 'personal' ? (q.extension ? ' (Ext: ' + q.extension + ')' : '') : qt === 'hold' ? ' (Hold)' : ' (Team)';
h += '<div class="queue-item queue-type-' + qt + (hasW ? ' has-calls' : '') + '">';
h += '<div class="queue-info"><div class="queue-name">' + icon + ' ' + q.queue_name + desc + '</div>';
h += '<div class="queue-details"><span class="queue-waiting' + (hasW ? ' has-calls' : '') + '">' + wc + ' waiting</span>';
h += '<span>Max: ' + q.max_size + '</span></div></div>';
h += '<button type="button" class="btn-sm accept-queue-call" data-queue-id="' + q.id + '"' + (hasW ? '' : ' disabled') + '>Accept</button>';
h += '</div>';
});
$list.html(h);
}
$(document).on('click', '.accept-queue-call', function() {
var qid = $(this).data('queue-id');
var $btn = $(this);
$btn.prop('disabled', true).text('...');
twpPost({ action: 'twp_accept_next_queue_call', queue_id: qid, nonce: twpNonce }, function(r) {
if (r.success) { showNotice('Connecting to caller...', 'success'); setTimeout(loadAdminQueues, 1000); }
else showNotice(r.data || 'No calls waiting', 'info');
}, function() { showNotice('Failed to accept call', 'error'); });
$btn.prop('disabled', false).text('Accept');
});
$('#admin-refresh-queues').on('click', loadAdminQueues);
// Load queues immediately and poll every 5 seconds.
loadAdminQueues();
setInterval(loadAdminQueues, 5000);
// ============================================================
// Mode Switching
// ============================================================
$('input[name="call_mode"]').on('change', function() {
var sel = $(this).val();
var cur = $('#mode-text').text().indexOf('Browser') !== -1 ? 'browser' : 'cell';
if (sel !== cur) {
$('#save-mode-btn').show();
$('.mode-option').removeClass('active');
$(this).closest('.mode-option').addClass('active');
$('#mode-text').text((sel === 'browser' ? 'Browser Phone' : 'Cell Phone') + ' (unsaved)').css('color', 'var(--warning)');
$('.mode-info > div').hide();
$('.' + sel + '-mode-info').show();
}
});
$('#save-mode-btn').on('click', function() {
var $btn = $(this);
var sel = $('input[name="call_mode"]:checked').val();
$btn.prop('disabled', true).text('...');
twpPost({ action: 'twp_save_call_mode', mode: sel, nonce: twpNonce }, function(r) {
if (r.success) {
$('#mode-text').text(sel === 'browser' ? 'Browser Phone' : 'Cell Phone').css('color', '');
$('#save-mode-btn').hide();
showNotice('Call mode saved', 'success');
} else { showNotice('Failed to save mode', 'error'); }
}, function() { showNotice('Failed to save mode', 'error'); });
$btn.prop('disabled', false).text('Save');
});
// ============================================================
// Agent Status Bar
// ============================================================
window.toggleAgentLogin = function() {
twpPost({ action: 'twp_toggle_agent_login', nonce: twpNonce }, function(r) {
if (r.success) location.reload();
else showNotice('Failed to change login status', 'error');
}, function() { showNotice('Failed to change login status', 'error'); });
};
window.updateAgentStatus = function(status) {
twpPost({ action: 'twp_set_agent_status', status: status, nonce: twpNonce }, function(r) {
if (r.success) showNotice('Status: ' + status, 'success');
else showNotice('Failed to update status', 'error');
}, function() { showNotice('Failed to update status', 'error'); });
};
// ============================================================
// Clipboard helper
// ============================================================
window.copyToClipboard = function(text) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(function() { showNotice('Copied!', 'success'); });
} else {
var ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showNotice('Copied!', 'success');
}
};
// ============================================================
// Initialize
// ============================================================
$(window).on('beforeunload', function() {
if (tokenRefreshTimer) clearTimeout(tokenRefreshTimer);
if (device) device.destroy();
});
// SDK init
var sdkAttempts = 0;
function checkAndInit() {
sdkAttempts++;
if (typeof Twilio !== 'undefined' && Twilio.Device) { initializeBrowserPhone(); }
else if (sdkAttempts < 100) { setTimeout(checkAndInit, 50); }
else { showError('Twilio Voice SDK failed to load. Check internet connection.'); }
}
if (typeof Twilio !== 'undefined' && Twilio.Device) initializeBrowserPhone();
else checkAndInit();
$(window).on('load', function() {
if (typeof Twilio !== 'undefined' && !device) initializeBrowserPhone();
// Signal Flutter that page is fully loaded.
notifyFlutterReady();
});
})(jQuery);
</script>
</body>
</html>
<?php
}
}