feat(auth): implement WebOTP SMS auto-fill, auto-submit, password setup in dashboard, and password visibility toggle

This commit is contained in:
parsa aghaei 2026-08-16 16:37:59 +03:30
parent 897b4d61ec
commit cbd7e1a24d
6 changed files with 646 additions and 265 deletions

View File

@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsEmail } from 'class-validator';
import { IsOptional, IsString, IsEmail, MinLength } from 'class-validator';
export class UpdateProfileDto {
@ApiPropertyOptional({ description: 'نام کاربر' })
@ -21,4 +21,15 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
mobile?: string;
@ApiPropertyOptional({ description: 'رمز عبور جدید' })
@IsOptional()
@IsString()
@MinLength(6, { message: 'رمز عبور باید حداقل ۶ کاراکتر باشد' })
password?: string;
@ApiPropertyOptional({ description: 'رمز عبور فعلی (در صورت وجود)' })
@IsOptional()
@IsString()
currentPassword?: string;
}

View File

@ -1,6 +1,13 @@
import { Injectable } from '@nestjs/common';
import {
Injectable,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcryptjs';
import { normalizeMobile } from '../common/utils/phone.utils';
import { UpdateProfileDto } from './dto/update-profile.dto';
export interface UserAddressInput {
title: string;
@ -49,7 +56,13 @@ export class UsersService {
},
});
if (dbUser) return dbUser;
if (dbUser) {
const { password, ...safeUser } = dbUser;
return {
...safeUser,
hasPassword: !!password,
};
}
return {
id: '12345678-1234-1234-1234-123456789012',
@ -64,12 +77,13 @@ export class UsersService {
orders: [],
addresses: [],
walletTransactions: [],
hasPassword: true,
createdAt: new Date(),
updatedAt: new Date(),
};
}
return this.prisma.user.findUnique({
const user = await this.prisma.user.findUnique({
where: { id },
include: {
pets: {
@ -98,12 +112,70 @@ export class UsersService {
},
},
});
if (!user) return null;
const { password, ...safeUser } = user;
return {
...safeUser,
hasPassword: !!password,
};
}
async update(id: string, data: Prisma.UserUpdateInput) {
return this.prisma.user.update({
async update(id: string, dto: UpdateProfileDto) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException('کاربر یافت نشد');
}
const updateData: Prisma.UserUpdateInput = {};
if (dto.firstName !== undefined) updateData.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) updateData.lastName = dto.lastName.trim();
if (dto.mobile !== undefined) {
const normalizedMobile = normalizeMobile(dto.mobile);
if (normalizedMobile && normalizedMobile !== user.mobile) {
const existing = await this.prisma.user.findUnique({
where: { mobile: normalizedMobile },
});
if (existing && existing.id !== id) {
throw new BadRequestException('این شماره موبایل قبلاً توسط کاربر دیگری ثبت شده است');
}
updateData.mobile = normalizedMobile;
}
}
if (dto.email !== undefined) {
const normalizedEmail = dto.email ? dto.email.trim().toLowerCase() : null;
if (normalizedEmail && normalizedEmail !== user.email) {
const existing = await this.prisma.user.findUnique({
where: { email: normalizedEmail },
});
if (existing && existing.id !== id) {
throw new BadRequestException('این آدرس ایمیل قبلاً توسط کاربر دیگری ثبت شده است');
}
}
updateData.email = normalizedEmail;
}
if (dto.password && dto.password.trim().length >= 6) {
// If user already has a password set, require and verify currentPassword
if (user.password) {
if (!dto.currentPassword) {
throw new BadRequestException('برای تغییر رمز عبور، وارد کردن رمز عبور فعلی الزامی است');
}
const isCurrentMatch = await bcrypt.compare(dto.currentPassword.trim(), user.password);
if (!isCurrentMatch) {
throw new BadRequestException('رمز عبور فعلی اشتباه است');
}
}
updateData.password = await bcrypt.hash(dto.password.trim(), 10);
}
const updatedUser = await this.prisma.user.update({
where: { id },
data,
data: updateData,
include: {
pets: true,
orders: {
@ -117,6 +189,12 @@ export class UsersService {
walletTransactions: true,
},
});
const { password, ...safeUser } = updatedUser;
return {
...safeUser,
hasPassword: !!password,
};
}
async addAddress(userId: string, data: UserAddressInput) {

View File

@ -1,11 +1,25 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, Building2, ShieldCheck, Phone, Key, LogIn, ArrowRight, RefreshCw, Lock, Upload, CheckCircle2 } from "lucide-react";
import {
X,
Building2,
ShieldCheck,
Phone,
Key,
LogIn,
ArrowRight,
RefreshCw,
Lock,
Upload,
CheckCircle2,
Eye,
EyeOff,
} from "lucide-react";
import { useUserStore } from "../lib/store/userStore";
import { authService } from "../lib/services/authService";
import { toast } from "sonner";
import { toPersian } from "../lib/utils";
import { toPersian, toEnglishDigits } from "../lib/utils";
interface AuthModalProps {
isOpen: boolean;
@ -14,7 +28,16 @@ interface AuthModalProps {
export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const { fetchProfile } = useUserStore();
const [view, setView] = useState<"login" | "register" | "otp-phone" | "otp" | "forgot-password" | "forgot-otp" | "reset-password" | "wholesale-request">("login");
const [view, setView] = useState<
| "login"
| "register"
| "otp-phone"
| "otp"
| "forgot-password"
| "forgot-otp"
| "reset-password"
| "wholesale-request"
>("login");
const [medicalLicense, setMedicalLicense] = useState("");
const [businessName, setBusinessName] = useState("");
const [documentUploaded, setDocumentUploaded] = useState(false);
@ -27,6 +50,8 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const [otpCode, setOtpCode] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const [showPassword, setShowPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const otpInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@ -52,6 +77,8 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
setOtpCode("");
setIsLoading(false);
setCountdown(0);
setShowPassword(false);
setShowNewPassword(false);
});
}
}, [isOpen]);
@ -64,13 +91,72 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
}
}, [countdown]);
const triggerVerifyOtp = useCallback(
async (codeToVerify: string) => {
const cleanCode = toEnglishDigits(codeToVerify).trim();
const cleanPhone = toEnglishDigits(phoneNumber).trim();
if (cleanCode.length !== 5 || !cleanPhone) return;
setIsLoading(true);
try {
const response = await authService.verifyOtp(cleanPhone, cleanCode);
if (response.success) {
await fetchProfile();
toast.success("ورود با موفقیت انجام شد");
onClose();
}
} catch (err: unknown) {
const errObj = err as { message?: string };
toast.error(errObj.message || "کد تایید نامعتبر است");
} finally {
setIsLoading(false);
}
},
[phoneNumber, fetchProfile, onClose],
);
// WebOTP API SMS Auto-read
useEffect(() => {
if (view === "otp" && typeof window !== "undefined" && "OTPCredential" in window) {
const ac = new AbortController();
(navigator as unknown as { credentials: { get: (opts: unknown) => Promise<{ code?: string }> } }).credentials
?.get({
otp: { transport: ["sms"] },
signal: ac.signal,
})
.then((otp) => {
if (otp?.code) {
const clean = toEnglishDigits(otp.code).replace(/[^0-9]/g, "").slice(0, 5);
setOtpCode(clean);
if (clean.length === 5) {
triggerVerifyOtp(clean);
}
}
})
.catch(() => {});
return () => {
ac.abort();
};
}
}, [view, triggerVerifyOtp]);
const handleOtpChange = (val: string) => {
const clean = toEnglishDigits(val).replace(/[^0-9]/g, "").slice(0, 5);
setOtpCode(clean);
if (clean.length === 5) {
triggerVerifyOtp(clean);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!phoneNumber || !password) return toast.error("شماره موبایل و رمز عبور الزامی است");
const cleanPhone = toEnglishDigits(phoneNumber).trim();
if (!cleanPhone || !password) return toast.error("شماره موبایل و رمز عبور الزامی است");
setIsLoading(true);
try {
const res = await authService.login(phoneNumber, password);
const res = await authService.login(cleanPhone, password.trim());
if (res.success) {
await fetchProfile();
toast.success("با موفقیت وارد شدید");
@ -86,13 +172,20 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
if (!firstName || !lastName || !phoneNumber || !password) {
const cleanPhone = toEnglishDigits(phoneNumber).trim();
if (!firstName || !lastName || !cleanPhone || !password) {
return toast.error("لطفاً فیلدهای الزامی را پر کنید");
}
setIsLoading(true);
try {
const res = await authService.register({ firstName, lastName, mobile: phoneNumber, email, password });
const res = await authService.register({
firstName: firstName.trim(),
lastName: lastName.trim(),
mobile: cleanPhone,
email: email ? email.trim() : undefined,
password: password.trim(),
});
if (res.success) {
await fetchProfile();
toast.success("ثبت‌نام با موفقیت انجام شد");
@ -108,7 +201,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const handleSendOtp = async (e: React.FormEvent) => {
e.preventDefault();
const cleanPhone = phoneNumber.trim();
const cleanPhone = toEnglishDigits(phoneNumber).trim();
if (!/^09\d{9}$/.test(cleanPhone)) {
toast.error("شماره موبایل نامعتبر است");
return;
@ -135,33 +228,14 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
const cleanCode = otpCode.trim();
if (cleanCode.length !== 5) {
toast.error("کد باید ۵ رقم باشد");
return;
}
setIsLoading(true);
try {
const response = await authService.verifyOtp(phoneNumber.trim(), cleanCode);
if (response.success) {
await fetchProfile();
toast.success("ورود موفقیت‌آمیز بود");
onClose();
}
} catch (err: unknown) {
const errObj = err as { message?: string };
toast.error(errObj.message || "کد تایید نامعتبر است");
} finally {
setIsLoading(false);
}
triggerVerifyOtp(otpCode);
};
const handleResendOtp = async () => {
if (countdown > 0) return;
setIsLoading(true);
try {
await authService.sendOtp(phoneNumber.trim());
await authService.sendOtp(toEnglishDigits(phoneNumber).trim());
toast.success("کد تایید جدید ارسال شد");
setCountdown(120);
} catch (err: unknown) {
@ -174,7 +248,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const handleForgotSendOtp = async (e: React.FormEvent) => {
e.preventDefault();
const cleanPhone = phoneNumber.trim();
const cleanPhone = toEnglishDigits(phoneNumber).trim();
if (!/^09\d{9}$/.test(cleanPhone)) {
toast.error("شماره موبایل نامعتبر است");
return;
@ -200,14 +274,14 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const handleForgotVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
if (otpCode.trim().length !== 5) {
const cleanCode = toEnglishDigits(otpCode).trim();
if (cleanCode.length !== 5) {
toast.error("کد باید ۵ رقم باشد");
return;
}
setIsLoading(true);
try {
// Verify OTP - if success, allow password reset
await authService.verifyOtp(phoneNumber.trim(), otpCode.trim());
await authService.verifyOtp(toEnglishDigits(phoneNumber).trim(), cleanCode);
setView("reset-password");
} catch (err: unknown) {
const errObj = err as { message?: string };
@ -219,18 +293,18 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPassword.length < 6) {
toast.error("رمز جدید باید حداقل ۶ کاراکتر باشد");
return;
if (!newPassword || newPassword.length < 6) {
return toast.error("رمز عبور جدید باید حداقل ۶ کاراکتر باشد");
}
setIsLoading(true);
try {
await authService.updateProfile({ password: newPassword } as unknown as Parameters<typeof authService.updateProfile>[0]);
toast.success("رمز عبور با موفقیت تغییر یافت");
setView("login");
await authService.updateProfile({ password: newPassword.trim() });
await fetchProfile();
toast.success("رمز عبور جدید با موفقیت تنظیم شد");
onClose();
} catch (err: unknown) {
const errObj = err as { message?: string };
toast.error(errObj.message || "خطا در تغییر رمز");
toast.error(errObj.message || "خطا در تغییر رمز عبور");
} finally {
setIsLoading(false);
}
@ -239,52 +313,36 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60]"
onClick={onClose}
className="absolute inset-0 bg-medical-gray-900/60 backdrop-blur-sm"
/>
{/* Modal content */}
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl z-[70] overflow-hidden border border-medical-gray-100 font-vazir"
dir="rtl"
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="relative w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl overflow-hidden border border-medical-gray-100 z-10"
>
{/* Top highlight bar */}
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-canina-blue to-indigo-500" />
<div className="p-8 relative">
{/* Close Button */}
{/* Header */}
<div className="p-6 pb-0 flex justify-between items-center">
<div className="flex items-center gap-2 text-canina-blue font-black text-sm">
<ShieldCheck className="w-5 h-5" />
<span>ورود امن به کانینو</span>
</div>
<button
onClick={onClose}
className="absolute top-6 left-6 p-2 rounded-full hover:bg-medical-gray-50 text-medical-gray-400 hover:text-medical-gray-600 transition-colors"
className="w-10 h-10 rounded-full bg-medical-gray-50 flex items-center justify-center text-medical-gray-400 hover:bg-medical-gray-100 transition-colors"
>
<X className="w-6 h-6" />
<X className="w-5 h-5" />
</button>
</div>
{/* Title Header */}
<div className="flex flex-col items-center text-center mb-8 pt-4">
<div className="w-20 h-20 bg-canina-blue/10 rounded-3xl flex items-center justify-center mb-4 shadow-inner">
<ShieldCheck className="w-10 h-10 text-canina-blue" />
</div>
<h2 className="text-3xl font-black text-medical-gray-900 mb-2 italic">
ورود به دنیای کانینا
</h2>
<p className="text-medical-gray-500 text-sm font-bold max-w-[280px]">
{view === "otp"
? `کد تایید ارسال شده به شماره ${toPersian(phoneNumber)} را وارد کنید`
: "برای دسترسی به پرونده سلامت و سفارشات وارد شوید"}
</p>
</div>
{/* Content Panel */}
<div className="p-8">
<AnimatePresence mode="wait">
{view === "login" && (
<motion.form
@ -295,58 +353,77 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
onSubmit={handleLogin}
className="space-y-4"
>
<div className="space-y-2">
<label htmlFor="login-phone" className="text-xs font-bold text-medical-gray-500">شماره موبایل</label>
<input
id="login-phone"
name="username"
type="tel"
maxLength={11}
autoComplete="username"
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 px-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-left tracking-widest"
/>
</div>
<div className="space-y-2">
<label htmlFor="login-password" className="text-xs font-bold text-medical-gray-500">رمز عبور</label>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
placeholder="رمز عبور خود را وارد کنید"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 px-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left"
/>
</div>
<div className="flex items-center justify-between pt-2 flex-wrap gap-2">
<button
type="button"
onClick={() => setView("otp-phone")}
className="text-xs font-bold text-canina-blue hover:underline"
>
ورود با کد یکبار مصرف (OTP)
</button>
<div className="flex items-center gap-3">
<div className="space-y-1">
<div className="flex justify-between items-center">
<label className="text-xs font-bold text-medical-gray-500">شماره موبایل</label>
<button
type="button"
onClick={() => { setPhoneNumber(""); setView("forgot-password"); }}
className="text-xs font-bold text-medical-gray-400 hover:text-canina-blue hover:underline"
onClick={() => setView("otp-phone")}
className="text-xs font-bold text-canina-blue hover:underline"
>
فراموشی رمز
ورود با کد پیامکی (OTP)
</button>
</div>
<div className="relative">
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" />
<input
autoFocus
type="tel"
maxLength={11}
required
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider"
dir="ltr"
/>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between items-center">
<label className="text-xs font-bold text-medical-gray-500">رمز عبور</label>
<button
type="button"
onClick={() => setView("forgot-password")}
className="text-xs font-bold text-medical-gray-400 hover:text-canina-blue"
>
فراموشی رمز عبور؟
</button>
</div>
<div className="relative">
<Lock className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400 pointer-events-none" />
<input
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
placeholder="رمز عبور..."
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-11 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider"
dir="ltr"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute left-3 top-1/2 -translate-y-1/2 p-1.5 text-medical-gray-400 hover:text-medical-gray-700 transition-colors cursor-pointer"
tabIndex={-1}
title={showPassword ? "مخفی کردن رمز" : "نمایش رمز"}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="flex items-center justify-between text-xs pt-1">
<div className="flex items-center gap-1 text-medical-gray-400">
<span>حساب کاربری ندارید؟</span>
<button
type="button"
onClick={() => setView("register")}
className="text-xs font-bold text-canina-blue hover:underline"
className="font-bold text-canina-blue hover:underline"
>
حساب ندارید؟ ثبتنام
ثبتنام عادی
</button>
</div>
</div>
@ -355,10 +432,10 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
<button
type="button"
onClick={() => setView("wholesale-request")}
className="w-full py-3 bg-amber-50 text-amber-900 border border-amber-200 rounded-xl text-xs font-black flex items-center justify-center gap-2 hover:bg-amber-100 transition-colors"
className="w-full py-3 bg-amber-50 text-amber-900 border border-amber-200 rounded-xl text-xs font-black flex items-center justify-center gap-2 hover:bg-amber-100 transition-colors cursor-pointer"
>
<Building2 className="w-4 h-4 text-amber-600" />
درخواست ثبتنام حساب خریدار عمده (پتشاپ / کلینیک)
درخواست حساب خریدار عمده (پتشاپ / کلینیک)
</button>
</div>
@ -380,7 +457,9 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
exit={{ opacity: 0, x: -20 }}
onSubmit={(e) => {
e.preventDefault();
toast.success("درخواست احراز هویت خریدار عمده با موفقیت ارسال شد و پس از بررسی ادمین تایید خواهد شد.");
toast.success(
"درخواست احراز هویت خریدار عمده با موفقیت ارسال شد و پس از بررسی ادمین تایید خواهد شد.",
);
setView("login");
}}
className="space-y-3"
@ -395,7 +474,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
type="text"
required
value={businessName}
onChange={e => setBusinessName(e.target.value)}
onChange={(e) => setBusinessName(e.target.value)}
placeholder="مثال: کلینیک دامپزشکی رازی"
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm"
/>
@ -407,9 +486,10 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
type="text"
required
value={medicalLicense}
onChange={e => setMedicalLicense(e.target.value)}
onChange={(e) => setMedicalLicense(e.target.value)}
placeholder="مثال: ۹۸۷۶۵۴۳۲۱"
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left"
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
dir="ltr"
/>
</div>
@ -420,18 +500,21 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
required
maxLength={11}
value={phoneNumber}
onChange={e => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))}
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))}
placeholder="۰۹۱۲..."
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left"
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
dir="ltr"
/>
</div>
<div
<div
onClick={() => {
setDocumentUploaded(true);
toast.success("تصویر پروانه با موفقیت بارگذاری شد.");
}}
className={`p-4 border-2 border-dashed rounded-xl text-center cursor-pointer transition-all ${documentUploaded ? 'border-green-500 bg-green-50' : 'border-medical-gray-300 hover:border-canina-blue'}`}
className={`p-4 border-2 border-dashed rounded-xl text-center cursor-pointer transition-all ${
documentUploaded ? "border-green-500 bg-green-50" : "border-medical-gray-300 hover:border-canina-blue"
}`}
>
{documentUploaded ? (
<div className="flex items-center justify-center gap-2 text-green-700 font-bold text-xs">
@ -450,7 +533,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
<button
type="button"
onClick={() => setView("login")}
className="text-xs font-bold text-canina-blue hover:underline"
className="text-xs font-bold text-canina-blue hover:underline cursor-pointer"
>
بازگشت به ورود
</button>
@ -480,12 +563,10 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
<label htmlFor="reg-fname" className="text-xs font-bold text-medical-gray-500">نام</label>
<input
id="reg-fname"
name="given-name"
type="text"
autoComplete="given-name"
required
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm"
/>
</div>
@ -493,29 +574,27 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
<label htmlFor="reg-lname" className="text-xs font-bold text-medical-gray-500">نام خانوادگی</label>
<input
id="reg-lname"
name="family-name"
type="text"
autoComplete="family-name"
required
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm"
/>
</div>
</div>
<div className="space-y-1">
<label htmlFor="reg-phone" className="text-xs font-bold text-medical-gray-500">شماره موبایل *</label>
<label htmlFor="reg-phone" className="text-xs font-bold text-medical-gray-500">شماره موبایل</label>
<input
id="reg-phone"
name="username"
type="tel"
required
maxLength={11}
autoComplete="username"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left tracking-widest text-sm"
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider"
dir="ltr"
/>
</div>
@ -523,35 +602,45 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
<label htmlFor="reg-email" className="text-xs font-bold text-medical-gray-500">ایمیل (اختیاری)</label>
<input
id="reg-email"
name="email"
type="email"
autoComplete="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left text-sm"
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
dir="ltr"
/>
</div>
<div className="space-y-1">
<label htmlFor="reg-password" className="text-xs font-bold text-medical-gray-500">رمز عبور *</label>
<input
id="reg-password"
name="new-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left text-sm"
/>
<label htmlFor="reg-pass" className="text-xs font-bold text-medical-gray-500">رمز عبور</label>
<div className="relative">
<input
id="reg-pass"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
required
placeholder="حداقل ۶ کاراکتر"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 pr-3 pl-10 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
dir="ltr"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute left-2.5 top-1/2 -translate-y-1/2 p-1 text-medical-gray-400 hover:text-medical-gray-700 transition-colors cursor-pointer"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="flex justify-start pt-1">
<button
type="button"
onClick={() => setView("login")}
className="text-xs font-bold text-canina-blue hover:underline"
className="text-xs font-bold text-canina-blue hover:underline cursor-pointer"
>
بازگشت به ورود
</button>
@ -582,7 +671,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
<button
type="button"
onClick={() => setView("login")}
className="text-[10px] font-black text-canina-blue hover:underline"
className="text-[10px] font-black text-canina-blue hover:underline cursor-pointer"
>
برگشت به صفحه ورود
</button>
@ -595,9 +684,10 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
maxLength={11}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))}
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-lg text-left tracking-widest"
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-lg text-left font-mono tracking-widest"
dir="ltr"
/>
</div>
</div>
@ -634,43 +724,31 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
<button
type="button"
onClick={() => setView("otp-phone")}
className="text-[10px] font-black text-canina-blue flex items-center gap-1 hover:underline"
className="text-[10px] font-black text-canina-blue flex items-center gap-1 hover:underline cursor-pointer"
>
<ArrowRight className="w-3 h-3" />
تغییر شماره
</button>
</div>
<div className="relative">
<Key className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
<Key className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400 pointer-events-none" />
<input
ref={otpInputRef}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={5}
placeholder="کد ۵ رقمی"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/[^0-9]/g, ''))}
onChange={(e) => handleOtpChange(e.target.value)}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-black text-2xl text-center tracking-[0.5em]"
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-center text-2xl font-mono tracking-[0.5em]"
dir="ltr"
/>
</div>
</div>
<div className="text-center">
{countdown > 0 ? (
<span className="text-xs font-bold text-medical-gray-400">
ارسال مجدد کد پس از {toPersian(countdown)} ثانیه
</span>
) : (
<button
type="button"
onClick={handleResendOtp}
disabled={isLoading}
className="text-xs font-black text-canina-blue hover:underline flex items-center gap-1.5 justify-center mx-auto"
>
<RefreshCw className="w-3.5 h-3.5" />
ارسال مجدد کد تایید
</button>
)}
<p className="text-[11px] text-medical-gray-400 text-center font-medium pt-1">
کد پیامکشده به صورت خودکار شناسایی و وارد میشود.
</p>
</div>
<button
@ -682,55 +760,77 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
<RefreshCw className="w-6 h-6 animate-spin" />
) : (
<>
<LogIn className="w-6 h-6" />
تایید کد و ورود
<ShieldCheck className="w-6 h-6" />
تایید و ورود
</>
)}
</button>
<div className="text-center pt-2">
{countdown > 0 ? (
<span className="text-xs font-bold text-medical-gray-400">
ارسال مجدد کد تا {toPersian(countdown.toString())} ثانیه دیگر
</span>
) : (
<button
type="button"
onClick={handleResendOtp}
className="text-xs font-black text-canina-blue hover:underline cursor-pointer"
>
ارسال مجدد کد تایید
</button>
)}
</div>
</motion.form>
)}
{/* Forgot Password - Enter Phone */}
{view === "forgot-password" && (
<motion.form
key="forgot-password-view"
key="forgot-view"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onSubmit={handleForgotSendOtp}
className="space-y-4"
>
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-4 text-center">
<Lock className="w-8 h-8 text-amber-500 mx-auto mb-2" />
<p className="text-sm font-bold text-amber-700">برای بازیابی رمز عبور، کد تایید به شمارهتان ارسال میشود</p>
<div className="space-y-1">
<label className="text-xs font-bold text-medical-gray-500">شماره موبایل حساب</label>
<div className="relative">
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" />
<input
autoFocus
type="tel"
maxLength={11}
required
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider"
dir="ltr"
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="forgot-phone" className="text-xs font-bold text-medical-gray-500">شماره موبایل</label>
<input
id="forgot-phone"
name="username"
type="tel"
maxLength={11}
autoComplete="username"
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 px-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-left tracking-widest"
/>
<div className="flex justify-start pt-1">
<button
type="button"
onClick={() => setView("login")}
className="text-xs font-bold text-canina-blue hover:underline cursor-pointer"
>
بازگشت به ورود
</button>
</div>
<button
type="submit"
disabled={isLoading || phoneNumber.length < 11}
className="w-full py-4 bg-canina-blue text-white rounded-xl font-black hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-lg shadow-canina-blue/20 flex items-center justify-center gap-2"
className="w-full py-4 bg-canina-blue text-white rounded-xl font-black hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-lg shadow-canina-blue/20 flex items-center justify-center gap-2 cursor-pointer"
>
{isLoading ? <RefreshCw className="w-5 h-5 animate-spin" /> : <>ارسال کد بازیابی</>}
</button>
<button type="button" onClick={() => setView("login")} className="w-full text-xs font-bold text-medical-gray-400 hover:text-canina-blue">بازگشت به ورود</button>
</motion.form>
)}
{/* Forgot Password - Enter OTP */}
{view === "forgot-otp" && (
<motion.form
key="forgot-otp-view"
@ -738,74 +838,86 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onSubmit={handleForgotVerifyOtp}
className="space-y-5"
className="space-y-4"
>
<p className="text-sm font-bold text-medical-gray-600 text-center">کد ارسال شده به {toPersian(phoneNumber)} را وارد کنید</p>
<div className="relative">
<input
ref={otpInputRef}
id="forgot-otp-code"
name="one-time-code"
type="text"
autoComplete="one-time-code"
inputMode="numeric"
maxLength={5}
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/[^0-9]/g, ''))}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pl-4 pr-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-black text-2xl text-center tracking-[0.5em]"
/>
<div className="space-y-1">
<label className="text-xs font-bold text-medical-gray-500">کد تایید بازیابی</label>
<div className="relative">
<Key className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400 pointer-events-none" />
<input
autoFocus
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={5}
required
placeholder="کد ۵ رقمی"
value={otpCode}
onChange={(e) => setOtpCode(toEnglishDigits(e.target.value).replace(/[^0-9]/g, "").slice(0, 5))}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-center font-mono font-bold text-xl tracking-[0.4em]"
dir="ltr"
/>
</div>
</div>
{countdown > 0 ? (
<p className="text-xs font-bold text-medical-gray-400 text-center">ارسال مجدد پس از {toPersian(countdown)} ثانیه</p>
) : (
<button type="button" onClick={handleForgotSendOtp} disabled={isLoading} className="text-xs font-black text-canina-blue hover:underline flex items-center gap-1.5 justify-center mx-auto">
<RefreshCw className="w-3.5 h-3.5" />ارسال مجدد
</button>
)}
<button type="submit" disabled={isLoading || otpCode.length < 5} className="w-full py-4 bg-canina-blue text-white rounded-xl font-black disabled:opacity-50 transition-all shadow-lg shadow-canina-blue/20 flex items-center justify-center gap-2">
{isLoading ? <RefreshCw className="w-5 h-5 animate-spin" /> : <>تایید کد</>}
<button
type="submit"
disabled={isLoading || otpCode.length < 5}
className="w-full py-4 bg-canina-blue text-white rounded-xl font-black hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-lg shadow-canina-blue/20 flex items-center justify-center gap-2 cursor-pointer"
>
{isLoading ? <RefreshCw className="w-5 h-5 animate-spin" /> : <>تایید و ادامه</>}
</button>
</motion.form>
)}
{/* Reset Password */}
{view === "reset-password" && (
<motion.form
key="reset-password-view"
key="reset-pass-view"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onSubmit={handleResetPassword}
className="space-y-4"
>
<div className="bg-green-50 border border-green-200 rounded-2xl p-4 text-center">
<p className="text-sm font-bold text-green-700">هویت تایید شد. رمز جدید خود را وارد کنید</p>
<div className="space-y-1">
<label className="text-xs font-bold text-medical-gray-500">رمز عبور جدید</label>
<div className="relative">
<Lock className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400 pointer-events-none" />
<input
autoFocus
type={showNewPassword ? "text" : "password"}
autoComplete="new-password"
required
placeholder="حداقل ۶ کاراکتر"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-11 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
dir="ltr"
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute left-3 top-1/2 -translate-y-1/2 p-1 text-medical-gray-400 hover:text-medical-gray-700 transition-colors cursor-pointer"
tabIndex={-1}
>
{showNewPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<label htmlFor="new-password" className="text-xs font-bold text-medical-gray-500">رمز جدید</label>
<input
id="new-password"
name="new-password"
type="password"
autoComplete="new-password"
placeholder="حداقل ۶ کاراکتر"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 px-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left"
/>
</div>
<button type="submit" disabled={isLoading || newPassword.length < 6} className="w-full py-4 bg-canina-blue text-white rounded-xl font-black disabled:opacity-50 transition-all shadow-lg shadow-canina-blue/20 flex items-center justify-center gap-2">
{isLoading ? <RefreshCw className="w-5 h-5 animate-spin" /> : <>تغییر رمز عبور</>}
<button
type="submit"
disabled={isLoading || newPassword.length < 6}
className="w-full py-4 bg-canina-blue text-white rounded-xl font-black hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-lg shadow-canina-blue/20 flex items-center justify-center gap-2 cursor-pointer"
>
{isLoading ? <RefreshCw className="w-5 h-5 animate-spin" /> : <>ثبت رمز عبور جدید</>}
</button>
</motion.form>
)}
</AnimatePresence>
</div>
</motion.div>
</>
</div>
)}
</AnimatePresence>
);

