This commit is contained in:
Parsa Nazer
2025-03-19 20:08:58 +03:30
25 changed files with 522 additions and 253 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ const { isLoading } = useImage({ src: src.value });
<template> <template>
<AvatarRoot <AvatarRoot
class="flex-center size-full select-none rounded-full align-middle overflow-hidden" class="flex-center size-full select-none rounded-full align-middle overflow-hidden inset-shadow-black/20 inset-shadow-sm"
> >
<Skeleton <Skeleton
v-if="isLoading" v-if="isLoading"
+11 -13
View File
@@ -32,7 +32,7 @@ const isHomePage = computed(() => route.path === "/");
<div <div
class="size-full flex items-center justify-between container h-[65px] lg:h-[85px] shrink-0 grow-0" class="size-full flex items-center justify-between container h-[65px] lg:h-[85px] shrink-0 grow-0"
> >
<button class="md:hidden" @click="isSideDrawerOpen = true"> <button class="md:hidden header-navbar-item" @click="isSideDrawerOpen = true">
<Icon name="humbleicons:bars" size="28" /> <Icon name="humbleicons:bars" size="28" />
</button> </button>
<div class="max-md:hidden flex items-center gap-8 lg:gap-16"> <div class="max-md:hidden flex items-center gap-8 lg:gap-16">
@@ -42,10 +42,10 @@ const isHomePage = computed(() => route.path === "/");
<Tooltip v-if="!!account && !!token" title="حساب کاربری"> <Tooltip v-if="!!account && !!token" title="حساب کاربری">
<NuxtLink <NuxtLink
:to="{ name: 'profile' }" :to="{ name: 'profile' }"
class="!size-[1.6rem] flex items-center justify-center relative overflow-hidden rounded-full border-[1.2px] border-black" class="flex items-center justify-center"
> >
<Avatar <Avatar
class="!size-[1.6rem]" class="!size-7"
:src="account.profile_photo" :src="account.profile_photo"
:alt=" :alt="
account.first_name && account.last_name account.first_name && account.last_name
@@ -66,7 +66,7 @@ const isHomePage = computed(() => route.path === "/");
</NuxtLink> </NuxtLink>
</Tooltip> </Tooltip>
<Tooltip title="محصولات"> <Tooltip title="محصولات">
<NuxtLink to="/products" class="flex-center"> <NuxtLink to="/products" class="flex-center header-navbar-item">
<Icon <Icon
name="ci:search" name="ci:search"
class="**:stroke-black size-4.5 lg:size-[21px]" class="**:stroke-black size-4.5 lg:size-[21px]"
@@ -78,22 +78,20 @@ const isHomePage = computed(() => route.path === "/");
<button class="relative"> <button class="relative">
<Icon <Icon
name="ci:cart" name="ci:cart"
class="**:stroke-black size-5 lg:size-6" class="**:stroke-black size-5 lg:size-6 header-navbar-item"
/> />
<span <div
v-if="cart?.items.length! > 0" v-if="cart?.items.length! > 0"
class="size-4 shrink-0 absolute -bottom-2 -right-2 flex-center rounded-sm text-white bg-red-600 text-xs animate-pulse" class="size-2 shrink-0 absolute -bottom-1.5 -right-1.5 rounded-full bg-red-600 after:size-[125%] after:absolute after:bg-red-600 flex-center after:rounded-full after:animate-ping"
> />
{{ cart?.items.length }}
</span>
</button> </button>
</NuxtLink> </NuxtLink>
</Tooltip> </Tooltip>
</div> </div>
<nav <nav
class="flex-center gap-6 lg:gap-[2.5rem] typo-label-xs lg:typo-label-sm font-light text-black/80" class="flex-center gap-6 lg:gap-[2.5rem] typo-label-xs lg:typo-label-sm font-light text-black/80 header-navbar-item"
> >
<NuxtLink <NuxtLink
v-for="(link, index) in NAV_LINKS" v-for="(link, index) in NAV_LINKS"
@@ -105,10 +103,10 @@ const isHomePage = computed(() => route.path === "/");
</nav> </nav>
</div> </div>
<div> <div class="header-navbar-item">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 lg:h-6" class="h-5 lg:h-6 "
fill="none" fill="none"
viewBox="0 0 220 40" viewBox="0 0 220 40"
> >
+28 -9
View File
@@ -3,29 +3,33 @@
// state // state
const { $gsap: gsap } = useNuxtApp(); const { $gsap: gsap } = useNuxtApp();
const showLoadingOverlay = useState("showLoadingOverlay");
const disableLoadingOverlay = useState("disableLoadingOverlay");
const shouldRenderLoadingOverlay = ref(true);
const showLoadingOverlay = useState('showLoadingOverlay'); const isWindowScrollLocked = useScrollLock(window);
// lifecycle // watch
watch(() => showLoadingOverlay.value, (value) => { watch(() => showLoadingOverlay.value, (value) => {
if (!value) { if (!value) {
const timeline = gsap.timeline(); const timeline = gsap.timeline();
const imageElement = document.querySelector("#loading-overlay-image") as HTMLImageElement;
imageElement.src = "/img/heymlz-loading-2.gif";
timeline timeline
.to("#loading-overlay", { .to("#loading-overlay", {
scale: 1 scale: 1
}) })
.to("#loading-overlay", { .to("#loading-overlay", {
scale: 0.8,
opacity: 0, opacity: 0,
delay: 2.5 delay: 2,
})
.to("#loading-overlay", {
opacity: 0,
y: "20%",
onComplete: () => { onComplete: () => {
shouldRenderLoadingOverlay.value = false; shouldRenderLoadingOverlay.value = false;
isWindowScrollLocked.value = false;
disableLoadingOverlay.value = true;
} }
}); });
} }
@@ -33,6 +37,16 @@ watch(() => showLoadingOverlay.value, (value) => {
once: true once: true
}); });
// lifecycle
onMounted(() => {
isWindowScrollLocked.value = true;
const newImage = new Image();
newImage.src = "/img/heymlz-loading-2.gif";
});
</script> </script>
<template> <template>
@@ -41,7 +55,12 @@ watch(() => showLoadingOverlay.value, (value) => {
id="loading-overlay" id="loading-overlay"
class="fixed inset-0 size-full z-9999 flex-center bg-black" class="fixed inset-0 size-full z-9999 flex-center bg-black"
> >
<img id="loading-overlay-image" src="/video/loading-2.gif" class="opacity-0 scale-70 absolute z-20" alt="" /> <img
id="loading-overlay-image"
src="/img/heymlz-loading-1.gif"
class="opacity-0 scale-70 absolute z-20"
alt=""
/>
<div <div
id="loading-overlay-gradient" 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]" 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]"
+2 -2
View File
@@ -49,10 +49,10 @@ const closeSideDrawer = () => {
<Tooltip v-if="!!account && !!token" title="حساب کاربری"> <Tooltip v-if="!!account && !!token" title="حساب کاربری">
<NuxtLink <NuxtLink
:to="{ name: 'profile' }" :to="{ name: 'profile' }"
class="!size-[1.6rem] flex items-center justify-center relative overflow-hidden rounded-full border-[1.2px] border-black" class="flex items-center justify-center"
> >
<Avatar <Avatar
class="!size-[1.6rem]" class="!size-7"
:src="account.profile_photo" :src="account.profile_photo"
:alt=" :alt="
account.first_name && account.last_name account.first_name && account.last_name
+2 -1
View File
@@ -51,9 +51,10 @@ const { data: account } = useGetAccount();
<template #trigger> <template #trigger>
<button <button
v-if="!!account" v-if="!!account"
class="size-[1.6rem] flex items-center justify-center relative overflow-hidden rounded-full border border-black" class="flex items-center justify-center"
> >
<Avatar <Avatar
class="!size-7"
:src="account.profile_photo" :src="account.profile_photo"
:alt=" :alt="
account.first_name && account.last_name account.first_name && account.last_name
@@ -5,6 +5,7 @@
type Props = { type Props = {
modelValue: number; modelValue: number;
max: number; max: number;
disable: boolean;
} }
// props // props
@@ -48,6 +49,7 @@ const onInput = (e: any) => {
<template> <template>
<NumberFieldRoot <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"
v-model="currentQuantity" v-model="currentQuantity"
:min="1" :min="1"
@@ -58,7 +60,7 @@ const onInput = (e: any) => {
</NumberFieldIncrement> </NumberFieldIncrement>
<NumberFieldInput <NumberFieldInput
@input="onInput" @input="onInput"
class="field-sizing-content bg-transparent w-[30px] text-center outline-none typo-label-md text-black" class="field-sizing-content w-[30px] bg-transparent text-center outline-none typo-label-md text-black"
/> />
<NumberFieldDecrement class="cursor-pointer"> <NumberFieldDecrement class="cursor-pointer">
<Icon name="ci:minus" class="**:stroke-slate-500 size-5" /> <Icon name="ci:minus" class="**:stroke-slate-500 size-5" />
@@ -0,0 +1,87 @@
<script lang="ts" setup>
// import
import useGetProduct from "~/composables/api/product/useGetProduct";
import useAddCartItem from "~/composables/api/orders/useAddCartItem";
import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
// provide / inject
const { selectedVariant } = inject(
"productVariant"
) as ProductVariantProvideType;
// state
const route = useRoute();
const id = route.params.id as string | undefined;
const { refetch: refetchProduct } = useGetProduct(id);
const { mutateAsync: addCartItem, isPending: isAddCartItemPending } = useAddCartItem();
const timer = ref<NodeJS.Timeout | null>(null);
const quantity = ref(1);
// methods
const onInput = (e: any) => {
const value = Number(e.target.value);
if (value > 0 && value <= selectedVariant.value!.in_stock) {
quantity.value = value;
} else {
quantity.value = 1;
}
};
watch(
() => quantity.value,
(newValue) => {
if (timer.value) clearTimeout(timer.value);
timer.value = setTimeout(async () => {
await addCartItem({ id: selectedVariant.value!.id, quantity: newValue });
await refetchProduct();
}, 350);
}
);
watch(selectedVariant, (newValue) => {
quantity.value = newValue!.cart_quantity;
});
// lifecycle
onMounted(() => {
quantity.value = selectedVariant.value!.cart_quantity;
});
</script>
<template>
<NumberFieldRoot
class="rounded-full border-slate-200 border-[1.5px] flex items-center bg-white gap-4 p-4"
v-model="quantity"
:min="1"
:max="selectedVariant!.in_stock"
>
<NumberFieldIncrement class="cursor-pointer">
<Icon name="ci:plus" class="**:stroke-slate-500 size-5" />
</NumberFieldIncrement>
<div class="relative">
<div
:class="isAddCartItemPending ? 'opacity-100' : 'opacity-0'"
class="w-[40px] h-[25px] flex-center transition-all absolute bg-white"
>
<Icon :name="'svg-spinners:180-ring-with-bg'" class="size-[25px]" />
</div>
<NumberFieldInput
@input="onInput"
:class="!isAddCartItemPending ? 'opacity-100' : 'opacity-0'"
class="transition-all field-sizing-content w-[40px] h-[25px] bg-transparent text-center outline-none typo-label-md text-black"
/>
</div>
<NumberFieldDecrement class="cursor-pointer">
<Icon name="ci:minus" class="**:stroke-slate-500 size-5" />
</NumberFieldDecrement>
</NumberFieldRoot>
</template>
+14 -14
View File
@@ -32,7 +32,9 @@ let scrollTrigger: ScrollTrigger;
// methods // methods
const onSwiper = (swiper: SwiperClass) => { const onSwiper = (swiper: SwiperClass) => {
showLoadingOverlay.value = false; setTimeout(() => {
showLoadingOverlay.value = false;
}, 1000);
swiper_instance.value = swiper; swiper_instance.value = swiper;
}; };
@@ -70,11 +72,9 @@ const initializeGsapAnimation = () => {
}, { }, {
value: 1.2 value: 1.2
}, "=") }, "=")
.fromTo("#header-navbar", { .fromTo(".header-navbar-item", {
background: "transparent",
filter: "invert(100%)" filter: "invert(100%)"
}, { }, {
background: "transparent",
filter: "invert(0%)" filter: "invert(0%)"
}, "=") }, "=")
.fromTo("#header-navbar", { .fromTo("#header-navbar", {
@@ -96,7 +96,9 @@ const initializeGsapAnimation = () => {
const resetTimelineForMobile = () => { const resetTimelineForMobile = () => {
gsap.to("#header-navbar", { gsap.to("#header-navbar", {
background: "white", background: "white"
});
gsap.to(".header-navbar-item", {
filter: "invert(0%)" filter: "invert(0%)"
}); });
gsap.set(".header-slider-item", { gsap.set(".header-slider-item", {
@@ -113,6 +115,7 @@ onMounted(() => {
initializeGsapAnimation(); initializeGsapAnimation();
scrollTrigger = ScrollTrigger.create({ scrollTrigger = ScrollTrigger.create({
anticipatePin: 1,
trigger: "#header-slider-container", trigger: "#header-slider-container",
animation: gsapTimeline, animation: gsapTimeline,
scrub: 1, scrub: 1,
@@ -124,27 +127,23 @@ onMounted(() => {
const calculateOnResize = () => { const calculateOnResize = () => {
if (window.innerWidth > 768) { if (window.innerWidth > 768) {
gsap.to("#header-navbar", {
background: "transparent",
filter: "invert(100%)"
});
scrollTrigger.enable(); scrollTrigger.enable();
} else { } else {
resetTimelineForMobile(); resetTimelineForMobile();
scrollTrigger.disable(); scrollTrigger.disable();
} }
} };
setTimeout(() => { setTimeout(() => {
calculateOnResize() calculateOnResize();
}, 100); }, 100);
setTimeout(() => { setTimeout(() => {
calculateOnResize() calculateOnResize();
}, 200); }, 200);
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
calculateOnResize() calculateOnResize();
}); });
}); });
@@ -213,7 +212,8 @@ onUnmounted(() => {
<div class="flex items-center gap-4 lg:gap-8"> <div class="flex items-center gap-4 lg:gap-8">
<div <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" /> <div
class="size-full scale-75 bg-white absolute rounded-full animate-ping" />
<button <button
@click="isMuted = !isMuted" @click="isMuted = !isMuted"
class="transition-all cursor-pointer flex-center bg-white z-10 size-full rounded-full" class="transition-all cursor-pointer flex-center bg-white z-10 size-full rounded-full"
+79 -88
View File
@@ -6,7 +6,7 @@ import useHomeData from "~/composables/api/home/useHomeData";
// state // state
const { data : homeData } = useHomeData(); const { data: homeData } = useHomeData();
const { $gsap: gsap, $ScrollTrigger: ScrollTrigger } = useNuxtApp(); const { $gsap: gsap, $ScrollTrigger: ScrollTrigger } = useNuxtApp();
@@ -17,71 +17,60 @@ let scrollTrigger: ScrollTrigger;
onMounted(() => { onMounted(() => {
gsapTimeline = gsap.timeline(); gsapTimeline = gsap.timeline();
gsapTimeline
.fromTo("#header-navbar", {
background: "white",
filter: "invert(0%)"
}, {
background: "transparent",
filter: "invert(100%)"
});
const showcaseElements = gsap.utils.toArray<HTMLElement>(".showcase-slide"); const showcaseElements = gsap.utils.toArray<HTMLElement>(".showcase-slide");
showcaseElements.forEach((element, index) => {
gsapTimeline.fromTo(element, index === 0 ? {
opacity: 1,
scale: 1,
// rotateX: -25,
y: 0,
ease: "none"
} : {
opacity: 0,
scale: 0.97,
// rotateX: -25,
y: 20,
ease: "none"
}, {
opacity: 1,
scale: 1,
// rotateX: 0,
y: 0,
ease: "none"
}, index === 0 ? "-=0%" : undefined);
if (index < showcaseElements.length - 1) {
gsapTimeline.to(element, {
opacity: 0,
scale: 1.03,
// rotateX: 25,
y: -20,
ease: "none"
});
}
});
gsapTimeline.to("#header-navbar", {
background: "white",
filter: "invert(0%)"
});
scrollTrigger = ScrollTrigger.create({
trigger: "#products-showcase-container",
animation: gsapTimeline,
scrub: 1,
pin: true,
start: "top top",
// markers: true,
end: "bottom top"
});
setTimeout(() => { setTimeout(() => {
scrollTrigger.refresh() showcaseElements.forEach((element, index) => {
}, 1000);
gsapTimeline.fromTo(element, index === 0 ? {
opacity: 1,
scale: 1,
// rotateX: -25,
top: 0,
ease: "none"
} : {
opacity: 0,
scale: 1,
// rotateX: -25,
top: 20,
ease: "none"
}, {
opacity: 1,
scale: 1,
// rotateX: 0,
top: 0,
ease: "none"
}, index === 0 ? "-=0%" : undefined);
if (index < showcaseElements.length - 1) {
gsapTimeline.to(element, {
opacity: 0,
scale: 1.03,
// rotateX: 25,
top: -20,
ease: "none"
});
}
});
scrollTrigger = ScrollTrigger.create({
trigger: "#products-showcase-container",
animation: gsapTimeline,
scrub: 1,
pin: true,
start: "top top",
anticipatePin: 1,
// markers: true,
end: "bottom top"
});
setTimeout(() => {
scrollTrigger.update();
scrollTrigger.refresh();
}, 1000);
}, 1000);
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -92,33 +81,35 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div <section
id="products-showcase-container" id="products-showcase-container"
class="perspective-midrange w-full h-[125svh] bg-black flex items-center justify-center" class="perspective-midrange relative z-[99999]"
> >
<NuxtLink <div class="w-full h-[125svh] bg-black">
v-for="slide in homeData!.show_case_slider" <NuxtLink
:key="slide.id" v-for="slide in homeData!.show_case_slider"
:to="slide.link" :key="slide.id"
class="showcase-slide origin-bottom absolute size-full bg-transparent flex items-center justify-center" :to="slide.link"
> class="showcase-slide origin-bottom absolute size-full bg-transparent flex items-center justify-center"
>
<img <img
class="w-[280px] xs:w-[350px] lg:w-[500px] xl:w-[650px] z-20 mb-30 sm:mb-20 lg:mb-30" class="w-[280px] xs:w-[350px] lg:w-[500px] xl:w-[650px] z-20 mb-30 sm:mb-20 lg:mb-30"
:src="slide.image" :src="slide.image"
:style="{ :style="{
mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0) 100%)', mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0) 100%)',
}" }"
alt="" alt=""
/> />
<div class="flex flex-col items-center justify-center gap-4 text-center absolute z-20 mt-20"> <div class="flex flex-col items-center justify-center gap-4 text-center absolute z-20 mt-20">
<span class="text-white typo-h-6 sm:typo-h-5 lg:typo-h-4 xl:typo-h-3"> <span class="text-white typo-h-6 sm:typo-h-5 lg:typo-h-4 xl:typo-h-3">
{{ slide.title }} {{ slide.title }}
</span> </span>
<p class="text-white max-w-[320px] xs:max-w-[360px] sm:max-w-[480px] lg:max-w-[550px] xl:max-w-[750px] typo-p-sm lg:typo-p-md xl:typo-p-lg"> <p class="text-white max-w-[320px] xs:max-w-[360px] sm:max-w-[480px] lg:max-w-[550px] xl:max-w-[750px] typo-p-sm lg:typo-p-md xl:typo-p-lg">
{{ slide.description }} {{ slide.description }}
</p> </p>
</div> </div>
</NuxtLink> </NuxtLink>
</div> </div>
</section>
</template> </template>
@@ -71,7 +71,7 @@ await suspense();
</NuxtLink> </NuxtLink>
</div> </div>
<Avatar <Avatar
class="!size-[3rem]" class="!size-12"
:src="account!.profile_photo" :src="account!.profile_photo"
:alt=" :alt="
account?.first_name && account?.last_name account?.first_name && account?.last_name
@@ -134,15 +134,11 @@ onFileDialogChange((files: any) => {
</div> </div>
<div class="w-full flex-col-center gap-5"> <div class="w-full flex-col-center gap-5">
<div <Avatar
class="size-32 border border-slate-200 rounded-full" :src="currentProfile"
> alt=""
<Avatar class="!size-32"
:src="currentProfile" />
:alt="''"
class="size-full"
/>
</div>
<Button <Button
class="rounded-full w-[8rem]" class="rounded-full w-[8rem]"
@click="openFileDialog" @click="openFileDialog"
@@ -0,0 +1,28 @@
<script lang="ts" setup>
// import
import { usePersianTimeAgo } from "~/composables/global/usePersianTimeAgo";
// type
type Props = {
date: string
}
// props
const props = defineProps<Props>();
const { date } = toRefs(props);
// state
const timeAgo = usePersianTimeAgo(new Date(date.value));
</script>
<template>
<div class="px-3 pb-1 pt-1.5 rounded-lg bg-neutral-600 text-white" dir="rtl">
{{ timeAgo }}
</div>
</template>
+9
View File
@@ -0,0 +1,9 @@
export default defineNuxtRouteMiddleware(() => {
const config = useRuntimeConfig();
if (config.public.DEBUG === "true") {
return;
} else {
return navigateTo("/");
}
});
+27 -26
View File
@@ -6,45 +6,45 @@ export default defineNuxtConfig({
css: [ css: [
"~/assets/css/tailwind.css", "~/assets/css/tailwind.css",
"swiper/css", "swiper/css",
"animate.css/animate.min.css", "animate.css/animate.min.css"
], ],
routeRules: { routeRules: {
"/products": { prerender: false, ssr: false }, "/products": { prerender: false, ssr: false }
}, },
app: { app: {
head: { head: {
title: "فروشگاه هی ملز", title: "فروشگاه هی ملز"
}, },
pageTransition: { pageTransition: {
enterActiveClass: enterActiveClass:
"animate__animated animate__fadeIn animate__faster", "animate__animated animate__fadeIn animate__faster",
leaveActiveClass: leaveActiveClass:
"animate__animated animate__fadeOut animate__faster", "animate__animated animate__fadeOut animate__faster",
mode: "out-in", mode: "out-in"
}, },
layoutTransition: { layoutTransition: {
enterActiveClass: enterActiveClass:
"animate__animated animate__fadeIn animate__faster", "animate__animated animate__fadeIn animate__faster",
leaveActiveClass: leaveActiveClass:
"animate__animated animate__fadeOut animate__faster", "animate__animated animate__fadeOut animate__faster",
mode: "out-in", mode: "out-in"
}, }
}, },
postcss: { postcss: {
plugins: { plugins: {
"@tailwindcss/postcss": {}, "@tailwindcss/postcss": {},
autoprefixer: {}, autoprefixer: {}
}, }
}, },
components: [ components: [
{ {
path: "~/components", path: "~/components",
pathPrefix: false, pathPrefix: false
}, }
], ],
icon: { icon: {
@@ -52,9 +52,9 @@ export default defineNuxtConfig({
customCollections: [ customCollections: [
{ {
prefix: "ci", prefix: "ci",
dir: "./public/icons", dir: "./public/icons"
}, }
], ]
}, },
modules: [ modules: [
@@ -65,15 +65,15 @@ export default defineNuxtConfig({
"DM Sans": "100..900", "DM Sans": "100..900",
Inter: "100..900", Inter: "100..900",
download: true, download: true,
inject: false, inject: false
}, }
}, }
], ],
"@nuxt/icon", "@nuxt/icon",
"reka-ui/nuxt", "reka-ui/nuxt",
"@vueuse/nuxt", "@vueuse/nuxt",
"@formkit/auto-animate/nuxt", "@formkit/auto-animate/nuxt",
"@vite-pwa/nuxt", "@vite-pwa/nuxt"
], ],
pwa: { pwa: {
@@ -88,26 +88,27 @@ export default defineNuxtConfig({
{ {
src: "/logo/logo-192x192.png", src: "/logo/logo-192x192.png",
sizes: "192x192", sizes: "192x192",
type: "image/png", type: "image/png"
}, },
{ {
src: "/logo/logo-512x512.png", src: "/logo/logo-512x512.png",
sizes: "512x512", sizes: "512x512",
type: "image/png", type: "image/png"
}, }
], ]
}, },
workbox: { workbox: {
navigateFallback: "/", navigateFallback: "/",
clientsClaim: true, clientsClaim: true,
skipWaiting: true, skipWaiting: true
}, },
devOptions: { enabled: true, type: "module" }, devOptions: { enabled: true, type: "module" }
}, },
runtimeConfig: { runtimeConfig: {
public: { public: {
API_BASE_URL: "https://api.heymlz.com", API_BASE_URL: process.env.API_BASE_URL,
}, DEBUG: process.env.DEBUG
}, }
}
}); });
+1
View File
@@ -29,6 +29,7 @@
"date-fns-jalali": "^4.1.0-0", "date-fns-jalali": "^4.1.0-0",
"fast-average-color": "^9.4.0", "fast-average-color": "^9.4.0",
"gsap": "^3.12.7", "gsap": "^3.12.7",
"highlight.js": "^11.11.1",
"isomorphic-dompurify": "^2.22.0", "isomorphic-dompurify": "^2.22.0",
"jalali-ts": "^8.0.0", "jalali-ts": "^8.0.0",
"nuxt": "^3.15.4", "nuxt": "^3.15.4",
+8 -1
View File
@@ -8,6 +8,7 @@ import ProductsGrid from "~/components/global/ProductsGrid.vue";
// state // state
const { data: homeData, suspense } = useHomeData(); const { data: homeData, suspense } = useHomeData();
const disableLoadingOverlay = useState("disableLoadingOverlay", () => false);
// ssr // ssr
@@ -20,11 +21,17 @@ if (response.isError) {
}); });
} }
// lifecycle
onMounted(() => {
window.scrollTo(0, 0);
});
</script> </script>
<template> <template>
<div class="w-full"> <div class="w-full">
<LoadingOverlay /> <LoadingOverlay v-if="!disableLoadingOverlay" />
<Hero class="mb-20 max-md:mt-[80px]" /> <Hero class="mb-20 max-md:mt-[80px]" />
<Preview /> <Preview />
<ProductsShowcase class="mb-40" /> <ProductsShowcase class="mb-40" />
+2 -1
View File
@@ -165,9 +165,10 @@ const handleSubmit = (withValidation: boolean) => {
> >
<div class="flex items-center justify-start gap-5 w-8/12"> <div class="flex items-center justify-start gap-5 w-8/12">
<div <div
class="size-32 shrink-0 rounded-full border border-slate-200 flex-center relative" class="relative shrink-0 rounded-full flex-center"
> >
<Avatar <Avatar
class="!size-32"
:src="account!.profile_photo" :src="account!.profile_photo"
:alt=" :alt="
account?.first_name && account?.last_name account?.first_name && account?.last_name
+2 -2
View File
@@ -123,7 +123,7 @@ const handleLogin = async () => {
}, },
}); });
navigateTo("/"); window.location.href = "/";
} catch (e) { } catch (e) {
otpCode.value = []; otpCode.value = [];
addToast({ message: "مشکلی پیش آمده" }); addToast({ message: "مشکلی پیش آمده" });
@@ -158,7 +158,7 @@ const resetForm = () => {
mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0.3) 80%)', mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0.3) 80%)',
}" }"
/> />
<div class="flex items-center justify-center flex-col size-full"> <div class="flex items-center justify-center flex-col size-full translate-y-[-80px]">
<img <img
class="aspect-square w-[300px] translate-y-[100px] animate-fade-in" class="aspect-square w-[300px] translate-y-[100px] animate-fade-in"
src="/img/heymlz-seat.gif" src="/img/heymlz-seat.gif"
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts" setup>
// import
import hljs from "highlight.js";
import javascript from "highlight.js/lib/languages/javascript";
import "highlight.js/styles/atom-one-dark.css";
import LogDate from "~/components/server-logs/LogDate.vue";
import { useQuery } from "@tanstack/vue-query";
// meta
definePageMeta({
middleware : "check-is-debug",
layout: "none"
});
// state
const { $axios: axios } = useNuxtApp();
const { data: serverLogs, isFetching, suspense } = useQuery({
queryKey: ["server-logs"],
queryFn: async () => {
const response = await axios.get("http://localhost:3000/api/server-logger");
return response.data.reverse();
},
refetchInterval: 5000,
staleTime: 0
});
await suspense();
// computed
const logIcon = (status: number) => {
if (status >= 200 && status < 300) return "bi:check-circle-fill";
return "bi:x-circle-fill";
};
// lifecycle
onMounted(() => {
hljs.registerLanguage("json", javascript);
hljs.highlightAll();
});
</script>
<template>
<div class="bg-neutral-900 w-full min-h-svh py-32">
<div class="fixed top-10 right-1/2 translate-x-1/2 flex-center" v-if="isFetching">
<Icon
name="svg-spinners:180-ring-with-bg"
class="size-12 mb-1 **:fill-neutral-500"
/>
</div>
<div class="w-full container flex flex-col gap-8">
<div
v-for="(log,index) in serverLogs"
:key="index"
class="border-2 p-5 rounded-xl log-item-animation"
:class="{
'bg-success-950/30 border-success-800' : log.status >= 200 && log.status < 300,
'bg-danger-950/30 border-danger-800' : log.status >= 400 && log.status < 600,
}"
>
<div class="flex items-center gap-4 mt-4">
<Icon
:name="logIcon(log.status)"
class="size-8 mb-1"
:class="{
'**:fill-success-500' : log.status >= 200 && log.status < 300,
'**:fill-danger-500' : log.status >= 400 && log.status < 600,
}"
/>
<h3 class="text-white font-medium text-3xl">
{{ log.url }}
</h3>
</div>
<div class="flex items-center gap-2 py-8">
<div
class="px-3 pb-1 pt-1.5 rounded-lg uppercase font-bold text-white"
:class="{
'bg-success-500' : log.status >= 200 && log.status < 300,
'bg-danger-500' : log.status >= 400 && log.status < 600,
}"
>
{{ log.method }}
</div>
<div
class="px-3 pb-1 pt-1.5 rounded-lg font-bold text-white"
:class="{
'bg-success-500' : log.status >= 200 && log.status < 300,
'bg-danger-500' : log.status >= 400 && log.status < 600,
}"
>
{{ log.status }}
</div>
<LogDate :date="log.date" />
</div>
<details class="text-white">
<summary class="cursor-pointer select-none">Details :</summary>
<div class="flex flex-col gap-2 mt-2 ml-4">
<details class="text-white">
<summary class="cursor-pointer select-none">Response :</summary>
<pre>
<code class="language-json">
{{ log.response }}
</code>
</pre>
</details>
<details class="text-white">
<summary class="cursor-pointer select-none">Req Headers :</summary>
<pre class="whitespace-pre-line">
<code class="language-json">
{{ log.requestHeaders }}
</code>
</pre>
</details>
<details class="text-white">
<summary class="cursor-pointer select-none">Res Headers :</summary>
<pre>
<code class="language-json">
{{ log.responseHeaders }}
</code>
</pre>
</details>
<details v-if="log.payload" class="text-white">
<summary class="cursor-pointer select-none">Payload :</summary>
<pre>
<code class="language-json">
{{ log.payload }}
</code>
</pre>
</details>
</div>
</details>
</div>
</div>
</div>
</template>
<style>
.log-item-animation {
animation-name: log-fade-in;
animation-duration: 0.5s;
}
@keyframes log-fade-in {
from {
opacity : 0;
scale: 0.8;
}
to {
opacity : 1;
scale: 1;
}
}
</style>
+3 -4
View File
@@ -29,11 +29,10 @@ export default defineNuxtPlugin(() => {
return response; return response;
}, },
async function(error) { async function(error) {
await Logger.axiosErrorLog(error);
// if (error.status === 401) { if (config.public.DEBUG === "true" && import.meta.server) {
// logout(); await Logger.axiosErrorLog(error);
// } }
return Promise.reject(error); return Promise.reject(error);
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+6
View File
@@ -0,0 +1,6 @@
import fs from "fs/promises";
export default defineEventHandler(async (event) => {
const oldLogs = await fs.readFile(".logs/log.json", "utf-8");
return JSON.parse(oldLogs) as Record<any, any>[];
});
+20 -80
View File
@@ -1,91 +1,31 @@
import fs from "fs/promises"; import fs from "fs/promises";
type LogType = {
title: string;
status?: "success" | "error" | "info" | "warning";
message?: string,
details?: any
}
class Logger { class Logger {
private static formatToMarkdown(log: LogType) {
const date = new Date();
let month = "" + (date.getMonth() + 1);
let day = "" + date.getDate();
let year = date.getFullYear();
let hour = date.getHours();
let minutes = date.getMinutes();
let seconds = date.getSeconds();
if (month.length < 2) {
month = "0" + month;
}
if (day.length < 2) {
day = "0" + day;
}
let markdownContent = "";
let icon = "️";
switch (log.status) {
case "info":
icon = "️";
break;
case "error":
icon = "‼️";
break;
case "warning":
icon = "⚠️";
break;
case "success":
icon = "✅";
break;
default :
icon = "️";
break;
}
markdownContent += `## ${icon} ${log.title} \n`;
// markdownContent += `## ${[year, month, day].join("-")} ${hour}:${minutes}:${seconds} \n`;
markdownContent += `## ${hour} : ${minutes} : ${seconds} \n`;
if (log.message) {
markdownContent += `**Message:**\n ${log.message}\n\n`;
}
if (log.details) {
markdownContent += `**Details:**\n\n\`\`\`json\n${JSON.stringify(log.details, null, 2)}\n\`\`\`\n\n`;
}
markdownContent += "<br></br>\n\n";
markdownContent += "---\n";
return markdownContent;
}
public static async log(info: LogType) {
const formattedLog = this.formatToMarkdown(info);
try {
await fs.appendFile(".logs/log.md", formattedLog);
} catch (e) {
console.error(e);
}
}
public static async axiosErrorLog(error: any) { public static async axiosErrorLog(error: any) {
const errorJson = error.toJSON();
const logData : LogType = { const nowDate = new Date();
title : error?.message,
message : `${error?.config?.method?.toUpperCase()} ${error?.config?.url}`,
details : error,
}
const formattedLog = this.formatToMarkdown(logData); const logData: AxiosLogType = {
url: errorJson.config.url,
code: errorJson.code!,
status: errorJson.status!,
method: errorJson.config.method,
response: error?.response?.data,
requestHeaders: errorJson.config.headers,
responseHeaders: error.response.headers,
payload: errorJson.config.data ? JSON.parse(errorJson.config.data) : undefined,
params: errorJson.config.params ?? undefined,
date: nowDate.toString()
};
try { try {
await fs.appendFile(".logs/log.md", formattedLog); const oldLogs = await fs.readFile(".logs/log.json", "utf-8");
const oldLogsJson = JSON.parse(oldLogs) as Record<any, any>[];
oldLogsJson.push(logData);
await fs.writeFile(".logs/log.json", JSON.stringify(oldLogsJson));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
+20
View File
@@ -8,6 +8,26 @@ declare global {
results: T[]; results: T[];
}; };
type LogType = {
title: string;
status?: "success" | "error" | "info" | "warning";
message?: string,
details?: any
}
type AxiosLogType = {
url: string,
method: string,
status: number,
code: string,
requestHeaders: Record<any, any>,
responseHeaders: Record<any, any>,
response?: Record<any, any>,
payload?: Record<any, any>,
params?: Record<any, any>,
date: string
}
type Chat = { type Chat = {
id: number; id: number;
sender: "ai" | "user"; sender: "ai" | "user";