feat: full profile editing (email/password/mobile/card) + lighter background particles
- ModalEditProfile now lets users optionally change email, password, and card number, plus a two-step OTP flow to change their mobile number. All fields remain optional except when actively submitted. - Particle background: dropped canvas shadowBlur and the warp effect on links (the most expensive per-frame draw calls), disabled detectRetina (was rendering at 2-3x resolution on high-DPI screens), capped fps at 30, and paused rendering when the tab is hidden or the canvas is out of viewport (pauseOnBlur / pauseOnOutsideViewport) — this was the main site-wide slowdown since it renders on every page via the root layout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Particles, { initParticlesEngine } from "@tsparticles/react";
|
||||
import {
|
||||
type Container,
|
||||
type ISourceOptions,
|
||||
MoveDirection,
|
||||
OutMode,
|
||||
@@ -26,10 +25,6 @@ export default function Particle() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const particlesLoaded = async (container?: Container): Promise<void> => {
|
||||
console.log(container);
|
||||
};
|
||||
|
||||
const options: ISourceOptions = useMemo(
|
||||
() => ({
|
||||
background: {
|
||||
@@ -37,7 +32,9 @@ export default function Particle() {
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
fpsLimit: 60,
|
||||
fpsLimit: 30,
|
||||
pauseOnBlur: true,
|
||||
pauseOnOutsideViewport: true,
|
||||
interactivity: {
|
||||
events: {
|
||||
onClick: {
|
||||
@@ -69,20 +66,6 @@ export default function Particle() {
|
||||
enable: true,
|
||||
opacity: 0.8,
|
||||
width: 1,
|
||||
shadow: {
|
||||
enable: true,
|
||||
blur: 5,
|
||||
color: "#71fbb2",
|
||||
},
|
||||
warp: {
|
||||
enable: true,
|
||||
speed: 2,
|
||||
},
|
||||
animation: {
|
||||
enable: true,
|
||||
speed: 0.5,
|
||||
sync: true,
|
||||
},
|
||||
},
|
||||
move: {
|
||||
direction: MoveDirection.none,
|
||||
@@ -110,7 +93,7 @@ export default function Particle() {
|
||||
value: { min: 1, max: 5 },
|
||||
},
|
||||
},
|
||||
detectRetina: true,
|
||||
detectRetina: false,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
@@ -121,11 +104,7 @@ export default function Particle() {
|
||||
|
||||
if (init) {
|
||||
return (
|
||||
<Particles
|
||||
id="tsparticles"
|
||||
particlesLoaded={particlesLoaded}
|
||||
options={options}
|
||||
/>
|
||||
<Particles id="tsparticles" options={options} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,12 @@ const ProfileDetails = ({
|
||||
}: ProfileDetailsProps) => {
|
||||
return (
|
||||
<div className="relative z-[10] w-full min-h-32 max-md:min-h-20 flex flex-row-reverse max-[400px]:gap-3 justify-between flex-wrap-reverse glass items-center py-2 px-4">
|
||||
<EditProfile nationalId={data?.result?.user_profile?.national_id} />
|
||||
<EditProfile
|
||||
nationalId={data?.result?.user_profile?.national_id}
|
||||
email={data?.result?.user_profile?.email}
|
||||
mobile={data?.result?.user_profile?.mobile}
|
||||
cardNumber={data?.result?.user_profile?.card_number}
|
||||
/>
|
||||
<ProductDetails
|
||||
enable_config={data?.result?.enable_config}
|
||||
disable_config={data?.result?.disable_config}
|
||||
@@ -30,7 +35,17 @@ const ProfileDetails = ({
|
||||
|
||||
export default ProfileDetails;
|
||||
|
||||
const EditProfile = ({ nationalId }: { nationalId?: string | null }) => {
|
||||
const EditProfile = ({
|
||||
nationalId,
|
||||
email,
|
||||
mobile,
|
||||
cardNumber,
|
||||
}: {
|
||||
nationalId?: string | null;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
cardNumber?: string | null;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
@@ -39,6 +54,9 @@ const EditProfile = ({ nationalId }: { nationalId?: string | null }) => {
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
nationalId={nationalId}
|
||||
email={email}
|
||||
mobile={mobile}
|
||||
cardNumber={cardNumber}
|
||||
/>
|
||||
<Button
|
||||
extra="!py-4 max-md:!py-2 max-md:px-2"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import MuiModal from "../../../../common/modal";
|
||||
import Input from "../../../../components/input";
|
||||
import Button from "../../../../components/button";
|
||||
import Alert from "../../../../components/alert/Alert";
|
||||
import useAxios from "../../../../hooks/useAxios";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@@ -8,26 +9,92 @@ import { AxiosError, AxiosResponse } from "axios";
|
||||
import toast from "react-hot-toast";
|
||||
import { queryClient } from "../../../../main";
|
||||
|
||||
const extractErrorMessage = (err: AxiosError): string => {
|
||||
const data = err.response?.data;
|
||||
if (data && typeof data === "object") {
|
||||
if ("alert" in data && (data as { alert?: string }).alert) {
|
||||
return (data as { alert: string }).alert;
|
||||
}
|
||||
if ("message" in data && (data as { message?: string }).message) {
|
||||
return (data as { message: string }).message;
|
||||
}
|
||||
}
|
||||
return "یک خطای غیرمنتظره رخ داد";
|
||||
};
|
||||
|
||||
const ModalEditProfile = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
nationalId,
|
||||
email,
|
||||
mobile,
|
||||
cardNumber,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
nationalId?: string | null;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
cardNumber?: string | null;
|
||||
}) => {
|
||||
const axios = useAxios();
|
||||
|
||||
const [nationalCode, setNationalCode] = useState(nationalId ?? "");
|
||||
const [emailValue, setEmailValue] = useState(email ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState("");
|
||||
const [cardNumberValue, setCardNumberValue] = useState(cardNumber ?? "");
|
||||
|
||||
const [newMobile, setNewMobile] = useState("");
|
||||
const [mobileStep, setMobileStep] = useState<"idle" | "code-sent">("idle");
|
||||
const [mobileCode, setMobileCode] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setNationalCode(nationalId ?? "");
|
||||
}, [nationalId, isOpen]);
|
||||
setEmailValue(email ?? "");
|
||||
setPassword("");
|
||||
setPasswordConfirmation("");
|
||||
setCardNumberValue(cardNumber ?? "");
|
||||
setNewMobile("");
|
||||
setMobileStep("idle");
|
||||
setMobileCode("");
|
||||
}, [isOpen, nationalId, email, cardNumber]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ["updateNationalId"],
|
||||
mutationFn: (national_id: string) => {
|
||||
return axios.post("/profile/national-id", { national_id });
|
||||
const saveMutation = useMutation({
|
||||
mutationKey: ["saveProfile"],
|
||||
mutationFn: async () => {
|
||||
const requests = [];
|
||||
|
||||
if (nationalCode.trim() && nationalCode.trim() !== (nationalId ?? "")) {
|
||||
requests.push(
|
||||
axios.post("/profile/national-id", {
|
||||
national_id: nationalCode.trim(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const updatePayload: Record<string, string> = {};
|
||||
if (emailValue.trim() !== (email ?? "")) {
|
||||
updatePayload.email = emailValue.trim();
|
||||
}
|
||||
if (password) {
|
||||
updatePayload.password = password;
|
||||
updatePayload.password_confirmation = passwordConfirmation;
|
||||
}
|
||||
if (cardNumberValue.trim() !== (cardNumber ?? "")) {
|
||||
updatePayload.card_number = cardNumberValue.trim();
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length > 0) {
|
||||
requests.push(axios.post("/profile/update", updatePayload));
|
||||
}
|
||||
|
||||
if (requests.length === 0) {
|
||||
throw new Error("هیچ تغییری برای ذخیره وجود ندارد");
|
||||
}
|
||||
|
||||
return Promise.all(requests);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["profile"] });
|
||||
@@ -35,34 +102,86 @@ const ModalEditProfile = ({
|
||||
});
|
||||
|
||||
const submitHandler = () => {
|
||||
if (nationalCode.trim().length !== 10) {
|
||||
if (nationalCode.trim() && nationalCode.trim().length !== 10) {
|
||||
toast.error("کد ملی باید ۱۰ رقم باشد");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.promise(mutation.mutateAsync(nationalCode.trim()), {
|
||||
if (password && password.length < 6) {
|
||||
toast.error("رمز عبور باید حداقل ۶ کاراکتر باشد");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password && password !== passwordConfirmation) {
|
||||
toast.error("تکرار رمز عبور مطابقت ندارد");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.promise(saveMutation.mutateAsync(), {
|
||||
loading: "درحال ذخیره...",
|
||||
success: (res: AxiosResponse) => {
|
||||
success: () => {
|
||||
setIsOpen(false);
|
||||
return "اطلاعات با موفقیت ذخیره شد";
|
||||
},
|
||||
error: (err: AxiosError | Error) => {
|
||||
if (err instanceof Error && !("response" in err)) {
|
||||
return err.message;
|
||||
}
|
||||
return extractErrorMessage(err as AxiosError);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const requestMobileCodeMutation = useMutation({
|
||||
mutationKey: ["requestMobileChange"],
|
||||
mutationFn: (mobileNumber: string) => {
|
||||
return axios.post("/profile/mobile/request-change", {
|
||||
mobile: mobileNumber,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const confirmMobileCodeMutation = useMutation({
|
||||
mutationKey: ["confirmMobileChange"],
|
||||
mutationFn: (code: string) => {
|
||||
return axios.post("/profile/mobile/confirm-change", { code });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["profile"] });
|
||||
},
|
||||
});
|
||||
|
||||
const requestMobileCodeHandler = () => {
|
||||
if (newMobile.trim().length !== 11) {
|
||||
toast.error("شماره موبایل باید ۱۱ رقم باشد");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.promise(requestMobileCodeMutation.mutateAsync(newMobile.trim()), {
|
||||
loading: "درحال ارسال کد...",
|
||||
success: (res: AxiosResponse) => {
|
||||
setMobileStep("code-sent");
|
||||
return `${res.data.alert}`;
|
||||
},
|
||||
error: (err: AxiosError) => {
|
||||
let errorMessage = "یک خطای غیرمنتظره رخ داد";
|
||||
if (
|
||||
err.response?.data &&
|
||||
typeof err.response.data === "object" &&
|
||||
"alert" in err.response.data
|
||||
) {
|
||||
errorMessage = (err.response.data as { alert: string }).alert;
|
||||
} else if (
|
||||
err.response?.data &&
|
||||
typeof err.response.data === "object" &&
|
||||
"message" in err.response.data
|
||||
) {
|
||||
errorMessage = (err.response.data as { message: string }).message;
|
||||
error: (err: AxiosError) => extractErrorMessage(err),
|
||||
});
|
||||
};
|
||||
|
||||
const confirmMobileCodeHandler = () => {
|
||||
if (mobileCode.trim().length !== 4) {
|
||||
toast.error("کد تایید باید ۴ رقم باشد");
|
||||
return;
|
||||
}
|
||||
return errorMessage;
|
||||
|
||||
toast.promise(confirmMobileCodeMutation.mutateAsync(mobileCode.trim()), {
|
||||
loading: "درحال تایید...",
|
||||
success: (res: AxiosResponse) => {
|
||||
setMobileStep("idle");
|
||||
setNewMobile("");
|
||||
setMobileCode("");
|
||||
return `${res.data.alert}`;
|
||||
},
|
||||
error: (err: AxiosError) => extractErrorMessage(err),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -74,7 +193,114 @@ const ModalEditProfile = ({
|
||||
submitOnClick={submitHandler}
|
||||
title="ویرایش پروفایل"
|
||||
>
|
||||
<div className="relative w-full h-full px-2 md:px-4 flex flex-col gap-3">
|
||||
<div className="relative w-full h-full px-2 md:px-4 flex flex-col gap-4">
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
label="شماره موبایل فعلی :"
|
||||
labelStyle="!text-start"
|
||||
extra="!w-full opacity-60"
|
||||
value={mobile ?? ""}
|
||||
disabled
|
||||
/>
|
||||
{mobileStep === "idle" ? (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
label="تغییر شماره موبایل :"
|
||||
labelStyle="!text-start"
|
||||
extra="!w-full"
|
||||
placeholder="شماره موبایل جدید"
|
||||
value={newMobile}
|
||||
onChange={(e) =>
|
||||
setNewMobile(e.target.value.replace(/[^0-9]/g, ""))
|
||||
}
|
||||
/>
|
||||
{newMobile.trim().length === 11 && (
|
||||
<Button extra="!py-2" onClick={requestMobileCodeHandler}>
|
||||
ارسال کد تایید
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Alert type="warning" extra="!mb-0 text-start">
|
||||
کد تایید به شماره {newMobile} پیامک شد.
|
||||
</Alert>
|
||||
<Input
|
||||
type="text"
|
||||
label="کد تایید :"
|
||||
labelStyle="!text-start"
|
||||
extra="!w-full"
|
||||
placeholder="کد ۴ رقمی"
|
||||
value={mobileCode}
|
||||
onChange={(e) =>
|
||||
setMobileCode(e.target.value.replace(/[^0-9]/g, ""))
|
||||
}
|
||||
/>
|
||||
<div className="w-full flex gap-2">
|
||||
<Button extra="!py-2" onClick={confirmMobileCodeHandler}>
|
||||
تایید و تغییر شماره
|
||||
</Button>
|
||||
<Button
|
||||
extra="!py-2 !bg-gray-600 !text-white"
|
||||
onClick={() => {
|
||||
setMobileStep("idle");
|
||||
setMobileCode("");
|
||||
}}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label="ایمیل :"
|
||||
labelStyle="!text-start"
|
||||
extra="!w-full"
|
||||
placeholder="ایمیل (اختیاری)"
|
||||
value={emailValue}
|
||||
onChange={(e) => setEmailValue(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
label="رمز عبور جدید :"
|
||||
labelStyle="!text-start"
|
||||
extra="!w-full"
|
||||
placeholder="در صورت تمایل به تغییر رمز پر کنید"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{password && (
|
||||
<Input
|
||||
type="password"
|
||||
label="تکرار رمز عبور جدید :"
|
||||
labelStyle="!text-start"
|
||||
extra="!w-full"
|
||||
placeholder="تکرار رمز عبور جدید"
|
||||
value={passwordConfirmation}
|
||||
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
label="شماره کارت :"
|
||||
labelStyle="!text-start"
|
||||
extra="!w-full"
|
||||
placeholder="شماره کارت (اختیاری)"
|
||||
value={cardNumberValue}
|
||||
onChange={(e) =>
|
||||
setCardNumberValue(e.target.value.replace(/[^0-9]/g, ""))
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert type="warning" extra="!mb-0 text-start">
|
||||
کد ملی برای احراز هویت شاهکار در خریدهای با مبلغ بالا لازم است.
|
||||
</Alert>
|
||||
|
||||
1
src/types/props.d.ts
vendored
1
src/types/props.d.ts
vendored
@@ -218,6 +218,7 @@ type UserProfile = {
|
||||
email: string;
|
||||
mobile: string;
|
||||
national_id?: string | null;
|
||||
card_number?: string | null;
|
||||
};
|
||||
|
||||
// تعریف تایپ پیکربندیها
|
||||
|
||||
Reference in New Issue
Block a user