This commit is contained in:
Parsa Nazer
2025-01-19 21:56:28 +03:30
34 changed files with 1919 additions and 238 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ dist
node_modules node_modules
# Logs # Logs
logs .logs
*.log *.log
# Misc # Misc
+1 -1
View File
@@ -47,7 +47,7 @@ const { colorObject } = useImageColor(`#category-image-${id.value}`);
class="flex flex-col gap-2" class="flex flex-col gap-2"
> >
<div class="typo-s-h-md"> <div class="typo-s-h-md">
تمام دسته ها {{ category }}
<span class="typo-p-xs -translate-y-1 inline-block mr-1"> <span class="typo-p-xs -translate-y-1 inline-block mr-1">
24 24
</span> </span>
+1 -1
View File
@@ -46,7 +46,7 @@ watch(
</script> </script>
<template> <template>
<ComboboxRoot class="relative" dir="rtl" :multiple="true" v-model="value"> <ComboboxRoot class="relative" dir="rtl" v-model="value">
<ComboboxAnchor <ComboboxAnchor
class="w-full inline-flex items-center justify-between rounded-full border border-slate-200 hover:border-slate-300 focus:border-slate-800 px-[1rem] text-sm leading-none py-3.5 gap-[5px] bg-slate-50 text-black hover:bg-white/90 transition-all data-[placeholder]:text-black/80 typo-label-sm outline-none" class="w-full inline-flex items-center justify-between rounded-full border border-slate-200 hover:border-slate-300 focus:border-slate-800 px-[1rem] text-sm leading-none py-3.5 gap-[5px] bg-slate-50 text-black hover:bg-white/90 transition-all data-[placeholder]:text-black/80 typo-label-sm outline-none"
> >
+13 -7
View File
@@ -19,24 +19,24 @@ const { logout } = useAuth();
const nav_links = ref<NavLink[]>([ const nav_links = ref<NavLink[]>([
{ {
title: "فروشگاه", title: "فروشگاه",
path: "#", path: "#"
}, },
{ {
title: "دسته بندی ها", title: "دسته بندی ها",
path: "#", path: "#"
}, },
{ {
title: "جستجو", title: "جستجو",
path: "#", path: "#"
}, },
{ {
title: "ارتباط با ما", title: "ارتباط با ما",
path: "#", path: "#"
}, },
{ {
title: "امکانات", title: "امکانات",
path: "#", path: "#"
}, }
]); ]);
</script> </script>
@@ -53,7 +53,13 @@ const nav_links = ref<NavLink[]>([
<button @click="() => logout(true)">خروج از وبسایت</button> <button @click="() => logout(true)">خروج از وبسایت</button>
</div> </div>
<div v-else class="text-black">KIR</div> <button
@click="navigateTo('/signin')"
class="cursor-pointer"
v-else
>
وارد شوید
</button>
<nav <nav
class="flex-center gap-[2.5rem] w-8/12 typo-label-sm text-slate-500" class="flex-center gap-[2.5rem] w-8/12 typo-label-sm text-slate-500"
+2 -2
View File
@@ -28,7 +28,7 @@ const inputRef = ref<HTMLInputElement | null>(null);
const classes = computed(() => { const classes = computed(() => {
return [ return [
"flex items-center cursor-text transition-all border-[1.5px] gap-3 typo-label-md px-4 py-3 rounded-100", "flex items-center justify-between cursor-text transition-all border-[1.5px] gap-3 typo-label-md px-4 py-3 rounded-100",
{ {
"input-solid": variant.value === "solid", "input-solid": variant.value === "solid",
"input-outlined": variant.value === "outlined", "input-outlined": variant.value === "outlined",
@@ -56,7 +56,7 @@ const onInput = (e: any) => {
:value="modelValue" :value="modelValue"
@input="onInput" @input="onInput"
ref="inputRef" ref="inputRef"
class="outline-none w-max" class="outline-none flex-1"
:placeholder="placeholder" :placeholder="placeholder"
/> />
+41 -24
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { GetProductsFilters } from "~/composables/api/products/useGetProducts";
// types // types
type Props = { type Props = {
total: number; total: number;
items: { items: {
type: "page" | "not-page"; type: string;
value: number; value: number;
}[]; }[];
}; };
@@ -12,34 +14,48 @@ type Props = {
// props // props
defineProps<Props>(); defineProps<Props>();
// state
const params: GetProductsFilters = inject("params");
const page = ref(Number(params.page) ?? 1);
// watch
watch(
() => page.value,
(newPage) => {
params.page = newPage;
}
);
</script> </script>
<template> <template>
<PaginationRoot <PaginationRoot
:total="100" :total="total"
:sibling-count="1" :sibling-count="1"
:items-per-page="10" :items-per-page="9"
show-edges show-edges
v-model:page="page"
> >
<PaginationList <PaginationList v-slot="{ items }" class="flex items-center gap-1">
v-slot="{ items }" <PaginationLast
class="flex items-center gap-1 text-stone-700 dark:text-white" class="w-9 h-9 flex items-center justify-center bg-transparent cursor-pointer hover:bg-stone-700/20 transition disabled:opacity-50 disabled:cursor-not-allowed rounded-lg"
>
<PaginationFirst
class="w-9 h-9 flex items-center justify-center bg-transparent hover:bg-white dark:hover:bg-stone-700/70 transition disabled:opacity-50 rounded-lg"
> >
<Icon name="ci:double-arrow-left" /> <Icon name="bi:chevron-double-right" class="**:stroke-black" />
</PaginationFirst> </PaginationLast>
<PaginationPrev <PaginationNext
class="w-9 h-9 flex items-center justify-center bg-transparent hover:bg-white dark:hover:bg-stone-700/70 transition mr-4 disabled:opacity-50 rounded-lg" class="w-9 h-9 ml-4 flex items-center justify-center cursor-pointer hover:bg-stone-700/20 transition disabled:opacity-50 disabled:cursor-not-allowed rounded-lg"
> >
<Icon name="ci:chevron-left" /> <Icon name="bi:chevron-right" class="**:stroke-black" />
</PaginationPrev> </PaginationNext>
<template v-for="(page, index) in items"> <template v-for="(page, index) in items">
<PaginationListItem <PaginationListItem
v-if="page.type === 'page'" v-if="page.type === 'page'"
:key="index" :key="index"
class="w-9 h-9 border dark:border-stone-800 rounded-lg data-[selected]:!bg-white data-[selected]:shadow-sm data-[selected]:text-blackA11 hover:bg-white dark:hover:bg-stone-700/70 transition" class="w-9 h-9 border border-stone-800 rounded-lg data-[selected]:!bg-black data-[selected]:text-white data-[selected]:shadow-sm hover:bg-stone-700/20 transition"
:value="page.value" :value="page.value"
> >
{{ page.value }} {{ page.value }}
@@ -53,16 +69,17 @@ defineProps<Props>();
&#8230; &#8230;
</PaginationEllipsis> </PaginationEllipsis>
</template> </template>
<PaginationNext
class="w-9 h-9 flex items-center justify-center bg-transparent hover:bg-white dark:hover:bg-stone-700/70 transition ml-4 disabled:opacity-50 rounded-lg" <PaginationPrev
class="w-9 h-9 flex items-center justify-center bg-transparent cursor-pointer hover:bg-stone-700/20 transition mr-4 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg"
> >
<Icon name="ci:chevron-right" /> <Icon name="bi:chevron-left" class="**:stroke-black" />
</PaginationNext> </PaginationPrev>
<PaginationLast <PaginationFirst
class="w-9 h-9 flex items-center justify-center bg-transparent hover:bg-white dark:hover:bg-stone-700/70 transition disabled:opacity-50 rounded-lg" class="w-9 h-9 flex items-center justify-center bg-transparent cursor-pointer hover:bg-stone-700/20 transition disabled:opacity-50 disabled:cursor-not-allowed rounded-lg"
> >
<Icon name="ci:double-arrow-right" /> <Icon name="bi:chevron-double-left" class="**:stroke-black" />
</PaginationLast> </PaginationFirst>
</PaginationList> </PaginationList>
</PaginationRoot> </PaginationRoot>
</template> </template>
+1 -1
View File
@@ -41,7 +41,7 @@ watch(
</Transition> </Transition>
<Transition name="fade-right"> <Transition name="fade-right">
<div <div
v-if="isSideShow" v-show="isSideShow"
id="side-content" id="side-content"
class="hidden md:flex w-1/3 bg-white h-full rounded-e-[1.5rem] overflow-hidden absolute top-0 min-md:flex-col" class="hidden md:flex w-1/3 bg-white h-full rounded-e-[1.5rem] overflow-hidden absolute top-0 min-md:flex-col"
> >
@@ -2,7 +2,8 @@
// import // import
import type { ToastOptions } from "~/composables/useToast"; import type { ToastOptions } from "~/composables/global/useToast";
import { useToast } from "~/composables/global/useToast";
// type // type
@@ -42,23 +42,23 @@ defineProps<Props>();
</Tag> </Tag>
</div> </div>
<div <div
class="absolute inset-x-0 bottom-0 pb-6 px-6 flex justify-between items-center" class="absolute inset-x-0 bottom-0 pb-6 px-6 flex flex-row-reverse justify-between items-end"
> >
<span class="typo-p-md"> ${{ price }} </span> <span class="typo-p-md"> {{ price }} </span>
<div class="flex flex-col gap-2 items-end"> <div class="flex flex-col gap-2 items-start">
<span class="typo-p-md"> <span class="typo-p-md">
{{ brand }} {{ brand }}
</span> </span>
<span class="typo-sub-h-md"> <span class="typo-sub-h-md">
{{ title }} {{ title }}
</span> </span>
<div class="flex items-center gap-2 mt-1"> <!-- <div class="flex items-center gap-2 mt-1">
<ColorCircle <ColorCircle
v-for="color in colors" v-for="color in colors"
:key="color" :key="color"
:style="{ backgroundColor: color }" :style="{ backgroundColor: color }"
/> />
</div> </div> -->
</div> </div>
</div> </div>
</div> </div>
@@ -1,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
// imports // imports
import useGetProducts, {
type GetProductsFilters,
} from "~/composables/api/products/useGetProducts";
import { useParams } from "~/composables/global/useParams";
import { PRODUCT_RANGE } from "~/constants"; import { PRODUCT_RANGE } from "~/constants";
// state // state
const params = useUrlSearchParams("history"); const params: GetProductsFilters = inject("params");
const sort_filter = ref([ const sort_filter = ref([
{ title: "جدیدترین ها", value: "newest" }, { title: "جدیدترین ها", value: "newest" },
@@ -13,6 +17,13 @@ const sort_filter = ref([
{ title: "ارزان ترین ها", value: "-price" }, { title: "ارزان ترین ها", value: "-price" },
]); ]);
const sliderValue = ref([PRODUCT_RANGE.min, PRODUCT_RANGE.max]);
const has_discount = ref(JSON.parse(params.has_discount) ?? false);
const in_stock = ref(JSON.parse(params.in_stock) ?? false);
const sliderValueDebounced = refDebounced(sliderValue, 1000);
const options = [ const options = [
{ {
name: "میوه", name: "میوه",
@@ -42,15 +53,47 @@ const options = [
}, },
]; ];
const { isPending: productsIsPending } = useGetProducts(params);
// methods // methods
const resetFilters = () => { const resetFilters = () => {
params.sort = null; params.search = "";
params.categories = []; params.sort = "newest";
params.range = [PRODUCT_RANGE.min, PRODUCT_RANGE.max]; sliderValue.value = [PRODUCT_RANGE.min, PRODUCT_RANGE.max];
params.has_discount = false; has_discount.value = false;
params.in_stock = false; in_stock.value = false;
params.category = "";
}; };
// watch
watch(
() => sliderValueDebounced.value,
(newValue) => {
params.price_gte = newValue[0];
params.price_lte = newValue[1];
}
);
watchOnce(
() => [params.price_gte, params.price_lte],
([newGte, newLte]) => {
sliderValue.value[0] = newGte;
sliderValue.value[1] = newLte;
},
{
immediate: true,
}
);
watch(
() => [has_discount.value, in_stock.value],
([newHasDiscount, newInStock]) => {
params.has_discount = newHasDiscount;
params.in_stock = newInStock;
}
);
</script> </script>
<template> <template>
@@ -87,14 +130,15 @@ const resetFilters = () => {
<Icon name="ci:grid" size="24" /> <Icon name="ci:grid" size="24" />
دسته بندی دسته بندی
</div> </div>
<ComboBox :options="options" v-model="params.categories" /> <ComboBox :options="options" v-model="params.category" />
<div class="w-full flex flex-wrap gap-2 px-[1rem]"> <div
v-if="params.category"
class="w-full flex flex-wrap gap-2 px-[1rem]"
>
<span <span
v-for="(sort_param, index) in params.categories"
:key="index"
class="py-1 px-3 cursor-pointer text-nowrap bg-slate-100 rounded-full text-sm" class="py-1 px-3 cursor-pointer text-nowrap bg-slate-100 rounded-full text-sm"
> >
{{ sort_param }} {{ params.category }}
</span> </span>
</div> </div>
</div> </div>
@@ -107,12 +151,12 @@ const resetFilters = () => {
محدوده قیمت محدوده قیمت
</div> </div>
<SliderRoot <SliderRoot
v-model="params.range" v-model="sliderValue"
class="relative flex items-center select-none touch-none h-5" class="relative flex items-center select-none touch-none h-5"
dir="rtl" dir="rtl"
:min="PRODUCT_RANGE.min" :min="PRODUCT_RANGE.min"
:max="PRODUCT_RANGE.max" :max="PRODUCT_RANGE.max"
:step="1" :step="1000"
> >
<SliderTrack <SliderTrack
class="bg-black/10 relative grow rounded-full h-[3px]" class="bg-black/10 relative grow rounded-full h-[3px]"
@@ -132,8 +176,8 @@ const resetFilters = () => {
<span class="text-sm text-black">حداقل</span> <span class="text-sm text-black">حداقل</span>
<span class="text-sm text-black"> <span class="text-sm text-black">
{{ {{
"range" in params "price_gte" in params
? params.range[0].toLocaleString() ? params.price_gte.toLocaleString()
: PRODUCT_RANGE.min : PRODUCT_RANGE.min
}} }}
</span> </span>
@@ -142,8 +186,8 @@ const resetFilters = () => {
<span class="text-sm text-black">حداکثر</span> <span class="text-sm text-black">حداکثر</span>
<span class="text-sm text-black"> <span class="text-sm text-black">
{{ {{
"range" in params "price_lte" in params
? params.range[1].toLocaleString() ? params.price_lte.toLocaleString()
: PRODUCT_RANGE.max : PRODUCT_RANGE.max
}} }}
</span> </span>
@@ -154,7 +198,7 @@ const resetFilters = () => {
<div class="flex items-center justify-between w-full gap-5"> <div class="flex items-center justify-between w-full gap-5">
<span class="text-black">فقط کالاهای تخفیف دار</span> <span class="text-black">فقط کالاهای تخفیف دار</span>
<SwitchRoot <SwitchRoot
v-model="params.has_discount" v-model="has_discount"
class="w-[3rem] h-[1.8rem] flex data-[state=unchecked]:bg-slate-200 data-[state=checked]:bg-stone-800 border border-slate-300 data-[state=checked]:border-stone-700 rounded-full relative transition-all focus-within:outline-none" class="w-[3rem] h-[1.8rem] flex data-[state=unchecked]:bg-slate-200 data-[state=checked]:bg-stone-800 border border-slate-300 data-[state=checked]:border-stone-700 rounded-full relative transition-all focus-within:outline-none"
> >
<SwitchThumb <SwitchThumb
@@ -167,7 +211,7 @@ const resetFilters = () => {
<span class="text-black">فقط کالاهای موجود</span> <span class="text-black">فقط کالاهای موجود</span>
<SwitchRoot <SwitchRoot
v-model="params.in_stock" v-model="in_stock"
class="w-[3rem] h-[1.8rem] flex data-[state=unchecked]:bg-slate-200 data-[state=checked]:bg-stone-800 border border-slate-300 data-[state=checked]:border-stone-700 rounded-full relative transition-all focus-within:outline-none" class="w-[3rem] h-[1.8rem] flex data-[state=unchecked]:bg-slate-200 data-[state=checked]:bg-stone-800 border border-slate-300 data-[state=checked]:border-stone-700 rounded-full relative transition-all focus-within:outline-none"
> >
<SwitchThumb <SwitchThumb
@@ -178,12 +222,21 @@ const resetFilters = () => {
</div> </div>
<Button <Button
end-icon="ci:close" :disabled="productsIsPending"
variant="solid" variant="solid"
@click="resetFilters" @click="resetFilters"
class="rounded-full py-4 !cursor-pointer" class="rounded-full py-4 !cursor-pointer disabled:pointer-events-none"
> >
بازنشانی به پیش فرض <Transition name="fade" mode="out-in">
<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> </Button>
</div> </div>
</template> </template>
+1
View File
@@ -61,6 +61,7 @@ const onSwiper = (swiper: SwiperClass) => {
:key="slide.id" :key="slide.id"
> >
<CategoryCard <CategoryCard
:id="slide.id"
category="یک دسته بندی تست" category="یک دسته بندی تست"
picture="/img/product-1.jpg" picture="/img/product-1.jpg"
:count="20" :count="20"
@@ -7,6 +7,7 @@ import ChatInput from "~/components/product/ChatBox/ChatInput.vue";
import { useIsMutating } from "@tanstack/vue-query"; 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";
// provide-inject // provide-inject
@@ -14,6 +15,8 @@ const { isOpen } = inject("isOpen") as any;
// state // state
const { isLoggedIn } = useAuth();
const route = useRoute(); const route = useRoute();
const id = route.params.id as string | number; const id = route.params.id as string | number;
@@ -126,55 +129,62 @@ whenever(
> >
<CloseButton :disabled="!!isCreateMessagePending" /> <CloseButton :disabled="!!isCreateMessagePending" />
<Transition name="zoom" mode="out-in"> <template v-if="isLoggedIn">
<div <Transition name="zoom" mode="out-in">
v-if="!isChatPending"
class="p-4.5 h-full flex flex-col justify-between gap-4"
>
<div <div
:style="{ v-if="!isChatPending"
class="p-4.5 h-full flex flex-col justify-between gap-4"
>
<div
:style="{
maskImage: maskImage:
'linear-gradient(to top, transparent, black 5%, black, black)', 'linear-gradient(to top, transparent, black 5%, black, black)',
}" }"
class="hide-scrollbar flex flex-col py-7 gap-6 h-full overflow-y-auto" class="hide-scrollbar flex flex-col py-7 gap-6 h-full overflow-y-auto"
ref="chatContainerEl" ref="chatContainerEl"
>
<div
v-if="hasMoreChat"
class="py-2 flex items-center justify-center"
> >
<Icon name="svg-spinners:3-dots-fade" size="24" /> <div
v-if="hasMoreChat"
class="py-2 flex items-center justify-center"
>
<Icon name="svg-spinners:3-dots-fade" size="24" />
</div>
<ChatMessage
v-for="(message, index) in chatMessages"
:key="message.id"
:id="message.id"
:reverse="message.sender === 'ai'"
:content="message.content"
:isLast="chatMessages?.length === index + 1"
@textUpdate="scrollToBottom"
/>
<ChatMessage
v-if="!!isCreateMessagePending"
:id="Date.now() + 1"
reverse
content=""
isLast
@textUpdate="scrollToBottom"
:loadingContent="!!isCreateMessagePending"
/>
</div> </div>
<ChatMessage
v-for="(message, index) in chatMessages" <ChatInput />
:key="message.id"
:id="message.id"
:reverse="message.sender === 'ai'"
:content="message.content"
:isLast="chatMessages?.length === index + 1"
@textUpdate="scrollToBottom"
/>
<ChatMessage
v-if="!!isCreateMessagePending"
:id="Date.now() + 1"
reverse
content=""
isLast
@textUpdate="scrollToBottom"
:loadingContent="!!isCreateMessagePending"
/>
</div> </div>
<ChatInput />
</div>
<div <div
v-else v-else
class="w-full h-full flex items-center justify-center absolute inset-0" class="w-full h-full flex items-center justify-center absolute inset-0"
> >
<AiLoading /> <AiLoading />
</div> </div>
</Transition> </Transition>
</template>
<div class="text-black p-4.5 size-full flex justify-center items-center" v-else>
Please sign in first
</div>
</div> </div>
</Transition> </Transition>
</template> </template>
@@ -4,6 +4,7 @@
import AiLoading from "~/components/product/ChatBox/AiLoading.vue"; import AiLoading from "~/components/product/ChatBox/AiLoading.vue";
import useCreateChatMessage from "~/composables/api/chat/useCreateChatMessage"; import useCreateChatMessage from "~/composables/api/chat/useCreateChatMessage";
import { useToast } from "~/composables/global/useToast";
// state // state
+11 -1
View File
@@ -3,6 +3,7 @@ export const useAuth = () => {
// state // state
const token = useCookie("token"); const token = useCookie("token");
const refreshToken = useCookie("refresh-token");
// method // method
@@ -10,11 +11,20 @@ export const useAuth = () => {
token.value = newToken; token.value = newToken;
}; };
const updateRefreshToken = (newToken: string) => {
refreshToken.value = newToken;
};
const logout = (reload ?: boolean) => { const logout = (reload ?: boolean) => {
token.value = undefined; token.value = undefined;
refreshToken.value = undefined;
if (reload) window.location.reload(); if (reload) window.location.reload();
}; };
return { token, updateToken, logout }; // computed
const isLoggedIn = computed(() => !!token.value);
return { token, refreshToken, updateRefreshToken, updateToken, logout, isLoggedIn };
}; };
@@ -0,0 +1,36 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type RefreshAuthRequest = {
refresh: string,
};
export type RefreshAuthResponse = {
access: string,
refresh: string,
};
const useRefreshAuth = () => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleRefreshAuth = async (variables: RefreshAuthRequest) => {
const { data } = await axios.post<RefreshAuthResponse>(`${API_ENDPOINTS.auth.refresh}/`, variables);
return data;
};
return useMutation({
mutationFn: (variables: RefreshAuthRequest) => handleRefreshAuth(variables)
});
};
export default useRefreshAuth;
@@ -0,0 +1,30 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type VerifyRequest = {
token: string,
};
const useVerify = () => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleVerify = async (variables: VerifyRequest) => {
const { data } = await axios.post(`${API_ENDPOINTS.auth.verify}`, variables);
return data;
};
return useMutation({
mutationFn: (variables: VerifyRequest) => handleVerify(variables)
});
};
export default useVerify;
+4 -1
View File
@@ -2,6 +2,7 @@
import { useInfiniteQuery } from "@tanstack/vue-query"; import { useInfiniteQuery } from "@tanstack/vue-query";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants"; import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
import { useAuth } from "~/composables/api/auth/useAuth";
// types // types
@@ -16,6 +17,8 @@ const useGetBranch = (
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
const { isLoggedIn } = useAuth();
// method // method
const handleGetChat = async ({ productId, limit, offset }: { const handleGetChat = async ({ productId, limit, offset }: {
@@ -37,7 +40,7 @@ const useGetBranch = (
}; };
return useInfiniteQuery({ return useInfiniteQuery({
enabled, enabled: isLoggedIn,
queryKey: [QUERY_KEYS.chat], queryKey: [QUERY_KEYS.chat],
initialPageParam: { initialPageParam: {
limit: 10, limit: 10,
@@ -0,0 +1,29 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetCategoriesResponse = { categories: Category[] };
const useGetCategories = () => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleGetCategories = async () => {
const { data } = await axios.get<GetCategoriesResponse>(`${API_ENDPOINTS.products.categories}`);
return data.categories;
};
return useQuery({
queryKey: [QUERY_KEYS.categories],
queryFn: () => handleGetCategories()
});
};
export default useGetCategories;
@@ -5,44 +5,53 @@ import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types // types
export type GetProductsResponse = Product[]; export type GetProductsResponse = ApiPaginated<Product>;
export type GetProductsFilters = { export type GetProductsFilters = {
search: string | undefined; search?: string | undefined;
sort: string | undefined; sort?: string | undefined;
categories: string[] | undefined; category?: string | undefined;
price_range: number[] | undefined; price_gte: number;
has_discount: boolean | undefined; price_lte: number;
in_stock: boolean | undefined; has_discount?: boolean | undefined;
in_stock?: boolean | undefined;
page: number;
}; };
// composable // composable
const useGetProducts = ( const useGetProducts = (params?: GetProductsFilters) => {
filters: GetProductsFilters,
page: ComputedRef<number>
) => {
// state // state
const { $axios: axios } = useNuxtApp(); const { $axios: axios } = useNuxtApp();
const {
search,
sort,
in_stock,
has_discount,
category,
price_gte,
price_lte,
page,
} = toRefs(params as GetProductsFilters);
// methods // methods
const handleGetProducts = async ({ const handleGetProducts = async (params?: GetProductsFilters) => {
filters,
page,
}: {
filters: GetProductsFilters;
page: number;
}) => {
const { data } = await axios.get<GetProductsResponse>( const { data } = await axios.get<GetProductsResponse>(
`${API_ENDPOINTS.products.get_all}`, `${API_ENDPOINTS.products.get_all}`,
{ {
params: { params: {
...filters, sort: params?.sort,
page, in_stock: params?.in_stock,
offest: page * 10 - 10, search: params?.search,
limit: 10, has_discount: params?.has_discount,
category: params?.category,
price_gte: params?.price_gte,
price_lte: params?.price_lte,
offest: params?.page! * 9 - 9,
limit: 9,
}, },
} }
); );
@@ -52,8 +61,18 @@ const useGetProducts = (
return useQuery({ return useQuery({
staleTime: 60 * 1000, staleTime: 60 * 1000,
queryKey: [QUERY_KEYS.products, filters, page], queryKey: [
queryFn: () => handleGetProducts({ filters, page: page.value }), QUERY_KEYS.products,
search,
sort,
in_stock,
has_discount,
category,
price_gte,
price_lte,
page,
],
queryFn: () => handleGetProducts(params),
}); });
}; };
+4
View File
@@ -7,6 +7,8 @@ export const API_ENDPOINTS = {
get: "/products", get: "/products",
}, },
auth: { auth: {
refresh: "/token/refresh",
verify: "/accounts/verify",
signin: "/token", signin: "/token",
logout: "/accounts/logout", logout: "/accounts/logout",
}, },
@@ -16,6 +18,7 @@ export const API_ENDPOINTS = {
}, },
products: { products: {
get_all: "/products", get_all: "/products",
categories: "/products/categories",
}, },
}; };
@@ -24,6 +27,7 @@ export const QUERY_KEYS = {
product: "product", product: "product",
products: "products", products: "products",
account: "account", account: "account",
categories: "categories",
}; };
export const MUTATION_KEYS = { export const MUTATION_KEYS = {
+67
View File
@@ -1,3 +1,70 @@
<script lang="ts" setup>
// import
import { useAuth } from "~/composables/api/auth/useAuth";
import useRefreshAuth from "~/composables/api/auth/useRefreshAuth";
import useVerify from "~/composables/api/auth/useVerify";
// state
const { mutateAsync: refreshAuth } = useRefreshAuth();
const { token, refreshToken, updateToken, updateRefreshToken, logout } = useAuth();
const { mutateAsync: verify } = useVerify();
// lifecycle
onServerPrefetch(async () => {
if (!!token.value) {
// 1.1 - token is there
try {
await verify({
token: token.value
});
// 2.1 - token is valid, finish
} catch (e) {
// 2.2 - token is there, but not valid, try to refresh token
if (!!refreshToken.value) {
// 3.1 - refresh token is there, try to refresh
try {
const refreshResponse = await refreshAuth({ refresh: refreshToken.value });
// 4.1 - token is refreshed successfully, finish
updateToken(refreshResponse.access);
updateRefreshToken(refreshResponse.refresh);
} catch (e) {
// 4.2 - cant refreshing token, logout
logout();
}
} else {
// 3.2 - refresh token is not exist, logout
logout();
}
}
} else {
// 1.2 - token is not exist, logout
logout();
}
});
</script>
<template> <template>
<div <div
class="w-full flex flex-col-center persian-number font-iran-yekan-x" class="w-full flex flex-col-center persian-number font-iran-yekan-x"
+5 -3
View File
@@ -1,7 +1,9 @@
export default defineNuxtRouteMiddleware(() => { import { useAuth } from "~/composables/api/auth/useAuth";
const { token } = useAuth();
if (token.value) { export default defineNuxtRouteMiddleware(() => {
const { token, logout } = useAuth();
if (!!token.value) {
return; return;
} else { } else {
return navigateTo("/signin"); return navigateTo("/signin");
@@ -1,3 +1,5 @@
import { useAuth } from "~/composables/api/auth/useAuth";
export default defineNuxtRouteMiddleware(() => { export default defineNuxtRouteMiddleware(() => {
const { token } = useAuth(); const { token } = useAuth();
+5
View File
@@ -5,6 +5,10 @@ export default defineNuxtConfig({
devtools: { enabled: false }, devtools: { enabled: false },
css: ["~/assets/css/tailwind.css", "swiper/css"], css: ["~/assets/css/tailwind.css", "swiper/css"],
routeRules: {
"/products": { prerender: false, ssr: false },
},
postcss: { postcss: {
plugins: { plugins: {
"@tailwindcss/postcss": {}, "@tailwindcss/postcss": {},
@@ -44,6 +48,7 @@ export default defineNuxtConfig({
"@nuxt/icon", "@nuxt/icon",
"reka-ui/nuxt", "reka-ui/nuxt",
"@vueuse/nuxt", "@vueuse/nuxt",
"@formkit/auto-animate/nuxt"
], ],
runtimeConfig: { runtimeConfig: {
+15 -1
View File
@@ -7,6 +7,7 @@
"name": "nuxt-app", "name": "nuxt-app",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@nuxt/icon": "^1.9.1", "@nuxt/icon": "^1.9.1",
"@nuxtjs/google-fonts": "^3.2.0", "@nuxtjs/google-fonts": "^3.2.0",
"@tanstack/vue-query": "^5.62.2", "@tanstack/vue-query": "^5.62.2",
@@ -24,7 +25,8 @@
"universal-cookie": "^7.2.2", "universal-cookie": "^7.2.2",
"vue": "latest", "vue": "latest",
"vue-router": "latest", "vue-router": "latest",
"vue-scrollto": "^2.20.0" "vue-scrollto": "^2.20.0",
"vue-skeletor": "^1.0.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0-beta.5", "@tailwindcss/postcss": "^4.0.0-beta.5",
@@ -1007,6 +1009,12 @@
} }
} }
}, },
"node_modules/@formkit/auto-animate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.2.tgz",
"integrity": "sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==",
"license": "MIT"
},
"node_modules/@iconify/collections": { "node_modules/@iconify/collections": {
"version": "1.0.492", "version": "1.0.492",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.492.tgz", "resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.492.tgz",
@@ -10774,6 +10782,12 @@
"bezier-easing": "2.1.0" "bezier-easing": "2.1.0"
} }
}, },
"node_modules/vue-skeletor": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/vue-skeletor/-/vue-skeletor-1.0.6.tgz",
"integrity": "sha512-ER4vHlFSXCW3ixK2DlczUE6CZliHsn4d2TvZ9/26C6Oq8zoyEY23BsqweMPtF8QULSz1+G5m2New1BwKNVOZhQ==",
"license": "MIT"
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+3 -1
View File
@@ -12,6 +12,7 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@nuxt/icon": "^1.9.1", "@nuxt/icon": "^1.9.1",
"@nuxtjs/google-fonts": "^3.2.0", "@nuxtjs/google-fonts": "^3.2.0",
"@tanstack/vue-query": "^5.62.2", "@tanstack/vue-query": "^5.62.2",
@@ -29,7 +30,8 @@
"universal-cookie": "^7.2.2", "universal-cookie": "^7.2.2",
"vue": "latest", "vue": "latest",
"vue-router": "latest", "vue-router": "latest",
"vue-scrollto": "^2.20.0" "vue-scrollto": "^2.20.0",
"vue-skeletor": "^1.0.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0-beta.5", "@tailwindcss/postcss": "^4.0.0-beta.5",
+82 -62
View File
@@ -1,68 +1,88 @@
<script lang="ts" setup>
import useGetCategories from "~/composables/api/product/useGetCategories";
// state
const { data: categories, suspense } = useGetCategories();
const search = ref("");
const debouncedSearch = refDebounced(search, 300);
// computed
const filteredCategories = computed(() => {
if (debouncedSearch.value.length > 0) {
return categories.value!.filter(cat => cat.name.includes(debouncedSearch.value));
}
return categories.value!;
});
// lifecycle
onServerPrefetch(async () => {
const response = await suspense();
if (response.isError) {
throw createError({
statusCode: 500,
statusMessage: `Error in categories page prefetch`
});
}
});
</script>
<template> <template>
<div class="container"> <div class="container">
<div class="mt-20"> <div class="mt-20 flex gap-6 justify-between items-center">
<span class="typo-h-3 text-black">دسته بندی ها</span> <span class="typo-h-3 text-black">دسته بندی ها</span>
<Input
class="max-w-[400px] w-full"
variant="outlined"
placeholder="جستجو..."
v-model="search"
>
<template #endItem>
<div class="flex items-center gap-1">
<Icon class="translate-y-[-1px]" name="ci:search" size="24" />
</div>
</template>
</Input>
</div> </div>
<div class="grid grid-cols-3 gap-4 w-full mt-12"> <Transition name="fade" mode="out-in">
<CategoryCard <div
:id="1" v-if="filteredCategories.length !== 0"
category="یک دسته بندی تست" v-auto-animate
picture="/img/product-1.jpg" class="grid grid-cols-3 gap-4 w-full mt-12"
:count="20" >
description="یک دسته بندی تستasdasd" <CategoryCard
dark-layer v-for="category in filteredCategories"
/> :key="category.id"
<CategoryCard :id="category.id"
:id="2" :category="category.name"
category="یک دسته بندی تست" picture="/img/product-1.jpg"
picture="/img/product-2.jpg" :count="20"
:count="20" description="یک دسته بندی تستasdasd"
description="یک دسته بندی تستasdasd" dark-layer
/> />
<CategoryCard </div>
:id="3"
category="یک دسته بندی تست" <div
picture="/img/product-3.jpg" v-else
:count="20" class="flex w-full mt-12"
description="یک دسته بندی تستasdasd" >
/> <div
<CategoryCard class="flex-col flex-grow py-[12rem] gap-6 border-2 border-slate-200 border-dashed size-full rounded-100 flex-center"
:id="8" >
category="یک دسته بندی تست" <Icon name="bi:search" size="50" class="**:fill-gray-500" />
picture="/img/product-4.jpg" <span class="text-lg text-gray-500">
:count="20" دسته بندی یافت نشد :(
description="یک دسته بندی تستasdasd" </span>
/> </div>
<CategoryCard </div>
:id="4" </Transition>
category="یک دسته بندی تست"
picture="/img/product-5.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="5"
category="یک دسته بندی تست"
picture="/img/product-1.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="6"
category="یک دسته بندی تست"
picture="/img/product-2.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
<CategoryCard
:id="7"
category="یک دسته بندی تست"
picture="/img/product-3.jpg"
:count="20"
description="یک دسته بندی تستasdasd"
/>
</div>
</div> </div>
</template> </template>
<script setup lang="ts">
</script>
+72 -29
View File
@@ -8,31 +8,48 @@ import { PRODUCT_RANGE } from "~/constants";
// state // state
const route = useRoute(); const params: GetProductsFilters = useUrlSearchParams("history", {
initialValue: {
search: "",
sort: "newest",
price_gte: PRODUCT_RANGE.min,
price_lte: PRODUCT_RANGE.max,
in_stock: false,
has_discount: false,
category: "",
page: "1",
},
});
const params: GetProductsFilters & { page: number } = const search = ref(params.search ?? "");
useUrlSearchParams("history"); const searchDebounced = refDebounced(search, 1000);
// computed // provide / inject
const page = computed(() => (route.query["page"] ? +route.query["page"] : 1)); provide("params", params);
// queries // queries
const { data: products, isLoading: productsIsLoading } = useGetProducts( const { data, isLoading: productsIsLoading } = useGetProducts(params);
params,
page
);
// life-cycle const products = computed(() => {
return data.value?.results.flat();
onMounted(() => {
if (!("range" in params)) {
params.range = [];
params.range[0] = PRODUCT_RANGE.min;
params.range[1] = PRODUCT_RANGE.max;
}
}); });
const paginationData = computed(() => {
return data!.value?.results.map((_, i: number) => {
return { type: "page", value: i };
});
});
// watch
watch(
() => searchDebounced.value,
(newValue) => {
params.search = newValue;
}
);
</script> </script>
<template> <template>
@@ -54,24 +71,50 @@ onMounted(() => {
<div class="w-full flex items-center justify-end gap-4"> <div class="w-full flex items-center justify-end gap-4">
<Input <Input
placeholder="جست و جو محصول ..." placeholder="جست و جو محصول ..."
v-model="search"
class="bg-slate-50 !border-slate-200 hover:border-slate-300 focus:!border-slate-800 !rounded-full w-8/12" class="bg-slate-50 !border-slate-200 hover:border-slate-300 focus:!border-slate-800 !rounded-full w-8/12"
/> />
<FilterButton /> <FilterButton />
</div> </div>
</div> </div>
<ul class="w-full grid grid-cols-3 gap-[1.5rem]"> <ul
<li v-for="i in 9" :key="i"> v-if="productsIsLoading"
<ProductCard class="w-full grid grid-cols-3 gap-[1.5rem]"
brand="Samsung" >
title="Galaxy S20 Ultra" <Skeleton
picture="/assets/img/product-1.jpg" v-for="i in 9"
:colors="['#0000ff', '#00ff00', 'red']" :key="i"
:price="599" class="w-full !h-[31.25rem] !rounded-2xl"
:rate="2.4" />
tag="New"
/>
</li>
</ul> </ul>
<div v-else class="w-full h-max">
<div v-if="!products?.length" class="flex flex-grow px-5 w-full">
<div
class="flex-col flex-grow py-[12rem] gap-6 border-2 border-slate-200 border-dashed size-full rounded-100 flex-center"
>
<Icon name="bi:search" size="50" class="**:fill-gray-500" />
<span class="text-lg text-gray-500">
محصولی یافت نشد :(
</span>
</div>
</div>
<ul v-else class="w-full grid grid-cols-3 gap-[1.5rem]">
<li v-for="(product, index) in products" :key="index">
<ProductCard
brand="Samsung"
:title="product.name"
picture="/assets/img/product-1.jpg"
:colors="['#0000ff', '#00ff00', 'red']"
:price="product.price"
:rate="product.rating"
tag="New"
/>
</li>
</ul>
<div class="w-full flex-center py-10">
<Pagination :items="paginationData" :total="data?.count" />
</div>
</div>
</div> </div>
</template> </template>
+4 -5
View File
@@ -28,11 +28,10 @@ definePageMeta({
const { addToast } = useToast(); const { addToast } = useToast();
const { updateToken } = useAuth(); const { updateToken, updateRefreshToken } = useAuth();
const { refetch: refetchAccount } = useGetAccount(); const { refetch: refetchAccount } = useGetAccount();
const showOtp = ref(false); const showOtp = ref(false);
const otpCode = ref([]); const otpCode = ref([]);
@@ -107,6 +106,8 @@ const handleLogin = async () => {
}); });
updateToken(response.access); updateToken(response.access);
updateRefreshToken(response.refresh);
await new Promise(resolve => setTimeout(resolve, 1000));
await refetchAccount(); await refetchAccount();
addToast({ addToast({
@@ -116,9 +117,7 @@ const handleLogin = async () => {
} }
}); });
setTimeout(() => { navigateTo("/");
navigateTo("/");
}, 2000);
} catch (e) { } catch (e) {
otpCode.value = []; otpCode.value = [];
addToast({ message: "مشکلی پیش آمده" }); addToast({ message: "مشکلی پیش آمده" });
+5 -5
View File
@@ -4,10 +4,10 @@ import { API_ENDPOINTS } from "~/constants";
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const { token } = useAuth(); const { token, logout } = useAuth();
const axios = axiosOriginal.create({ const axios = axiosOriginal.create({
baseURL: config.public.API_BASE_URL, baseURL: config.public.API_BASE_URL
}); });
axios.interceptors.request.use((config) => { axios.interceptors.request.use((config) => {
@@ -25,9 +25,9 @@ export default defineNuxtPlugin(() => {
return response; return response;
}, function(error) { }, function(error) {
if (error.status === 401) { // if (error.status === 401) {
logout(); // logout();
} // }
return Promise.reject(error); return Promise.reject(error);
}); });
+6
View File
@@ -0,0 +1,6 @@
import { Skeletor } from 'vue-skeletor';
import 'vue-skeletor/dist/vue-skeletor.css';
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component("Skeleton", Skeletor)
})
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
import fs from "fs/promises";
type LogType = {
title: string;
status?: "success" | "error" | "info" | "warning";
message?: string,
details?: any
}
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`;
if (log.message) {
markdownContent += `**Message:** ${log.message}\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);
}
}
}
export default Logger;
+11
View File
@@ -32,4 +32,15 @@ declare global {
"meta_rating": number | null "meta_rating": number | null
} }
type Category = {
"id": number,
"name": string,
"slug": string,
"icon": string,
"meta_title": string,
"meta_description": string,
"parent": number,
"children": "string"
}
} }