feat(voice): Redesign call widget and in-thread call bubbles
Brings the floating call widget and in-thread voice-call bubbles in line with the new Figma design across Twilio and WhatsApp, light and dark. - New CallCard component for the floating widget with incoming / outgoing / ongoing states, rounded-square avatar, status badges, duration timer, and "Go to conversation thread" footer. - Floating widget header now shows the contact's city, country, and country flag (derived from country_code) when available, falling back to the channel icon + inbox name. - In-thread VoiceCall bubble redesigned: 44x44 tonal icon container, title + duration/subtext, audio player + transcript when a recording exists, and a "Call back" pill for missed inbound calls. - isOutbound on the call bubble now accepts both outgoing/outbound and incoming/inbound spellings and falls back to message orientation, so the label can no longer disagree with which side of the thread the bubble sits on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d765e7c106
commit
ddc863c6f7
@@ -3,9 +3,12 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
import { VOICE_CALL_STATUS, MESSAGE_TYPES } from '../constants';
|
||||
import { useCallActions } from 'dashboard/composables/useCallSession';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { formatDuration } from 'shared/helpers/timeHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -17,17 +20,10 @@ const LABEL_MAP = {
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call-bold',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -39,26 +35,35 @@ const {
|
||||
conversationId,
|
||||
currentUserId,
|
||||
inboxId,
|
||||
sender,
|
||||
messageType,
|
||||
} = useMessageContext();
|
||||
// Lightweight consumer — bubble doesn't own global listeners; the
|
||||
// FloatingCallWidget is the singleton root that drives the session.
|
||||
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
||||
useCallActions();
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
// Server-side call records use `outgoing`/`incoming`, while the Pinia store
|
||||
// and a few API hops normalise to `outbound`/`inbound`. Accept either so the
|
||||
// bubble label matches the message orientation no matter the source.
|
||||
const isOutbound = computed(() => {
|
||||
const dir = call.value?.direction;
|
||||
if (dir === 'outgoing' || dir === 'outbound') return true;
|
||||
if (dir === 'incoming' || dir === 'inbound') return false;
|
||||
// Fall back to the message orientation: agent-authored messages sit on the
|
||||
// right (outbound) and contact-authored ones on the left.
|
||||
return messageType.value === MESSAGE_TYPES.OUTGOING;
|
||||
});
|
||||
const isWhatsapp = computed(() => call.value?.provider === 'whatsapp');
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
|
||||
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
|
||||
// Twilio's call-status webhook flipped the call to in-progress before the
|
||||
// participant-join webhook claimed it).
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
@@ -79,13 +84,9 @@ const audioAttachment = computed(() =>
|
||||
(attachments?.value || []).find(a => a.fileType === 'audio')
|
||||
);
|
||||
|
||||
// Duration may arrive on call.duration_seconds (push_event_data) or
|
||||
// content_attributes.data.duration_seconds — and either may be camelCased
|
||||
// upstream, so check every variant.
|
||||
const durationSeconds = computed(() => {
|
||||
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
|
||||
if (fromCall != null) return fromCall;
|
||||
|
||||
const data = contentAttributes?.value?.data;
|
||||
return data?.durationSeconds || data?.duration_seconds;
|
||||
});
|
||||
@@ -117,11 +118,6 @@ const subtext = computed(() => {
|
||||
return formattedDuration.value;
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
// Outbound calls can flip to in-progress before the contact actually
|
||||
// picks up (e.g., Twilio marks in-progress when the agent joins the
|
||||
// conference); claiming "they answered" here would be misleading. The
|
||||
// 'Call in progress' label alone is accurate; the floating widget shows
|
||||
// the live duration.
|
||||
if (isOutbound.value) return null;
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
@@ -143,25 +139,33 @@ const subtext = computed(() => {
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
return isOutbound.value
|
||||
? 'i-ph-phone-outgoing-bold'
|
||||
: 'i-ph-phone-incoming-bold';
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
// Subtle icon container — matches the design's tonal swatch over the bubble bg.
|
||||
// Status drives the accent: teal for live, ruby for missed, neutral otherwise.
|
||||
const iconContainerClass = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (isMissedInbound.value) {
|
||||
return 'bg-n-alpha-2 text-n-ruby-9';
|
||||
}
|
||||
return 'bg-n-alpha-2 text-n-slate-12';
|
||||
});
|
||||
|
||||
const callSid = computed(() => call.value?.providerCallId);
|
||||
|
||||
// Show "Join call" when the call is still ringing, no agent has claimed it,
|
||||
// and the conversation is unassigned or assigned to the current user. Mirrors
|
||||
// the eligibility used by FloatingCallWidget so the bubble can act as a
|
||||
// recovery affordance after a refresh or missed widget.
|
||||
const canJoinCall = computed(() => {
|
||||
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
|
||||
if (isOutbound.value) return false;
|
||||
if (acceptedByAgentId.value) return false;
|
||||
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
|
||||
// Suppress the button once this call is the local active session — the
|
||||
// message status webhook may lag behind, so we can't rely on `status` alone
|
||||
// to hide it after a successful join from this client.
|
||||
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
|
||||
return false;
|
||||
const assignee = conversationAssignee.value;
|
||||
@@ -198,48 +202,102 @@ const handleJoinCall = async () => {
|
||||
callSid: callSid.value,
|
||||
});
|
||||
};
|
||||
|
||||
const canCallBack = computed(
|
||||
() => isMissedInbound.value && !!inboxId.value && !!conversationId.value
|
||||
);
|
||||
|
||||
const handleCallBack = async () => {
|
||||
if (!canCallBack.value) return;
|
||||
try {
|
||||
if (isWhatsapp.value) {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
conversationId.value
|
||||
);
|
||||
if (response?.status === 'locked') return;
|
||||
if (response?.call_id) {
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId: conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await store.dispatch('contacts/initiateCall', {
|
||||
contactId: sender.value?.id,
|
||||
inboxId: inboxId.value,
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-sm">
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<BaseBubble class="!p-3 !max-w-md min-w-[240px]" hide-meta>
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<!-- Header row: icon + title + duration/subtext -->
|
||||
<div class="flex gap-2.5 items-start">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
:class="bgColor"
|
||||
class="flex justify-center items-center rounded-xl size-11 shrink-0"
|
||||
:class="iconContainerClass"
|
||||
>
|
||||
<Icon
|
||||
class="size-5"
|
||||
:icon="iconName"
|
||||
:class="{
|
||||
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
|
||||
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
|
||||
}"
|
||||
/>
|
||||
<Icon class="size-4" :icon="iconName" />
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
<div class="flex flex-col flex-1 min-w-0 self-center">
|
||||
<span
|
||||
class="font-display text-sm font-medium leading-tight truncate tracking-tight"
|
||||
>
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span v-if="subtext" class="text-xs text-n-slate-11">
|
||||
<span
|
||||
v-if="subtext"
|
||||
class="text-sm leading-tight truncate tracking-tight opacity-75"
|
||||
>
|
||||
{{ subtext }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recordingAttachment" class="px-3 pb-3 w-full">
|
||||
<AudioChip :attachment="recordingAttachment" class="text-n-slate-12" />
|
||||
</div>
|
||||
<!-- Audio player (when there's a recording) -->
|
||||
<AudioChip
|
||||
v-if="recordingAttachment"
|
||||
:attachment="recordingAttachment"
|
||||
show-transcribed-text
|
||||
/>
|
||||
|
||||
<!-- Call back button (missed inbound) -->
|
||||
<button
|
||||
v-if="canCallBack"
|
||||
type="button"
|
||||
class="flex justify-center items-center gap-2 px-3 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleCallBack"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
<span class="text-sm font-medium text-white tracking-tight">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.CALL_BACK') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Join call button (ringing inbound) -->
|
||||
<button
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
class="flex justify-center items-center gap-2 px-3 h-10 bg-n-teal-9 hover:bg-n-teal-10 disabled:opacity-50 rounded-full transition-colors"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
<span class="text-sm font-medium text-white tracking-tight">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CallCard from 'dashboard/components/widgets/call/CallCard.vue';
|
||||
|
||||
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
|
||||
|
||||
@@ -33,6 +33,12 @@ const isWhatsappActive = computed(
|
||||
() => activeCall.value?.provider === 'whatsapp'
|
||||
);
|
||||
|
||||
const mainCardState = computed(() => {
|
||||
if (hasActiveCall.value) return 'ongoing';
|
||||
const direction = incomingCalls.value[0]?.callDirection;
|
||||
return direction === 'outbound' ? 'outgoing' : 'incoming';
|
||||
});
|
||||
|
||||
const toggleMute = () => {
|
||||
isMuted.value = !isMuted.value;
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
@@ -42,6 +48,20 @@ watch(hasActiveCall, active => {
|
||||
if (!active) isMuted.value = false;
|
||||
});
|
||||
|
||||
// Convert ISO 3166-1 alpha-2 country code (e.g. "US") to its regional indicator
|
||||
// flag emoji. Returns empty string if the code is missing or malformed.
|
||||
const countryCodeToFlag = code => {
|
||||
if (!code || code.length !== 2) return '';
|
||||
const base = 0x1f1e6;
|
||||
const offset = 'A'.charCodeAt(0);
|
||||
return String.fromCodePoint(
|
||||
...code
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(c => base + (c.charCodeAt(0) - offset))
|
||||
);
|
||||
};
|
||||
|
||||
const getCallInfo = call => {
|
||||
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
||||
@@ -49,6 +69,16 @@ const getCallInfo = call => {
|
||||
// Inbound WhatsApp calls stash caller info on the call record (from the cable
|
||||
// payload) so the widget has something to show before the conversation lands.
|
||||
const caller = call?.caller;
|
||||
const additional = sender?.additional_attributes || {};
|
||||
const city = additional.city || '';
|
||||
const country = additional.country || '';
|
||||
const countryCode = additional.country_code || '';
|
||||
// Prefer the richest available location string ("City, Country"); fall back to
|
||||
// whichever single field is present; finally fall back to the inbox name so
|
||||
// there's always something to show.
|
||||
const locationParts = [city, country].filter(Boolean);
|
||||
const location =
|
||||
locationParts.join(', ') || inbox?.name || 'Customer support';
|
||||
return {
|
||||
conversation,
|
||||
inbox,
|
||||
@@ -60,6 +90,9 @@ const getCallInfo = call => {
|
||||
'Unknown caller',
|
||||
phoneNumber: sender?.phone_number || caller?.phone || '',
|
||||
inboxName: inbox?.name || 'Customer support',
|
||||
location,
|
||||
countryFlag: countryCodeToFlag(countryCode),
|
||||
hasLocation: locationParts.length > 0,
|
||||
avatar: sender?.avatar || sender?.thumbnail || caller?.avatar,
|
||||
};
|
||||
};
|
||||
@@ -164,184 +197,34 @@ onBeforeUnmount(stopRingtone);
|
||||
<template>
|
||||
<div
|
||||
v-if="incomingCalls.length || hasActiveCall"
|
||||
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-80"
|
||||
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-3 w-[400px]"
|
||||
>
|
||||
<!-- Incoming Calls (shown above active call) -->
|
||||
<div
|
||||
<CallCard
|
||||
v-for="call in hasActiveCall ? incomingCalls : []"
|
||||
:key="call.callSid"
|
||||
class="flex flex-col gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||
>
|
||||
<Avatar
|
||||
:src="getCallInfo(call).avatar"
|
||||
:name="getCallInfo(call).contactName"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{ getCallInfo(call).contactName }}
|
||||
</p>
|
||||
<p
|
||||
v-if="getCallInfo(call).phoneNumber"
|
||||
class="text-xs text-n-slate-11 truncate mb-0"
|
||||
>
|
||||
{{ getCallInfo(call).phoneNumber }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="call.conversationId"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-n-alpha-2 hover:bg-n-alpha-3 rounded-lg transition-colors"
|
||||
@click="goToConversation(call)"
|
||||
>
|
||||
<i class="text-base text-n-slate-11 i-ph-chat-circle-text-bold" />
|
||||
<span class="text-xs text-n-slate-12">
|
||||
{{ $t('CONVERSATION.VOICE_WIDGET.VIEW_CHAT_HISTORY') }}
|
||||
</span>
|
||||
<span class="ml-auto text-xs text-n-slate-11">
|
||||
#{{ call.conversationId }}
|
||||
</span>
|
||||
<i class="text-xs text-n-slate-11 i-ph-caret-right-bold" />
|
||||
</button>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<button
|
||||
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')"
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-slate-3 hover:bg-n-slate-4 rounded-full transition-colors"
|
||||
@click="dismissCall(call.callSid)"
|
||||
>
|
||||
<i class="text-lg text-n-slate-12 i-ph-phone-x-bold" />
|
||||
</button>
|
||||
<button
|
||||
class="flex justify-center items-center gap-2 px-4 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleJoinCall(call)"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
<span class="text-sm font-medium text-white">
|
||||
{{ $t('CONVERSATION.VOICE_WIDGET.JOIN_CALL') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
:call="call"
|
||||
state="incoming"
|
||||
:call-info="getCallInfo(call)"
|
||||
@accept="handleJoinCall(call)"
|
||||
@reject="dismissCall(call.callSid)"
|
||||
@go-to-conversation="goToConversation(call)"
|
||||
/>
|
||||
|
||||
<!-- Main Call Widget -->
|
||||
<div
|
||||
<CallCard
|
||||
v-if="hasActiveCall || incomingCalls.length"
|
||||
class="flex flex-col gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||
:class="{ 'animate-pulse': !hasActiveCall }"
|
||||
>
|
||||
<Avatar
|
||||
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
|
||||
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
|
||||
</p>
|
||||
<p
|
||||
v-if="getCallInfo(activeCall || incomingCalls[0]).phoneNumber"
|
||||
class="text-xs text-n-slate-11 truncate mb-0"
|
||||
>
|
||||
{{ getCallInfo(activeCall || incomingCalls[0]).phoneNumber }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="hasActiveCall"
|
||||
class="font-mono text-sm text-n-slate-12 shrink-0"
|
||||
>
|
||||
{{ formattedCallDuration }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-n-slate-11 shrink-0">
|
||||
{{
|
||||
incomingCalls[0]?.callDirection === 'outbound'
|
||||
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="(activeCall || incomingCalls[0])?.conversationId"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-n-alpha-2 hover:bg-n-alpha-3 rounded-lg transition-colors"
|
||||
@click="goToConversation(activeCall || incomingCalls[0])"
|
||||
>
|
||||
<i class="text-base text-n-slate-11 i-ph-chat-circle-text-bold" />
|
||||
<span class="text-xs text-n-slate-12">
|
||||
{{ $t('CONVERSATION.VOICE_WIDGET.VIEW_CHAT_HISTORY') }}
|
||||
</span>
|
||||
<span class="ml-auto text-xs text-n-slate-11">
|
||||
#{{ (activeCall || incomingCalls[0])?.conversationId }}
|
||||
</span>
|
||||
<i class="text-xs text-n-slate-11 i-ph-caret-right-bold" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="hasActiveCall && isWhatsappActive"
|
||||
v-tooltip.top="
|
||||
isMuted
|
||||
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
|
||||
"
|
||||
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
|
||||
:class="
|
||||
isMuted
|
||||
? 'bg-n-amber-9 hover:bg-n-amber-10'
|
||||
: 'bg-n-slate-3 hover:bg-n-slate-4'
|
||||
"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<i
|
||||
class="text-lg"
|
||||
:class="
|
||||
isMuted
|
||||
? 'text-white i-ph-microphone-slash-bold'
|
||||
: 'text-n-slate-12 i-ph-microphone-bold'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="
|
||||
!hasActiveCall && incomingCalls[0]?.callDirection !== 'outbound'
|
||||
"
|
||||
class="flex justify-center items-center gap-2 px-4 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleJoinCall(incomingCalls[0])"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
<span class="text-sm font-medium text-white">
|
||||
{{ $t('CONVERSATION.VOICE_WIDGET.JOIN_CALL') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex justify-center items-center gap-2 px-4 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
@click="
|
||||
hasActiveCall
|
||||
? handleEndCall()
|
||||
: rejectIncomingCall(incomingCalls[0]?.callSid)
|
||||
"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-x-bold" />
|
||||
<span class="text-sm font-medium text-white">
|
||||
{{ $t('CONVERSATION.VOICE_WIDGET.END_CALL') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
:call="activeCall || incomingCalls[0]"
|
||||
:state="mainCardState"
|
||||
:call-info="getCallInfo(activeCall || incomingCalls[0])"
|
||||
:duration="hasActiveCall ? formattedCallDuration : ''"
|
||||
:is-muted="isMuted"
|
||||
:show-mute="hasActiveCall && isWhatsappActive"
|
||||
@accept="handleJoinCall(incomingCalls[0])"
|
||||
@reject="rejectIncomingCall(incomingCalls[0]?.callSid)"
|
||||
@end="handleEndCall"
|
||||
@toggle-mute="toggleMute"
|
||||
@go-to-conversation="goToConversation(activeCall || incomingCalls[0])"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
call: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
callInfo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
// 'incoming' | 'outgoing' | 'ongoing'
|
||||
state: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
duration: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isMuted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showMute: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['accept', 'reject', 'end', 'toggleMute', 'goToConversation']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isOngoing = computed(() => props.state === 'ongoing');
|
||||
const isIncoming = computed(() => props.state === 'incoming');
|
||||
const isOutgoing = computed(() => props.state === 'outgoing');
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
if (isOngoing.value) return 'i-ph-phone-call-bold';
|
||||
if (isOutgoing.value) return 'i-ph-phone-outgoing-bold';
|
||||
return 'i-ph-phone-incoming-bold';
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (isOngoing.value) return t('CONVERSATION.VOICE_WIDGET.CALL_IN_PROGRESS');
|
||||
if (isOutgoing.value) return t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL');
|
||||
return t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL');
|
||||
});
|
||||
|
||||
const channelIcon = computed(() => {
|
||||
if (props.call?.provider === 'whatsapp') return 'i-ri-whatsapp-fill';
|
||||
return 'i-ph-phone-bold';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 pt-4 pb-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
|
||||
>
|
||||
<!-- Top section: status badge + location/inbox + duration -->
|
||||
<div class="flex flex-col gap-3 pb-2">
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<!-- Ongoing: status badge on left -->
|
||||
<div v-if="isOngoing" class="flex items-center gap-1.5 shrink-0">
|
||||
<i class="text-sm text-n-teal-9" :class="statusIcon" />
|
||||
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Caller location (city, country) or fallback to channel + inbox name -->
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span
|
||||
v-if="callInfo.hasLocation && callInfo.countryFlag"
|
||||
class="text-sm leading-none shrink-0"
|
||||
>
|
||||
{{ callInfo.countryFlag }}
|
||||
</span>
|
||||
<i
|
||||
v-else-if="!isOngoing"
|
||||
class="text-sm text-n-slate-10 shrink-0"
|
||||
:class="channelIcon"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
|
||||
>
|
||||
{{ callInfo.location }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Ongoing: duration on right -->
|
||||
<p
|
||||
v-if="isOngoing"
|
||||
class="font-display text-base font-medium text-n-slate-11 shrink-0 mb-0 tabular-nums tracking-tight"
|
||||
>
|
||||
{{ duration }}
|
||||
</p>
|
||||
<!-- Incoming/Outgoing: status badge on right -->
|
||||
<div v-else class="flex items-center gap-1.5 shrink-0">
|
||||
<i class="text-sm text-n-teal-9" :class="statusIcon" />
|
||||
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main row: avatar + name/phone + actions -->
|
||||
<div class="flex items-end gap-3 px-4">
|
||||
<div class="shrink-0">
|
||||
<Avatar
|
||||
:src="callInfo.avatar"
|
||||
:name="callInfo.contactName"
|
||||
:size="44"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="font-display text-sm font-medium text-n-slate-12 truncate mb-0.5 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.contactName }}
|
||||
</p>
|
||||
<p
|
||||
v-if="callInfo.phoneNumber"
|
||||
class="text-sm text-n-slate-11 truncate mb-0 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.phoneNumber }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Mute toggle (WhatsApp ongoing only) -->
|
||||
<button
|
||||
v-if="isOngoing && showMute"
|
||||
v-tooltip.top="
|
||||
isMuted
|
||||
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
|
||||
"
|
||||
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
|
||||
:class="
|
||||
isMuted
|
||||
? 'bg-n-amber-9 hover:bg-n-amber-10 text-white'
|
||||
: 'bg-n-teal-3 hover:bg-n-teal-4 text-n-teal-11'
|
||||
"
|
||||
@click="$emit('toggleMute')"
|
||||
>
|
||||
<i
|
||||
class="text-base"
|
||||
:class="
|
||||
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Accept call (incoming only) -->
|
||||
<button
|
||||
v-if="isIncoming"
|
||||
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.JOIN_CALL')"
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors shadow-sm"
|
||||
@click="$emit('accept')"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
|
||||
<!-- Reject / end call (all states) -->
|
||||
<button
|
||||
v-tooltip.top="
|
||||
isOngoing
|
||||
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
|
||||
"
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shadow-sm"
|
||||
@click="isOngoing ? $emit('end') : $emit('reject')"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-x-bold rotate-[134deg]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: go to conversation thread -->
|
||||
<button
|
||||
v-if="call?.conversationId"
|
||||
class="flex items-center justify-between gap-2 px-4 pt-3 border-t border-n-strong hover:bg-n-alpha-1 transition-colors group"
|
||||
@click="$emit('goToConversation')"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-n-slate-11 tracking-tight group-hover:text-n-slate-12"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
|
||||
>
|
||||
<i class="text-sm i-ph-chat-circle-text-bold" />
|
||||
<span class="text-sm tracking-tight tabular-nums">
|
||||
#{{ call.conversationId }}
|
||||
</span>
|
||||
<i class="text-xs i-ph-caret-right-bold" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -89,7 +89,8 @@
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call"
|
||||
"JOIN_CALL": "Join call",
|
||||
"CALL_BACK": "Call back"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
@@ -311,7 +312,8 @@
|
||||
"END_CALL": "End call",
|
||||
"MUTE": "Mute mic",
|
||||
"UNMUTE": "Unmute mic",
|
||||
"VIEW_CHAT_HISTORY": "View chat history"
|
||||
"VIEW_CHAT_HISTORY": "View chat history",
|
||||
"GO_TO_CONVERSATION": "Go to conversation thread"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
Reference in New Issue
Block a user