435 lines
16 KiB
Vue
435 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import AIChatBar from '@/components/AIChatBar.vue'
|
|
import { Phone, Sparkles, X, ChevronUp, Hash, Mic, MicOff, PhoneIncoming, PhoneOff } from 'lucide-vue-next'
|
|
import { toast } from 'vue-sonner'
|
|
import { useSoftphone } from '~/composables/useSoftphone'
|
|
|
|
const isDrawerOpen = useState<boolean>('bottomDrawerOpen', () => false)
|
|
const activeTab = useState<string>('bottomDrawerTab', () => 'softphone')
|
|
const drawerHeight = useState<number>('bottomDrawerHeight', () => 240)
|
|
|
|
const softphone = useSoftphone()
|
|
|
|
const minHeight = 200
|
|
const collapsedHeight = 72
|
|
const maxHeight = ref(480)
|
|
const isResizing = ref(false)
|
|
const resizeStartY = ref(0)
|
|
const resizeStartHeight = ref(0)
|
|
|
|
const phoneNumber = ref('')
|
|
const showDialpad = ref(false)
|
|
|
|
const statusLabel = computed(() => (softphone.isConnected.value ? 'Connected' : 'Offline'))
|
|
|
|
const clampHeight = (height: number) => Math.min(Math.max(height, minHeight), maxHeight.value)
|
|
|
|
const updateMaxHeight = () => {
|
|
if (!process.client) return
|
|
maxHeight.value = Math.round(window.innerHeight * 0.6)
|
|
drawerHeight.value = clampHeight(drawerHeight.value)
|
|
}
|
|
|
|
const openDrawer = (tab?: string) => {
|
|
if (tab) {
|
|
activeTab.value = tab
|
|
}
|
|
isDrawerOpen.value = true
|
|
if (activeTab.value === 'softphone') {
|
|
softphone.open()
|
|
}
|
|
}
|
|
|
|
const minimizeDrawer = () => {
|
|
isDrawerOpen.value = false
|
|
}
|
|
|
|
const startResize = (event: MouseEvent | TouchEvent) => {
|
|
if (!isDrawerOpen.value) {
|
|
isDrawerOpen.value = true
|
|
}
|
|
isResizing.value = true
|
|
resizeStartY.value = 'touches' in event ? event.touches[0].clientY : event.clientY
|
|
resizeStartHeight.value = drawerHeight.value
|
|
}
|
|
|
|
const handleResize = (event: MouseEvent | TouchEvent) => {
|
|
if (!isResizing.value) return
|
|
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY
|
|
const delta = resizeStartY.value - clientY
|
|
drawerHeight.value = clampHeight(resizeStartHeight.value + delta)
|
|
}
|
|
|
|
const stopResize = () => {
|
|
isResizing.value = false
|
|
}
|
|
|
|
watch(
|
|
() => softphone.incomingCall.value,
|
|
(incoming) => {
|
|
if (incoming) {
|
|
activeTab.value = 'softphone'
|
|
isDrawerOpen.value = true
|
|
}
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => activeTab.value,
|
|
(tab) => {
|
|
if (tab === 'softphone' && isDrawerOpen.value) {
|
|
softphone.open()
|
|
}
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => isDrawerOpen.value,
|
|
(open) => {
|
|
if (open && activeTab.value === 'softphone') {
|
|
softphone.open()
|
|
}
|
|
}
|
|
)
|
|
|
|
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 formatPhoneNumber = (number: string): string => {
|
|
if (!number) return ''
|
|
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')}`
|
|
}
|
|
|
|
onMounted(() => {
|
|
console.log('BottomDrawer mounted');
|
|
updateMaxHeight()
|
|
window.addEventListener('mousemove', handleResize)
|
|
window.addEventListener('mouseup', stopResize)
|
|
window.addEventListener('touchmove', handleResize, { passive: true })
|
|
window.addEventListener('touchend', stopResize)
|
|
window.addEventListener('resize', updateMaxHeight)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
console.log('BottomDrawer unmounted');
|
|
window.removeEventListener('mousemove', handleResize)
|
|
window.removeEventListener('mouseup', stopResize)
|
|
window.removeEventListener('touchmove', handleResize)
|
|
window.removeEventListener('touchend', stopResize)
|
|
window.removeEventListener('resize', updateMaxHeight)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-30 flex justify-center px-4">
|
|
<div
|
|
class="pointer-events-auto w-full max-w-5xl border border-border bg-background shadow-xl transition-all duration-200"
|
|
:style="{ height: `${isDrawerOpen ? drawerHeight : collapsedHeight}px` }"
|
|
>
|
|
<div class="flex items-center justify-between border-border px-4 py-2">
|
|
|
|
<Tabs v-if="!isDrawerOpen" v-model="activeTab" class="flex h-full flex-col">
|
|
<TabsList class="mx-4 mt-2 grid w-fit grid-cols-2">
|
|
<TabsTrigger value="softphone" class="flex items-center gap-2" @click="openDrawer('softphone')">
|
|
<Phone class="h-4 w-4" />
|
|
Softphone
|
|
<span
|
|
class="inline-flex h-2 w-2 rounded-full"
|
|
:class="softphone.isConnected.value ? 'bg-emerald-500' : 'bg-muted-foreground/40'"
|
|
/>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="ai" class="flex items-center gap-2" @click="openDrawer('ai')">
|
|
<Sparkles class="h-4 w-4" />
|
|
AI Agent
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<div
|
|
class="flex h-6 flex-1 cursor-row-resize items-center justify-center"
|
|
@mousedown="startResize"
|
|
@touchstart.prevent="startResize"
|
|
>
|
|
<span class="h-1.5 w-12 rounded-full bg-muted" />
|
|
</div>
|
|
<Button variant="ghost" size="icon" class="ml-3" @click="isDrawerOpen ? minimizeDrawer() : openDrawer()">
|
|
<X v-if="isDrawerOpen" class="h-4 w-4" />
|
|
<ChevronUp v-else class="h-4 w-4" />
|
|
<span class="sr-only">{{ isDrawerOpen ? 'Minimize drawer' : 'Expand drawer' }}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<Tabs v-if="isDrawerOpen" v-model="activeTab" class="flex h-full flex-col border-t">
|
|
<TabsList class="mx-4 mt-2 grid w-fit grid-cols-2">
|
|
<TabsTrigger value="softphone" class="flex items-center gap-2" @click="openDrawer('softphone')">
|
|
<Phone class="h-4 w-4" />
|
|
Softphone
|
|
<span
|
|
class="inline-flex h-2 w-2 rounded-full"
|
|
:class="softphone.isConnected.value ? 'bg-emerald-500' : 'bg-muted-foreground/40'"
|
|
/>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="ai" class="flex items-center gap-2" @click="openDrawer('ai')">
|
|
<Sparkles class="h-4 w-4" />
|
|
AI Agent
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<div v-show="isDrawerOpen" class="flex-1 overflow-hidden">
|
|
<TabsContent value="softphone" class="h-full">
|
|
<div class="flex h-full flex-col gap-4 px-6 pb-6 pt-4">
|
|
<div
|
|
class="flex items-center justify-between rounded-lg border px-4 py-3"
|
|
:class="softphone.isConnected.value ? 'border-emerald-200 bg-emerald-50/40' : 'border-border bg-muted/30'"
|
|
>
|
|
<div>
|
|
<p class="text-sm font-medium">Softphone</p>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ softphone.isConnected.value ? 'Ready to place and receive calls.' : 'Connect to start placing calls.' }}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<span
|
|
class="inline-flex h-2.5 w-2.5 rounded-full"
|
|
:class="softphone.isConnected.value ? 'bg-emerald-500' : 'bg-muted-foreground/40'"
|
|
/>
|
|
<span class="text-muted-foreground">{{ statusLabel }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="softphone.aiSuggestions.value.length > 0" class="space-y-2">
|
|
<h3 class="text-sm font-semibold flex items-center gap-2">
|
|
<span>AI Assistant</span>
|
|
<span class="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">
|
|
{{ softphone.aiSuggestions.value.length }}
|
|
</span>
|
|
</h3>
|
|
<div class="space-y-2 max-h-40 overflow-y-auto">
|
|
<div
|
|
v-for="(suggestion, index) in softphone.aiSuggestions.value.slice(0, 5)"
|
|
:key="index"
|
|
class="rounded-lg border p-3 text-sm transition-all"
|
|
:class="{
|
|
'bg-blue-50 border-blue-200': suggestion.type === 'response',
|
|
'bg-emerald-50 border-emerald-200': suggestion.type === 'action',
|
|
'bg-purple-50 border-purple-200': suggestion.type === 'insight',
|
|
}"
|
|
>
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span
|
|
class="text-xs font-semibold uppercase"
|
|
:class="{
|
|
'text-blue-700': suggestion.type === 'response',
|
|
'text-emerald-700': suggestion.type === 'action',
|
|
'text-purple-700': suggestion.type === 'insight',
|
|
}"
|
|
>
|
|
{{ suggestion.type }}
|
|
</span>
|
|
<span class="text-xs text-muted-foreground">just now</span>
|
|
</div>
|
|
<p class="leading-relaxed">{{ suggestion.text }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="softphone.incomingCall.value" class="rounded-lg border border-blue-200 bg-blue-50/60 p-4">
|
|
<div class="text-center space-y-4">
|
|
<div>
|
|
<p class="text-sm text-muted-foreground">Incoming call from</p>
|
|
<p class="text-2xl font-semibold">
|
|
{{ formatPhoneNumber(softphone.incomingCall.value.fromNumber) }}
|
|
</p>
|
|
</div>
|
|
<div class="flex gap-2 justify-center">
|
|
<Button @click="handleAccept" class="bg-emerald-500 hover:bg-emerald-600">
|
|
<Phone class="h-4 w-4 mr-2" />
|
|
Accept
|
|
</Button>
|
|
<Button @click="handleReject" variant="destructive">
|
|
<PhoneOff class="h-4 w-4 mr-2" />
|
|
Reject
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="softphone.currentCall.value" class="space-y-4">
|
|
<div class="rounded-lg border bg-muted/40 p-4 text-center space-y-2">
|
|
<p class="text-sm text-muted-foreground">
|
|
{{ softphone.currentCall.value.direction === 'outbound' ? 'Calling' : 'Connected with' }}
|
|
</p>
|
|
<p class="text-2xl font-semibold">
|
|
{{ formatPhoneNumber(
|
|
softphone.currentCall.value.direction === 'outbound'
|
|
? softphone.currentCall.value.toNumber
|
|
: softphone.currentCall.value.fromNumber
|
|
) }}
|
|
</p>
|
|
<p class="text-xs text-muted-foreground capitalize">{{ softphone.callStatus.value }}</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<Button variant="outline" size="sm" @click="softphone.toggleMute">
|
|
<Mic v-if="!softphone.isMuted.value" class="h-4 w-4" />
|
|
<MicOff v-else class="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="outline" size="sm" @click="showDialpad = !showDialpad">
|
|
<Hash class="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="destructive" size="sm" @click="handleEndCall">
|
|
<PhoneOff class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<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>
|
|
</div>
|
|
|
|
<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">
|
|
<Phone class="h-4 w-4 mr-2" />
|
|
Call
|
|
</Button>
|
|
<Button @click="phoneNumber = ''" variant="outline">
|
|
<X class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<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 rounded-md px-2 py-1 hover:bg-muted/40 cursor-pointer"
|
|
@click="phoneNumber = call.direction === 'outbound' ? call.toNumber : call.fromNumber"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<Phone v-if="call.direction === 'outbound'" class="h-3 w-3 text-emerald-500" />
|
|
<PhoneIncoming v-else class="h-3 w-3 text-blue-500" />
|
|
<span class="text-sm">
|
|
{{ formatPhoneNumber(call.direction === 'outbound' ? call.toNumber : call.fromNumber) }}
|
|
</span>
|
|
</div>
|
|
<span class="text-xs text-muted-foreground">{{ formatDuration(call.duration) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="ai" class="h-full">
|
|
<div class="flex h-full flex-col justify-end">
|
|
<AIChatBar />
|
|
</div>
|
|
</TabsContent>
|
|
</div>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
</template>
|