chore: floating call button
This commit is contained in:
@@ -6,6 +6,7 @@ import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import FloatingCallWidget from './components/widgets/FloatingCallWidget.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
@@ -14,6 +15,8 @@ import { setColorTheme } from './helper/themeHelper';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useFontSize } from 'dashboard/composables/useFontSize';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
@@ -25,6 +28,7 @@ export default {
|
||||
|
||||
components: {
|
||||
AddAccountModal,
|
||||
FloatingCallWidget,
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
@@ -51,6 +55,7 @@ export default {
|
||||
showAddAccountModal: false,
|
||||
latestChatwootVersion: null,
|
||||
reconnectService: null,
|
||||
showCallWidget: false, // Set to true for testing, false for production
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -60,6 +65,8 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
activeCall: 'calls/getActiveCall',
|
||||
hasActiveCall: 'calls/hasActiveCall',
|
||||
}),
|
||||
hasAccounts() {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
@@ -86,6 +93,13 @@ export default {
|
||||
},
|
||||
},
|
||||
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);
|
||||
@@ -106,6 +120,84 @@ export default {
|
||||
setLocale(locale) {
|
||||
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');
|
||||
},
|
||||
|
||||
// Public method that can be called from anywhere
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
},
|
||||
async initializeAccount() {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
@@ -153,6 +245,15 @@ export default {
|
||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||
<WootSnackbarBox />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<LoadingState v-else />
|
||||
</template>
|
||||
|
||||
@@ -16,6 +16,63 @@ class VoiceAPI extends ApiClient {
|
||||
`/api/v1/accounts/${accountId}/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')
|
||||
);
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
// Validate call SID format - Twilio call SID starts with 'CA' followed by alphanumeric characters
|
||||
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.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
|
||||
// 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`, {
|
||||
params: { call_sid: callSid },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new VoiceAPI();
|
||||
export default new VoiceAPI();
|
||||
|
||||
@@ -272,11 +272,11 @@ defineExpose({
|
||||
class="w-full"
|
||||
@input="
|
||||
isValidationField(item.key) &&
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
"
|
||||
@blur="
|
||||
isValidationField(item.key) &&
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+6
@@ -108,7 +108,13 @@ const onCardClick = e => {
|
||||
v-tooltip.left="inboxName"
|
||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
|
||||
>
|
||||
<!-- Special handling for voice channel -->
|
||||
<span
|
||||
v-if="inbox.channelType === 'Channel::Voice'"
|
||||
class="i-ph-phone-fill text-n-slate-11 size-3"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:icon="inboxIcon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-3"
|
||||
/>
|
||||
|
||||
@@ -60,38 +60,47 @@ const filteredAttrs = computed(() => {
|
||||
const computedVariant = computed(() => {
|
||||
if (props.variant) return props.variant;
|
||||
// The useAttrs method returns attributes values an empty string (not boolean value as in props).
|
||||
if (attrs.solid || attrs.solid === '') return 'solid';
|
||||
if (attrs.outline || attrs.outline === '') return 'outline';
|
||||
if (attrs.faded || attrs.faded === '') return 'faded';
|
||||
if (attrs.link || attrs.link === '') return 'link';
|
||||
if (attrs.ghost || attrs.ghost === '') return 'ghost';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.solid || attrObj.solid === '') return 'solid';
|
||||
if (attrObj.outline || attrObj.outline === '') return 'outline';
|
||||
if (attrObj.faded || attrObj.faded === '') return 'faded';
|
||||
if (attrObj.link || attrObj.link === '') return 'link';
|
||||
if (attrObj.ghost || attrObj.ghost === '') return 'ghost';
|
||||
return 'solid'; // Default variant
|
||||
});
|
||||
|
||||
const computedColor = computed(() => {
|
||||
if (props.color) return props.color;
|
||||
if (attrs.blue || attrs.blue === '') return 'blue';
|
||||
if (attrs.ruby || attrs.ruby === '') return 'ruby';
|
||||
if (attrs.amber || attrs.amber === '') return 'amber';
|
||||
if (attrs.slate || attrs.slate === '') return 'slate';
|
||||
if (attrs.teal || attrs.teal === '') return 'teal';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.blue || attrObj.blue === '') return 'blue';
|
||||
if (attrObj.ruby || attrObj.ruby === '') return 'ruby';
|
||||
if (attrObj.amber || attrObj.amber === '') return 'amber';
|
||||
if (attrObj.slate || attrObj.slate === '') return 'slate';
|
||||
if (attrObj.green || attrObj.green === '') return 'green';
|
||||
if (attrObj.teal || attrObj.teal === '') return 'teal';
|
||||
return 'blue'; // Default color
|
||||
});
|
||||
|
||||
const computedSize = computed(() => {
|
||||
if (props.size) return props.size;
|
||||
if (attrs.xs || attrs.xs === '') return 'xs';
|
||||
if (attrs.sm || attrs.sm === '') return 'sm';
|
||||
if (attrs.md || attrs.md === '') return 'md';
|
||||
if (attrs.lg || attrs.lg === '') return 'lg';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.xs || attrObj.xs === '') return 'xs';
|
||||
if (attrObj.sm || attrObj.sm === '') return 'sm';
|
||||
if (attrObj.md || attrObj.md === '') return 'md';
|
||||
if (attrObj.lg || attrObj.lg === '') return 'lg';
|
||||
return 'md';
|
||||
});
|
||||
|
||||
const computedJustify = computed(() => {
|
||||
if (props.justify) return props.justify;
|
||||
if (attrs.start || attrs.start === '') return 'start';
|
||||
if (attrs.center || attrs.center === '') return 'center';
|
||||
if (attrs.end || attrs.end === '') return 'end';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.start || attrObj.start === '') return 'start';
|
||||
if (attrObj.center || attrObj.center === '') return 'center';
|
||||
if (attrObj.end || attrObj.end === '') return 'end';
|
||||
|
||||
return 'center';
|
||||
});
|
||||
@@ -141,6 +150,17 @@ const STYLE_CONFIG = {
|
||||
ghost:
|
||||
'text-n-slate-12 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
green: {
|
||||
solid:
|
||||
'bg-green-600 text-white hover:enabled:bg-green-700 focus-visible:bg-green-700 outline-transparent',
|
||||
faded:
|
||||
'bg-green-600/10 text-green-700 hover:enabled:bg-green-600/20 focus-visible:bg-green-600/20 outline-transparent',
|
||||
outline:
|
||||
'text-green-700 hover:enabled:bg-green-600/10 focus-visible:bg-green-600/10 outline-green-600',
|
||||
ghost:
|
||||
'text-green-700 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-green-700 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
},
|
||||
teal: {
|
||||
solid:
|
||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||
|
||||
@@ -16,7 +16,9 @@ defineOptions({
|
||||
});
|
||||
|
||||
const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
// Safely access the URL, providing a fallback if not available
|
||||
const url = attachment?.dataUrl || attachment?.data_url || '';
|
||||
return timeStampAppendedURL(url);
|
||||
});
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
@@ -91,8 +93,17 @@ const changePlaybackSpeed = () => {
|
||||
};
|
||||
|
||||
const downloadAudio = async () => {
|
||||
const { fileType, dataUrl, extension } = attachment;
|
||||
downloadFile({ url: dataUrl, type: fileType, extension });
|
||||
// Get the URL with fallback options
|
||||
const url = attachment?.dataUrl || attachment?.data_url || '';
|
||||
if (!url) {
|
||||
console.error('No valid URL found for download');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType = attachment?.fileType || attachment?.file_type || 'file';
|
||||
const extension = attachment?.extension || 'mp3';
|
||||
|
||||
downloadFile({ url, type: fileType, extension });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.toggle-button {
|
||||
@apply bg-slate-200 dark:bg-slate-600;
|
||||
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
|
||||
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
|
||||
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
|
||||
--toggle-button-box-shadow:
|
||||
rgb(255, 255, 255) 0px 0px 0px 0px, rgba(59, 130, 246, 0.5) 0px 0px 0px 0px,
|
||||
rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
|
||||
border-radius: var(--border-radius-large);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
<script>
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
|
||||
export default {
|
||||
name: 'FloatingCallWidget',
|
||||
props: {
|
||||
callSid: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inboxName: {
|
||||
type: String,
|
||||
default: 'Primary',
|
||||
},
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['call-ended'],
|
||||
setup(props, { emit }) {
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = ref(null);
|
||||
const isCallActive = ref(!!props.callSid);
|
||||
const isMuted = ref(false);
|
||||
const showCallOptions = ref(false);
|
||||
const isFullscreen = ref(false);
|
||||
|
||||
// Define local fallback translations in case i18n fails
|
||||
const translations = {
|
||||
'CONVERSATION.END_CALL': 'End call',
|
||||
'CONVERSATION.CALL_ENDED': 'Call ended',
|
||||
'CONVERSATION.CALL_END_ERROR': 'Failed to end call',
|
||||
};
|
||||
|
||||
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')}`;
|
||||
});
|
||||
|
||||
const startDurationTimer = () => {
|
||||
console.log('Starting duration timer');
|
||||
if (durationTimer.value) clearInterval(durationTimer.value);
|
||||
|
||||
durationTimer.value = setInterval(() => {
|
||||
callDuration.value += 1;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopDurationTimer = () => {
|
||||
if (durationTimer.value) {
|
||||
clearInterval(durationTimer.value);
|
||||
durationTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 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();
|
||||
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
|
||||
if (savedConversationId && savedCallSid && savedCallSid !== 'pending') {
|
||||
// Check if it's a valid Twilio call SID (starts with CA or TJ)
|
||||
const isValidTwilioSid =
|
||||
savedCallSid.startsWith('CA') || savedCallSid.startsWith('TJ');
|
||||
|
||||
if (isValidTwilioSid) {
|
||||
console.log(
|
||||
'FloatingCallWidget: Making direct API call to end Twilio call with SID:',
|
||||
savedCallSid,
|
||||
'for conversation:',
|
||||
savedConversationId
|
||||
);
|
||||
|
||||
// Use the direct API call without using global method
|
||||
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||
.then(response => {
|
||||
console.log(
|
||||
'FloatingCallWidget: Call ended successfully via API:',
|
||||
response
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(
|
||||
'FloatingCallWidget: Error ending call via API:',
|
||||
error
|
||||
);
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
'FloatingCallWidget: Invalid Twilio call SID format:',
|
||||
savedCallSid
|
||||
);
|
||||
}
|
||||
} else if (savedCallSid === 'pending') {
|
||||
console.log(
|
||||
'FloatingCallWidget: Call was still in pending state, no API call needed'
|
||||
);
|
||||
} else if (!savedConversationId) {
|
||||
console.log(
|
||||
'FloatingCallWidget: No conversation ID available for ending call'
|
||||
);
|
||||
} else {
|
||||
console.log('FloatingCallWidget: Missing required data for API call');
|
||||
}
|
||||
|
||||
// 3. 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
|
||||
if (window.app) {
|
||||
console.log('Forcing app state update');
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// 5. Emit event
|
||||
emit('call-ended');
|
||||
|
||||
// 6. Update store - using store from setup scope
|
||||
store.dispatch('calls/clearActiveCall');
|
||||
|
||||
// 7. User feedback
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
};
|
||||
|
||||
// Original more careful implementation
|
||||
const endCall = async () => {
|
||||
console.log('Attempting to end call with SID:', props.callSid);
|
||||
|
||||
// First, always hide the UI for immediate feedback
|
||||
stopDurationTimer();
|
||||
isCallActive.value = false;
|
||||
|
||||
// Force update the app's state
|
||||
if (typeof window !== 'undefined' && window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// Emit the event to parent components
|
||||
emit('call-ended');
|
||||
|
||||
// Show success message to user
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
|
||||
// Now try the API call (after UI is updated)
|
||||
try {
|
||||
// Skip actual API call if it's a test or temp call SID
|
||||
if (
|
||||
props.callSid &&
|
||||
!props.callSid.startsWith('test-') &&
|
||||
!props.callSid.startsWith('temp-') &&
|
||||
!props.callSid.startsWith('debug-')
|
||||
) {
|
||||
console.log('Ending real call with SID:', props.callSid);
|
||||
await VoiceAPI.endCall(props.callSid);
|
||||
} else {
|
||||
console.log('Using fake/temp call SID, skipping API call');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in API call to end call:', error);
|
||||
// Don't show error to user since UI is already updated
|
||||
}
|
||||
|
||||
// Clear from store as last step
|
||||
const store = useStore();
|
||||
store.dispatch('calls/clearActiveCall');
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
// This would typically connect to Twilio's mute functionality
|
||||
// For now we'll just toggle the state
|
||||
isMuted.value = !isMuted.value;
|
||||
useAlert({
|
||||
message: isMuted.value ? 'Call muted' : 'Call unmuted',
|
||||
type: 'info',
|
||||
});
|
||||
|
||||
// In a real implementation, you'd call Twilio's API to mute the call
|
||||
// Example: window.twilioDevice.activeConnection().mute(isMuted.value);
|
||||
};
|
||||
|
||||
const toggleCallOptions = () => {
|
||||
showCallOptions.value = !showCallOptions.value;
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
isFullscreen.value = !isFullscreen.value;
|
||||
// Would typically adjust UI accordingly
|
||||
};
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
// Save the call data before UI updates
|
||||
const savedCallSid = props.callSid;
|
||||
const savedConversationId = props.conversationId;
|
||||
|
||||
// Always update UI immediately for better user experience
|
||||
stopDurationTimer();
|
||||
isCallActive.value = false;
|
||||
|
||||
// Update app state
|
||||
if (window.app) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// Update store
|
||||
store.dispatch('calls/clearActiveCall');
|
||||
|
||||
// Emit event
|
||||
emit('call-ended');
|
||||
|
||||
// Make API call if we have a valid conversation ID and a real call SID (not pending)
|
||||
if (savedConversationId && savedCallSid && savedCallSid !== 'pending') {
|
||||
// Check if it's a valid Twilio call SID (starts with CA or TJ)
|
||||
const isValidTwilioSid =
|
||||
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',
|
||||
});
|
||||
});
|
||||
} 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' });
|
||||
}
|
||||
};
|
||||
|
||||
// Safe translation helper with fallback
|
||||
const safeTranslate = key => {
|
||||
try {
|
||||
return t(key);
|
||||
} catch (error) {
|
||||
return translations[key] || key;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
console.log('FloatingCallWidget mounted with callSid:', props.callSid);
|
||||
// Always start the timer, regardless of callSid
|
||||
startDurationTimer();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopDurationTimer();
|
||||
});
|
||||
|
||||
// Watch for call SID changes
|
||||
watch(
|
||||
() => props.callSid,
|
||||
newCallSid => {
|
||||
isCallActive.value = !!newCallSid;
|
||||
|
||||
if (newCallSid) {
|
||||
startDurationTimer();
|
||||
} else {
|
||||
stopDurationTimer();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
isCallActive,
|
||||
callDuration,
|
||||
formattedCallDuration,
|
||||
isMuted,
|
||||
showCallOptions,
|
||||
isFullscreen,
|
||||
endCall,
|
||||
forceEndCall,
|
||||
handleEndCallClick,
|
||||
toggleMute,
|
||||
toggleCallOptions,
|
||||
toggleFullscreen,
|
||||
safeTranslate,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="floating-call-widget">
|
||||
<div class="call-info">
|
||||
<span class="inbox-name">{{ inboxName }}</span>
|
||||
<span class="call-duration">{{ formattedCallDuration }}</span>
|
||||
</div>
|
||||
|
||||
<div class="call-controls">
|
||||
<button
|
||||
class="control-button mute-button"
|
||||
:class="{ active: isMuted }"
|
||||
:disabled="callSid === 'pending'"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<span :class="isMuted ? 'i-ph-microphone-slash' : 'i-ph-microphone'" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="control-button end-call-button"
|
||||
title="End Call"
|
||||
@click.prevent.stop="handleEndCallClick"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<span class="i-ph-dots-three" />
|
||||
</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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.floating-call-widget {
|
||||
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;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
color: white;
|
||||
|
||||
.call-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.inbox-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.call-duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.call-controls {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: 8px;
|
||||
|
||||
.control-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: #374151;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
|
||||
&:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&.end-call-button {
|
||||
background: #dc2626;
|
||||
|
||||
&:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -22,7 +22,13 @@ export default {
|
||||
<div
|
||||
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
|
||||
>
|
||||
<!-- Use i-ph- icons for phone specifically, and FluentIcon for others -->
|
||||
<span
|
||||
v-if="inbox.channel_type === 'Channel::Voice'"
|
||||
class="mr-0.5 rtl:ml-0.5 rtl:mr-0 i-ph-phone text-sm"
|
||||
></span>
|
||||
<fluent-icon
|
||||
v-else
|
||||
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
|
||||
:icon="computedInboxClass"
|
||||
size="12"
|
||||
|
||||
@@ -110,12 +110,23 @@ export const hasValidAvatarUrl = avatarUrl => {
|
||||
};
|
||||
|
||||
export const timeStampAppendedURL = dataUrl => {
|
||||
const url = new URL(dataUrl);
|
||||
if (!url.searchParams.has('t')) {
|
||||
url.searchParams.append('t', Date.now());
|
||||
}
|
||||
try {
|
||||
// Make sure the URL is valid before trying to construct it
|
||||
if (!dataUrl || typeof dataUrl !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
const url = new URL(dataUrl);
|
||||
if (!url.searchParams.has('t')) {
|
||||
url.searchParams.append('t', Date.now());
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
} catch (error) {
|
||||
// If URL construction fails, just return the original URL
|
||||
console.error('Invalid URL in timeStampAppendedURL:', error);
|
||||
return dataUrl || '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getHostNameFromURL = url => {
|
||||
|
||||
@@ -72,7 +72,7 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
|
||||
case INBOX_TYPES.TWILIO:
|
||||
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
|
||||
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
@@ -111,7 +111,7 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
return phoneNumber?.startsWith('whatsapp')
|
||||
? 'brand-whatsapp'
|
||||
: 'brand-sms';
|
||||
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
|
||||
@@ -382,6 +382,9 @@
|
||||
"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": {
|
||||
"TRY_THESE_PROMPTS": "Try these prompts"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<script>
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import actionCableService from 'dashboard/helper/actionCable';
|
||||
|
||||
export default {
|
||||
name: 'CallManager',
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
conversation: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['callEnded'],
|
||||
setup(props, { emit }) {
|
||||
const store = useStore();
|
||||
const { accountId } = useAccount();
|
||||
const callStatus = ref('');
|
||||
const callSid = ref('');
|
||||
const callDuration = ref(0);
|
||||
const recordingUrl = ref('');
|
||||
const transcription = ref('');
|
||||
const durationTimer = ref(null);
|
||||
const isCallActive = computed(
|
||||
() => callStatus.value && callStatus.value !== 'completed'
|
||||
);
|
||||
|
||||
const callStatusText = computed(() => {
|
||||
switch (callStatus.value) {
|
||||
case 'queued':
|
||||
return 'Call queued';
|
||||
case 'ringing':
|
||||
return 'Phone ringing...';
|
||||
case 'in-progress':
|
||||
return 'Call in progress';
|
||||
case 'completed':
|
||||
return 'Call completed';
|
||||
case 'failed':
|
||||
return 'Call failed';
|
||||
case 'busy':
|
||||
return 'Phone was busy';
|
||||
case 'no-answer':
|
||||
return 'No answer';
|
||||
default:
|
||||
return 'Call initiated';
|
||||
}
|
||||
});
|
||||
|
||||
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')}`;
|
||||
});
|
||||
|
||||
const startDurationTimer = () => {
|
||||
if (durationTimer.value) clearInterval(durationTimer.value);
|
||||
|
||||
durationTimer.value = setInterval(() => {
|
||||
callDuration.value += 1;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopDurationTimer = () => {
|
||||
if (durationTimer.value) {
|
||||
clearInterval(durationTimer.value);
|
||||
durationTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const updateCallStatus = status => {
|
||||
callStatus.value = status;
|
||||
|
||||
if (status === 'in-progress') {
|
||||
startDurationTimer();
|
||||
} else if (
|
||||
status === 'completed' ||
|
||||
status === 'failed' ||
|
||||
status === 'busy' ||
|
||||
status === 'no-answer'
|
||||
) {
|
||||
stopDurationTimer();
|
||||
emit('callEnded');
|
||||
}
|
||||
};
|
||||
|
||||
const endCall = async () => {
|
||||
if (!callSid.value) return;
|
||||
|
||||
try {
|
||||
await VoiceAPI.endCall(callSid.value, props.conversation.id);
|
||||
updateCallStatus('completed');
|
||||
useAlert('Call ended', 'success');
|
||||
} catch (error) {
|
||||
useAlert('Failed to end call. Please try again.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const setupCall = () => {
|
||||
// If there's an active conversation, check for call details
|
||||
if (props.conversation) {
|
||||
const messages = props.conversation.messages || [];
|
||||
|
||||
// Find the most recent call activity message
|
||||
const callMessage = messages.find(
|
||||
message =>
|
||||
message.message_type === 10 && // activity message
|
||||
message.additional_attributes?.call_sid
|
||||
);
|
||||
|
||||
if (callMessage) {
|
||||
const attrs = callMessage.additional_attributes;
|
||||
callSid.value = attrs.call_sid;
|
||||
updateCallStatus(attrs.status || 'initiated');
|
||||
|
||||
if (attrs.recording_url) {
|
||||
recordingUrl.value = attrs.recording_url;
|
||||
}
|
||||
|
||||
// Find transcription if available
|
||||
const transcriptionMessage = messages.find(
|
||||
message =>
|
||||
message.message_type === 0 && // incoming message
|
||||
message.additional_attributes?.is_transcription
|
||||
);
|
||||
|
||||
if (transcriptionMessage) {
|
||||
transcription.value = transcriptionMessage.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Setup WebSocket listener for call status updates
|
||||
const setupWebSocket = () => {
|
||||
if (!props.conversation) return;
|
||||
|
||||
try {
|
||||
// Set up ActionCable to listen for call status changes
|
||||
if (accountId.value && props.conversation?.inbox_id) {
|
||||
const roomName = `${accountId.value}_${props.conversation.inbox_id}`;
|
||||
console.log(
|
||||
`Setting up ActionCable listener for call status in room: ${roomName}`
|
||||
);
|
||||
|
||||
// Setup ActionCable connection and handler for call status events
|
||||
actionCableService.createConsumer();
|
||||
actionCableService.addRoom(roomName);
|
||||
|
||||
const handleCallStatusChanged = ({ event_name, data }) => {
|
||||
// Only handle call_status_changed events
|
||||
if (event_name !== 'call_status_changed') return;
|
||||
|
||||
console.log('Received call status change via ActionCable:', data);
|
||||
|
||||
// Only update if it's for our current call
|
||||
if (data.call_sid === callSid.value) {
|
||||
console.log(
|
||||
`Updating call status from ${callStatus.value} to ${data.status}`
|
||||
);
|
||||
updateCallStatus(data.status);
|
||||
|
||||
// If call is completed, refresh the conversation to get any recordings
|
||||
if (data.status === 'completed' || data.status === 'canceled') {
|
||||
// Notify parent component that call has ended
|
||||
emit('callEnded');
|
||||
|
||||
// Refresh the conversation to get updated messages with recordings
|
||||
if (props.conversation?.id) {
|
||||
store.dispatch('fetchConversation', {
|
||||
id: props.conversation.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Register for events
|
||||
actionCableService.onReceivedMessage = handleCallStatusChanged;
|
||||
}
|
||||
|
||||
// Also set up store watcher as backup method
|
||||
if (
|
||||
store.state.conversations &&
|
||||
store.state.conversations.conversations
|
||||
) {
|
||||
const unwatch = store.watch(
|
||||
state => {
|
||||
if (!props.conversation || !props.conversation.id) return null;
|
||||
const conversations = state.conversations?.conversations || {};
|
||||
const conv = conversations[props.conversation.id];
|
||||
return conv ? conv.messages : null;
|
||||
},
|
||||
messages => {
|
||||
if (!messages) return;
|
||||
|
||||
try {
|
||||
// Check for call status messages
|
||||
const callMessage = messages.find(
|
||||
message =>
|
||||
message.message_type === 10 && // activity message
|
||||
message.additional_attributes &&
|
||||
message.additional_attributes.call_sid === callSid.value
|
||||
);
|
||||
|
||||
if (callMessage?.additional_attributes) {
|
||||
if (callMessage.additional_attributes.call_status) {
|
||||
updateCallStatus(
|
||||
callMessage.additional_attributes.call_status
|
||||
);
|
||||
}
|
||||
|
||||
if (callMessage.additional_attributes.recording_url) {
|
||||
recordingUrl.value =
|
||||
callMessage.additional_attributes.recording_url;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for transcription messages
|
||||
const transcriptionMessage = messages.find(
|
||||
message =>
|
||||
message.message_type === 0 && // incoming message
|
||||
message.additional_attributes &&
|
||||
message.additional_attributes.is_transcription
|
||||
);
|
||||
|
||||
if (transcriptionMessage) {
|
||||
transcription.value = transcriptionMessage.content;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing message updates:', err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Clean up the watcher when component is unmounted
|
||||
onBeforeUnmount(() => {
|
||||
if (unwatch) {
|
||||
unwatch();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('Conversations store not found or initialized');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting up message watcher:', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Wrap in try/catch to prevent Vue errors if there's an issue
|
||||
try {
|
||||
if (props.conversation && props.conversation.id) {
|
||||
// Proceed with setup, using props.conversation.messages or default inside setupCall
|
||||
setupCall();
|
||||
setupWebSocket();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in CallManager mounted:', error);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopDurationTimer();
|
||||
|
||||
// Clean up the ActionCable connection
|
||||
if (accountId.value && props.conversation?.inbox_id) {
|
||||
try {
|
||||
const roomName = `${accountId.value}_${props.conversation.inbox_id}`;
|
||||
actionCableService.removeRoom(roomName);
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up ActionCable:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
callStatus,
|
||||
callStatusText,
|
||||
isCallActive,
|
||||
recordingUrl,
|
||||
transcription,
|
||||
callDuration,
|
||||
formattedCallDuration,
|
||||
endCall,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-show="isCallActive && callStatus"
|
||||
v-if="isCallActive && callStatus"
|
||||
class="relative p-4 mb-4 border border-solid rounded-md bg-n-slate-1 border-n-slate-4 flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-red-600 animate-pulse i-ph-phone-call text-xl" />
|
||||
<h3 class="mb-0 text-base font-medium">{{ callStatusText }}</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="callDuration" class="text-sm text-n-slate-9">
|
||||
{{ formattedCallDuration }}
|
||||
</div>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONVERSATION.END_CALL')"
|
||||
icon="i-ph-phone-x"
|
||||
sm
|
||||
ruby
|
||||
@click.stop.prevent="endCall"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="recordingUrl" class="w-full mt-2">
|
||||
<audio controls class="w-full h-10">
|
||||
<source :src="recordingUrl" type="audio/mpeg" />
|
||||
{{ $t('CONVERSATION.AUDIO_NOT_SUPPORTED') }}
|
||||
</audio>
|
||||
</div>
|
||||
<div
|
||||
v-if="transcription"
|
||||
class="mt-2 p-2 border border-solid rounded bg-n-slate-2 border-n-slate-5 text-sm"
|
||||
>
|
||||
<h4 class="mb-1 text-xs font-semibold text-n-slate-10">
|
||||
{{ $t('CONVERSATION.TRANSCRIPTION') }}
|
||||
</h4>
|
||||
<p class="m-0">{{ transcription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,6 +13,7 @@ import ComposeConversation from 'dashboard/components-next/NewConversation/Compo
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import CallManager from './CallManager.vue';
|
||||
|
||||
import {
|
||||
isAConversationRoute,
|
||||
@@ -30,6 +31,7 @@ export default {
|
||||
ComposeConversation,
|
||||
SocialIcons,
|
||||
ContactMergeModal,
|
||||
CallManager,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
@@ -55,6 +57,8 @@ export default {
|
||||
showMergeModal: false,
|
||||
showDeleteModal: false,
|
||||
isCallLoading: false,
|
||||
activeCallConversation: null,
|
||||
isHoveringCallButton: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -144,18 +148,255 @@ export default {
|
||||
},
|
||||
async initiateVoiceCall() {
|
||||
if (!this.contact || !this.contact.id) return;
|
||||
|
||||
|
||||
this.isCallLoading = true;
|
||||
try {
|
||||
const response = await VoiceAPI.initiateCall(this.contact.id);
|
||||
useAlert('Call initiated successfully', 'success');
|
||||
const conversation = response.data;
|
||||
|
||||
// First set local state for immediate UI update
|
||||
this.activeCallConversation = conversation;
|
||||
console.log('Call initiated, conversation data:', conversation);
|
||||
|
||||
// Always create a call SID even if it's not in the response
|
||||
let callSid = conversation?.call_sid;
|
||||
|
||||
// If not directly available, try to find it in messages
|
||||
if (!callSid) {
|
||||
const messages = conversation?.messages || [];
|
||||
const callMessage = messages.find(
|
||||
message =>
|
||||
message.message_type === 10 &&
|
||||
message.additional_attributes &&
|
||||
message.additional_attributes.call_sid
|
||||
);
|
||||
|
||||
callSid = callMessage?.additional_attributes?.call_sid;
|
||||
}
|
||||
|
||||
// If still not found, check conversation.additional_attributes
|
||||
if (!callSid && conversation?.additional_attributes) {
|
||||
callSid = conversation.additional_attributes.call_sid;
|
||||
}
|
||||
|
||||
// If we don't have a call SID, log the error but continue
|
||||
// This will allow the UI to show something while we wait for the real call SID
|
||||
if (!callSid) {
|
||||
console.log(
|
||||
'No call SID found in response, waiting for server to assign one'
|
||||
);
|
||||
|
||||
// We'll rely on WebSocket updates to get the real call SID when available
|
||||
// For now just set a placeholder for UI purposes
|
||||
callSid = 'pending';
|
||||
}
|
||||
|
||||
// Log for debugging
|
||||
console.log('Voice call response:', conversation);
|
||||
console.log('Using call SID:', callSid);
|
||||
|
||||
// Always set the global call state for the floating widget
|
||||
const inbox = conversation.inbox_id
|
||||
? this.$store.getters['inboxes/getInbox'](conversation.inbox_id)
|
||||
: null;
|
||||
|
||||
this.$store.dispatch('calls/setActiveCall', {
|
||||
callSid,
|
||||
inboxName: inbox?.name || 'Primary',
|
||||
conversationId: conversation.id,
|
||||
contactId: this.contact.id,
|
||||
});
|
||||
|
||||
// Set App's showCallWidget to true
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
|
||||
// After a brief delay, force update UI
|
||||
setTimeout(() => {
|
||||
this.$forceUpdate();
|
||||
}, 100);
|
||||
|
||||
useAlert('Voice call initiated successfully');
|
||||
} catch (error) {
|
||||
// Error handled with useAlert
|
||||
useAlert('Failed to initiate call. Please try again.', 'error');
|
||||
useAlert('Failed to initiate voice call');
|
||||
} finally {
|
||||
this.isCallLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleCallEnded() {
|
||||
this.activeCallConversation = null;
|
||||
// Clear global call state
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
},
|
||||
|
||||
// Simplified emergency end call function
|
||||
forceEndActiveCall() {
|
||||
console.log('FORCE END ACTIVE CALL triggered from ContactInfo');
|
||||
|
||||
// Important: Save a reference to the conversation before resetting it
|
||||
const savedConversation = this.activeCallConversation;
|
||||
|
||||
// 1. Immediately update local state for immediate UI feedback
|
||||
this.activeCallConversation = null;
|
||||
this.isHoveringCallButton = false;
|
||||
this.$forceUpdate();
|
||||
|
||||
// 2. Reset App global state
|
||||
if (window.app) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// 3. Reset store state
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
|
||||
// 4. Get the call SID from the saved conversation
|
||||
if (savedConversation) {
|
||||
// Try to find the call SID
|
||||
let callSid = null;
|
||||
|
||||
// Check all possible locations
|
||||
if (savedConversation.call_sid) {
|
||||
callSid = savedConversation.call_sid;
|
||||
} else if (savedConversation.additional_attributes?.call_sid) {
|
||||
callSid = savedConversation.additional_attributes.call_sid;
|
||||
} else if (
|
||||
savedConversation.messages &&
|
||||
savedConversation.messages.length > 0
|
||||
) {
|
||||
// Look in messages
|
||||
const callMessage = savedConversation.messages.find(
|
||||
message =>
|
||||
message.message_type === 10 &&
|
||||
message.additional_attributes?.call_sid
|
||||
);
|
||||
|
||||
if (callMessage) {
|
||||
callSid = callMessage.additional_attributes.call_sid;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('ContactInfo: Found call SID for API call:', callSid);
|
||||
|
||||
// 5. Make direct API call to end the call if we have a valid call SID
|
||||
if (callSid && callSid !== 'pending') {
|
||||
// Check if it's a valid Twilio call SID
|
||||
const isValidTwilioSid =
|
||||
callSid.startsWith('CA') || callSid.startsWith('TJ');
|
||||
|
||||
if (isValidTwilioSid) {
|
||||
console.log(
|
||||
'ContactInfo: Making direct API call to end call with SID:',
|
||||
callSid
|
||||
);
|
||||
|
||||
// Make API call with conversation ID
|
||||
VoiceAPI.endCall(callSid, savedConversation.id)
|
||||
.then(response => {
|
||||
console.log(
|
||||
'ContactInfo: Call ended successfully via API:',
|
||||
response
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('ContactInfo: Error ending call via API:', error);
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
'ContactInfo: Invalid Twilio call SID format:',
|
||||
callSid
|
||||
);
|
||||
}
|
||||
} else if (callSid === 'pending') {
|
||||
console.log(
|
||||
'ContactInfo: Call was still in pending state, no API call needed'
|
||||
);
|
||||
} else {
|
||||
console.log('ContactInfo: No call SID available for API call');
|
||||
}
|
||||
}
|
||||
|
||||
// 6. User feedback
|
||||
useAlert({ message: 'Call ended successfully', type: 'success' });
|
||||
},
|
||||
|
||||
// Original more careful implementation
|
||||
async endActiveCall() {
|
||||
console.log('End active call triggered from ContactInfo component');
|
||||
|
||||
// First, immediately update the UI for responsive feedback
|
||||
const savedActiveCall = this.activeCallConversation;
|
||||
this.activeCallConversation = null;
|
||||
this.$forceUpdate();
|
||||
|
||||
// Reset app-level state
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// Clear global state
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
|
||||
// Always give user success feedback
|
||||
useAlert({ message: 'Call ended successfully', type: 'success' });
|
||||
|
||||
// Then try the API call (after UI is updated)
|
||||
try {
|
||||
if (savedActiveCall) {
|
||||
// Try to find the call SID
|
||||
let callSid = null;
|
||||
|
||||
// Check all possible locations
|
||||
if (savedActiveCall.call_sid) {
|
||||
callSid = savedActiveCall.call_sid;
|
||||
} else if (savedActiveCall.additional_attributes?.call_sid) {
|
||||
callSid = savedActiveCall.additional_attributes.call_sid;
|
||||
} else {
|
||||
// Look in messages
|
||||
const messages = savedActiveCall.messages || [];
|
||||
const callMessage = messages.find(
|
||||
message =>
|
||||
message.message_type === 10 &&
|
||||
message.additional_attributes?.call_sid
|
||||
);
|
||||
|
||||
if (callMessage) {
|
||||
callSid = callMessage.additional_attributes.call_sid;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Found call SID for API call:', callSid);
|
||||
|
||||
// Make the API call if we have a valid call SID
|
||||
if (callSid && callSid !== 'pending') {
|
||||
// Check if it's a valid Twilio call SID
|
||||
const isValidTwilioSid =
|
||||
callSid.startsWith('CA') || callSid.startsWith('TJ');
|
||||
|
||||
if (isValidTwilioSid) {
|
||||
try {
|
||||
console.log('Making API call to end call with SID:', callSid);
|
||||
await VoiceAPI.endCall(callSid, savedActiveCall.id);
|
||||
console.log('API call to end call succeeded');
|
||||
} catch (apiError) {
|
||||
console.error('API call to end call failed:', apiError);
|
||||
// We've already updated UI, so don't show error to user
|
||||
}
|
||||
} else {
|
||||
console.log('Invalid Twilio call SID format:', callSid);
|
||||
}
|
||||
} else if (callSid === 'pending') {
|
||||
console.log('Call was still in pending state, no API call needed');
|
||||
} else {
|
||||
console.log('No call SID available for API call');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in endActiveCall:', error);
|
||||
}
|
||||
},
|
||||
async deleteContact({ id }) {
|
||||
try {
|
||||
await this.$store.dispatch('contacts/delete', id);
|
||||
@@ -189,6 +430,16 @@ export default {
|
||||
openMergeModal() {
|
||||
this.showMergeModal = true;
|
||||
},
|
||||
onCallButtonClick() {
|
||||
if (this.activeCallConversation) {
|
||||
useAlert('Call already ongoing', 'warning');
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
} else {
|
||||
this.initiateVoiceCall();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -196,6 +447,14 @@ export default {
|
||||
<template>
|
||||
<div class="relative items-center w-full p-4">
|
||||
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
|
||||
<!-- Call Manager Component - Shows only when a call is active -->
|
||||
<CallManager
|
||||
v-if="activeCallConversation && contact && contact.id"
|
||||
:contact="contact"
|
||||
:conversation="activeCallConversation"
|
||||
@call-ended="handleCallEnded"
|
||||
/>
|
||||
|
||||
<div class="flex flex-row justify-between">
|
||||
<Thumbnail
|
||||
v-if="showAvatar"
|
||||
@@ -296,13 +555,16 @@ export default {
|
||||
</ComposeConversation>
|
||||
<NextButton
|
||||
v-if="contact.phone_number"
|
||||
v-tooltip.top-end="'Call'"
|
||||
v-tooltip.top-end="
|
||||
activeCallConversation ? 'Call already ongoing' : 'Call'
|
||||
"
|
||||
icon="i-ph-phone"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
:is-loading="isCallLoading"
|
||||
@click.stop.prevent="initiateVoiceCall"
|
||||
:is-loading="!activeCallConversation && isCallLoading"
|
||||
:color="activeCallConversation ? 'teal' : undefined"
|
||||
@click.stop.prevent="onCallButtonClick"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
|
||||
|
||||
@@ -78,7 +78,7 @@ export default {
|
||||
icon="i-lucide-clipboard"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<slot v-if="buttonSlot" name="button"></slot>
|
||||
<slot v-if="buttonSlot" name="button" />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1,94 +1,3 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
:header-title="$t('INBOX_MGMT.ADD.VOICE.TITLE')"
|
||||
:header-content="$t('INBOX_MGMT.ADD.VOICE.DESC')"
|
||||
/>
|
||||
|
||||
<form class="flex flex-wrap flex-col gap-4 p-2" @submit.prevent="createChannel">
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.PROVIDER.LABEL') }}
|
||||
<select
|
||||
v-model="provider"
|
||||
class="p-2 bg-white border border-n-blue-100 rounded"
|
||||
@change="onProviderChange"
|
||||
>
|
||||
<option
|
||||
v-for="option in providerOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Twilio Provider Config -->
|
||||
<div v-if="provider === 'twilio'" class="flex-shrink-0 flex-grow-0">
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.phoneNumber.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL') }}
|
||||
<input
|
||||
v-model.trim="phoneNumber"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
|
||||
@blur="v$.phoneNumber.$touch"
|
||||
/>
|
||||
<span v-if="v$.phoneNumber.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.accountSid.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL') }}
|
||||
<input
|
||||
v-model.trim="accountSid"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
|
||||
@blur="v$.accountSid.$touch"
|
||||
/>
|
||||
<span v-if="v$.accountSid.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.authToken.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL') }}
|
||||
<input
|
||||
v-model.trim="authToken"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
|
||||
@blur="v$.authToken.$touch"
|
||||
/>
|
||||
<span v-if="v$.authToken.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add other provider configs here -->
|
||||
|
||||
<div class="mt-4">
|
||||
<NextButton
|
||||
:is-loading="uiFlags.isCreating"
|
||||
:is-disabled="v$.$invalid"
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
|
||||
type="submit"
|
||||
color="blue"
|
||||
@click="createChannel"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
@@ -163,10 +72,10 @@ export default {
|
||||
if (this.v$.$invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const providerConfig = this.getProviderConfig();
|
||||
|
||||
|
||||
const channel = await this.$store.dispatch(
|
||||
'inboxes/createVoiceChannel',
|
||||
{
|
||||
@@ -178,7 +87,7 @@ export default {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
@@ -187,9 +96,110 @@ export default {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error.response?.data?.message || this.$t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE'));
|
||||
useAlert(
|
||||
error.response?.data?.message ||
|
||||
this.$t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
:header-title="$t('INBOX_MGMT.ADD.VOICE.TITLE')"
|
||||
:header-content="$t('INBOX_MGMT.ADD.VOICE.DESC')"
|
||||
/>
|
||||
|
||||
<form
|
||||
class="flex flex-wrap flex-col gap-4 p-2"
|
||||
@submit.prevent="createChannel"
|
||||
>
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.PROVIDER.LABEL') }}
|
||||
<select
|
||||
v-model="provider"
|
||||
class="p-2 bg-white border border-n-blue-100 rounded"
|
||||
@change="onProviderChange"
|
||||
>
|
||||
<option
|
||||
v-for="option in providerOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Twilio Provider Config -->
|
||||
<div v-if="provider === 'twilio'" class="flex-shrink-0 flex-grow-0">
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.phoneNumber.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL') }}
|
||||
<input
|
||||
v-model.trim="phoneNumber"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
|
||||
@blur="v$.phoneNumber.$touch"
|
||||
/>
|
||||
<span v-if="v$.phoneNumber.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.accountSid.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL') }}
|
||||
<input
|
||||
v-model.trim="accountSid"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.accountSid.$touch"
|
||||
/>
|
||||
<span v-if="v$.accountSid.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.authToken.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL') }}
|
||||
<input
|
||||
v-model.trim="authToken"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.authToken.$touch"
|
||||
/>
|
||||
<span v-if="v$.authToken.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add other provider configs here -->
|
||||
|
||||
<div class="mt-4">
|
||||
<NextButton
|
||||
:is-loading="uiFlags.isCreating"
|
||||
:is-disabled="v$.$invalid"
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
|
||||
type="submit"
|
||||
color="blue"
|
||||
@click="createChannel"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,7 @@ import auditlogs from './modules/auditlogs';
|
||||
import auth from './modules/auth';
|
||||
import automations from './modules/automations';
|
||||
import bulkActions from './modules/bulkActions';
|
||||
import calls from './modules/calls';
|
||||
import campaigns from './modules/campaigns';
|
||||
import cannedResponse from './modules/cannedResponse';
|
||||
import categories from './modules/helpCenterCategories';
|
||||
@@ -64,6 +65,7 @@ export default createStore({
|
||||
auth,
|
||||
automations,
|
||||
bulkActions,
|
||||
calls,
|
||||
campaigns,
|
||||
cannedResponse,
|
||||
categories,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const state = {
|
||||
activeCall: null,
|
||||
};
|
||||
|
||||
const getters = {
|
||||
getActiveCall: $state => $state.activeCall,
|
||||
hasActiveCall: $state => !!$state.activeCall,
|
||||
};
|
||||
|
||||
const actions = {
|
||||
setActiveCall({ commit }, callData) {
|
||||
console.log('Setting active call in store:', callData);
|
||||
commit('SET_ACTIVE_CALL', callData);
|
||||
|
||||
// If we're in a browser environment, try to set the app state
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
SET_ACTIVE_CALL($state, callData) {
|
||||
$state.activeCall = callData;
|
||||
},
|
||||
CLEAR_ACTIVE_CALL($state) {
|
||||
$state.activeCall = null;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
Reference in New Issue
Block a user