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:
2026-06-20 20:59:01 +07:00
parent 52706cab7d
commit 36157bd53b
18 changed files with 1419 additions and 188 deletions
+62 -12
View File
@@ -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 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>
)}
+184
View File
@@ -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ử 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 đ thể join 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 tài khoản mới
</button>
</div>
</div>
)}
</div>
<LoginModal
isOpen={isLoginOpen}
onClose={() => setIsLoginOpen(false)}
onSwitchToSignup={onGoToSignup}
onLoginSuccess={handleSuccessLogin}
/>
</div>
);
};
+79 -1
View File
@@ -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>
);
+60
View File
@@ -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>