This commit is contained in:
Mamalizz
2025-04-16 22:59:08 +03:30
30 changed files with 494 additions and 355 deletions
+5 -3
View File
@@ -1,4 +1,6 @@
{
"tabWidth": 4,
"semi": true
}
"singleAttributePerLine": true,
"printWidth": 120,
"tabWidth": 4,
"semi": true
}
+1
View File
@@ -16,6 +16,7 @@ const closeModal = () => {
<template>
<div>
<LoadingIndicator />
<NuxtPwaManifest />
<UpdatePwaModal
+88 -87
View File
@@ -1,47 +1,78 @@
<script lang="ts" setup>
// state
// states
const { $gsap: gsap } = useNuxtApp();
const disableLoadingOverlay = useState("disableLoadingOverlay");
const isSiteLoadingDisabled = useCookie("is-site-loading-disabled", {
default: () => false,
});
const shouldRenderLoadingOverlay = ref(true);
const isAssetLoaded = ref(false);
const criticalLoad = ref(true);
const progressInterval = ref<NodeJS.Timeout | null>(null);
const assetLoadingProgress = ref(10);
const isWindowScrollLocked = useScrollLock(window);
// lifecycle
// computed
onMounted(async () => {
const timeline = gsap.timeline();
timeline.to("#loading-overlay", {
opacity: 1,
});
isWindowScrollLocked.value = true;
const preload = (url: string) => {
return new Promise((resolve) => {
const image = new Image();
image.src = url;
image.onload = () => resolve(true);
});
const progressStyle = computed(() => {
return {
width: `${assetLoadingProgress.value}%`,
};
});
await Promise.all([
preload("/img/heymlz/heymlz-fast-loading.gif"),
preload("/img/heymlz/heymlz-pulling.gif"),
preload("/img/heymlz/heymlz-falling.gif"),
preload("/img/heymlz/heymlz-seat.gif"),
]);
// methods
timeline.to("#loading-overlay", {
const onAssetLoaded = () => {
criticalLoad.value = false;
clearInterval(progressInterval.value!);
assetLoadingProgress.value = 100;
isAssetLoaded.value = true;
};
const onAssetFinished = () => {
gsap.to("#loading-overlay", {
opacity: 0,
delay: 5.5,
onComplete: () => {
shouldRenderLoadingOverlay.value = false;
isWindowScrollLocked.value = false;
disableLoadingOverlay.value = true;
isSiteLoadingDisabled.value = true;
},
});
};
// watch
watch([assetLoadingProgress, criticalLoad], ([assetLoadingProgress, criticalLoad]) => {
if (criticalLoad && assetLoadingProgress >= 100) {
onAssetFinished();
}
});
// lifecycle
onMounted(() => {
isWindowScrollLocked.value = true;
if (!isSiteLoadingDisabled.value) {
}
const heymlzLoadingAnimation = document.querySelector("#heymlz-loading-animation") as HTMLVideoElement;
if (heymlzLoadingAnimation?.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) {
onAssetLoaded();
}
progressInterval.value = setInterval(() => {
assetLoadingProgress.value += Math.random() * 10;
}, 250);
gsap.to("#loading-overlay", {
opacity: 1,
});
});
</script>
@@ -51,66 +82,36 @@ onMounted(async () => {
id="loading-overlay"
class="fixed inset-0 size-full z-9999 flex-center bg-black"
>
<NuxtImg
id="loading-overlay-image"
src="/img/heymlz/heymlz-fast-loading.gif"
class="absolute z-20 w-[700px] brightness-75"
alt=""
<div
class="flex-col-center gap-6 transition-all duration-450 ease-in-out"
:class="isAssetLoaded ? 'opacity-0 scale-75' : 'opacity-100 scale-100'"
>
<NuxtImg
src="/img/heymlz/heymlz-text-logo.png"
class="invert w-[250px]"
/>
<div class="bg-slate-800 w-[400px] h-1 rounded-full overflow-hidden">
<div
class="bg-slate-100 h-full w-full transition-all duration-250"
:style="progressStyle"
/>
</div>
</div>
<video
id="heymlz-loading-animation"
muted
autoplay
playsinline
webkit-playsinline
@canplay="onAssetLoaded"
@ended="onAssetFinished"
src="/video/heymlz/heymlz-fast-loading.mp4"
class="absolute z-20 w-[700px] brightness-75 transition-all duration-[1s]"
:class="isAssetLoaded ? 'opacity-100' : 'opacity-0'"
:style="{
mask: 'linear-gradient(to bottom, rgba(0,0,0,0) 0%, black 20%, black 80%, rgba(0,0,0,0) 100%)',
}"
/>
<!-- <div
id="loading-overlay-gradient"
class="opacity-0 scale-x-0 w-[1000px] h-[70px] bg-linear-to-r from-blue-500 via-violet-500 to-purple-500 blur-[150px] rounded-[100px]"
/> -->
</div>
</template>
<style>
#loading-overlay-image {
animation-name: loading-overlay-image-animation;
animation-duration: 1s;
animation-delay: 0.35s;
animation-fill-mode: forwards;
}
#loading-overlay-gradient {
animation: 1.5s normal 0.5s 1 forwards loading-overlay-gradient-animation,
1s ease-in-out 2s infinite alternate-reverse
loading-overlay-gradient-pules-animation;
}
@keyframes loading-overlay-image-animation {
from {
opacity: 0;
scale: 0.7;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes loading-overlay-gradient-animation {
from {
opacity: 0;
scale: 0 1 1;
}
to {
opacity: 0.9;
scale: 1 1 1;
}
}
@keyframes loading-overlay-gradient-pules-animation {
from {
opacity: 0.8;
scale: 0.8 1 1;
}
to {
opacity: 0.9;
scale: 1 1 1;
}
}
</style>
@@ -13,7 +13,7 @@
<span class="typo-p-sm whitespace-nowrap">معمولا طی ۲ ساعت اماده میشود</span>
</div>
</div>
<span class="typo-p-xs">
<span class="typo-p-xs max-sm:hidden">
برسی موجودی در فروشگاه های دیگر
</span>
</div>
@@ -50,7 +50,7 @@ const onInput = (e: any) => {
<template>
<NumberFieldRoot
:disabled="disable"
class="rounded-full border-slate-200 border-[1.5px] flex items-center bg-white gap-4 p-4"
class="rounded-full border-slate-200 border-[1.5px] flex items-center bg-white gap-4 p-4 max-sm:h-[48px]"
v-model="currentQuantity"
:min="1"
:max="max"
@@ -15,7 +15,7 @@ defineProps<Props>();
<template>
<div class="flex flex-col gap-2 w-full">
<p class="typo-p-md text-slate-500">
<p class="typo-p-sm sm:typo-p-md text-slate-500">
تعداد
<span class="text-black font-bold">
{{ maxQuantity }}
@@ -31,7 +31,7 @@ const { picture, price, title, color } = toRefs(props);
<span class="typo-p-md text-black">{{ price }}</span>
</div>
</div>
<Button class="rounded-full">
<Button class="rounded-full max-sm:h-[45px]">
افزودن
<span class="max-sm:hidden">به سبد</span>
</Button>
+1 -1
View File
@@ -39,7 +39,7 @@ const onSwiper = (swiper: SwiperClass) => {
<div class="w-full my-20 relative">
<NuxtImg
class="aspect-square w-[240px] md:w-[300px] lg:w-[350px] translate-[-164px] md:translate-[-206px] lg:translate-[-240px] absolute left-1/2 -translate-x-1/2 z-10"
class="aspect-square w-[240px] md:w-[300px] lg:w-[350px] translate-y-[-164px] md:translate-y-[-206px] lg:translate-y-[-240px] absolute left-1/2 -translate-x-1/2 z-10"
:style="{
filter: 'drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.4))',
}"
+119 -67
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
// import
import { Swiper, SwiperSlide } from "swiper/vue";
@@ -15,12 +14,9 @@ const swiper_instance = ref<SwiperClass | null>(null);
const observerTarget = ref(null);
const shouldPauseVideos = ref(false);
useIntersectionObserver(
observerTarget,
([entry], observerElement) => {
shouldPauseVideos.value = entry.rootBounds ? !entry.isIntersecting : false;
}
);
useIntersectionObserver(observerTarget, ([entry], observerElement) => {
shouldPauseVideos.value = entry.rootBounds ? !entry.isIntersecting : false;
});
const isMuted = ref(true);
const slidesPerView = ref(1);
@@ -37,7 +33,7 @@ const onSwiper = (swiper: SwiperClass) => {
const updateVideoStates = () => {
const activeIndex = swiper_instance.value?.realIndex || 0;
const videosElements = document.querySelectorAll(`.slide-video`) as NodeListOf<HTMLVideoElement>;
videosElements.forEach(videoElement => {
videosElements.forEach((videoElement) => {
if (videoElement.id === `slide-video-${activeIndex}` && !shouldPauseVideos.value) {
videoElement.play();
} else {
@@ -48,58 +44,84 @@ const updateVideoStates = () => {
// watch
watch(() => [shouldPauseVideos.value, swiper_instance.value?.realIndex], () => {
updateVideoStates();
});
watch(
() => [shouldPauseVideos.value, swiper_instance.value?.realIndex],
() => {
updateVideoStates();
}
);
// lifecycle
const initializeGsapAnimation = () => {
gsapTimeline
.fromTo(".header-slider-item", {
borderRadius: 0,
height: "100svh"
}, {
height: "80svh",
borderRadius: "20px"
})
.fromTo(slidesPerView, {
value: 1
}, {
value: 1.2
}, "=")
.fromTo(".header-navbar-item", {
filter: "invert(100%)"
}, {
filter: "invert(0%)"
}, "=")
.fromTo("#header-navbar", {
background: "transparent"
}, {
background: "white"
})
.fromTo("#header-slider-wrapper", {
marginTop: "0px",
scale: 1.025
}, {
marginTop: () => {
const navbarEl = document.querySelector("#header-navbar") as HTMLDivElement;
return `${navbarEl.clientHeight}px`;
.fromTo(
".header-slider-item",
{
borderRadius: 0,
height: "100svh",
},
scale: 1
}, "=");
{
height: "80svh",
borderRadius: "20px",
}
)
.fromTo(
slidesPerView,
{
value: 1,
},
{
value: 1.2,
},
"="
)
.fromTo(
".header-navbar-item",
{
filter: "invert(100%)",
},
{
filter: "invert(0%)",
},
"="
)
.fromTo(
"#header-navbar",
{
background: "transparent",
},
{
background: "white",
}
)
.fromTo(
"#header-slider-wrapper",
{
marginTop: "0px",
scale: 1.025,
},
{
marginTop: () => {
const navbarEl = document.querySelector("#header-navbar") as HTMLDivElement;
return `${navbarEl.clientHeight}px`;
},
scale: 1,
},
"="
);
};
const resetTimelineForMobile = () => {
gsap.to("#header-navbar", {
background: "white"
background: "white",
});
gsap.to(".header-navbar-item", {
filter: "invert(0%)"
filter: "invert(0%)",
});
gsap.set(".header-slider-item", {
borderRadius: "20px",
height: "450px"
height: "450px",
});
};
@@ -118,7 +140,7 @@ onMounted(() => {
pin: true,
start: "top top",
// markers: true,
end: "bottom top"
end: "bottom top",
});
const calculateOnResize = () => {
@@ -141,14 +163,12 @@ onMounted(() => {
window.addEventListener("resize", () => {
calculateOnResize();
});
});
onUnmounted(() => {
resetTimelineForMobile();
scrollTrigger.disable();
});
</script>
<template>
@@ -156,7 +176,10 @@ onUnmounted(() => {
id="header-slider-container"
class="w-full z-50"
>
<div id="header-slider-wrapper" class="relative">
<div
id="header-slider-wrapper"
class="relative"
>
<Swiper
ref="observerTarget"
:slides-per-view="slidesPerView"
@@ -164,8 +187,8 @@ onUnmounted(() => {
:centered-slides="true"
:breakpoints="{
768: {
spaceBetween : 40
}
spaceBetween: 40,
},
}"
@swiper="onSwiper"
>
@@ -174,9 +197,7 @@ onUnmounted(() => {
:key="slide.id"
>
<div class="max-md:container">
<div
class="header-slider-item relative w-full overflow-hidden max-md:rounded-[20px]"
>
<div class="header-slider-item relative w-full overflow-hidden max-md:rounded-[20px]">
<template v-if="!!slide.video">
<video
:id="`slide-video-${index}`"
@@ -202,14 +223,15 @@ onUnmounted(() => {
:class="swiper_instance?.realIndex !== index ? 'opacity-0' : ''"
class="w-full transition-opacity pb-6 xs:pb-10 lg:pb-16 px-6 xs:px-10 lg:px-16 gap-6 xs:gap-10 lg:gap-12 container flex flex-col h-full justify-end relative z-10"
>
<div class="header-slider-item-child w-full">
<div class="border-b border-white/10 pb-6 flex flex-col gap-2 md:gap-4">
<div class="flex items-center gap-4 lg:gap-8">
<div
class="hover:scale-110 size-[36px] md:size-[42px] lg:size-[50px] relative flex items-center justify-center">
class="hover:scale-110 size-[36px] md:size-[42px] lg:size-[50px] relative flex items-center justify-center"
>
<div
class="size-full scale-75 bg-white absolute rounded-full animate-ping" />
class="size-full scale-75 bg-white absolute rounded-full animate-ping"
/>
<button
@click="isMuted = !isMuted"
class="transition-all cursor-pointer flex-center bg-white z-10 size-full rounded-full"
@@ -244,7 +266,11 @@ onUnmounted(() => {
id="header-slider-pagination-child"
class="flex items-center justify-between"
>
<button @click="swiper_instance?.slidePrev()">
<button
@click="swiper_instance?.slidePrev()"
class="relative"
>
<div class="size-8 blur-xl bg-white absolute ping-animation max-sm:hidden"></div>
<Icon
class="**:stroke-white cursor-pointer size-6 md:size-8"
name="ci:arrow-right"
@@ -253,27 +279,53 @@ onUnmounted(() => {
<div class="flex items-center justify-center gap-3 text-white">
<div
v-for="(_slide, index) in homeData!.sliders"
:class="swiper_instance?.realIndex === index ? 'bg-white' : 'bg-transparent'"
:class="
swiper_instance?.realIndex === index ? 'bg-white' : 'bg-transparent'
"
class="border border-white size-2 md:size-3 rounded-full transition-all duration-200"
>
</div>
></div>
</div>
<button>
<button
@click="swiper_instance?.slideNext()"
class="relative"
>
<div class="size-8 blur-xl bg-white absolute ping-animation max-sm:hidden"></div>
<Icon
@click="swiper_instance?.slideNext()"
class="**:stroke-white cursor-pointer size-6 md:size-8"
name="ci:arrow-left"
/>
</button>
</div>
</div>
</div>
</div>
</SwiperSlide>
</Swiper>
</div>
</div>
</template>
</template>
<style>
@keyframes ping-anime {
0% {
scale: 0.7;
opacity: 1;
}
10% {
scale: 2;
opacity: 0;
}
100% {
scale: 2;
opacity: 0;
}
}
.ping-animation {
animation-name: ping-anime;
animation-iteration-count: infinite;
animation-duration: 6s;
}
</style>
+3 -3
View File
@@ -19,7 +19,7 @@ const heymlzElementIsVisible = useElementVisibility(heymlzElement, {
rootMargin: "0px 0px -40% 0px",
});
const showHeymlzAnimation = ref(false);
const showHeymlzAnimation = ref(true);
const { x: dragAxisX } = useDraggable(draggableEl, {
initialValue: { x: 0, y: 0 },
@@ -92,7 +92,7 @@ watch(
:src="homeData!.difreance_section.image1"
:class="
showHeymlzAnimation
? 'brightness-50 blur-md'
? 'brightness-25 blur-sm'
: 'brightness-[95%] blur-[0px]'
"
class="select-none absolute size-full object-cover transition-[filter] duration-250"
@@ -119,7 +119,7 @@ watch(
:src="homeData!.difreance_section.image2"
:class="
showHeymlzAnimation
? 'brightness-50 blur-md'
? 'brightness-25 blur-sm'
: 'brightness-[95%] blur-[0px]'
"
class="overlay-image select-none absolute object-cover size-full transition-[filter] duration-250"
@@ -1,40 +0,0 @@
<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,
}"
>
<NuxtImg
:style="{
maskImage: 'radial-gradient(black, transparent 70%)'
}"
src="/img/heymlz/heymlz-idle.gif"
class="size-full object-cover absolute pt-2"
alt="ai-loading"
/>
</div>
</template>
@@ -1,7 +1,6 @@
<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";
@@ -20,6 +19,8 @@ const { isLoggedIn } = useAuth();
const route = useRoute();
const id = route.params.id as string | number;
const isMobile = useMediaQuery("(max-width: 480px)");
const scrollToBottomTimer = ref<NodeJS.Timeout | null>(null);
const chatContainerEl = ref<HTMLElement | null>(null);
@@ -31,26 +32,24 @@ const {
isPending: isChatPending,
isFetchingNextPage: isNextChatPagePending,
hasNextPage: hasMoreChat,
fetchNextPage: loadMoreChat
fetchNextPage: loadMoreChat,
} = useGetChat(id, isOpen);
const isCreateMessagePending = useIsMutating({
mutationKey: [MUTATION_KEYS.create_chat]
mutationKey: [MUTATION_KEYS.create_chat],
});
const canLoadMoreChat = ref(false);
const isChatScrollLocked = useScrollLock(chatContainerEl);
const { y: chatContainerScrollY } = useScroll(chatContainerEl, {
behavior: "smooth"
behavior: "smooth",
});
useInfiniteScroll(
chatContainerEl,
async () => {
if (hasMoreChat.value && !isChatPending.value) {
lastMessageBeforeUpdate.value = chatMessages.value
? chatMessages.value[0].id
: 0;
lastMessageBeforeUpdate.value = chatMessages.value ? chatMessages.value[0].id : 0;
await loadMoreChat();
}
},
@@ -58,7 +57,7 @@ useInfiniteScroll(
distance: 10,
direction: "top",
throttle: 1000,
canLoadMore: () => canLoadMoreChat.value
canLoadMore: () => canLoadMoreChat.value,
}
);
@@ -103,8 +102,7 @@ watch(
`#message-container-${lastMessageBeforeUpdate.value}`
) as HTMLElement;
lastChatMessageEl?.scrollIntoView();
chatContainerEl.value!.scrollTop =
chatContainerEl.value!.scrollTop + scrollTopOld;
chatContainerEl.value!.scrollTop = chatContainerEl.value!.scrollTop + scrollTopOld;
});
}
}
@@ -121,28 +119,31 @@ whenever(
}, 2000);
},
{
once: true
once: true,
}
);
</script>
<template>
<Transition name="fade-right-to-left">
<Transition :name="isMobile ? 'fade-down' : 'fade-right-to-left'">
<div
v-if="isOpen"
class="fixed z-50 right-8 bottom-8 w-[450px] transition-all duration-500 overflow-hidden h-[700px] rounded-250 shadow-2xl shadow-black/30 pt-[40px] bg-white"
class="fixed z-50 max-xs:inset-0 xs:right-8 xs:bottom-8 w-full xs:w-[450px] transition-all duration-500 overflow-hidden h-svh xs:h-[700px] xs:rounded-250 shadow-2xl shadow-black/30 pt-[40px] bg-white"
>
<CloseButton :disabled="!!isCreateMessagePending" />
<template v-if="isLoggedIn">
<Transition name="zoom" mode="out-in">
<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)'
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"
@@ -183,7 +184,11 @@ whenever(
v-else
class="w-full h-full flex items-center justify-center absolute inset-0"
>
<AiLoading />
<NuxtImg
class="size-[250px] drop-shadow-2xl"
src="/img/heymlz/heymlz-small-idle.gif"
alt=""
/>
</div>
</Transition>
</template>
@@ -191,12 +196,15 @@ whenever(
class="text-black p-6 size-full flex justify-center items-center flex-col"
v-else
>
<NuxtImg class="size-[250px]" src="/img/heymlz/heymlz-loading-1.gif" alt="" />
<NuxtImg
class="size-[250px] drop-shadow-2xl"
src="/img/heymlz/heymlz-small-idle.gif"
alt=""
/>
<div class="flex flex-col gap-4 items-center">
<span class="text-center typo-p-xl font-bold">سلام دوست عزیز!</span>
<p class="text-center typo-p-md">
من میتونم هر سوالی رو درمورد این محصول جواب بدم
اگه میخوای شروع کنیم روی دکمه زیر کلیک کن
من میتونم هر سوالی رو درمورد این محصول جواب بدم اگه میخوای شروع کنیم روی دکمه زیر کلیک کن
</p>
</div>
<NuxtLink to="/signin">
@@ -1,7 +1,19 @@
<script setup lang="ts">
// types
type Props = {
showChatButton: boolean;
};
// props
defineProps<Props>();
// state
const isOpen = ref(false);
const isMobile = useMediaQuery("(max-width: 480px)");
const isWindowScrollLocked = useScrollLock(window);
// methods
@@ -14,19 +26,29 @@ provide("isOpen", {
closeChat,
});
// watches
watch([isMobile, isOpen], ([isMobile, isOpen]) => {
isWindowScrollLocked.value = isMobile && isOpen;
});
</script>
<template>
<button
v-if="!isOpen"
@click="isOpen = !isOpen"
class="cursor-pointer z-50 fixed shadow-xl shadow-black/30 right-8 bottom-8 bg-black size-[70px] flex justify-center items-center rounded-full"
<Transition
name="fade"
:duration="150"
>
<Icon
name="streamline:artificial-intelligence-spark"
class="**:stroke-white size-[26px]"
/>
</button>
<button
v-if="showChatButton && !isOpen"
@click="isOpen = !isOpen"
class="cursor-pointer z-50 fixed shadow-xl shadow-black/30 right-8 bottom-8 bg-blue-500 size-[70px] flex justify-center items-center rounded-full"
>
<Icon
name="streamline:artificial-intelligence-spark"
class="**:stroke-white size-[26px]"
/>
</button>
</Transition>
<ChatBoxContainer :isOpen="isOpen" />
</template>
@@ -1,7 +1,6 @@
<script setup lang="ts">
// types
import AiLoading from "~/components/product/ChatBox/AiLoading.vue";
import useCreateChatMessage from "~/composables/api/chat/useCreateChatMessage";
import { useToast } from "~/composables/global/useToast";
@@ -14,8 +13,7 @@ const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast();
const { mutateAsync: createMessage, isPending: isCreatingMessage } =
useCreateChatMessage(queryClient);
const { mutateAsync: createMessage, isPending: isCreatingMessage } = useCreateChatMessage(queryClient);
const chatInputEl = ref<HTMLInputElement | null>(null);
@@ -46,7 +44,10 @@ const sendMessage = async () => {
<template>
<div class="relative">
<div id="poda" class="poda-rotate">
<div
id="poda"
class="poda-rotate"
>
<div
class="glow w-full"
:class="isCreatingMessage ? '' : '!opacity-0'"
@@ -80,11 +81,7 @@ const sendMessage = async () => {
<input
ref="chatInputEl"
:disabled="isCreatingMessage"
:placeholder="
isCreatingMessage
? 'دارم فکر میکنم...'
: 'سوال خود را بپرسید'
"
:placeholder="isCreatingMessage ? 'دارم فکر میکنم...' : 'سوال خود را بپرسید'"
type="text"
name="text"
class="focus:outline-none h-full typo-p-sm w-full border-none"
@@ -97,11 +94,11 @@ const sendMessage = async () => {
:class="isCreatingMessage ? 'bg-transparent' : 'bg-black'"
>
<TransitionGroup name="fade-down">
<AiLoading
<Icon
v-if="isCreatingMessage"
circle
:size="75"
class="mb-1"
name="svg-spinners:wind-toy"
size="24"
class="text-black"
/>
<Icon
v-else
@@ -188,14 +185,7 @@ const sendMessage = async () => {
height: 600px;
background-repeat: no-repeat;
background-position: 0 0;
background-image: conic-gradient(
transparent,
#998fdc,
transparent 10%,
transparent 50%,
#cf7bba,
transparent 60%
);
background-image: conic-gradient(transparent, #998fdc, transparent 10%, transparent 50%, #cf7bba, transparent 60%);
transition: all 2s;
}
@@ -1,4 +1,6 @@
<script setup lang="ts">
import useGetAccount from '~/composables/api/account/useGetAccount';
// types
type Props = {
@@ -21,6 +23,7 @@ const emit = defineEmits(["textUpdate"]);
// state
const { $gsap: gsap } = useNuxtApp();
const { data: account } = useGetAccount();
// methods
@@ -79,26 +82,32 @@ onMounted(() => {
>
<NuxtImg
v-if="reverse"
src="/img/heymlz/footer-share.svg"
src="/img/heymlz/heymlz-full-body.jpg"
class="size-full object-cover absolute"
alt="profile"
/>
<NuxtImg
v-else
:src="account?.profile_photo ?? ''"
class="size-full object-cover absolute"
alt="profile"
/>
</div>
<div
class="rounded-150 px-4 py-3"
class="rounded-150 px-4 py-3 whitespace-pre-wrap overflow-hidden"
:class="
reverse
? 'bg-slate-100 text-slate-600'
: 'bg-black text-white'
"
>
<p
<div
v-if="!loadingContent"
:id="`chat-message-content-${id}`"
class="typo-p-sm font-normal whitespace-pre-wrap"
v-html="content"
>
{{ content }}
</p>
</div>
<Icon v-else name="svg-spinners:3-dots-bounce" size="20" />
</div>
@@ -15,16 +15,17 @@ const page = ref(1);
const { token } = useAuth();
const userComment = ref("");
const showMoreComments = ref(false);
const { data: comments, refetch: refetchComments } = useGetComments(id, page);
const { mutateAsync: createComment, isPending: isCreateCommentPending } =
useCreateComment(id);
const { mutateAsync: createComment, isPending: isCreateCommentPending } = useCreateComment(id);
// methods
const submitComment = async () => {
if (userComment.value.length > 3) {
await createComment({
content: userComment.value
content: userComment.value,
});
userComment.value = "";
@@ -32,6 +33,16 @@ const submitComment = async () => {
await refetchComments();
}
};
// computed
const limitedComments = computed(() => {
if (showMoreComments.value) {
return comments.value!.results;
}
return comments.value!.results.slice(0, 3);
});
</script>
<template>
@@ -43,9 +54,7 @@ const submitComment = async () => {
<h3 class="typo-h-6 max-sm:text-xl md:typo-h-5 lg:typo-h-4">نظرات کاربران</h3>
<div class="flex flex-col gap-2">
<Rating :rate="2" />
<span class="typo-p-sm">
بر اساس {{ comments?.count }} نظر
</span>
<span class="typo-p-sm"> بر اساس {{ comments?.count }} نظر </span>
</div>
<form
@submit.prevent="submitComment"
@@ -66,8 +75,14 @@ const submitComment = async () => {
>
نظر بنویسید
</Button>
<NuxtLink v-else to="/signin">
<Button type="button" class="rounded-full w-full">
<NuxtLink
v-else
to="/signin"
>
<Button
type="button"
class="rounded-full w-full"
>
وارد شوید
</Button>
</NuxtLink>
@@ -75,18 +90,30 @@ const submitComment = async () => {
</div>
<div class="flex flex-col gap-8 w-full">
<Comment
v-for="comment in comments!.results"
v-for="comment in limitedComments"
:key="comment.id"
title=""
:content="comment.content"
:date="comment.timestamp"
:username="'منصور مرزبان'"
/>
<div class="flex items-center justify-center w-full">
<Pagination
v-if="showMoreComments"
:total="comments!.count"
:items="comments!.results.map((item, i) => ({ type: 'page', value: i }))"
/>
<Button
v-else
type="button"
variant="primary"
@click="showMoreComments = !showMoreComments"
class="rounded-full px-8"
end-icon="bi:plus"
>
نمایش همه
</Button>
</div>
</div>
</div>
+77 -52
View File
@@ -1,5 +1,4 @@
<script lang="ts" setup>
// import
import useGetProduct from "~/composables/api/product/useGetProduct";
@@ -31,61 +30,78 @@ const { selectedVariant, changeSelectedVariant } = inject("productVariant") as P
const addItemToCart = async () => {
await addCartItem({
id: selectedVariant.value!.id,
quantity: selectedQuantity.value
quantity: selectedQuantity.value,
});
await refetchProduct();
};
// watch
watch(() => selectedVariantId.value, (newId) => {
const newVariant = product.value!.variants.find(variant => variant.id === newId)!;
changeSelectedVariant(newVariant);
});
watch(
() => selectedVariantId.value,
(newId) => {
const newVariant = product.value!.variants.find((variant) => variant.id === newId)!;
changeSelectedVariant(newVariant);
}
);
watch(() => selectedColor.value, (newValue) => {
const filteredVariants = product.value!.variants.filter(v => v.color === newValue);
selectedVariantId.value = filteredVariants[0].id;
selectedVariant.value = filteredVariants[0];
}, {
immediate: true
});
watch(() => selectedVariant.value!, (newValue) => {
selectedQuantity.value = 1;
selectedSlide.value = newValue.images[0].id;
});
watch(
() => selectedColor.value,
(newValue) => {
const filteredVariants = product.value!.variants.filter((v) => v.color === newValue);
selectedVariantId.value = filteredVariants[0].id;
selectedVariant.value = filteredVariants[0];
},
{
immediate: true,
}
);
watch(
() => selectedVariant.value!,
(newValue) => {
selectedQuantity.value = 1;
selectedSlide.value = newValue.images[0].id;
}
);
</script>
<template>
<div class="flex max-lg:flex-col gap-12 xl:gap-16 container pt-[5rem] pb-28">
<div class="flex flex-col gap-3 lg:hidden">
<NuxtLink to="#" class="typo-label-sm"> {{ product!.category.name }}</NuxtLink>
<h1 class="typo-h-6 xs:typo-h-5 sm:typo-h-4 lg:typo-h-2"> {{ product!.name }} </h1>
<NuxtLink
to="#"
class="typo-label-sm"
>
{{ product!.category.name }}</NuxtLink
>
<h1 class="typo-h-6 xs:typo-h-5 sm:typo-h-4 lg:typo-h-2">
{{ product!.name }}
</h1>
<div class="flex w-full items-center justify-between h-[85px]">
<div class="flex items-end gap-4">
<div class="flex flex-col gap-2">
<span
v-if="selectedVariant!.discount > 0"
class="typo-p-lg relative flex-center w-fit"
class="typo-p-md sm:typo-p-lg relative flex-center w-fit"
:class="'after:w-full after:h-[2px] after:bg-black after:absolute'"
>
{{ selectedVariant!.price }}
</span>
<span
class="typo-p-2xl relative flex-center w-fit font-medium"
>
<span class="typo-p-md sm:typo-p-2xl relative flex-center w-fit font-medium">
{{ selectedVariant!.discount > 0 ? selectedVariant!.price : selectedVariant!.price }}
</span>
</div>
<div
v-if="selectedVariant!.discount > 0"
class="text-white bg-blue-500 mb-1 px-4 py-2 text-xs rounded-full flex items-center gap-1"
class="text-white bg-blue-500 mb-1 px-3 sm:px-4 py-1.5 sm:py-2 text-xs rounded-full flex items-center gap-1"
>
<Icon name="material-symbols:percent" class="size-4" />
<Icon
name="material-symbols:percent"
class="size-3.5 sm:size-4"
/>
{{ selectedVariant!.discount }}
درصد تخفیف
<span class="max-sm:hidden"> تخفیف درصد </span>
</div>
</div>
<Rating :rate="3" />
@@ -103,7 +119,9 @@ watch(() => selectedVariant.value!, (newValue) => {
>
{{ product!.category.name }}
</NuxtLink>
<h1 class="typo-h-4 xl:typo-h-3 max-lg:hidden"> {{ product!.name }} </h1>
<h1 class="typo-h-4 xl:typo-h-3 max-lg:hidden">
{{ product!.name }}
</h1>
<div class="flex w-full items-center justify-between h-[85px] max-lg:hidden">
<div class="flex items-end gap-4">
<div class="flex flex-col gap-2">
@@ -114,9 +132,7 @@ watch(() => selectedVariant.value!, (newValue) => {
>
{{ selectedVariant!.price }}
</span>
<span
class="typo-p-2xl relative flex-center w-fit font-medium"
>
<span class="typo-p-2xl relative flex-center w-fit font-medium">
{{ selectedVariant!.discount > 0 ? selectedVariant!.price : selectedVariant!.price }}
</span>
</div>
@@ -124,37 +140,42 @@ watch(() => selectedVariant.value!, (newValue) => {
v-if="selectedVariant!.discount > 0"
class="text-white bg-blue-500 mb-1 px-4 py-2 text-xs rounded-full flex items-center gap-1"
>
<Icon name="material-symbols:percent" class="size-4" />
<Icon
name="material-symbols:percent"
class="size-4"
/>
{{ selectedVariant!.discount }}
<span class="max-sm:hidden">درصد</span>
تخفیف
</div>
<Rating :rate="3" class="sm:hidden" />
<Rating
:rate="3"
class="sm:hidden"
/>
</div>
<Rating :rate="3" class="max-sm:hidden" />
<Rating
:rate="3"
class="max-sm:hidden"
/>
</div>
<div
class="py-8 typo-p-md text-slate-500 text-justify [&_a]:text-blue-400 [&_strong]:font-bold [&_u]:text-red-400"
class="py-8 typo-sm max-sm:leading-[175%] sm:typo-p-md text-slate-500 text-justify [&_a]:text-blue-400 [&_strong]:font-bold [&_u]:text-red-400"
v-html="product!.description"
/>
<div class="flex items-center gap-4">
<span class="typo-p-lg">
تنوع رنگی :
</span>
<span class="typo-md sm:typo-p-lg"> تنوع رنگی : </span>
<div class="flex items-center gap-4 py-4">
<ColorCircle
v-for="color in product!.colors"
:key="color"
@click="selectedColor = color"
selectable
:selected="selectedColor === color "
:style="{backgroundColor: color}"
:selected="selectedColor === color"
:style="{ backgroundColor: color }"
class="cursor-pointer"
/>
</div>
@@ -162,7 +183,7 @@ watch(() => selectedVariant.value!, (newValue) => {
<div class="flex items-center gap-6 flex-wrap">
<ProductVariant
@click="variant.in_stock > 0 ? selectedVariantId = variant.id : undefined"
@click="variant.in_stock > 0 ? (selectedVariantId = variant.id) : undefined"
v-for="variant in product!.variants.filter(p => p.color === selectedColor)"
:key="variant.id"
:variantDetail="variant"
@@ -171,7 +192,6 @@ watch(() => selectedVariant.value!, (newValue) => {
</div>
<div class="w-full flex flex-col gap-6 mt-10">
<RemainQuantity
:maxQuantity="selectedVariant!.in_stock"
:quantity="selectedQuantity"
@@ -184,24 +204,31 @@ watch(() => selectedVariant.value!, (newValue) => {
@click="addItemToCart"
:loading="isAddCartItemPending"
:disabled="isAddCartItemPending"
class="w-full rounded-full"
class="w-full rounded-full max-sm:h-[48px]"
end-icon="ci:plus"
>
افزودن به سبد خرید
</Button>
<NuxtLink v-else to="/cart" class="w-full">
<NuxtLink
v-else
to="/cart"
class="w-full"
>
<Button
class="w-full rounded-full h-full"
class="w-full rounded-full h-full max-sm:h-[48px]"
end-icon="ci:cart"
>
مشاهده در سبد خرید
</Button>
</NuxtLink>
</template>
<NuxtLink v-else to="/signin" class="w-full">
<NuxtLink
v-else
to="/signin"
class="w-full"
>
<Button
class="w-full rounded-full h-full"
class="w-full rounded-full h-full max-sm:h-[48px]"
end-icon="ci:user"
>
ابتدا وارد شوید
@@ -216,13 +243,11 @@ watch(() => selectedVariant.value!, (newValue) => {
/>
<UpdateQuantity v-else />
</div>
<InfoCard />
<Share />
</div>
</div>
</div>
@@ -34,7 +34,7 @@ defineProps<Props>();
</div>
<div class="w-full">
<div class="w-full flex justify-between items-center gap-2">
<span class="text-xl font-medium">
<span class="text-md sm:text-xl font-medium">
{{ variantDetail.price }}
</span>
<div
@@ -68,7 +68,7 @@ defineProps<Props>();
<div
v-for="attribute in variantDetail.product_attributes"
class="flex items-center gap-2 text-sm rounded-full border border-slate-400 px-4 h-[40px]"
class="flex items-center gap-2 text-xs sm:text-sm rounded-full border border-slate-400 px-3 sm:px-4 h-[35px] sm:h-[40px]"
>
<span>{{ attribute.attribute_type.name }}</span>
<span>{{ attribute.value }}</span>
+57 -12
View File
@@ -1,10 +1,27 @@
<script lang="ts" setup>
// import
import useGetProduct from "~/composables/api/product/useGetProduct";
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
// types
type Props = {
showChatButton: boolean;
};
// props
defineProps<Props>();
// emits
const emit = defineEmits(["update:showChatButton"]);
// provide / inject
const { selectedVariant } = inject("productVariant") as ProductVariantProvideType;
// state
const route = useRoute();
@@ -12,12 +29,32 @@ const id = route.params.id as string | undefined;
const { data: product } = useGetProduct(id);
const { selectedVariant } = inject("productVariant") as ProductVariantProvideType;
const videoSectionEl = useTemplateRef<HTMLDivElement>("videoSectionEl");
const { bottom: videoSectionBottomBounding, height: videoSectionHeightBounding } = useElementBounding(videoSectionEl);
const isVideoSectionVisible = useElementVisibility(videoSectionEl, {
rootMargin: "0px 0px -20% 0px",
});
// computed
const isVideoSectionHittingBottom = computed(() => {
return videoSectionBottomBounding.value < videoSectionHeightBounding.value - 100;
});
// watch
watch([isVideoSectionVisible, isVideoSectionHittingBottom], ([isVideoSectionVisible, isVideoSectionHittingBottom]) => {
emit("update:showChatButton", !(isVideoSectionVisible && !isVideoSectionHittingBottom));
});
</script>
<template>
<section v-if="selectedVariant?.video" class="h-[110svh] w-full relative bg-black mt-[5rem]">
<section
v-if="selectedVariant?.video"
ref="videoSectionEl"
class="h-[110svh] w-full relative bg-black mt-[5rem]"
>
<video
:src="selectedVariant.video"
class="object-cover absolute size-full"
@@ -28,14 +65,22 @@ const { selectedVariant } = inject("productVariant") as ProductVariantProvideTyp
loop
/>
<div class="size-full absolute inset-0 bg-black/20" />
<div class="absolute max-sm:flex items-center justify-center max-sm:px-5 sm:right-10 bottom-10 w-full">
<StickyCard
:color="selectedVariant.color!"
:price="selectedVariant.price"
:picture="selectedVariant.images[0].image"
:title="product!.name"
class="max-sm:!w-full"
/>
</div>
<Transition
name="fade"
:duration="150"
>
<div
v-if="isVideoSectionVisible && !isVideoSectionHittingBottom"
class="fixed max-sm:flex max-sm:w-full items-center justify-center max-sm:px-5 sm:right-10 bottom-10 h-fit w-fit"
>
<StickyCard
:color="selectedVariant.color!"
:price="selectedVariant.price"
:picture="selectedVariant.images[0].image"
:title="product!.name"
class="max-sm:!w-full"
/>
</div>
</Transition>
</section>
</template>
+2 -2
View File
@@ -49,13 +49,13 @@
"workbox-window": "^7.3.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.9",
"@tailwindcss/postcss": "^4.1.4",
"@types/node": "^22.13.11",
"@types/sanitize-html": "^2.13.0",
"@types/web-push": "^3.6.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.9",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.2",
"vue-tsc": "^2.2.8"
}
+6 -9
View File
@@ -1,14 +1,14 @@
<script lang="ts" setup>
// import
import useHomeData from "~/composables/api/home/useHomeData";
import ProductsGrid from "~/components/global/ProductsGrid.vue";
import { useStorage } from "@vueuse/core";
// state
const { data: homeData, suspense } = useHomeData();
const disableLoadingOverlay = useState("disableLoadingOverlay", () => false);
// ssr
@@ -17,7 +17,7 @@ const response = await suspense();
if (response.isError) {
throw createError({
statusCode: 500,
statusMessage: `Landing error : ${response.error.message}`
statusMessage: `Landing error : ${response.error.message}`,
});
}
@@ -26,12 +26,11 @@ if (response.isError) {
onMounted(() => {
window.scrollTo(0, 0);
});
</script>
<template>
<div class="w-full">
<LoadingOverlay v-if="!disableLoadingOverlay" />
<LoadingOverlay />
<Hero class="mb-20 max-md:mt-[80px]" />
<Preview />
<ProductsShowcase class="mb-40" />
@@ -41,8 +40,6 @@ onMounted(() => {
/>
<Categories class="mt-40" />
<Brands />
<ClientOnly>
<LatestStories class="mb-20" />
</ClientOnly>
<LatestStories class="mb-20" />
</div>
</template>
</template>
+10 -10
View File
@@ -1,5 +1,4 @@
<script lang="ts" setup>
// import
import ChatButton from "~/components/product/ChatBox/ChatButton.vue";
@@ -18,18 +17,20 @@ const { suspense: suspenseComments } = useGetComments(id, page);
const selectedVariant = ref<ProductVariant>();
const showChatButton = ref(true);
// type
export type ProductVariantProvideType = {
selectedVariant: typeof selectedVariant,
changeSelectedVariant: (value: ProductVariant) => void
}
selectedVariant: typeof selectedVariant;
changeSelectedVariant: (value: ProductVariant) => void;
};
// provide / inject
provide("productVariant", {
selectedVariant,
changeSelectedVariant: (value: ProductVariant) => selectedVariant.value = value
changeSelectedVariant: (value: ProductVariant) => (selectedVariant.value = value),
});
// ssr
@@ -40,22 +41,21 @@ const commentsResponse = await suspenseComments();
if (productResponse.isError || commentsResponse.isError) {
throw createError({
statusCode: 404,
statusMessage: `error : product ${id} prefetch error`
statusMessage: `error : product ${id} prefetch error`,
});
}
</script>
<template>
<div class="w-full flex flex-col ">
<div class="w-full flex flex-col">
<ProductHero />
<ProductVideo />
<ProductVideo v-model:showChatButton="showChatButton" />
<ProductComments />
<ProductDetails />
<ProductsGrid
title="محصولات مشابه"
:products="product!.related_products"
/>
<ChatButton />
<ChatButton :showChatButton="showChatButton" />
</div>
</template>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 745 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 KiB

After

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 944 KiB

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.