This commit is contained in:
marzban-dev
2025-11-01 20:35:55 +03:30
parent 6524ae7605
commit 4fde6357d1
16 changed files with 292 additions and 67 deletions
+17 -7
View File
@@ -1,8 +1,22 @@
# Todos # Todos
[ ] Mega menu for categories in menu [x] Mega menu for categories in menu
[ ] Marquee animation bug ( Replace with nuxt ui marquee ) [x] Marquee animation bug
[x] Add favorite button for products
[x] Change footer to a dynamic ray animation
[x] Create saved products tab in user panel
[x] Add save button to product page
[x] Add save button to product page
[x] Make mega menu responsive
[x] Fix mega menu bugs
[ ] Pause marquee on mouse hover [ ] Pause marquee on mouse hover
@@ -14,8 +28,4 @@
<s>Test showcase background with gradient</s> <s>Test showcase background with gradient</s>
[ ] Add favorite button for products [ ] Compress all large pictures and videos
[x] Change footer to a dynamic ray animation
[ ] Compress all large pictures and videos
+1 -1
View File
@@ -26,7 +26,7 @@ const { colorObject } = useImageColor(`#category-image-${id.value}`);
</script> </script>
<template> <template>
<NuxtLink :to="`/products/category/${slug}`"> <NuxtLink :to="{ name: 'products-slug', params: { slug: ['category', slug] } }">
<div class="group relative rounded-150 overflow-hidden w-full aspect-square bg-white brightness-[97%]"> <div class="group relative rounded-150 overflow-hidden w-full aspect-square bg-white brightness-[97%]">
<Transition name="fade"> <Transition name="fade">
<video <video
+1 -1
View File
@@ -9,7 +9,7 @@
<Button <Button
end-icon="ci:filter" end-icon="ci:filter"
variant="outlined" variant="outlined"
class="max-lg:size-11 rounded-xl max-lg:aspect-square lg:py-3.5" class="max-lg:size-11 rounded-xl max-lg:aspect-square lg:py-3.5 relative"
> >
<span class="hidden lg:block"> فیلتر محصولات </span> <span class="hidden lg:block"> فیلتر محصولات </span>
</Button> </Button>
+18 -13
View File
@@ -31,7 +31,7 @@ const isScrollLocked = useScrollLock(window);
const navbarEl = ref<HTMLElement | null>(null); const navbarEl = ref<HTMLElement | null>(null);
const { height: navbarHeight } = useElementSize(navbarEl); const { height: navbarHeight } = useElementSize(navbarEl);
const { $gsap: gsap } = useNuxtApp(); const { $gsap: gsap, hooks } = useNuxtApp();
let gsapTimeline: gsap.core.Timeline; let gsapTimeline: gsap.core.Timeline;
const prevNavbarStyle = ref({ const prevNavbarStyle = ref({
@@ -71,6 +71,12 @@ const updatePrevStyle = () => {
prevNavbarStyle.value.itemFilter = getComputedStyle(headerNavbarItemEl).filter; prevNavbarStyle.value.itemFilter = getComputedStyle(headerNavbarItemEl).filter;
}; };
const closeAfterNavigate = () => {
hooks.hookOnce("page:finish", () => {
emit("update:isOpen", false);
});
};
// watches // watches
watch( watch(
@@ -92,12 +98,9 @@ watch(isOpen, async (newValue) => {
updatePrevStyle(); updatePrevStyle();
gsapTimeline gsapTimeline
.to( .to(".header-navbar-item", {
".header-navbar-item", filter: "invert(0%)",
{ })
filter: "invert(0%)",
}
)
.to( .to(
"#header-navbar", "#header-navbar",
{ {
@@ -146,7 +149,7 @@ onMounted(() => {
<div <div
v-if="isOpen" v-if="isOpen"
:style="{ marginTop: `${navbarHeight}px` }" :style="{ marginTop: `${navbarHeight}px` }"
class="fixed right-0 top-0 w-full z-999 bg-black/50 h-svh border-t border-slate-200 flex justify-center" class="max-md:hidden fixed right-0 top-0 w-full z-1100 bg-black/50 h-svh border-t border-slate-200 flex justify-center"
> >
<div class="container"> <div class="container">
<div <div
@@ -157,7 +160,7 @@ onMounted(() => {
<button <button
v-for="(item, index) in items" v-for="(item, index) in items"
:key="item.title" :key="item.title"
class="text-black transition-all p-4 rounded-xl cursor-default text-start" class="text-black transition-all p-4 rounded-xl cursor-default text-start max-lg:text-sm"
:class="index === selectedItem ? 'bg-blue-100 text-blue-500' : 'bg-slate-100'" :class="index === selectedItem ? 'bg-blue-100 text-blue-500' : 'bg-slate-100'"
:id="`mega-menu-tab-${index}`" :id="`mega-menu-tab-${index}`"
@mouseenter="menuTabMouseEnter(index)" @mouseenter="menuTabMouseEnter(index)"
@@ -175,7 +178,7 @@ onMounted(() => {
> >
<div <div
:key="selectedItem" :key="selectedItem"
class="grid grid-cols-4 p-4 text-back w-full h-fit" class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 p-4 text-back w-full h-fit"
:style="{ :style="{
// gridTemplateRows: 'repeat(5, auto)', // gridTemplateRows: 'repeat(5, auto)',
// justifyContent: 'center', // justifyContent: 'center',
@@ -183,10 +186,12 @@ onMounted(() => {
> >
<NuxtLink <NuxtLink
v-for="item in selectedItemSubItems" v-for="item in selectedItemSubItems"
:to="'https://google.com'" :to="{ name: 'products-slug', params: { slug: ['category', item.link] } }"
class="p-4 whitespace-nowrap h-fit text-slate-500 flex items-center justify-between hover:text-blue-500 hover:-translate-x-1.5 transition-all" @click="closeAfterNavigate"
class="p-4 whitespace-nowrap h-fit text-slate-500 flex items-center justify-between hover:text-blue-500 hover:-translate-x-1.5 transition-all max-lg:text-sm"
> >
<span class="truncate w-[90%]"> <span class="truncate w-[90%] flex items-center gap-2">
<span class="w-2 h-[3px] rounded-full bg-blue-400"> </span>
{{ item.title }} {{ item.title }}
</span> </span>
</NuxtLink> </NuxtLink>
+6 -4
View File
@@ -15,25 +15,27 @@ withDefaults(defineProps<Props>(), {
</script> </script>
<template> <template>
<section class="w-full flex flex-col gap-10 md:gap-[4rem] py-[5rem] container"> <section class="w-full flex flex-col gap-10 @min-[48rem]:gap-[4rem] py-[5rem] container @container">
<div <div
v-if="withHeader" v-if="withHeader"
class="w-full flex justify-between items-center" class="w-full flex justify-between items-center"
> >
<span class="text-black typo-h-6 max-sm:text-xl md:typo-h-5 lg:typo-h-4"> <span class="text-black typo-h-6 @max-[40rem]:text-xl @min-[48rem]:typo-h-5 @min-[64rem]:typo-h-4">
{{ title }} {{ title }}
</span> </span>
<NuxtLink to="/products"> <NuxtLink to="/products">
<Button <Button
variant="primary" variant="primary"
class="rounded-full max-md:h-[38px] max-md:text-xs" class="rounded-full @max-[48rem]:h-[38px] @max-[48rem]:text-xs"
end-icon="ci:arrow-left" end-icon="ci:arrow-left"
> >
نمایش همه نمایش همه
</Button> </Button>
</NuxtLink> </NuxtLink>
</div> </div>
<ul class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-y-8 gap-5 sm:gap-8"> <ul
class="grid grid-cols-2 @min-[40rem]:grid-cols-3 @min-[64rem]:grid-cols-4 @min-[80rem]:grid-cols-5 gap-y-8 gap-5 @min-[40rem]:gap-8"
>
<li <li
class="w-full" class="w-full"
v-for="product in products" v-for="product in products"
@@ -7,6 +7,7 @@ import { useIsMutating } from "@tanstack/vue-query";
import { MUTATION_KEYS } from "~/constants"; import { MUTATION_KEYS } from "~/constants";
import CloseButton from "~/components/product/ChatBox/CloseButton.vue"; import CloseButton from "~/components/product/ChatBox/CloseButton.vue";
import { useAuth } from "~/composables/api/auth/useAuth"; import { useAuth } from "~/composables/api/auth/useAuth";
import { useScroll } from "@vueuse/core";
// provide-inject // provide-inject
@@ -5,6 +5,8 @@ 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
@@ -12,8 +14,11 @@ 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 { data: product, refetch: refetchProduct } = useGetProduct(id); const { $queryClient: queryClient } = useNuxtApp();
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);
@@ -35,6 +40,11 @@ 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]) => {
@@ -113,12 +123,30 @@ watch(
:slides="selectedVariant!.images" :slides="selectedVariant!.images"
/> />
<div class="lg:w-1/2 flex flex-col gap-3 lg:mt-12"> <div class="lg:w-1/2 flex flex-col gap-3 lg:mt-12">
<NuxtLink <div class="flex items-center justify-between w-full">
to="#" <NuxtLink
class="typo-label-sm max-lg:hidden" to="#"
> class="typo-label-sm max-lg:hidden"
{{ product!.category.name }} >
</NuxtLink> {{ product!.category.name }}
</NuxtLink>
<button
@click="saveProductHandler"
:disabled="isSaveProductPending || isFetchingPending"
class="size-10 bg-slate-50 border-slate-200 border rounded-lg flex-center"
>
<Icon
v-if="isSaveProductPending || isFetchingPending"
name="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' : 'bi:bookmark'"
/>
</button>
</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 }}
</h1> </h1>
@@ -34,6 +34,12 @@ const profileLinks = ref([
path: { name: "profile-purchases-and-orders" }, path: { name: "profile-purchases-and-orders" },
matchPattern: /^profile-purchases-and-orders/, matchPattern: /^profile-purchases-and-orders/,
}, },
{
icon: "bi:bookmark",
title: "علاقه‌مندی ها",
path: { name: "profile-saved-products" },
matchPattern: /^profile-saved-products/,
},
{ {
icon: "bi:ticket", icon: "bi:ticket",
title: "تیکت ها", title: "تیکت ها",
@@ -95,26 +101,20 @@ const toggleSidebar = inject("toggleSidebar");
:src="account!.profile_photo" :src="account!.profile_photo"
:alt=" :alt="
account?.first_name && account?.last_name account?.first_name && account?.last_name
? `${account?.first_name.charAt( ? `${account?.first_name.charAt(0)} ${account?.last_name.charAt(0)}`
0
)} ${account?.last_name.charAt(0)}`
: 'بدون نام کاربری' : 'بدون نام کاربری'
" "
/> />
</div> </div>
</div> </div>
<div <div class="w-full flex flex-col gap-2 rounded-xl bg-slate-50 border border-slate-200 p-4">
class="w-full flex flex-col gap-2 rounded-xl bg-slate-50 border border-slate-200 p-4"
>
<NuxtLink <NuxtLink
v-for="(link, index) in profileLinks" v-for="(link, index) in profileLinks"
:key="index" :key="index"
:to="{ ...link.path }" :to="{ ...link.path }"
:class=" :class="
isLinkActive(link) isLinkActive(link) ? 'bg-black text-slate-100 **:fill-slate-100' : '**:fill-black hover:bg-gray-200'
? 'bg-black text-slate-100 **:fill-slate-100'
: '**:fill-black hover:bg-gray-200'
" "
class="flex items-center justify-between transition-all rounded-lg py-3.5 lg:py-4 px-3" class="flex items-center justify-between transition-all rounded-lg py-3.5 lg:py-4 px-3"
@click="toggleSidebar" @click="toggleSidebar"
@@ -126,7 +126,10 @@ const toggleSidebar = inject("toggleSidebar");
<span class="text-xs lg:text-sm">{{ link.title }}</span> <span class="text-xs lg:text-sm">{{ link.title }}</span>
</span> </span>
<Icon name="bi:chevron-left" class="transition-all" /> <Icon
name="bi:chevron-left"
class="transition-all"
/>
</NuxtLink> </NuxtLink>
<LogoutModal> <LogoutModal>
@@ -141,9 +144,7 @@ const toggleSidebar = inject("toggleSidebar");
class="**:fill-danger-500" class="**:fill-danger-500"
/> />
</div> </div>
<span class="text-xs lg:text-sm"> <span class="text-xs lg:text-sm"> خروج از حساب </span>
خروج از حساب
</span>
</span> </span>
<Icon <Icon
+14 -13
View File
@@ -15,6 +15,10 @@ const useGetChat = (productId: string | number, enabled: Ref<boolean>) => {
const { isLoggedIn } = useAuth(); const { isLoggedIn } = useAuth();
const isEnabled = computed(() => {
return enabled.value && isLoggedIn.value;
});
// methods // methods
const handleGetChat = async ({ const handleGetChat = async ({
@@ -26,23 +30,20 @@ const useGetChat = (productId: string | number, enabled: Ref<boolean>) => {
limit: number; limit: number;
offset: number; offset: number;
}) => { }) => {
const { data } = await axios.get<GetChatResponse>( const { data } = await axios.get<GetChatResponse>(`${API_ENDPOINTS.chat.messages}/${productId}`, {
`${API_ENDPOINTS.chat.messages}/${productId}`, params: {
{ offset,
params: { limit,
offset, },
limit, headers: {
}, Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`,
headers: { },
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoyMTY3ODE2OTAwLCJpYXQiOjE3MzU4MTY5MDAsImp0aSI6ImQwN2E2Y2Y2NzgwZjRlNTE5NWIzOGQxMTAzYzU4NDQ3IiwidXNlcl9pZCI6NX0.slwd7ZSV7nUXEuDTYwwHUOo9ekCefwEEL4kVv2vSTFo`, });
},
}
);
return data; return data;
}; };
return useInfiniteQuery({ return useInfiniteQuery({
enabled: isLoggedIn, enabled: isEnabled,
queryKey: [QUERY_KEYS.chat], queryKey: [QUERY_KEYS.chat],
initialPageParam: { initialPageParam: {
limit: 10, limit: 10,
@@ -0,0 +1,29 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type SaveProductRequest = {
product_slug: string;
};
const useSaveProduct = () => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleSaveProduct = async (variables: SaveProductRequest) => {
const { data } = await axios.post(`${API_ENDPOINTS.product.save}`, variables);
return data;
};
return useMutation({
mutationFn: (variables: SaveProductRequest) => handleSaveProduct(variables),
});
};
export default useSaveProduct;
@@ -0,0 +1,40 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import { useAppParams } from "~/composables/global/useAppParams";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetSavedProductsResponse = ApiPaginated<ProductListItem>;
// composable
const useGetSavedProducts = () => {
// state
const { $axios: axios } = useNuxtApp();
const { page } = useAppParams();
// methods
const handleGetSavedProducts = async ({ signal }: { signal: AbortSignal }) => {
const { data } = await axios.get<GetSavedProductsResponse>(`${API_ENDPOINTS.products.saved}`, {
params: {
offset: Number(page.value) * 15 - 15,
limit: 15,
},
signal,
});
return data;
};
return useQuery({
queryKey: [QUERY_KEYS.saved_products, page],
queryFn: ({ signal }) => handleGetSavedProducts({ signal }),
});
};
export default useGetSavedProducts;
+3
View File
@@ -23,6 +23,7 @@ export const API_ENDPOINTS = {
comments: "/products/comments", comments: "/products/comments",
create_comment: "/products/comments", create_comment: "/products/comments",
get: "/products", get: "/products",
save: "/accounts/favorites/toggle",
}, },
auth: { auth: {
refresh: "/token/refresh", refresh: "/token/refresh",
@@ -37,6 +38,7 @@ export const API_ENDPOINTS = {
products: { products: {
get_all: "/products", get_all: "/products",
categories: "/products/categories", categories: "/products/categories",
saved: "/accounts/favorites",
}, },
resellers_products: { resellers_products: {
get_all: "/products/slider_category", get_all: "/products/slider_category",
@@ -78,6 +80,7 @@ export const QUERY_KEYS = {
chat: "chat", chat: "chat",
product: "product", product: "product",
products: "products", products: "products",
saved_products: "saved-products",
resellers_products: "resellers_products", resellers_products: "resellers_products",
account: "account", account: "account",
categories: "categories", categories: "categories",
+1 -3
View File
@@ -51,9 +51,7 @@ watch(
dir="rtl" dir="rtl"
> >
<Header /> <Header />
<main <main class="w-full overflow-x-hidden container flex items-start gap-8 lg:gap-6 min-h-svh relative">
class="w-full overflow-x-hidden container flex items-start gap-8 lg:gap-6 min-h-svh relative"
>
<ProfileSidebar v-model:isShow="isSidebarShow" /> <ProfileSidebar v-model:isShow="isSidebarShow" />
<div class="w-full lg:w-9/12"> <div class="w-full lg:w-9/12">
<NuxtPage /> <NuxtPage />
@@ -2,6 +2,4 @@
<div></div> <div></div>
</template> </template>
<script setup lang="ts"></script> <script setup lang="ts"></script>
<style scoped></style>
@@ -0,0 +1,108 @@
<script lang="ts" setup>
// imports
import useGetSavedProducts from "~/composables/api/products/useSavedProducts";
// meta
useSeoMeta({
title: "پنل کاربری محصولات ذخیره شده",
});
definePageMeta({
middleware: "check-is-logged-in",
layout: "profile",
});
// states
const { data, isLoading: productsIsLoading } = useGetSavedProducts();
// computed
const products = computed(() => {
return data.value?.results.flat();
});
const paginationData = computed(() => {
return data.value?.results.map((_, i: number) => {
return { type: "page", value: i };
});
});
</script>
<template>
<div class="w-full flex flex-col gap-5">
<ProfilePageTitle
title="علاقه‌مندی های شما"
icon="bi:bookmark"
/>
<ProfileSection :title="`${paginationData?.length} محصول`">
<template #button>
<Button
end-icon="ci:chevron-left"
size="md"
class="rounded-full transition-all"
variant="solid"
@click="navigateTo({ name: 'products-slug' })"
>
<span class="whitespace-pre @max-[64rem]::text-xs"> همه محصولات </span>
</Button>
</template>
<div class="w-full">
<ul
v-if="productsIsLoading"
class="grid grid-cols-2 @min-[40rem]:grid-cols-3 @min-[64rem]:grid-cols-4 @min-[80rem]:grid-cols-5 gap-y-8 gap-5 @min-[40rem]:gap-8 w-full"
>
<div
class="w-full flex flex-col gap-3"
v-for="i in 10"
:key="i"
>
<Skeleton
v-for="i in 3"
:key="i"
class="w-full"
:class="{
'!h-[11.9rem] @min-[64rem]:!h-[17.25rem] !rounded-2xl': i == 1,
'!h-[1.4rem] @min-[64rem]:!h-[1.5rem] !rounded-sm': [2, 3].includes(i),
'!w-1/2 @min-[64rem]:!w-full': i == 2,
'@min-[64rem]:!w-1/2': i == 3,
}"
/>
</div>
</ul>
<div
v-else
class="w-full h-max"
>
<div
v-if="!products?.length"
class="flex flex-grow w-full"
>
<Placeholder
title="محصولی یافت نشد :("
icon="bi:search"
/>
</div>
<ProductsGrid
:with-header="false"
:products="products!"
class="!p-0"
/>
<div
v-if="data && paginationData && data.count > 15"
class="w-full flex-center py-10 mt-5 @min-[64rem]:mt-10"
>
<Pagination
:items="paginationData"
:total="data.count"
:per-page="15"
/>
</div>
</div>
</div>
</ProfileSection>
</div>
</template>
+3 -2
View File
@@ -1,4 +1,4 @@
export { }; export {};
declare global { declare global {
type ApiPaginated<T> = { type ApiPaginated<T> = {
@@ -104,6 +104,7 @@ declare global {
meta_rating: number; meta_rating: number;
category: SubCategory; category: SubCategory;
colors: string[]; colors: string[];
added_to_favorites: boolean;
}; };
type ProductListItem = Pick<Product, "id" | "variants" | "name" | "rating" | "slug" | "category" | "colors">; type ProductListItem = Pick<Product, "id" | "variants" | "name" | "rating" | "slug" | "category" | "colors">;
@@ -235,7 +236,7 @@ declare global {
discount_code: DiscountCode; discount_code: DiscountCode;
items: CartItem[]; items: CartItem[];
cart_total: string; cart_total: string;
items_discount_amount: string items_discount_amount: string;
tax_amount: string; tax_amount: string;
final_price: string; final_price: string;
address: Address; address: Address;