Merge branch 'main' of https://github.com/Byeto-Company/hossein_por_shop
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"semi": true
|
||||
}
|
||||
"singleAttributePerLine": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"semi": true
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const closeModal = () => {
|
||||
<template>
|
||||
<div>
|
||||
<LoadingIndicator />
|
||||
|
||||
<NuxtPwaManifest />
|
||||
|
||||
<UpdatePwaModal
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))',
|
||||
}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 745 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 553 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 399 KiB After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 944 KiB After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 30 KiB |