mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-22 10:33:24 +00:00
feat: loader skeletons for conversation sidebar
fix: flickering when conversation is loading. fix: load conversation and messages using promise.all feat: enforce fixed desktop layout with 1024px viewport - minor fixes and refactors
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=1366, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@vue/reactivity": "^3.4.15",
|
||||
"@vue/runtime-core": "^3.4.15",
|
||||
"@vueup/vue-quill": "^1.2.0",
|
||||
"@vueuse/core": "^12.2.0",
|
||||
"@vueuse/core": "^12.4.0",
|
||||
"add": "^2.0.6",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
|
||||
Generated
+1
-1
@@ -66,7 +66,7 @@ importers:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(vue@3.5.13(typescript@5.7.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^12.2.0
|
||||
specifier: ^12.4.0
|
||||
version: 12.4.0(typescript@5.7.3)
|
||||
add:
|
||||
specifier: ^2.0.6
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Toaster />
|
||||
<TooltipProvider :delay-duration="100">
|
||||
<TooltipProvider :delay-duration="150">
|
||||
<div class="!font-jakarta">
|
||||
<RouterView />
|
||||
</div>
|
||||
|
||||
@@ -7,24 +7,23 @@
|
||||
}
|
||||
|
||||
.page-content {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow-y: scroll;
|
||||
padding-bottom: 100px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
html,
|
||||
body {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Theme.
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
@@ -156,6 +155,7 @@ body {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
table {
|
||||
width: 100% !important;
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ body {
|
||||
}
|
||||
|
||||
.box {
|
||||
@apply border shadow;
|
||||
@apply border shadow rounded-lg;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
@@ -187,7 +187,7 @@ body {
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #888;
|
||||
border-radius: 10px;
|
||||
border-radius: 3px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
@@ -198,7 +198,7 @@ body {
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f0f0f0;
|
||||
border-radius: 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
// End Scrollbar
|
||||
|
||||
|
||||
@@ -265,11 +265,12 @@ const sidebarOpen = useStorage('sidebarOpen', true)
|
||||
v-on:update:open="sidebarOpen = $event"
|
||||
>
|
||||
|
||||
<!-- Main sidebar holding other sidebars -->
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
class="overflow-hidden [&>[data-sidebar=sidebar]]:flex-row !border-r-0"
|
||||
>
|
||||
<!-- Left Sidebar (Icon Sidebar) -->
|
||||
<!-- Icon sidebar -->
|
||||
<Sidebar collapsible="none" class="!w-[calc(var(--sidebar-width-icon)_+_1px)] border-r">
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center text-gray-600 dark:text-gray-300">
|
||||
<span class="dot-loader mb-4">
|
||||
<span class="dot-loader">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
|
||||
@@ -12,6 +12,7 @@ const props = defineProps({
|
||||
variant: { type: String, required: false, default: 'sidebar' },
|
||||
collapsible: { type: String, required: false, default: 'offcanvas' },
|
||||
class: { type: null, required: false },
|
||||
collapseOnMobile: { type: Boolean, required: false, default: true },
|
||||
});
|
||||
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
@@ -32,7 +33,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
</div>
|
||||
|
||||
<Sheet
|
||||
v-else-if="isMobile"
|
||||
v-else-if="isMobile && collapseOnMobile"
|
||||
:open="openMobile"
|
||||
v-bind="$attrs"
|
||||
@update:open="setOpenMobile"
|
||||
@@ -54,7 +55,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="group peer hidden md:block"
|
||||
:class="cn('group peer', collapseOnMobile ? 'hidden md:block' : 'block')"
|
||||
:data-state="state"
|
||||
:data-collapsible="state === 'collapsed' ? collapsible : ''"
|
||||
:data-variant="variant"
|
||||
@@ -76,7 +77,8 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
|
||||
'duration-200 fixed inset-y-0 z-10 h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
|
||||
collapseOnMobile ? 'hidden' : '',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
@@ -91,7 +93,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
class="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
class="flex h-full w-full flex-col text-sidebar-foreground bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ const props = withDefaults(defineProps<PrimitiveProps & {
|
||||
data-sidebar="menu-action"
|
||||
:class="cn(
|
||||
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 after:md:hidden',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
|
||||
@@ -71,7 +71,7 @@ provideSidebarContext({
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
}"
|
||||
:class="cn('group/sidebar-wrapper flex min-h-svh w-full text-sidebar-foreground has-[[data-variant=inset]]:bg-sidebar', props.class)"
|
||||
:class="cn('group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar', props.class)"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="space-y-5 rounded-lg" :class="{ 'box border p-5': actions.length > 0 }">
|
||||
<div class="space-y-5 rounded-lg" :class="{ 'box p-5': actions.length > 0 }">
|
||||
<div class="space-y-5">
|
||||
<div v-for="(action, index) in actions" :key="index" class="space-y-5">
|
||||
<div v-if="index > 0">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 rounded-lg" :class="{ 'box border p-5': ruleGroup.rules?.length > 0 }">
|
||||
<div class="space-y-5 rounded-lg" :class="{ 'box p-5': ruleGroup.rules?.length > 0 }">
|
||||
<div class="space-y-5">
|
||||
<div v-for="(rule, index) in ruleGroup.rules" :key="rule" class="space-y-5">
|
||||
<div v-if="index > 0">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col box px-5 rounded-lg justify-center py-3">
|
||||
<div class="flex flex-col box px-5 justify-center py-3">
|
||||
<div class="flex justify-between space-y-1">
|
||||
<div>
|
||||
<span class="sub-title space-x-3 flex justify-center items-center">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<p class="text-base">Set permissions for this role</p>
|
||||
|
||||
<div v-for="entity in permissions" :key="entity.name" class="border box p-4 rounded-lg shadow-sm">
|
||||
<div v-for="entity in permissions" :key="entity.name" class="box p-4">
|
||||
<p class="text-lg mb-5">{{ entity.name }}</p>
|
||||
<div class="space-y-4">
|
||||
<FormField v-for="permission in entity.permissions" :key="permission.name" type="checkbox"
|
||||
|
||||
@@ -39,9 +39,9 @@
|
||||
|
||||
<!-- Messages & reply box -->
|
||||
<div>
|
||||
<div class="flex flex-col h-[calc(100vh-theme(spacing.10))]">
|
||||
<div class="flex flex-col h-screen">
|
||||
<MessageList class="flex-1 overflow-y-auto" />
|
||||
<div class="sticky bottom-0 bg-white">
|
||||
<div class="sticky bottom-0">
|
||||
<ReplyBox class="h-max" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,7 +50,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="h-screen w-full flex items-center justify-center">
|
||||
<div class="h-screen w-full flex items-center justify-center min-w-[400px]">
|
||||
<p>Select a conversation from the left panel.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
:editor="editor"
|
||||
:tippy-options="{ duration: 100 }"
|
||||
v-if="editor"
|
||||
class="bg-white p-1 box rounded-lg will-change-transform"
|
||||
class="bg-white p-1 box will-change-transform"
|
||||
>
|
||||
<div class="flex space-x-1 items-center">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Fullscreen editor -->
|
||||
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
|
||||
<DialogContent
|
||||
class="max-w-[70%] max-h-[70%] h-[90%] w-full bg-card text-card-foreground rounded-lg shadow-xl p-0 px-4 py-4"
|
||||
class="max-w-[70%] max-h-[70%] h-[90%] w-full bg-card text-card-foreground px-4 py-4"
|
||||
@escapeKeyDown="isEditorFullscreen = false"
|
||||
hide-close-button="true"
|
||||
>
|
||||
@@ -130,7 +130,7 @@
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div class="bg-card text-card-foreground rounded-lg shadow-md px-2 border pt-2 m-2">
|
||||
<div class="bg-card text-card-foreground box px-2 pt-2 m-2">
|
||||
<!-- Main Editor non-fullscreen -->
|
||||
<div v-if="!isEditorFullscreen" class="">
|
||||
<!-- Message type toggle -->
|
||||
@@ -158,7 +158,7 @@
|
||||
variant="ghost"
|
||||
@click="isEditorFullscreen = true"
|
||||
>
|
||||
<Maximize2 size="18" />
|
||||
<Maximize2 size="15" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
<MessagesSkeleton v-else />
|
||||
<MessagesSkeleton :count="20" v-else />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-1"
|
||||
>
|
||||
<div v-show="!isAtBottom" class="absolute bottom-6 right-6 z-10">
|
||||
<div v-show="!isAtBottom" class="absolute bottom-12 right-6 z-10">
|
||||
<button
|
||||
@click="handleScrollToBottom"
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-white text-primary transition-colors duration-200 hover:bg-gray-100"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const skeletonCount = 10
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="index in skeletonCount" :key="index" class="flex items-center space-x-4 mb-4" :class="index % 2 === 0 ? 'justify-end' : 'justify-start'">
|
||||
<div
|
||||
v-for="index in count"
|
||||
:key="index"
|
||||
class="flex items-center space-x-4 mb-4"
|
||||
:class="index % 2 === 0 ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<template v-if="index % 2 !== 0">
|
||||
<Skeleton class="h-12 w-12 rounded-full" />
|
||||
<div class="space-y-2">
|
||||
@@ -15,7 +14,6 @@ const skeletonCount = 10
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-4 w-[250px]" />
|
||||
@@ -26,3 +24,11 @@ const skeletonCount = 10
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
defineProps({
|
||||
count: Number,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Subject</p>
|
||||
<p>{{ conversation.subject || '-' }}</p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
<p v-else>
|
||||
{{ conversation.subject || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Reference number</p>
|
||||
<p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
<p v-else>
|
||||
{{ conversation.reference_number }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">Initiated at</p>
|
||||
<p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
<p v-else>
|
||||
{{ format(conversation.created_at, 'PPpp') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -25,10 +30,13 @@
|
||||
:actualAt="conversation.first_reply_at"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="conversation.first_reply_at">
|
||||
{{ format(conversation.first_reply_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
<div v-else>
|
||||
<p v-if="conversation.first_reply_at">
|
||||
{{ format(conversation.first_reply_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
@@ -36,32 +44,42 @@
|
||||
<p class="font-medium">Resolved at</p>
|
||||
<SlaBadge :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" />
|
||||
</div>
|
||||
<p v-if="conversation.resolved_at">
|
||||
{{ format(conversation.resolved_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
<div v-else>
|
||||
<p v-if="conversation.resolved_at">
|
||||
{{ format(conversation.resolved_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5" v-if="conversation.closed_at">
|
||||
<p class="font-medium">Closed at</p>
|
||||
<p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
<p v-else>
|
||||
{{ format(conversation.closed_at, 'PPpp') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-5">
|
||||
<p class="font-medium">SLA policy</p>
|
||||
<p v-if="conversation.sla_policy_name">
|
||||
{{ conversation.sla_policy_name }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
<div v-else>
|
||||
<p v-if="conversation.sla_policy_name">
|
||||
{{ conversation.sla_policy_name }}
|
||||
</p>
|
||||
<p v-else>-</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { format } from 'date-fns'
|
||||
import SlaBadge from '@/features/sla/SlaBadge.vue'
|
||||
defineProps({
|
||||
conversation: Object
|
||||
})
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const conversation = computed(() => conversationStore.current)
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<ConversationSideBarContact :conversation="conversationStore.current" class="p-4 border-b" />
|
||||
<ConversationSideBarContact class="p-4" />
|
||||
<Accordion
|
||||
type="multiple"
|
||||
collapsible
|
||||
@@ -127,7 +127,7 @@
|
||||
Information
|
||||
</AccordionTrigger>
|
||||
<AccordionContent class="p-4">
|
||||
<ConversationInfo :conversation="conversationStore.current" />
|
||||
<ConversationInfo />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
</AccordionTrigger>
|
||||
<AccordionContent class="p-4">
|
||||
<div
|
||||
v-if="conversationStore.current.previous_conversations.length === 0"
|
||||
v-if="conversationStore.current.previous_conversations.length === 0 || conversationStore.conversation.loading"
|
||||
class="text-center text-sm text-muted-foreground py-4"
|
||||
>
|
||||
No previous conversations
|
||||
@@ -153,7 +153,7 @@
|
||||
type: 'assigned'
|
||||
}
|
||||
}"
|
||||
class="block p-3 rounded-md hover:bg-muted transition-colors"
|
||||
class="block p-2 rounded-md hover:bg-muted"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
@@ -196,7 +196,6 @@ import { SelectTag } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import api from '@/api'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { CircleAlert, SignalLow, SignalMedium, SignalHigh, Users } from 'lucide-vue-next'
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -18,15 +18,28 @@
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mt-3">
|
||||
{{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
|
||||
<span v-if="conversationStore.conversation.loading">
|
||||
<Skeleton class="w-32 h-4" />
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
|
||||
</span>
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact?.email">
|
||||
<p class="text-sm text-muted-foreground flex gap-2 mt-1">
|
||||
<Mail class="size-3 mt-1" />
|
||||
{{ conversation.contact.email }}
|
||||
<span v-if="conversationStore.conversation.loading">
|
||||
<Skeleton class="w-32 h-4" />
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ conversation.contact.email }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground flex gap-2 mt-1">
|
||||
<Phone class="size-3 mt-1" />
|
||||
<span>
|
||||
<span v-if="conversationStore.conversation.loading">
|
||||
<Skeleton class="w-32 h-4" />
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ conversation?.contact?.phone_number || 'Not available' }}
|
||||
</span>
|
||||
</p>
|
||||
@@ -35,15 +48,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { PanelLeft } from 'lucide-vue-next'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Mail, Phone } from 'lucide-vue-next'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const emitter = useEmitter()
|
||||
|
||||
defineProps({
|
||||
conversation: Object
|
||||
})
|
||||
const conversation = computed(() => conversationStore.current)
|
||||
</script>
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="h-screen">
|
||||
<SidebarProvider :style="{ '--sidebar-width': '20rem' }" :open="conversationSidebarOpen">
|
||||
<Sidebar side="right" collapsible="offcanvas" variant="sidebar">
|
||||
<Sidebar side="right" collapsible="offcanvas" variant="sidebar" :collapseOnMobile="false">
|
||||
<SidebarContent>
|
||||
<SidebarGroup style="padding: 0">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<ConversationSideBar v-if="conversationStore.current" />
|
||||
<ConversationSideBar />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarRail>
|
||||
<button
|
||||
v-if="!conversationSidebarOpen"
|
||||
@click="toggleSidebar"
|
||||
class="absolute right-full top-16 p-1 -mr-2 rounded-l-full bg-white text-primary border hover:bg-opacity-90 transition-all duration-200 shadow-lg group"
|
||||
>
|
||||
<ChevronLeft size="16" class="group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
</SidebarRail>
|
||||
<button
|
||||
v-if="!conversationSidebarOpen"
|
||||
@click="toggleSidebar"
|
||||
class="absolute right-full top-16 p-2 rounded-l-full bg-white text-primary hover:bg-opacity-90 transition-all duration-200 shadow-lg group"
|
||||
>
|
||||
<ChevronLeft size="16" class="group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
@@ -30,7 +27,6 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import ConversationSideBar from './ConversationSideBar.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
@@ -40,11 +36,9 @@ import {
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarRail
|
||||
SidebarProvider
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const emitter = useEmitter()
|
||||
const conversationSidebarOpen = useStorage('conversationSidebarOpen', true)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col gap-x-5 box p-5 rounded-md space-y-5 bg-white">
|
||||
<div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-2xl">{{ title }}</p>
|
||||
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<DialogContent>
|
||||
<DialogHeader class="space-y-1">
|
||||
<DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
|
||||
<DialogDescription> Views let you create custom filters and save them. </DialogDescription>
|
||||
<DialogDescription> Views let you create filters and save them. </DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="grid gap-4 py-4">
|
||||
@@ -13,7 +13,7 @@
|
||||
<FormControl>
|
||||
<Input id="name" class="col-span-3" placeholder="Name" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>Enter a unique name for your view.</FormDescription>
|
||||
<FormDescription>Enter an unique name for your view.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<template>
|
||||
<div class="flex h-screen">
|
||||
<template v-if="!isSearchRoute">
|
||||
<div class="w-[390px] border-r shrink-0 overflow-y-auto">
|
||||
<ConversationList />
|
||||
</div>
|
||||
<div class="grow overflow-hidden">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex h-screen w-full" v-if="!isSearchRoute">
|
||||
<div class="w-[400px] border-r shrink-0 overflow-y-auto">
|
||||
<ConversationList />
|
||||
</div>
|
||||
<div class="grow">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
const currentContactName = computed(() => {
|
||||
if (!conversation.data?.contact) return ''
|
||||
return conversation.data?.contact.first_name + ' ' + conversation.data?.contact.last_name
|
||||
})
|
||||
|
||||
@@ -233,28 +234,6 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
return conversation.data?.cc || []
|
||||
})
|
||||
|
||||
async function fetchConversation (uuid) {
|
||||
resetCurrentConversation()
|
||||
conversation.loading = true
|
||||
// Set messages loading to true as well as both are loaded together
|
||||
messages.loading = true
|
||||
try {
|
||||
const resp = await api.getConversation(uuid)
|
||||
conversation.data = resp.data.data
|
||||
markConversationAsRead(uuid)
|
||||
resetMessages()
|
||||
} catch (error) {
|
||||
conversation.errorMessage = handleHTTPError(error).message
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: conversation.errorMessage
|
||||
})
|
||||
} finally {
|
||||
conversation.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchParticipants (uuid) {
|
||||
try {
|
||||
const resp = await api.getConversationParticipants(uuid)
|
||||
@@ -272,7 +251,25 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessages (uuid) {
|
||||
async function fetchConversation (uuid) {
|
||||
conversation.loading = true
|
||||
try {
|
||||
const resp = await api.getConversation(uuid)
|
||||
conversation.data = resp.data.data
|
||||
} catch (error) {
|
||||
conversation.errorMessage = handleHTTPError(error).message
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: conversation.errorMessage
|
||||
})
|
||||
} finally {
|
||||
conversation.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessages (uuid, reset = false) {
|
||||
if (reset) resetMessages()
|
||||
messages.loading = true
|
||||
try {
|
||||
const response = await api.getConversationMessages(uuid, { page: messages.page, page_size: MESSAGE_LIST_PAGE_SIZE })
|
||||
@@ -288,7 +285,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
if (newMessages.length === 0 && messages.page === 1) messages.data = []
|
||||
if (result.total_pages <= messages.page) messages.hasMore = false
|
||||
else messages.hasMore = true
|
||||
// Prepend new messages to the list
|
||||
messages.data.unshift(...newMessages)
|
||||
// Mark conversation as read
|
||||
markConversationAsRead(uuid)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
|
||||
@@ -26,8 +26,9 @@ export const useUserStore = defineStore('user', () => {
|
||||
const teams = computed(() => user.value.teams || [])
|
||||
|
||||
const getFullName = computed(() => {
|
||||
if (!user.value.first_name && !user.value.last_name) return ''
|
||||
return `${user.value.first_name || ''} ${user.value.last_name || ''}`.trim()
|
||||
const first = user.value.first_name ?? ''
|
||||
const last = user.value.last_name ?? ''
|
||||
return `${first} ${last}`.trim()
|
||||
})
|
||||
|
||||
const getInitials = computed(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<div class="grow">
|
||||
<Conversation v-if="conversationStore.messages.data" />
|
||||
<Conversation v-if="conversationStore.current || conversationStore.conversation.loading" />
|
||||
</div>
|
||||
<div class="grow-0">
|
||||
<div>
|
||||
<ConversationSideBarWrapper
|
||||
v-if="conversationStore.current || conversationStore.conversation.loading"
|
||||
/>
|
||||
@@ -24,12 +24,12 @@ const props = defineProps({
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
const fetchConversation = async (uuid) => {
|
||||
await conversationStore.fetchConversation(uuid)
|
||||
await conversationStore.fetchMessages(uuid)
|
||||
await Promise.all([
|
||||
conversationStore.fetchParticipants(uuid),
|
||||
conversationStore.updateAssigneeLastSeen(uuid)
|
||||
conversationStore.fetchConversation(uuid),
|
||||
conversationStore.fetchMessages(uuid, true),
|
||||
conversationStore.fetchParticipants(uuid)
|
||||
])
|
||||
await conversationStore.updateAssigneeLastSeen(uuid)
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ConversationPlaceholder v-if="!conversationStore.current" />
|
||||
<ConversationPlaceholder v-if="!conversationStore.current && !conversationStore.conversation.loading" />
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
height: {
|
||||
screen: '100dvh',
|
||||
},
|
||||
minHeight: {
|
||||
screen: '100dvh',
|
||||
},
|
||||
fontFamily: {
|
||||
jakarta: ['Plus Jakarta Sans', 'Helvetica Neue', 'sans-serif'],
|
||||
inter: ['Inter', 'Helvetica Neue', 'sans-serif'],
|
||||
|
||||
Reference in New Issue
Block a user