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:
2026-09-22 01:38:00 +03:30
parent bbf1fc8899
commit 9286a283c0
4 changed files with 277 additions and 53 deletions

View File

@@ -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} />
);
}

View File

@@ -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"

View File

@@ -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;
}
return errorMessage;
error: (err: AxiosError) => extractErrorMessage(err),
});
};
const confirmMobileCodeHandler = () => {
if (mobileCode.trim().length !== 4) {
toast.error("کد تایید باید ۴ رقم باشد");
return;
}
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>

View File

@@ -218,6 +218,7 @@ type UserProfile = {
email: string;
mobile: string;
national_id?: string | null;
card_number?: string | null;
};
// تعریف تایپ پیکربندی‌ها