chore: floating call button

This commit is contained in:
Sojan
2025-04-28 01:08:03 -07:00
parent 8d660df4c4
commit a7ff808d01
28 changed files with 2424 additions and 155 deletions
+101
View File
@@ -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>
+58 -1
View File
@@ -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>
@@ -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"
+16 -5
View File
@@ -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 => {
+2 -2
View File
@@ -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>
+2
View File
@@ -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,
};