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 { useEffect, useMemo, useState } from "react";
|
||||||
import Particles, { initParticlesEngine } from "@tsparticles/react";
|
import Particles, { initParticlesEngine } from "@tsparticles/react";
|
||||||
import {
|
import {
|
||||||
type Container,
|
|
||||||
type ISourceOptions,
|
type ISourceOptions,
|
||||||
MoveDirection,
|
MoveDirection,
|
||||||
OutMode,
|
OutMode,
|
||||||
@@ -26,10 +25,6 @@ export default function Particle() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const particlesLoaded = async (container?: Container): Promise<void> => {
|
|
||||||
console.log(container);
|
|
||||||
};
|
|
||||||
|
|
||||||
const options: ISourceOptions = useMemo(
|
const options: ISourceOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
background: {
|
background: {
|
||||||
@@ -37,7 +32,9 @@ export default function Particle() {
|
|||||||
value: "",
|
value: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fpsLimit: 60,
|
fpsLimit: 30,
|
||||||
|
pauseOnBlur: true,
|
||||||
|
pauseOnOutsideViewport: true,
|
||||||
interactivity: {
|
interactivity: {
|
||||||
events: {
|
events: {
|
||||||
onClick: {
|
onClick: {
|
||||||
@@ -69,20 +66,6 @@ export default function Particle() {
|
|||||||
enable: true,
|
enable: true,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
width: 1,
|
width: 1,
|
||||||
shadow: {
|
|
||||||
enable: true,
|
|
||||||
blur: 5,
|
|
||||||
color: "#71fbb2",
|
|
||||||
},
|
|
||||||
warp: {
|
|
||||||
enable: true,
|
|
||||||
speed: 2,
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
enable: true,
|
|
||||||
speed: 0.5,
|
|
||||||
sync: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
move: {
|
move: {
|
||||||
direction: MoveDirection.none,
|
direction: MoveDirection.none,
|
||||||
@@ -110,7 +93,7 @@ export default function Particle() {
|
|||||||
value: { min: 1, max: 5 },
|
value: { min: 1, max: 5 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
detectRetina: true,
|
detectRetina: false,
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@@ -121,11 +104,7 @@ export default function Particle() {
|
|||||||
|
|
||||||
if (init) {
|
if (init) {
|
||||||
return (
|
return (
|
||||||
<Particles
|
<Particles id="tsparticles" options={options} />
|
||||||
id="tsparticles"
|
|
||||||
particlesLoaded={particlesLoaded}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ const ProfileDetails = ({
|
|||||||
}: ProfileDetailsProps) => {
|
}: ProfileDetailsProps) => {
|
||||||
return (
|
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">
|
<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
|
<ProductDetails
|
||||||
enable_config={data?.result?.enable_config}
|
enable_config={data?.result?.enable_config}
|
||||||
disable_config={data?.result?.disable_config}
|
disable_config={data?.result?.disable_config}
|
||||||
@@ -30,7 +35,17 @@ const ProfileDetails = ({
|
|||||||
|
|
||||||
export default 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 [isOpen, setIsOpen] = useState(false);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
return (
|
return (
|
||||||
@@ -39,6 +54,9 @@ const EditProfile = ({ nationalId }: { nationalId?: string | null }) => {
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
setIsOpen={setIsOpen}
|
setIsOpen={setIsOpen}
|
||||||
nationalId={nationalId}
|
nationalId={nationalId}
|
||||||
|
email={email}
|
||||||
|
mobile={mobile}
|
||||||
|
cardNumber={cardNumber}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
extra="!py-4 max-md:!py-2 max-md:px-2"
|
extra="!py-4 max-md:!py-2 max-md:px-2"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||||
import MuiModal from "../../../../common/modal";
|
import MuiModal from "../../../../common/modal";
|
||||||
import Input from "../../../../components/input";
|
import Input from "../../../../components/input";
|
||||||
|
import Button from "../../../../components/button";
|
||||||
import Alert from "../../../../components/alert/Alert";
|
import Alert from "../../../../components/alert/Alert";
|
||||||
import useAxios from "../../../../hooks/useAxios";
|
import useAxios from "../../../../hooks/useAxios";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
@@ -8,26 +9,92 @@ import { AxiosError, AxiosResponse } from "axios";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { queryClient } from "../../../../main";
|
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 = ({
|
const ModalEditProfile = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
nationalId,
|
nationalId,
|
||||||
|
email,
|
||||||
|
mobile,
|
||||||
|
cardNumber,
|
||||||
}: {
|
}: {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
nationalId?: string | null;
|
nationalId?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
cardNumber?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const axios = useAxios();
|
const axios = useAxios();
|
||||||
|
|
||||||
const [nationalCode, setNationalCode] = useState(nationalId ?? "");
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
setNationalCode(nationalId ?? "");
|
setNationalCode(nationalId ?? "");
|
||||||
}, [nationalId, isOpen]);
|
setEmailValue(email ?? "");
|
||||||
|
setPassword("");
|
||||||
|
setPasswordConfirmation("");
|
||||||
|
setCardNumberValue(cardNumber ?? "");
|
||||||
|
setNewMobile("");
|
||||||
|
setMobileStep("idle");
|
||||||
|
setMobileCode("");
|
||||||
|
}, [isOpen, nationalId, email, cardNumber]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationKey: ["updateNationalId"],
|
mutationKey: ["saveProfile"],
|
||||||
mutationFn: (national_id: string) => {
|
mutationFn: async () => {
|
||||||
return axios.post("/profile/national-id", { national_id });
|
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: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["profile"] });
|
queryClient.invalidateQueries({ queryKey: ["profile"] });
|
||||||
@@ -35,34 +102,86 @@ const ModalEditProfile = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const submitHandler = () => {
|
const submitHandler = () => {
|
||||||
if (nationalCode.trim().length !== 10) {
|
if (nationalCode.trim() && nationalCode.trim().length !== 10) {
|
||||||
toast.error("کد ملی باید ۱۰ رقم باشد");
|
toast.error("کد ملی باید ۱۰ رقم باشد");
|
||||||
return;
|
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: "درحال ذخیره...",
|
loading: "درحال ذخیره...",
|
||||||
success: (res: AxiosResponse) => {
|
success: () => {
|
||||||
setIsOpen(false);
|
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}`;
|
return `${res.data.alert}`;
|
||||||
},
|
},
|
||||||
error: (err: AxiosError) => {
|
error: (err: AxiosError) => extractErrorMessage(err),
|
||||||
let errorMessage = "یک خطای غیرمنتظره رخ داد";
|
});
|
||||||
if (
|
};
|
||||||
err.response?.data &&
|
|
||||||
typeof err.response.data === "object" &&
|
const confirmMobileCodeHandler = () => {
|
||||||
"alert" in err.response.data
|
if (mobileCode.trim().length !== 4) {
|
||||||
) {
|
toast.error("کد تایید باید ۴ رقم باشد");
|
||||||
errorMessage = (err.response.data as { alert: string }).alert;
|
return;
|
||||||
} else if (
|
|
||||||
err.response?.data &&
|
|
||||||
typeof err.response.data === "object" &&
|
|
||||||
"message" in err.response.data
|
|
||||||
) {
|
|
||||||
errorMessage = (err.response.data as { message: string }).message;
|
|
||||||
}
|
}
|
||||||
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}
|
submitOnClick={submitHandler}
|
||||||
title="ویرایش پروفایل"
|
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 type="warning" extra="!mb-0 text-start">
|
||||||
کد ملی برای احراز هویت شاهکار در خریدهای با مبلغ بالا لازم است.
|
کد ملی برای احراز هویت شاهکار در خریدهای با مبلغ بالا لازم است.
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
1
src/types/props.d.ts
vendored
1
src/types/props.d.ts
vendored
@@ -218,6 +218,7 @@ type UserProfile = {
|
|||||||
email: string;
|
email: string;
|
||||||
mobile: string;
|
mobile: string;
|
||||||
national_id?: string | null;
|
national_id?: string | null;
|
||||||
|
card_number?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// تعریف تایپ پیکربندیها
|
// تعریف تایپ پیکربندیها
|
||||||
|
|||||||
Reference in New Issue
Block a user