View File

@ -1,7 +1,7 @@
"use client";
import React, { useState } from "react";
import { motion } from "motion/react";
import { User, ShoppingBag, Wallet, MapPin, LogOut, ChevronRight, Package, Calendar, UserCircle, ShoppingCart, Trash2, Edit2, Phone, Hash, CheckCircle2, ArrowUpCircle, ArrowDownCircle, Info, Clock, CheckCircle, Heart, Sparkles, MessageSquare, Send, Stethoscope, FileText } from "lucide-react";
import { User, ShoppingBag, Wallet, MapPin, LogOut, ChevronRight, Package, Calendar, UserCircle, ShoppingCart, Trash2, Edit2, Phone, Hash, CheckCircle2, ArrowUpCircle, ArrowDownCircle, Info, Clock, CheckCircle, Heart, Sparkles, MessageSquare, Send, Stethoscope, FileText, Lock, Key, Eye, EyeOff, ShieldCheck } from "lucide-react";
import { OrderRowSkeleton } from "./Skeleton";
import { useUserStore, Address } from "../lib/store/userStore";
import { useCartStore } from "../lib/store/cartStore";
@ -107,6 +107,48 @@ export default function UserDashboard() {
}
};
// Password Management State
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showCurrentPass, setShowCurrentPass] = useState(false);
const [showNewPass, setShowNewPass] = useState(false);
const [showConfirmPass, setShowConfirmPass] = useState(false);
const [isSavingPassword, setIsSavingPassword] = useState(false);
const handleSavePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (!newPassword || newPassword.length < 6) {
toast.error("رمز عبور جدید باید حداقل ۶ کاراکتر باشد");
return;
}
if (newPassword !== confirmPassword) {
toast.error("رمز عبور جدید و تکرار آن یکسان نیستند");
return;
}
if (profile.hasPassword && !currentPassword) {
toast.error("لطفاً رمز عبور فعلی خود را وارد کنید");
return;
}
setIsSavingPassword(true);
try {
await updateProfile({
password: newPassword.trim(),
currentPassword: profile.hasPassword ? currentPassword.trim() : undefined,
});
toast.success(profile.hasPassword ? "رمز عبور با موفقیت تغییر کرد" : "رمز عبور با موفقیت برای حساب شما ثبت شد");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch (err: unknown) {
const errObj = err as { message?: string };
toast.error(errObj.message || "خطا در ثبت رمز عبور");
} finally {
setIsSavingPassword(false);
}
};
const handleLogout = () => {
logout();
router.push('/');
@ -339,9 +381,140 @@ export default function UserDashboard() {
</button>
)}
</div>
</form>
</div>
)}
</form>
{/* Password & Security Section */}
<div className="mt-12 pt-8 border-t border-medical-gray-100">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-canina-blue/10 text-canina-blue flex items-center justify-center">
<Lock className="w-5 h-5" />
</div>
<div>
<h4 className="text-lg font-black text-medical-gray-900 italic">
{profile.hasPassword ? "تغییر رمز عبور حساب" : "تعریف رمز عبور برای حساب کاربری"}
</h4>
<p className="text-xs font-medium text-medical-gray-400">
{profile.hasPassword
? "برای افزایش امنیت می‌توانید رمز عبور ورود خود را تغییر دهید."
: "با تعیین رمز عبور می‌توانید در دفعات بعدی علاوه بر پیامک، با رمز عبور نیز وارد شوید."}
</p>
</div>
</div>
<span
className={cn(
"px-3 py-1 rounded-full text-[11px] font-bold self-start sm:self-auto",
profile.hasPassword
? "bg-green-50 text-green-700 border border-green-200"
: "bg-amber-50 text-amber-700 border border-amber-200"
)}
>
{profile.hasPassword ? "رمز عبور فعال است" : "ورود فقط با OTP"}
</span>
</div>
<form onSubmit={handleSavePassword} className="bg-medical-gray-50/50 p-6 sm:p-8 rounded-[2rem] border border-medical-gray-100 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{profile.hasPassword && (
<div className="space-y-1.5">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">
رمز عبور فعلی *
</label>
<div className="relative">
<input
type={showCurrentPass ? "text" : "password"}
autoComplete="current-password"
required
placeholder="رمز فعلی..."
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full bg-white border border-medical-gray-200 rounded-xl py-3 pr-3 pl-10 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
dir="ltr"
/>
<button
type="button"
onClick={() => setShowCurrentPass(!showCurrentPass)}
className="absolute left-2.5 top-1/2 -translate-y-1/2 p-1 text-medical-gray-400 hover:text-medical-gray-700 transition-colors cursor-pointer"
tabIndex={-1}
>
{showCurrentPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
)}
<div className="space-y-1.5">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">
{profile.hasPassword ? "رمز عبور جدید *" : "رمز عبور دلخواه *"}
</label>
<div className="relative">
<input
type={showNewPass ? "text" : "password"}
autoComplete="new-password"
required
placeholder="حداقل ۶ کاراکتر"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full bg-white border border-medical-gray-200 rounded-xl py-3 pr-3 pl-10 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
dir="ltr"
/>
<button
type="button"
onClick={() => setShowNewPass(!showNewPass)}
className="absolute left-2.5 top-1/2 -translate-y-1/2 p-1 text-medical-gray-400 hover:text-medical-gray-700 transition-colors cursor-pointer"
tabIndex={-1}
>
{showNewPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">
تکرار رمز عبور *
</label>
<div className="relative">
<input
type={showConfirmPass ? "text" : "password"}
autoComplete="new-password"
required
placeholder="تکرار رمز عبور"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full bg-white border border-medical-gray-200 rounded-xl py-3 pr-3 pl-10 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
dir="ltr"
/>
<button
type="button"
onClick={() => setShowConfirmPass(!showConfirmPass)}
className="absolute left-2.5 top-1/2 -translate-y-1/2 p-1 text-medical-gray-400 hover:text-medical-gray-700 transition-colors cursor-pointer"
tabIndex={-1}
>
{showConfirmPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
<div className="flex justify-end pt-3">
<button
type="submit"
disabled={isSavingPassword || newPassword.length < 6 || !confirmPassword}
className="px-8 py-3.5 bg-canina-blue hover:bg-indigo-700 text-white rounded-xl font-black text-xs transition-all shadow-md shadow-canina-blue/15 flex items-center gap-2 cursor-pointer disabled:opacity-50"
>
{isSavingPassword ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<ShieldCheck className="w-4 h-4" />
)}
{profile.hasPassword ? "بروزرسانی رمز عبور" : "ثبت رمز عبور"}
</button>
</div>
</form>
</div>
</div>
)}
{activeTab === "orders" && (
<div>

View File

@ -9,6 +9,9 @@ export interface User {
role: string;
walletBalance: number;
charityDonationTotal: number;
hasPassword?: boolean;
password?: string;
currentPassword?: string;
createdAt: string;
updatedAt: string;
pets?: unknown[];

View File

@ -1,7 +1,7 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { authService } from "../services/authService";
import { authService, User } from "../services/authService";
import api from "../services/api";
import { useCartStore } from "./cartStore";
import { usePetStore } from "./usePetStore";
@ -37,6 +37,7 @@ interface UserProfile {
mobile: string;
walletBalance: number;
charityDonationTotal: number;
hasPassword?: boolean;
addresses: Address[];
transactions: Transaction[];
}
@ -49,7 +50,7 @@ interface UserStore {
setRole: (role: UserRole) => void;
setLoggedIn: (isLoggedIn: boolean) => void;
setAuthModalOpen: (open: boolean) => void;
updateProfile: (profile: Partial<UserProfile>) => Promise<void>;
updateProfile: (profile: Partial<UserProfile & { password?: string; currentPassword?: string }>) => Promise<void>;
addAddress: (address: Address) => Promise<void>;
updateAddress: (id: string, address: Address) => Promise<void>;
deleteAddress: (id: string) => Promise<void>;
@ -72,6 +73,7 @@ export const useUserStore = create<UserStore>()(
mobile: "",
walletBalance: 0,
charityDonationTotal: 0,
hasPassword: false,
addresses: [],
transactions: [],
},
@ -80,7 +82,7 @@ export const useUserStore = create<UserStore>()(
setAuthModalOpen: (open) => set({ isAuthModalOpen: open }),
updateProfile: async (updates) => {
try {
const profileData = await authService.updateProfile(updates);
const profileData = await authService.updateProfile(updates as Partial<User>);
set((state) => ({
profile: {
...state.profile,
@ -88,6 +90,7 @@ export const useUserStore = create<UserStore>()(
lastName: profileData.lastName,
email: profileData.email,
mobile: profileData.mobile || "",
hasPassword: profileData.hasPassword !== undefined ? profileData.hasPassword : state.profile.hasPassword,
}
}));
} catch (error) {
@ -246,6 +249,7 @@ export const useUserStore = create<UserStore>()(
mobile: profileData.mobile || "",
walletBalance: backendWallet,
charityDonationTotal: backendCharity,
hasPassword: profileData.hasPassword !== undefined ? profileData.hasPassword : state.profile.hasPassword,
addresses: Array.isArray(pd.addresses) && pd.addresses.length > 0 ? (pd.addresses as Address[]) : state.profile.addresses,
transactions: backendTransactions.length > 0 ? backendTransactions : state.profile.transactions
}