Files
travelplanning/frontend/src/components/JoinTourLoginModal.tsx
T

330 lines
12 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2, LogIn } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
interface JoinTourLoginModalProps {
isOpen: boolean;
onClose: () => void;
inviteToken: string;
onJoinSuccess?: (user: any, tourData: any) => void;
onSwitchToSignup?: () => void;
}
export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
isOpen,
onClose,
inviteToken,
onJoinSuccess,
onSwitchToSignup
}) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const notify = useNotification();
// Ref to store the latest inviteToken to avoid stale closure issue
const inviteTokenRef = useRef(inviteToken);
useEffect(() => {
inviteTokenRef.current = inviteToken;
}, [inviteToken]);
const handleGoogleLogin = async (googleResponse: any) => {
setError('');
setIsLoading(true);
try {
const response = await fetch(`/api/v1/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: googleResponse.credential }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập Google thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Use the latest inviteToken from ref
let currentToken = inviteTokenRef.current;
console.log('[JoinTourLogin] Attempting to join with token:', currentToken?.substring(0, 10) + '...');
// Also check pendingInviteToken as fallback
let pendingToken = localStorage.getItem('pendingInviteToken');
// If no token found, try to get from URL
if (!currentToken && !pendingToken) {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
if (urlToken) {
currentToken = urlToken;
localStorage.setItem('pendingInviteToken', urlToken);
console.log('[JoinTourLogin] Using token from URL params:', urlToken.substring(0, 10) + '...');
}
}
const tokenToUse = currentToken || pendingToken;
if (!tokenToUse) {
console.error('[JoinTourLogin] No invite token available');
throw new Error('Không có mã lời mời để tham gia tour');
}
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: tokenToUse }),
});
console.log('[JoinTourLogin] Join response status:', joinRes.status);
const joinData = await joinRes.json().catch(() => ({}));
if (joinRes.ok) {
console.log('[JoinTourLogin] Join successful');
notify({
title: 'Thành công',
message: joinData.message || 'Bạn đã gia nhập tour!',
type: 'success'
});
localStorage.removeItem('pendingInviteToken');
if (onJoinSuccess) {
onJoinSuccess(data.user, joinData);
}
onClose();
} else {
// Token invalid or expired - clear it and show error
console.log('[JoinTourLogin] Token invalid, clearing from storage');
localStorage.removeItem('pendingInviteToken');
// Email mismatch or other error
const errorMessage = joinData.message || `Không thể gia nhập tour (HTTP ${joinRes.status})`;
console.error('[JoinTourLogin] Join failed:', errorMessage);
setError(`Lỗi gia nhập tour: ${errorMessage}`);
setIsLoading(false);
}
} catch (e: any) {
console.error('[JoinTourLogin] Join exception:', e);
setError(`Lỗi khi gia nhập: ${e.message}`);
setIsLoading(false);
}
} catch (err: any) {
console.error('[JoinTourLogin] Google login error:', err);
setError(err.message);
setIsLoading(false);
}
};
const handleEmailPasswordJoin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const response = await fetch(`/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Use the latest inviteToken from ref
const tokenToUse = inviteTokenRef.current || localStorage.getItem('pendingInviteToken');
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: tokenToUse }),
});
const joinData = await joinRes.json().catch(() => ({}));
if (joinRes.ok) {
notify({
title: 'Thành công',
message: joinData.message || 'Bạn đã gia nhập tour!',
type: 'success'
});
localStorage.removeItem('pendingInviteToken');
if (onJoinSuccess) {
onJoinSuccess(data.user, joinData);
}
onClose();
} else {
const errorMessage = joinData.message || 'Không thể gia nhập tour';
setError(`Lỗi gia nhập tour: ${errorMessage}`);
setIsLoading(false);
}
} catch (e: any) {
setError(`Lỗi khi gia nhập: ${e.message}`);
setIsLoading(false);
}
} catch (err: any) {
console.error('[JoinTourLogin] Login error:', err);
setError(err.message);
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).google) {
try {
(window as any).google.accounts.id.initialize({
client_id: (import.meta as any).env.VITE_GOOGLE_CLIENT_ID || '864264639911-dummyid.apps.googleusercontent.com',
callback: handleGoogleLogin,
});
(window as any).google.accounts.id.renderButton(
document.getElementById('google-signin-btn-join-tour'),
{ theme: 'outline', size: 'large', width: '380' }
);
} catch (e) {
console.error('Lỗi khởi tạo Google Sign-in:', e);
}
}
}, 100);
return () => clearTimeout(timer);
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/50">
<div className="w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="relative h-32 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 overflow-hidden">
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_50%,rgba(255,255,255,.3)_0%,transparent_50%)]" />
</div>
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 p-2 hover:bg-white/20 rounded-full transition-colors"
>
<X className="w-5 h-5 text-white" />
</button>
<div className="absolute inset-0 flex items-center justify-center">
<LogIn className="w-12 h-12 text-white opacity-80" />
</div>
</div>
{/* Content */}
<div className="p-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2 text-center">
Gia nhập tour
</h2>
<p className="text-center text-gray-600 mb-6">
Đăng nhập để tham gia chuyến du lịch này
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{/* Google OAuth Button */}
<div className="mb-6 flex justify-center">
<div id="google-signin-btn-join-tour" className="w-full" />
</div>
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Hoặc</span>
</div>
</div>
{/* Email/Password Form */}
<form onSubmit={handleEmailPasswordJoin} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Mật khẩu
</label>
<div className="relative">
<Lock className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Nhập mật khẩu"
required
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-blue-500 to-purple-500 text-white font-semibold py-3 rounded-lg hover:shadow-lg transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Đang gia nhập...
</>
) : (
<>
<ArrowRight className="w-5 h-5" />
Gia nhập tour
</>
)}
</button>
</form>
{/* Signup Link */}
<div className="mt-6 text-center text-sm text-gray-600">
Chưa tài khoản?{' '}
<button
onClick={() => {
onClose();
if (onSwitchToSignup) onSwitchToSignup();
}}
className="font-semibold text-blue-500 hover:text-blue-600 transition"
>
Đăng tại đây
</button>
</div>
</div>
</div>
</div>
);
};