chore: new call button

This commit is contained in:
Sojan
2025-05-11 00:35:32 -07:00
parent aa4ef28e0e
commit ccdbc2c7f9
7 changed files with 406 additions and 10 deletions
@@ -0,0 +1,27 @@
<template>
<div>
<Button
icon="i-ri-phone-fill"
color="slate"
size="sm"
:tooltip="$t('CALL_BUTTON.TOOLTIP')"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
@click="openCallModal"
/>
<div v-if="showCallModal" class="fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0">
<CallModal @close="showCallModal = false" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CallModal from './CallModal.vue';
const showCallModal = ref(false);
const openCallModal = () => {
showCallModal.value = true;
};
</script>
@@ -0,0 +1,306 @@
<template>
<div class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl">
<div class="px-4 py-3 flex items-center">
<h3 class="text-base font-medium">{{ $t('CALL_MODAL.START_CALL') }}</h3>
</div>
<!-- Inbox Selector (First) -->
<div class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible">
<label class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ $t('CALL_MODAL.VIA') }}
</label>
<div
v-if="selectedInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12 flex items-center gap-2">
<span class="i-ri-phone-fill text-n-slate-11"></span>
{{ selectedInbox.name }} - {{ selectedInbox.phoneNumber }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="flex-shrink-0"
@click="selectedInbox = null"
/>
</div>
<div
v-else
v-on-click-outside="() => showInboxDropdown = false"
class="relative flex items-center h-7"
>
<Button
:label="$t('CALL_MODAL.SELECT_INBOX')"
variant="link"
size="sm"
color="slate"
class="hover:!no-underline"
@click="showInboxDropdown = !showInboxDropdown"
/>
<DropdownMenu
v-if="voiceInboxesList.length > 0 && showInboxDropdown"
:menu-items="voiceInboxesList"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
@action="selectInbox($event)"
/>
</div>
</div>
<!-- Contact Selector -->
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isSearching"
:is-creating-contact="false"
:contact-id="null"
:contactable-inboxes-list="[]"
:show-inboxes-dropdown="false"
:has-errors="false"
@search-contacts="handleContactSearch"
@set-selected-contact="handleSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<!-- Action buttons -->
<div class="flex items-center justify-end w-full h-[3.25rem] gap-2 px-4 py-3">
<Button
:label="$t('CALL_MODAL.CANCEL')"
variant="faded"
color="slate"
size="sm"
class="!text-xs font-medium"
@click="$emit('close')"
/>
<Button
:label="$t('CALL_MODAL.CALL')"
icon="i-ri-phone-fill"
size="sm"
class="!text-xs font-medium"
:disabled="!selectedInbox || !selectedContact || isLoading"
:is-loading="isLoading"
@click="makeCall"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { debounce } from '@chatwoot/utils';
import { vOnClickOutside } from '@vueuse/components';
import ContactAPI from 'dashboard/api/contacts';
import VoiceAPI from 'dashboard/api/channels/voice';
import camelcaseKeys from 'camelcase-keys';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ContactSelector from 'dashboard/components-next/NewConversation/components/ContactSelector.vue';
import axios from 'axios';
const { t } = useI18n();
const store = useStore();
const emit = defineEmits(['close']);
const selectedContact = ref(null);
const selectedInbox = ref(null);
const showContactsDropdown = ref(false);
const showInboxDropdown = ref(false);
const contacts = ref([]);
const isSearching = ref(false);
const isLoading = ref(false);
const inboxes = useMapGetter('inboxes/getInboxes');
const voiceInboxesList = computed(() => {
return inboxes.value
.filter(inbox => inbox.channel_type === INBOX_TYPES.VOICE)
.map(inbox => ({
id: inbox.id,
title: `${inbox.name}`,
subtitle: inbox.phone_number,
label: `${inbox.name} - ${inbox.phone_number}`,
action: 'select-inbox',
value: inbox.id,
sourceId: inbox.id,
phoneNumber: inbox.phone_number,
name: inbox.name,
icon: 'i-ri-phone-fill',
}));
});
// Auto-select the first available voice inbox
watch(voiceInboxesList, (newList) => {
if (newList.length > 0 && !selectedInbox.value) {
selectedInbox.value = newList[0];
}
}, { immediate: true });
const selectInbox = item => {
const inbox = voiceInboxesList.value.find(i => i.value === item.value);
if (inbox) {
selectedInbox.value = inbox;
showInboxDropdown.value = false;
}
};
const handleSelectedContact = ({ value, action, ...rest }) => {
// If this is a direct call to a phone number
if (action === 'create' && value.match(/^\+?[0-9\s\-()]+$/)) {
selectedContact.value = {
id: 'direct-call',
name: t('CALL_MODAL.CALL_DIRECTLY'),
sourceId: 'direct-call',
phoneNumber: value,
action: 'contact',
};
} else {
// For existing contacts, make sure we're capturing their ID properly
console.log('Contact selected from dropdown:', { value, action, ...rest });
selectedContact.value = {
...rest,
sourceId: rest.id || rest.value || value // Make sure we have the ID in sourceId
};
}
showContactsDropdown.value = false;
};
const handleDropdownUpdate = (type, value) => {
showContactsDropdown.value = value;
};
const clearSelectedContact = () => {
selectedContact.value = null;
};
// This function gets called from the ContactSelector component
const handleContactSearch = value => {
showContactsDropdown.value = true;
// Pass all the needed keys for search when using the value sent directly
debouncedSearchContacts(value);
};
const debouncedSearchContacts = debounce(async query => {
if (!query || query.length < 2) {
contacts.value = [];
return;
}
isSearching.value = true;
try {
// Use the simple search endpoint since it's more reliable for this use case
const { data } = await ContactAPI.search(query);
console.log('Search response:', data); // Log the search response
// Ensure contacts.value is an array and convert to camelCase
const searchResults = data?.payload ? camelcaseKeys(data.payload, { deep: true }) : [];
// Filter to only include contacts with phone numbers
const contactsWithPhone = searchResults.filter(contact => contact.phoneNumber);
// Map the contacts to ensure they have sourceId set to ID for consistency
contacts.value = contactsWithPhone.map(contact => ({
...contact,
sourceId: contact.id, // Make sure sourceId is set
value: contact.id // Make sure value is set for TagInput
}));
// If it looks like a phone number, add option to call directly
if (query.match(/^\+?[0-9\s\-()]+$/) && !contacts.value.some(c => c.phoneNumber === query)) {
contacts.value.push({
id: 'direct-call',
name: t('CALL_MODAL.CALL_DIRECTLY'),
phoneNumber: query,
sourceId: 'direct-call',
value: 'direct-call'
});
}
console.log('Processed contacts for dropdown:', contacts.value);
} catch (error) {
console.error('Error searching contacts:', error);
contacts.value = []; // Ensure this is always an array
useAlert(t('CALL_MODAL.CONTACT_SEARCH_ERROR'));
} finally {
isSearching.value = false;
}
}, 300);
const makeCall = async () => {
if (!selectedInbox.value || !selectedContact.value) {
useAlert(t('CALL_MODAL.VALIDATION_ERROR'));
return;
}
isLoading.value = true;
try {
const isDirect = selectedContact.value.sourceId === 'direct-call';
const contactId = isDirect ? null : (selectedContact.value.sourceId || selectedContact.value.id);
if (contactId) {
console.log('Making call to contact ID:', contactId, 'with full contact:', selectedContact.value);
// Use VoiceAPI.initiateCall instead of direct axios call
await VoiceAPI.initiateCall(contactId);
} else {
// For direct phone number calls
const phoneNumber = selectedContact.value.phoneNumber;
if (!phoneNumber) {
throw new Error('Phone number is required for direct calls');
}
// First create a contact with this phone number
console.log('Creating new contact with phone number:', phoneNumber);
const contactPayload = {
phone_number: phoneNumber,
inbox_id: selectedInbox.value.sourceId,
name: `Phone: ${phoneNumber}`,
};
const contactResponse = await ContactAPI.create(contactPayload);
console.log('Created contact:', contactResponse.data);
// Then initiate call to the newly created contact
if (contactResponse.data && contactResponse.data.payload && contactResponse.data.payload.contact) {
const newContactId = contactResponse.data.payload.contact.id;
console.log('Using new contact ID:', newContactId);
await VoiceAPI.initiateCall(newContactId);
} else {
throw new Error('Failed to create contact for direct call');
}
}
useAlert(t('CALL_MODAL.SUCCESS_MESSAGE'));
emit('close');
} catch (error) {
console.error('Error making call:', error);
let errorMessage = t('CALL_MODAL.ERROR_MESSAGE');
// Simple error handling - just show server message if available
if (error.response && error.response.data && error.response.data.error) {
errorMessage = error.response.data.error;
}
useAlert(errorMessage);
} finally {
isLoading.value = false;
}
};
onMounted(() => {
// The first inbox will be selected automatically via the watch
// This ensures it works even if voiceInboxesList is populated after mounting
});
</script>
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -16,6 +17,7 @@ import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import CallButton from 'dashboard/components-next/CallModal/CallButton.vue';
const emit = defineEmits([
'closeKeyShortcutModal',
@@ -63,6 +65,11 @@ const conversationCustomViews = useMapGetter(
'customViews/getConversationCustomViews'
);
// Check if there are any voice inboxes
const hasVoiceInbox = computed(() =>
inboxes.value.some(inbox => inbox.channel_type === INBOX_TYPES.VOICE)
);
onMounted(() => {
store.dispatch('labels/get');
store.dispatch('inboxes/get');
@@ -510,6 +517,7 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<CallButton v-if="hasVoiceInbox" class="flex-shrink-0" />
<ComposeConversation align-position="right">
<template #trigger="{ toggle }">
<Button
@@ -540,4 +548,4 @@ const menuItems = computed(() => {
/>
</section>
</aside>
</template>
</template>
@@ -0,0 +1,20 @@
{
"CALL_MODAL": {
"START_CALL": "Start a call",
"MAKE_CALL_FROM": "Make the call from",
"TO": "To",
"VIA": "Via",
"SELECT_INBOX": "Select an inbox",
"ENTER_NUMBER_OR_NAME": "Enter a name or phone number...",
"CALL": "Call",
"CANCEL": "Cancel",
"CALL_DIRECTLY": "Call this number directly",
"SUCCESS_MESSAGE": "Call initiated successfully",
"ERROR_MESSAGE": "Failed to initiate call. Please try again.",
"CONTACT_SEARCH_ERROR": "Error searching contacts. Please try again.",
"VALIDATION_ERROR": "Please select both an inbox and a contact or phone number."
},
"CALL_BUTTON": {
"TOOLTIP": "Make a call"
}
}
@@ -5,6 +5,7 @@ import attributesMgmt from './attributesMgmt.json';
import auditLogs from './auditLogs.json';
import automation from './automation.json';
import bulkActions from './bulkActions.json';
import callModal from './callModal.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import chatlist from './chatlist.json';
@@ -44,6 +45,7 @@ export default {
...auditLogs,
...automation,
...bulkActions,
...callModal,
...campaign,
...cannedMgmt,
...chatlist,
@@ -74,4 +76,4 @@ export default {
...sla,
...teamsSettings,
...whatsappTemplates,
};
};
@@ -2,6 +2,7 @@ import * as types from '../mutation-types';
import ContactAPI from '../../api/contacts';
import ConversationApi from '../../api/conversations';
import camelcaseKeys from 'camelcase-keys';
import axios from 'axios';
export const createMessagePayload = (payload, message) => {
const { content, cc_emails, bcc_emails } = message;
@@ -82,12 +83,13 @@ export const getters = {
};
export const actions = {
create: async ({ commit }, { params, isFromWhatsApp }) => {
create: async ({ commit }, { params, isFromWhatsApp, isVoiceCall }) => {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isCreating: true,
});
const { contactId, files } = params;
try {
// Create the basic payload
const payload = setNewConversationPayload({
isFromWhatsApp,
params,
@@ -95,11 +97,35 @@ export const actions = {
files,
});
const { data } = await ConversationApi.create(payload);
commit(types.default.ADD_CONTACT_CONVERSATION, {
id: contactId,
data,
});
// If this is a voice call, adjust the endpoint to trigger voice
let data;
if (isVoiceCall) {
const accountId = window.store.getters['accounts/getCurrentAccountId'];
if (contactId) {
// Use the regular contacts call endpoint for existing contacts
const response = await axios.post(`/api/v1/accounts/${accountId}/contacts/${contactId}/call`);
data = response.data;
} else {
// For direct phone calls without a contact, use a special endpoint
// Add phoneNumber to the payload for voice call
payload.phone_number = params.phoneNumber || '';
const response = await axios.post(`/api/v1/accounts/${accountId}/conversations/trigger_voice`, payload);
data = response.data;
}
} else {
// Regular conversation creation
const response = await ConversationApi.create(payload);
data = response.data;
}
if (contactId) {
commit(types.default.ADD_CONTACT_CONVERSATION, {
id: contactId,
data,
});
}
return data;
} catch (error) {