Fix chat box and make it responsive

This commit is contained in:
marzban-dev
2025-04-16 22:12:41 +03:30
parent e2c0f7b4a2
commit 3d660af2b9
4 changed files with 83 additions and 54 deletions
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
// import // import
import AiLoading from "~/components/product/ChatBox/AiLoading.vue";
import useGetChat from "~/composables/api/chat/useGetChat"; import useGetChat from "~/composables/api/chat/useGetChat";
import ChatInput from "~/components/product/ChatBox/ChatInput.vue"; import ChatInput from "~/components/product/ChatBox/ChatInput.vue";
import { useIsMutating } from "@tanstack/vue-query"; import { useIsMutating } from "@tanstack/vue-query";
@@ -20,6 +19,8 @@ const { isLoggedIn } = useAuth();
const route = useRoute(); const route = useRoute();
const id = route.params.id as string | number; const id = route.params.id as string | number;
const isMobile = useMediaQuery("(max-width: 480px)");
const scrollToBottomTimer = ref<NodeJS.Timeout | null>(null); const scrollToBottomTimer = ref<NodeJS.Timeout | null>(null);
const chatContainerEl = ref<HTMLElement | null>(null); const chatContainerEl = ref<HTMLElement | null>(null);
@@ -31,26 +32,24 @@ const {
isPending: isChatPending, isPending: isChatPending,
isFetchingNextPage: isNextChatPagePending, isFetchingNextPage: isNextChatPagePending,
hasNextPage: hasMoreChat, hasNextPage: hasMoreChat,
fetchNextPage: loadMoreChat fetchNextPage: loadMoreChat,
} = useGetChat(id, isOpen); } = useGetChat(id, isOpen);
const isCreateMessagePending = useIsMutating({ const isCreateMessagePending = useIsMutating({
mutationKey: [MUTATION_KEYS.create_chat] mutationKey: [MUTATION_KEYS.create_chat],
}); });
const canLoadMoreChat = ref(false); const canLoadMoreChat = ref(false);
const isChatScrollLocked = useScrollLock(chatContainerEl); const isChatScrollLocked = useScrollLock(chatContainerEl);
const { y: chatContainerScrollY } = useScroll(chatContainerEl, { const { y: chatContainerScrollY } = useScroll(chatContainerEl, {
behavior: "smooth" behavior: "smooth",
}); });
useInfiniteScroll( useInfiniteScroll(
chatContainerEl, chatContainerEl,
async () => { async () => {
if (hasMoreChat.value && !isChatPending.value) { if (hasMoreChat.value && !isChatPending.value) {
lastMessageBeforeUpdate.value = chatMessages.value lastMessageBeforeUpdate.value = chatMessages.value ? chatMessages.value[0].id : 0;
? chatMessages.value[0].id
: 0;
await loadMoreChat(); await loadMoreChat();
} }
}, },
@@ -58,7 +57,7 @@ useInfiniteScroll(
distance: 10, distance: 10,
direction: "top", direction: "top",
throttle: 1000, throttle: 1000,
canLoadMore: () => canLoadMoreChat.value canLoadMore: () => canLoadMoreChat.value,
} }
); );
@@ -103,8 +102,7 @@ watch(
`#message-container-${lastMessageBeforeUpdate.value}` `#message-container-${lastMessageBeforeUpdate.value}`
) as HTMLElement; ) as HTMLElement;
lastChatMessageEl?.scrollIntoView(); lastChatMessageEl?.scrollIntoView();
chatContainerEl.value!.scrollTop = chatContainerEl.value!.scrollTop = chatContainerEl.value!.scrollTop + scrollTopOld;
chatContainerEl.value!.scrollTop + scrollTopOld;
}); });
} }
} }
@@ -121,28 +119,31 @@ whenever(
}, 2000); }, 2000);
}, },
{ {
once: true once: true,
} }
); );
</script> </script>
<template> <template>
<Transition name="fade-right-to-left"> <Transition :name="isMobile ? 'fade-down' : 'fade-right-to-left'">
<div <div
v-if="isOpen" v-if="isOpen"
class="fixed z-50 right-8 bottom-8 w-[450px] transition-all duration-500 overflow-hidden h-[700px] rounded-250 shadow-2xl shadow-black/30 pt-[40px] bg-white" class="fixed z-50 max-xs:inset-0 xs:right-8 xs:bottom-8 w-full xs:w-[450px] transition-all duration-500 overflow-hidden h-svh xs:h-[700px] xs:rounded-250 shadow-2xl shadow-black/30 pt-[40px] bg-white"
> >
<CloseButton :disabled="!!isCreateMessagePending" /> <CloseButton :disabled="!!isCreateMessagePending" />
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
<Transition name="zoom" mode="out-in"> <Transition
name="zoom"
mode="out-in"
>
<div <div
v-if="!isChatPending" v-if="!isChatPending"
class="p-4.5 h-full flex flex-col justify-between gap-4" class="p-4.5 h-full flex flex-col justify-between gap-4"
> >
<div <div
:style="{ :style="{
maskImage: 'linear-gradient(to top, transparent, black 5%, black, black)' maskImage: 'linear-gradient(to top, transparent, black 5%, black, black)',
}" }"
class="hide-scrollbar flex flex-col py-7 gap-6 h-full overflow-y-auto" class="hide-scrollbar flex flex-col py-7 gap-6 h-full overflow-y-auto"
ref="chatContainerEl" ref="chatContainerEl"
@@ -183,7 +184,11 @@ whenever(
v-else v-else
class="w-full h-full flex items-center justify-center absolute inset-0" class="w-full h-full flex items-center justify-center absolute inset-0"
> >
<AiLoading /> <NuxtImg
class="size-[250px] drop-shadow-2xl"
src="/img/heymlz/heymlz-small-idle.gif"
alt=""
/>
</div> </div>
</Transition> </Transition>
</template> </template>
@@ -191,12 +196,15 @@ whenever(
class="text-black p-6 size-full flex justify-center items-center flex-col" class="text-black p-6 size-full flex justify-center items-center flex-col"
v-else v-else
> >
<NuxtImg class="size-[250px]" src="/img/heymlz/heymlz-loading-1.gif" alt="" /> <NuxtImg
class="size-[250px] drop-shadow-2xl"
src="/img/heymlz/heymlz-small-idle.gif"
alt=""
/>
<div class="flex flex-col gap-4 items-center"> <div class="flex flex-col gap-4 items-center">
<span class="text-center typo-p-xl font-bold">سلام دوست عزیز!</span> <span class="text-center typo-p-xl font-bold">سلام دوست عزیز!</span>
<p class="text-center typo-p-md"> <p class="text-center typo-p-md">
من میتونم هر سوالی رو درمورد این محصول جواب بدم من میتونم هر سوالی رو درمورد این محصول جواب بدم اگه میخوای شروع کنیم روی دکمه زیر کلیک کن
اگه میخوای شروع کنیم روی دکمه زیر کلیک کن
</p> </p>
</div> </div>
<NuxtLink to="/signin"> <NuxtLink to="/signin">
@@ -1,7 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
// types
type Props = {
showChatButton: boolean;
};
// props
defineProps<Props>();
// state // state
const isOpen = ref(false); const isOpen = ref(false);
const isMobile = useMediaQuery("(max-width: 480px)");
const isWindowScrollLocked = useScrollLock(window);
// methods // methods
@@ -14,19 +26,29 @@ provide("isOpen", {
closeChat, closeChat,
}); });
// watches
watch([isMobile, isOpen], ([isMobile, isOpen]) => {
isWindowScrollLocked.value = isMobile && isOpen;
});
</script> </script>
<template> <template>
<button <Transition
v-if="!isOpen" name="fade"
@click="isOpen = !isOpen" :duration="150"
class="cursor-pointer z-50 fixed shadow-xl shadow-black/30 right-8 bottom-8 bg-black size-[70px] flex justify-center items-center rounded-full"
> >
<Icon <button
name="streamline:artificial-intelligence-spark" v-if="showChatButton && !isOpen"
class="**:stroke-white size-[26px]" @click="isOpen = !isOpen"
/> class="cursor-pointer z-50 fixed shadow-xl shadow-black/30 right-8 bottom-8 bg-blue-500 size-[70px] flex justify-center items-center rounded-full"
</button> >
<Icon
name="streamline:artificial-intelligence-spark"
class="**:stroke-white size-[26px]"
/>
</button>
</Transition>
<ChatBoxContainer :isOpen="isOpen" /> <ChatBoxContainer :isOpen="isOpen" />
</template> </template>
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
// types // types
import AiLoading from "~/components/product/ChatBox/AiLoading.vue";
import useCreateChatMessage from "~/composables/api/chat/useCreateChatMessage"; import useCreateChatMessage from "~/composables/api/chat/useCreateChatMessage";
import { useToast } from "~/composables/global/useToast"; import { useToast } from "~/composables/global/useToast";
@@ -14,8 +13,7 @@ const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast(); const { addToast } = useToast();
const { mutateAsync: createMessage, isPending: isCreatingMessage } = const { mutateAsync: createMessage, isPending: isCreatingMessage } = useCreateChatMessage(queryClient);
useCreateChatMessage(queryClient);
const chatInputEl = ref<HTMLInputElement | null>(null); const chatInputEl = ref<HTMLInputElement | null>(null);
@@ -46,7 +44,10 @@ const sendMessage = async () => {
<template> <template>
<div class="relative"> <div class="relative">
<div id="poda" class="poda-rotate"> <div
id="poda"
class="poda-rotate"
>
<div <div
class="glow w-full" class="glow w-full"
:class="isCreatingMessage ? '' : '!opacity-0'" :class="isCreatingMessage ? '' : '!opacity-0'"
@@ -80,11 +81,7 @@ const sendMessage = async () => {
<input <input
ref="chatInputEl" ref="chatInputEl"
:disabled="isCreatingMessage" :disabled="isCreatingMessage"
:placeholder=" :placeholder="isCreatingMessage ? 'دارم فکر میکنم...' : 'سوال خود را بپرسید'"
isCreatingMessage
? 'دارم فکر میکنم...'
: 'سوال خود را بپرسید'
"
type="text" type="text"
name="text" name="text"
class="focus:outline-none h-full typo-p-sm w-full border-none" class="focus:outline-none h-full typo-p-sm w-full border-none"
@@ -97,11 +94,11 @@ const sendMessage = async () => {
:class="isCreatingMessage ? 'bg-transparent' : 'bg-black'" :class="isCreatingMessage ? 'bg-transparent' : 'bg-black'"
> >
<TransitionGroup name="fade-down"> <TransitionGroup name="fade-down">
<AiLoading <Icon
v-if="isCreatingMessage" v-if="isCreatingMessage"
circle name="svg-spinners:wind-toy"
:size="75" size="24"
class="mb-1" class="text-black"
/> />
<Icon <Icon
v-else v-else
@@ -188,14 +185,7 @@ const sendMessage = async () => {
height: 600px; height: 600px;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: 0 0; background-position: 0 0;
background-image: conic-gradient( background-image: conic-gradient(transparent, #998fdc, transparent 10%, transparent 50%, #cf7bba, transparent 60%);
transparent,
#998fdc,
transparent 10%,
transparent 50%,
#cf7bba,
transparent 60%
);
transition: all 2s; transition: all 2s;
} }
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import useGetAccount from '~/composables/api/account/useGetAccount';
// types // types
type Props = { type Props = {
@@ -21,6 +23,7 @@ const emit = defineEmits(["textUpdate"]);
// state // state
const { $gsap: gsap } = useNuxtApp(); const { $gsap: gsap } = useNuxtApp();
const { data: account } = useGetAccount();
// methods // methods
@@ -79,26 +82,32 @@ onMounted(() => {
> >
<NuxtImg <NuxtImg
v-if="reverse" v-if="reverse"
src="/img/heymlz/footer-share.svg" src="/img/heymlz/heymlz-full-body.jpg"
class="size-full object-cover absolute"
alt="profile"
/>
<NuxtImg
v-else
:src="account?.profile_photo ?? ''"
class="size-full object-cover absolute" class="size-full object-cover absolute"
alt="profile" alt="profile"
/> />
</div> </div>
<div <div
class="rounded-150 px-4 py-3" class="rounded-150 px-4 py-3 whitespace-pre-wrap overflow-hidden"
:class=" :class="
reverse reverse
? 'bg-slate-100 text-slate-600' ? 'bg-slate-100 text-slate-600'
: 'bg-black text-white' : 'bg-black text-white'
" "
> >
<p <div
v-if="!loadingContent" v-if="!loadingContent"
:id="`chat-message-content-${id}`" :id="`chat-message-content-${id}`"
class="typo-p-sm font-normal whitespace-pre-wrap" class="typo-p-sm font-normal whitespace-pre-wrap"
v-html="content"
> >
{{ content }} </div>
</p>
<Icon v-else name="svg-spinners:3-dots-bounce" size="20" /> <Icon v-else name="svg-spinners:3-dots-bounce" size="20" />
</div> </div>