From f002acac27dffdfc634389fb9651144702de0599 Mon Sep 17 00:00:00 2001 From: thangnguyen Date: Wed, 21 May 2025 06:47:23 +0700 Subject: [PATCH 01/10] Add forgot password functionality with OTP verification and reset password features --- app/layout.tsx | 2 + components/models/ForgotPasswordModal.tsx | 374 ++++++++++++++++++++++ components/models/LoginModal.tsx | 44 ++- hook/useForgotPasswordModal.ts | 15 + middleware.ts | 1 + package-lock.json | 21 ++ package.json | 1 + pages/api/auth/[...nextauth].ts | 3 +- pages/api/auth/forgot-password.ts | 91 ++++++ pages/api/auth/reset-password.ts | 49 +++ pages/api/auth/verify-otp.ts | 48 +++ prisma/schema.prisma | 14 +- 12 files changed, 647 insertions(+), 16 deletions(-) create mode 100644 components/models/ForgotPasswordModal.tsx create mode 100644 hook/useForgotPasswordModal.ts create mode 100644 pages/api/auth/forgot-password.ts create mode 100644 pages/api/auth/reset-password.ts create mode 100644 pages/api/auth/verify-otp.ts diff --git a/app/layout.tsx b/app/layout.tsx index 4f12c96..cd68225 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,6 +9,7 @@ import Navbar from "@/components/navbar/Navbar"; import { Nunito } from "next/font/google"; import "../styles/globals.css"; import getCurrentUser from "./actions/getCurrentUser"; +import ForgotPasswordModal from '@/components/models/ForgotPasswordModal'; export const metadata = { title: "Airbnb", @@ -34,6 +35,7 @@ export default async function RootLayout({ + diff --git a/components/models/ForgotPasswordModal.tsx b/components/models/ForgotPasswordModal.tsx new file mode 100644 index 0000000..164a412 --- /dev/null +++ b/components/models/ForgotPasswordModal.tsx @@ -0,0 +1,374 @@ +'use client'; + +import axios from 'axios'; +import { useCallback, useState } from "react"; +import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; +import { toast } from "react-hot-toast"; + +import useLoginModal from "@/hook/useLoginModal"; +import useForgotPasswordModal from "@/hook/useForgotPasswordModal"; +import Modal from "./Modal"; +import Input from "../inputs/Input"; +import Heading from "../Heading"; +import Button from "../Button"; + +enum STEPS { + EMAIL = 0, + OTP = 1, + NEW_PASSWORD = 2, + COMPLETED = 3, +} + +// Định nghĩa interface cho form values +interface ForgotPasswordFormValues { + email: string; + otp: string; + password: string; + confirmPassword: string; +} + +const ForgotPasswordModal = () => { + const loginModal = useLoginModal(); + const forgotPasswordModal = useForgotPasswordModal(); + const [step, setStep] = useState(STEPS.EMAIL); + const [isLoading, setIsLoading] = useState(false); + const [email, setEmail] = useState(""); + const [countdown, setCountdown] = useState(300); // 5 minutes in seconds + const [timerActive, setTimerActive] = useState(false); + + const { + register, + handleSubmit, + setValue, + watch, + formState: { + errors, + }, + reset + } = useForm({ + defaultValues: { + email: '', + otp: '', + password: '', + confirmPassword: '', + }, + }); + + // OTP input refs + const [otpValues, setOtpValues] = useState(['', '', '', '', '', '']); + + // Handle OTP input change + const handleOtpChange = (index: number, value: string) => { + if (value.length <= 1) { + const newOtpValues = [...otpValues]; + newOtpValues[index] = value; + setOtpValues(newOtpValues); + + // Move to next input field if current field is filled + if (value !== '' && index < 5) { + const nextInput = document.getElementById(`otp-${index + 1}`); + nextInput?.focus(); + } + + // Update form value + const otpString = newOtpValues.join(''); + setValue('otp', otpString); + } + }; + + // Start countdown timer for OTP + const startCountdown = useCallback(() => { + setCountdown(300); // Reset to 5 minutes + setTimerActive(true); + + const timer = setInterval(() => { + setCountdown(prevCount => { + if (prevCount <= 1) { + clearInterval(timer); + setTimerActive(false); + return 0; + } + return prevCount - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, []); + + // Format seconds to MM:SS + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + // Handle sending OTP email + const onSendOTP = useCallback(async (email: string) => { + setIsLoading(true); + try { + await axios.post('/api/auth/forgot-password', { email }); + toast.success('Mã xác thực đã được gửi đến email của bạn'); + setEmail(email); + startCountdown(); + setStep(STEPS.OTP); + } catch (error) { + toast.error('Không thể gửi mã xác thực. Vui lòng kiểm tra lại email.'); + } finally { + setIsLoading(false); + } + }, [startCountdown]); + + // Resend OTP + const onResendOTP = useCallback(async () => { + if (timerActive) return; + + setIsLoading(true); + try { + await axios.post('/api/auth/forgot-password', { email }); + toast.success('Mã xác thực mới đã được gửi đến email của bạn'); + startCountdown(); + } catch (error) { + toast.error('Không thể gửi lại mã xác thực'); + } finally { + setIsLoading(false); + } + }, [email, timerActive, startCountdown]); + + // Verify OTP + const onVerifyOTP = useCallback(async (otp: string) => { + setIsLoading(true); + try { + await axios.post('/api/auth/verify-otp', { email, otp }); + toast.success('Mã xác thực hợp lệ'); + setStep(STEPS.NEW_PASSWORD); + } catch (error) { + toast.error('Mã xác thực không hợp lệ hoặc đã hết hạn'); + } finally { + setIsLoading(false); + } + }, [email]); + + // Reset password - sửa lỗi kiểu dữ liệu ở đây + const onResetPassword = useCallback(async ({ password, confirmPassword }: { password: string, confirmPassword: string }) => { + if (password !== confirmPassword) { + toast.error('Mật khẩu xác nhận không khớp'); + return; + } + + setIsLoading(true); + try { + await axios.post('/api/auth/reset-password', { + email, + password + }); + toast.success('Mật khẩu đã được cập nhật thành công'); + setStep(STEPS.COMPLETED); + } catch (error) { + toast.error('Không thể cập nhật mật khẩu'); + } finally { + setIsLoading(false); + } + }, [email]); + + // Handle form submission - sửa lỗi kiểu dữ liệu ở đây + const onSubmit: SubmitHandler = (data) => { + switch (step) { + case STEPS.EMAIL: + onSendOTP(data.email); + break; + case STEPS.OTP: + onVerifyOTP(data.otp); + break; + case STEPS.NEW_PASSWORD: + onResetPassword({ + password: data.password, + confirmPassword: data.confirmPassword + }); + break; + default: + break; + } + }; + + const onOpenLoginModal = useCallback(() => { + forgotPasswordModal.onClose(); + loginModal.onOpen(); + }, [forgotPasswordModal, loginModal]); + + // Handle completion and reset + const handleComplete = useCallback(() => { + reset(); + setStep(STEPS.EMAIL); + forgotPasswordModal.onClose(); + loginModal.onOpen(); + }, [reset, forgotPasswordModal, loginModal]); + + // Different content for each step + const bodyContent = () => { + switch (step) { + case STEPS.EMAIL: + return ( +
+ + +
+ ); + case STEPS.OTP: + return ( +
+ +
+ {otpValues.map((value, index) => ( + handleOtpChange(index, e.target.value)} + className="w-12 h-12 text-center text-xl border rounded-md" + /> + ))} +
+
+

+ Mã xác thực sẽ hết hạn sau: {formatTime(countdown)} +

+ +
+
+ ); + case STEPS.NEW_PASSWORD: + return ( +
+ + + +
+ ); + case STEPS.COMPLETED: + return ( +
+
+ + + +
+ +

+ Bạn có thể đăng nhập bằng mật khẩu mới ngay bây giờ. +

+
+ ); + default: + return null; + } + }; + + // Action buttons based on current step + const footerContent = ( +
+ {step === STEPS.COMPLETED ? ( +
+ ); + + return ( + + ); +}; + +export default ForgotPasswordModal; \ No newline at end of file diff --git a/components/models/LoginModal.tsx b/components/models/LoginModal.tsx index df273dc..565d7ea 100644 --- a/components/models/LoginModal.tsx +++ b/components/models/LoginModal.tsx @@ -1,7 +1,8 @@ "use client"; -import useLoginModel from "@/hook/useLoginModal"; +import useLoginModal from "@/hook/useLoginModal"; import useRegisterModal from "@/hook/useRegisterModal"; +import useForgotPasswordModal from "@/hook/useForgotPasswordModal"; import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import { useCallback, useState } from "react"; @@ -19,8 +20,9 @@ type Props = {}; function LoginModal({}: Props) { const router = useRouter(); - const registerModel = useRegisterModal(); - const loginModel = useLoginModel(); + const registerModal = useRegisterModal(); + const loginModal = useLoginModal(); + const forgotPasswordModal = useForgotPasswordModal(); const [isLoading, setIsLoading] = useState(false); const { @@ -46,7 +48,7 @@ function LoginModal({}: Props) { if (callback?.ok) { toast.success("Login Successfully"); router.refresh(); - loginModel.onClose(); + loginModal.onClose(); } else if (callback?.error) { toast.error("Something Went Wrong"); } @@ -54,9 +56,14 @@ function LoginModal({}: Props) { }; const toggle = useCallback(() => { - loginModel.onClose(); - registerModel.onOpen(); - }, [loginModel, registerModel]); + loginModal.onClose(); + registerModal.onOpen(); + }, [loginModal, registerModal]); + + const onForgotPassword = useCallback(() => { + loginModal.onClose(); + forgotPasswordModal.onOpen(); + }, [loginModal, forgotPasswordModal]); const bodyContent = (
@@ -72,6 +79,7 @@ function LoginModal({}: Props) { */}
-
- {`Didn't have an Account?`}{" "} +
- Create an Account + Forgot password? +
+
{`Didn't have an Account?`}
+ + Create an Account + +
@@ -112,10 +128,10 @@ function LoginModal({}: Props) { return ( void; + onClose: () => void; +} + +const useForgotPasswordModal = create((set) => ({ + isOpen: false, + onOpen: () => set({ isOpen: true }), + onClose: () => set({ isOpen: false }) +})); + +export default useForgotPasswordModal; \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index f89bef5..b25057b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,3 +3,4 @@ export { default } from "next-auth/middleware"; export const config = { matcher: ["/trips", "/reservations", "/properties", "/favorites"], }; + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b313f8d..524d6f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@types/date-fns": "^2.6.0", "@types/jsonwebtoken": "^9.0.9", "@types/node": "18.15.11", + "@types/nodemailer": "^6.4.17", "@types/react": "18.0.31", "@types/react-dom": "18.0.11", "axios": "^1.3.4", @@ -7064,6 +7065,15 @@ "form-data": "^4.0.0" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -12140,6 +12150,17 @@ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "optional": true, + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", diff --git a/package.json b/package.json index 81592b4..6142a8d 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@types/date-fns": "^2.6.0", "@types/jsonwebtoken": "^9.0.9", "@types/node": "18.15.11", + "@types/nodemailer": "^6.4.17", "@types/react": "18.0.31", "@types/react-dom": "18.0.11", "axios": "^1.3.4", diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index 2122689..a4d3421 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -6,7 +6,8 @@ import CredentialsProvider from "next-auth/providers/credentials"; import FacebookProvider from "next-auth/providers/facebook"; import GoogleProvider from "next-auth/providers/google"; import jwt from "jsonwebtoken"; - +import { NextApiRequest, NextApiResponse } from 'next'; +import nodemailer from 'nodemailer'; export const authOptions: AuthOptions = { adapter: PrismaAdapter(prisma), providers: [ diff --git a/pages/api/auth/forgot-password.ts b/pages/api/auth/forgot-password.ts new file mode 100644 index 0000000..7c71a9c --- /dev/null +++ b/pages/api/auth/forgot-password.ts @@ -0,0 +1,91 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import prisma from "@/lib/prismadb"; +import nodemailer from 'nodemailer'; + +// Tạo OTP ngẫu nhiên 6 chữ số +const generateOTP = (): string => { + return Math.floor(100000 + Math.random() * 900000).toString(); +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method Not Allowed' }); + } + + const { email } = req.body; + + if (!email) { + return res.status(400).json({ message: 'Email is required' }); + } + + try { + // Kiểm tra xem email có tồn tại trong hệ thống không + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + // Vẫn trả về thành công để không để lộ thông tin về việc email có tồn tại hay không + return res.status(200).json({ message: 'OTP sent if email exists' }); + } + + // Tạo OTP và thời gian hết hạn (5 phút) + const otp = generateOTP(); + const expiresAt = new Date(); + expiresAt.setMinutes(expiresAt.getMinutes() + 5); + + // Lưu OTP vào database + await prisma.resetToken.upsert({ + where: { userId: user.id }, + update: { + token: otp, + expiresAt, + }, + create: { + userId: user.id, + token: otp, + expiresAt, + }, + }); + + // Cấu hình transporter cho nodemailer (cần thiết lập trong .env) + const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASSWORD, + }, + }); + + // Gửi email chứa OTP + await transporter.sendMail({ + from: `"AirBnb" <${process.env.EMAIL_USER}>`, + to: email, + subject: 'Mã xác thực đặt lại mật khẩu', + html: ` +
+
+

AirBnb

+
+

Xin chào,

+

Chúng tôi nhận được yêu cầu đặt lại mật khẩu cho tài khoản của bạn. Vui lòng sử dụng mã xác thực sau để tiếp tục:

+
+
${otp}
+
+

Mã xác thực này sẽ hết hạn sau 5 phút.

+

Nếu bạn không yêu cầu đặt lại mật khẩu, vui lòng bỏ qua email này.

+

Trân trọng,

+

Đội ngũ AirBnb

+
+ `, + }); + + return res.status(200).json({ message: 'OTP sent successfully' }); + } catch (error) { + console.error('Error sending OTP:', error); + return res.status(500).json({ message: 'Error sending OTP' }); + } +} \ No newline at end of file diff --git a/pages/api/auth/reset-password.ts b/pages/api/auth/reset-password.ts new file mode 100644 index 0000000..6f2f301 --- /dev/null +++ b/pages/api/auth/reset-password.ts @@ -0,0 +1,49 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import bcrypt from 'bcrypt'; +import prisma from "@/lib/prismadb"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method Not Allowed' }); + } + + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ message: 'Email and password are required' }); + } + + try { + // Tìm user với email đã cung cấp + const user = await prisma.user.findUnique({ + where: { email }, + include: { resetToken: true }, + }); + + if (!user || !user.resetToken || !user.resetToken.verified) { + return res.status(400).json({ message: 'Unauthorized password reset' }); + } + + // Hash mật khẩu mới + const hashedPassword = await bcrypt.hash(password, 12); + + // Cập nhật mật khẩu và xóa token đặt lại + await prisma.$transaction([ + prisma.user.update({ + where: { id: user.id }, + data: { hashedPassword }, + }), + prisma.resetToken.delete({ + where: { userId: user.id }, + }), + ]); + + return res.status(200).json({ message: 'Password reset successfully' }); + } catch (error) { + console.error('Error resetting password:', error); + return res.status(500).json({ message: 'Error resetting password' }); + } +} \ No newline at end of file diff --git a/pages/api/auth/verify-otp.ts b/pages/api/auth/verify-otp.ts new file mode 100644 index 0000000..2156e48 --- /dev/null +++ b/pages/api/auth/verify-otp.ts @@ -0,0 +1,48 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import prisma from "@/lib/prismadb"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method Not Allowed' }); + } + + const { email, otp } = req.body; + + if (!email || !otp) { + return res.status(400).json({ message: 'Email and OTP are required' }); + } + + try { + // Tìm user với email đã cung cấp + const user = await prisma.user.findUnique({ + where: { email }, + include: { resetToken: true }, + }); + + if (!user || !user.resetToken) { + return res.status(400).json({ message: 'Invalid OTP' }); + } + + // Kiểm tra OTP có hợp lệ không và chưa hết hạn + if ( + user.resetToken.token !== otp || + user.resetToken.expiresAt < new Date() + ) { + return res.status(400).json({ message: 'Invalid or expired OTP' }); + } + + // OTP hợp lệ, đánh dấu đã được xác minh + await prisma.resetToken.update({ + where: { userId: user.id }, + data: { verified: true }, + }); + + return res.status(200).json({ message: 'OTP verified successfully' }); + } catch (error) { + console.error('Error verifying OTP:', error); + return res.status(500).json({ message: 'Error verifying OTP' }); + } +} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 42702dc..5e7f5ec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,7 @@ model User { reservations Reservation[] reviews Review[] transactions Transaction[] + resetToken ResetToken? } model Account { @@ -59,7 +60,7 @@ model Listing { user User @relation(fields: [userId], references: [id], onDelete: Cascade) reservations Reservation[] reviews Review[] - transactions Transaction[] + transactions Transaction[] } model Reservation { @@ -97,4 +98,15 @@ model Transaction { updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade) +} + +model ResetToken { + id String @id @default(auto()) @map("_id") @db.ObjectId + token String + userId String @unique @db.ObjectId + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + expiresAt DateTime + verified Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } \ No newline at end of file From 4f3f1f20bfb69208b915d6d0d270a35eda771537 Mon Sep 17 00:00:00 2001 From: thangnguyen Date: Wed, 21 May 2025 07:07:23 +0700 Subject: [PATCH 02/10] refactor: clean up ForgotPasswordModal component by removing unused interface and improving code readability --- components/models/ForgotPasswordModal.tsx | 36 ++++++++++------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/components/models/ForgotPasswordModal.tsx b/components/models/ForgotPasswordModal.tsx index 164a412..539e892 100644 --- a/components/models/ForgotPasswordModal.tsx +++ b/components/models/ForgotPasswordModal.tsx @@ -19,14 +19,6 @@ enum STEPS { COMPLETED = 3, } -// Định nghĩa interface cho form values -interface ForgotPasswordFormValues { - email: string; - otp: string; - password: string; - confirmPassword: string; -} - const ForgotPasswordModal = () => { const loginModal = useLoginModal(); const forgotPasswordModal = useForgotPasswordModal(); @@ -36,8 +28,8 @@ const ForgotPasswordModal = () => { const [countdown, setCountdown] = useState(300); // 5 minutes in seconds const [timerActive, setTimerActive] = useState(false); - const { - register, + const { + register, handleSubmit, setValue, watch, @@ -45,7 +37,7 @@ const ForgotPasswordModal = () => { errors, }, reset - } = useForm({ + } = useForm({ defaultValues: { email: '', otp: '', @@ -121,7 +113,7 @@ const ForgotPasswordModal = () => { // Resend OTP const onResendOTP = useCallback(async () => { if (timerActive) return; - + setIsLoading(true); try { await axios.post('/api/auth/forgot-password', { email }); @@ -148,7 +140,7 @@ const ForgotPasswordModal = () => { } }, [email]); - // Reset password - sửa lỗi kiểu dữ liệu ở đây + // Reset password const onResetPassword = useCallback(async ({ password, confirmPassword }: { password: string, confirmPassword: string }) => { if (password !== confirmPassword) { toast.error('Mật khẩu xác nhận không khớp'); @@ -157,9 +149,9 @@ const ForgotPasswordModal = () => { setIsLoading(true); try { - await axios.post('/api/auth/reset-password', { - email, - password + await axios.post('/api/auth/reset-password', { + email, + password }); toast.success('Mật khẩu đã được cập nhật thành công'); setStep(STEPS.COMPLETED); @@ -170,8 +162,8 @@ const ForgotPasswordModal = () => { } }, [email]); - // Handle form submission - sửa lỗi kiểu dữ liệu ở đây - const onSubmit: SubmitHandler = (data) => { + // Handle form submission + const onSubmit: SubmitHandler = (data) => { switch (step) { case STEPS.EMAIL: onSendOTP(data.email); @@ -203,6 +195,7 @@ const ForgotPasswordModal = () => { loginModal.onOpen(); }, [reset, forgotPasswordModal, loginModal]); + // Different content for each step // Different content for each step const bodyContent = () => { switch (step) { @@ -315,7 +308,8 @@ const ForgotPasswordModal = () => {
); default: - return null; + // Trả về một div trống thay vì null + return
; } }; @@ -335,8 +329,8 @@ const ForgotPasswordModal = () => { step === STEPS.EMAIL ? "Tiếp tục" : step === STEPS.OTP - ? "Xác nhận" - : "Đặt lại mật khẩu" + ? "Xác nhận" + : "Đặt lại mật khẩu" } onClick={handleSubmit(onSubmit)} /> From f99469ec1d248a1e0638076bf93d8508050b6d5c Mon Sep 17 00:00:00 2001 From: thangnguyen Date: Wed, 21 May 2025 21:52:54 +0700 Subject: [PATCH 03/10] feat: enhance ForgotPasswordModal with custom toast notifications and update UI text to English --- components/models/ForgotPasswordModal.tsx | 149 ++++++++++++++-------- 1 file changed, 98 insertions(+), 51 deletions(-) diff --git a/components/models/ForgotPasswordModal.tsx b/components/models/ForgotPasswordModal.tsx index 539e892..f77f71c 100644 --- a/components/models/ForgotPasswordModal.tsx +++ b/components/models/ForgotPasswordModal.tsx @@ -3,7 +3,9 @@ import axios from 'axios'; import { useCallback, useState } from "react"; import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; -import { toast } from "react-hot-toast"; +import { toast as hotToast } from "react-hot-toast"; // Renamed to avoid conflicts +import { toast } from "react-toastify"; // Import toast from react-toastify +import { FiMail, FiCheckCircle, FiAlertCircle } from 'react-icons/fi'; import useLoginModal from "@/hook/useLoginModal"; import useForgotPasswordModal from "@/hook/useForgotPasswordModal"; @@ -94,17 +96,81 @@ const ForgotPasswordModal = () => { return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; - // Handle sending OTP email + // Custom toast notifications with icons + const showSuccessEmailToast = () => { + toast.success( +
+ +
+

Email sent successfully!

+

Please check your email for the OTP code

+
+
, + { + position: "bottom-left", + autoClose: 1000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + icon: false + } + ); + }; + + const showInvalidOTPToast = () => { + toast.error( +
+ +
+

Invalid OTP code!

+

Please check and try again

+
+
, + { + position: "bottom-left", + autoClose: 1000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + icon: false + } + ); + }; + + const showVerifySuccessToast = () => { + toast.success( +
+ {/* Only keep one icon */} + +
+

Verification successful!

+

Please create a new password

+
+
, + { + position: "bottom-left", + autoClose: 1000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + icon: false + } + ); + }; + const onSendOTP = useCallback(async (email: string) => { setIsLoading(true); try { - await axios.post('/api/auth/forgot-password', { email }); - toast.success('Mã xác thực đã được gửi đến email của bạn'); + axios.post('/api/auth/forgot-password', { email }); + showSuccessEmailToast(); // New toast notification setEmail(email); startCountdown(); setStep(STEPS.OTP); } catch (error) { - toast.error('Không thể gửi mã xác thực. Vui lòng kiểm tra lại email.'); + toast.error('Unable to send verification code. Please check your email.'); } finally { setIsLoading(false); } @@ -117,10 +183,10 @@ const ForgotPasswordModal = () => { setIsLoading(true); try { await axios.post('/api/auth/forgot-password', { email }); - toast.success('Mã xác thực mới đã được gửi đến email của bạn'); + showSuccessEmailToast(); // New toast notification startCountdown(); } catch (error) { - toast.error('Không thể gửi lại mã xác thực'); + toast.error('Unable to resend verification code'); } finally { setIsLoading(false); } @@ -131,10 +197,10 @@ const ForgotPasswordModal = () => { setIsLoading(true); try { await axios.post('/api/auth/verify-otp', { email, otp }); - toast.success('Mã xác thực hợp lệ'); + showVerifySuccessToast(); // New toast notification setStep(STEPS.NEW_PASSWORD); } catch (error) { - toast.error('Mã xác thực không hợp lệ hoặc đã hết hạn'); + showInvalidOTPToast(); // New toast notification } finally { setIsLoading(false); } @@ -143,7 +209,7 @@ const ForgotPasswordModal = () => { // Reset password const onResetPassword = useCallback(async ({ password, confirmPassword }: { password: string, confirmPassword: string }) => { if (password !== confirmPassword) { - toast.error('Mật khẩu xác nhận không khớp'); + toast.error('Password confirmation does not match'); return; } @@ -153,10 +219,10 @@ const ForgotPasswordModal = () => { email, password }); - toast.success('Mật khẩu đã được cập nhật thành công'); + toast.success('Password has been updated successfully'); setStep(STEPS.COMPLETED); } catch (error) { - toast.error('Không thể cập nhật mật khẩu'); + toast.error('Unable to update password'); } finally { setIsLoading(false); } @@ -195,7 +261,6 @@ const ForgotPasswordModal = () => { loginModal.onOpen(); }, [reset, forgotPasswordModal, loginModal]); - // Different content for each step // Different content for each step const bodyContent = () => { switch (step) { @@ -203,8 +268,8 @@ const ForgotPasswordModal = () => { return (
{ return (
{otpValues.map((value, index) => ( @@ -238,7 +303,7 @@ const ForgotPasswordModal = () => {

- Mã xác thực sẽ hết hạn sau: {formatTime(countdown)} + Code expires in: {formatTime(countdown)}

@@ -255,12 +320,12 @@ const ForgotPasswordModal = () => { return (
{ /> {

- Bạn có thể đăng nhập bằng mật khẩu mới ngay bây giờ. + You can now log in with your new password.

); default: - // Trả về một div trống thay vì null return
; } }; @@ -316,34 +380,15 @@ const ForgotPasswordModal = () => { // Action buttons based on current step const footerContent = (
- {step === STEPS.COMPLETED ? ( - +
+ ); +}; + +export default ContactButtons; \ No newline at end of file From d127a6b9a408038ebe7a68eb691fef51df862668 Mon Sep 17 00:00:00 2001 From: thangnguyen Date: Wed, 21 May 2025 23:38:30 +0700 Subject: [PATCH 09/10] fix: remove unnecessary blank line in layout component --- app/layout.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/layout.tsx b/app/layout.tsx index 9e152f4..157f726 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -12,7 +12,6 @@ import getCurrentUser from "./actions/getCurrentUser"; import ForgotPasswordModal from '@/components/models/ForgotPasswordModal'; import ContactButtons from '@/components/ContactButtons'; - export const metadata = { title: "Airbnb", description: "Airbnb Clone", From 73e7cc7496f1b23cb83eb95cfd3061e99c312dbe Mon Sep 17 00:00:00 2001 From: thangnguyen Date: Thu, 22 May 2025 11:11:25 +0700 Subject: [PATCH 10/10] refactor: remove ContactButtons component and its usage in layout --- app/layout.tsx | 3 -- components/ContactButtons.tsx | 55 ----------------------------------- 2 files changed, 58 deletions(-) delete mode 100644 components/ContactButtons.tsx diff --git a/app/layout.tsx b/app/layout.tsx index 157f726..eba30c4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -10,8 +10,6 @@ import { Nunito } from "next/font/google"; import "../styles/globals.css"; import getCurrentUser from "./actions/getCurrentUser"; import ForgotPasswordModal from '@/components/models/ForgotPasswordModal'; -import ContactButtons from '@/components/ContactButtons'; - export const metadata = { title: "Airbnb", description: "Airbnb Clone", @@ -39,7 +37,6 @@ export default async function RootLayout({ -
{children}
diff --git a/components/ContactButtons.tsx b/components/ContactButtons.tsx deleted file mode 100644 index ee26e5e..0000000 --- a/components/ContactButtons.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { SiZalo } from 'react-icons/si'; -import { BsMessenger } from 'react-icons/bs'; -import { IoIosCall } from 'react-icons/io'; - -const ContactButtons = () => { - const [isExpanded, setIsExpanded] = useState(false); - - return ( -
- {/* Contact buttons that appear when expanded */} -
- {/* Zalo Button */} - - - - - {/* Messenger Button */} - - - -
- - {/* Main toggle button */} - -
- ); -}; - -export default ContactButtons; \ No newline at end of file