Merge branch 'develop' into feat/captain-credit-usage

This commit is contained in:
Shivam Mishra
2026-07-22 10:56:33 +05:30
committed by GitHub
28 changed files with 439 additions and 216 deletions
@@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -83,39 +82,18 @@ const navigateToConversation = conversationId => {
const whatsappCallSession = useWhatsappCallSession();
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
// across all inboxes would be treated as having no conversation.
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId, {
inboxId,
});
const conversations = data?.payload || [];
const match = [...conversations].sort(
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
)[0];
return match?.id || null;
};
const startWhatsappCall = async (inboxId, conversationIdHint) => {
// WhatsApp /initiate is conversation-scoped, so we must hand it a
// conversation. Use the caller's hint when given (in-conversation flow);
// otherwise pick the most recent one in the inbox.
const conversationId =
conversationIdHint || (await findWhatsappConversationId(inboxId));
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
const response = await whatsappCallSession.initiateOutboundCall(
conversationIdHint
? { conversationId: conversationIdHint }
: { contactId: props.contactId, inboxId }
);
// The composable returns { status: 'locked' } when an init is already in
// flight or a call is already active; treat that as a soft no-op rather than
// claiming success.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
const conversationId = response?.conversation_id || conversationIdHint;
if (!response?.id) {
// Permission template path returns no call id. Mirror the header button and
// surface whether the request was just sent or is already pending instead of
@@ -234,6 +234,7 @@ onMounted(() => resetContacts());
ref="popoverRef"
:align="align"
:show-content-border="false"
:close-on-scroll="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
@@ -263,9 +263,9 @@ const handleCallBack = async () => {
if (!canCallBack.value || isInitiatingCall.value) return;
try {
if (isWhatsapp.value) {
const response = await whatsappCallSession.initiateOutboundCall(
conversationId.value
);
const response = await whatsappCallSession.initiateOutboundCall({
conversationId: conversationId.value,
});
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
@@ -1,7 +1,11 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
import {
useBreakpoints,
breakpointsTailwind,
useEventListener,
} from '@vueuse/core';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -16,6 +20,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
closeOnScroll: {
type: Boolean,
default: true,
},
showContentBorder: {
type: Boolean,
default: true,
@@ -41,8 +49,12 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
{ align: props.align }
);
const SCROLL_CLOSE_THRESHOLD = 24;
const triggerTopAtOpen = ref(0);
const show = async () => {
isActive.value = true;
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (!isMobile.value) {
await nextTick();
updatePosition();
@@ -56,6 +68,22 @@ const hide = () => {
emit('hide');
};
// The teleported popover tracks its trigger while ancestors scroll; allow
// small drift (trackpad inertia), but close once the trigger moves further.
useEventListener(
window,
'scroll',
event => {
if (!props.closeOnScroll || !showPopover.value) return;
if (popoverRef.value?.contains(event.target)) return;
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
hide();
}
},
{ capture: true, passive: true }
);
const toggle = async () => {
if (isActive.value) hide();
else await show();