This commit is contained in:
Parsa Nazer
2025-10-20 18:57:42 +03:30
25 changed files with 579 additions and 527 deletions
+32 -4
View File
@@ -36,7 +36,7 @@ const { isLoading: cartImageIsLoading } = useImage({
const { mutateAsync: deleteCartItem, isPending: deleteCartItemIsPending } = useDeleteCartItem();
const { mutateAsync: addCartItem } = useAddCartItem();
const { mutateAsync: addCartItem, isPending: addItemInCartQuantityIsPending } = useAddCartItem();
// methods
@@ -153,7 +153,7 @@ watch(
</div>
<NuxtLink
:to="`product/${data.product.id}`"
:to="{ name: 'product-id', params: { id: data.product.slug } }"
class="font-semibold typo-sub-h-sm lg:typo-sub-h-xl text-black underline underline-offset-2"
>
{{ data.product.title }}
@@ -196,7 +196,22 @@ watch(
/>
</button>
<div class="size-10 flex-center">{{ counter }}</div>
<div
v-if="addItemInCartQuantityIsPending"
class="size-10 flex-center"
>
<Icon
:name="'svg-spinners:180-ring-with-bg'"
class="size-[18px]"
/>
</div>
<div
v-else
class="size-10 flex-center"
>
{{ counter }}
</div>
<button
@click="handleDecreaseQuantity"
@@ -246,7 +261,20 @@ watch(
/>
</button>
<div class="size-10 text-sm flex-center">
<div
v-if="addItemInCartQuantityIsPending"
class="size-10 text-sm flex-center"
>
<Icon
:name="'svg-spinners:180-ring-with-bg'"
class="size-[25px]"
/>
</div>
<div
v-else
class="size-10 text-sm flex-center"
>
{{ counter }}
</div>
+50 -17
View File
@@ -1,12 +1,16 @@
<script setup lang="ts">
// types
import { useImage } from "@vueuse/core";
import { useRatio } from "~/composables/global/useRatio";
// types
type Props = {
// brands: string[];
// brands?: string[];
};
// props
const { isMobile } = useRatio();
const props = defineProps<Props>();
const {} = toRefs(props);
@@ -18,6 +22,15 @@ const brands = ref([
"/img/brands/brand-5.png",
"/img/brands/brand-6.png",
]);
// preload images using vueuse
const isReady = ref(false);
onMounted(async () => {
const loaders = brands.value.map((src) => useImage({ src }));
await Promise.all(loaders.map((l) => (l.isLoading.value ? l.promise : Promise.resolve())));
isReady.value = true;
});
</script>
<template>
@@ -29,48 +42,68 @@ const brands = ref([
متون بلکه روزنامه و مجله در ستون و سطرآنچنان که
</p>
</div>
<!-- TOP MARQUEE -->
<div class="-rotate-z-2 z-20 w-[110%] shadow-2xl shadow-black/7">
<Marquee
class="bg-blue-500 h-full"
class="bg-blue-500 will-change-transform"
:clone="true"
dir="ltr"
:duration="3"
:duration="14"
>
<div class="flex items-center gap-12 sm:gap-20 px-6 sm:px-10 h-[90px] sm:h-[140px]">
<div
v-for="i in 6"
:key="i"
class="flex items-center gap-12 sm:gap-20 px-6 sm:px-10 h-[70px] sm:h-[140px] shrink-0 grow-0"
>
<div class="text-[30px] text-white lg:text-[40px] mt-2 whitespace-nowrap font-semibold opacity-85">
HEYMLZ
</div>
<NuxtImg
loading="lazy"
fetch-priority="low"
src="/img/heymlz/heymlz-logo.png"
class="h-[25px] sm:h-[45px] invert"
/>
<div class="size-[25px] sm:size-[45px] flex-center">
<NuxtImg
loading="lazy"
fetch-priority="low"
src="/img/heymlz/heymlz-logo.png"
class="w-full object-contain invert"
/>
</div>
</div>
</Marquee>
</div>
<!-- BOTTOM MARQUEE -->
<div class="rotate-z-2 z-10 w-[110%]">
<Marquee
class="bg-slate-100/70"
:direction="'reverse'"
v-if="isReady"
class="bg-slate-100/70 will-change-transform"
direction="reverse"
:clone="true"
dir="ltr"
:duration="10"
:duration="14"
>
<div
v-for="brand in brands"
:key="brand"
class="flex items-center px-6 sm:px-10 h-[90px] sm:h-[140px]"
class="flex items-center px-6 sm:px-10 h-[70px] sm:h-[140px] shrink-0 grow-0"
>
<NuxtImg
loading="lazy"
fetch-priority="low"
:src="brand"
class="h-[25px] sm:h-[45px] opacity-25"
class="opacity-25 object-contain w-[120px] sm:w-[200px] h-[45px] sm:h-[90px]"
/>
</div>
</Marquee>
</div>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+1 -1
View File
@@ -5,7 +5,7 @@ type Props = {
error?: boolean;
message?: string;
placeholder?: string;
modelValue: string;
modelValue: string | undefined;
};
// props
+2 -19
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// imports
import { useAppParams } from "~/composables/global/useAppParams";
import { useRatio } from "~/composables/global/useRatio";
// types
@@ -20,27 +21,9 @@ defineProps<Props>();
// state
const params: any = inject("params");
const router = useRouter();
const route = useRoute();
const { page } = useAppParams();
const { isMobile } = useRatio();
const { y } = useWindowScroll({ behavior: "smooth" });
// computed
const page = computed({
get: () => (route.query["page"] ? Number(route.query["page"]) : 1),
set: (value: number) => {
params.page = value;
router.push({
query: { ...route.query, page: value },
});
y.value = 0;
},
});
</script>
<template>
+4 -2
View File
@@ -22,8 +22,10 @@ const emit = defineEmits<Emits>();
// computed
const value = computed({
get: () => modelValue.value ?? false,
set: (value) => emit("update:modelValue", value),
get: () => (modelValue.value == "true" ? true : false),
set: (value) => {
emit("update:modelValue", value);
},
});
</script>
@@ -56,7 +56,7 @@ watch(
size="md"
class="rounded-full max-xs:typo-label-sm flex items-center justify-center gap-1.5"
>
{{ isDescriptionCollapsed ? 'نمایش بیشتر' : 'نمایش کمتر' }}
{{ isDescriptionCollapsed ? "نمایش بیشتر" : "نمایش کمتر" }}
<Icon
name="ci:chevron-left"
size="16"
+74 -140
View File
@@ -2,62 +2,44 @@
// imports
import useGetCategories from "~/composables/api/categories/useGetCategories";
import useGetProducts, { type GetProductsFilters } from "~/composables/api/products/useGetProducts";
import { PRODUCT_RANGE } from "~/constants";
import useGetProducts from "~/composables/api/products/useGetProducts";
import { useAppParams } from "~/composables/global/useAppParams";
import { PRODUCT_RANGE, PRODUCTS_SORTS } from "~/constants";
// state
const route = useRoute();
const router = useRouter();
const params = inject("params") as GetProductsFilters;
const { sort, in_stock, has_discount, price_gte, price_lte, search, page, slug } = useAppParams();
const isSideShow = useState("side-modal-product-filters");
const currentCategory = computed({
get: () => {
return Array.isArray(route.params.slug) ? route.params.slug[1] ?? undefined : undefined;
return Array.isArray(slug.value) ? slug.value[1] ?? undefined : undefined;
},
set: (newValue) => {
router.push({ path: `/products/category/${newValue}`, query: { ...route.query } });
isSideShow.value = false;
router.push({
name: "products-slug",
params: { slug: ["category", newValue] },
query: { ...route.query },
});
},
});
const sort_filter = ref([
{ title: "جدیدترین ها", value: "newest" },
{ title: "گران ترین ها", value: "price" },
{ title: "ارزان ترین ها", value: "-price" },
]);
const sliderValue = ref([params.price_gte ?? PRODUCT_RANGE.min, params.price_lte ?? PRODUCT_RANGE.max]);
const has_discount = ref(Boolean(params.has_discount) ?? false);
const in_stock = ref(Boolean(params.in_stock) ?? false);
const sliderValue = ref([price_gte.value ?? PRODUCT_RANGE.min, price_lte.value ?? PRODUCT_RANGE.max]);
const sliderValueDebounced = refDebounced(sliderValue, 1000);
const filtersSuccessMessage = ref<{ title: string; status: string } | null>(null);
// queries
const filters = computed(() => {
return {
sort: params.sort ?? "newest",
search: params.search ?? "",
price_gte: params.price_gte ?? PRODUCT_RANGE.min,
price_lte: params.price_lte ?? PRODUCT_RANGE.max,
in_stock: params.in_stock ?? false,
has_discount: params.has_discount ?? false,
category: currentCategory.value,
page: params.page ?? 1,
};
});
const { data: categories, suspense } = useGetCategories();
await suspense();
const { isPending: productsIsPending, status: productsStatus } = useGetProducts(filters);
const { isPending: productsIsPending } = useGetProducts();
// computed
@@ -80,49 +62,23 @@ const allCategories = computed(() => {
// methods
const resetFilters = () => {
params.search = "";
params.sort = "";
search.value = "";
sort.value = "";
sliderValue.value = [PRODUCT_RANGE.min, PRODUCT_RANGE.max];
has_discount.value = false;
in_stock.value = false;
params.page = 1;
has_discount.value = "false";
in_stock.value = "false";
page.value = 1;
router.push({ path: `/products/`, query: { ...route.query, page: 1 } });
isSideShow.value = false;
};
// watch
watch(
() => sliderValueDebounced.value,
(newValue) => {
params.price_gte = newValue[0];
params.price_lte = newValue[1];
}
);
watch(
() => [has_discount.value, in_stock.value],
([newHasDiscount, newInStock]) => {
params.has_discount = newHasDiscount;
params.in_stock = newInStock;
}
);
watch(
() => productsStatus.value,
(nv) => {
if (nv == "success") {
filtersSuccessMessage.value = {
title: "فیلتر اعمال شد",
status: nv,
};
} else if (nv == "error") {
filtersSuccessMessage.value = {
title: "خطایی در اعمال فیلتر رخ داد",
status: nv,
};
}
setTimeout(() => {
filtersSuccessMessage.value = null;
}, 4000);
price_gte.value = newValue[0];
price_lte.value = newValue[1];
}
);
</script>
@@ -140,13 +96,13 @@ watch(
</div>
<div class="w-full flex items-center gap-2">
<button
v-for="(sort, index) in sort_filter"
v-for="(sort_filter, index) in PRODUCTS_SORTS"
:key="index"
@click="params.sort = sort.value"
:class="params.sort == sort.value ? 'bg-black text-white' : 'bg-slate-100'"
@click="sort = sort_filter.value"
:class="sort == sort_filter.value ? 'bg-black text-white' : 'bg-slate-100'"
class="py-1 px-3 cursor-pointer text-nowrap transition-all rounded-md text-xs lg:text-sm"
>
{{ sort.title }}
{{ sort_filter.title }}
</button>
</div>
</div>
@@ -219,80 +175,58 @@ watch(
</div>
</div>
<div class="w-full flex flex-col items-center gap-5">
<Transition
enter-active-class="animate__animated animate__fadeInUp animate__faster"
leave-active-class="animate__animated animate__fadeOutDown animate__faster"
<div class="flex items-center gap-4 w-full">
<Button
:disabled="productsIsPending"
variant="primary"
@click="isSideShow = false"
class="w-full rounded-xl py-3 !cursor-pointer disabled:pointer-events-none z-[3]"
>
<div
v-if="!!filtersSuccessMessage"
class="w-max rounded-full py-1.5 px-3 text-xs border flex-center gap-0.5 z-[2]"
:class="
filtersSuccessMessage.status == 'success'
? 'text-success-600 bg-success-100 border-success-600'
: ' text-danger-600 bg-danger-100 border-danger-600'
"
<Transition
name="fade"
mode="out-in"
>
<span class="text-sm">{{ filtersSuccessMessage.title }}</span>
<Icon
:name="filtersSuccessMessage.status == 'success' ? 'bi:check' : 'bi:x'"
size="20"
/>
</div>
</Transition>
<div class="flex items-center gap-4 w-full">
<Button
:disabled="productsIsPending"
variant="primary"
@click="() => (isSideShow = false)"
class="w-full rounded-full py-4 !cursor-pointer disabled:pointer-events-none z-[3]"
<span class="flex-center gap-3 text-sm">
اعمال فیلتر
<Icon
name="ci:plus"
size="20"
/>
</span>
</Transition>
</Button>
<Button
:disabled="productsIsPending"
variant="solid"
@click="resetFilters"
class="w-full rounded-xl py-3 !cursor-pointer disabled:pointer-events-none z-[3]"
>
<Transition
name="fade"
mode="out-in"
>
<Transition
name="fade"
mode="out-in"
<span
v-if="productsIsPending"
class="flex-center gap-3 text-sm"
>
<span class="flex-center gap-3">
ثبت فیلتر
<Icon
name="ci:plus"
size="20"
/>
</span>
</Transition>
</Button>
<Button
:disabled="productsIsPending"
variant="solid"
@click="resetFilters"
class="w-full rounded-full py-4 !cursor-pointer disabled:pointer-events-none z-[3]"
>
<Transition
name="fade"
mode="out-in"
در حال دریافت اطلاعات
<Icon
name="svg-spinners:3-dots-bounce"
size="20"
/>
</span>
<span
v-else
class="flex-center gap-3 text-sm"
>
<span
v-if="productsIsPending"
class="flex-center gap-3"
>
در حال دریافت اطلاعات
<Icon
name="svg-spinners:3-dots-bounce"
size="20"
/>
</span>
<span
v-else
class="flex-center gap-3"
>
بازنشانی به پیش فرض
<Icon
name="ci:close"
size="20"
/>
</span>
</Transition>
</Button>
</div>
بازنشانی به پیش فرض
<Icon
name="ci:close"
size="20"
/>
</span>
</Transition>
</Button>
</div>
</div>
</template>
@@ -2,62 +2,44 @@
// imports
import useGetResellersCategories from "~/composables/api/resellers/useGetResellersCategories";
import useGetResellersProducts, {
type GetResellersProductsFilters,
} from "~/composables/api/resellers/useGetResellersProducts";
import { PRODUCT_RANGE } from "~/constants";
import useGetResellersProducts from "~/composables/api/resellers/useGetResellersProducts";
import { useAppParams } from "~/composables/global/useAppParams";
import { PRODUCT_RANGE, PRODUCTS_SORTS } from "~/constants";
// state
const route = useRoute();
const router = useRouter();
const params = inject("params") as GetResellersProductsFilters;
const { sort, in_stock, has_discount, price_gte, price_lte, search, page, slug } = useAppParams();
const isSideShow = useState("side-modal-resellers-product-filters");
const currentCategory = computed({
get: () => {
return Array.isArray(route.params.slug) ? route.params.slug[1] ?? undefined : undefined;
return Array.isArray(slug.value) ? slug.value[1] ?? undefined : undefined;
},
set: (newValue) => {
router.push({ path: `/resellers/category/${newValue}`, query: { ...route.query } });
isSideShow.value = false;
router.push({
name: "resellers-slug",
params: { slug: ["category", `${newValue}`] },
query: { ...route.query },
});
},
});
const sort_filter = ref([
{ title: "جدیدترین ها", value: "newest" },
{ title: "گران ترین ها", value: "price" },
{ title: "ارزان ترین ها", value: "-price" },
]);
const sliderValue = ref([params.price_gte ?? PRODUCT_RANGE.min, params.price_lte ?? PRODUCT_RANGE.max]);
const has_discount = ref(Boolean(params.has_discount) ?? false);
const in_stock = ref(Boolean(params.in_stock) ?? false);
const sliderValue = ref([price_gte.value ?? PRODUCT_RANGE.min, price_lte.value ?? PRODUCT_RANGE.max]);
const sliderValueDebounced = refDebounced(sliderValue, 1000);
const filtersSuccessMessage = ref<{ title: string; status: string } | null>(null);
// queries
const filters = computed(() => {
return {
sort: params.sort ?? "newest",
search: params.search ?? "",
price_gte: params.price_gte ?? PRODUCT_RANGE.min,
price_lte: params.price_lte ?? PRODUCT_RANGE.max,
in_stock: params.in_stock ?? false,
has_discount: params.has_discount ?? false,
category: currentCategory.value,
page: params.page ?? 1,
};
});
const { data: categories, suspense } = useGetResellersCategories();
await suspense();
const { isPending: productsIsPending, status: productsStatus } = useGetResellersProducts(filters);
const { isPending: productsIsPending } = useGetResellersProducts();
// computed
@@ -70,48 +52,23 @@ const allCategories = computed(() => {
// methods
const resetFilters = () => {
params.search = "";
params.sort = "";
search.value = "";
sort.value = "";
sliderValue.value = [PRODUCT_RANGE.min, PRODUCT_RANGE.max];
has_discount.value = false;
in_stock.value = false;
has_discount.value = "false";
in_stock.value = "false";
page.value = 1;
router.push({ path: `/resellers/`, query: { ...route.query } });
isSideShow.value = false;
};
// watch
watch(
() => sliderValueDebounced.value,
(newValue) => {
params.price_gte = newValue[0];
params.price_lte = newValue[1];
}
);
watch(
() => [has_discount.value, in_stock.value],
([newHasDiscount, newInStock]) => {
params.has_discount = newHasDiscount;
params.in_stock = newInStock;
}
);
watch(
() => productsStatus.value,
(nv) => {
if (nv == "success") {
filtersSuccessMessage.value = {
title: "فیلتر اعمال شد",
status: nv,
};
} else if (nv == "error") {
filtersSuccessMessage.value = {
title: "خطایی در اعمال فیلتر رخ داد",
status: nv,
};
}
setTimeout(() => {
filtersSuccessMessage.value = null;
}, 4000);
price_gte.value = newValue[0];
price_lte.value = newValue[1];
}
);
</script>
@@ -129,13 +86,13 @@ watch(
</div>
<div class="w-full flex items-center gap-2">
<button
v-for="(sort, index) in sort_filter"
v-for="(sort_filter, index) in PRODUCTS_SORTS"
:key="index"
@click="params.sort = sort.value"
:class="params.sort == sort.value ? 'bg-black text-white' : 'bg-slate-100'"
@click="sort = sort_filter.value"
:class="sort == sort_filter.value ? 'bg-black text-white' : 'bg-slate-100'"
class="py-1 px-3 cursor-pointer text-nowrap transition-all rounded-md text-xs lg:text-sm"
>
{{ sort.title }}
{{ sort_filter.title }}
</button>
</div>
</div>
@@ -209,32 +166,31 @@ watch(
</div>
</div>
<div class="w-full flex flex-col items-center gap-5">
<Transition
enter-active-class="animate__animated animate__fadeInUp animate__faster"
leave-active-class="animate__animated animate__fadeOutDown animate__faster"
<div class="flex items-center gap-4 w-full">
<Button
:disabled="productsIsPending"
variant="primary"
@click="isSideShow = false"
class="w-full rounded-xl py-3 !cursor-pointer disabled:pointer-events-none z-[3]"
>
<div
v-if="!!filtersSuccessMessage"
class="w-max rounded-full py-1.5 px-3 text-xs border flex-center gap-0.5 z-[2]"
:class="
filtersSuccessMessage.status == 'success'
? 'text-success-600 bg-success-100 border-success-600'
: ' text-danger-600 bg-danger-100 border-danger-600'
"
<Transition
name="fade"
mode="out-in"
>
<span class="text-sm">{{ filtersSuccessMessage.title }}</span>
<Icon
:name="filtersSuccessMessage.status == 'success' ? 'bi:check' : 'bi:x'"
size="20"
/>
</div>
</Transition>
<span class="flex-center gap-3 text-sm">
اعمال فیلتر
<Icon
name="ci:plus"
size="20"
/>
</span>
</Transition>
</Button>
<Button
:disabled="productsIsPending"
variant="solid"
@click="resetFilters"
class="w-full rounded-full py-4 !cursor-pointer disabled:pointer-events-none z-[3]"
class="w-full rounded-xl py-3 !cursor-pointer disabled:pointer-events-none z-[3]"
>
<Transition
name="fade"
@@ -242,7 +198,7 @@ watch(
>
<span
v-if="productsIsPending"
class="flex-center gap-3"
class="flex-center gap-3 text-sm"
>
در حال دریافت اطلاعات
<Icon
@@ -252,7 +208,7 @@ watch(
</span>
<span
v-else
class="flex-center gap-3"
class="flex-center gap-3 text-sm"
>
بازنشانی به پیش فرض
<Icon
@@ -1,31 +1,28 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import { useAppParams } from "~/composables/global/useAppParams";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetAllNotificationsResponse = ApiPaginated<Notification>;
export type GetAllNotificationsRequest = {
sort: string | undefined;
status: string | undefined;
page: string | string[];
};
const useGetAllNotifications = (params: ComputedRef<GetAllNotificationsRequest>) => {
const useGetAllNotifications = () => {
// state
const { $axios: axios } = useNuxtApp();
const { sort, status, page } = useAppParams();
// methods
const handleGetAllNotifications = async (params: GetAllNotificationsRequest) => {
const handleGetAllNotifications = async () => {
const { data } = await axios.get<GetAllNotificationsResponse>(API_ENDPOINTS.account.notifications.get_all, {
params: {
sort: params.sort,
filter: params.status,
offset: Number(params.page) * 10 - 10,
sort: sort.value ?? "created_at",
filter: status.value,
offset: Number(page.value) * 10 - 10,
limit: 10,
},
});
@@ -33,8 +30,8 @@ const useGetAllNotifications = (params: ComputedRef<GetAllNotificationsRequest>)
};
return useQuery({
queryKey: [QUERY_KEYS.notifications, params],
queryFn: () => handleGetAllNotifications(params.value),
queryKey: [QUERY_KEYS.notifications, sort, status, page],
queryFn: () => handleGetAllNotifications(),
});
};
@@ -1,6 +1,7 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import { useAppParams } from "~/composables/global/useAppParams";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
@@ -13,32 +14,30 @@ export type GetAllOrdersRequest = {
page: string | string[];
};
const useGetAllOrders = (params: ComputedRef<GetAllOrdersRequest>) => {
const useGetAllOrders = () => {
// state
const { $axios: axios } = useNuxtApp();
const { sort, status, page } = useAppParams();
// methods
const handleGetAllOrders = async (params: GetAllOrdersRequest) => {
const { data } = await axios.get<GetAllOrdersResponse>(
API_ENDPOINTS.orders.get_all,
{
params: {
sort: params.sort,
filter: params.status,
offset: Number(params.page) * 7 - 7,
limit: 7,
},
}
);
const handleGetAllOrders = async () => {
const { data } = await axios.get<GetAllOrdersResponse>(API_ENDPOINTS.orders.get_all, {
params: {
sort: sort.value ?? "created_at",
filter: status.value,
offset: Number(page.value) * 10 - 10,
limit: 10,
},
});
return data;
};
return useQuery({
queryKey: [QUERY_KEYS.orders, params],
queryFn: () => handleGetAllOrders(params.value),
queryKey: [QUERY_KEYS.orders, sort, status, page],
queryFn: () => handleGetAllOrders(),
});
};
@@ -1,31 +1,32 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import { useAppParams } from "~/composables/global/useAppParams";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetTransactionResponse = Transaction;
export type GetTransactionRequest = string;
const useGetTransaction = (params: ComputedRef<GetTransactionRequest>) => {
const useGetTransaction = () => {
// state
const { $axios: axios } = useNuxtApp();
const { tracking_code } = useAppParams();
// methods
const handleGetTransaction = async (tc: GetTransactionRequest) => {
const handleGetTransaction = async () => {
const { data } = await axios.get<GetTransactionResponse>(
`${API_ENDPOINTS.orders.checkout.transaction}/${tc}`
`${API_ENDPOINTS.orders.checkout.transaction}/${tracking_code.value}`
);
return data;
};
return useQuery({
queryKey: [QUERY_KEYS.transaction, params],
queryFn: () => handleGetTransaction(params.value),
queryKey: [QUERY_KEYS.transaction, tracking_code],
queryFn: () => handleGetTransaction(),
});
};
@@ -1,57 +1,105 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import { useAppParams } from "~/composables/global/useAppParams";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetProductsResponse = ApiPaginated<ProductListItem>;
export type GetProductsFilters = {
search?: string | undefined;
sort?: string | undefined;
category?: string | undefined;
price_gte: number;
price_lte: number;
has_discount?: boolean | undefined;
in_stock?: boolean | undefined;
page: number;
};
// composable
const useGetProducts = (params?: ComputedRef<GetProductsFilters>) => {
const useGetProducts = () => {
// state
const { $axios: axios } = useNuxtApp();
const { $axios: axios, $queryClient: queryClient } = useNuxtApp();
const { search, sort, in_stock, has_discount, slug, price_gte, price_lte, page } = useAppParams();
const searchDebounced = refDebounced(search, 500);
// computed
const products_category = computed(() => {
if (Array.isArray(slug.value) && slug.value.length >= 2) {
return slug.value[1];
}
return undefined;
});
const filters_clone = computed(() => {
return {
search,
sort,
in_stock,
has_discount,
slug,
price_gte,
price_lte,
};
});
// watch
watch(
() => filters_clone.value,
() => {
queryClient.cancelQueries({
queryKey: [
QUERY_KEYS.products,
searchDebounced,
sort,
in_stock,
has_discount,
products_category,
price_gte,
price_lte,
page,
],
});
page.value = 1;
},
{
deep: true,
}
);
// methods
const handleGetProducts = async (params?: GetProductsFilters) => {
const { data } = await axios.get<GetProductsResponse>(
`${API_ENDPOINTS.products.get_all}`,
{
params: {
sort: params?.sort,
in_stock: params?.in_stock,
search: params?.search,
has_discount: params?.has_discount,
category: params?.category,
price_gte: params?.price_gte,
price_lte: params?.price_lte,
offset: Number(params?.page) * 15 - 15,
limit: 15
}
}
);
const handleGetProducts = async ({ signal }: { signal: AbortSignal }) => {
const { data } = await axios.get<GetProductsResponse>(`${API_ENDPOINTS.products.get_all}`, {
params: {
sort: sort.value || "newest",
in_stock: in_stock.value,
search: searchDebounced.value,
has_discount: has_discount.value,
category: products_category.value,
price_gte: price_gte.value,
price_lte: price_lte.value,
offset: Number(page.value) * 15 - 15,
limit: 15,
},
signal,
});
return data;
};
return useQuery({
staleTime: 60 * 1000,
queryKey: [QUERY_KEYS.products, params],
queryFn: () => handleGetProducts(params?.value)
queryKey: [
QUERY_KEYS.products,
searchDebounced,
sort,
in_stock,
has_discount,
products_category,
price_gte,
price_lte,
page,
],
queryFn: ({ signal }) => handleGetProducts({ signal }),
});
};
@@ -1,45 +1,86 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import { useAppParams } from "~/composables/global/useAppParams";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetResellersProductsResponse = ApiPaginated<ProductListItem>;
export type GetResellersProductsFilters = {
search?: string | undefined;
sort?: string | undefined;
category?: string | undefined;
price_gte: number;
price_lte: number;
has_discount?: boolean | undefined;
in_stock?: boolean | undefined;
page: number;
};
// composable
const useGetResellersProducts = (params?: ComputedRef<GetResellersProductsFilters>) => {
const useGetResellersProducts = () => {
// state
const { $axios: axios } = useNuxtApp();
const { $axios: axios, $queryClient: queryClient } = useNuxtApp();
const { search, sort, in_stock, has_discount, slug, price_gte, price_lte, page } = useAppParams();
const searchDebounced = refDebounced(search, 500);
// computed
const products_category = computed(() => {
if (Array.isArray(slug.value) && slug.value.length >= 2) {
return slug.value[1];
}
return undefined;
});
const filters_clone = computed(() => {
return {
search,
sort,
in_stock,
has_discount,
slug,
price_gte,
price_lte,
};
});
// watch
watch(
() => filters_clone.value,
() => {
queryClient.cancelQueries({
queryKey: [
QUERY_KEYS.products,
searchDebounced,
sort,
in_stock,
has_discount,
products_category,
price_gte,
price_lte,
page,
],
});
page.value = 1;
},
{
deep: true,
}
);
// methods
const handleGetResellersProducts = async (params?: GetResellersProductsFilters) => {
const handleGetResellersProducts = async ({ signal }: { signal: AbortSignal }) => {
const { data } = await axios.get<GetResellersProductsResponse>(`${API_ENDPOINTS.resellers_products.get_all}`, {
params: {
sort: params?.sort,
in_stock: params?.in_stock,
search: params?.search,
has_discount: params?.has_discount,
category: params?.category,
price_gte: params?.price_gte,
price_lte: params?.price_lte,
offset: Number(params?.page) * 15 - 15,
sort: sort.value || "newest",
in_stock: in_stock.value,
search: searchDebounced.value,
has_discount: has_discount.value,
category: products_category.value,
price_gte: price_gte.value,
price_lte: price_lte.value,
offset: Number(page.value) * 15 - 15,
limit: 15,
},
signal,
});
return data;
@@ -47,8 +88,18 @@ const useGetResellersProducts = (params?: ComputedRef<GetResellersProductsFilter
return useQuery({
staleTime: 60 * 1000,
queryKey: [QUERY_KEYS.resellers_products, params],
queryFn: () => handleGetResellersProducts(params?.value),
queryKey: [
QUERY_KEYS.resellers_products,
searchDebounced,
sort,
in_stock,
has_discount,
products_category,
price_gte,
price_lte,
page,
],
queryFn: ({ signal }) => handleGetResellersProducts({ signal }),
});
};
@@ -1,6 +1,7 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import { useAppParams } from "~/composables/global/useAppParams";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
@@ -13,19 +14,21 @@ export type GetAllTicketsRequest = {
page: string | string[];
};
const useGetAllTickets = (params: ComputedRef<GetAllTicketsRequest>) => {
const useGetAllTickets = () => {
// state
const { $axios: axios } = useNuxtApp();
const { sort, status, page } = useAppParams();
// methods
const handleGetAllTickets = async (params: GetAllTicketsRequest) => {
const handleGetAllTickets = async () => {
const { data } = await axios.get<GetAllTicketsResponse>(API_ENDPOINTS.tickets.get_all, {
params: {
sort: params.sort,
filter: params.status,
offset: Number(params.page) * 7 - 7,
sort: sort.value ?? "created_at",
filter: status.value,
offset: Number(page.value) * 7 - 7,
limit: 7,
},
});
@@ -33,8 +36,8 @@ const useGetAllTickets = (params: ComputedRef<GetAllTicketsRequest>) => {
};
return useQuery({
queryKey: [QUERY_KEYS.tickets, params],
queryFn: () => handleGetAllTickets(params.value),
queryKey: [QUERY_KEYS.tickets, sort, status, page],
queryFn: () => handleGetAllTickets(),
});
};
@@ -0,0 +1,71 @@
import { PRODUCT_RANGE } from "~/constants";
export const useAppParams = () => {
// state
const { y } = useWindowScroll({ behavior: "smooth" });
const slug = useRouteParams<string | undefined>("slug");
const sort = useRouteQuery<string | undefined>("sort", undefined, {
mode: "replace",
});
const search = useRouteQuery<string | undefined>("search", "", {
mode: "replace",
});
const page = useRouteQuery<number | undefined>("page", 1, {
mode: "push",
transform: (value) => (!!value ? +value : undefined),
});
const category = useRouteQuery<string | undefined>("category", "", {
mode: "replace",
});
const status = useRouteQuery<string | undefined>("status", undefined, {
mode: "replace",
});
const price_gte = useRouteQuery<number | undefined>("price_gte", PRODUCT_RANGE.min, {
mode: "replace",
});
const price_lte = useRouteQuery<number | undefined>("price_lte", PRODUCT_RANGE.max, {
mode: "replace",
});
const in_stock = useRouteQuery<string>("in_stock", "false", {
mode: "replace",
});
const has_discount = useRouteQuery<string>("has_discount", "false", {
mode: "replace",
});
const tracking_code = useRouteQuery<string>("tracking_code", "", {
mode: "replace",
});
watch(
() => page.value,
() => {
y.value = 0;
}
);
return {
slug,
sort,
search,
page,
category,
price_gte,
price_lte,
in_stock,
status,
has_discount,
tracking_code,
};
};
+6
View File
@@ -122,3 +122,9 @@ export const NAV_LINKS = [
icon: "ci:call",
},
];
export const PRODUCTS_SORTS = [
{ title: "جدیدترین ها", value: "newest" },
{ title: "گران ترین ها", value: "price" },
{ title: "ارزان ترین ها", value: "-price" },
];
+29
View File
@@ -20,6 +20,7 @@
"@vuelidate/validators": "^2.0.4",
"@vueuse/integrations": "^12.7.0",
"@vueuse/nuxt": "^13.3.0",
"@vueuse/router": "^13.9.0",
"animate.css": "^4.1.1",
"axios": "^1.8.1",
"date-fns-jalali": "^4.1.0-0",
@@ -5997,6 +5998,34 @@
"node": ">=18.12.0"
}
},
"node_modules/@vueuse/router": {
"version": "13.9.0",
"resolved": "https://registry.npmjs.org/@vueuse/router/-/router-13.9.0.tgz",
"integrity": "sha512-7AYay8Pv/0fC4D0eygbIyZuLyVs+9D7dsnO5D8aqat9qcOz91v/XFWR667WE1+p+OkU0ib+FjQUdnTVBNoIw8g==",
"license": "MIT",
"dependencies": {
"@vueuse/shared": "13.9.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0",
"vue-router": "^4.0.0"
}
},
"node_modules/@vueuse/router/node_modules/@vueuse/shared": {
"version": "13.9.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz",
"integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/shared": {
"version": "12.8.2",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz",
+1
View File
@@ -28,6 +28,7 @@
"@vuelidate/validators": "^2.0.4",
"@vueuse/integrations": "^12.7.0",
"@vueuse/nuxt": "^13.3.0",
"@vueuse/router": "^13.9.0",
"animate.css": "^4.1.1",
"axios": "^1.8.1",
"date-fns-jalali": "^4.1.0-0",
+1 -1
View File
@@ -16,7 +16,7 @@ definePageMeta({
middleware: "check-is-logged-in",
prevPage: { name: "cart", label: "سبد خرید" },
nextPage: { name: "cart-checkout", label: "تسویه حساب", query: "ZARINPAL" },
nextPage: { name: "cart-checkout", label: "تسویه حساب", query: "ZIBAL" },
});
// types
+1 -1
View File
@@ -14,7 +14,7 @@ const id = route.params.id as string | undefined;
const { suspense: suspenseProduct, data: product } = useGetProduct(id);
useSeoMeta({
title: `محصول ${product.value?.name}`,
title: product.value?.name,
ogImage: product.value?.variants[0].images[0].image,
twitterImage: product.value?.variants[0].images[0].image,
ogDescription: product.value?.description,
+15 -43
View File
@@ -1,16 +1,5 @@
<script setup lang="ts">
// import
import useGetProducts, { type GetProductsFilters } from "~/composables/api/products/useGetProducts";
import { PRODUCT_RANGE } from "~/constants";
// state
const route = useRoute();
useSeoMeta({
title: "محصولات",
});
// meta
definePageMeta({
validate: (route) => {
@@ -22,53 +11,36 @@ definePageMeta({
},
});
const params: GetProductsFilters = useUrlSearchParams("history", {
removeFalsyValues: true,
removeNullishValues: true,
});
// import
const filters = computed(() => {
return {
sort: params.sort ?? "newest",
search: params.search ?? "",
price_gte: params.price_gte ?? PRODUCT_RANGE.min,
price_lte: params.price_lte ?? PRODUCT_RANGE.max,
in_stock: params.in_stock ?? false,
has_discount: params.has_discount ?? false,
category: Array.isArray(route.params.slug) ? route.params.slug[1] ?? undefined : undefined,
page: route.query["page"] ?? 1,
};
});
import { useAppParams } from "~/composables/global/useAppParams";
import useGetProducts from "~/composables/api/products/useGetProducts";
const search = ref(params.search ?? "");
const searchDebounced = refDebounced(search, 1000);
// state
// provide / inject
provide("params", params);
const { search } = useAppParams();
// queries
const { data, isLoading: productsIsLoading } = useGetProducts(filters);
const { data, isLoading: productsIsLoading } = useGetProducts();
// computed
const products = computed(() => {
return data.value?.results.flat();
});
const paginationData = computed(() => {
return data!.value?.results.map((_, i: number) => {
return data.value?.results.map((_, i: number) => {
return { type: "page", value: i };
});
});
// watch
// seo
watch(
() => searchDebounced.value,
(newValue) => {
params.search = newValue;
}
);
useSeoMeta({
title: "محصولات",
});
</script>
<template>
@@ -133,7 +105,7 @@ watch(
class="w-full h-max"
>
<div
v-if="!products!.length"
v-if="!products?.length"
class="flex flex-grow w-full"
>
<Placeholder
+7 -16
View File
@@ -14,16 +14,15 @@ definePageMeta({
import { usePushNotifications } from "~/composables/global/usePushNotifications";
import useSubscribeNotification from "~/composables/api/notifications/useSubscribeNotification";
import useGetAllNotifications, {
type GetAllNotificationsRequest,
} from "~/composables/api/account/useGetAllNotifications";
import useGetAllNotifications from "~/composables/api/account/useGetAllNotifications";
import { useAppParams } from "~/composables/global/useAppParams";
// state
const params: GetAllNotificationsRequest = useUrlSearchParams("history");
const subscribe = ref(false);
const { status, sort } = useAppParams();
const sortFilters = ref([
{
title: "اخبار",
@@ -59,17 +58,9 @@ const { isPending: subscribeNotificationIsPending } = useSubscribeNotification()
// computeds
const filters = computed(() => {
return {
sort: params.sort ?? "created_at",
status: params.status ?? "",
page: params.page ?? 1,
};
});
const hasNotifications = computed(() => data.value && data.value.count > 0);
const { data, isLoading: notificationsIsLoading } = useGetAllNotifications(filters);
const { data, isLoading: notificationsIsLoading } = useGetAllNotifications();
const notifications = computed(() => {
return data.value?.results.flat();
@@ -136,7 +127,7 @@ watch(
<span class="text-xs lg:text-sm">فیلتر بر اساس</span>
<Select
v-model="params.sort!"
v-model="sort"
triggerRootClass="!py-2.5"
class="w-[6rem]"
>
@@ -169,7 +160,7 @@ watch(
<span class="text-xs lg:text-sm">وضعیت</span>
<Select
v-model="params.status!"
v-model="status"
triggerRootClass="!py-2.5"
class="w-[6rem]"
>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import useGetAllOrders, { type GetAllOrdersRequest } from "~/composables/api/orders/useGetAllOrders";
import { useAppParams } from "~/composables/global/useAppParams";
// meta
@@ -14,15 +15,9 @@ definePageMeta({
// state
const params = useUrlSearchParams("history") as GetAllOrdersRequest;
const route = useRoute();
const filters = computed(() => {
return {
sort: params.sort ?? "created_at",
status: params.status ?? "",
page: params.page ?? 1,
};
});
const { sort, status } = useAppParams();
const tableHeads = ref(["شماره سفارش", "تاریخ ثبت", "تعداد اقلام", "مبلغ", "وضعیت", "عملیات"]);
@@ -72,13 +67,9 @@ const statusFilters = ref([
},
]);
// provide / inject
provide("params", params);
// queries
const { data, isPending: purchasesIsPending } = useGetAllOrders(filters);
const { data, isPending: purchasesIsPending } = useGetAllOrders();
// computed
@@ -89,9 +80,9 @@ const purchases = computed(() => {
const hasPurchases = computed(() => data.value && data.value.count > 0);
const hasFilters = computed(() =>
Object.keys(params)
Object.keys(route.query)
.filter((key) => key != "page")
.some((key) => (params as any)[key] != undefined)
.some((key) => (route.query as any)[key] != undefined)
);
const paginationData = computed(() => {
@@ -103,8 +94,8 @@ const paginationData = computed(() => {
// methods
const clearFilters = () => {
params.sort = undefined;
params.status = undefined;
sort.value = "";
status.value = "";
};
</script>
@@ -123,7 +114,7 @@ const clearFilters = () => {
>
<span class="text-xs lg:text-sm">ترتیب بر اساس</span>
<Select
v-model="params.sort!"
v-model="sort"
triggerRootClass="!py-2.5"
class="w-[6rem]"
>
@@ -156,7 +147,7 @@ const clearFilters = () => {
>
<span class="text-xs lg:text-sm">وضعیت</span>
<Select
v-model="params.status!"
v-model="status"
triggerRootClass="!py-2.5"
class="w-[6rem]"
>
+11 -25
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
// imports
import useGetAllTickets, { type GetAllTicketsRequest } from "~/composables/api/tickets/useGetAllTickets";
import useGetAllTickets from "~/composables/api/tickets/useGetAllTickets";
import { useAppParams } from "~/composables/global/useAppParams";
// meta
@@ -16,20 +17,9 @@ definePageMeta({
// state
const params: GetAllTicketsRequest = useUrlSearchParams("history", {
initialValue: {
page: 1,
},
writeMode: "push",
});
const route = useRoute();
const filters = computed(() => {
return {
sort: params.sort ?? "created_at",
status: params.status ?? "",
page: params.page ?? 1,
};
});
const { sort, status } = useAppParams();
const tableHeads = ref(["دسته بندی", "موضوع", "تاریخ ایجاد و بروز رسانی", "وضعیت", "عملیات"]);
@@ -59,13 +49,9 @@ const statusFilters = ref([
},
]);
// provide / inject
provide("params", params);
// queries
const { data, isPending: ticketsIsPending } = useGetAllTickets(filters);
const { data, isPending: ticketsIsPending } = useGetAllTickets();
// computed
@@ -76,9 +62,9 @@ const tickets = computed(() => {
const hasTickets = computed(() => data.value && data.value.count > 0);
const hasFilters = computed(() =>
Object.keys(params)
Object.keys(route.query)
.filter((key) => key != "page")
.some((key) => (params as any)[key] != undefined)
.some((key) => (route.query as any)[key] != undefined)
);
const paginationData = computed(() => {
@@ -90,8 +76,8 @@ const paginationData = computed(() => {
// methods
const clearFilters = () => {
params.sort = undefined;
params.status = undefined;
sort.value = "";
status.value = "";
};
</script>
@@ -110,7 +96,7 @@ const clearFilters = () => {
>
<span class="text-xs lg:text-sm">ترتیب بر اساس</span>
<Select
v-model="params.sort!"
v-model="sort"
triggerRootClass="!py-2.5"
class="w-[6rem]"
>
@@ -143,7 +129,7 @@ const clearFilters = () => {
>
<span class="text-xs lg:text-sm">وضعیت</span>
<Select
v-model="params.status!"
v-model="status"
triggerRootClass="!py-2.5"
class="w-[6rem]"
>
+15 -45
View File
@@ -1,76 +1,46 @@
<script setup lang="ts">
// import
import useGetResellersProducts, {
type GetResellersProductsFilters,
} from "~/composables/api/resellers/useGetResellersProducts";
import { PRODUCT_RANGE } from "~/constants";
// state
const route = useRoute();
useSeoMeta({
title: "محصولات",
});
// meta
definePageMeta({
validate: (route) => {
if (Array.isArray(route.params.slug)) {
return route.params.slug.length === 2 && route.params.slug[0] === "category";
return route.params.slug.length === 2 && route.params.slug[0].startsWith("category");
}
return true;
},
});
const params: GetResellersProductsFilters = useUrlSearchParams("history", {
removeFalsyValues: true,
removeNullishValues: true,
});
// import
const filters = computed(() => {
return {
sort: params.sort ?? "newest",
search: params.search ?? "",
price_gte: params.price_gte ?? PRODUCT_RANGE.min,
price_lte: params.price_lte ?? PRODUCT_RANGE.max,
in_stock: params.in_stock ?? false,
has_discount: params.has_discount ?? false,
category: Array.isArray(route.params.slug) ? route.params.slug[1] ?? undefined : undefined,
page: params.page ?? 1,
};
});
import { useAppParams } from "~/composables/global/useAppParams";
import useGetResellersProducts from "~/composables/api/resellers/useGetResellersProducts";
const search = ref(params.search ?? "");
const searchDebounced = refDebounced(search, 1000);
// state
// provide / inject
provide("params", params);
const { search } = useAppParams();
// queries
const { data, isLoading: productsIsLoading } = useGetResellersProducts(filters);
const { data, isLoading: productsIsLoading } = useGetResellersProducts();
// computed
const products = computed(() => {
return data.value?.results.flat();
});
const paginationData = computed(() => {
return data!.value?.results.map((_, i: number) => {
return data.value?.results.map((_, i: number) => {
return { type: "page", value: i };
});
});
// watch
// seo
watch(
() => searchDebounced.value,
(newValue) => {
params.search = newValue;
}
);
useSeoMeta({
title: "محصولات",
});
</script>
<template>