This commit is contained in:
Parsa Nazer
2025-02-12 18:33:26 +03:30
15 changed files with 452 additions and 125 deletions
+30 -14
View File
@@ -1,4 +1,8 @@
<script setup lang="ts">
// imports
import { useImage } from "@vueuse/core";
// types
type Props = {
@@ -8,25 +12,37 @@ type Props = {
// props
defineProps<Props>();
const props = defineProps<Props>();
const { src } = toRefs(props);
// state
const { isLoading } = useImage({ src: src.value });
</script>
<template>
<AvatarRoot
class="inline-flex size-[45px] select-none items-center justify-center rounded-full align-middle"
class="flex-center size-full select-none rounded-full align-middle overflow-hidden"
>
<AvatarImage
class="h-full w-full rounded-full object-cover"
:src="src"
:alt="alt"
<Skeleton
v-if="isLoading"
class="w-full !h-[110%] !rounded-full aspect-square"
/>
<AvatarFallback
class="flex-center size-full text-sm font-medium rounded-full"
:delay-ms="600"
>
<div class="size-full rounded-full flex-center">
<Icon name="ci:profile" size="16" class="**:stroke-black" />
</div>
</AvatarFallback>
<template v-else>
<AvatarImage
class="!size-full rounded-full object-cover"
:src="src"
:alt="alt"
/>
<AvatarFallback
class="flex-center size-full text-sm font-medium rounded-full"
:delay-ms="600"
>
<div class="size-full rounded-full flex-center">
<Icon name="ci:profile" size="16" class="**:stroke-black" />
</div>
</AvatarFallback>
</template>
</AvatarRoot>
</template>
+5 -2
View File
@@ -13,7 +13,7 @@ const props = withDefaults(defineProps<Props>(), {
variant: "solid",
size: "lg",
});
const { variant, size } = toRefs(props);
const { variant, size, loading } = toRefs(props);
// computed
const classes = computed(() => {
@@ -30,12 +30,15 @@ const classes = computed(() => {
"btn-lg": size.value === "lg",
"btn-md": size.value === "md",
},
{
"pointer-events-none opacity-80 cursor-not-allowed": loading.value,
},
];
});
</script>
<template>
<button :class="classes">
<button :class="classes" :disabled="loading">
<Icon v-if="!loading && startIcon" :name="startIcon" />
<slot v-if="!loading" />
<Icon v-if="!loading && endIcon" :name="endIcon" />
+1 -1
View File
@@ -34,7 +34,7 @@ const value = computed({
<template>
<ClientOnly>
<DatePicker
format="jYYYY-jMM-jDD"
format="YYYY-MM-DD"
displayFormat="jYYYY-jMM-jDD"
:editable="false"
placeholder="وارد نشده"
+2 -1
View File
@@ -34,9 +34,10 @@ const isHomePage = computed(() => route.path === "/");
<Tooltip v-if="!!account && !!token" title="حساب کاربری">
<NuxtLink
:to="{ name: 'profile' }"
class="size-[1.6rem] flex items-center justify-center relative overflow-hidden rounded-full border-[1.2px] border-black"
class="!size-[1.6rem] flex items-center justify-center relative overflow-hidden rounded-full border-[1.2px] border-black"
>
<Avatar
class="!size-[1.6rem]"
:src="account.profile_photo"
:alt="
account.first_name && account.last_name
+1 -1
View File
@@ -37,7 +37,7 @@ const selectedValue = computed({
const classes = computed(() => {
return [
"flex items-center 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 flex-1 w-full outline-none",
"flex items-center text-black justify-between cursor-text transition-all border-[1.5px] gap-3 grow-0 typo-label-md px-4 py-3.5 selection:bg-slate-100 rounded-100 flex-1 w-full outline-none",
{
"input-solid": variant.value === "solid",
"input-outlined": variant.value === "outlined",
@@ -13,8 +13,11 @@ defineProps<Props>();
<template>
<div class="flex flex-col w-full">
<div class="flex flex-col items-start">
<div class="w-full flex items-center p-5">
<div
class="w-full flex items-center justify-between h-[3rem] pb-5 px-5"
>
<span class="typo-sub-h-lg">{{ title }}</span>
<slot name="button" />
</div>
</div>
<div class="w-full flex flex-col border border-slate-200 rounded-xl">
@@ -23,9 +23,10 @@ withDefaults(defineProps<Props>(), {
</div>
<slot />
<div
v-if="error.$error"
class="w-full typo-label-xs flex items-center py-1 px-3 rounded-md text-danger-600"
>
این فیلد الزامی است
{{ error.$errors[0].$message }}
</div>
</div>
</template>
@@ -1,9 +1,48 @@
<script setup lang="ts">
// imports
import useUpdateAccount from "~/composables/api/account/useUpdateAccount";
import { useToast } from "~/composables/global/useToast";
// types
type Props = {
modelValue: File | null;
isShow: boolean;
};
type Emits = {
"update:modelValue": [value: File];
"update:isShow": [value: boolean];
};
// props
const props = defineProps<Props>();
const { modelValue, isShow } = toRefs(props);
// emits
const emit = defineEmits<Emits>();
// state
const isShow = ref(false);
const visible = computed({
get: () => isShow.value ?? false,
set: (value: boolean) => emit("update:isShow", value),
});
const currentProfile = ref("");
const { addToast } = useToast();
const {
open: openFileDialog,
reset: resetFileDialog,
onChange: onFileDialogChange,
} = useFileDialog({
accept: ".jpg, .jpeg, .png",
directory: false,
});
const avatars = ref([
"/avatars/1.jpg",
@@ -11,42 +50,41 @@ const avatars = ref([
"/avatars/3.jpg",
"/avatars/4.jpg",
"/avatars/5.jpg",
"/avatars/1.jpg",
"/avatars/2.jpg",
"/avatars/3.jpg",
"/avatars/4.jpg",
"/avatars/5.jpg",
"/avatars/1.jpg",
"/avatars/2.jpg",
"/avatars/3.jpg",
"/avatars/4.jpg",
"/avatars/5.jpg",
"/avatars/1.jpg",
"/avatars/2.jpg",
"/avatars/3.jpg",
"/avatars/4.jpg",
"/avatars/5.jpg",
"/avatars/1.jpg",
"/avatars/2.jpg",
"/avatars/3.jpg",
"/avatars/4.jpg",
"/avatars/5.jpg",
"/avatars/5.jpg",
"/avatars/1.jpg",
"/avatars/2.jpg",
"/avatars/3.jpg",
"/avatars/4.jpg",
"/avatars/5.jpg",
"/avatars/1.jpg",
"/avatars/2.jpg",
"/avatars/3.jpg",
"/avatars/4.jpg",
"/avatars/5.jpg",
]);
// queries
const { isPending: updateAccountIsPending } = useUpdateAccount();
// computed
const currentProfile = computed({
get: () =>
!!modelValue.value ? URL.createObjectURL(modelValue.value) : null,
set: (value: File) => emit("update:modelValue", value),
});
// methods
onFileDialogChange((files: any) => {
const file = files[0];
if (file.size > 2 * 1024 * 1024) {
addToast({
message: "محدودیت حجم فایل حداکثر ۲ مگابایت می باشد",
options: {
status: "error",
},
});
return;
}
emit("update:modelValue", file);
resetFileDialog();
});
</script>
<template>
<Modal v-model="isShow" title="عکس پروفایل" icon="bi:image" iconSize="20">
<Modal v-model="visible" title="عکس پروفایل" icon="bi:image" iconSize="20">
<template #trigger>
<button
class="bg-black text-slate-100 rounded-full p-2 flex-center absolute -bottom-0 -right-0"
@@ -105,7 +143,18 @@ const avatars = ref([
class="size-full"
/>
</div>
<Button class="rounded-full">آپلود عکس شما</Button>
<Button
class="rounded-full w-[8rem]"
@click="openFileDialog"
:loading="updateAccountIsPending"
size="md"
>
<Icon
v-if="updateAccountIsPending"
name="svg-spinners:3-dots-bounce"
/>
<span v-else>آپلود عکس شما</span>
</Button>
</div>
</div>
</div>
@@ -0,0 +1,45 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type UpdateAccountRequest = {
profile_photo?: File | null;
first_name?: string;
last_name?: string;
phone?: string;
gender?: string | undefined;
email?: string;
birth_date?: string;
};
const useUpdateAccount = () => {
// state
const { $axios: axios } = useNuxtApp();
// method
const handleUpdateAccount = async (params: UpdateAccountRequest) => {
const { data } = await axios.patch(
API_ENDPOINTS.account.update,
{
...params,
},
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
return data;
};
return useMutation({
mutationFn: (data: UpdateAccountRequest) => handleUpdateAccount(data),
});
};
export default useUpdateAccount;
@@ -0,0 +1,39 @@
export const useObjectTrack = (object: Ref) => {
// state
const isNotEqual = ref(false);
const { history, reset, clear } = useRefHistory(object, {
deep: true,
});
// watch
watch(
() => history.value,
(newHistory) => {
if (newHistory.length < 2) {
isNotEqual.value = false;
return;
}
const initial = newHistory[0].snapshot;
const current = newHistory[newHistory.length - 1].snapshot;
const hasChanges = Object.keys(initial).some(
(key) =>
JSON.stringify(current[key]) !==
JSON.stringify(initial[key])
);
isNotEqual.value = hasChanges;
},
{ deep: true }
);
return {
isNotEqual,
reset,
clear,
};
};
+1
View File
@@ -13,6 +13,7 @@ export const API_ENDPOINTS = {
get_all: "/accounts/address/list",
delete: "/accounts/address/delete",
},
update: "/accounts/profile",
},
product: {
comments: "/products/comments",
+3 -1
View File
@@ -8,7 +8,9 @@ definePageMeta({
</script>
<template>
<div></div>
<div class="w-full flex flex-col gap-10">
<ProfilePageTitle title="آدرس های شما" icon="bi:map" />
</div>
</template>
<style scoped></style>
+180 -17
View File
@@ -1,7 +1,15 @@
<script setup lang="ts">
// imports
import useVuelidate from "@vuelidate/core";
import { helpers, required, minLength, email } from "@vuelidate/validators";
import useGetAccount from "~/composables/api/account/useGetAccount";
import useUpdateAccount, {
type UpdateAccountRequest,
} from "~/composables/api/account/useUpdateAccount";
import { useObjectTrack } from "~/composables/global/useObjectTrack";
import { useToast } from "~/composables/global/useToast";
import { QUERY_KEYS } from "~/constants";
// meta
@@ -12,15 +20,26 @@ definePageMeta({
// state
const personalData = ref({
name: "",
last_name: "",
phone: "",
gender: undefined,
email: "",
birthDate: "",
const { data: account } = useGetAccount();
const { $queryClient: queryClient } = useNuxtApp();
const { addToast } = useToast();
const personalData = ref<UpdateAccountRequest>({
profile_photo: null,
first_name: account.value?.first_name ?? "",
last_name: account.value?.last_name ?? "",
phone: account.value?.phone ?? "",
gender: account.value?.gender ?? undefined,
email: account.value?.email ?? "",
birth_date: account.value?.birth_date ?? "",
});
const profilePictureModalIsShow = ref(false);
const { isNotEqual, clear: clearObjectTracker } = useObjectTrack(personalData);
const alises = ref([
"شکارچی",
"آیفون باز",
@@ -31,7 +50,109 @@ const alises = ref([
// queries
const { data: account } = useGetAccount();
const { mutateAsync: updateAccount, isPending: updateAccountIsPending } =
useUpdateAccount();
// computed
const formRules = computed(() => {
return {
first_name: {
required: helpers.withMessage("فیلد نام الزامی می باشد", required),
minLength: helpers.withMessage(
"فیلد نام حداقل ۳ کرکتر می باشد",
minLength(3)
),
},
last_name: {
required: helpers.withMessage(
"فیلد نام خانوادگی الزامی می باشد",
required
),
minLength: helpers.withMessage(
"فیلد نام خانوادگی حداقل ۳ کرکتر می باشد",
minLength(3)
),
},
phone: {
required: helpers.withMessage(
"فیلد شماره تلفن الزامی می باشد",
required
),
phoneValidator: helpers.withMessage(
"شماره تلفن وارد شده معتبر نمی باشد",
helpers.regex(/^0?[1-9][0-9]{9}$/)
),
},
gender: {
required: helpers.withMessage(
"فیلد جنسیت الزامی می باشد",
required
),
},
email: {
required: helpers.withMessage(
"فیلد حساب الکترونیکی الزامی می باشد",
required
),
email: helpers.withMessage(
"حساب الکترونیکی وارد شده معتبر نمی باشد",
email
),
},
birth_date: {
required: helpers.withMessage(
"فیلد تاریخ تولد الزامی می باشد",
required
),
},
};
});
const formValidator$ = useVuelidate(formRules, personalData);
// methods
const updateData = () => {
updateAccount(
{ ...personalData.value },
{
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.account],
});
addToast({
message: "اطلاعات با موفقیت تغییر یافت",
options: {
status: "success",
},
});
clearObjectTracker();
},
onError: () => {
addToast({
message: "خطایی در تغییر اطلاعات رخ داد",
options: {
status: "error",
},
});
},
}
);
};
const handleSubmit = (withValidation: boolean) => {
if (withValidation) {
formValidator$.value.$validate();
if (!formValidator$.value.$errors.length) {
updateData();
}
} else {
updateData();
profilePictureModalIsShow.value = false;
}
};
</script>
<template>
@@ -57,7 +178,11 @@ const { data: account } = useGetAccount();
"
/>
<PictureModal />
<ProfilePictureModal
v-model:is-show="profilePictureModalIsShow"
v-model="personalData.profile_photo!"
@update:model-value="() => handleSubmit(false)"
/>
</div>
<div class="flex flex-col gap-3">
@@ -100,50 +225,88 @@ const { data: account } = useGetAccount();
</div>
</div>
<ProfileSection title="اطلاعات شما">
<template #button>
<Button
v-if="isNotEqual"
:loading="updateAccountIsPending"
class="rounded-full w-[6.5rem]"
@click="handleSubmit(true)"
size="md"
>
<Icon
v-if="updateAccountIsPending"
name="svg-spinners:3-dots-bounce"
/>
<span v-else> ثبت تغییرات </span>
</Button>
</template>
<div
class="w-full grid grid-cols-1 lg:grid-cols-2 gap-x-3 gap-y-5"
>
<PersonalDataField id="personal-data-name" label="نام">
<Input v-model="personalData.name" variant="outlined" />
<PersonalDataField
id="personal-data-name"
label="نام"
:error="formValidator$.first_name"
>
<Input
v-model="personalData.first_name!"
variant="outlined"
:error="formValidator$.first_name.$error"
/>
</PersonalDataField>
<PersonalDataField
id="personal-data-last-name"
label="نام خانوادگی"
:error="formValidator$.last_name"
>
<Input
v-model="personalData.last_name"
v-model="personalData.last_name!"
variant="outlined"
:error="formValidator$.last_name.$error"
/>
</PersonalDataField>
<PersonalDataField id="personal-data-gender" label="جنسیت">
<PersonalDataField
id="personal-data-gender"
label="جنسیت"
:error="formValidator$.gender"
>
<Select
v-model="personalData.gender"
v-model="personalData.gender!"
:options="['مرد', 'زن']"
variant="outlined"
:error="formValidator$.gender.$error"
/>
</PersonalDataField>
<PersonalDataField
id="personal-data-birth-date"
label="تاریخ تولد"
:error="formValidator$.birth_date"
>
<Datepicker v-model="personalData.birthDate" />
<Datepicker
v-model="personalData.birth_date!"
:error="formValidator$.birth_date.$error"
/>
</PersonalDataField>
<PersonalDataField
id="personal-data-phone"
label="تلفن همراه"
:error="formValidator$.phone"
>
<Input
v-model="personalData.phone"
v-model="personalData.phone!"
variant="outlined"
:error="formValidator$.phone.$error"
/>
</PersonalDataField>
<PersonalDataField
id="personal-email"
label="حساب الکترونیکی"
:error="formValidator$.email"
>
<Input
v-model="personalData.email"
v-model="personalData.email!"
variant="outlined"
:error="formValidator$.email.$error"
/>
</PersonalDataField>
</div>
+50 -48
View File
@@ -1,5 +1,4 @@
<script lang="ts" setup>
// import
import { helpers, required } from "@vuelidate/validators";
@@ -21,7 +20,7 @@ type LoginInfo = {
// meta
definePageMeta({
middleware: ["check-is-not-logged-in"]
middleware: ["check-is-not-logged-in"],
});
// state
@@ -39,13 +38,16 @@ const formRules = computed(() => {
return {
phone: {
required: helpers.withMessage("Phone is required", required),
phoneValidator: helpers.regex(/^[1-9][0-9]{9}$/)
}
phoneValidator: helpers.withMessage(
"شماره تلفن وارد شده معتبر نمی باشد",
helpers.regex(/^[1-9][0-9]{9}$/)
),
},
};
});
const loginInfo = ref<LoginInfo>({
phone: ""
phone: "",
});
const formValidator$ = useVuelidate(formRules, loginInfo);
@@ -54,13 +56,17 @@ const {
timer: otpBlockerTimePassed,
start: startOtpBlocker,
reset: resetOtpBlocker,
isPending: isResendOtpBlocked
isPending: isResendOtpBlocked,
} = useTimer({
duration: 5000
duration: 5000,
});
const { mutateAsync: sendOtp, isPending: sendOtpIsPending } = useOtp();
const { mutateAsync: signIn, isPending: signInIsPending, status: signInStatus } = useSignIn();
const {
mutateAsync: signIn,
isPending: signInIsPending,
status: signInStatus,
} = useSignIn();
// computed
@@ -68,24 +74,23 @@ const sendOtpHandler = async () => {
if (!sendOtpIsPending.value) {
try {
await sendOtp({
phone: `0${loginInfo.value.phone}`
phone: `0${loginInfo.value.phone}`,
});
addToast({
message: "کد برای شما ارسال شد",
options: {
status: "success"
}
status: "success",
},
});
showOtp.value = true;
} catch (e) {
addToast({
message: "مشکلی پیش آمده",
options: {
status: "error"
}
status: "error",
},
});
}
}
@@ -102,19 +107,19 @@ const handleLogin = async () => {
try {
const response = await signIn({
otp: otpCode.value.join(""),
phone: `0${loginInfo.value.phone}`
phone: `0${loginInfo.value.phone}`,
});
updateToken(response.access);
updateRefreshToken(response.refresh);
await new Promise(resolve => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000));
await refetchAccount();
addToast({
message: "با موفقیت وارد شدید",
options: {
status: "success"
}
status: "success",
},
});
navigateTo("/");
@@ -141,22 +146,15 @@ const resetForm = () => {
otpCode.value = [];
showOtp.value = false;
};
</script>
<template>
<div class="container min-h-[700px] flex flex-col items-center justify-center">
<h1 class="typo-hero-2">
فرم ورود
</h1>
<form
@submit.prevent
class="max-w-[500px] w-full mt-12"
>
<div
v-if="!showOtp"
class="flex items-center gap-2 w-full"
>
<div
class="container min-h-[700px] flex flex-col items-center justify-center"
>
<h1 class="typo-hero-2">فرم ورود</h1>
<form @submit.prevent class="max-w-[500px] w-full mt-12">
<div v-if="!showOtp" class="flex items-center gap-2 w-full">
<Input
data-testid="phone-input"
class="w-full"
@@ -166,20 +164,28 @@ const resetForm = () => {
:error="formValidator$.phone.$error"
>
<template #startItem>
<span class="text-slate-500">
+98
</span>
<span class="text-slate-500"> +98 </span>
</template>
</Input>
<div class="flex items-center gap-1">
<Icon class="translate-y-[-1px]" name="twemoji:flag-iran" size="24" />
<Icon
class="translate-y-[-1px]"
name="twemoji:flag-iran"
size="24"
/>
</div>
</div>
<OtpInput
v-else
v-model="otpCode"
:status="signInStatus === 'success' ? 'success' : signInStatus === 'error' ? 'error' : 'idle'"
:status="
signInStatus === 'success'
? 'success'
: signInStatus === 'error'
? 'error'
: 'idle'
"
:disabled="signInIsPending || sendOtpIsPending"
:autofocus="true"
@complete="handleLogin"
@@ -197,10 +203,7 @@ const resetForm = () => {
ارسال کد
</Button>
<div
v-else
class="flex items-center w-full gap-4 mt-4"
>
<div v-else class="flex items-center w-full gap-4 mt-4">
<Button
class="rounded-full w-full mt-4"
type="button"
@@ -215,7 +218,11 @@ const resetForm = () => {
type="submit"
@click="resendOtp"
:loading="signInIsPending || sendOtpIsPending"
:disabled="signInIsPending || isResendOtpBlocked || sendOtpIsPending"
:disabled="
signInIsPending ||
isResendOtpBlocked ||
sendOtpIsPending
"
>
ارسال مجدد کد
{{ isResendOtpBlocked ? otpBlockerTimePassed : "" }}
@@ -223,13 +230,8 @@ const resetForm = () => {
</div>
<div class="flex items-center gap-2 justify-center mt-6">
<span>
بازگشت به فروشگاه
</span>
<Icon
name="ci:left-rotation"
size="24"
/>
<span> بازگشت به فروشگاه </span>
<Icon name="ci:left-rotation" size="24" />
</div>
</form>
</div>
+4 -2
View File
@@ -15,11 +15,13 @@ declare global {
};
type Account = {
profile_photo: File | null;
first_name: string;
last_name: string;
email: string;
profile_photo: string;
phone: string;
gender: string | undefined;
email: string;
birth_date: string;
};
type Product = {