feat(auth): implement WebOTP SMS auto-fill, auto-submit, password setup in dashboard, and password visibility toggle
This commit is contained in:
parent
897b4d61ec
commit
cbd7e1a24d
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user