1<template>
2 <div>
3 <div
4 v-show="showSmallCallPopup"
5 class="ms-2 flex cursor-pointer select-none items-center justify-between gap-1 rounded-full bg-surface-gray-10 px-2 py-1 mt-1 text-base !text-ink-gray-2"
6 @click="toggleCallPopup"
7 >
8 <div
9 class="flex justify-center items-center size-5 rounded-full bg-surface-gray-9 shrink-0 me-1"
10 >
11 <Avatar
12 v-if="contact?.image"
13 :image="contact.image"
14 :label="contact.full_name"
15 class="!size-5"
16 />
17 <AvatarIcon v-else class="size-3" />
18 </div>
19 <span>{{
20 contact?.full_name ?? (contact?.mobile_no || contact?.phone)
21 }}</span>
22 <span>·</span>
23 <div v-if="callStatus == 'In progress'">
24 {{ counterUp?.updatedTime }}
25 </div>
26 <div
27 v-else-if="callStatus == 'Call ended' || callStatus == 'No answer'"
28 class="blink"
29 :class="{
30 'text-ink-red-6':
31 callStatus == 'Call ended' || callStatus == 'No answer',
32 }"
33 >
34 <span>{{ callStatus }}</span>
35 <span v-if="callStatus == 'Call ended'">
36 <span> · </span>
37 <span>{{ callDuration }}</span>
38 </span>
39 </div>
40 <div v-else>{{ callStatus }}</div>
41 </div>
42 <div v-show="showCallPopup" v-bind="$attrs">
43 <div
44 ref="callPopupHeader"
45 class="fixed z-20 flex w-60 cursor-move select-none flex-col rounded-6 bg-surface-gray-10 p-2 !text-ink-gray-2 shadow-2xl"
46 :style="style"
47 >
48 <div class="flex flex-row-reverse items-center">
49 <Button
50 @click="closeCallPopup"
51 class="bg-surface-gray-10 text-ink-base hover:bg-surface-gray-9 shrink-0"
52 icon="lucide-x"
53 size="md"
54 />
55 <Button
56 @click="toggleCallPopup"
57 variant="ghost"
58 class="bg-surface-gray-10 text-ink-base hover:bg-surface-gray-9 shrink-0"
59 >
60 <MinimizeIcon class="size-4 cursor-pointer" />
61 </Button>
62 </div>
63 <div class="flex flex-col items-center justify-center gap-3">
64 <Avatar
65 :image="contact.image"
66 :label="contact.full_name"
67 class="relative flex !h-24 !w-24 items-center justify-center [&>div]:text-[30px]"
68 :class="callStatus == 'In progress' ? '' : 'pulse'"
69 />
70 <div class="flex flex-col items-center justify-center gap-1">
71 <div class="text-xl-medium">
72 {{ contact?.full_name ?? "Unknown" }}
73 </div>
74 <div class="text-sm text-ink-gray-5">
75 {{ contact?.mobile_no || contact?.phone }}
76 </div>
77 </div>
78 <CountUpTimer ref="counterUp">
79 <div class="my-1 text-base">
80 {{ counterUp?.updatedTime }}
81 </div>
82 </CountUpTimer>
83 <div class="my-1 text-base">
84 {{ callStatus }}
85 </div>
86 </div>
87 </div>
88 </div>
89 </div>
90</template>
91<script setup lang="ts">
92import { globalStore } from "@/stores/globalStore";
93import { useTelephonyStore } from "@/stores/telephony";
94import { useDraggable, useWindowSize } from "@vueuse/core";
95import { Avatar, Button, call, toast } from "frappe-ui";
96import { inject, onBeforeUnmount, ref, watch } from "vue";
97import CountUpTimer from "./CountUpTimer.vue";
98import AvatarIcon from "./Icons/AvatarIcon.vue";
99import MinimizeIcon from "./Icons/MinimizeIcon.vue";
100
101const telephonyStore = useTelephonyStore();
102
103const onCallStarted = inject<(() => void) | undefined>(
104 "onCallStarted",
105 undefined
106);
107const onCallEnded = inject<(() => void) | undefined>("onCallEnded", undefined);
108const onCallFailed = inject<(() => void) | undefined>(
109 "onCallFailed",
110 undefined
111);
112
113const callPopupHeader = ref(null);
114const showCallPopup = ref(false);
115let showSmallCallPopup = ref(false);
116const { $socket } = globalStore();
117function toggleCallPopup() {
118 showCallPopup.value = !showCallPopup.value;
119 if (showSmallCallPopup.value == undefined) {
120 showSmallCallPopup.value = !showSmallCallPopup.value;
121 } else {
122 showSmallCallPopup.value = !showSmallCallPopup.value;
123 }
124}
125
126const { width, height } = useWindowSize();
127
128let { style, x, y } = useDraggable(callPopupHeader, {
129 initialValue: { x: width.value - 280, y: height.value - 310 },
130 preventDefault: true,
131});
132
133watch([width, height], () => {
134 x.value = Math.max(0, width.value - 280);
135 y.value = Math.max(0, height.value - 310);
136});
137
138const callStatus = ref("");
139const phoneNumber = ref("");
140const callData = ref(null);
141const counterUp = ref(null);
142
143const contact = ref({
144 full_name: "",
145 image: "",
146 mobile_no: "",
147 phone: "",
148});
149
150watch(phoneNumber, (value) => {
151 if (!value) return;
152 call("telephony.api.get_contact_by_phone_number", {
153 phone_number: phoneNumber.value,
154 }).then((data) => {
155 contact.value = data;
156 });
157});
158
159function makeOutgoingCall(number) {
160 phoneNumber.value = number;
161
162 call("telephony.exotel.handler.make_a_call", {
163 to_number: phoneNumber.value,
164 link_doctype: telephonyStore.linkDoc.doctype,
165 link_docname: telephonyStore.linkDoc.docname,
166 })
167 .then((callDetails) => {
168 callData.value = callDetails;
169 callStatus.value = "Calling...";
170 showCallPopup.value = true;
171 showSmallCallPopup.value = false;
172 onCallStarted && onCallStarted();
173 })
174 .catch((err) => {
175 const error = err?.messages?.[0] || "Something went wrong";
176 toast.error(error);
177 onCallFailed && onCallFailed();
178 });
179}
180
181let agentEmail = "";
182
183function setup(userEmail) {
184 agentEmail = userEmail;
185 $socket.on("exotel_call", onExotelCall);
186}
187
188function onExotelCall(data) {
189 callData.value = data;
190
191 callStatus.value = updateStatus(data);
192
193 if (!showCallPopup.value && !showSmallCallPopup.value) {
194 if (data.AgentEmail && data.AgentEmail == agentEmail) {
195 // Incoming call
196 phoneNumber.value = data.CallFrom || data.From;
197 showCallPopup.value = true;
198 } else {
199 // Outgoing call
200 phoneNumber.value = data.To;
201 }
202 }
203}
204
205onBeforeUnmount(() => {
206 $socket.off("exotel_call", onExotelCall);
207});
208
209function closeCallPopup() {
210 showCallPopup.value = false;
211 showSmallCallPopup.value = false;
212}
213
214const callDuration = ref("00:00");
215
216function updateStatus(data) {
217 // outgoing call
218 if (
219 data.EventType == "answered" &&
220 data.Direction == "outbound-api" &&
221 data.Status == "in-progress" &&
222 data["Legs[0][Status]"] == "in-progress" &&
223 data["Legs[1][Status]"] == ""
224 ) {
225 return "Ringing...";
226 } else if (
227 data.EventType == "answered" &&
228 data.Direction == "outbound-api" &&
229 data.Status == "in-progress" &&
230 data["Legs[1][Status]"] == "in-progress"
231 ) {
232 counterUp.value.start();
233 onCallStarted && onCallStarted();
234 return "In progress";
235 } else if (
236 data.EventType == "terminal" &&
237 data.Direction == "outbound-api" &&
238 (data.Status == "no-answer" || data.Status == "busy") &&
239 (data["Legs[1][Status]"] == "no-answer" ||
240 data["Legs[0][Status]"] == "no-answer" ||
241 data["Legs[1][Status]"] == "busy" ||
242 data["Legs[0][Status]"] == "busy")
243 ) {
244 counterUp.value.stop();
245 onCallFailed && onCallFailed();
246 return "No answer";
247 } else if (
248 data.EventType == "terminal" &&
249 data.Direction == "outbound-api" &&
250 data.Status == "completed"
251 ) {
252 counterUp.value.stop();
253 callDuration.value = counterUp.value.getTime(
254 parseInt(data["Legs[0][OnCallDuration]"]) ||
255 parseInt(data.DialCallDuration)
256 );
257 closeCallPopup();
258 onCallEnded && onCallEnded();
259 return "Call ended";
260 }
261
262 // incoming call
263 if (
264 data.EventType == "Dial" &&
265 data.Direction == "incoming" &&
266 data.Status == "busy"
267 ) {
268 phoneNumber.value = data.From || data.CallFrom;
269 counterUp.value.start();
270 return "Incoming call";
271 } else if (
272 data.Direction == "incoming" &&
273 data.CallType == "incomplete" &&
274 data.DialCallStatus == "no-answer"
275 ) {
276 onCallFailed && onCallFailed();
277 counterUp.value.stop();
278 return "No answer";
279 } else if (
280 data.Direction == "incoming" &&
281 (data.CallType == "completed" || data.CallType == "client-hangup") &&
282 (data.DialCallStatus == "completed" || data.DialCallStatus == "canceled")
283 ) {
284 onCallEnded && onCallEnded();
285 callDuration.value = counterUp.value.getTime(
286 parseInt(data["Legs[0][OnCallDuration]"]) ||
287 parseInt(data.DialCallDuration)
288 );
289 closeCallPopup();
290 counterUp.value.stop();
291 return "Call ended";
292 }
293}
294
295defineExpose({ makeOutgoingCall, setup });
296</script>
297<style scoped>
298@keyframes blink {
299 0% {
300 opacity: 1;
301 }
302 50% {
303 opacity: 0;
304 }
305 100% {
306 opacity: 1;
307 }
308}
309
310.blink {
311 animation: blink 1s ease-in-out 6;
312}
313
314:deep(.ProseMirror) {
315 caret-color: var(--ink-base);
316}
317.pulse::before {
318 content: "";
319 position: absolute;
320 border: 1px solid green;
321 width: calc(100% + 20px);
322 height: calc(100% + 20px);
323 border-radius: 50%;
324 animation: pulse 1s linear infinite;
325}
326
327.pulse::after {
328 content: "";
329 position: absolute;
330 border: 1px solid green;
331 width: calc(100% + 20px);
332 height: calc(100% + 20px);
333 border-radius: 50%;
334 animation: pulse 1s linear infinite;
335 animation-delay: 0.3s;
336}
337
338@keyframes pulse {
339 0% {
340 transform: scale(0.5);
341 opacity: 0;
342 }
343
344 50% {
345 transform: scale(1);
346 opacity: 1;
347 }
348
349 100% {
350 transform: scale(1.3);
351 opacity: 0;
352 }
353}
354</style>
355