This commit is contained in:
marzban-dev
2025-01-06 19:00:55 +03:30
parent fe33bcaa52
commit b480ecd345
32 changed files with 1128 additions and 69 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ import { VueQueryDevtools } from "@tanstack/vue-query-devtools";
</div>
<ToastContainer />
<ToastViewport
class="[--viewport-padding:_25px] fixed bottom-0 right-0 flex flex-col p-[var(--viewport-padding)] gap-[10px] w-[390px] max-w-[100vw] m-0 list-none z-[2147483647] outline-none"
class="[--viewport-padding:_25px] fixed bottom-0 left-0 flex flex-col p-[var(--viewport-padding)] gap-[10px] w-[390px] max-w-[100vw] m-0 list-none z-[2147483647] outline-none"
/>
</ToastProvider>
</NuxtLayout>
+84
View File
@@ -0,0 +1,84 @@
/*
Zoom animation
*/
.zoom-leave-active,
.zoom-enter-active {
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
.zoom-enter-to,
.zoom-leave-from {
opacity: 1;
transform: scale(1) translateY(0px);
}
.zoom-enter-from,
.zoom-leave-to {
opacity: 0;
transform: scale(0.95) translateY(15px);
}
/*
Fade animation
*/
.fade-leave-active,
.fade-enter-active {
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
transform: scale(1);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
/*
FadeDown animation
*/
.fade-down-leave-active,
.fade-down-enter-active {
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
.fade-down-enter-to,
.fade-down-leave-from {
opacity: 1;
transform: translateY(0px) scale(1);
}
.fade-down-enter-from,
.fade-down-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.95);
}
/*
FadeRightToLeft animation
*/
.fade-right-to-left-leave-active,
.fade-right-to-left-enter-active {
transform-origin: bottom right;
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
.fade-right-to-left-enter-to,
.fade-right-to-left-leave-from {
opacity: 1;
transform: translateX(0px) scale(1);
}
.fade-right-to-left-enter-from,
.fade-right-to-left-leave-to {
opacity: 0;
transform: translateX(30px) scale(0.95);
}
+1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "./animations.css";
@import "./other.utils.css";
@import "./typo.utils.css";
@import "./button.comp.css";
+21 -4
View File
@@ -2,31 +2,47 @@
// types
import { useImageColor } from "~/composables/useImageColor";
type Props = {
id: number,
category: string;
count: number;
description: string;
picture: string;
darkLayer?: boolean;
}
// props
defineProps<Props>();
const props = defineProps<Props>();
const { id } = toRefs(props);
// state
const { colorObject } = useImageColor(`#category-image-${id.value}`);
</script>
<template>
<div class="relative rounded-150 overflow-hidden w-full h-[500px]">
<img
:id="`category-image-${id}`"
class="absolute object-cover size-full"
:src="picture"
alt=""
/>
<div class="bg-linear-to-t from-black/80 to-transparent absolute z-10 size-full" />
<div
v-if="darkLayer"
class="bg-linear-to-t from-black/50 to-transparent to-40% absolute z-10 size-full"
/>
<div class="absolute z-20 bottom-0 p-6 flex items-end justify-between w-full">
<div class="flex flex-col gap-2 text-white">
<div
:class="(colorObject?.isLight && !darkLayer) ? 'text-black' : 'text-white'"
class="flex flex-col gap-2"
>
<div class="typo-s-h-md">
تمام دسته ها
<span class="typo-p-xs -translate-y-1 inline-block mr-1">
@@ -39,7 +55,8 @@ defineProps<Props>();
<Icon
size="24"
name="ci:arrow-left"
class="**:stroke-white mb-1"
class="mb-1"
:class="(colorObject?.isLight && !darkLayer) ? '**:stroke-black' : '**:stroke-white'"
/>
</div>
@@ -34,7 +34,8 @@ const highlights = ref<Highlight[]>([
</script>
<template>
<section class="w-full flex-center py-[5rem] gap-[1.25rem] container">
<section class="w-full border-t-[0.5px] border-slate-200 mt-20">
<div class="w-full flex-center py-[5rem] gap-[1.25rem] container">
<template v-for="(highlight, index) in highlights" :key="index">
<div class="flex flex-col-center gap-[.75rem] w-1/4 px-5">
<div class="size-[1.5rem] flex-center">
@@ -44,7 +45,7 @@ const highlights = ref<Highlight[]>([
<span class="typo-sub-h-md text-black text-center">
{{ highlight.title }}
</span>
<p class="text-slate-500 typo-p-md text-center">
<p class="text-slate-500 typo-p-sm mt-1 text-center">
{{ highlight.description }}
</p>
</div>
@@ -55,6 +56,7 @@ const highlights = ref<Highlight[]>([
v-if="index + 1 != highlights.length"
/>
</template>
</div>
</section>
</template>
+1 -1
View File
@@ -12,7 +12,7 @@ const {} = toRefs(props);
</script>
<template>
<section class="h-[100svh] mt-20 px-20">
<section class="mt-20 px-20">
<div class="flex items-center justify-between mb-20">
<span class="typo-h-4 text-black">
مقالات اخیر سایت
@@ -0,0 +1,40 @@
<script setup lang="ts">
// types
type Props = {
circle?: boolean,
size?: number
}
// props
const props = withDefaults(defineProps<Props>(), {
size: 200
});
const { circle } = toRefs(props);
</script>
<template>
<div
:style="{
height: `${size}px`,
width: circle ? `${size}px` : '100%',
}"
class="relative flex items-center w-full justify-center shrink-0"
:class="{
'rounded-full overflow-hidden': circle,
}"
>
<img
:style="{
maskImage: 'radial-gradient(black, transparent 70%)'
}"
src="/public/img/ai-loading-2.gif"
class="size-full object-cover absolute pt-2"
alt="ai-loading"
/>
</div>
</template>
@@ -0,0 +1,174 @@
<script setup lang="ts">
// import
import AiLoading from "~/components/product/ChatBox/AiLoading.vue";
import useGetChat from "~/composables/api/chat/useGetChat";
import ChatInput from "~/components/product/ChatBox/ChatInput.vue";
import { useInfiniteScroll, useScroll, useScrollLock, whenever } from "@vueuse/core";
import { useIsMutating } from "@tanstack/vue-query";
import { MUTATION_KEYS } from "~/constants";
import CloseButton from "~/components/product/ChatBox/CloseButton.vue";
// provide-inject
const { isOpen } = inject("isOpen") as any;
// state
const id = ref(1);
const chatContainerEl = ref<HTMLElement | null>(null);
const lastMessageBeforeUpdate = ref(0);
const {
data: chat,
isPending: isChatPending,
isFetchingNextPage: isNextChatPagePending,
hasNextPage: hasMoreChat,
fetchNextPage: loadMoreChat
} = useGetChat(id, isOpen);
const isCreateMessagePending = useIsMutating({
mutationKey: [MUTATION_KEYS.create_chat]
});
const canLoadMoreChat = ref(false);
const isChatScrollLocked = useScrollLock(chatContainerEl);
const { y: chatContainerScrollY } = useScroll(chatContainerEl, {
behavior: "smooth"
});
useInfiniteScroll(
chatContainerEl,
async () => {
if (hasMoreChat.value && !isChatPending.value) {
lastMessageBeforeUpdate.value = chatMessages.value ? chatMessages.value[0].id : 0;
await loadMoreChat();
}
},
{
distance: 10,
direction: "top",
throttle: 1000,
canLoadMore: () => canLoadMoreChat.value
}
);
// method
const scrollToBottom = () => {
chatContainerScrollY.value = chatContainerEl.value?.scrollHeight ?? 0;
};
// computed
const chatMessages = computed(() => {
return chat.value?.pages.flatMap(page => {
return page.results;
}).reverse();
});
// watch
watch(() => isCreateMessagePending.value, (newValue) => {
requestAnimationFrame(() => {
scrollToBottom();
isChatScrollLocked.value = !!newValue;
});
});
watch(() => chat.value, () => {
if (canLoadMoreChat.value) {
const scrollTopOld = chatContainerEl.value!.scrollTop - 100;
requestAnimationFrame(() => {
const lastChatMessageEl = document.querySelector(`#message-container-${lastMessageBeforeUpdate.value}`) as HTMLElement;
lastChatMessageEl?.scrollIntoView();
chatContainerEl.value!.scrollTop = chatContainerEl.value!.scrollTop + scrollTopOld;
});
}
});
whenever(() => !!chatContainerEl.value, () => {
requestAnimationFrame(() => {
scrollToBottom();
});
setTimeout(() => {
canLoadMoreChat.value = true;
}, 2000);
}, {
once: true
});
</script>
<template>
<Transition name="fade-right-to-left">
<div
v-if="isOpen"
class="fixed 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"
>
<CloseButton
:disabled="!!isCreateMessagePending"
/>
<Transition
name="zoom"
mode="out-in"
>
<div
v-if="!isChatPending"
class="p-4.5 h-full flex flex-col justify-between gap-4"
>
<div
:style="{
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"
ref="chatContainerEl"
>
<div v-if="hasMoreChat" class="py-2 flex items-center justify-center">
<Icon
name="svg-spinners:3-dots-fade"
size="24"
/>
</div>
<ChatMessage
v-for="(message, index) in chatMessages"
:key="message.id"
:id="message.id"
:reverse="message.sender === 'ai'"
:content="message.content"
:isLast="chatMessages?.length === index + 1"
@textUpdate="scrollToBottom"
/>
<ChatMessage
v-if="!!isCreateMessagePending"
:id="Date.now() + 1"
reverse
content=""
isLast
@textUpdate="scrollToBottom"
:loadingContent="!!isCreateMessagePending"
/>
</div>
<ChatInput />
</div>
<div
v-else
class="w-full h-full flex items-center justify-center absolute inset-0"
>
<AiLoading />
</div>
</Transition>
</div>
</Transition>
</template>
@@ -0,0 +1,305 @@
<script setup lang="ts">
// types
import AiLoading from "~/components/product/ChatBox/AiLoading.vue";
import useCreateChatMessage from "~/composables/api/chat/useCreateChatMessage";
// state
const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast();
const { mutateAsync: createMessage, isPending: isCreatingMessage } = useCreateChatMessage(queryClient);
const chatInputEl = ref<HTMLInputElement | null>(null);
// method
const sendMessage = async () => {
const value = chatInputEl.value!.value;
if (value && value.length > 0) {
try {
chatInputEl.value!.value = "";
await createMessage({
new_message: value,
productId: 1
});
} catch (e) {
addToast({
message: "مشکلی پیش آمده",
options: {
status: "error"
}
});
}
}
};
</script>
<template>
<div class="relative">
<div id="poda" class="poda-rotate">
<div
class="glow w-full"
:class="isCreatingMessage ? '' : '!opacity-0'"
/>
<div
class="darkBorderBg w-full"
:class="isCreatingMessage ? '' : '!opacity-0'"
/>
<div
class="darkBorderBg w-full"
:class="isCreatingMessage ? '' : '!opacity-0'"
/>
<div
class="darkBorderBg w-full"
:class="isCreatingMessage ? '' : '!opacity-0'"
/>
<div
class="white w-full"
:class="isCreatingMessage ? '' : '!opacity-0'"
/>
<form
class="transition-all duration-200 relative flex items-center gap-4 w-full shadow-sm rounded-full h-[56px] border pe-3 ps-4"
:class="isCreatingMessage ? 'border-transparent shadow-black/10 bg-white/85 backdrop-blur-xl' : 'border-slate-200 shadow-transparent bg-white'"
>
<input
ref="chatInputEl"
:disabled="isCreatingMessage"
:placeholder="isCreatingMessage ? 'دارم فکر میکنم...' : 'سوال خود را بپرسید'"
type="text"
name="text"
class="focus:outline-none h-full typo-p-sm w-full border-none"
/>
<button
type="submit"
:disabled="isCreatingMessage"
@click="sendMessage"
class="disabled:cursor-default cursor-pointer outline-none transition-all duration-350 size-[35px] shrink-0 rounded-full flex items-center justify-center"
:class="isCreatingMessage ? 'bg-transparent' : 'bg-black'"
>
<TransitionGroup name="fade-down">
<AiLoading v-if="isCreatingMessage" circle :size="75" class="mb-1" />
<Icon v-else name="iconamoon:send-light" class="absolute **:stroke-white" />
</TransitionGroup>
</button>
</form>
</div>
</div>
</template>
<style>
.white,
.darkBorderBg,
.glow {
max-height: 70px;
/* max-width: 314px; */
height: 100%;
width: 100%;
position: absolute;
overflow: hidden;
z-index: -1;
/* Border Radius */
border-radius: 99999px;
filter: blur(3px);
opacity: 0.6;
transition: opacity 0.4s;
}
#poda {
display: flex;
align-items: center;
justify-content: center;
}
.white {
max-height: 63px;
max-width: 100%;
border-radius: 10px;
filter: blur(2px);
}
.white::before {
content: "";
z-index: -2;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(83deg);
position: absolute;
width: 600px;
height: 600px;
background-repeat: no-repeat;
background-position: 0 0;
filter: brightness(1.4);
background-image: conic-gradient(
transparent 0%,
#a099d8,
transparent 8%,
transparent 50%,
#dfa2da,
transparent 58%
);
/* animation: rotate 4s linear infinite; */
transition: all 2s;
}
.darkBorderBg {
max-height: 65px;
max-width: 100%;
}
.darkBorderBg::before {
content: "";
z-index: -2;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(82deg);
position: absolute;
width: 600px;
height: 600px;
background-repeat: no-repeat;
background-position: 0 0;
background-image: conic-gradient(
transparent,
#998fdc,
transparent 10%,
transparent 50%,
#cf7bba,
transparent 60%
);
transition: all 2s;
}
/*
#poda:hover > .darkBorderBg::before {
transform: translate(-50%, -50%) rotate(262deg);
}
#poda:hover > .glow::before {
transform: translate(-50%, -50%) rotate(240deg);
}
#poda:hover > .white::before {
transform: translate(-50%, -50%) rotate(263deg);
}
#poda:hover > .border-input::before {
transform: translate(-50%, -50%) rotate(250deg);
}
#poda:hover > .darkBorderBg::before {
transform: translate(-50%, -50%) rotate(-98deg);
}
#poda:hover > .glow::before {
transform: translate(-50%, -50%) rotate(-120deg);
}
#poda:hover > .white::before {
transform: translate(-50%, -50%) rotate(-97deg);
}
#poda:hover > .border-input::before {
transform: translate(-50%, -50%) rotate(-110deg);
}
*/
.poda-rotate > .darkBorderBg::before {
animation: rotate-dark-border-bg 2.5s 0.1s linear infinite;
}
.poda-rotate > .glow::before {
animation: rotate-glow 2.5s 0.1s linear infinite;
}
.poda-rotate > .white::before {
animation: rotate-white 2.5s 0.1s linear infinite;
}
/*
#poda:focus-within > .darkBorderBg::before {
transform: translate(-50%, -50%) rotate(442deg);
transition: all 4s;
}
#poda:focus-within > .glow::before {
transform: translate(-50%, -50%) rotate(420deg);
transition: all 4s;
}
#poda:focus-within > .white::before {
transform: translate(-50%, -50%) rotate(443deg);
transition: all 4s;
}
#poda:focus-within > .border-input::before {
transform: translate(-50%, -50%) rotate(430deg);
transition: all 4s;
}
*/
.glow {
overflow: hidden;
filter: blur(30px);
opacity: 0.4;
max-height: 130px;
max-width: 354px;
}
.glow:before {
content: "";
z-index: -2;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(60deg);
position: absolute;
width: 999px;
height: 999px;
background-repeat: no-repeat;
background-position: 0 0;
/*border color, change middle color*/
background-image: conic-gradient(
transparent,
#998fdc 5%,
transparent 38%,
transparent 50%,
#cf7bba 60%,
transparent 87%
);
/* change speed here */
/* animation: rotate 4s 0.3s linear infinite; */
transition: all 2s;
}
@keyframes rotate-dark-border-bg {
100% {
transform: translate(-50%, -50%) rotate(442deg);
}
}
@keyframes rotate-glow {
100% {
transform: translate(-50%, -50%) rotate(420deg);
}
}
@keyframes rotate-white {
100% {
transform: translate(-50%, -50%) rotate(443deg);
}
}
</style>
@@ -0,0 +1,102 @@
<script setup lang="ts">
// types
type Props = {
id: number,
reverse?: boolean,
content: string,
isLast?: boolean,
loadingContent?: boolean
}
// props
const props = defineProps<Props>();
const { reverse, id, isLast, content } = toRefs(props);
// emit
const emit = defineEmits(["textUpdate"]);
// state
const { $gsap: gsap } = useNuxtApp();
// method
const showMessage = () => {
gsap.fromTo(`#message-container-${id.value}`, {
rotateX: -50,
translateY: -40,
opacity: 0
}, {
rotateX: 0,
translateY: 0,
opacity: 1,
duration: 0.5,
ease: "expo.out"
});
};
// lifecycle
onMounted(() => {
if (isLast.value) {
showMessage();
}
if (reverse.value && isLast.value) {
gsap.fromTo(`#chat-message-content-${id.value}`, {
text: "",
duration: 2.5,
ease: "none"
}, {
text: { value: content.value, rtl: false },
duration: 2.5,
ease: "none",
onUpdate: () => emit("textUpdate")
});
}
});
</script>
<template>
<div class="perspective-near">
<div
:id="`message-container-${id}`"
class="flex gap-2.5 origin-top w-full"
:class="reverse ? 'flex-row-reverse' : ''"
>
<div
class="relative overflow-hidden flex items-center justify-center mt-px bg-slate-300 rounded-full size-[35px] shrink-0"
>
<img
v-if="!reverse"
src="/public/img/hero-bg.jpg"
class="size-full object-cover absolute"
alt="profile"
/>
</div>
<div
class="rounded-150 px-4 py-3"
:class="reverse ? 'bg-slate-100 text-slate-600' : 'bg-black text-white'"
>
<p
v-if="!loadingContent"
:id="`chat-message-content-${id}`"
class="typo-p-sm font-normal whitespace-pre-wrap"
>
{{ content }}
</p>
<Icon
v-else
name="svg-spinners:3-dots-move"
size="20"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
// types
type Props = {
disabled?: boolean,
}
// props
const props = defineProps<Props>();
const {} = toRefs(props);
// provide-inject
const { closeChat } = inject("isOpen") as any;
</script>
<template>
<div
class="absolute bg-white border-b border-slate-100 px-5 h-[60px] top-0 z-20 flex justify-between w-full items-center">
<span class="typo-p-sm">
چت بات هوش مصنوعی
</span>
<button
@click="closeChat"
:disabled="disabled"
class="hover:bg-slate-100 transition-all disabled:cursor-default cursor-pointer rounded-full size-[25px] flex items-center justify-center"
>
<Icon name="iconamoon:sign-times-duotone" class="**:stroke-slate-900" />
</button>
</div>
</template>
@@ -1,17 +1,21 @@
<script setup lang="ts">
// import
import type { ToastOptions } from "~/composables/useToast";
// type
type Props = {
id: number;
message: string,
description?: string
options: ToastOptions
}
// props
const props = defineProps<Props>();
const { id } = toRefs(props);
const { id, options } = toRefs(props);
// state
@@ -23,14 +27,49 @@ const open = ref(true);
const onSwipeEnd = () => {
setTimeout(() => {
destroyToast(id);
destroyToast(id.value);
}, 1000);
};
// computed
const statusIcon = computed(() => {
const status = options.value.status;
switch (status) {
case "success":
return {
name: "duo-icons:check-circle",
class: "**:fill-success-500 [filter:drop-shadow(0_0_10px_var(--color-success-500))]"
};
case "error":
return {
name: "duo-icons:alert-triangle",
class: "**:fill-danger-500 [filter:drop-shadow(0_0_10px_var(--color-danger-500))]"
};
case "info":
return {
name: "duo-icons:info",
class: "**:fill-cyan-500 [filter:drop-shadow(0_0_10px_var(--color-cyan-500))]"
};
case "warning":
return {
name: "duo-icons:alert-octagon",
class: "**:fill-warning-500 [filter:drop-shadow(0_0_10px_var(--color-warning-500))]"
};
default:
return {
name: "duo-icons:info",
class: "**:fill-slate-500 [filter:drop-shadow(0_0_10px_var(--color-slate-500))]"
};
}
});
// watch
watch(() => open.value, (value) => {
if (!value) onSwipeEnd()
if (!value) onSwipeEnd();
});
// lifecycle
@@ -38,25 +77,31 @@ watch(() => open.value, (value) => {
onMounted(() => {
setTimeout(() => {
open.value = false;
}, 4000);
}, options.value.duration ?? 4000);
});
</script>
<template>
<ToastRoot
:duration="options.duration ?? 4000"
@swipeEnd="onSwipeEnd"
v-model:open="open"
class="bg-white rounded-lg shadow-xl shadow-black/5 border-[0.5px] border-black p-[15px] grid [grid-template-areas:_'title_action'_'description_action'] grid-cols-[auto_max-content] gap-x-[15px] items-center data-[state=open]:animate-toast-in data-[state=closed]:animate-toast-hide data-[swipe=move]:translate-x-[var(--reka-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-[transform_200ms_ease-out] data-[swipe=end]:animate-toast-out"
class="bg-white shadow-md shadow-black/3 border-t-[0.5px] border-slate-200 border-black p-4 grid [grid-template-areas:_'title_action'_'description_action'] grid-cols-[auto_max-content] gap-x-[15px] items-center data-[state=open]:animate-toast-in data-[state=closed]:animate-toast-hide data-[swipe=move]:translate-x-[var(--reka-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-[transform_200ms_ease-out] data-[swipe=end]:animate-toast-out"
:class="options.description ? 'rounded-150' : 'rounded-full'"
>
<ToastTitle class="[grid-area:_title] mb-[5px] font-medium text-black text-sm">
{{ message }}
<ToastTitle
:class="[ { 'mb-1.5' : options.description } ]"
class="[grid-area:_title] font-medium text-slate-600 text-sm flex items-center gap-2"
>
<Icon :name="statusIcon.name" :class="statusIcon.class" size="24" />
<span>{{ message }}</span>
</ToastTitle>
<ToastDescription v-if="description" as-child>
<ToastDescription v-if="options.description" as-child>
<div
class="[grid-area:_description] m-0 text-black leading-[1.3]"
class="[grid-area:_description] m-0 mr-8 text-slate-500 typo-p-sm text-justify"
>
{{ description }}
{{ options.description }}
</div>
</ToastDescription>
</ToastRoot>
@@ -14,6 +14,6 @@ const { toasts } = useToast();
:key="toast.id"
:id="toast.id"
:message="toast.message"
:description="toast.description"
:options="toast.options"
/>
</template>
+1 -1
View File
@@ -13,7 +13,7 @@ export type OtpRequest = {
// methods
export const handleOtp = async (variables: OtpRequest) => {
const { data } = await axios.post<OtpRequest>(`${API_ENDPOINTS.account.send_otp}`, variables);
const { data } = await axios.post(`${API_ENDPOINTS.account.send_otp}`, variables);
return data;
};
+1 -1
View File
@@ -14,7 +14,7 @@ export type SignInRequest = {
// methods
export const handleSignIn = async (variables: SignInRequest) => {
const { data } = await axios.post<SignInRequest>(`${API_ENDPOINTS.auth.signin}/`, variables);
const { data } = await axios.post(`${API_ENDPOINTS.auth.signin}/`, variables);
return data;
};
@@ -0,0 +1,116 @@
// imports
import { QueryClient, useMutation } from "@tanstack/vue-query";
import type { InfiniteData } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, MUTATION_KEYS, QUERY_KEYS } from "~/constants";
// types
export type CreateChatMessageRequest = {
productId: string | number;
new_message: string;
};
export type CreateChatMessageResponse = Chat[]
// methods
export const handleCreateChatMessage = async (variables: CreateChatMessageRequest) => {
const { data } = await axios.post<CreateChatMessageResponse>(`${API_ENDPOINTS.chat.new_message}/${variables.productId}`, variables, {
headers: {
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`
}
});
return data;
};
// composable
const useCreateChatMessage = (queryClient: QueryClient) => {
return useMutation({
mutationKey: [MUTATION_KEYS.create_chat],
mutationFn: (variables: CreateChatMessageRequest) => handleCreateChatMessage(variables),
onMutate: (newMessage) => {
const prevData = queryClient.getQueriesData({ queryKey: [QUERY_KEYS.chat] });
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>([QUERY_KEYS.chat], (oldData) => {
const lastPage = oldData!.pages[oldData!.pages.length - 1];
return {
pages: [
{
count: lastPage.count,
next: lastPage.next,
previous: lastPage.previous,
results: [
{
id: Date.now(),
content: newMessage.new_message,
sender: "user"
}
]
},
...oldData!.pages
],
pageParams: [
...oldData!.pageParams,
{
limit: 10,
offset: 0
}
]
};
});
return { prevData: prevData ? prevData[0][1] : undefined };
},
onSuccess: (response) => {
queryClient.setQueryData<InfiniteData<ApiPaginated<Chat>>>([QUERY_KEYS.chat], (oldData) => {
if (oldData) {
const lastPage = oldData!.pages[oldData!.pages.length - 1];
return {
pages: [
{
count: lastPage.count,
next: lastPage.next,
previous: lastPage.previous,
results: {
...response[0],
id: Date.now()
}
},
...oldData!.pages
],
pageParams: [
...oldData!.pageParams,
{
limit: 10,
offset: 0
}
]
};
}
return oldData;
});
},
onError: (err, newMessage, context) => {
if (context) {
queryClient.setQueryData(
[QUERY_KEYS.chat],
context.prevData
);
}
},
onSettled: (newMessage) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.chat] });
}
});
};
export default useCreateChatMessage;
+32
View File
@@ -0,0 +1,32 @@
import type { FastAverageColorResult } from "fast-average-color";
import { FastAverageColor } from "fast-average-color";
export const useImageColor = (img: string) => {
const fac = new FastAverageColor();
const colorObject = ref<FastAverageColorResult>();
const isPending = ref(false);
const extractImageColor = async () => {
isPending.value = true;
const imageEl = document.querySelector(img) as HTMLImageElement;
try {
const color = await fac.getColorAsync(imageEl);
isPending.value = false;
colorObject.value = color;
} catch (e) {
isPending.value = false;
}
};
onMounted(() => {
extractImageColor();
});
return {
colorObject,
extractImageColor,
isPending
};
};
+9 -10
View File
@@ -1,23 +1,22 @@
export type ToastOptions = {
description?: string;
duration?: number;
status?: "success" | "error" | "info" | "warning",
}
type Toast = {
id: number;
message: string;
description?: string;
duration?: number;
}
type Props = {
message: string,
description?: string,
options?: Omit<Toast, "id" | "message">
options?: ToastOptions
}
const toasts = ref<Toast[]>([]);
export function useToast() {
const addToast = ({ message, description, options = {} }: Props) => {
const addToast = ({ message, options = {} }: Omit<Toast, "id">) => {
const id = Date.now();
toasts.value.push({ id, message, description, ...options });
toasts.value.push({ id, message, options });
};
const destroyToast = (id: number) => {
+24 -16
View File
@@ -1,33 +1,41 @@
export const API_ENDPOINTS = {
account: {
send_otp : "/accounts/send_otp",
send_otp: "/accounts/send_otp"
},
auth: {
signin: "/token",
logout: "/accounts/logout",
logout: "/accounts/logout"
},
chat: {
messages: "/chat/product",
new_message: "/chat/product"
}
};
export const QUERY_KEYS = {
chat: "chat",
};
export const MUTATION_KEYS = {
create_chat: "create_chat",
};
export const FILE_FORMATS = [
{
ext: [".jpg", ".jpeg", ".png", ".svg", ".bmp", ".webp", ".gif", ".tiff", ".heif", ".raw"],
icon: "bi:file-earmark-image",
icon: "bi:file-earmark-image"
},
{
ext: [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mpeg", ".3gp", ".mts"],
icon: "bi:file-earmark-play",
icon: "bi:file-earmark-play"
},
{
ext: [".zip", ".rar", ".tar.gz", ".dmg", ".7z", ".bz2", ".tar", ".gz", ".xz"],
icon: "bi:file-earmark-zip",
icon: "bi:file-earmark-zip"
},
{
ext: [".mp3", ".wav", ".aac", ".flac", ".ogg", ".wma", ".alac", ".m4a", ".opus", ".aiff"],
icon: "bi:file-earmark-music",
icon: "bi:file-earmark-music"
},
{
ext: [
@@ -45,9 +53,9 @@ export const FILE_FORMATS = [
".sh",
".md",
".rust",
".pl",
".pl"
],
icon: "bi:file-earmark-code",
icon: "bi:file-earmark-code"
},
{
ext: [
@@ -63,28 +71,28 @@ export const FILE_FORMATS = [
".tsv",
".ods",
".db",
".sqlite",
".sqlite"
],
icon: "bi:file-earmark-spreadsheet",
icon: "bi:file-earmark-spreadsheet"
},
{
ext: [".pdf", ".epub", ".xps", ".djvu", ".cbz", ".cbt"],
icon: "bi:file-earmark-pdf",
icon: "bi:file-earmark-pdf"
},
{
ext: [".ppt", ".pptx", ".odp", ".pps", ".ppsx", ".key"],
icon: "bi:file-earmark-slides",
icon: "bi:file-earmark-slides"
},
{
ext: [".txt", ".doc", ".docx", ".odt", ".rtf", ".wps", ".wpd"],
icon: "bi:file-earmark-text",
icon: "bi:file-earmark-text"
},
{
ext: [".lock", ".lck"],
icon: "bi:file-earmark-lock",
icon: "bi:file-earmark-lock"
},
{
ext: [".exe", ".bin", ".elf", ".dll", ".iso", ".dmg", ".swf", ".o", ".obg", ".pe", ".app"],
icon: "bi:file-earmark-binary",
},
icon: "bi:file-earmark-binary"
}
];
+1 -1
View File
@@ -47,7 +47,7 @@ export default defineNuxtConfig({
runtimeConfig: {
public: {
API_BASE_URL: "http://38.60.202.91:8000",
API_BASE_URL: "http://38.60.202.91:8001",
},
},
});
+10
View File
@@ -14,6 +14,7 @@
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.7.9",
"fast-average-color": "^9.4.0",
"gsap": "^3.12.5",
"nuxt": "^3.14.1592",
"reka-ui": "^1.0.0-alpha.6",
@@ -5162,6 +5163,15 @@
"ufo": "^1.1.2"
}
},
"node_modules/fast-average-color": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.4.0.tgz",
"integrity": "sha512-bvM8vV6YwK07dPbzFz77zJaBcfF6ABVfgNwaxVgXc2G+o0e/tzLCF9WU8Ryp1r0Nkk6JuJNsWCzbb4cLOMlB+Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+1
View File
@@ -19,6 +19,7 @@
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.7.9",
"fast-average-color": "^9.4.0",
"gsap": "^3.12.5",
"nuxt": "^3.14.1592",
"reka-ui": "^1.0.0-alpha.6",
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="container">
<div class="mt-20">
<span class="typo-h-3 text-black">دسته بندی ها</span>
</div>
<div class="grid grid-cols-3 gap-4 w-full mt-12">
<CategoryCard
:id="1"
category="یک دسته بندی تست"
picture="/img/product-1.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
dark-layer
/>
<CategoryCard
:id="2"
category="یک دسته بندی تست"
picture="/img/product-2.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="3"
category="یک دسته بندی تست"
picture="/img/product-3.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="8"
category="یک دسته بندی تست"
picture="/img/product-4.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="4"
category="یک دسته بندی تست"
picture="/img/product-5.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="5"
category="یک دسته بندی تست"
picture="/img/product-1.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="6"
category="یک دسته بندی تست"
picture="/img/product-2.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="7"
category="یک دسته بندی تست"
picture="/img/product-3.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
</div>
</div>
</template>
<script setup lang="ts">
</script>
+4 -1
View File
@@ -1,4 +1,6 @@
<script lang="ts" setup></script>
<script lang="ts" setup>
import ChatButton from "~/components/product/ChatBox/ChatButton.vue";
</script>
<template>
<div class="w-full flex flex-col">
@@ -7,5 +9,6 @@
<ProductComments />
<ProductDetails />
<RelatedProducts title="محصولات مشابه" />
<ChatButton />
</div>
</template>
+12 -2
View File
@@ -57,12 +57,22 @@ const sendOtpHandler = async () => {
phone: `0${loginInfo.value.phone}`
});
addToast({ message: "کد برای شما ارسال شد" });
addToast({
message: "کد برای شما ارسال شد",
options: {
status: "success"
}
});
showOtp.value = true;
} catch (e) {
addToast({ message: "مشکلی پیش آمده" });
addToast({
message: "مشکلی پیش آمده",
options: {
status: "error"
}
});
}
}
};
+2 -1
View File
@@ -1,10 +1,11 @@
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { ScrollToPlugin } from 'gsap/ScrollToPlugin'
import { TextPlugin } from "gsap/TextPlugin";
export default defineNuxtPlugin(() => {
if (process.client) {
gsap.registerPlugin(ScrollTrigger, ScrollToPlugin)
gsap.registerPlugin(ScrollTrigger, ScrollToPlugin, TextPlugin)
}
return {
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+7
View File
@@ -7,4 +7,11 @@ declare global {
previous: string | null;
results: T[];
};
type Chat = {
id: number,
sender: "ai" | "user",
content: string
}
}