merge with front and admin login and min price

This commit is contained in:
Parsa Nazer
2025-02-04 00:23:45 +03:30
15 changed files with 506 additions and 142 deletions
+72 -12
View File
@@ -1,4 +1,10 @@
<script setup lang="ts">
// imports
import useDeleteAddress from "~/composables/api/account/useDeleteAddress";
import { useToast } from "~/composables/global/useToast";
import { QUERY_KEYS } from "~/constants";
// types
type Props = {
@@ -8,29 +14,86 @@ type Props = {
// props
const props = defineProps<Props>();
defineProps<Props>();
// emit
const emit = defineEmits(["select"]);
// computed
// state
const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast();
// queries
const { mutateAsync: deleteAddress, isPending: deleteAddressIsPending } =
useDeleteAddress();
// methods
const handleDeleteAddress = (id: number) => {
deleteAddress(
{ id },
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.addresses],
});
addToast({
message: "آدرس با موفقیت حذف شد",
options: {
status: "success",
},
});
},
onError: () => {
addToast({
message: "مشکلی در حذف آدرس رخ داد",
options: {
status: "error",
},
});
},
}
);
};
</script>
<template>
<button
@click="!!address ? emit('select', address) : null"
@click.prevent="!!address ? emit('select', address) : null"
:class="
isSelected
? 'border-cyan-500 ring-2 ring-offset-2 ring-cyan-500'
? 'border-slate-200 ring-2 ring-offset-2 ring-black'
: 'border-slate-200'
"
class="flex flex-col items-center transition-all cursor-pointer w-full gap-4 p-4 border rounded-xl bg-slate-50"
class="flex flex-col items-center transition-all relative cursor-pointer w-full group gap-4 p-4 border rounded-xl bg-slate-50 overflow-hidden"
>
<div v-if="deleteAddressIsPending" class="absolute inset-0">
<Skeleton class="!size-full !rounded-xl" />
</div>
<span class="flex items-center justify-between w-full gap-3">
<div
class="flex items-center gap-3 lg:text-[1.125rem] font-semibold text-slate-900"
>
{{ !!address ? address.name : "آدرس" }}
<span
class="flex items-center justify-start w-full lg:text-[1.125rem] font-semibold text-slate-900"
v-if="isSelected"
class="bg-black rounded-xl px-3 py-2 text-slate-200 text-xs"
>
آدرس
انتخاب شده
</span>
</div>
<button
v-if="!!address"
@click.stop="handleDeleteAddress(address.id!)"
class="size-8 bg-slate-200/50 rounded-sm flex-center me-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Icon name="bi:trash" class="**:fill-red-500" />
</button>
</span>
<div
@@ -41,16 +104,13 @@ const emit = defineEmits(["select"]);
>
{{
!!address
? `ایران, ${address.province}, ${address.city}, ${address.full_address}, کدپستی ${address.postal_code}`
? `ایران, ${address.province}, ${address.city}, ${address.address}, کدپستی ${address.postal_code}`
: "افزودن آدرس جدید"
}}
</span>
<div class="flex items-center justify-end w-full lg:w-3/12">
<AddressModal
@add="(data) => $emit('add', data)"
:address="address"
/>
<AddressModal :address="address" />
</div>
</div>
</button>
+168 -49
View File
@@ -1,4 +1,13 @@
<script setup lang="ts">
// imports
import useCreateOrUpdateAddress, {
type CreateOrUpdateAddressRequest,
} from "~/composables/api/account/useCreateOrUpdateAddress";
import useGetAccount from "~/composables/api/account/useGetAccount";
import { QUERY_KEYS } from "~/constants";
import { useToast } from "~/composables/global/useToast";
// types
type Props = {
@@ -11,45 +20,105 @@ const props = defineProps<Props>();
const { address } = toRefs(props);
// emit
const emit = defineEmits(["add"]);
// state
const isShow = ref(false);
const addressData = ref<
Pick<Address, "province" | "city" | "postal_code" | "full_address">
>({
province: address.value?.province ?? "",
city: address.value?.city ?? "",
postal_code: address.value?.postal_code ?? "",
full_address: address.value?.full_address ?? "",
});
// computed
const isEditing = computed(() => !!address.value);
// state
const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast();
const isShow = ref(false);
const addressData = ref<CreateOrUpdateAddressRequest>({
id: address.value?.id ?? undefined,
province: address.value?.province ?? "",
city: address.value?.city ?? "",
postal_code: address.value?.postal_code ?? "",
address: address.value?.address ?? "",
name: address.value?.name ?? "",
phone: address.value?.phone ?? "",
for_me: !isEditing.value
? address.value?.for_me ?? "بله"
: address.value?.for_me == true
? "بله"
: "خیر",
});
// queries
const { data: account } = useGetAccount();
const {
mutateAsync: createOrUpdateAddress,
isPending: createAddressIsPending,
} = useCreateOrUpdateAddress(isEditing);
// methods
const closeModal = () => {
if (!isEditing.value) {
addressData.value = {
id: undefined,
province: "",
city: "",
address: "",
postal_code: "",
full_address: "",
name: "",
phone: "",
for_me: "بله",
};
}
isShow.value = false;
};
const addNew = () => {
emit("add", { ...addressData.value, id: Date.now() });
createOrUpdateAddress(
{ ...addressData.value },
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.addresses],
});
closeModal();
addToast({
message: isEditing.value
? "آدرس با موفقیت ویرایش شد"
: "آدرس با موفقیت اضافه شد",
options: {
status: "success",
},
});
},
onError: () => {
addToast({
message: isEditing.value
? "آدرس با موفقیت ویرایش شد"
: "مشکلی در افزودن آدرس رخ داد",
options: {
status: "error",
},
});
},
}
);
};
watch(
() => addressData.value.for_me,
(newValue) => {
if (!isEditing.value) {
addressData.value.phone =
newValue == "بله" ? account.value?.phone : "";
}
},
{
immediate: true,
deep: true,
}
);
</script>
<template>
@@ -74,7 +143,7 @@ const addNew = () => {
</DialogTrigger>
<DialogPortal>
<DialogOverlay
class="bg-black/20 data-[state=open]:animate-overlay-show fixed inset-0 z-30"
class="bg-black/50 backdrop-blur-sm data-[state=open]:animate-overlay-show fixed inset-0 z-30"
/>
<DialogContent
class="data-[state=open]:animate-content-show text-black font-iran-yekan-x fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[50rem] translate-x-[-50%] translate-y-[-50%] rounded-3xl bg-white shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] focus:outline-none z-[100]"
@@ -86,10 +155,16 @@ const addNew = () => {
class="inline-flex size-8 items-center justify-center transition-all rounded-full bg-gray-50 border border-slate-200 hover:border-black focus:outline-none"
aria-label="Close"
>
<Icon name="bi:x-lg" class="**:fill-red-600" />
<Icon name="bi:x-lg" class="**:fill-black" />
</DialogClose>
<DialogTitle class="typo-sub-h-xl font-semibold">
<DialogTitle
class="typo-sub-h-xl font-semibold flex items-center gap-3"
>
{{ !!address ? "ویرایش آدرس" : "افزودن آدرس" }}
<Icon
:name="!!address ? 'bi:pen' : 'bi:plus'"
:size="!!address ? '20' : '32'"
/>
</DialogTitle>
</div>
@@ -97,6 +172,59 @@ const addNew = () => {
<div
class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
>
<div class="flex flex-col gap-2">
<label
for="name"
class="text-xs font-semibold lg:text-sm text-gray-900"
>نام پیش فرض
<span class="text-sm text-red-500"
>*</span
></label
>
<Input
id="name"
type="text"
placeholder="اینجا وارد کنید ..."
v-model="addressData.name"
/>
</div>
<div class="flex flex-col gap-2">
<label
for="province"
class="text-xs font-semibold lg:text-sm text-gray-900"
>آدرس شما؟
<span class="text-sm text-red-500"
>*</span
></label
>
<Select
:options="['بله', 'خیر']"
placeholder="انتخاب کنید"
v-model="addressData.for_me"
/>
</div>
<div class="flex flex-col gap-2">
<label
for="phone"
class="text-xs font-semibold lg:text-sm text-gray-900"
>شماره تلفن
<span class="text-sm text-red-500"
>*</span
></label
>
<Input
id="phone"
type="text"
placeholder="اینجا وارد کنید ..."
v-model="addressData.phone"
/>
</div>
<div class="flex flex-col gap-2">
<label
for="province"
@@ -107,18 +235,10 @@ const addNew = () => {
></label
>
<ComboBox
<Input
id="province"
:options="[
{
name: 'استان ها',
children: [
{ name: 'مشهد' },
{ name: 'مشهد' },
{ name: 'مشهد' },
],
},
]"
type="text"
placeholder="اینجا وارد کنید ..."
v-model="addressData.province"
/>
</div>
@@ -132,18 +252,10 @@ const addNew = () => {
>*</span
></label
>
<ComboBox
<Input
id="city"
:options="[
{
name: 'استان ها',
children: [
{ name: 'مشهد' },
{ name: 'مشهد' },
{ name: 'مشهد' },
],
},
]"
type="text"
placeholder="اینجا وارد کنید ..."
v-model="addressData.city"
/>
</div>
@@ -160,8 +272,7 @@ const addNew = () => {
<Input
id="post"
type="text"
placeholder="جست و جو"
class="rounded-xl"
placeholder="اینجا وارد کنید ..."
v-model="addressData.postal_code"
/>
</div>
@@ -177,16 +288,24 @@ const addNew = () => {
<textarea
id="address"
placeholder="آدرس خود را بنویسید"
v-model="addressData.full_address"
v-model="addressData.address"
class="flex items-center field-sizing-content resize-none bg-slate-50 border-slate-200 hover:border-black focus:border-black max-h-[10rem] text-black justify-between cursor-text transition-all border-[1.5px] gap-3 typo-label-md px-4 py-3.5 selection:bg-slate-100 rounded-100 outline-none flex-1 !text-sm placeholder-slate-400"
></textarea>
</div>
</div>
<div class="p-6 border-t border-slate-200 flex gap-3">
<Button @click="addNew" class="rounded-full px-10"
>ثبت</Button
<Button
:disabled="createAddressIsPending"
@click="addNew"
class="rounded-full px-10"
>
<Icon
v-if="createAddressIsPending"
name="svg-spinners:3-dots-bounce"
/>
<span v-else>ثبت</span>
</Button>
<DialogClose aria-label="Close">
<Button variant="outlined" class="rounded-full px-10">
انصراف
@@ -0,0 +1,23 @@
<script setup lang="ts">
// types
type Props = {
title: string;
icon: string;
};
// props
defineProps<Props>();
</script>
<template>
<div
class="w-full flex-col flex-grow py-[12rem] gap-6 border-2 border-slate-200 border-dashed size-full rounded-100 flex-center"
>
<Icon :name="icon" size="50" class="**:fill-gray-500" />
<span class="text-lg text-gray-500"> {{ title }} </span>
</div>
</template>
<style scoped></style>
+9 -10
View File
@@ -13,7 +13,9 @@ type Emit = {
// props
defineProps<Props>();
const props = defineProps<Props>();
const { modelValue } = toRefs(props);
// emit
@@ -21,16 +23,12 @@ const emit = defineEmits<Emit>();
// state
const selectedValue = ref();
const selectedValue = computed({
get: () => modelValue.value,
set: (value: string) => emit("update:modelValue", value),
});
// watch
watch(
() => selectedValue.value,
(newValue) => {
emit("update:modelValue", newValue);
}
);
</script>
<template>
@@ -41,7 +39,8 @@ watch(
>
<SelectValue
:placeholder="placeholder"
class="text-slate-400 font-iran-yekan-x"
:class="selectedValue ? 'text-black' : 'text-slate-400'"
class="font-iran-yekan-x"
/>
<Icon name="bi:chevron-down" size="16" />
</SelectTrigger>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
// types
type Props = {
modelValue: boolean;
};
type Emit = {
"update:modelValue": [value: boolean];
};
// props
const props = defineProps<Props>();
const { modelValue } = toRefs(props);
// emit
const emit = defineEmits<Emit>();
// computed
const value = computed({
get: () => modelValue.value ?? false,
set: (value) => emit("update:modelValue", value),
});
</script>
<template>
<SwitchRoot
v-model="value"
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
class="size-6 my-auto bg-white text-sm ms-1 flex items-center justify-center shadow-xl rounded-full transition-transform translate-x-0.5 will-change-transform data-[state=checked]:-translate-x-[68%]"
/>
</SwitchRoot>
</template>
<style scoped></style>
@@ -177,27 +177,14 @@ watch(
<div class="flex items-center justify-between w-full gap-5">
<span class="text-black">فقط کالاهای تخفیف دار</span>
<SwitchRoot
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"
>
<SwitchThumb
class="size-6 my-auto bg-white text-sm ms-1 flex items-center justify-center shadow-xl rounded-full transition-transform translate-x-0.5 will-change-transform data-[state=checked]:-translate-x-[68%]"
/>
</SwitchRoot>
<Switch v-model="has_discount" />
</div>
<div class="flex items-center justify-between w-full gap-5">
<span class="text-black">فقط کالاهای موجود</span>
<SwitchRoot
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"
>
<SwitchThumb
class="size-6 my-auto bg-white text-sm ms-1 flex items-center justify-center shadow-xl rounded-full transition-transform translate-x-0.5 will-change-transform data-[state=checked]:-translate-x-[68%]"
/>
</SwitchRoot>
<Switch v-model="has_discount" />
</div>
</div>
@@ -0,0 +1,38 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type CreateOrUpdateAddressRequest = Address;
const useCreateOrUpdateAddress = (update: ComputedRef<Boolean>) => {
// state
const { $axios: axios } = useNuxtApp();
// method
const handleCreateOrUpdateAddress = async (
addressData: CreateOrUpdateAddressRequest
) => {
const { data } = await axios[update.value ? "put" : "post"](
update.value
? `${API_ENDPOINTS.account.address.update}/${addressData.id}`
: API_ENDPOINTS.account.address.create,
{
...addressData,
for_me: addressData.for_me == "بله" ? true : false,
}
);
return data;
};
return useMutation({
mutationFn: (addressData: CreateOrUpdateAddressRequest) =>
handleCreateOrUpdateAddress(addressData),
});
};
export default useCreateOrUpdateAddress;
@@ -0,0 +1,32 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type DeleteAddressRequest = {
id: number;
};
const useDeleteAddress = () => {
// state
const { $axios: axios } = useNuxtApp();
// method
const handleDeleteAddress = async (id: number) => {
const { data } = await axios.delete(
`${API_ENDPOINTS.account.address.delete}/${id}`
);
return data;
};
return useMutation({
mutationFn: (data: DeleteAddressRequest) =>
handleDeleteAddress(data.id),
});
};
export default useDeleteAddress;
@@ -0,0 +1,30 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetAllAddressResponse = Address[];
const useGetAllAddress = () => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleGetAllAddress = async () => {
const { data } = await axios.get<GetAllAddressResponse>(
API_ENDPOINTS.account.address.get_all
);
return data;
};
return useQuery({
queryKey: [QUERY_KEYS.addresses],
queryFn: () => handleGetAllAddress(),
});
};
export default useGetAllAddress;
+17 -10
View File
@@ -2,31 +2,37 @@ export const API_ENDPOINTS = {
home: "/home",
blog: {
articles: "/blogs/all",
article: "/blogs"
article: "/blogs",
},
account: {
profile: "/accounts/profile",
send_otp: "/accounts/send_otp"
send_otp: "/accounts/send_otp",
address: {
create: "/accounts/address/create",
update: "/accounts/address/edit",
get_all: "/accounts/address/list",
delete: "/accounts/address/delete",
},
},
product: {
comments: "/products/comments",
create_comment: "/products/comments",
get: "/products"
get: "/products",
},
auth: {
refresh: "/token/refresh",
verify: "/accounts/verify",
signin: "/token",
logout: "/accounts/logout"
logout: "/accounts/logout",
},
chat: {
messages: "/chat/product",
new_message: "/chat/product"
new_message: "/chat/product",
},
products: {
get_all: "/products",
categories: "/products/categories"
}
categories: "/products/categories",
},
};
export const QUERY_KEYS = {
@@ -38,14 +44,15 @@ export const QUERY_KEYS = {
product: "product",
products: "products",
account: "account",
categories: "categories"
categories: "categories",
addresses: "addresses",
};
export const MUTATION_KEYS = {
create_chat: "create_chat"
create_chat: "create_chat",
};
export const PRODUCT_RANGE = {
min: 0,
max: 100_000_000
max: 100_000_000,
};
+3 -1
View File
@@ -52,7 +52,9 @@ const nextPage = computed(() => route.meta.nextPage);
<div
class="w-full flex flex-col items-center relative justify-between gap-8 lg:gap-6 lg:flex-row lg:items-start"
>
<div class="flex flex-col w-full gap-4 lg:gap-6 lg:w-9/12">
<div
class="flex flex-col w-full gap-4 lg:gap-6 lg:w-9/12 shrink-0"
>
<slot />
</div>
+1
View File
@@ -3,6 +3,7 @@
definePageMeta({
layout: "cart",
middleware: "check-is-logged-in",
pageTitle: "ثبت سفارش",
prevPage: { name: "cart-delivery", label: "انتخاب آدرس" },
nextPage: { name: "checkout", label: "پرداخت" },
+31 -10
View File
@@ -1,8 +1,13 @@
<script setup lang="ts">
// imports
import useGetAllAddress from "~/composables/api/account/useGetAllAddress";
// meta
definePageMeta({
layout: "cart",
middleware: "check-is-logged-in",
pageTitle: "انتخاب آدرس",
prevPage: { name: "cart", label: "سبد خرید" },
nextPage: { name: "cart-checkout", label: "تسویه حساب" },
@@ -21,8 +26,6 @@ type DeliveryData = {
// state
const addresses = ref<Address[]>([]);
const deliveryData = ref<DeliveryData>({
address: null,
deliveryMethod: {
@@ -32,11 +35,9 @@ const deliveryData = ref<DeliveryData>({
},
});
// methods
const { data: addresses, isLoading: addressesIsLoading } = useGetAllAddress();
const handleAddNewAddress = (address: Address) => {
addresses.value.push(address);
};
// methods
const handleSelectAddress = (address: Address) => {
deliveryData.value.address = { ...address };
@@ -45,10 +46,29 @@ const handleSelectAddress = (address: Address) => {
<template>
<div class="flex flex-col w-full gap-5">
<AddressItem @add="handleAddNewAddress" />
<div v-if="addresses.length > 0" class="flex flex-col w-full gap-6">
<AddressItem />
<div class="flex flex-col w-full gap-6">
<span class="typo-sub-h-xl"> آدرس های شما </span>
<div class="flex flex-col gap-6 w-full">
<div v-if="addressesIsLoading" class="flex flex-col gap-6 w-full">
<Skeleton
v-for="i in 3"
class="w-full !h-[10rem] !rounded-xl"
/>
</div>
<template v-else>
<div
v-if="!addresses?.length"
class="flex flex-grow w-full"
v-auto-animate
>
<Placeholder
title="آدرسی یافت نشد :("
icon="bi:map"
class="!py-[2rem]"
/>
</div>
<div v-else class="flex flex-col gap-6 w-full">
<AddressItem
v-for="(address, index) in addresses"
:key="index"
@@ -57,10 +77,11 @@ const handleSelectAddress = (address: Address) => {
:isSelected="address.id == deliveryData.address?.id"
/>
</div>
</template>
</div>
<div
class="flex flex-col items-center w-full gap-4 p-4 border border-slate-200 rounded-xl bg-slate-50"
class="flex flex-col items-center w-full gap-4 my-3 p-4 border border-slate-200 rounded-xl bg-slate-50"
>
<span
class="flex items-center justify-start w-full lg:text-[1.125rem] font-semibold text-slate-900"
+1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
definePageMeta({
layout: "cart",
middleware: "check-is-logged-in",
pageTitle: "سبد خرید",
prevPage: { name: "index", label: "بازگشت به خانه" },
nextPage: { name: "cart-delivery", label: "انتخاب آدرس" },
+28 -25
View File
@@ -20,7 +20,7 @@ declare global {
email: string;
profile_photo: string;
phone: string;
}
};
type Product = {
id: number;
@@ -28,10 +28,10 @@ declare global {
name: string;
description: string;
currency: string;
"video": string | null,
"image1": string,
"image2": string,
"image3": string,
video: string | null;
image1: string;
image2: string;
image3: string;
rating: number;
view: number;
sell: number;
@@ -63,40 +63,43 @@ declare global {
}
type UserComment = {
"id": number,
"content": string,
"timestamp": string,
"show": boolean,
"product": number,
"user": number
}
id: number;
content: string;
timestamp: string;
show: boolean;
product: number;
user: number;
};
type Category = {
id: number;
name: string;
slug: string;
icon: string;
"product_count": string,
"subcategorys": SubCategory[]
product_count: string;
subcategorys: SubCategory[];
};
type SubCategory = {
"id": number,
"name": string,
"slug": string,
"icon": string,
"image": string,
"product_count": string,
"parent": string,
"show": boolean
}
id: number;
name: string;
slug: string;
icon: string;
image: string;
product_count: string;
parent: string;
show: boolean;
};
type Address = {
id: number;
id: number | undefined;
province: string;
city: string;
postal_code: string;
full_address: string;
address: string;
phone: string;
name: string;
for_me: "خیر" | "بله";
};
type DeliveryMethod = {