update frontend

This commit is contained in:
Parsa Nazer
2026-05-28 10:35:35 +03:30
11 changed files with 238 additions and 124 deletions
@@ -113,11 +113,15 @@ const onSwiper = (swiper: SwiperClass) => {
</Swiper> </Swiper>
</div> </div>
<template #fallback> <template #fallback>
<div class="w-full grid grid-cols-3 sm:grid-cols-4 gap-6"> <div class="w-full flex sm:grid justify-between sm:grid-cols-4 gap-6">
<div class="bg-neutral-100 !size-full !rounded-2xl !aspect-square" /> <div
<div class="bg-neutral-100 !size-full !rounded-2xl !aspect-square" /> class="bg-neutral-100 items-stretch w-25 sm:size-full rounded-e-2xl sm:rounded-2xl sm:aspect-square"
<div class="bg-neutral-100 !size-full !rounded-2xl !aspect-square" /> />
<div class="bg-neutral-100 !size-full !rounded-2xl !aspect-square" /> <div class="bg-neutral-100 size-full rounded-2xl aspect-square" />
<div
class="bg-neutral-100 items-stretch w-25 sm:size-full rounded-s-2xl sm:rounded-2xl sm:aspect-square"
/>
<div class="bg-neutral-100 size-full rounded-2xl aspect-square max-sm:hidden" />
</div> </div>
</template> </template>
</ClientOnly> </ClientOnly>
@@ -1,21 +1,23 @@
<script lang="ts" setup> <script lang="ts" setup>
// types // types
type Props = { type Props = {
rate: number rate: number;
} haveRate?: boolean;
};
// props // props
defineProps<Props>(); defineProps<Props>();
</script> </script>
<template> <template>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="typo-p-sm font-normal translate-y-px"> <span class="typo-p-sm font-normal translate-y-px">
<template v-if="haveRate">
{{ rate }} {{ rate }}
</template>
<template v-else> ( بدون نظر ) </template>
</span> </span>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Icon <Icon
@@ -1,23 +0,0 @@
<template>
<div class="flex items-center gap-6">
<span class="typo-p-md text-black">
اشتراک گذاری:
</span>
<div class="flex items-center gap-3">
<NuxtLink>
<Icon name="ci:instagram" class="**:stroke-slate-500 size-6" />
</NuxtLink>
<NuxtLink>
<Icon name="ci:facebook" class="**:stroke-slate-500 size-6" />
</NuxtLink>
<NuxtLink>
<Icon name="ci:tiktok" class="**:stroke-slate-500 size-6" />
</NuxtLink>
<NuxtLink>
<Icon name="ci:youtube" class="**:stroke-slate-500 size-6" />
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
</script>
@@ -72,6 +72,7 @@ const changeSlide = (id: number) => {
</div> </div>
<div class="relative w-full"> <div class="relative w-full">
<ClientOnly>
<Swiper <Swiper
:slides-per-view="3" :slides-per-view="3"
:space-between="20" :space-between="20"
@@ -111,6 +112,14 @@ const changeSlide = (id: number) => {
</div> </div>
</SwiperSlide> </SwiperSlide>
</Swiper> </Swiper>
<template #fallback>
<div class="w-full grid grid-cols-3 gap-6">
<div class="bg-neutral-100 size-full rounded-2xl aspect-square" />
<div class="bg-neutral-100 size-full rounded-2xl aspect-square" />
<div class="bg-neutral-100 size-full rounded-2xl aspect-square" />
</div>
</template>
</ClientOnly>
<div <div
v-if="slides.length > 3" v-if="slides.length > 3"
@@ -102,7 +102,7 @@ const limitedComments = computed(() => {
v-if="!!comments" v-if="!!comments"
class="bg-slate-50" class="bg-slate-50"
> >
<div class="flex relative gap-8 my-42 container max-lg:flex-col"> <div class="flex relative gap-8 my-24 sm:my-42 container max-lg:flex-col">
<div <div
class="sticky top-0 flex flex-col gap-6 lg:min-w-100 h-fit bg-white p-8 rounded-xl border-[0.5px] border-slate-200" class="sticky top-0 flex flex-col gap-6 lg:min-w-100 h-fit bg-white p-8 rounded-xl border-[0.5px] border-slate-200"
> >
@@ -9,7 +9,7 @@ const { selectedVariant } = inject("productVariant") as ProductVariantProvideTyp
</script> </script>
<template> <template>
<section class="w-full container pt-20 flex flex-col gap-y-[1.5rem]"> <section class="w-full container sm:pt-20 flex flex-col gap-y-6">
<div class="w-full flex"> <div class="w-full flex">
<span class="text-black max-lg:hidden typo-h-4 mb-4"> جزئیات محصول </span> <span class="text-black max-lg:hidden typo-h-4 mb-4"> جزئیات محصول </span>
</div> </div>
@@ -0,0 +1,57 @@
<script lang="ts" setup>
// imports
import { useAuth } from "~/composables/api/auth/useAuth";
import useGetProduct from "~/composables/api/product/useGetProduct";
import useSaveProduct from "~/composables/api/product/useSaveProduct";
import { useToast } from "~/composables/global/useToast";
import { QUERY_KEYS } from "~/constants";
// states
const route = useRoute();
const id = route.params.id as string | undefined;
const { $queryClient: queryClient } = useNuxtApp();
const { token } = useAuth();
const { addToast } = useToast();
const { mutateAsync: saveProduct, isPending: isSaveProductPending } = useSaveProduct();
const { data: product, refetch: refetchProduct, isFetching: isFetchingPending } = useGetProduct(id);
// methods
const saveProductHandler = async () => {
if (!!token.value) {
await saveProduct({ product_slug: product.value!.slug });
await queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.product] });
} else {
addToast({
options: { status: "info" },
message: "برای ذخیره کردن لطفا وارد شوید.",
});
}
};
</script>
<template>
<button
@click="saveProductHandler"
:disabled="isSaveProductPending || isFetchingPending"
class="px-2 sm:px-3 py-2 flex items-center gap-2 bg-slate-50 border-slate-200 border rounded-lg flex-center"
>
<span class="typo-label-sm max-sm:hidden"> ذخیره </span>
<Icon
v-if="isSaveProductPending || isFetchingPending"
name="ci:svg-spinners-180-ring-with-bg"
/>
<Icon
v-else
:class="product?.added_to_favorites ? '**:fill-blue-400' : ''"
:name="product?.added_to_favorites ? 'bi-bookmark-fill' : 'ci:bi-bookmark'"
/>
</button>
</template>
@@ -0,0 +1,69 @@
<script lang="ts" setup>
// imports
import { useToast } from "~/composables/global/useToast";
// types
type Props = {
product: Product;
};
// props
const props = defineProps<Props>();
const { product } = toRefs(props);
// states
const { addToast } = useToast();
// methods
const shareProduct = async () => {
const shareData = {
title: product.value.name,
text: `لینک اشتراک گذاری محصول ${product.value.name}`,
url: window.location.href,
};
// Native share
if (navigator.share) {
try {
await navigator.share(shareData);
} catch (error) {
console.error("Share canceled or failed", error);
}
return;
}
// Fallback → copy link
try {
await navigator.clipboard.writeText(shareData.url);
addToast({
message: "لینک کالا کپی شد !",
});
} catch (error) {
console.error("Clipboard failed", error);
addToast({
options: { status: "error" },
message: "کپی لینک کالا با خطا مواجه شد !",
});
}
};
</script>
<template>
<button
@click="shareProduct"
class="px-2 py-2 flex items-center gap-2 bg-slate-50 border-slate-200 border rounded-lg flex-center"
>
<span class="typo-label-sm max-sm:hidden"> ارسال </span>
<Icon
name="ci:bi-share"
/>
</button>
</template>
@@ -5,8 +5,6 @@ import useGetProduct from "~/composables/api/product/useGetProduct";
import type { ProductVariantProvideType } from "~/pages/product/[id].vue"; import type { ProductVariantProvideType } from "~/pages/product/[id].vue";
import useAddCartItem from "~/composables/api/orders/useAddCartItem"; import useAddCartItem from "~/composables/api/orders/useAddCartItem";
import { useAuth } from "~/composables/api/auth/useAuth"; import { useAuth } from "~/composables/api/auth/useAuth";
import useSaveProduct from "~/composables/api/product/useSaveProduct";
import { QUERY_KEYS } from "~/constants";
// state // state
@@ -14,11 +12,9 @@ const route = useRoute();
const id = route.params.id as string | undefined; const id = route.params.id as string | undefined;
const { token } = useAuth(); const { token } = useAuth();
const { $queryClient: queryClient } = useNuxtApp();
const { data: product, refetch: refetchProduct, isFetching: isFetchingPending } = useGetProduct(id); const { data: product, refetch: refetchProduct, isFetching: isFetchingPending } = useGetProduct(id);
const { mutateAsync: addCartItem, isPending: isAddCartItemPending } = useAddCartItem(); const { mutateAsync: addCartItem, isPending: isAddCartItemPending } = useAddCartItem();
const { mutateAsync: saveProduct, isPending: isSaveProductPending } = useSaveProduct();
const selectedVariantId = ref(product.value!.variants[0].id); const selectedVariantId = ref(product.value!.variants[0].id);
const selectedQuantity = ref(1); const selectedQuantity = ref(1);
@@ -40,11 +36,6 @@ const addItemToCart = async () => {
await refetchProduct(); await refetchProduct();
}; };
const saveProductHandler = async () => {
await saveProduct({ product_slug: product.value!.slug });
await queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.product] });
};
// watch // watch
watch([selectedVariantId, product], ([selectedVariantId, product]) => { watch([selectedVariantId, product], ([selectedVariantId, product]) => {
@@ -77,31 +68,32 @@ watch(
</script> </script>
<template> <template>
<div class="flex max-lg:flex-col lg:gap-12 xl:gap-16 container pt-[5rem] pb-28"> <div class="flex max-lg:flex-col lg:gap-12 xl:gap-16 container pt-8 sm:pt-20 pb-28">
<div class="flex flex-col gap-3 lg:hidden"> <div class="flex flex-col gap-3 lg:hidden">
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<div class="flex items-center gap-1.5">
<NuxtLink
to="#"
class="typo-label-sm"
>
محصولات
</NuxtLink>
<Icon
name="ci:bi-chevron-left"
size="14"
/>
<NuxtLink <NuxtLink
to="#" to="#"
class="typo-label-sm" class="typo-label-sm"
> >
{{ product!.category.name }} {{ product!.category.name }}
</NuxtLink> </NuxtLink>
<button </div>
@click="saveProductHandler"
:disabled="isSaveProductPending || isFetchingPending || !token"
class="size-10 bg-slate-50 border-slate-200 border rounded-lg flex-center"
>
<Icon
v-if="isSaveProductPending || isFetchingPending"
name="ci:svg-spinners-180-ring-with-bg"
/>
<Icon <div class="flex items-center gap-2">
v-else <ShareButton :product="product!" />
:class="product?.added_to_favorites ? '**:fill-blue-400' : ''" <SaveButton />
:name="product?.added_to_favorites ? 'bi-bookmark-fill' : 'ci:bi-bookmark'" </div>
/>
</button>
</div> </div>
<h1 class="typo-h-6 xs:typo-h-5 sm:typo-h-4 lg:typo-h-2"> <h1 class="typo-h-6 xs:typo-h-5 sm:typo-h-4 lg:typo-h-2">
{{ product!.name }} {{ product!.name }}
@@ -136,7 +128,10 @@ watch(
<span class="max-sm:hidden"> تخفیف درصد </span> <span class="max-sm:hidden"> تخفیف درصد </span>
</div> </div>
</div> </div>
<Rating :rate="product!.rating" /> <Rating
:rate="product!.rating"
:have-rate="product!.rating !== 0"
/>
</div> </div>
</div> </div>
<Slider <Slider
@@ -146,28 +141,28 @@ watch(
/> />
<div class="lg:w-1/2 flex flex-col gap-3 mt-12"> <div class="lg:w-1/2 flex flex-col gap-3 mt-12">
<div class="flex items-center justify-between w-full max-lg:hidden"> <div class="flex items-center justify-between w-full max-lg:hidden">
<div class="flex items-center gap-2.5">
<NuxtLink
to="#"
class="typo-label-sm"
>
محصولات
</NuxtLink>
<Icon
name="ci:bi-chevron-left"
size="14"
/>
<NuxtLink <NuxtLink
to="#" to="#"
class="typo-label-sm" class="typo-label-sm"
> >
{{ product!.category.name }} {{ product!.category.name }}
</NuxtLink> </NuxtLink>
<button </div>
@click="saveProductHandler" <div class="flex items-center gap-2">
:disabled="isSaveProductPending || isFetchingPending || !token" <ShareButton :product="product!" />
class="size-10 bg-slate-50 border-slate-200 border rounded-lg flex-center" <SaveButton />
> </div>
<Icon
v-if="isSaveProductPending || isFetchingPending"
name="ci:svg-spinners-180-ring-with-bg"
/>
<Icon
v-else
:class="product?.added_to_favorites ? '**:fill-blue-400' : ''"
:name="product?.added_to_favorites ? 'bi-bookmark-fill' : 'ci:bi-bookmark'"
/>
</button>
</div> </div>
<h1 class="typo-h-4 xl:typo-h-3 max-lg:hidden"> <h1 class="typo-h-4 xl:typo-h-3 max-lg:hidden">
{{ product!.name }} {{ product!.name }}
@@ -205,12 +200,14 @@ watch(
<Rating <Rating
:rate="product!.rating" :rate="product!.rating"
:have-rate="product!.rating !== 0"
class="sm:hidden" class="sm:hidden"
/> />
</div> </div>
<Rating <Rating
:rate="product!.rating" :rate="product!.rating"
:have-rate="product!.rating !== 0"
class="max-sm:hidden" class="max-sm:hidden"
/> />
</div> </div>
@@ -338,8 +335,6 @@ watch(
</div> </div>
<InfoCard /> <InfoCard />
<Share />
</div> </div>
<ProductDescription <ProductDescription
+1 -1
View File
@@ -73,6 +73,6 @@ onMounted(() => {
</div> </div>
<ProductComments :product="product!" /> <ProductComments :product="product!" />
<ChatButton :showChatButton="showChatButton" /> <!-- <ChatButton :showChatButton="showChatButton" /> -->
</div> </div>
</template> </template>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><!-- Icon from Bootstrap Icons by The Bootstrap Authors - https://github.com/twbs/icons/blob/main/LICENSE.md --><path fill="currentColor" d="M13.5 1a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3M11 2.5a2.5 2.5 0 1 1 .603 1.628l-6.718 3.12a2.5 2.5 0 0 1 0 1.504l6.718 3.12a2.5 2.5 0 1 1-.488.876l-6.718-3.12a2.5 2.5 0 1 1 0-3.256l6.718-3.12A2.5 2.5 0 0 1 11 2.5m-8.5 4a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3m11 5.5a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3"/></svg>

After

Width:  |  Height:  |  Size: 528 B