chore: floating button for incoming call
This commit is contained in:
@@ -55,7 +55,7 @@ export default {
|
||||
showAddAccountModal: false,
|
||||
latestChatwootVersion: null,
|
||||
reconnectService: null,
|
||||
showCallWidget: false, // Set to true for testing, false for production
|
||||
showCallWidget: false, // Will be set to true when calls are active
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -67,6 +67,8 @@ export default {
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
activeCall: 'calls/getActiveCall',
|
||||
hasActiveCall: 'calls/hasActiveCall',
|
||||
incomingCall: 'calls/getIncomingCall',
|
||||
hasIncomingCall: 'calls/hasIncomingCall',
|
||||
}),
|
||||
hasAccounts() {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
@@ -91,18 +93,34 @@ export default {
|
||||
}
|
||||
},
|
||||
},
|
||||
hasIncomingCall: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
console.log('App.vue detected change in hasIncomingCall to', newVal);
|
||||
if (newVal) {
|
||||
console.log('Incoming call data:', this.incomingCall);
|
||||
this.showCallWidget = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
hasActiveCall: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
console.log('App.vue detected change in hasActiveCall to', newVal);
|
||||
if (newVal) {
|
||||
console.log('Active call data:', this.activeCall);
|
||||
this.showCallWidget = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Make app instance available globally for debugging and cross-component access
|
||||
window.app = this;
|
||||
|
||||
// Set up global force end call mechanism
|
||||
window.forceEndCall = () => this.forceEndCall();
|
||||
window.forceEndCallHandlers = [];
|
||||
|
||||
this.initializeColorTheme();
|
||||
this.listenToThemeChanges();
|
||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||
|
||||
// Make app instance available globally for direct call widget updates
|
||||
window.app = this;
|
||||
},
|
||||
unmounted() {
|
||||
if (this.reconnectService) {
|
||||
@@ -121,80 +139,52 @@ export default {
|
||||
this.$root.$i18n.locale = locale;
|
||||
},
|
||||
handleCallEnded() {
|
||||
console.log('Call ended event received in App.vue');
|
||||
// Update our local state first for immediate UI update
|
||||
this.showCallWidget = false;
|
||||
// Then update the store
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
this.$store.dispatch('calls/clearIncomingCall');
|
||||
},
|
||||
|
||||
// Public method that can be called from anywhere
|
||||
handleCallJoined() {
|
||||
this.showCallWidget = true;
|
||||
},
|
||||
handleCallRejected() {
|
||||
this.showCallWidget = false;
|
||||
this.$store.dispatch('calls/clearIncomingCall');
|
||||
},
|
||||
forceEndCall() {
|
||||
console.log('Force end call triggered in App.vue');
|
||||
|
||||
// 1. Update UI immediately
|
||||
this.showCallWidget = false;
|
||||
|
||||
// 2. Try to notify any other components
|
||||
if (window.forceEndCallHandlers) {
|
||||
window.forceEndCallHandlers.forEach(handler => {
|
||||
try {
|
||||
handler();
|
||||
} catch (e) {
|
||||
console.error('Error in end call handler:', e);
|
||||
// Optionally log error in production
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. CRITICAL: Make API call to actually end the call on the server
|
||||
if (this.activeCall && this.activeCall.callSid) {
|
||||
const { callSid, conversationId } = this.activeCall;
|
||||
|
||||
// Save references before clearing the store
|
||||
const savedCallSid = callSid;
|
||||
const savedConversationId = conversationId;
|
||||
|
||||
// Now clear the store
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
|
||||
// Make API call if we have a conversation ID
|
||||
if (savedConversationId) {
|
||||
console.log(
|
||||
'App.vue making API call to end call with SID:',
|
||||
savedCallSid,
|
||||
'for conversation:',
|
||||
savedConversationId
|
||||
);
|
||||
|
||||
// Make the API call to end the call on the server with both parameters
|
||||
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||
.then(response => {
|
||||
console.log('Call ended successfully via API:', response);
|
||||
useAlert({ message: 'Call ended successfully', type: 'success' });
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error ending call via API:', error);
|
||||
|
||||
// If first attempt fails, try one more time with additional logging
|
||||
console.log('Retrying end call with more debugging...');
|
||||
setTimeout(() => {
|
||||
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||
.then(retryResponse => {
|
||||
console.log('Retry successful:', retryResponse);
|
||||
})
|
||||
.catch(retryError => {
|
||||
console.error('Retry also failed:', retryError);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
useAlert({ message: 'Call UI has been reset', type: 'info' });
|
||||
});
|
||||
} else {
|
||||
console.log('App.vue: Not making API call because conversation ID is missing');
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
}
|
||||
} else {
|
||||
// No active call data, just clear the store
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
}
|
||||
},
|
||||
@@ -247,12 +237,16 @@ export default {
|
||||
<NetworkNotification />
|
||||
<!-- Floating call widget that appears during active calls -->
|
||||
<FloatingCallWidget
|
||||
v-if="showCallWidget || (activeCall && activeCall.callSid)"
|
||||
:key="`call-${Date.now()}`"
|
||||
:call-sid="activeCall ? activeCall.callSid : 'test-call'"
|
||||
:inbox-name="activeCall ? (activeCall.inboxName || 'Primary') : 'Primary'"
|
||||
:conversation-id="activeCall ? activeCall.conversationId : null"
|
||||
@call-ended="handleCallEnded"
|
||||
v-if="showCallWidget || hasActiveCall || hasIncomingCall"
|
||||
:key="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : 'no-call')"
|
||||
:call-sid="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : '')"
|
||||
:inbox-name="activeCall ? (activeCall.inboxName || 'Primary') : (incomingCall ? incomingCall.inboxName : 'Primary')"
|
||||
:conversation-id="activeCall ? activeCall.conversationId : (incomingCall ? incomingCall.conversationId : null)"
|
||||
:contact-name="activeCall ? activeCall.contactName : (incomingCall ? incomingCall.contactName : '')"
|
||||
:contact-id="activeCall ? activeCall.contactId : (incomingCall ? incomingCall.contactId : null)"
|
||||
@callEnded="handleCallEnded"
|
||||
@callJoined="handleCallJoined"
|
||||
@callRejected="handleCallRejected"
|
||||
/>
|
||||
</div>
|
||||
<LoadingState v-else />
|
||||
|
||||
@@ -3,76 +3,87 @@ import ApiClient from '../ApiClient';
|
||||
|
||||
class VoiceAPI extends ApiClient {
|
||||
constructor() {
|
||||
// Use empty string for resource to avoid duplicate 'accounts' in URL
|
||||
super('', { accountScoped: true });
|
||||
// Use 'voice' as the resource with accountScoped: true
|
||||
super('voice', { accountScoped: true });
|
||||
}
|
||||
|
||||
// Initiate a call to a contact
|
||||
initiateCall(contactId) {
|
||||
// Get the account ID from the current URL
|
||||
const accountId = this.accountIdFromRoute;
|
||||
// Make sure we have the right endpoint path
|
||||
return axios.post(
|
||||
`/api/v1/accounts/${accountId}/contacts/${contactId}/call`
|
||||
);
|
||||
if (!contactId) {
|
||||
throw new Error('Contact ID is required to initiate a call');
|
||||
}
|
||||
|
||||
// Based on the route definition, the correct URL path is /api/v1/accounts/{accountId}/contacts/{contactId}/call
|
||||
// The endpoint is defined in the contacts namespace, not voice namespace
|
||||
return axios.post(`${this.baseUrl().replace('/voice', '')}/contacts/${contactId}/call`);
|
||||
}
|
||||
|
||||
// End an active call
|
||||
endCall(callSid, conversationId) {
|
||||
if (!conversationId) {
|
||||
console.error('VoiceAPI: Cannot end call - conversation ID is required');
|
||||
return Promise.reject(
|
||||
new Error('Conversation ID is required to end a call')
|
||||
);
|
||||
throw new Error('Conversation ID is required to end a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
console.error('VoiceAPI: Cannot end call - call SID is required');
|
||||
return Promise.reject(new Error('Call SID is required to end a call'));
|
||||
throw new Error('Call SID is required to end a call');
|
||||
}
|
||||
|
||||
// Validate call SID format - Twilio call SID starts with 'CA' followed by alphanumeric characters
|
||||
// Validate call SID format - Twilio call SID starts with 'CA' or 'TJ'
|
||||
if (!callSid.startsWith('CA') && !callSid.startsWith('TJ')) {
|
||||
console.error('VoiceAPI: Invalid call SID format:', callSid);
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.'
|
||||
)
|
||||
throw new Error(
|
||||
'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.'
|
||||
);
|
||||
}
|
||||
|
||||
// Get the account ID from the current URL
|
||||
const accountId = this.accountIdFromRoute;
|
||||
console.log(
|
||||
`VoiceAPI: Ending call with SID ${callSid} for conversation ${conversationId} in account ${accountId}`
|
||||
);
|
||||
|
||||
// Make the actual API call with conversation ID as a parameter
|
||||
// Using the route structure that matches the Rails routes.rb definition
|
||||
return axios
|
||||
.post(`/api/v1/accounts/${accountId}/voice/end_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
id: conversationId, // Also include as 'id' as the controller may check for it
|
||||
})
|
||||
.then(response => {
|
||||
console.log('VoiceAPI: End call API succeeded:', response);
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('VoiceAPI: End call API failed:', error);
|
||||
throw error;
|
||||
});
|
||||
return axios.post(`${this.url}/end_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Get call status
|
||||
getCallStatus(callSid) {
|
||||
// Get the account ID from the current URL
|
||||
const accountId = this.accountIdFromRoute;
|
||||
return axios.get(`/api/v1/accounts/${accountId}/voice/call_status`, {
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to get call status');
|
||||
}
|
||||
|
||||
return axios.get(`${this.url}/call_status`, {
|
||||
params: { call_sid: callSid },
|
||||
});
|
||||
}
|
||||
|
||||
// Join an incoming call as an agent (join the conference)
|
||||
joinCall(callSid, conversationId) {
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to join a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to join a call');
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/join_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Reject an incoming call as an agent (don't join the conference)
|
||||
rejectCall(callSid, conversationId) {
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to reject a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to reject a call');
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/reject_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new VoiceAPI();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
export default {
|
||||
name: 'FloatingCallWidget',
|
||||
@@ -20,8 +21,16 @@ export default {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
contactName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
contactId: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['call-ended'],
|
||||
emits: ['callEnded', 'callJoined', 'callRejected'],
|
||||
setup(props, { emit }) {
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
@@ -31,20 +40,47 @@ export default {
|
||||
const isMuted = ref(false);
|
||||
const showCallOptions = ref(false);
|
||||
const isFullscreen = ref(false);
|
||||
const ringtoneAudio = ref(null);
|
||||
const displayContactName = ref(props.contactName || 'Loading...');
|
||||
|
||||
// Define local fallback translations in case i18n fails
|
||||
const translations = {
|
||||
'CONVERSATION.END_CALL': 'End call',
|
||||
'CONVERSATION.JOIN_CALL': 'Join call',
|
||||
'CONVERSATION.REJECT_CALL': 'Reject',
|
||||
'CONVERSATION.CALL_ENDED': 'Call ended',
|
||||
'CONVERSATION.CALL_END_ERROR': 'Failed to end call',
|
||||
'CONVERSATION.CALL_ACCEPTED': 'Joining call...',
|
||||
'CONVERSATION.CALL_REJECTED': 'Call rejected',
|
||||
'CONVERSATION.CALL_JOIN_ERROR': 'Failed to join call',
|
||||
'CONVERSATION.INCOMING_CALL': 'Incoming call',
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
const activeCall = computed(() => store.getters['calls/getActiveCall']);
|
||||
const incomingCall = computed(() => store.getters['calls/getIncomingCall']);
|
||||
const hasIncomingCall = computed(() => store.getters['calls/hasIncomingCall']);
|
||||
const hasActiveCall = computed(() => store.getters['calls/hasActiveCall']);
|
||||
|
||||
const isIncoming = computed(() => {
|
||||
return hasIncomingCall.value && !hasActiveCall.value;
|
||||
});
|
||||
|
||||
const callInfo = computed(() => {
|
||||
return isIncoming.value ? incomingCall.value : activeCall.value;
|
||||
});
|
||||
|
||||
const isJoined = computed(() => {
|
||||
return activeCall.value && activeCall.value.isJoined;
|
||||
});
|
||||
|
||||
const formattedCallDuration = computed(() => {
|
||||
const minutes = Math.floor(callDuration.value / 60);
|
||||
const seconds = callDuration.value % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const startDurationTimer = () => {
|
||||
console.log('Starting duration timer');
|
||||
if (durationTimer.value) clearInterval(durationTimer.value);
|
||||
@@ -61,21 +97,58 @@ export default {
|
||||
}
|
||||
};
|
||||
|
||||
const playRingtone = () => {
|
||||
if (!ringtoneAudio.value) {
|
||||
// Fixed path to an existing audio file
|
||||
ringtoneAudio.value = new Audio('/audio/dashboard/call-ring.mp3');
|
||||
ringtoneAudio.value.loop = true;
|
||||
|
||||
// Preload the audio to reduce delay
|
||||
ringtoneAudio.value.preload = 'auto';
|
||||
|
||||
// Make sure volume is set appropriately
|
||||
ringtoneAudio.value.volume = 0.7;
|
||||
|
||||
// Log confirmation
|
||||
console.log('Ringtone audio initialized with path: /audio/dashboard/call-ring.mp3');
|
||||
}
|
||||
|
||||
// Force play with user interaction if needed
|
||||
const playPromise = ringtoneAudio.value.play();
|
||||
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(error => {
|
||||
console.error('Failed to play ringtone:', error);
|
||||
|
||||
// If autoplay was prevented, try again on next user interaction
|
||||
document.addEventListener('click', () => {
|
||||
ringtoneAudio.value.play().catch(() => {});
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const stopRingtone = () => {
|
||||
if (ringtoneAudio.value) {
|
||||
ringtoneAudio.value.pause();
|
||||
ringtoneAudio.value.currentTime = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Emergency force end call function - simpler and more direct
|
||||
const forceEndCall = () => {
|
||||
console.log('FORCE END CALL triggered from floating widget');
|
||||
|
||||
// Try all methods to ensure call ends
|
||||
|
||||
// 1. Local component state
|
||||
stopDurationTimer();
|
||||
stopRingtone();
|
||||
isCallActive.value = false;
|
||||
|
||||
// Save the call data before potential reset
|
||||
const savedCallSid = props.callSid;
|
||||
const savedConversationId = props.conversationId;
|
||||
|
||||
// 2. First, make direct API call if we have a valid call SID and conversation ID
|
||||
// First, make direct API call if we have a valid call SID and conversation ID
|
||||
if (savedConversationId && savedCallSid && savedCallSid !== 'pending') {
|
||||
// Check if it's a valid Twilio call SID (starts with CA or TJ)
|
||||
const isValidTwilioSid =
|
||||
@@ -121,45 +194,45 @@ export default {
|
||||
console.log('FloatingCallWidget: Missing required data for API call');
|
||||
}
|
||||
|
||||
// 3. Also use global method to update UI states
|
||||
// Also use global method to update UI states
|
||||
if (window.forceEndCall) {
|
||||
console.log('Using global forceEndCall method');
|
||||
window.forceEndCall();
|
||||
}
|
||||
|
||||
// Fallbacks if global method not available
|
||||
|
||||
// 4. Force App state update directly
|
||||
// Force App state update directly
|
||||
if (window.app) {
|
||||
console.log('Forcing app state update');
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// 5. Emit event
|
||||
emit('call-ended');
|
||||
// Emit event
|
||||
emit('callEnded');
|
||||
|
||||
// 6. Update store - using store from setup scope
|
||||
// Update store - using store from setup scope
|
||||
store.dispatch('calls/clearActiveCall');
|
||||
store.dispatch('calls/clearIncomingCall');
|
||||
|
||||
// 7. User feedback
|
||||
// User feedback
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
};
|
||||
|
||||
// Original more careful implementation
|
||||
// End active call
|
||||
const endCall = async () => {
|
||||
console.log('Attempting to end call with SID:', props.callSid);
|
||||
|
||||
// First, always hide the UI for immediate feedback
|
||||
stopDurationTimer();
|
||||
stopRingtone();
|
||||
isCallActive.value = false;
|
||||
|
||||
// Force update the app's state
|
||||
// Force update the app's state to hide widget
|
||||
if (typeof window !== 'undefined' && window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// Emit the event to parent components
|
||||
emit('call-ended');
|
||||
emit('callEnded');
|
||||
|
||||
// Show success message to user
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
@@ -174,7 +247,7 @@ export default {
|
||||
!props.callSid.startsWith('debug-')
|
||||
) {
|
||||
console.log('Ending real call with SID:', props.callSid);
|
||||
await VoiceAPI.endCall(props.callSid);
|
||||
await VoiceAPI.endCall(props.callSid, props.conversationId);
|
||||
} else {
|
||||
console.log('Using fake/temp call SID, skipping API call');
|
||||
}
|
||||
@@ -184,8 +257,95 @@ export default {
|
||||
}
|
||||
|
||||
// Clear from store as last step
|
||||
const store = useStore();
|
||||
store.dispatch('calls/clearActiveCall');
|
||||
store.dispatch('calls/clearIncomingCall');
|
||||
|
||||
// Set global call status in all possible places to ensure widget is removed
|
||||
if (window.globalCallStatus) {
|
||||
window.globalCallStatus.active = false;
|
||||
window.globalCallStatus.incoming = false;
|
||||
}
|
||||
|
||||
// Add a timeout to ensure UI is properly reset if there are async issues
|
||||
setTimeout(() => {
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
store.dispatch('calls/clearActiveCall');
|
||||
store.dispatch('calls/clearIncomingCall');
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Accept incoming call
|
||||
const acceptCall = async () => {
|
||||
console.log('Accepting incoming call with SID:', incomingCall.value?.callSid);
|
||||
|
||||
stopRingtone();
|
||||
|
||||
try {
|
||||
// Call the API to join the call (conference) as an agent
|
||||
if (incomingCall.value) {
|
||||
const { callSid, conversationId } = incomingCall.value;
|
||||
|
||||
// Show user feedback
|
||||
useAlert({ message: safeTranslate('CONVERSATION.CALL_ACCEPTED'), type: 'info' });
|
||||
|
||||
// Make API call to join the conference
|
||||
await VoiceAPI.joinCall(callSid, conversationId);
|
||||
|
||||
// Move incoming call to active call
|
||||
store.dispatch('calls/acceptIncomingCall');
|
||||
|
||||
// Start call duration timer
|
||||
startDurationTimer();
|
||||
|
||||
// Emit event
|
||||
emit('callJoined');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error joining call:', error);
|
||||
useAlert({ message: safeTranslate('CONVERSATION.CALL_JOIN_ERROR'), type: 'error' });
|
||||
forceEndCall();
|
||||
}
|
||||
};
|
||||
|
||||
// Reject incoming call
|
||||
const rejectCall = async () => {
|
||||
console.log('Rejecting incoming call with SID:', incomingCall.value?.callSid);
|
||||
|
||||
stopRingtone();
|
||||
|
||||
try {
|
||||
if (incomingCall.value) {
|
||||
const { callSid, conversationId } = incomingCall.value;
|
||||
|
||||
// Show user feedback
|
||||
useAlert({ message: safeTranslate('CONVERSATION.CALL_REJECTED'), type: 'info' });
|
||||
|
||||
// Make API call to reject the call (optional, the caller will stay in the queue)
|
||||
await VoiceAPI.rejectCall(callSid, conversationId);
|
||||
|
||||
// Clear the incoming call from store
|
||||
store.dispatch('calls/clearIncomingCall');
|
||||
|
||||
// Emit event
|
||||
emit('callRejected');
|
||||
|
||||
// Update app state - checking for $data property
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rejecting call:', error);
|
||||
// Clear anyway for UX purposes
|
||||
store.dispatch('calls/clearIncomingCall');
|
||||
|
||||
// Update app state
|
||||
if (window.app) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
@@ -211,33 +371,34 @@ export default {
|
||||
};
|
||||
|
||||
// Explicit debug handler for end call click
|
||||
const handleEndCallClick = () => {
|
||||
console.log('END CALL BUTTON CLICKED in FloatingCallWidget');
|
||||
console.log(
|
||||
'Current call SID:',
|
||||
props.callSid,
|
||||
'Conversation ID:',
|
||||
props.conversationId
|
||||
);
|
||||
|
||||
const handleEndCallClick = async () => {
|
||||
// Save the call data before UI updates
|
||||
const savedCallSid = props.callSid;
|
||||
const savedConversationId = props.conversationId;
|
||||
const callData = isIncoming.value ? incomingCall.value : activeCall.value;
|
||||
|
||||
if (!callData) {
|
||||
console.log('No call data found');
|
||||
return;
|
||||
}
|
||||
|
||||
const savedCallSid = callData.callSid;
|
||||
const savedConversationId = callData.conversationId;
|
||||
|
||||
// Always update UI immediately for better user experience
|
||||
stopDurationTimer();
|
||||
stopRingtone();
|
||||
isCallActive.value = false;
|
||||
|
||||
// Update app state
|
||||
if (window.app) {
|
||||
// Update app state - checking for $data property
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// Update store
|
||||
store.dispatch('calls/clearActiveCall');
|
||||
store.dispatch('calls/clearIncomingCall');
|
||||
|
||||
// Emit event
|
||||
emit('call-ended');
|
||||
emit('callEnded');
|
||||
|
||||
// Make API call if we have a valid conversation ID and a real call SID (not pending)
|
||||
if (savedConversationId && savedCallSid && savedCallSid !== 'pending') {
|
||||
@@ -246,54 +407,37 @@ export default {
|
||||
savedCallSid.startsWith('CA') || savedCallSid.startsWith('TJ');
|
||||
|
||||
if (isValidTwilioSid) {
|
||||
console.log(
|
||||
'handleEndCallClick: Making API call to end Twilio call with SID:',
|
||||
savedCallSid,
|
||||
'for conversation:',
|
||||
savedConversationId
|
||||
);
|
||||
|
||||
// Make the API call after UI is updated
|
||||
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||
.then(response => {
|
||||
console.log(
|
||||
'handleEndCallClick: Call ended successfully via API:',
|
||||
response
|
||||
);
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(
|
||||
'handleEndCallClick: Error ending call via API:',
|
||||
error
|
||||
);
|
||||
useAlert({
|
||||
message: 'Call ended (but server may still show as active)',
|
||||
type: 'warning',
|
||||
});
|
||||
try {
|
||||
await VoiceAPI.endCall(savedCallSid, savedConversationId);
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
} catch (error) {
|
||||
console.error('Error ending call:', error);
|
||||
useAlert({
|
||||
message: 'Call ended (but server may still show as active)',
|
||||
type: 'warning',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'handleEndCallClick: Invalid Twilio call SID format:',
|
||||
savedCallSid
|
||||
);
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
}
|
||||
} else {
|
||||
if (savedCallSid === 'pending') {
|
||||
console.log(
|
||||
'handleEndCallClick: Call was still in pending state, no API call needed'
|
||||
);
|
||||
} else if (!savedConversationId) {
|
||||
console.log(
|
||||
'handleEndCallClick: No conversation ID available for ending call'
|
||||
);
|
||||
} else {
|
||||
console.log('handleEndCallClick: Missing required data for API call');
|
||||
}
|
||||
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
}
|
||||
|
||||
// Set global call status in all possible places to ensure widget is removed
|
||||
if (window.globalCallStatus) {
|
||||
window.globalCallStatus.active = false;
|
||||
window.globalCallStatus.incoming = false;
|
||||
}
|
||||
|
||||
// Add a timeout to ensure UI is properly reset if there are async issues
|
||||
setTimeout(() => {
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
store.dispatch('calls/clearActiveCall');
|
||||
store.dispatch('calls/clearIncomingCall');
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Safe translation helper with fallback
|
||||
@@ -304,30 +448,97 @@ export default {
|
||||
return translations[key] || key;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to fetch contact details if needed
|
||||
const fetchContactDetails = async () => {
|
||||
// If we already have a contact name, don't fetch
|
||||
if (displayContactName.value !== 'Loading...' && displayContactName.value !== 'Unknown Caller') {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a contact ID, fetch the details
|
||||
const contactId = props.contactId || callInfo.value?.contactId;
|
||||
if (contactId) {
|
||||
try {
|
||||
console.log('Fetching contact details for ID:', contactId);
|
||||
const response = await ContactAPI.show(contactId);
|
||||
if (response.data && response.data.payload) {
|
||||
const contact = response.data.payload;
|
||||
displayContactName.value = contact.name || 'Unknown Caller';
|
||||
console.log('Contact details fetched:', contact.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching contact details:', error);
|
||||
displayContactName.value = 'Unknown Caller';
|
||||
}
|
||||
} else {
|
||||
displayContactName.value = 'Unknown Caller';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
console.log('FloatingCallWidget mounted with callSid:', props.callSid);
|
||||
// Always start the timer, regardless of callSid
|
||||
startDurationTimer();
|
||||
// If this is an active call, start timer
|
||||
if (hasActiveCall.value) {
|
||||
startDurationTimer();
|
||||
}
|
||||
|
||||
// If this is an incoming call, play ringtone
|
||||
if (isIncoming.value) {
|
||||
// Slight delay to ensure DOM is fully rendered
|
||||
setTimeout(() => {
|
||||
playRingtone();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Fetch contact details if needed (after slight delay to ensure callInfo is populated)
|
||||
setTimeout(() => {
|
||||
fetchContactDetails();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopDurationTimer();
|
||||
stopRingtone();
|
||||
});
|
||||
|
||||
// Watch for call SID changes
|
||||
// Watch for call store changes
|
||||
watch(
|
||||
() => props.callSid,
|
||||
newCallSid => {
|
||||
isCallActive.value = !!newCallSid;
|
||||
|
||||
if (newCallSid) {
|
||||
startDurationTimer();
|
||||
} else {
|
||||
() => isIncoming.value,
|
||||
newIsIncoming => {
|
||||
if (newIsIncoming) {
|
||||
// Immediate UI feedback with delay for audio to allow browser autoplay policies
|
||||
stopDurationTimer();
|
||||
setTimeout(() => {
|
||||
playRingtone();
|
||||
}, 300);
|
||||
} else {
|
||||
stopRingtone();
|
||||
}
|
||||
},
|
||||
{ immediate: true } // Check immediately on component creation
|
||||
);
|
||||
|
||||
watch(
|
||||
() => isJoined.value,
|
||||
newIsJoined => {
|
||||
if (newIsJoined) {
|
||||
stopRingtone();
|
||||
startDurationTimer();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Watch for call info changes to fetch contact details if needed
|
||||
watch(
|
||||
() => callInfo.value,
|
||||
(newCallInfo) => {
|
||||
if (newCallInfo && newCallInfo.contactId) {
|
||||
// Try to fetch contact details when call info changes
|
||||
fetchContactDetails();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
isCallActive,
|
||||
@@ -336,8 +547,16 @@ export default {
|
||||
isMuted,
|
||||
showCallOptions,
|
||||
isFullscreen,
|
||||
isIncoming,
|
||||
isJoined,
|
||||
activeCall,
|
||||
incomingCall,
|
||||
callInfo,
|
||||
displayContactName,
|
||||
endCall,
|
||||
forceEndCall,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
handleEndCallClick,
|
||||
toggleMute,
|
||||
toggleCallOptions,
|
||||
@@ -349,49 +568,76 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="floating-call-widget">
|
||||
<div class="call-info">
|
||||
<span class="inbox-name">{{ inboxName }}</span>
|
||||
<span class="call-duration">{{ formattedCallDuration }}</span>
|
||||
<div
|
||||
class="floating-call-widget"
|
||||
:class="{
|
||||
'is-minimized': !showCallOptions,
|
||||
'is-fullscreen': isFullscreen,
|
||||
'is-incoming': isIncoming,
|
||||
}"
|
||||
>
|
||||
<div class="call-header">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="call-icon-wrapper">
|
||||
<span v-if="isIncoming" class="i-ph-phone-call text-xl" />
|
||||
<span v-else class="i-ph-phone text-xl" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h3 class="call-title">
|
||||
{{ displayContactName }}
|
||||
</h3>
|
||||
<div class="call-subtitle">
|
||||
{{ isIncoming ? $t('CONVERSATION.INCOMING_CALL') : (callInfo.inboxName || 'Voice Call') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="call-duration" v-if="!isIncoming">
|
||||
{{ formattedCallDuration }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="call-controls">
|
||||
<div class="call-actions">
|
||||
<button
|
||||
class="control-button mute-button"
|
||||
:class="{ active: isMuted }"
|
||||
:disabled="callSid === 'pending'"
|
||||
@click="toggleMute"
|
||||
v-if="isIncoming"
|
||||
class="control-button accept-call-button"
|
||||
@click="acceptCall"
|
||||
:title="$t('CONVERSATION.JOIN_CALL')"
|
||||
>
|
||||
<span :class="isMuted ? 'i-ph-microphone-slash' : 'i-ph-microphone'" />
|
||||
<span class="i-ph-phone" />
|
||||
<span class="button-text">{{ $t('CONVERSATION.JOIN_CALL') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="isIncoming"
|
||||
class="control-button reject-call-button"
|
||||
@click="rejectCall"
|
||||
:title="$t('CONVERSATION.REJECT_CALL')"
|
||||
>
|
||||
<span class="i-ph-phone-x" />
|
||||
<span class="button-text">{{ $t('CONVERSATION.REJECT_CALL') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!isIncoming"
|
||||
class="control-button end-call-button"
|
||||
title="End Call"
|
||||
@click.prevent.stop="handleEndCallClick"
|
||||
@click="handleEndCallClick"
|
||||
:title="$t('CONVERSATION.END_CALL')"
|
||||
>
|
||||
<span class="i-ph-phone-x" />
|
||||
</button>
|
||||
|
||||
<div v-if="callSid === 'pending'" class="status-indicator">
|
||||
Connecting...
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="control-button settings-button"
|
||||
@click="toggleCallOptions"
|
||||
v-if="!isIncoming"
|
||||
class="control-button mute-button"
|
||||
:class="{ active: isMuted }"
|
||||
@click="toggleMute"
|
||||
:title="isMuted ? 'Unmute' : 'Mute'"
|
||||
>
|
||||
<span class="i-ph-dots-three" />
|
||||
<span :class="isMuted ? 'i-ph-microphone-slash' : 'i-ph-microphone'" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showCallOptions" class="call-options">
|
||||
<button @click="toggleFullscreen">
|
||||
{{ isFullscreen ? 'Minimize Call' : 'Expand Call' }}
|
||||
</button>
|
||||
<!-- Add more call options as needed -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -400,122 +646,190 @@ export default {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background-color: #1f2937;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(31, 41, 55, 0.95); /* var(--b-700, #1f2937) with opacity */
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
padding: 16px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
width: 320px;
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid var(--b-600, #374151);
|
||||
|
||||
.call-info {
|
||||
&.is-minimized {
|
||||
min-width: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
&.is-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&.is-incoming {
|
||||
animation: pulse 1.5s infinite;
|
||||
border-color: var(--b-600, #374151);
|
||||
background-color: rgba(31, 41, 55, 0.95); /* Same background, no red */
|
||||
}
|
||||
}
|
||||
|
||||
.call-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.call-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--b-500, #4b5563);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.call-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.call-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--s-200, #9ca3af);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.call-duration {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--s-100, #f3f4f6);
|
||||
background-color: var(--b-600, #374151);
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.call-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
|
||||
.control-button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: var(--b-600, #374151);
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.inbox-name {
|
||||
font-weight: 500;
|
||||
&:hover {
|
||||
background: var(--b-500, #4b5563);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.call-duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.call-controls {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: 8px;
|
||||
&.active {
|
||||
background: var(--w-500, #2563eb);
|
||||
}
|
||||
|
||||
.control-button {
|
||||
&.end-call-button {
|
||||
background: var(--r-500, #dc2626);
|
||||
|
||||
&:hover {
|
||||
background: var(--r-600, #b91c1c);
|
||||
}
|
||||
}
|
||||
|
||||
&.accept-call-button,
|
||||
&.reject-call-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: #374151;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.accept-call-button {
|
||||
background: var(--g-500, #10b981);
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
|
||||
&:hover {
|
||||
background: #4b5563;
|
||||
background: var(--g-600, #059669);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&.end-call-button {
|
||||
background: #dc2626;
|
||||
|
||||
&:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: #2563eb;
|
||||
border-radius: 16px;
|
||||
padding: 0 12px;
|
||||
&.reject-call-button {
|
||||
background: var(--r-500, #dc2626);
|
||||
color: white;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.call-options {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 6px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #e5e7eb;
|
||||
background: var(--r-600, #b91c1c);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.button-text {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(220, 38, 38, 0);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -212,6 +212,33 @@ export class DashboardAudioNotificationHelper {
|
||||
showBadgeOnFavicon();
|
||||
this.playAudioEvery30Seconds();
|
||||
};
|
||||
|
||||
onIncomingCall = () => {
|
||||
// Always play audio alerts for incoming calls, regardless of other settings
|
||||
// This ensures users never miss a call notification
|
||||
|
||||
// Use a different tone for calls if available, otherwise use regular tone
|
||||
const originalTone = this.audioConfig.tone;
|
||||
try {
|
||||
// Temporarily set a call-specific tone if it exists
|
||||
this.audioConfig.tone = 'call-ring';
|
||||
this.intializeAudio();
|
||||
this.playAudioAlert();
|
||||
} catch (error) {
|
||||
console.error('Error playing call notification:', error);
|
||||
// Fallback to regular tone
|
||||
this.audioConfig.tone = originalTone;
|
||||
this.intializeAudio();
|
||||
this.playAudioAlert();
|
||||
} finally {
|
||||
// Restore original tone for messages
|
||||
this.audioConfig.tone = originalTone;
|
||||
this.intializeAudio();
|
||||
}
|
||||
|
||||
// Also show badge on favicon
|
||||
showBadgeOnFavicon();
|
||||
};
|
||||
}
|
||||
|
||||
export default new DashboardAudioNotificationHelper(GlobalStore);
|
||||
|
||||
@@ -30,6 +30,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
|
||||
// Call events
|
||||
'incoming_call': this.onIncomingCall,
|
||||
'call_status_changed': this.onCallStatusChanged
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,6 +195,38 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
};
|
||||
|
||||
onIncomingCall = data => {
|
||||
// Normalize snake_case to camelCase for consistency with frontend code
|
||||
const normalizedPayload = {
|
||||
callSid: data.call_sid,
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
inboxName: data.inbox_name,
|
||||
contactName: data.contact_name,
|
||||
contactId: data.contact_id,
|
||||
};
|
||||
|
||||
// Update store
|
||||
this.app.$store.dispatch('calls/setIncomingCall', normalizedPayload);
|
||||
|
||||
// Also update App.vue showCallWidget directly for immediate UI feedback
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
};
|
||||
|
||||
onCallStatusChanged = data => {
|
||||
// Normalize snake_case to camelCase for consistency with frontend code
|
||||
const normalizedPayload = {
|
||||
callSid: data.call_sid,
|
||||
status: data.status,
|
||||
conversationId: data.conversation_id,
|
||||
};
|
||||
|
||||
// Update store
|
||||
this.app.$store.dispatch('calls/setActiveCall', normalizedPayload);
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -236,6 +236,20 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"JOIN_CALL": "Join call",
|
||||
"REJECT_CALL": "Reject call",
|
||||
"END_CALL": "End call",
|
||||
"MINIMIZE_CALL": "Minimize call",
|
||||
"EXPAND_CALL": "Expand call",
|
||||
"CALL_STATUS": {
|
||||
"CONNECTING": "Connecting...",
|
||||
"RINGING": "Ringing...",
|
||||
"CONNECTED": "Connected",
|
||||
"ENDED": "Call ended",
|
||||
"FAILED": "Call failed",
|
||||
"REJECTED": "Call rejected"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
@@ -382,7 +396,6 @@
|
||||
"VOICE_CALL": "Call",
|
||||
"CALL_ERROR": "Failed to initiate call. Please try again.",
|
||||
"CALL_INITIATED": "Call initiated successfully.",
|
||||
"END_CALL": "End call",
|
||||
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback",
|
||||
"TRANSCRIPTION": "Call transcription",
|
||||
"COPILOT": {
|
||||
|
||||
@@ -1,30 +1,77 @@
|
||||
const state = {
|
||||
activeCall: null,
|
||||
incomingCall: null,
|
||||
};
|
||||
|
||||
const getters = {
|
||||
getActiveCall: $state => $state.activeCall,
|
||||
hasActiveCall: $state => !!$state.activeCall,
|
||||
getIncomingCall: $state => $state.incomingCall,
|
||||
hasIncomingCall: $state => !!$state.incomingCall,
|
||||
};
|
||||
|
||||
const actions = {
|
||||
setActiveCall({ commit }, callData) {
|
||||
console.log('Setting active call in store:', callData);
|
||||
if (!callData || !callData.callSid) {
|
||||
throw new Error('Invalid call data provided');
|
||||
}
|
||||
|
||||
commit('SET_ACTIVE_CALL', callData);
|
||||
|
||||
// If we're in a browser environment, try to set the app state
|
||||
// Update app state if in browser environment
|
||||
if (typeof window !== 'undefined' && window.app?.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
},
|
||||
|
||||
clearActiveCall({ commit }) {
|
||||
commit('CLEAR_ACTIVE_CALL');
|
||||
|
||||
// Update app state if in browser environment
|
||||
if (typeof window !== 'undefined' && window.app?.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
},
|
||||
|
||||
setIncomingCall({ commit, state }, callData) {
|
||||
if (!callData || !callData.callSid) {
|
||||
throw new Error('Invalid call data provided');
|
||||
}
|
||||
|
||||
// Don't set as incoming if call is already active
|
||||
if (state.activeCall?.callSid === callData.callSid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't set as incoming if call is already incoming
|
||||
if (state.incomingCall?.callSid === callData.callSid) {
|
||||
return;
|
||||
}
|
||||
|
||||
commit('SET_INCOMING_CALL', callData);
|
||||
|
||||
// Update app state if in browser environment
|
||||
if (typeof window !== 'undefined' && window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
},
|
||||
clearActiveCall({ commit }) {
|
||||
console.log('Clearing active call in store');
|
||||
commit('CLEAR_ACTIVE_CALL');
|
||||
|
||||
// If we're in a browser environment, try to clear the app state
|
||||
if (typeof window !== 'undefined' && window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
clearIncomingCall({ commit }) {
|
||||
commit('CLEAR_INCOMING_CALL');
|
||||
},
|
||||
|
||||
acceptIncomingCall({ commit, state }) {
|
||||
const incomingCall = state.incomingCall;
|
||||
if (!incomingCall) {
|
||||
throw new Error('No incoming call to accept');
|
||||
}
|
||||
|
||||
// Move incoming call to active call
|
||||
commit('SET_ACTIVE_CALL', {
|
||||
...incomingCall,
|
||||
isJoined: true,
|
||||
});
|
||||
commit('CLEAR_INCOMING_CALL');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,6 +82,12 @@ const mutations = {
|
||||
CLEAR_ACTIVE_CALL($state) {
|
||||
$state.activeCall = null;
|
||||
},
|
||||
SET_INCOMING_CALL($state, callData) {
|
||||
$state.incomingCall = callData;
|
||||
},
|
||||
CLEAR_INCOMING_CALL($state) {
|
||||
$state.incomingCall = null;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
Reference in New Issue
Block a user