feat: cài đặt tính năng gửi mã OTP qua mail xác thực

This commit is contained in:
2026-06-18 23:00:12 +07:00
parent 88b47099b1
commit 623f0164c8
3 changed files with 190 additions and 145 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { LandingPage } from './pages/LandingPage'; import { LandingPage } from './pages/LandingPage';
import { ExploreMap } from './pages/ExploreMap'; import { ExploreMap } from './pages/ExploreMap';
import { TourDetailPage } from './pages/TourDetailPage'; import { TourDetailPage } from './pages/TourDetailPage';
import { SignupPage } from './pages/SignupPage'; import SignupPage from './pages/SignupPage';
import { MyPhotosPage } from './pages/MyPhotosPage'; import { MyPhotosPage } from './pages/MyPhotosPage';
import { MyNotePage } from './pages/MyNotePage'; import { MyNotePage } from './pages/MyNotePage';
import { useTourStore } from './store/useTourStore'; import { useTourStore } from './store/useTourStore';
+177 -142
View File
@@ -1,12 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin } from 'lucide-react'; import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin, ShieldCheck } from 'lucide-react';
interface SignupPageProps { interface SignupPageProps {
onBack: () => void; onBack: () => void;
onSuccess: () => void; onSuccess: () => void;
} }
export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => { const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
email: '', email: '',
@@ -15,39 +15,58 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
phone: '', phone: '',
address: '' address: ''
}); });
const [step, setStep] = useState<'form' | 'otp'>('form');
const [otp, setOtp] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (formData.password !== formData.confirmPassword) {
setError('Mật khẩu xác nhận không khớp');
return;
}
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch(`/api/v1/auth/signup`, { if (step === 'form') {
method: 'POST', if (formData.password !== formData.confirmPassword) {
headers: { 'Content-Type': 'application/json' }, setError('Mật khẩu xác nhận không khớp');
body: JSON.stringify({ setIsLoading(false);
email: formData.email, return;
password: formData.password, }
name: formData.name,
phone: formData.phone || undefined,
address: formData.address || undefined
}),
});
const data = await response.json(); const response = await fetch(`/api/v1/auth/signup/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
password: formData.password,
name: formData.name,
phone: formData.phone || undefined,
address: formData.address || undefined
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Yêu cầu đăng ký thất bại');
if (!response.ok) { setStep('otp');
throw new Error(data.message || 'Đăng ký thất bại'); } else {
// Xác thực mã OTP bước hoàn tất
const response = await fetch(`/api/v1/auth/signup/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
otp: otp
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Mã OTP không chính xác');
onSuccess();
} }
onSuccess();
} catch (err: any) { } catch (err: any) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -56,139 +75,155 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
}; };
return ( return (
<div className="flex h-screen w-full overflow-hidden font-sans bg-white"> <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
{/* Nửa bên trái: Hình ảnh decor (Đồng bộ với LandingPage) */} <div className="max-w-md w-full space-y-8 bg-white p-10 rounded-3xl shadow-2xl">
<div className="hidden lg:block lg:w-1/2 relative bg-blue-900"> <button
<img onClick={onBack}
src="https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop" className="absolute top-6 left-6 text-gray-400 hover:text-gray-600 transition-colors"
alt="Travel background" >
className="absolute inset-0 h-full w-full object-cover opacity-50" <ChevronLeft className="w-6 h-6" />
/> </button>
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/40 to-transparent" />
<div className="absolute top-12 left-12 flex items-center gap-2 text-white z-10">
<Compass className="w-8 h-8" />
<span className="text-2xl font-black uppercase tracking-tighter">Travel Planner</span>
</div>
<div className="absolute bottom-20 left-12 text-white z-10 max-w-md">
<h2 className="text-4xl font-bold mb-4">Bắt đu hành trình của riêng bạn.</h2>
<p className="text-blue-100 opacity-80">Tạo tài khoản đ lưu lại những kế hoạch du lịch tuyệt vời nhất cùng bạn .</p>
</div>
</div>
{/* Nửa bên phải: Form đăng ký */} <div className="mb-10">
<div className="flex-1 flex flex-col justify-center px-8 sm:px-16 lg:px-24 py-12 overflow-y-auto"> <h1 className="text-3xl font-extrabold text-gray-900">
<div className="max-w-md w-full mx-auto"> {step === 'form' ? 'Tạo tài khoản mới' : 'Xác thực tài khoản'}
<button onClick={onBack} className="flex items-center text-gray-400 hover:text-blue-600 mb-8 transition-colors font-medium"> </h1>
<ChevronLeft className="w-5 h-5" /> Quay lại <p className="text-gray-500 mt-2 font-medium">
</button> {step === 'form'
? 'Khám phá các tính năng lập kế hoạch chuyên nghiệp.'
: `Vui lòng nhập mã OTP đã được gửi tới ${formData.email}`}
</p>
</div>
<div className="mb-10"> {error && (
<h1 className="text-3xl font-extrabold text-gray-900">Tạo tài khoản mới</h1> <div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
<p className="text-gray-500 mt-2 font-medium">Khám phá các tính năng lập kế hoạch chuyên nghiệp.</p> {error}
</div> </div>
)}
{error && ( <form className="space-y-5" onSubmit={handleSubmit}>
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1"> {step === 'form' ? (
{error} <>
</div> <div className="space-y-1.5">
)} <label className="text-sm font-bold text-gray-700 ml-1">Họ tên</label>
<div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="text"
placeholder="Nhập họ và tên của bạn"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<form className="space-y-5" onSubmit={handleSubmit}> <div className="space-y-1.5">
<div className="space-y-1.5"> <label className="text-sm font-bold text-gray-700 ml-1">Email</label>
<label className="text-sm font-bold text-gray-700 ml-1">Họ tên</label> <div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="email"
placeholder="Nhập email của bạn"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="password"
placeholder="Nhập mật khẩu"
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận mật khẩu</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="password"
placeholder="Xác nhận mật khẩu"
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
<div className="relative group">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="tel"
placeholder="Nhập số điện thoại"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Đa chỉ</label>
<div className="relative group">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="text"
placeholder="Nhập địa chỉ"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
</div>
</>
) : (
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700 ml-1"> xác thực OTP</label>
<div className="relative group"> <div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" /> <ShieldCheck className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input <input
required required
type="text" type="text"
placeholder="Nguyễn Văn A" maxLength={6}
value={formData.name} placeholder="Nhập 6 số mã OTP"
onChange={(e) => setFormData({ ...formData, name: e.target.value })} value={otp}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all" onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl text-center font-bold tracking-[0.5em] text-lg focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/> />
</div> </div>
</div> </div>
)}
<div className="space-y-1.5"> <button
<label className="text-sm font-bold text-gray-700 ml-1">Email</label> disabled={isLoading}
<div className="relative group"> type="submit"
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" /> className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 rounded-2xl shadow-xl shadow-blue-100 transition-all active:scale-[0.98] disabled:opacity-50 mt-4"
<input >
required {isLoading ? 'Đang xử lý...' : step === 'form' ? 'Tạo tài khoản' : 'Xác nhận kích hoạt'}
type="email" {!isLoading && <ArrowRight className="w-5 h-5" />}
placeholder="email@example.com" </button>
value={formData.email} </form>
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
<div className="relative group">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="tel"
placeholder="0912 345 678"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Đa chỉ</label>
<div className="relative group">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="text"
placeholder="Quận 1, TP.HCM"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
<input
required
type="password"
placeholder="••••••••"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận</label>
<input
required
type="password"
placeholder="••••••••"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<button
disabled={isLoading}
type="submit"
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 rounded-2xl shadow-xl shadow-blue-100 transition-all active:scale-[0.98] disabled:opacity-50 mt-4"
>
{isLoading ? 'Đang xử lý...' : 'Tạo tài khoản'}
{!isLoading && <ArrowRight className="w-5 h-5" />}
</button>
</form>
</div>
</div> </div>
</div> </div>
); );
}; };
export default SignupPage;
+10
View File
@@ -37,6 +37,7 @@
"cache-manager": "^7.2.8", "cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5", "cache-manager-redis-yet": "^5.1.5",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"nodemailer": "^9.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.12.0", "pg": "^8.12.0",
@@ -6471,6 +6472,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nodemailer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",