chore: new call button
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user