This commit is contained in:
Parsa Nazer
2025-01-09 20:18:45 +03:30
52 changed files with 2274 additions and 282 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>
+104
View File
@@ -0,0 +1,104 @@
/*
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;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/*
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);
}
/*
FadeRight animation
*/
.fade-right-leave-active,
.fade-right-enter-active {
transform-origin: right;
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
.fade-right-enter-to,
.fade-right-leave-from {
opacity: 1;
transform: translateX(0);
}
.fade-right-enter-from,
.fade-right-leave-to {
opacity: 0;
transform: translateX(100%);
}
+13 -8
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "./animations.css";
@import "./other.utils.css";
@import "./typo.utils.css";
@import "./button.comp.css";
@@ -166,38 +167,42 @@
@keyframes contentShow {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
transform: scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
transform: scale(1);
}
}
@keyframes toastHide {
from { opacity: 1 }
to { opacity: 0 }
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes toastSlideIn {
from {
opacity: 0;
transform: translateX(calc(100% + var(--viewport-padding)))
transform: translateX(calc(100% + var(--viewport-padding)));
}
to {
opacity: 1;
transform: translateX(0)
transform: translateX(0);
}
}
@keyframes toastSlideOut {
from {
opacity: 1;
transform: translateX(var(--reka-toast-swipe-end-x))
transform: translateX(var(--reka-toast-swipe-end-x));
}
to {
opacity: 0;
transform: translateX(calc(100% + var(--viewport-padding)))
transform: translateX(calc(100% + var(--viewport-padding)));
}
}
}
+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>
+105
View File
@@ -0,0 +1,105 @@
<script setup lang="ts">
// types
type Option = {
name: string;
children: { name: string }[];
};
type Props = {
options: Option[];
modelValue: string[];
};
// props
const props = defineProps<Props>();
const { modelValue } = toRefs(props);
// emit
const emit = defineEmits(["update:modelValue"]);
// state
const value = ref<string[]>([]);
// watch
watch(
() => value.value,
(newValue) => {
emit("update:modelValue", newValue);
}
);
watch(
() => modelValue.value,
(newValue: string[]) => {
value.value = newValue;
},
{
immediate: true,
}
);
</script>
<template>
<ComboboxRoot class="relative" dir="rtl" :multiple="true" v-model="value">
<ComboboxAnchor
class="w-full inline-flex items-center justify-between rounded-full border border-slate-200 hover:border-slate-300 focus:border-slate-800 px-[1rem] text-sm leading-none py-3.5 gap-[5px] bg-slate-50 text-black hover:bg-white/90 transition-all data-[placeholder]:text-black/80 typo-label-sm outline-none"
>
<ComboboxInput
class="!bg-transparent outline-none text-black h-full selection:bg-slate-100 placeholder-stone-400"
placeholder="جست و جوی دسته بندی"
/>
<ComboboxTrigger>
<Icon name="ci:chevron-down" class="size-5" />
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxContent
class="absolute z-10 w-full mt-1.5 bg-slate-50 overflow-hidden rounded-3xl shadow-sm border border-slate-200 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=bottom]:animate-slideUpAndFade"
>
<ComboboxViewport class="p-[1rem]">
<ComboboxEmpty
class="text-mauve8 text-xs font-medium text-center py-5"
/>
<template v-for="(group, index) in options" :key="group.name">
<ComboboxGroup>
<ComboboxSeparator
v-if="index !== 0"
class="h-[1px] bg-slate-200 m-[1rem]"
/>
<ComboboxLabel
class="px-[1.2rem] w-full text-sm bg-black text-white rounded-full leading-[25px] py-1.5"
>
{{ group.name }}
</ComboboxLabel>
<ComboboxItem
v-for="option in group.children"
:key="option.name"
:value="option.name"
class="text-sm leading-none text-black/90 my-1.5 rounded-full hover:!bg-slate-200 flex items-center py-2.5 px-[1.2rem] relative select-none data-[disabled]:text-slate-50 data-[disabled]:pointer-events-none"
>
<ComboboxItemIndicator
class="absolute left-3 w-[25px] inline-flex items-center justify-center"
>
<Icon name="ci:checkmark" size="18" />
</ComboboxItemIndicator>
<span>
{{ option.name }}
</span>
</ComboboxItem>
</ComboboxGroup>
</template>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>
</template>
<style scoped></style>
@@ -34,27 +34,29 @@ const highlights = ref<Highlight[]>([
</script>
<template>
<section 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">
<Icon :name="highlight.icon" />
</div>
<div class="w-full flex-col-center gap-[.25rem]">
<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">
<Icon :name="highlight.icon" />
</div>
<div class="w-full flex-col-center gap-[.25rem]">
<span class="typo-sub-h-md text-black text-center">
{{ highlight.title }}
</span>
<p class="text-slate-500 typo-p-md text-center">
{{ highlight.description }}
</p>
<p class="text-slate-500 typo-p-sm mt-1 text-center">
{{ highlight.description }}
</p>
</div>
</div>
</div>
<div
class="w-[1px] h-[5rem] bg-slate-200"
v-if="index + 1 != highlights.length"
/>
</template>
<div
class="w-[1px] h-[5rem] bg-slate-200"
v-if="index + 1 != highlights.length"
/>
</template>
</div>
</section>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
// types
type Props = {
title: string;
};
defineProps<Props>();
// state
const isSideShow = ref(false);
const isLocked = useScrollLock(window);
// watch
watch(
() => isSideShow.value,
(newValue) => {
if (newValue) {
isLocked.value = true;
}
}
);
</script>
<template>
<div class="relative z-[90]">
<div @click="isSideShow = true">
<slot name="button" />
</div>
<div v-show="isSideShow" class="fixed inset-0">
<Transition name="fade">
<div
v-if="isSideShow"
id="side-overlay"
class="size-full bg-black/20"
@click="isSideShow = !isSideShow"
></div>
</Transition>
<Transition name="fade-right">
<div
v-if="isSideShow"
id="side-content"
class="hidden md:flex w-1/3 bg-white h-full rounded-e-[1.5rem] overflow-hidden absolute top-0 min-md:flex-col"
>
<div
class="w-full flex justify-between items-center py-[2.5rem] px-[3rem]"
>
<span class="typo-h-5">
{{ title }}
</span>
<button
@click="isSideShow = !isSideShow"
class="size-[3.5rem] -me-5 flex-center rounded-full cursor-pointer"
>
<Icon
name="ci:close"
size="24"
class="**:stroke-black"
/>
</button>
</div>
<div
class="size-full flex flex-col grow overflow-y-auto p-[3rem] pt-0"
>
<slot />
</div>
</div>
</Transition>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,187 @@
<script setup lang="ts">
// imports
import { PRODUCT_RANGE } from "~/constants";
// state
const params = useUrlSearchParams("history");
const sort_filter = ref(["جدیدترین ها", "گران ترین ها", "ارزان ترین ها"]);
const options = [
{
name: "میوه",
children: [
{ name: "سیب" },
{ name: "موز" },
{ name: "پرتقال" },
{ name: "طالبی" },
{ name: "انگور" },
{ name: "هندوانه" },
{ name: "خربزه" },
{ name: "گلابی" },
],
},
{
name: "سبزیجات",
children: [
{ name: "کلم" },
{ name: "بروکلی" },
{ name: "هویج" },
{ name: "کاهو" },
{ name: "اسفناج" },
{ name: "چوی بوک" },
{ name: "گل کلم" },
{ name: "سیب زمینی" },
],
},
];
// methods
const resetFilters = () => {
params.sort = null;
params.categories = [];
params.range = [PRODUCT_RANGE.min, PRODUCT_RANGE.max];
params.has_discount = false;
params.in_stock = false;
};
</script>
<template>
<div class="size-full flex flex-col gap-14 justify-between">
<div class="w-full flex flex-col gap-10">
<div class="flex items-center justify-between w-full gap-5">
<div
class="flex items-center justify-start gap-2 text-lg w-full"
>
<Icon name="ci:filter-list" size="24" />
ترتیب بر اساس
</div>
<div class="w-full flex items-start justify-end gap-2">
<button
v-for="(sort, index) in sort_filter"
:key="index"
@click="params.sort = sort"
:class="
params.sort == sort
? 'bg-black text-white'
: 'bg-slate-100'
"
class="py-1 px-3 cursor-pointer text-nowrap transition-all rounded-full text-sm"
>
{{ sort }}
</button>
</div>
</div>
<div class="flex flex-col w-full gap-5">
<div
class="flex items-center justify-start gap-2 text-lg w-full"
>
<Icon name="ci:grid" size="24" />
دسته بندی
</div>
<ComboBox :options="options" v-model="params.categories" />
<div class="w-full flex flex-wrap gap-2 px-[1rem]">
<span
v-for="(sort_param, index) in params.categories"
:key="index"
class="py-1 px-3 cursor-pointer text-nowrap bg-slate-100 rounded-full text-sm"
>
{{ sort_param }}
</span>
</div>
</div>
<div class="flex flex-col w-full gap-5">
<div
class="flex items-center justify-start gap-2 text-lg w-full"
>
<Icon name="ci:scan-box" size="24" />
محدوده قیمت
</div>
<SliderRoot
v-model="params.range"
class="relative flex items-center select-none touch-none h-5"
dir="rtl"
:min="PRODUCT_RANGE.min"
:max="PRODUCT_RANGE.max"
:step="1"
>
<SliderTrack
class="bg-black/10 relative grow rounded-full h-[3px]"
>
<SliderRange
class="absolute bg-black rounded-full h-full"
/>
</SliderTrack>
<SliderThumb
v-for="thumb in Object.keys(PRODUCT_RANGE)"
:key="thumb"
class="block w-5 h-5 bg-white border-2 border-slate-400 rounded-[10px] hover:bg-slate-100 focus:outline-none focus:shadow-[0_0_0_5px] focus:shadow-black/60"
/>
</SliderRoot>
<div class="flex items-center justify-between">
<div class="flex-center gap-2">
<span class="text-sm text-black">حداقل</span>
<span class="text-sm text-black">
{{
"range" in params
? params.range[0].toLocaleString()
: PRODUCT_RANGE.min
}}
</span>
</div>
<div class="flex-center gap-2">
<span class="text-sm text-black">حداکثر</span>
<span class="text-sm text-black">
{{
"range" in params
? params.range[1].toLocaleString()
: PRODUCT_RANGE.max
}}
</span>
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-5">
<span class="text-black">فقط کالاهای تخفیف دار</span>
<SwitchRoot
v-model="params.has_discount"
class="w-[3rem] h-[1.8rem] flex data-[state=unchecked]:bg-slate-200 data-[state=checked]:bg-stone-800 border border-slate-300 data-[state=checked]:border-stone-700 rounded-full relative transition-all focus-within:outline-none"
>
<SwitchThumb
class="size-6 my-auto bg-white text-sm ms-1 flex items-center justify-center shadow-xl rounded-full transition-transform translate-x-0.5 will-change-transform data-[state=checked]:-translate-x-[68%]"
/>
</SwitchRoot>
</div>
<div class="flex items-center justify-between w-full gap-5">
<span class="text-black">فقط کالاهای موجود</span>
<SwitchRoot
v-model="params.in_stock"
class="w-[3rem] h-[1.8rem] flex data-[state=unchecked]:bg-slate-200 data-[state=checked]:bg-stone-800 border border-slate-300 data-[state=checked]:border-stone-700 rounded-full relative transition-all focus-within:outline-none"
>
<SwitchThumb
class="size-6 my-auto bg-white text-sm ms-1 flex items-center justify-center shadow-xl rounded-full transition-transform translate-x-0.5 will-change-transform data-[state=checked]:-translate-x-[68%]"
/>
</SwitchRoot>
</div>
</div>
<Button
end-icon="ci:close"
variant="solid"
@click="resetFilters"
class="rounded-full py-4 !cursor-pointer"
>
بازنشانی به پیش فرض
</Button>
</div>
</template>
<style scoped></style>
+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">
مقالات اخیر سایت
+29 -25
View File
@@ -1,9 +1,4 @@
<script setup lang="ts">
// import
import { useDraggable } from "@vueuse/core";
// state
const clipPathPercent = ref(49);
@@ -13,39 +8,43 @@ const previewContainerEl = ref<HTMLElement | null>(null);
const { x: dragAxisX } = useDraggable(draggableEl, {
initialValue: { x: 0, y: 0 },
axis: "x"
axis: "x",
});
// watch
watch(() => dragAxisX.value, (newValue) => {
const clientRect = previewContainerEl.value?.getBoundingClientRect()!;
const percent = clientRect.width / 100;
const clipPercent = (newValue - clientRect.x - 8) / percent;
if (clipPercent >= 5 && clipPercent <= 95) {
clipPathPercent.value = clipPercent;
watch(
() => dragAxisX.value,
(newValue) => {
const clientRect = previewContainerEl.value?.getBoundingClientRect()!;
const percent = clientRect.width / 100;
const clipPercent = (newValue - clientRect.x - 8) / percent;
if (clipPercent >= 5 && clipPercent <= 95) {
clipPathPercent.value = clipPercent;
}
}
});
);
</script>
<template>
<div class="container">
<div class="flex flex-col items-center gap-3 mb-16">
<span class="typo-p-md text-slate-500">یک متن تست لورم</span>
<span class="typo-h-3 text-black">تفاوت محصول را ببینید در اینجا</span>
<span class="typo-h-3 text-black"
>تفاوت محصول را ببینید در اینجا</span
>
</div>
<div ref="previewContainerEl" class="rounded-200 overflow-hidden h-[90svh] relative">
<div
ref="previewContainerEl"
class="rounded-200 overflow-hidden h-[90svh] relative"
>
<img
src="/img/hero-bg.jpg"
class="select-none absolute size-full object-cover"
alt=""
/>
<div
class="absolute size-full right-0 w-full"
>
<div class="absolute size-full right-0 w-full">
<img
src="/img/hero-bg.jpg"
class="overlay-image select-none absolute object-cover size-full hue-rotate-200 brightness-35"
@@ -53,7 +52,7 @@ watch(() => dragAxisX.value, (newValue) => {
/>
<div
:style="{
left: `${clipPathPercent}%`
left: `${clipPathPercent}%`,
}"
ref="draggableEl"
class="select-none w-2 h-full bg-white absolute left-0 flex items-center justify-center"
@@ -61,12 +60,18 @@ watch(() => dragAxisX.value, (newValue) => {
<div
class="cursor-grab hover:scale-115 transition-transform rounded-full absolute bg-white size-11 flex items-center justify-center"
>
<Icon name="ci:arrows" size="24" class="**:stroke-black" />
<Icon
name="ci:arrows"
size="24"
class="**:stroke-black"
/>
</div>
</div>
</div>
<div class="absolute bottom-0 p-10 w-full flex justify-between items-end bg-linear-to-t from-black/55 to-transparent">
<div
class="absolute bottom-0 p-10 w-full flex justify-between items-end bg-linear-to-t from-black/55 to-transparent"
>
<div class="flex flex-col gap-2 text-white">
<span class="typo-p-md">رنگ محصول</span>
<span class="typo-h-3">نارنجی</span>
@@ -76,7 +81,6 @@ watch(() => dragAxisX.value, (newValue) => {
<span class="typo-h-3">سفید</span>
</div>
</div>
</div>
</div>
</template>
@@ -90,4 +94,4 @@ watch(() => dragAxisX.value, (newValue) => {
v-bind('clipPathPercent + "%"') 100%
);
}
</style>
</style>
@@ -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,179 @@
<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 { 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,45 @@
<script setup lang="ts">
// types
type Props = {}
// props
const props = defineProps<Props>();
const {} = toRefs(props);
// state
const isOpen = ref(false);
// method
const closeChat = () => isOpen.value = false;
// provide-inject
provide("isOpen", {
isOpen,
closeChat
});
</script>
<template>
<button
v-if="!isOpen"
@click="isOpen = !isOpen"
class="cursor-pointer fixed shadow-xl shadow-black/30 right-8 bottom-8 bg-black size-[70px] flex justify-center items-center rounded-full"
>
<Icon
name="streamline:artificial-intelligence-spark"
class="**:stroke-white"
size="26"
/>
</button>
<ChatBoxContainer :isOpen="isOpen" />
</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>
+6 -66
View File
@@ -1,78 +1,18 @@
<script setup lang="ts"></script>
<template>
<DialogRoot modal>
<DialogTrigger>
<SideModal title="فیلتر محصولات">
<template #button>
<Button
end-icon="ci:filter"
variant="secondary"
class="rounded-full"
class="rounded-full py-4 !bg-slate-100 !cursor-pointer"
>
فیلتر محصولات
</Button>
</DialogTrigger>
<DialogPortal to="#teleports">
<DialogOverlay
class="bg-black/60 backdrop-blur-sm data-[state=open]:animate-overlay-show fixed inset-0 z-30"
/>
<DialogContent
class="data-[state=open]:animate-content-show absolute top-1/2 left-1/2 max-h-[85vh] w-[90vw] max-w-[450px] -translate-x-1/2 -translate-y-1/2 rounded-[6px] bg-white p-[25px] shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] focus:outline-none z-[100]"
>
<DialogTitle
class="flex items-center typo-h-4 text-black font-semibold"
>
Edit profile
</DialogTitle>
<DialogDescription
class="text-mauve11 mt-[10px] mb-5 text-sm leading-normal"
>
Make changes to your profile here. Click save when you're
done.
</DialogDescription>
<fieldset class="mb-[15px] flex items-center gap-5">
<label
class="text-black w-[90px] text-right text-sm"
for="name"
>
Name
</label>
<input
id="name"
class="text-black bg-stone-50 shadow-green7 focus:shadow-gray-100 inline-flex h-[35px] w-full flex-1 items-center justify-center rounded-lg px-[10px] text-sm leading-none shadow-[0_0_0_1px] outline-none focus:shadow-[0_0_0_2px]"
defaultValue="Pedro Duarte"
/>
</fieldset>
<fieldset class="mb-[15px] flex items-center gap-5">
<label
class="text-black w-[90px] text-right text-sm"
for="username"
>
Username
</label>
<input
id="username"
class="text-black bg-stone-50 shadow-green7 focus:shadow-gray-100 inline-flex h-[35px] w-full flex-1 items-center justify-center rounded-lg px-[10px] text-sm leading-none shadow-[0_0_0_1px] outline-none focus:shadow-[0_0_0_2px]"
defaultValue="@peduarte"
/>
</fieldset>
<div class="mt-[25px] flex justify-end">
<DialogClose as-child>
<button
class="bg-green4 text-green11 text-sm hover:bg-green5 focus:shadow-green7 inline-flex h-[35px] items-center justify-center rounded-lg px-[15px] font-semibold leading-none focus:shadow-[0_0_0_2px] focus:outline-none"
>
Save changes
</button>
</DialogClose>
</div>
<DialogClose
class="text-black hover:bg-green4 focus:shadow-green7 absolute top-[10px] right-[10px] inline-flex h-[25px] w-[25px] appearance-none items-center justify-center rounded-full focus:shadow-[0_0_0_2px] focus:outline-none"
aria-label="Close"
>
<Icon name="ci:close" />
</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<FilterProducts />
</SideModal>
</template>
<style lang="scss" scoped></style>
@@ -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,33 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type CreateBranchRequest = {
name: string;
description: string;
};
// methods
export const handleCreateBranch = async ({ name, description }: CreateBranchRequest) => {
const payload: CreateBranchRequest = {
name,
description,
};
await axios.post<CreateBranchRequest>(API_ENDPOINTS.branch.createBranch, payload);
};
// composable
const useCreateBranch = () => {
return useMutation({
mutationFn: (data: CreateBranchRequest) => handleCreateBranch(data),
});
};
export default useCreateBranch;
@@ -0,0 +1,54 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
import type { ComputedRef } from "vue";
// types
export type GetBranchResponse = Branch;
// methods
export const handleGetBranch = async (
branchId: string,
page: string | undefined,
folderId: string | undefined,
sort: string | undefined,
signal: AbortSignal
) => {
const { data } = await axios.get<GetBranchResponse>(`${API_ENDPOINTS.branch.get}/${branchId}`, {
signal,
params: {
sort_by: sort,
folder_id: folderId,
offset: ((!!page ? Number(page) : 1) * 20) - 20,
limit: 20
}
});
return data;
};
// composable
const useGetBranch = (
branchId: ComputedRef<string>,
page: ComputedRef<string | undefined>,
folderId: ComputedRef<string | undefined>,
sort: ComputedRef<string | undefined>
) => {
return useQuery({
queryKey: [QUERY_KEYS.branch, branchId, page, folderId, sort],
queryFn: ({ signal }) => handleGetBranch(
branchId.value,
page.value,
folderId.value,
sort.value,
signal
)
});
};
export default useGetBranch;
@@ -0,0 +1,29 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetBranchesResponse = Branch[];
// methods
export const handleGetBranches = async () => {
const { data } = await axios.get<GetBranchesResponse>(`${API_ENDPOINTS.branch.getAll}`);
return data;
};
// composable
const useGetBranches = () => {
return useQuery({
staleTime: 60 * 1000,
queryKey: [QUERY_KEYS.branches],
queryFn: () => handleGetBranches()
});
};
export default useGetBranches;
@@ -0,0 +1,31 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetUserBranchesResponse = Branch[];
// methods
export const handleGetUserBranches = async () => {
const { data } = await axios.get<GetUserBranchesResponse>(
`${API_ENDPOINTS.branch.getUserBranches}`
);
return data;
};
// composable
const useGetUserBranches = () => {
return useQuery({
staleTime: 60 * 1000,
queryKey: [QUERY_KEYS.userBranches],
queryFn: () => handleGetUserBranches(),
});
};
export default useGetUserBranches;
@@ -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;
@@ -0,0 +1,60 @@
// imports
import { useInfiniteQuery, useQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
import type { ComputedRef } from "vue";
// types
export type GetBranchResponse = ApiPaginated<Chat>;
// methods
export const handleGetChat = async ({ productId, limit, offset }: {
productId: number | string,
limit: number,
offset: number
}) => {
const { data } = await axios.get<GetBranchResponse>(`${API_ENDPOINTS.chat.messages}/${productId}`, {
params: {
offset,
limit
},
headers: {
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`
}
});
return data;
};
// composable
const useGetBranch = (
productId: Ref<string | number>,
enabled: Ref<boolean>
) => {
return useInfiniteQuery({
enabled,
queryKey: [QUERY_KEYS.chat],
initialPageParam: {
limit: 10,
offset: 0
},
queryFn: ({ pageParam }) => handleGetChat({
limit: pageParam.limit,
offset: pageParam.offset,
productId: productId.value
}),
getNextPageParam: (lastPage, pages) => {
if (!lastPage.next) return undefined;
return {
limit: 10,
offset: pages.length * 10
};
}
});
};
export default useGetBranch;
@@ -0,0 +1,45 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type AddDocRequest = {
name: string,
parent?: string,
branch: string | undefined,
type: {
title: "File" | "Folder",
icon: "bi:folder" | "bi:file-earmark"
},
content: File | undefined
};
// methods
export const handleAddDoc = async (variables: AddDocRequest & { updateUploadProgress: (p: number) => void }) => {
const { data } = await axios.post<AddDocRequest>(`${API_ENDPOINTS.branch.getDoc}/`, {
...variables,
type: variables.type.title.toLocaleLowerCase()
}, {
onUploadProgress: (progressEvent) => {
variables.updateUploadProgress(progressEvent.progress!);
},
headers: {
"Content-Type": "multipart/form-data"
}
});
return data;
};
// composable
const useAddDoc = () => {
return useMutation({
mutationFn: (variables: AddDocRequest & { updateUploadProgress: (p: number) => void }) => handleAddDoc(variables)
});
};
export default useAddDoc;
@@ -0,0 +1,27 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type DeleteDocRequest = {
id: number
};
// methods
export const handleDeleteDoc = async ({ id }: { id: string | undefined }) => {
await axios.delete<DeleteDocRequest>(`${API_ENDPOINTS.branch.getDoc}/${id}`);
};
// composable
const useDeleteDoc = (id: Ref<string | undefined>) => {
return useMutation({
mutationFn: () => handleDeleteDoc({ id: id.value })
});
};
export default useDeleteDoc;
@@ -0,0 +1,29 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type EditDocRequest = {
id: number
};
// methods
export const handleEditDoc = async ({ id, name }: { id: string | undefined, name: string | undefined }) => {
await axios.patch<EditDocRequest>(`${API_ENDPOINTS.branch.getDoc}/${id}`, { name });
};
// composable
const useEditDoc = (id: Ref<string | undefined>) => {
return useMutation({
mutationFn: ({ name }: { name: Ref<string | undefined> }) => {
return handleEditDoc({ id: id.value, name: name.value });
}
});
};
export default useEditDoc;
@@ -0,0 +1,33 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetDocResponse = DocumentStructure;
// methods
export const handleGetDoc = async (id : string | undefined) => {
const { data } = await axios.get<GetDocResponse>(`${API_ENDPOINTS.branch.getDoc}/${id}`);
return data;
};
// composable
const useGetDoc = (id: ComputedRef<string | undefined>) => {
const enabled = computed(() => {
return !!id.value
});
return useQuery({
enabled,
queryKey: [QUERY_KEYS.document, id],
queryFn: () => handleGetDoc(id.value)
});
};
export default useGetDoc;
@@ -0,0 +1,32 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type MoveDocRequest = {
itemsToMove: number[] | string[],
parent: number | string
};
// methods
export const handleMoveDoc = async ({ itemsToMove, parent }: MoveDocRequest) => {
const apiUrl = `${API_ENDPOINTS.branch.moveDoc}?new_parent_id=${parent}&${itemsToMove.map(i => `patch_list=${i}&`)}`
const splittedUrl = apiUrl.split("");
splittedUrl.pop()
await axios.patch<MoveDocRequest>(splittedUrl.join("").replaceAll(",", ""));
};
// composable
const useMoveDoc = () => {
return useMutation({
mutationFn: (variables: MoveDocRequest) => handleMoveDoc(variables)
});
};
export default useMoveDoc;
@@ -0,0 +1,40 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS } from "~/constants";
// types
export type ReplyDocRequest = {
user_id: number;
message: string;
reply_id: number;
};
export type ReplyDocResponse = {
chat_id: number;
};
// methods
export const handleReplyDoc = async ({ user_id, message, reply_id }: ReplyDocRequest) => {
const payload = {
user_id,
message,
item_id: reply_id,
};
const { data } = await axios.post<ReplyDocResponse>(API_ENDPOINTS.branch.replyDoc, payload);
return data;
};
// composable
const useReplyDoc = () => {
return useMutation({
mutationFn: (data: ReplyDocRequest) => handleReplyDoc(data),
});
};
export default useReplyDoc;
@@ -0,0 +1,78 @@
// imports
import { useInfiniteQuery } from "@tanstack/vue-query";
import axios from "~/configs/axios.config";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
import type { ComputedRef } from "vue";
// types
export type SearchFileResponse = Branch;
type HandlerProps = typeof initialPageParam & {
signal: AbortSignal,
search: string,
id: number | undefined;
sort: string | undefined;
}
// state
const initialPageParam = {
limit: 10,
offset: 0
};
// methods
export const handleSearchFile = async ({ search, offset, limit, id, signal, sort }: HandlerProps) => {
const { data } = await axios.get<SearchFileResponse>(`${API_ENDPOINTS.branch.get}/${id}`, {
params: {
offset,
limit,
search,
sort_by: sort
},
signal
});
return data;
};
// composable
const useSearchFile = (search: Ref<string>, id: Ref<number | undefined>, sort: ComputedRef<string | undefined>) => {
const enabled = computed(() => {
return search.value.trim() != "" && !!id.value;
});
return useInfiniteQuery({
enabled,
retry: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
queryKey: [QUERY_KEYS.searchFile, search, id, sort],
queryFn: ({ pageParam, signal }) => handleSearchFile({
...pageParam,
signal,
search: search.value,
sort: sort.value,
id: id.value
}),
initialPageParam,
getNextPageParam: (lastPage, pages) => {
const page = pages.length + 1;
const limit = initialPageParam.limit;
const nextPageParams = {
offset: page * limit - limit,
limit
};
return lastPage?.structure.next ? nextPageParams : undefined;
}
});
};
export default useSearchFile;
+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) => {
+16 -80
View File
@@ -1,90 +1,26 @@
export const API_ENDPOINTS = {
account : {
send_otp : "/accounts/send_otp",
account: {
send_otp: "/accounts/send_otp",
},
auth: {
signin: "/token",
logout: "/accounts/logout",
}
},
chat: {
messages: "/chat/product",
new_message: "/chat/product",
},
};
export const QUERY_KEYS = {
chat: "chat",
};
export const FILE_FORMATS = [
{
ext: [".jpg", ".jpeg", ".png", ".svg", ".bmp", ".webp", ".gif", ".tiff", ".heif", ".raw"],
icon: "bi:file-earmark-image",
},
{
ext: [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mpeg", ".3gp", ".mts"],
icon: "bi:file-earmark-play",
},
{
ext: [".zip", ".rar", ".tar.gz", ".dmg", ".7z", ".bz2", ".tar", ".gz", ".xz"],
icon: "bi:file-earmark-zip",
},
{
ext: [".mp3", ".wav", ".aac", ".flac", ".ogg", ".wma", ".alac", ".m4a", ".opus", ".aiff"],
icon: "bi:file-earmark-music",
},
{
ext: [
".py",
".js",
".java",
".cpp",
".c",
".html",
".css",
".php",
".rb",
".swift",
".go",
".sh",
".md",
".rust",
".pl",
],
icon: "bi:file-earmark-code",
},
{
ext: [
".json",
".xml",
".sql",
".csv",
".xls",
".xlsx",
".avro",
".hdf5",
".yaml",
".tsv",
".ods",
".db",
".sqlite",
],
icon: "bi:file-earmark-spreadsheet",
},
{
ext: [".pdf", ".epub", ".xps", ".djvu", ".cbz", ".cbt"],
icon: "bi:file-earmark-pdf",
},
{
ext: [".ppt", ".pptx", ".odp", ".pps", ".ppsx", ".key"],
icon: "bi:file-earmark-slides",
},
{
ext: [".txt", ".doc", ".docx", ".odt", ".rtf", ".wps", ".wpd"],
icon: "bi:file-earmark-text",
},
{
ext: [".lock", ".lck"],
icon: "bi:file-earmark-lock",
},
{
ext: [".exe", ".bin", ".elf", ".dll", ".iso", ".dmg", ".swf", ".o", ".obg", ".pe", ".app"],
icon: "bi:file-earmark-binary",
},
];
export const MUTATION_KEYS = {
create_chat: "create_chat",
};
export const PRODUCT_RANGE = {
min: 0,
max: 100000,
};
+2 -1
View File
@@ -43,11 +43,12 @@ export default defineNuxtConfig({
],
"@nuxt/icon",
"reka-ui/nuxt",
"@vueuse/nuxt",
],
runtimeConfig: {
public: {
API_BASE_URL: "http://38.60.202.91:8000",
API_BASE_URL: "http://38.60.202.91:8001",
},
},
});
+150 -45
View File
@@ -13,7 +13,9 @@
"@tanstack/vue-query-devtools": "^5.62.3",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"@vueuse/nuxt": "^12.3.0",
"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",
@@ -3399,14 +3401,14 @@
}
},
"node_modules/@vueuse/core": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz",
"integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==",
"version": "12.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.3.0.tgz",
"integrity": "sha512-cnV8QDKZrsyKC7tWjPbeEUz2cD9sa9faxF2YkR8QqNwfofgbOhmfIgvSYmkp+ttSvfOw4E6hLcQx15mRPr0yBA==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "12.0.0",
"@vueuse/shared": "12.0.0",
"@vueuse/metadata": "12.3.0",
"@vueuse/shared": "12.3.0",
"vue": "^3.5.13"
},
"funding": {
@@ -3414,18 +3416,98 @@
}
},
"node_modules/@vueuse/metadata": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz",
"integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==",
"version": "12.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.3.0.tgz",
"integrity": "sha512-M/iQHHjMffOv2npsw2ihlUx1CTiBwPEgb7DzByLq7zpg1+Ke8r7s9p5ybUWc5OIeGewtpY4Xy0R2cKqFqM8hFg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/nuxt": {
"version": "12.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/nuxt/-/nuxt-12.3.0.tgz",
"integrity": "sha512-xlpYIroHvdrwyZRHvXKekdFLH0IIPaOLKkt8VcVFZLHC3CgPiiwqGDK2Uvea8Hb51zhTWodYpd5Y2jCcsjZmBw==",
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^3.15.0",
"@vueuse/core": "12.3.0",
"@vueuse/metadata": "12.3.0",
"local-pkg": "^0.5.1",
"vue": "^3.5.13"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"nuxt": "^3.0.0"
}
},
"node_modules/@vueuse/nuxt/node_modules/@nuxt/kit": {
"version": "3.15.1",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.15.1.tgz",
"integrity": "sha512-7cVWjzfz3L6CsZrg6ppDZa7zGrZxCSfZjEQDIvVFn4mFKtJlK9k2izf5EewL6luzWwIQojkZAC3iq/1wtgI0Xw==",
"license": "MIT",
"dependencies": {
"@nuxt/schema": "3.15.1",
"c12": "^2.0.1",
"consola": "^3.3.3",
"defu": "^6.1.4",
"destr": "^2.0.3",
"globby": "^14.0.2",
"ignore": "^7.0.0",
"jiti": "^2.4.2",
"klona": "^2.0.6",
"knitwork": "^1.2.0",
"mlly": "^1.7.3",
"ohash": "^1.1.4",
"pathe": "^2.0.0",
"pkg-types": "^1.3.0",
"scule": "^1.3.0",
"semver": "^7.6.3",
"ufo": "^1.5.4",
"unctx": "^2.4.1",
"unimport": "^3.14.5",
"untyped": "^1.5.2"
},
"engines": {
"node": ">=18.20.5"
}
},
"node_modules/@vueuse/nuxt/node_modules/@nuxt/schema": {
"version": "3.15.1",
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-3.15.1.tgz",
"integrity": "sha512-n5kOHt8uUyUM9z4Wu/8tIZkBYh3KTCGvyruG6oD9bfeT4OaS21+X3M7XsTXFMe+eYBZA70IFFlWn1JJZIPsKeA==",
"license": "MIT",
"dependencies": {
"consola": "^3.3.3",
"defu": "^6.1.4",
"pathe": "^2.0.0",
"std-env": "^3.8.0"
},
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/@vueuse/nuxt/node_modules/ignore": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.0.tgz",
"integrity": "sha512-lcX8PNQygAa22u/0BysEY8VhaFRzlOkvdlKczDPnJvrkJD1EuqzEky5VYYKM2iySIuaVIDv9N190DfSreSLw2A==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@vueuse/nuxt/node_modules/pathe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.0.tgz",
"integrity": "sha512-G7n4uhtk9qJt2hlD+UFfsIGY854wpF+zs2bUbQ3CQEUTcn7v25LRsrmurOxTo4bJgjE4qkyshd9ldsEuY9M6xg==",
"license": "MIT"
},
"node_modules/@vueuse/shared": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz",
"integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==",
"version": "12.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.3.0.tgz",
"integrity": "sha512-X3YD35GUeW0d5Gajcwv9jdLAJTV2Jdb/Ll6Ii2JIYcKLYZqv5wxyLeKtiQkqWmHg3v0J0ZWjDUMVOw2E7RCXfA==",
"license": "MIT",
"dependencies": {
"vue": "^3.5.13"
@@ -4372,9 +4454,9 @@
"license": "MIT"
},
"node_modules/consola": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz",
"integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.3.3.tgz",
"integrity": "sha512-Qil5KwghMzlqd51UXM0b6fyaGHtOC22scxrwrz4A2882LyUMwQjnvaedN1HAeXzphspQ6CpHkzMAWxBTUruDLg==",
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
@@ -5162,6 +5244,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",
@@ -6107,9 +6198,9 @@
}
},
"node_modules/jiti": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.1.tgz",
"integrity": "sha512-yPBThwecp1wS9DmoA4x4KR2h3QoslacnDR8ypuFM962kI4/456Iy1oHx2RAgh4jfZNdn0bctsdadceiBUgpU1g==",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -6203,9 +6294,9 @@
}
},
"node_modules/knitwork": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/knitwork/-/knitwork-1.1.0.tgz",
"integrity": "sha512-oHnmiBUVHz1V+URE77PNot2lv3QiYU2zQf1JjOVkMt3YDKGbu8NAFr+c4mcNOhdsGrB/VpVbRwPwhiXrPhxQbw==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/knitwork/-/knitwork-1.2.0.tgz",
"integrity": "sha512-xYSH7AvuQ6nXkq42x0v5S8/Iry+cfulBz/DJQzhIyESdLD7425jXsPy4vn5cCXU+HhRN2kVw51Vd1K6/By4BQg==",
"license": "MIT"
},
"node_modules/kolorist": {
@@ -6622,9 +6713,9 @@
}
},
"node_modules/magic-string": {
"version": "0.30.14",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.14.tgz",
"integrity": "sha512-5c99P1WKTed11ZC0HMJOj6CDIue6F8ySu+bJL+85q1zBEIY8IklrJ1eiKC2NDRh3Ct3FcvmJPyQHb9erXMTJNw==",
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
@@ -7655,13 +7746,13 @@
}
},
"node_modules/pkg-types": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.1.tgz",
"integrity": "sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.0.tgz",
"integrity": "sha512-kS7yWjVFCkIw9hqdJBoMxDdzEngmkr5FXeWZZfQ6GoYacjVnsW6l2CcYW/0ThD0vF4LPJgVYnrg4d0uuhwYQbg==",
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.2",
"mlly": "^1.7.3",
"pathe": "^1.1.2"
}
},
@@ -9270,15 +9361,28 @@
"license": "MIT"
},
"node_modules/unctx": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/unctx/-/unctx-2.3.1.tgz",
"integrity": "sha512-PhKke8ZYauiqh3FEMVNm7ljvzQiph0Mt3GBRve03IJm7ukfaON2OBK795tLwhbyfzknuRRkW0+Ze+CQUmzOZ+A==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/unctx/-/unctx-2.4.1.tgz",
"integrity": "sha512-AbaYw0Nm4mK4qjhns67C+kgxR2YWiwlDBPzxrN8h8C6VtAdCgditAY5Dezu3IJy4XVqAnbrXt9oQJvsn3fyozg==",
"license": "MIT",
"dependencies": {
"acorn": "^8.8.2",
"acorn": "^8.14.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.0",
"unplugin": "^1.3.1"
"magic-string": "^0.30.17",
"unplugin": "^2.1.0"
}
},
"node_modules/unctx/node_modules/unplugin": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.1.2.tgz",
"integrity": "sha512-Q3LU0e4zxKfRko1wMV2HmP8lB9KWislY7hxXpxd+lGx0PRInE4vhMBVEZwpdVYHvtqzhSrzuIfErsob6bQfCzw==",
"license": "MIT",
"dependencies": {
"acorn": "^8.14.0",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/undici-types": {
@@ -9340,15 +9444,16 @@
}
},
"node_modules/unimport": {
"version": "3.14.3",
"resolved": "https://registry.npmjs.org/unimport/-/unimport-3.14.3.tgz",
"integrity": "sha512-yEJps4GW7jBdoQlxEV0ElBCJsJmH8FdZtk4oog0y++8hgLh0dGnDpE4oaTc0Lfx4N5rRJiGFUWHrBqC8CyUBmQ==",
"version": "3.14.5",
"resolved": "https://registry.npmjs.org/unimport/-/unimport-3.14.5.tgz",
"integrity": "sha512-tn890SwFFZxqaJSKQPPd+yygfKSATbM8BZWW1aCR2TJBTs1SDrmLamBueaFtYsGjHtQaRgqEbQflOjN2iW12gA==",
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.3",
"acorn": "^8.14.0",
"escape-string-regexp": "^5.0.0",
"estree-walker": "^3.0.3",
"fast-glob": "^3.3.2",
"local-pkg": "^0.5.1",
"magic-string": "^0.30.14",
"mlly": "^1.7.3",
@@ -9357,7 +9462,6 @@
"pkg-types": "^1.2.1",
"scule": "^1.3.0",
"strip-literal": "^2.1.1",
"tinyglobby": "^0.2.10",
"unplugin": "^1.16.0"
}
},
@@ -9617,17 +9721,18 @@
}
},
"node_modules/untyped": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/untyped/-/untyped-1.5.1.tgz",
"integrity": "sha512-reBOnkJBFfBZ8pCKaeHgfZLcehXtM6UTxc+vqs1JvCps0c4amLNp3fhdGBZwYp+VLyoY9n3X5KOP7lCyWBUX9A==",
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/untyped/-/untyped-1.5.2.tgz",
"integrity": "sha512-eL/8PlhLcMmlMDtNPKhyyz9kEBDS3Uk4yMu/ewlkT2WFbtzScjHWPJLdQLmaGPUKjXzwe9MumOtOgc4Fro96Kg==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.25.7",
"@babel/standalone": "^7.25.7",
"@babel/types": "^7.25.7",
"@babel/core": "^7.26.0",
"@babel/standalone": "^7.26.4",
"@babel/types": "^7.26.3",
"citty": "^0.1.6",
"defu": "^6.1.4",
"jiti": "^2.3.1",
"mri": "^1.2.0",
"jiti": "^2.4.1",
"knitwork": "^1.2.0",
"scule": "^1.3.0"
},
"bin": {
+2
View File
@@ -18,7 +18,9 @@
"@tanstack/vue-query-devtools": "^5.62.3",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"@vueuse/nuxt": "^12.3.0",
"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>
+32 -6
View File
@@ -1,20 +1,46 @@
<script setup lang="ts"></script>
<script setup lang="ts">
// import
import { PRODUCT_RANGE } from "~/constants";
// state
const params = useUrlSearchParams("history");
// life-cycle
onMounted(() => {
if (!("range" in params)) {
params.range = [];
params.range[0] = PRODUCT_RANGE.min;
params.range[1] = PRODUCT_RANGE.max;
}
});
</script>
<template>
<div class="w-full container flex flex-col">
<div class="w-full flex justify-between items-center py-[5rem]">
<div class="flex flex-col items-start gap-[1.5rem] text-black">
<div class="flex-center gap-[.75rem]">
<div class="w-full flex justify-end items-center py-[5rem]">
<div
class="flex flex-col items-start gap-[1.5rem] text-black w-full"
>
<!-- <div class="flex-center gap-[.75rem]">
<span>خانه</span>
<span>/</span>
<span>محصولات</span>
<span>/</span>
<span>همه</span>
</div>
</div> -->
<h1 class="typo-hero-2">همه محصولات</h1>
</div>
<FilterButton />
<div class="w-full flex items-center justify-end gap-4">
<Input
placeholder="جست و جو محصول ..."
class="bg-slate-50 !border-slate-200 hover:border-slate-300 focus:!border-slate-800 !rounded-full w-8/12"
/>
<FilterButton />
</div>
</div>
<ul class="w-full grid grid-cols-3 gap-[1.5rem]">
<li v-for="i in 9" :key="i">
+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 {
+3
View File
@@ -0,0 +1,3 @@
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 6H19M21 12H16M21 18H16M7 20V13.5612C7 13.3532 7 13.2492 6.97958 13.1497C6.96147 13.0615 6.93151 12.9761 6.89052 12.8958C6.84431 12.8054 6.77934 12.7242 6.64939 12.5617L3.35061 8.43826C3.22066 8.27583 3.15569 8.19461 3.10948 8.10417C3.06849 8.02393 3.03853 7.93852 3.02042 7.85026C3 7.75078 3 7.64677 3 7.43875V5.6C3 5.03995 3 4.75992 3.10899 4.54601C3.20487 4.35785 3.35785 4.20487 3.54601 4.10899C3.75992 4 4.03995 4 4.6 4H13.4C13.9601 4 14.2401 4 14.454 4.10899C14.6422 4.20487 14.7951 4.35785 14.891 4.54601C15 4.75992 15 5.03995 15 5.6V7.43875C15 7.64677 15 7.75078 14.9796 7.85026C14.9615 7.93852 14.9315 8.02393 14.8905 8.10417C14.8443 8.19461 14.7793 8.27583 14.6494 8.43826L11.3506 12.5617C11.2207 12.7242 11.1557 12.8054 11.1095 12.8958C11.0685 12.9761 11.0385 13.0615 11.0204 13.1497C11 13.2492 11 13.3532 11 13.5612V17L7 20Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

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
}
}