feat: refactor frontend conversations

- remove unused heroicons pkg.
This commit is contained in:
Abhinav Raut
2024-09-30 01:01:06 +05:30
parent 5d2d227f24
commit e16c627f67
25 changed files with 691 additions and 683 deletions

View File

@@ -16,7 +16,6 @@
},
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@heroicons/vue": "^2.1.1",
"@internationalized/date": "^3.5.5",
"@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.10",

View File

@@ -1,5 +1,6 @@
<template>
<div class="relative" v-if="conversationStore.messages.data">
<!-- Header -->
<div class="px-4 border-b h-[47px] flex items-center justify-between">
<div class="flex items-center space-x-3 text-sm">
@@ -30,12 +31,17 @@
</DropdownMenu>
</div>
</div>
<!-- Header end -->
<Error class="sticky" :error-message="conversationStore.messages.errorMessage"></Error>
<!-- Messages & reply box -->
<div class="flex flex-col h-screen">
<!-- flex-1-->
<MessageList class="flex-1" />
<ReplyBox class="h-max mb-12" />
</div>
<!-- Messages & reply box end -->
</div>
</template>

View File

@@ -1,491 +0,0 @@
<template>
<div class="p-3">
<div>
<Avatar class="size-20">
<AvatarImage
:src="conversationStore.conversation.data.avatar_url"
v-if="conversationStore.conversation.data.avatar_url"
/>
<AvatarFallback>
{{ conversationStore.conversation.data.first_name.toUpperCase().substring(0, 2) }}
</AvatarFallback>
</Avatar>
<h4 class="mt-3">
{{
conversationStore.conversation.data.first_name +
' ' +
conversationStore.conversation.data.last_name
}}
</h4>
<p
class="text-sm text-muted-foreground flex gap-2 mt-1"
v-if="conversationStore.conversation.data.email"
>
<Mail class="size-3 mt-1"></Mail>
{{ conversationStore.conversation.data.email }}
</p>
<p
class="text-sm text-muted-foreground flex gap-2 mt-1"
v-if="conversationStore.conversation.data.phone_number"
>
<Phone class="size-3 mt-1"></Phone>
{{ conversationStore.conversation.data.phone_number }}
</p>
</div>
<Accordion
type="single"
collapsible
class="border-t mt-4"
:default-value="actionAccordion.title"
>
<AccordionItem :value="actionAccordion.title">
<AccordionTrigger>
<h4 class="scroll-m-20 text-base font-medium tracking-tight">
{{ actionAccordion.title }}
</h4>
</AccordionTrigger>
<AccordionContent>
<!-- Agent assign -->
<div class="mb-3">
<Popover v-model:open="agentSelectDropdownOpen">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="agentSelectDropdownOpen"
class="w-full justify-between"
>
{{
conversationStore.conversation.data.assigned_user_id
? (() => {
console.log(
' ->',
conversationStore.conversation.data.assigned_user_id,
'agents ',
agents
)
const agent = agents.find(
(agent) =>
agent.id === conversationStore.conversation.data.assigned_user_id
)
return agent
? `${agent.first_name} ${agent.last_name}`
: 'Select agent...'
})()
: 'Select agent...'
}}
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="p-0 PopoverContent">
<Command @update:modelValue="handleAssignedUserChange">
<CommandInput class="h-9" placeholder="Search agent..." />
<CommandEmpty>No agent found.</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem
v-for="agent in agents"
:key="agent.id"
:value="agent.id + ':' + agent.first_name + ' ' + agent.last_name"
@select="
(ev) => {
if (typeof ev.detail.value === 'string') {
const id = ev.detail.value.split(':')[0]
console.log('setting id ', id)
conversationStore.conversation.data.assigned_user_id = Number(id)
}
agentSelectDropdownOpen = false
}
"
>
{{ agent.first_name + ' ' + agent.last_name }}
<CheckIcon
:class="
cn(
'ml-auto h-4 w-4',
conversationStore.conversation.data.assigned_user_id === agent.id
? 'opacity-100'
: 'opacity-0'
)
"
/>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<!-- Agent assign end -->
<!-- Team assign -->
<div class="mb-3">
<Popover v-model:open="teamSelectDropdownOpen">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="teamSelectDropdownOpen"
class="w-full justify-between"
>
{{
conversationStore.conversation.data.assigned_team_id
? teams.find(
(team) => team.id === conversationStore.conversation.data.assigned_team_id
)?.name
: 'Select team...'
}}
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="p-0 PopoverContent">
<Command @update:modelValue="handleAssignedTeamChange">
<CommandInput class="h-9" placeholder="Search team..." />
<CommandEmpty>No team found.</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem
v-for="team in teams"
:key="team.id"
:value="team.id + ':' + team.name"
@select="
(ev) => {
if (ev.detail.value) {
const id = ev.detail.value.split(':')[0]
conversationStore.conversation.data.assigned_team_id = Number(id)
}
teamSelectDropdownOpen = false
}
"
>
{{ team.name }}
<CheckIcon
:class="
cn(
'ml-auto h-4 w-4',
conversationStore.conversation.data.assigned_team_id === team.id
? 'opacity-100'
: 'opacity-0'
)
"
/>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<!-- Team assign end -->
<!-- Priority -->
<div class="mb-3">
<Popover v-model:open="prioritySelectDropdownOpen">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="prioritySelectDropdownOpen"
class="w-full justify-between"
>
{{
conversationStore.conversation.data.priority
? priorities.find(
(priority) => priority === conversationStore.conversation.data.priority
)
: 'Select priority...'
}}
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="p-0 PopoverContent">
<Command @update:modelValue="handlePriorityChange">
<CommandInput class="h-9" placeholder="Search priority..." />
<CommandEmpty>No priority found.</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem
v-for="priority in priorities"
:key="priority"
:value="priority"
@select="
(ev) => {
if (ev.detail.value) {
const p = ev.detail.value
conversationStore.conversation.data.priority = p
}
prioritySelectDropdownOpen = false
}
"
>
{{ priority }}
<CheckIcon
:class="
cn(
'ml-auto h-4 w-4',
conversationStore.conversation.data.priority === priority
? 'opacity-100'
: 'opacity-0'
)
"
/>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<!-- Priority end -->
<!-- Tags -->
<TagsInput
class="px-0 gap-0 w-full"
:model-value="tagsSelected"
@update:modelValue="handleUpsertTags"
>
<div class="flex gap-2 flex-wrap items-center px-3">
<TagsInputItem v-for="item in tagsSelected" :key="item" :value="item">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
</div>
<ComboboxRoot
v-model="tagsSelected"
v-model:open="tagDropdownOpen"
v-model:searchTerm="tagSearchTerm"
class="w-full"
>
<ComboboxAnchor as-child>
<ComboboxInput placeholder="Add tags..." as-child>
<TagsInputInput
class="w-full px-3"
:class="tagsSelected.length > 0 ? 'mt-2' : ''"
@keydown.enter.prevent
/>
</ComboboxInput>
</ComboboxAnchor>
<ComboboxPortal>
<CommandList
position="popper"
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<CommandEmpty />
<CommandGroup>
<CommandItem
v-for="ftag in tagsFiltered"
:key="ftag.value"
:value="ftag.label"
@select.prevent="
(ev) => {
if (typeof ev.detail.value === 'string') {
tagSearchTerm = ''
tagsSelected.push(ev.detail.value)
tagDropdownOpen = false
}
if (tagsFiltered.length === 0) {
tagDropdownOpen = false
}
}
"
>
{{ ftag.label }}
</CommandItem>
</CommandGroup>
</CommandList>
</ComboboxPortal>
</ComboboxRoot>
</TagsInput>
<!-- Tags end -->
</AccordionContent>
</AccordionItem>
</Accordion>
<Accordion type="single" collapsible :default-value="infoAccordion.title">
<AccordionItem :value="infoAccordion.title">
<AccordionTrigger>
<h4 class="scroll-m-20 text-base font-medium tracking-tight">
{{ infoAccordion.title }}
</h4>
</AccordionTrigger>
<AccordionContent>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Initiated at</p>
<p>
{{ format(conversationStore.conversation.data.created_at, 'PPpp') }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">First reply at</p>
<p v-if="conversationStore.conversation.data.first_reply_at">
{{ format(conversationStore.conversation.data.first_reply_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Resolved at</p>
<p v-if="conversationStore.conversation.data.resolved_at">
{{ format(conversationStore.conversation.data.resolved_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Closed at</p>
<p v-if="conversationStore.conversation.data.closed_at">
{{ format(conversationStore.conversation.data.closed_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { format } from 'date-fns'
import api from '@/api'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '@/components/ui/accordion'
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ComboboxAnchor, ComboboxInput, ComboboxPortal, ComboboxRoot } from 'radix-vue'
import {
CommandEmpty,
CommandGroup,
CommandInput,
Command,
CommandItem,
CommandList
} from '@/components/ui/command'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
import { Mail, Phone } from 'lucide-vue-next'
import { useToast } from '@/components/ui/toast/use-toast'
import { handleHTTPError } from '@/utils/http'
const priorities = ref([])
const { toast } = useToast()
const conversationStore = useConversationStore()
const agents = ref([])
const teams = ref([])
const agentSelectDropdownOpen = ref(false)
const teamSelectDropdownOpen = ref(false)
const prioritySelectDropdownOpen = ref(false)
const tagsSelected = computed(() => conversationStore.conversation.data.tags)
const tags = ref([])
const tagIDMap = {}
const tagDropdownOpen = ref(false)
const tagSearchTerm = ref('')
const tagsFiltered = computed(() => tags.value.filter((i) => !tagsSelected.value.includes(i.label)))
const actionAccordion = {
title: 'Actions'
}
const infoAccordion = {
title: 'Information'
}
onMounted(() => {
api
.getUsers()
.then((resp) => {
agents.value = resp.data.data
})
.catch((error) => {
toast({
title: 'Could not fetch users',
variant: 'destructive',
description: handleHTTPError(error).message
})
})
api
.getTeams()
.then((resp) => {
teams.value = resp.data.data
})
.catch((error) => {
toast({
title: 'Could not fetch teams',
variant: 'destructive',
description: handleHTTPError(error).message
})
})
api
.getTags()
.then(async (resp) => {
let dt = resp.data.data
dt.forEach((item) => {
tags.value.push({
label: item.name,
value: item.id
})
tagIDMap[item.name] = item.id
})
})
.catch((error) => {
toast({
title: 'Could not fetch tags',
variant: 'destructive',
description: handleHTTPError(error).message
})
})
getPrioritites()
})
const getPrioritites = async () => {
const resp = await api.getPriorities()
priorities.value = resp.data.data.map((priority) => priority.name)
}
const handleAssignedUserChange = (v) => {
conversationStore.updateAssignee('user', {
assignee_id: v.split(':')[0]
})
}
const handleAssignedTeamChange = (v) => {
conversationStore.updateAssignee('team', {
assignee_id: v.split(':')[0]
})
}
const handlePriorityChange = (v) => {
conversationStore.updatePriority(v)
}
const handleUpsertTags = () => {
let tagIDs = tagsSelected.value.map((tag) => {
if (tag in tagIDMap) {
return tagIDMap[tag]
}
})
conversationStore.upsertTags({
tag_ids: JSON.stringify(tagIDs)
})
}
</script>

View File

@@ -39,6 +39,7 @@ const editor = ref(
StarterKit,
Image.configure({
HTMLAttributes: {
// Common class for all inline images.
class: 'inline-image',
},
}),

View File

@@ -49,13 +49,13 @@ import { transformImageSrcToCID } from '@/utils/strings'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import Editor from './Editor.vue'
import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
import { useCannedResponses } from '@/stores/canned_responses'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxBottomMenuBar.vue'
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxMenuBar.vue'
const emitter = useEmitter()
const clearContent = ref(false)

View File

@@ -1,8 +1,10 @@
<template>
<div class="flex justify-between items-center border-y h-14 px-2">
<div class="flex justify-items-start gap-2">
<!-- File inputs -->
<input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" />
<input type="file" class="hidden" ref="inlineImageInput" accept="image/*" @change="handleInlineImageUpload" />
<!-- Editor buttons -->
<Toggle class="px-2 py-2 border-0" variant="outline" @click="toggleBold" :pressed="isBold">
<Bold class="h-4 w-4" />
</Toggle>
@@ -16,8 +18,7 @@
<Image class="h-4 w-4" />
</Toggle>
</div>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!hasText"> Send
</Button>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!hasText">Send</Button>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col items-center justify-center h-64 space-y-2">
<MessageCircleWarning :stroke-width="1.4" :size="90" />
<component :is="icon" :stroke-width="1.4" :size="90" />
<h1 class="text-lg font-semibold text-gray-800">
{{ title }}
</h1>
@@ -11,9 +11,11 @@
</template>
<script setup>
import { MessageCircleWarning } from 'lucide-vue-next'
defineProps({
icon: {
type: Object,
required: true
},
title: {
type: String,
required: true

View File

@@ -0,0 +1,111 @@
<template>
<div class="h-screen">
<!-- Filters -->
<ConversationListFilters v-model:type="conversationType" v-model:filter="conversationFilter" :handleFilterChange="handleFilterChange">
</ConversationListFilters>
<!-- Error / Empty list -->
<EmptyList v-if="emptyConversations" title="No conversation found" message="Try adjusting filters."
:icon="MessageCircleQuestion"></EmptyList>
<EmptyList v-if="conversationStore.conversations.errorMessage" title="Could not fetch conversations"
:message="conversationStore.conversations.errorMessage" :icon="MessageCircleWarning"></EmptyList>
<div class="h-screen overflow-y-scroll pb-[180px] flex flex-col">
<!-- List skeleton -->
<div v-if="conversationsLoading">
<ConversationListItemSkeleton v-for="index in 8" :key="index"></ConversationListItemSkeleton>
</div>
<!-- Item -->
<ConversationListItem />
<!-- Load more -->
<div class="flex justify-center items-center mt-5 relative">
<div v-if="conversationStore.conversations.hasMore && !hasErrored && hasConversations">
<Button variant="link" @click="loadNextPage">
<Spinner v-if="conversationStore.conversations.loading" />
<p v-else>Load more...</p>
</Button>
</div>
<div v-else-if="everythingLoaded">All conversations loaded!</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, watch, computed, onUnmounted } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { subscribeConversationsList } from '@/websocket.js'
import { CONVERSATION_LIST_TYPE, CONVERSATION_FILTERS } from '@/constants/conversation'
import { MessageCircleWarning, MessageCircleQuestion } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import Spinner from '@/components/ui/spinner/Spinner.vue'
import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue'
import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue'
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
import ConversationListFilters from '@/components/conversation/list/ConversationListFilters.vue'
const conversationStore = useConversationStore()
const conversationFilter = ref(CONVERSATION_FILTERS.ALL)
const conversationType = ref(CONVERSATION_LIST_TYPE.ASSIGNED)
let listRefreshInterval = null
onMounted(() => {
conversationStore.fetchConversations(conversationType.value, conversationFilter.value)
subscribeConversationsList(conversationType.value, conversationFilter.value)
// Refresh list every min.
listRefreshInterval = setInterval(() => {
conversationStore.fetchConversations(conversationType.value, conversationFilter.value)
}, 60000)
})
onUnmounted(() => {
clearInterval(listRefreshInterval)
})
watch(conversationType, (newType) => {
conversationStore.fetchConversations(newType, conversationFilter.value)
subscribeConversationsList(newType, conversationFilter.value)
})
const handleFilterChange = (filter) => {
conversationFilter.value = filter
conversationStore.fetchConversations(conversationType.value, filter)
subscribeConversationsList(conversationType.value, conversationFilter.value)
}
const loadNextPage = () => {
conversationStore.fetchNextConversations(conversationType.value, conversationFilter.value)
}
const hasConversations = computed(() => {
return (
conversationStore.sortedConversations.length !== 0 &&
!conversationStore.conversations.errorMessage &&
!conversationStore.conversations.loading
)
})
const emptyConversations = computed(() => {
return (
conversationStore.sortedConversations.length === 0 &&
!conversationStore.conversations.errorMessage &&
!conversationStore.conversations.loading
)
})
const hasErrored = computed(() => {
return conversationStore.conversations.errorMessage ? true : false
})
const everythingLoaded = computed(() => {
return !conversationStore.conversations.errorMessage && !emptyConversations.value
})
const conversationsLoading = computed(() => {
return conversationStore.conversations.loading
})
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="flex justify-between px-2 py-2 border-b">
<Tabs v-model="conversationType">
<TabsList class="w-full flex justify-evenly">
<TabsTrigger value="assigned" class="w-full"> Assigned </TabsTrigger>
<TabsTrigger value="unassigned" class="w-full"> Unassigned </TabsTrigger>
<TabsTrigger value="all" class="w-full"> All </TabsTrigger>
</TabsList>
</Tabs>
<Popover>
<PopoverTrigger as-child>
<div class="flex items-center mr-2">
<ListFilter size="20" class="mx-auto cursor-pointer"></ListFilter>
</div>
</PopoverTrigger>
<PopoverContent class="w-52">
<div>
<Select v-model="conversationFilter">
<SelectTrigger>
<SelectValue placeholder="Select a filter" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="status_all"> All </SelectItem>
<SelectItem value="status_open"> Open </SelectItem>
<SelectItem value="status_processing"> Processing </SelectItem>
<SelectItem value="status_spam"> Spam </SelectItem>
<SelectItem value="status_resolved"> Resolved </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
</div>
</template>
<script setup>
import { defineModel, watch } from 'vue'
import { ListFilter } from 'lucide-vue-next'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const conversationType = defineModel('type')
const conversationFilter = defineModel('filter')
const props = defineProps({
handleFilterChange: {
type: Function,
required: true
},
})
watch(conversationFilter, (newValue) => {
props.handleFilterChange(newValue)
})
</script>

View File

@@ -3,6 +3,7 @@
:class="{ 'bg-slate-100': conversation.uuid === conversationStore.conversation.data?.uuid }"
v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid"
@click="router.push('/conversations/' + conversation.uuid)">
<div class="pl-3">
<Avatar class="size-[45px]">
<AvatarImage :src="conversation.avatar_url" v-if="conversation.avatar_url" />
@@ -11,11 +12,12 @@
</AvatarFallback>
</Avatar>
</div>
<div class="ml-3 w-full border-b pb-2">
<div class="flex justify-between pt-2 pr-3">
<div>
<p class="text-xs text-gray-600 flex gap-x-1">
<Mail size="12" />
<Mail size="13" />
{{ conversation.inbox_name }}
</p>
<p class="text-base font-normal">
@@ -49,7 +51,6 @@
import { useRouter } from 'vue-router'
import { useConversationStore } from '@/stores/conversation'
import { formatTime } from '@/utils/datetime'
import { Mail, CheckCheck } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'

View File

@@ -0,0 +1,13 @@
<template>
<div class="flex items-center gap-5 p-6 border-b">
<Skeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
<Skeleton class="h-4 w-[250px]" />
<Skeleton class="h-4 w-[200px]" />
</div>
</div>
</template>
<script setup>
import { Skeleton } from '@/components/ui/skeleton'
</script>

View File

@@ -0,0 +1,75 @@
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
{{ assignedAgentName }}
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="p-0">
<Command @update:searchTerm="handleFilterAgents">
<CommandInput class="h-9" placeholder="Search agent" />
<CommandEmpty>No agent found.</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem v-for="agent in filteredAgents" :key="agent.id" :value="agent.id"
@select="handleSelectAgent(agent.id)">
{{ `${agent.first_name} ${agent.last_name}` }}
<CheckIcon :class="cn(
'ml-auto h-4 w-4',
conversation.assigned_user_id === agent.id ? 'opacity-100' : 'opacity-0'
)" />
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
CommandEmpty,
CommandGroup,
CommandInput,
Command,
CommandItem,
CommandList
} from '@/components/ui/command'
const open = ref(false)
const props = defineProps({
conversation: Object,
selectAgent: Function,
agents: Array,
})
const filteredAgents = ref([])
const assignedAgentName = computed(() => {
const assignedUserId = props.conversation.assigned_user_id
if (!assignedUserId) return 'Select agent...'
const agent = props.agents.find(agent => agent.id === assignedUserId)
return agent ? `${agent.first_name} ${agent.last_name}` : 'Select agent...'
})
const handleFilterAgents = (search) => {
filteredAgents.value = props.agents.filter(agent =>
`${agent.first_name.toLowerCase()} ${agent.last_name.toLowerCase()}`.includes(search.toLowerCase())
)
}
const handleSelectAgent = (id) => {
props.selectAgent(id)
open.value = false
}
watch(() => props.agents, (newAgents) => {
filteredAgents.value = [...newAgents]
}, { immediate: true })
</script>

View File

@@ -0,0 +1,74 @@
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
{{ assignedTeamName }}
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="p-0">
<Command @update:searchTerm="handleFilterTeams">
<CommandInput class="h-9" placeholder="Search team" />
<CommandEmpty>No team found.</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem v-for="team in filteredTeams" :key="team.id" :value="team.id" @select="handleSelectTeam(team.id)">
{{ team.name }}
<CheckIcon :class="cn(
'ml-auto h-4 w-4',
conversation.assigned_team_id === team.id ? 'opacity-100' : 'opacity-0'
)" />
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
CommandEmpty,
CommandGroup,
CommandInput,
Command,
CommandItem,
CommandList
} from '@/components/ui/command'
const open = ref(false)
const props = defineProps({
conversation: Object,
selectTeam: Function,
teams: Array,
})
const filteredTeams = ref([])
const assignedTeamName = computed(() => {
const assignedTeamId = props.conversation.assigned_team_id
if (!assignedTeamId) return 'Select team...'
const team = props.teams.find(team => team.id === assignedTeamId)
return team ? team.name : 'Select team...'
})
const handleFilterTeams = (search) => {
filteredTeams.value = props.teams.filter(team =>
team.name.toLowerCase().includes(search.toLowerCase())
)
}
const handleSelectTeam = (id) => {
props.selectTeam(id)
open.value = false
}
watch(() => props.teams, (newTeams) => {
filteredTeams.value = [...newTeams]
}, { immediate: true })
</script>

View File

@@ -0,0 +1,5 @@
<template>
hi
</template>
<script setup></script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Initiated at</p>
<p>
{{ format(conversation.created_at, 'PPpp') }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">First reply at</p>
<p v-if="conversation.first_reply_at">
{{ format(conversation.first_reply_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Resolved at</p>
<p v-if="conversation.resolved_at">
{{ format(conversation.resolved_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Closed at</p>
<p v-if="conversation.closed_at">
{{ format(conversation.closed_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
</template>
<script setup>
import { format } from 'date-fns'
defineProps({
conversation: Object
})
</script>

View File

@@ -0,0 +1,170 @@
<template>
<div class="p-3">
<ConversationSideBarContact :conversation="conversationStore.conversation.data"></ConversationSideBarContact>
<Accordion type="multiple" collapsible class="border-t mt-4">
<AccordionItem value="Actions">
<AccordionTrigger>
<h4 class="scroll-m-20 text-base font-medium tracking-tight">
Actions
</h4>
</AccordionTrigger>
<AccordionContent class="space-y-5">
<!-- Agent -->
<AssignAgent :agents="agents" :conversation="conversationStore.conversation.data" :selectAgent="selectAgent">
</AssignAgent>
<!-- Team -->
<AssignTeam :teams="teams" :conversation="conversationStore.conversation.data" :selectTeam="selectTeam">
</AssignTeam>
<!-- Priority -->
<PriorityChange :priorities="priorities" :conversation="conversationStore.conversation.data"
:selectPriority="selectPriority"></PriorityChange>
<!-- Tags -->
<SelectTag :initialValue="conversationStore.conversation.data.tags" v-model="selectedTags" :items="tags"
placeHolder="Select tags"></SelectTag>
</AccordionContent>
</AccordionItem>
<AccordionItem value="Information">
<AccordionTrigger>
<span class="scroll-m-20 text-base font-medium tracking-tight">
Information
</span>
</AccordionTrigger>
<AccordionContent>
<ConversationInfo :conversation="conversationStore.conversation.data"></ConversationInfo>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import api from '@/api'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '@/components/ui/accordion'
import ConversationInfo from './ConversationInfo.vue'
import ConversationSideBarContact from '@/components/conversation/sidebar/ConversationSideBarContact.vue'
import AssignAgent from './AssignAgent.vue'
import AssignTeam from './AssignTeam.vue'
import PriorityChange from './PriorityChange.vue'
import { SelectTag } from '@/components/ui/select'
import { useToast } from '@/components/ui/toast/use-toast'
import { handleHTTPError } from '@/utils/http'
const priorities = ref([])
const { toast } = useToast()
const conversationStore = useConversationStore()
const agents = ref([])
const teams = ref([])
const selectedTags = ref([])
const tags = ref([])
const tagIDMap = {}
const filteredAgents = ref([])
onMounted(() => {
fetchUsers()
fetchTeams()
fetchTags()
getPrioritites()
})
const fetchUsers = async () => {
try {
const resp = await api.getUsers()
agents.value = resp.data.data
filteredAgents.value = resp.data.data
} catch (error) {
toast({
title: 'Could not fetch users',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const fetchTeams = async () => {
try {
const resp = await api.getTeams()
teams.value = resp.data.data
} catch (error) {
toast({
title: 'Could not fetch teams',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const fetchTags = async () => {
try {
const resp = await api.getTags()
resp.data.data.forEach(item => {
tagIDMap[item.name] = item.id
tags.value.push(item.name)
})
} catch (error) {
toast({
title: 'Could not fetch tags',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const getPrioritites = async () => {
const resp = await api.getPriorities()
priorities.value = resp.data.data.map((priority) => priority.name)
}
const handleAssignedUserChange = (id) => {
conversationStore.updateAssignee('user', {
assignee_id: id
})
}
const handleAssignedTeamChange = (id) => {
conversationStore.updateAssignee('team', {
assignee_id: id
})
}
const handlePriorityChange = (priority) => {
conversationStore.updatePriority(priority)
}
watch(selectedTags, () => {
handleUpsertTags()
}, { deep: true })
const handleUpsertTags = () => {
let tagIDs = selectedTags.value.map((tag) => {
if (tag in tagIDMap) {
return tagIDMap[tag]
}
})
conversationStore.upsertTags({
tag_ids: JSON.stringify(tagIDs)
})
}
const selectAgent = (id) => {
conversationStore.conversation.data.assigned_user_id = id
handleAssignedUserChange(id)
}
const selectTeam = (id) => {
conversationStore.conversation.data.assigned_team_id = id
handleAssignedTeamChange(id)
}
const selectPriority = (priority) => {
conversationStore.conversation.data.priority = priority
handlePriorityChange(priority)
}
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div>
<Avatar class="size-20">
<AvatarImage :src="conversation?.avatar_url" v-if="conversation?.avatar_url" />
<AvatarFallback>
{{ conversation?.first_name.toUpperCase().substring(0, 2) }}
</AvatarFallback>
</Avatar>
<h4 class="mt-3">
{{ conversation?.first_name + ' ' + conversation?.last_name }}
</h4>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.email">
<Mail class="size-3 mt-1"></Mail>
{{ conversation.email }}
</p>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.phone_number">
<Phone class="size-3 mt-1"></Phone>
{{ conversation.phone_number }}
</p>
</div>
</template>
<script setup>
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Mail, Phone } from 'lucide-vue-next'
defineProps({
conversation: Object
});
</script>

View File

@@ -0,0 +1,76 @@
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
{{
selectedPriority
}}
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="p-0 PopoverContent">
<Command @update:searchTerm="handleFilterPriorities">
<CommandInput class="h-9" placeholder="Search priority" />
<CommandEmpty>No priority found.</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem v-for="priority in filteredPriorities" :key="priority" :value="priority"
@select="handleSelectPriority(priority)">
{{ priority }}
<CheckIcon :class="cn(
'ml-auto h-4 w-4',
conversation.priority === priority ? 'opacity-100' : 'opacity-0'
)" />
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
CommandEmpty,
CommandGroup,
CommandInput,
Command,
CommandItem,
CommandList
} from '@/components/ui/command'
const open = ref(false)
const props = defineProps({
priorities: Array,
conversation: Object,
selectPriority: Function,
})
const filteredPriorities = ref([])
const selectedPriority = computed(() => {
return props.conversation.priority
? props.priorities.find(priority => priority === props.conversation.priority)
: 'Select priority'
})
const handleFilterPriorities = (search) => {
filteredPriorities.value = props.priorities.filter(priority =>
priority.toLowerCase().includes(search.toLowerCase())
)
}
watch(() => props.priorities, (newPriorities) => {
filteredPriorities.value = [...newPriorities]
}, { immediate: true })
const handleSelectPriority = (priority) => {
props.selectPriority(priority)
open.value = false
}
</script>

View File

@@ -1,152 +0,0 @@
<template>
<div class="h-screen">
<div class="flex justify-between px-2 py-2 border-b">
<Tabs v-model:model-value="conversationType">
<TabsList class="w-full flex justify-evenly">
<TabsTrigger value="assigned" class="w-full"> Assigned </TabsTrigger>
<TabsTrigger value="unassigned" class="w-full"> Unassigned </TabsTrigger>
<TabsTrigger value="all" class="w-full"> All </TabsTrigger>
</TabsList>
</Tabs>
<Popover>
<PopoverTrigger as-child>
<div class="flex items-center mr-2">
<ListFilter size="20" class="mx-auto cursor-pointer"></ListFilter>
</div>
</PopoverTrigger>
<PopoverContent class="w-52">
<div>
<Select @update:modelValue="handleFilterChange" v-model="predefinedFilter">
<SelectTrigger>
<SelectValue placeholder="Select a filter" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<!-- <SelectLabel>Status</SelectLabel> -->
<SelectItem value="status_all"> All </SelectItem>
<SelectItem value="status_open"> Open </SelectItem>
<SelectItem value="status_processing"> Processing </SelectItem>
<SelectItem value="status_spam"> Spam </SelectItem>
<SelectItem value="status_resolved"> Resolved </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
</div>
<EmptyList v-if="emptyConversations" title="No conversation found" message="Try adjusting filters."></EmptyList>
<EmptyList v-if="conversationStore.conversations.errorMessage" title="Something went wrong" :message="conversationStore.conversations.errorMessage"></EmptyList>
<div class="h-screen overflow-y-scroll pb-[180px] flex flex-col">
<ConversationListItem />
<div v-if="conversationsLoading">
<div class="flex items-center gap-5 p-6 border-b" v-for="index in 8" :key="index">
<Skeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
<Skeleton class="h-4 w-[250px]" />
<Skeleton class="h-4 w-[200px]" />
</div>
</div>
</div>
<div class="flex justify-center items-center mt-5 relative">
<div v-if="conversationStore.conversations.hasMore && !hasErrored && hasConversations">
<Button variant="link" @click="loadNextPage">
<Spinner v-if="conversationStore.conversations.loading" />
<p v-else>Load more...</p>
</Button>
</div>
<div v-else-if="everythingLoaded">All conversations loaded!</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, watch, computed, onUnmounted } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { subscribeConversationsList } from '@/websocket.js'
import { CONVERSATION_LIST_TYPE, CONVERSATION_PRE_DEFINED_FILTERS } from '@/constants/conversation'
import { ListFilter } from 'lucide-vue-next'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import Spinner from '@/components/ui/spinner/Spinner.vue'
import EmptyList from '@/components/conversationlist/ConversationEmptyList.vue'
import ConversationListItem from '@/components/conversationlist/ConversationListItem.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const conversationStore = useConversationStore()
const predefinedFilter = ref(CONVERSATION_PRE_DEFINED_FILTERS.ALL)
const conversationType = ref(CONVERSATION_LIST_TYPE.ASSIGNED)
let listRefreshInterval = null
onMounted(() => {
conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
subscribeConversationsList(conversationType.value, predefinedFilter.value)
// Refesh list every 1 minute to sync any missed changes.
listRefreshInterval = setInterval(() => {
conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
}, 60000)
})
onUnmounted(() => {
clearInterval(listRefreshInterval)
})
watch(conversationType, (newType) => {
conversationStore.fetchConversations(newType, predefinedFilter.value)
subscribeConversationsList(newType, predefinedFilter.value)
})
const handleFilterChange = (filter) => {
predefinedFilter.value = filter
conversationStore.fetchConversations(conversationType.value, filter)
subscribeConversationsList(conversationType.value, predefinedFilter.value)
}
const loadNextPage = () => {
conversationStore.fetchNextConversations(conversationType.value, predefinedFilter.value)
}
const hasConversations = computed(() => {
return (
conversationStore.sortedConversations.length !== 0 &&
!conversationStore.conversations.errorMessage &&
!conversationStore.conversations.loading
)
})
const emptyConversations = computed(() => {
return (
conversationStore.sortedConversations.length === 0 &&
!conversationStore.conversations.errorMessage &&
!conversationStore.conversations.loading
)
})
const hasErrored = computed(() => {
return conversationStore.conversations.errorMessage ? true : false
})
const everythingLoaded = computed(() => {
return !conversationStore.conversations.errorMessage && !emptyConversations.value
})
const conversationsLoading = computed(() => {
return conversationStore.conversations.loading
})
</script>

View File

@@ -11,27 +11,17 @@
<ComboboxRoot v-model:open="isOpen" class="w-full">
<ComboboxAnchor as-child>
<ComboboxInput :placeholder="placeHolder" as-child>
<TagsInputInput
class="w-full px-3"
:class="selectedItems.length > 0 ? 'mt-2' : ''"
@keydown.enter.prevent
/>
<TagsInputInput class="w-full px-3" :class="selectedItems.length > 0 ? 'mt-2' : ''"
@keydown.enter.prevent />
</ComboboxInput>
</ComboboxAnchor>
<ComboboxPortal>
<CommandList
position="popper"
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<CommandList position="popper"
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2">
<CommandEmpty />
<CommandGroup>
<CommandItem
v-for="item in filteredItems"
:key="item"
:value="item"
@select.prevent="selectItem(item)"
>
<CommandItem v-for="item in filteredItems" :key="item" :value="item" @select.prevent="selectItem(item)">
{{ item }}
</CommandItem>
</CommandGroup>
@@ -96,7 +86,7 @@ const filteredItems = computed(() => {
return props.items.filter((item) => !selectedItems.value.includes(item))
})
function selectItem(item) {
function selectItem (item) {
if (!selectedItems.value.includes(item)) {
selectedItems.value.push(item)
}

View File

@@ -1,4 +1,4 @@
export const CONVERSATION_PRE_DEFINED_FILTERS = {
export const CONVERSATION_FILTERS = {
ALL: 'status_all',
STATUS_OPEN: 'status_open',
STATUS_PROCESSING: 'status_processing',

View File

@@ -1,5 +1,4 @@
<template>
<!-- Resizable panel last resize value is stored in the localstorage -->
<ResizablePanelGroup direction="horizontal" auto-save-id="conversation.vue.resizable.panel">
<ResizablePanel :min-size="23" :default-size="23" :max-size="40">
<ConversationList></ConversationList>
@@ -25,9 +24,9 @@
import { onMounted, watch } from 'vue'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import ConversationList from '@/components/conversationlist/ConversationList.vue'
import ConversationList from '@/components/conversation/list/ConversationList.vue'
import Conversation from '@/components/conversation/ConversationPage.vue'
import ConversationSideBar from '@/components/conversation/ConversationSideBar.vue'
import ConversationSideBar from '@/components/conversation/sidebar/ConversationSideBar.vue'
import ConversationPlaceholder from '@/components/conversation/ConversationPlaceholder.vue'
import { useConversationStore } from '@/stores/conversation'

View File

@@ -108,7 +108,7 @@ export function sendMessage (message) {
export function subscribeConversationsList (type, filter) {
const message = {
action: 'conversations_sub',
action: 'conversations_list_sub',
type: type,
filter: filter
};

View File

@@ -703,11 +703,6 @@
dependencies:
"@hapi/hoek" "^9.0.0"
"@heroicons/vue@^2.1.1":
version "2.1.3"
resolved "https://registry.yarnpkg.com/@heroicons/vue/-/vue-2.1.3.tgz#7553aea937bf23013b44ab3787ce181a45188356"
integrity sha512-CP4ipIwFbV4NEn8ULUCN110wkV0wZq6dsViDL3HwgIh+jn5yQGlRm6QaRN+Mv+o+UsUBbRDei3Je/q0NZHf5Gg==
"@humanwhocodes/config-array@^0.11.14":
version "0.11.14"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b"

View File

@@ -2,9 +2,7 @@ package models
// Action constants for WebSocket messages.
const (
ActionConversationsListSub = "conversations_sub"
ActionConversationSub = "conversation_sub"
ActionConversationUnSub = "conversation_unsub"
ActionConversationsListSub = "conversations_list_sub"
MessageTypeMessagePropUpdate = "message_prop_update"
MessageTypeConversationPropertyUpdate = "conversation_prop_update"
MessageTypeNewMessage = "new_message"