281 lines
10 KiB
Vue
281 lines
10 KiB
Vue
<template>
|
|
<Dialog v-model:open="softphone.isOpen.value">
|
|
<DialogContent class="sm:max-w-[500px] max-h-[80vh] overflow-hidden flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>Softphone</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div class="flex-1 overflow-y-auto space-y-4">
|
|
<!-- Connection Status -->
|
|
<div class="flex items-center justify-between p-3 rounded-lg border" :class="{
|
|
'bg-green-50 border-green-200': softphone.isConnected.value,
|
|
'bg-red-50 border-red-200': !softphone.isConnected.value
|
|
}">
|
|
<span class="text-sm font-medium">
|
|
{{ softphone.isConnected.value ? 'Connected' : 'Disconnected' }}
|
|
</span>
|
|
<div class="h-2 w-2 rounded-full" :class="{
|
|
'bg-green-500': softphone.isConnected.value,
|
|
'bg-red-500': !softphone.isConnected.value
|
|
}"></div>
|
|
</div>
|
|
|
|
<!-- Incoming Call -->
|
|
<div v-if="softphone.incomingCall.value" class="p-4 rounded-lg border border-blue-200 bg-blue-50 animate-pulse">
|
|
<div class="text-center space-y-4">
|
|
<div>
|
|
<p class="text-sm text-gray-600">Incoming call from</p>
|
|
<p class="text-2xl font-bold">{{ formatPhoneNumber(softphone.incomingCall.value.fromNumber) }}</p>
|
|
</div>
|
|
<div class="flex gap-2 justify-center">
|
|
<Button @click="handleAccept" class="bg-green-500 hover:bg-green-600">
|
|
<PhoneIcon class="w-4 h-4 mr-2" />
|
|
Accept
|
|
</Button>
|
|
<Button @click="handleReject" variant="destructive">
|
|
<PhoneOffIcon class="w-4 h-4 mr-2" />
|
|
Reject
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Active Call -->
|
|
<div v-if="softphone.currentCall.value" class="space-y-4">
|
|
<div class="p-4 rounded-lg border bg-gray-50">
|
|
<div class="text-center space-y-2">
|
|
<p class="text-sm text-gray-600">
|
|
{{ softphone.currentCall.value.direction === 'outbound' ? 'Calling' : 'Connected with' }}
|
|
</p>
|
|
<p class="text-2xl font-bold">
|
|
{{ formatPhoneNumber(
|
|
softphone.currentCall.value.direction === 'outbound'
|
|
? softphone.currentCall.value.toNumber
|
|
: softphone.currentCall.value.fromNumber
|
|
) }}
|
|
</p>
|
|
<p class="text-sm text-gray-500 capitalize">{{ softphone.callStatus.value }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Call Controls -->
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<Button variant="outline" size="sm" @click="toggleMute">
|
|
<MicIcon v-if="!isMuted" class="w-4 h-4" />
|
|
<MicOffIcon v-else class="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="outline" size="sm" @click="showDialpad = !showDialpad">
|
|
<Hash class="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="destructive" size="sm" @click="handleEndCall">
|
|
<PhoneOffIcon class="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Dialpad -->
|
|
<div v-if="showDialpad" class="grid grid-cols-3 gap-2">
|
|
<Button
|
|
v-for="digit in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']"
|
|
:key="digit"
|
|
variant="outline"
|
|
size="sm"
|
|
@click="handleDtmf(digit)"
|
|
class="h-12 text-lg font-semibold"
|
|
>
|
|
{{ digit }}
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- AI Transcript -->
|
|
<div v-if="softphone.transcript.value.length > 0" class="space-y-2">
|
|
<h3 class="text-sm font-semibold">Transcript</h3>
|
|
<div class="max-h-40 overflow-y-auto p-3 rounded-lg border bg-gray-50 space-y-1">
|
|
<p
|
|
v-for="(item, index) in softphone.transcript.value.slice(-10)"
|
|
:key="index"
|
|
class="text-sm"
|
|
:class="{ 'text-gray-400': !item.isFinal }"
|
|
>
|
|
{{ item.text }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- AI Suggestions -->
|
|
<div v-if="softphone.aiSuggestions.value.length > 0" class="space-y-2">
|
|
<h3 class="text-sm font-semibold">AI Suggestions</h3>
|
|
<div class="space-y-2 max-h-32 overflow-y-auto">
|
|
<div
|
|
v-for="(suggestion, index) in softphone.aiSuggestions.value.slice(0, 5)"
|
|
:key="index"
|
|
class="p-2 rounded-lg border text-sm"
|
|
:class="{
|
|
'bg-blue-50 border-blue-200': suggestion.type === 'response',
|
|
'bg-green-50 border-green-200': suggestion.type === 'action',
|
|
'bg-purple-50 border-purple-200': suggestion.type === 'insight'
|
|
}"
|
|
>
|
|
<span class="text-xs font-medium uppercase text-gray-600">{{ suggestion.type }}</span>
|
|
<p class="mt-1">{{ suggestion.text }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dialer (when no active call) -->
|
|
<div v-if="!softphone.currentCall.value && !softphone.incomingCall.value" class="space-y-4">
|
|
<div>
|
|
<label class="text-sm font-medium">Phone Number</label>
|
|
<Input
|
|
v-model="phoneNumber"
|
|
placeholder="+1234567890"
|
|
class="mt-1"
|
|
@keyup.enter="handleCall"
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<Button
|
|
v-for="digit in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']"
|
|
:key="digit"
|
|
variant="outline"
|
|
@click="phoneNumber += digit"
|
|
class="h-12 text-lg font-semibold"
|
|
>
|
|
{{ digit }}
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<Button @click="handleCall" class="flex-1" :disabled="!phoneNumber">
|
|
<PhoneIcon class="w-4 h-4 mr-2" />
|
|
Call
|
|
</Button>
|
|
<Button @click="phoneNumber = ''" variant="outline">
|
|
<XIcon class="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Recent Calls -->
|
|
<div v-if="softphone.callHistory.value.length > 0" class="space-y-2">
|
|
<h3 class="text-sm font-semibold">Recent Calls</h3>
|
|
<div class="space-y-1 max-h-40 overflow-y-auto">
|
|
<div
|
|
v-for="call in softphone.callHistory.value.slice(0, 5)"
|
|
:key="call.callSid"
|
|
class="flex items-center justify-between p-2 rounded hover:bg-gray-100 cursor-pointer"
|
|
@click="phoneNumber = call.direction === 'outbound' ? call.toNumber : call.fromNumber"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<PhoneIcon v-if="call.direction === 'outbound'" class="w-3 h-3 text-green-500" />
|
|
<PhoneIncomingIcon v-else class="w-3 h-3 text-blue-500" />
|
|
<span class="text-sm">
|
|
{{ formatPhoneNumber(call.direction === 'outbound' ? call.toNumber : call.fromNumber) }}
|
|
</span>
|
|
</div>
|
|
<span class="text-xs text-gray-500">{{ formatDuration(call.duration) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue';
|
|
import { useSoftphone } from '~/composables/useSoftphone';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog';
|
|
import { Button } from '~/components/ui/button';
|
|
import { Input } from '~/components/ui/input';
|
|
import { PhoneIcon, PhoneOffIcon, PhoneIncomingIcon, MicIcon, MicOffIcon, Hash, XIcon } from 'lucide-vue-next';
|
|
import { toast } from 'vue-sonner';
|
|
|
|
const softphone = useSoftphone();
|
|
|
|
const phoneNumber = ref('');
|
|
const showDialpad = ref(false);
|
|
const isMuted = ref(false);
|
|
|
|
const handleCall = async () => {
|
|
if (!phoneNumber.value) {
|
|
toast.error('Please enter a phone number');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await softphone.initiateCall(phoneNumber.value);
|
|
phoneNumber.value = '';
|
|
toast.success('Call initiated');
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to initiate call');
|
|
}
|
|
};
|
|
|
|
const handleAccept = async () => {
|
|
if (!softphone.incomingCall.value) return;
|
|
|
|
try {
|
|
await softphone.acceptCall(softphone.incomingCall.value.callSid);
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to accept call');
|
|
}
|
|
};
|
|
|
|
const handleReject = async () => {
|
|
if (!softphone.incomingCall.value) return;
|
|
|
|
try {
|
|
await softphone.rejectCall(softphone.incomingCall.value.callSid);
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to reject call');
|
|
}
|
|
};
|
|
|
|
const handleEndCall = async () => {
|
|
if (!softphone.currentCall.value) return;
|
|
|
|
try {
|
|
await softphone.endCall(softphone.currentCall.value.callSid);
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to end call');
|
|
}
|
|
};
|
|
|
|
const handleDtmf = async (digit: string) => {
|
|
if (!softphone.currentCall.value) return;
|
|
|
|
try {
|
|
await softphone.sendDtmf(softphone.currentCall.value.callSid, digit);
|
|
} catch (error: any) {
|
|
console.error('Failed to send DTMF:', error);
|
|
}
|
|
};
|
|
|
|
const toggleMute = () => {
|
|
isMuted.value = !isMuted.value;
|
|
// TODO: Implement actual audio muting
|
|
toast.info(isMuted.value ? 'Muted' : 'Unmuted');
|
|
};
|
|
|
|
const formatPhoneNumber = (number: string): string => {
|
|
if (!number) return '';
|
|
// Simple US format
|
|
const cleaned = number.replace(/\D/g, '');
|
|
if (cleaned.length === 11 && cleaned[0] === '1') {
|
|
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`;
|
|
} else if (cleaned.length === 10) {
|
|
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
|
|
}
|
|
return number;
|
|
};
|
|
|
|
const formatDuration = (seconds?: number): string => {
|
|
if (!seconds) return '--:--';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
</script>
|