bugs: lỗi khi người dùng link để tham gia tour nhưng không xuất hiện trong danh sách thành viên
This commit is contained in:
@@ -5,7 +5,7 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2 } from 'lucide-react';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock } from 'lucide-react';
|
||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { CreateTourModal } from '../components/CreateTourModal';
|
||||
@@ -174,7 +174,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
}, [publicTours]);
|
||||
|
||||
// State cho menu chuột phải chia sẻ
|
||||
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean } | null>(null);
|
||||
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean, isParticipant: boolean, hasPendingRequest: boolean } | null>(null);
|
||||
|
||||
const handleShare = (id: string, title: string) => {
|
||||
const shareUrl = `${window.location.origin}?viewTour=${id}`;
|
||||
@@ -211,6 +211,35 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
setShareMenu(null);
|
||||
};
|
||||
|
||||
const handleRequestJoin = async (tourId: string) => {
|
||||
setShareMenu(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || 'Gửi yêu cầu tham gia thất bại.');
|
||||
}
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã gửi yêu cầu tham gia tour. Vui lòng chờ chủ tour duyệt.',
|
||||
type: 'success'
|
||||
});
|
||||
fetchPublicTours();
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message || 'Không thể gửi yêu cầu tham gia.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (s: any) => {
|
||||
if (s.type === 'tour') {
|
||||
onViewTour(s.id);
|
||||
@@ -471,9 +500,12 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
eventHandlers={{
|
||||
click: () => onViewTour(tour.id),
|
||||
contextmenu: (e) => {
|
||||
// Kiểm tra quyền chia sẻ (OWNER, MANAGER, MEMBER)
|
||||
const role = tour.participants?.[0]?.role;
|
||||
const canShare = ['OWNER', 'MANAGER', 'MEMBER'].includes(role);
|
||||
const currentUserId = user?.id;
|
||||
const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId);
|
||||
const isParticipant = !!myParticipant;
|
||||
const myRole = myParticipant?.role;
|
||||
const canShare = isParticipant && ['OWNER', 'MANAGER', 'MEMBER'].includes(myRole);
|
||||
const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0;
|
||||
|
||||
// Hiển thị menu tại vị trí chuột
|
||||
setShareMenu({
|
||||
@@ -481,7 +513,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
y: e.containerPoint.y,
|
||||
id: tour.id,
|
||||
title: tour.title,
|
||||
canShare
|
||||
canShare,
|
||||
isParticipant,
|
||||
hasPendingRequest
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -575,15 +609,31 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
style={{ top: shareMenu.y, left: shareMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{shareMenu.canShare ? (
|
||||
<button
|
||||
onClick={() => handleShare(shareMenu.id, shareMenu.title)}
|
||||
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2 transition-colors"
|
||||
{shareMenu.isParticipant ? (
|
||||
shareMenu.canShare ? (
|
||||
<button
|
||||
onClick={() => handleShare(shareMenu.id, shareMenu.title)}
|
||||
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-4 py-2 text-xs text-gray-400 italic font-bold">Bạn đã gia nhập tour này</div>
|
||||
)
|
||||
) : shareMenu.hasPendingRequest ? (
|
||||
<button
|
||||
disabled
|
||||
className="w-full text-left px-4 py-2 text-sm font-bold text-gray-400 flex items-center gap-2 cursor-not-allowed bg-gray-50/50"
|
||||
>
|
||||
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
|
||||
<Clock className="w-4 h-4 text-gray-400" /> Đang chờ duyệt...
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-4 py-2 text-xs text-gray-400 italic">Bạn không có quyền chia sẻ tour này</div>
|
||||
<button
|
||||
onClick={() => handleRequestJoin(shareMenu.id)}
|
||||
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-blue-700 flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 text-blue-600" /> Yêu cầu tham gia Tour
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Compass, Loader2, LogIn, UserPlus, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { LoginModal } from '../components/LoginModal';
|
||||
|
||||
interface JoinTourPageProps {
|
||||
onLoginSuccess: (user: any) => void;
|
||||
onGoToSignup: () => void;
|
||||
onViewTour: (tourId: string) => void;
|
||||
onGoToHome: () => void;
|
||||
}
|
||||
|
||||
export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGoToSignup, onViewTour, onGoToHome }) => {
|
||||
const [inviteToken, setInviteToken] = useState<string | null>(null);
|
||||
const [isLoginOpen, setIsLoginOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
const [tourId, setTourId] = useState<string | null>(null);
|
||||
|
||||
// Guard chống React StrictMode chạy effect 2 lần trong dev mode
|
||||
const hasJoinedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get('token');
|
||||
setInviteToken(token);
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem('pendingInviteToken', token);
|
||||
|
||||
// Nếu đã đăng nhập, tự động thực hiện join
|
||||
const systemToken = localStorage.getItem('token');
|
||||
if (systemToken && !hasJoinedRef.current) {
|
||||
hasJoinedRef.current = true;
|
||||
handleJoinTour(token, systemToken);
|
||||
}
|
||||
} else {
|
||||
setError('Mã lời mời không tồn tại hoặc không hợp lệ.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleJoinTour = async (token: string, authToken: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/v1/tours/join-by-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authToken}`
|
||||
},
|
||||
body: JSON.stringify({ token })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || 'Không thể gia nhập tour.');
|
||||
}
|
||||
|
||||
setSuccessMsg(data.message || 'Bạn đã tham gia tour thành công!');
|
||||
setTourId(data.tourId);
|
||||
localStorage.removeItem('pendingInviteToken');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Đã xảy ra lỗi.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccessLogin = (user: any) => {
|
||||
onLoginSuccess(user);
|
||||
const token = localStorage.getItem('token');
|
||||
const pendingToken = localStorage.getItem('pendingInviteToken') || inviteToken;
|
||||
if (token && pendingToken) {
|
||||
handleJoinTour(pendingToken, token);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoggedIn = !!localStorage.getItem('token');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8 font-sans">
|
||||
<div className="max-w-md w-full space-y-8 bg-white p-10 rounded-3xl shadow-2xl text-center border border-gray-100">
|
||||
|
||||
{/* Logo/Icon */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-blue-50 rounded-2xl flex items-center justify-center text-blue-600 shadow-md">
|
||||
<Compass className="w-10 h-10 animate-spin-slow" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4 py-6">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto" />
|
||||
<h2 className="text-xl font-bold text-gray-900">Đang xử lý tham gia hành trình...</h2>
|
||||
<p className="text-sm text-gray-500">Vui lòng đợi trong giây lát.</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="w-12 h-12 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto">
|
||||
<AlertCircle className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Gia nhập thất bại</h2>
|
||||
<p className="text-sm text-red-600 bg-red-50 p-4 rounded-2xl font-semibold border border-red-100">{error}</p>
|
||||
<div className="pt-4 flex flex-col gap-2">
|
||||
{isLoggedIn ? (
|
||||
<button
|
||||
onClick={onGoToHome}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3.5 rounded-2xl transition-all shadow-lg"
|
||||
>
|
||||
Về trang khám phá
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsLoginOpen(true)}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3.5 rounded-2xl transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<LogIn className="w-5 h-5" /> Thử đăng nhập lại
|
||||
</button>
|
||||
<button
|
||||
onClick={onGoToHome}
|
||||
className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold py-3.5 rounded-2xl transition-all"
|
||||
>
|
||||
Về trang chủ
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : successMsg ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="w-12 h-12 bg-green-50 text-green-500 rounded-full flex items-center justify-center mx-auto">
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Thành công!</h2>
|
||||
<p className="text-sm text-green-700 bg-green-50 p-4 rounded-2xl font-semibold border border-green-100">{successMsg}</p>
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (tourId) onViewTour(tourId);
|
||||
else onGoToHome();
|
||||
}}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3.5 rounded-2xl transition-all shadow-lg"
|
||||
>
|
||||
Xem chi tiết Tour
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-black text-gray-900 tracking-tight">Chào mừng bạn!</h2>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
Bạn nhận được một lời mời tham gia hành trình du lịch. Vui lòng đăng nhập hoặc tạo tài khoản để có thể join và xem các hoạt động, chi phí của tour.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 pt-4">
|
||||
<button
|
||||
onClick={() => setIsLoginOpen(true)}
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg transition-all active:scale-[0.98]"
|
||||
>
|
||||
<LogIn className="w-5 h-5" /> Đăng nhập hệ thống
|
||||
</button>
|
||||
<button
|
||||
onClick={onGoToSignup}
|
||||
className="w-full flex items-center justify-center gap-2 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-4 rounded-2xl border border-gray-200 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<UserPlus className="w-5 h-5" /> Đăng ký tài khoản mới
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<LoginModal
|
||||
isOpen={isLoginOpen}
|
||||
onClose={() => setIsLoginOpen(false)}
|
||||
onSwitchToSignup={onGoToSignup}
|
||||
onLoginSuccess={handleSuccessLogin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { User, Mail, Lock, ArrowRight, ChevronLeft, Phone, MapPin, ShieldCheck } from 'lucide-react';
|
||||
|
||||
interface SignupPageProps {
|
||||
@@ -20,6 +20,71 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
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 ký bằng Google thất bại');
|
||||
}
|
||||
|
||||
localStorage.removeItem('guest_token');
|
||||
localStorage.removeItem('guest_user');
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
// Tự động gia nhập tour nếu có pendingInviteToken
|
||||
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
|
||||
if (pendingInviteToken) {
|
||||
await fetch(`/api/v1/tours/join-by-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${data.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ token: pendingInviteToken }),
|
||||
}).catch((e) => console.error('Lỗi tự động gia nhập:', e));
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'form') 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-signup'),
|
||||
{ theme: 'outline', size: 'large', width: '380' }
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Lỗi khởi tạo Google Sign-in:', e);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [step]);
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
@@ -230,6 +295,19 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
{!isLoading && <ArrowRight className="w-5 h-5" />}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{step === 'form' && (
|
||||
<>
|
||||
<div className="relative my-6 flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200"></div>
|
||||
</div>
|
||||
<span className="relative px-3 bg-white text-xs font-bold text-gray-400 uppercase">Hoặc</span>
|
||||
</div>
|
||||
|
||||
<div id="google-signin-btn-signup" className="w-full flex justify-center"></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -844,6 +844,27 @@ export const TourDetailPage = ({
|
||||
const canUploadPhoto = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE'].includes(userRole || '');
|
||||
const isOwner = isPublicView ? false : userRole === 'OWNER';
|
||||
const canShare = isPublicView || ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||
const canManage = isOwner || (!isPublicView && userRole === 'MANAGER');
|
||||
|
||||
const duplicateMatches = useMemo(() => {
|
||||
if (!canManage || !currentTour?.participants) return [];
|
||||
|
||||
const manualMembers = currentTour.participants.filter((p: any) => !p.userId && p.displayName);
|
||||
const systemMembers = currentTour.participants.filter((p: any) => p.userId && p.user?.name);
|
||||
|
||||
const matches: Array<{ manual: any; system: any }> = [];
|
||||
|
||||
manualMembers.forEach((m: any) => {
|
||||
const match = systemMembers.find((s: any) => {
|
||||
return s.user.name.trim().toLowerCase() === m.displayName.trim().toLowerCase();
|
||||
});
|
||||
if (match) {
|
||||
matches.push({ manual: m, system: match });
|
||||
}
|
||||
});
|
||||
|
||||
return matches;
|
||||
}, [canManage, currentTour?.participants]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPublicView) {
|
||||
@@ -1609,6 +1630,45 @@ export const TourDetailPage = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{duplicateMatches.map((match) => (
|
||||
<div key={match.manual.id} className="mt-3 p-3 bg-amber-500/20 backdrop-blur-md border border-amber-500/30 rounded-2xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-xs text-amber-100 animate-in fade-in slide-in-from-top-1 shadow-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-400 animate-pulse shrink-0"></span>
|
||||
<span>
|
||||
Phát hiện thành viên ngoài hệ thống <strong>"{match.manual.displayName}"</strong> trùng tên với tài khoản <strong>"{match.system.user.name}"</strong> vừa tham gia.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/tours/${currentTour.id}/members/merge`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
manualParticipantId: match.manual.id,
|
||||
systemUserId: match.system.userId
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || 'Hợp nhất thất bại.');
|
||||
}
|
||||
notify({ title: 'Thành công', message: 'Đã gán thành viên ngoài hệ thống thành công!', type: 'success' });
|
||||
fetchTour(currentTour.id);
|
||||
} catch (e: any) {
|
||||
notify({ title: 'Lỗi', message: e.message || 'Không thể hợp nhất', type: 'error' });
|
||||
}
|
||||
}}
|
||||
className="px-3.5 py-2 bg-amber-500 hover:bg-amber-600 active:scale-95 text-white font-black rounded-xl transition-all shrink-0 shadow-md text-[11px]"
|
||||
>
|
||||
Gán & Hợp nhất
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user