Add comprehensive call control features and web phone transfer capabilities

## New Call Control Features
- Call hold/unhold with music playback
- Call transfer with agent selection dialog
- Call requeue to different queues
- Call recording with start/stop controls
- Real-time recording status tracking

## Enhanced Transfer System
- Transfer to agents with cell phones (direct)
- Transfer to web phone agents via personal queues
- Automatic queue creation for each user
- Real-time agent availability status
- Visual agent selection with status indicators (📱 phone, 💻 web)

## Call Recordings Management
- New database table for call recordings
- Recordings tab in voicemail interface
- Play/download recordings functionality
- Admin-only delete capability
- Integration with Twilio recording webhooks

## Agent Queue System
- Personal queues (agent_[user_id]) for web phone transfers
- Automatic polling for incoming transfers
- Transfer notifications with browser alerts
- Agent status tracking (available/busy/offline)

## Technical Enhancements
- 8 new AJAX endpoints for call controls
- Recording status webhooks
- Enhanced transfer dialogs with agent selection
- Improved error handling and user feedback
- Mobile-responsive call control interface

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-30 11:52:50 -07:00
parent 7398f97f24
commit dc3c12e006
8 changed files with 1721 additions and 33 deletions

View File

@@ -1,4 +1,208 @@
/* Twilio Browser Phone Frontend Styles - Mobile First */
/* Call Control Panel Styles */
.twp-call-controls-panel {
margin: 15px 0;
padding: 15px;
background: #fff;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.call-control-buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.twp-btn-control {
padding: 10px 15px;
background: #6c757d;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.twp-btn-control:hover {
background: #5a6268;
transform: translateY(-1px);
}
.twp-btn-control.btn-active {
background: #007bff;
}
.twp-btn-control.btn-recording {
background: #dc3545;
animation: recording-pulse 1.5s infinite;
}
@keyframes recording-pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
/* Dialog Overlay Styles */
.twp-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.twp-dialog {
background: white;
border-radius: 12px;
padding: 25px;
max-width: 400px;
width: 90%;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.twp-dialog h3 {
margin: 0 0 15px 0;
font-size: 1.3rem;
color: #212529;
}
.twp-dialog p {
margin: 0 0 20px 0;
color: #6c757d;
}
.twp-dialog input[type="tel"],
.twp-dialog select {
width: 100%;
padding: 10px;
border: 2px solid #dee2e6;
border-radius: 6px;
font-size: 16px;
margin-bottom: 20px;
}
.twp-dialog input[type="tel"]:focus,
.twp-dialog select:focus {
outline: none;
border-color: #007bff;
}
.dialog-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.dialog-buttons .twp-btn {
padding: 10px 20px;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.dialog-buttons .twp-btn-primary {
background: #007bff;
color: white;
border: none;
}
.dialog-buttons .twp-btn-primary:hover {
background: #0056b3;
}
.dialog-buttons .twp-btn-secondary {
background: #6c757d;
color: white;
border: none;
}
.dialog-buttons .twp-btn-secondary:hover {
background: #5a6268;
}
/* Agent Transfer Dialog Styles */
.twp-agent-transfer-dialog {
max-width: 500px;
}
.agent-list {
max-height: 300px;
overflow-y: auto;
margin-bottom: 20px;
border: 1px solid #dee2e6;
border-radius: 6px;
}
.agent-option {
padding: 12px;
border-bottom: 1px solid #e9ecef;
cursor: pointer;
transition: background 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
}
.agent-option:last-child {
border-bottom: none;
}
.agent-option:hover:not(.offline) {
background: #f8f9fa;
}
.agent-option.selected {
background: #e7f3ff;
border-left: 3px solid #007bff;
}
.agent-option.offline {
opacity: 0.5;
cursor: not-allowed;
}
.agent-info {
display: flex;
align-items: center;
gap: 10px;
}
.agent-name {
font-weight: 500;
color: #212529;
}
.agent-method {
font-size: 18px;
}
.agent-status {
font-size: 14px;
white-space: nowrap;
}
.manual-option {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dee2e6;
}
.manual-option p {
margin-bottom: 10px;
font-size: 14px;
color: #6c757d;
}
.twp-browser-phone-container {
max-width: 400px;
margin: 0 auto;

View File

@@ -23,6 +23,11 @@
let notificationPermission = 'default';
let backgroundAlertInterval = null;
let isPageVisible = true;
let isOnHold = false;
let isRecording = false;
let recordingSid = null;
let personalQueueTimer = null;
let personalQueueName = null;
// Initialize when document is ready
$(document).ready(function() {
@@ -38,6 +43,7 @@
initVoicemailSection();
initializeNotifications();
initializePageVisibility();
initializePersonalQueue();
});
/**
@@ -271,6 +277,47 @@
hangupCall();
});
// Call control buttons
$('#twp-hold-btn').on('click', function() {
toggleHold();
});
$('#twp-transfer-btn').on('click', function() {
showTransferDialog();
});
$('#twp-requeue-btn').on('click', function() {
showRequeueDialog();
});
$('#twp-record-btn').on('click', function() {
toggleRecording();
});
// Transfer dialog handlers
$(document).on('click', '#twp-confirm-transfer', function() {
const agentNumber = $('#twp-transfer-agent-number').val();
if (agentNumber) {
transferCall(agentNumber);
}
});
$(document).on('click', '#twp-cancel-transfer', function() {
hideTransferDialog();
});
// Requeue dialog handlers
$(document).on('click', '#twp-confirm-requeue', function() {
const queueId = $('#twp-requeue-select').val();
if (queueId) {
requeueCall(queueId);
}
});
$(document).on('click', '#twp-cancel-requeue', function() {
hideRequeueDialog();
});
// Accept queue call button
$('#twp-accept-queue-call').on('click', function() {
acceptQueueCall();
@@ -465,12 +512,24 @@
* End call and cleanup
*/
function endCall() {
// Stop recording if active
if (isRecording) {
stopRecording();
}
currentCall = null;
isOnHold = false;
isRecording = false;
recordingSid = null;
stopCallTimer();
updateCallState('idle');
hideCallInfo();
$('.twp-browser-phone-container').removeClass('incoming-call');
// Reset control buttons
$('#twp-hold-btn').text('Hold').removeClass('btn-active');
$('#twp-record-btn').text('Record').removeClass('btn-active');
// Restart alerts if enabled and there are waiting calls
if (alertEnabled) {
const hasWaitingCalls = userQueues.some(q => parseInt(q.current_waiting) > 0);
@@ -671,20 +730,24 @@
function updateCallState(state) {
const $callBtn = $('#twp-call-btn');
const $hangupBtn = $('#twp-hangup-btn');
const $controlsPanel = $('#twp-call-controls-panel');
switch (state) {
case 'idle':
$callBtn.show().prop('disabled', false);
$hangupBtn.hide();
$controlsPanel.hide();
break;
case 'connecting':
case 'ringing':
$callBtn.hide();
$hangupBtn.show();
$controlsPanel.hide();
break;
case 'connected':
$callBtn.hide();
$hangupBtn.show();
$controlsPanel.show();
break;
}
}
@@ -1002,6 +1065,9 @@
if (backgroundAlertInterval) {
clearInterval(backgroundAlertInterval);
}
if (personalQueueTimer) {
clearInterval(personalQueueTimer);
}
if (twilioDevice) {
twilioDevice.destroy();
}
@@ -1376,4 +1442,526 @@
// Load alert preference on init
loadAlertPreference();
/**
* Initialize personal queue for incoming transfers
*/
function initializePersonalQueue() {
if (!twp_frontend_ajax.user_id) return;
// Set personal queue name
personalQueueName = 'agent_' + twp_frontend_ajax.user_id;
// Start polling for incoming transfers
checkPersonalQueue();
personalQueueTimer = setInterval(checkPersonalQueue, 3000); // Check every 3 seconds
}
/**
* Check personal queue for incoming transfers
*/
function checkPersonalQueue() {
// Don't check if already in a call
if (currentCall) return;
$.ajax({
url: twp_frontend_ajax.ajax_url,
method: 'POST',
data: {
action: 'twp_check_personal_queue',
nonce: twp_frontend_ajax.nonce
},
success: function(response) {
if (response.success && response.data.has_waiting_call) {
handleIncomingTransfer(response.data);
}
},
error: function() {
// Silently fail - don't interrupt user
}
});
}
/**
* Handle incoming transfer notification
*/
function handleIncomingTransfer(data) {
// Show notification
showMessage('Incoming transfer! The call will be connected automatically.', 'info');
// Show browser notification
if (notificationPermission === 'granted') {
showBrowserNotification('📞 Incoming Transfer!', {
body: 'A call is being transferred to you',
icon: '📞',
vibrate: [300, 200, 300],
requireInteraction: true,
tag: 'transfer-notification'
});
}
// Play alert sound if enabled
if (alertEnabled) {
playAlertSound();
}
// Auto-accept the transfer after a short delay
setTimeout(function() {
acceptTransferCall(data);
}, 2000);
}
/**
* Accept incoming transfer call
*/
function acceptTransferCall(data) {
$.ajax({
url: twp_frontend_ajax.ajax_url,
method: 'POST',
data: {
action: 'twp_accept_transfer_call',
call_sid: data.call_sid,
queue_id: data.queue_id,
nonce: twp_frontend_ajax.nonce
},
success: function(response) {
if (response.success) {
showMessage('Transfer accepted, connecting...', 'success');
} else {
showMessage('Failed to accept transfer: ' + (response.data || 'Unknown error'), 'error');
}
},
error: function() {
showMessage('Failed to accept transfer', 'error');
}
});
}
/**
* Toggle call hold
*/
function toggleHold() {
if (!currentCall || currentCall.status() !== 'open') {
showMessage('No active call to hold', 'error');
return;
}
const $holdBtn = $('#twp-hold-btn');
$.ajax({
url: twp_frontend_ajax.ajax_url,
method: 'POST',
data: {
action: 'twp_toggle_hold',
call_sid: currentCall.parameters.CallSid,
hold: !isOnHold,
nonce: twp_frontend_ajax.nonce
},
success: function(response) {
if (response.success) {
isOnHold = !isOnHold;
if (isOnHold) {
$holdBtn.text('Unhold').addClass('btn-active');
showMessage('Call placed on hold', 'info');
} else {
$holdBtn.text('Hold').removeClass('btn-active');
showMessage('Call resumed', 'info');
}
} else {
showMessage('Failed to toggle hold: ' + (response.data || 'Unknown error'), 'error');
}
},
error: function() {
showMessage('Failed to toggle hold', 'error');
}
});
}
/**
* Transfer call to another agent
*/
function transferCall(agentNumber) {
if (!currentCall || currentCall.status() !== 'open') {
showMessage('No active call to transfer', 'error');
return;
}
$.ajax({
url: twp_frontend_ajax.ajax_url,
method: 'POST',
data: {
action: 'twp_transfer_call',
call_sid: currentCall.parameters.CallSid,
agent_number: agentNumber,
nonce: twp_frontend_ajax.nonce
},
success: function(response) {
if (response.success) {
showMessage('Call transferred successfully', 'success');
hideTransferDialog();
// End the call on our end
if (currentCall) {
currentCall.disconnect();
}
} else {
showMessage('Failed to transfer call: ' + (response.data || 'Unknown error'), 'error');
}
},
error: function() {
showMessage('Failed to transfer call', 'error');
}
});
}
/**
* Requeue call to a different queue
*/
function requeueCall(queueId) {
if (!currentCall || currentCall.status() !== 'open') {
showMessage('No active call to requeue', 'error');
return;
}
$.ajax({
url: twp_frontend_ajax.ajax_url,
method: 'POST',
data: {
action: 'twp_requeue_call',
call_sid: currentCall.parameters.CallSid,
queue_id: queueId,
nonce: twp_frontend_ajax.nonce
},
success: function(response) {
if (response.success) {
showMessage('Call requeued successfully', 'success');
hideRequeueDialog();
// End the call on our end
if (currentCall) {
currentCall.disconnect();
}
} else {
showMessage('Failed to requeue call: ' + (response.data || 'Unknown error'), 'error');
}
},
error: function() {
showMessage('Failed to requeue call', 'error');
}
});
}
/**
* Toggle call recording
*/
function toggleRecording() {
if (!currentCall || currentCall.status() !== 'open') {
showMessage('No active call to record', 'error');
return;
}
if (isRecording) {
stopRecording();
} else {
startRecording();
}
}
/**
* Start recording the current call
*/
function startRecording() {
const $recordBtn = $('#twp-record-btn');
$.ajax({
url: twp_frontend_ajax.ajax_url,
method: 'POST',
data: {
action: 'twp_start_recording',
call_sid: currentCall.parameters.CallSid,
nonce: twp_frontend_ajax.nonce
},
success: function(response) {
if (response.success) {
isRecording = true;
recordingSid = response.data.recording_sid;
$recordBtn.text('Stop Recording').addClass('btn-active btn-recording');
showMessage('Recording started', 'success');
} else {
showMessage('Failed to start recording: ' + (response.data || 'Unknown error'), 'error');
}
},
error: function() {
showMessage('Failed to start recording', 'error');
}
});
}
/**
* Stop recording the current call
*/
function stopRecording() {
if (!recordingSid) return;
const $recordBtn = $('#twp-record-btn');
$.ajax({
url: twp_frontend_ajax.ajax_url,
method: 'POST',
data: {
action: 'twp_stop_recording',
call_sid: currentCall ? currentCall.parameters.CallSid : '',
recording_sid: recordingSid,
nonce: twp_frontend_ajax.nonce
},
success: function(response) {
if (response.success) {
isRecording = false;
recordingSid = null;
$recordBtn.text('Record').removeClass('btn-active btn-recording');
showMessage('Recording stopped', 'info');
} else {
showMessage('Failed to stop recording: ' + (response.data || 'Unknown error'), 'error');
}
},
error: function() {
showMessage('Failed to stop recording', 'error');
}
});
}
/**
* Show transfer dialog
*/
function showTransferDialog() {
// First load available agents
$.ajax({
url: twp_frontend_ajax.ajax_url,
method: 'POST',
data: {
action: 'twp_get_online_agents',
nonce: twp_frontend_ajax.nonce
},
success: function(response) {
if (response.success && response.data.length > 0) {
showAgentTransferDialog(response.data);
} else {
// Fallback to manual phone number entry
showManualTransferDialog();
}
},
error: function() {
// Fallback to manual phone number entry
showManualTransferDialog();
}
});
}
/**
* Show agent selection transfer dialog
*/
function showAgentTransferDialog(agents) {
let agentOptions = '<div class="agent-list">';
agents.forEach(function(agent) {
const statusClass = agent.is_available ? 'available' : (agent.status === 'busy' ? 'busy' : 'offline');
const statusText = agent.is_available ? '🟢 Available' : (agent.status === 'busy' ? '🔴 Busy' : '⚫ Offline');
const methodIcon = agent.has_phone ? '📱' : '💻';
agentOptions += `
<div class="agent-option ${statusClass}" data-agent-id="${agent.id}"
data-transfer-method="${agent.transfer_method}"
data-transfer-value="${agent.transfer_value}">
<div class="agent-info">
<span class="agent-name">${agent.name}</span>
<span class="agent-method">${methodIcon}</span>
</div>
<div class="agent-status">${statusText}</div>
</div>
`;
});
agentOptions += '</div>';
const dialog = `
<div id="twp-transfer-dialog" class="twp-dialog-overlay">
<div class="twp-dialog twp-agent-transfer-dialog">
<h3>Transfer Call to Agent</h3>
<p>Select an agent to transfer this call to:</p>
${agentOptions}
<div class="manual-option">
<p>Or enter a phone number manually:</p>
<input type="tel" id="twp-transfer-manual-number" placeholder="+1234567890" />
</div>
<div class="dialog-buttons">
<button id="twp-confirm-agent-transfer" class="twp-btn twp-btn-primary" disabled>Transfer</button>
<button id="twp-cancel-transfer" class="twp-btn twp-btn-secondary">Cancel</button>
</div>
</div>
</div>
`;
$('body').append(dialog);
// Handle agent selection
let selectedAgent = null;
$('.agent-option').on('click', function() {
if ($(this).hasClass('offline')) {
showMessage('Cannot transfer to offline agents', 'error');
return;
}
$('.agent-option').removeClass('selected');
$(this).addClass('selected');
selectedAgent = {
id: $(this).data('agent-id'),
method: $(this).data('transfer-method'),
value: $(this).data('transfer-value')
};
$('#twp-transfer-manual-number').val('');
$('#twp-confirm-agent-transfer').prop('disabled', false);
});
// Handle manual number entry
$('#twp-transfer-manual-number').on('input', function() {
const number = $(this).val().trim();
if (number) {
$('.agent-option').removeClass('selected');
selectedAgent = null;
$('#twp-confirm-agent-transfer').prop('disabled', false);
} else {
$('#twp-confirm-agent-transfer').prop('disabled', !selectedAgent);
}
});
// Handle transfer confirmation
$('#twp-confirm-agent-transfer').on('click', function() {
const manualNumber = $('#twp-transfer-manual-number').val().trim();
if (manualNumber) {
// Manual phone transfer
transferCall(manualNumber);
} else if (selectedAgent) {
// Agent transfer (phone or queue)
transferToAgent(selectedAgent);
}
});
}
/**
* Show manual transfer dialog (fallback)
*/
function showManualTransferDialog() {
const dialog = `
<div id="twp-transfer-dialog" class="twp-dialog-overlay">
<div class="twp-dialog">
<h3>Transfer Call</h3>
<p>Enter the phone number to transfer this call:</p>
<input type="tel" id="twp-transfer-agent-number" placeholder="+1234567890" />
<div class="dialog-buttons">
<button id="twp-confirm-transfer" class="twp-btn twp-btn-primary">Transfer</button>
<button id="twp-cancel-transfer" class="twp-btn twp-btn-secondary">Cancel</button>
</div>
</div>
</div>
`;
$('body').append(dialog);
}
/**
* Transfer call to selected agent
*/
function transferToAgent(agent) {
if (!currentCall || currentCall.status() !== 'open') {
showMessage('No active call to transfer', 'error');
return;
}
$.ajax({
url: twp_frontend_ajax.ajax_url,
method: 'POST',
data: {
action: 'twp_transfer_to_agent_queue',
call_sid: currentCall.parameters.CallSid,
agent_id: agent.id,
transfer_method: agent.method,
transfer_value: agent.value,
nonce: twp_frontend_ajax.nonce
},
success: function(response) {
if (response.success) {
showMessage('Call transferred successfully', 'success');
hideTransferDialog();
// End the call on our end
if (currentCall) {
currentCall.disconnect();
}
} else {
showMessage('Failed to transfer call: ' + (response.data || 'Unknown error'), 'error');
}
},
error: function() {
showMessage('Failed to transfer call', 'error');
}
});
}
/**
* Hide transfer dialog
*/
function hideTransferDialog() {
$('#twp-transfer-dialog').remove();
}
/**
* Show requeue dialog
*/
function showRequeueDialog() {
// Load available queues first
$.ajax({
url: twp_frontend_ajax.ajax_url,
method: 'POST',
data: {
action: 'twp_get_all_queues',
nonce: twp_frontend_ajax.nonce
},
success: function(response) {
if (response.success && response.data.length > 0) {
let options = '';
response.data.forEach(function(queue) {
options += `<option value="${queue.id}">${queue.queue_name}</option>`;
});
const dialog = `
<div id="twp-requeue-dialog" class="twp-dialog-overlay">
<div class="twp-dialog">
<h3>Requeue Call</h3>
<p>Select a queue to transfer this call to:</p>
<select id="twp-requeue-select">
${options}
</select>
<div class="dialog-buttons">
<button id="twp-confirm-requeue" class="twp-btn twp-btn-primary">Requeue</button>
<button id="twp-cancel-requeue" class="twp-btn twp-btn-secondary">Cancel</button>
</div>
</div>
</div>
`;
$('body').append(dialog);
} else {
showMessage('No queues available', 'error');
}
},
error: function() {
showMessage('Failed to load queues', 'error');
}
});
}
/**
* Hide requeue dialog
*/
function hideRequeueDialog() {
$('#twp-requeue-dialog').remove();
}
})(jQuery);