Sửa lỗi OWNER, MANAGER không thể chia sẻ link
This commit is contained in:
@@ -165,7 +165,7 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
|
||||
);
|
||||
};
|
||||
|
||||
export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBack: () => void, tourId: string, isPublicView?: boolean }) => {
|
||||
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
||||
const currentTour = useTourStore(state => state.currentTour);
|
||||
const legs = useTourStore(state => state.legs);
|
||||
@@ -195,6 +195,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action
|
||||
|
||||
// Hàm tối ưu để cập nhật số lượng bình luận mà không cần fetch lại toàn bộ Tour
|
||||
const handleCommentIncrement = (locationId: string) => {
|
||||
@@ -235,17 +236,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
});
|
||||
|
||||
// SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi
|
||||
|
||||
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||
const canInvite = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||
const isOwner = userRole === 'OWNER';
|
||||
// Nếu là public view, không có quyền chỉnh sửa
|
||||
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||
const canInvite = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||
const isOwner = isPublicView ? false : userRole === 'OWNER';
|
||||
const canShare = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
||||
if (isPublicView) {
|
||||
fetchPublicTourDetails(tourId);
|
||||
} else if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
||||
fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([]));
|
||||
}
|
||||
}, [currentTour, userRole]);
|
||||
|
||||
// Fetch tour details when tourId changes or public view status changes
|
||||
if (tourId) { isPublicView ? fetchPublicTourDetails(tourId) : fetchTour(tourId); }
|
||||
}, [tourId, isPublicView, userRole]); // Add tourId to dependencies
|
||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||
|
||||
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
||||
@@ -255,14 +260,14 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
if (initialViewState) {
|
||||
setMapCenter(initialViewState.center);
|
||||
}
|
||||
const loadData = async () => {
|
||||
// Nếu chưa có tour nào trong store, thử tải danh sách public trước
|
||||
if (publicTours.length === 0) {
|
||||
await fetchPublicTours();
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
// This useEffect is for initial load, but we now have tourId prop
|
||||
// The fetching logic is moved to the useEffect above that depends on tourId and isPublicView
|
||||
// So this useEffect can be simplified or removed if its only purpose was initial data load.
|
||||
// if (publicTours.length === 0 && !isPublicView) { // Only fetch public tours if not in public view and not already loaded
|
||||
// fetchPublicTours();
|
||||
// }
|
||||
}, [initialViewState]); // Removed publicTours, currentTour, fetchPublicTours, fetchTour from dependencies
|
||||
|
||||
|
||||
const handleShare = () => {
|
||||
if (!currentTour) return;
|
||||
@@ -273,10 +278,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
title: currentTour.title,
|
||||
url: shareUrl,
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
} else if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success');
|
||||
});
|
||||
} else {
|
||||
// Giải pháp dự phòng cho môi trường không có HTTPS (truy cập qua IP)
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = shareUrl;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success');
|
||||
} catch (err) {}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -298,12 +314,12 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
return () => { socket.disconnect(); };
|
||||
}, [currentTour?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo
|
||||
if (publicTours.length > 0 && !currentTour) {
|
||||
fetchTour(publicTours[0].id);
|
||||
}
|
||||
}, [publicTours, currentTour, fetchTour]);
|
||||
// This useEffect is for initial demo loading, might not be needed if tourId is always passed
|
||||
// useEffect(() => {
|
||||
// if (publicTours.length > 0 && !currentTour && !isPublicView) {
|
||||
// fetchTour(publicTours[0].id);
|
||||
// }
|
||||
// }, [publicTours, currentTour, fetchTour, isPublicView]);
|
||||
|
||||
|
||||
|
||||
@@ -471,18 +487,20 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
|
||||
<h1 className="text-lg font-bold text-gray-800 truncate px-4 flex-1 text-center">
|
||||
{tourInfo.title}
|
||||
</h1>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors"
|
||||
title="Chia sẻ tour"
|
||||
>
|
||||
<Share2 className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Nút chia sẻ: Chỉ dành cho OWNER, MANAGER, MEMBER */}
|
||||
{canShare && (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors"
|
||||
title="Chia sẻ tour"
|
||||
>
|
||||
<Share2 className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tour Header Info */}
|
||||
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
|
||||
<img
|
||||
@@ -528,7 +546,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
|
||||
{/* Member Avatars Stack */}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2"> {/* Always show participants */}
|
||||
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
|
||||
<button
|
||||
key={p.userId || i}
|
||||
@@ -542,7 +560,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
|
||||
</button>
|
||||
))}
|
||||
{isOwner && joinRequests.slice(0, 3).map((req: any) => (
|
||||
{isOwner && !isPublicView && joinRequests.slice(0, 3).map((req: any) => ( // Hide join requests in public view
|
||||
<div key={req.id} className="relative group">
|
||||
<div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg">
|
||||
{req.user?.name?.charAt(0) || '?'}
|
||||
@@ -615,18 +633,20 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!currentTour) return;
|
||||
if (canInvite) setIsAddMemberOpen(true);
|
||||
}}
|
||||
disabled={!canInvite}
|
||||
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
|
||||
canInvite ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
{!isPublicView && ( // Hide add member button in public view
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!currentTour) return;
|
||||
if (canInvite) setIsAddMemberOpen(true);
|
||||
}}
|
||||
disabled={!canInvite}
|
||||
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
|
||||
canInvite ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -634,7 +654,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
|
||||
{/* Financial Quick-View Widget or Quote */}
|
||||
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
|
||||
<div
|
||||
<div // Always show quote if public view, otherwise show financial widget if has access
|
||||
onClick={() => hasFinanceAccess && setActiveTab('expense')}
|
||||
className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200 cursor-pointer hover:scale-[1.02] active:scale-95' : 'bg-white text-gray-600 border border-gray-100'}`}
|
||||
>
|
||||
@@ -647,7 +667,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
</div>
|
||||
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
|
||||
</>
|
||||
) : (
|
||||
) : ( // If no finance access or is public view, show quote
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" />
|
||||
<p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p>
|
||||
@@ -691,7 +711,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
|
||||
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||
@@ -735,7 +755,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
setTargetLegId(loc.legId);
|
||||
setMapCenter([loc.latitude, loc.longitude]);
|
||||
setIsAddLocationOpen(true);
|
||||
}} />
|
||||
}} isPublicView={isPublicView} />
|
||||
) : (
|
||||
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
||||
<MapContainer
|
||||
@@ -745,7 +765,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
preferCanvas={true}
|
||||
>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
{canEdit && <MapContextMenu onAction={handleMapAction} />}
|
||||
{canEdit && !isPublicView && <MapContextMenu onAction={handleMapAction} />} {/* Hide map context menu in public view */}
|
||||
|
||||
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
||||
<MapTourBounds locations={allLocations} />
|
||||
@@ -793,7 +813,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
<div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white">
|
||||
Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải để ghim địa điểm
|
||||
{isPublicView ? 'Xem chi tiết lộ trình' : 'Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải để ghim địa điểm'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -821,7 +841,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
{activeTab === 'settings' && !isPublicView && ( // Hide settings tab in public view
|
||||
<div className="space-y-4">
|
||||
<div className="p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -999,7 +1019,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
</div>
|
||||
|
||||
{/* Floating Action Button (Mobile) */}
|
||||
{canEdit && (
|
||||
{canEdit && !isPublicView && ( // Hide floating action button in public view
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -1024,6 +1044,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
onRemoveMember={(userId) => removeMember(currentTour.id, userId)}
|
||||
onMemberAdded={() => fetchTour(currentTour.id)}
|
||||
userRole={userRole || undefined}
|
||||
isPublicView={isPublicView} // Pass isPublicView
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1035,6 +1056,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
initialLegId={targetLegId || undefined}
|
||||
editingLocation={editingLocation}
|
||||
tourId={currentTour.id}
|
||||
isPublicView={isPublicView} // Pass isPublicView
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1111,6 +1133,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
onClose={() => setIsCommentModalOpen(false)}
|
||||
locationId={commentLocationId}
|
||||
locationName={commentLocationName}
|
||||
isPublicView={isPublicView} // Pass isPublicView
|
||||
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user