Sửa lỗi OWNER, MANAGER không thể chia sẻ link
This commit is contained in:
Vendored
+4
@@ -357,6 +357,10 @@ let TourController = class TourController {
|
|||||||
},
|
},
|
||||||
take: 20,
|
take: 20,
|
||||||
include: {
|
include: {
|
||||||
|
participants: {
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
select: { role: true }
|
||||||
|
},
|
||||||
photos: { take: 1 },
|
photos: { take: 1 },
|
||||||
legs: {
|
legs: {
|
||||||
orderBy: { sequence: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -358,6 +358,10 @@ class TourController {
|
|||||||
},
|
},
|
||||||
take: 20,
|
take: 20,
|
||||||
include: {
|
include: {
|
||||||
|
participants: {
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
select: { role: true }
|
||||||
|
},
|
||||||
photos: { take: 1 },
|
photos: { take: 1 },
|
||||||
legs: {
|
legs: {
|
||||||
orderBy: { sequence: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, cen
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any }) => {
|
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isPublicView = false }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isPublicView?: boolean }) => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -147,7 +147,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 3. Early return phải nằm SAU tất cả các khai báo Hook
|
// 3. Early return phải nằm SAU tất cả các khai báo Hook
|
||||||
if (!isOpen) return null;
|
if (!isOpen || isPublicView) return null; // Do not render if public view
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ interface AddMemberModalProps {
|
|||||||
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
|
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
|
||||||
onRemoveMember?: (userId: string) => Promise<void>;
|
onRemoveMember?: (userId: string) => Promise<void>;
|
||||||
onMemberAdded?: () => void;
|
onMemberAdded?: () => void;
|
||||||
userRole?: string;
|
userRole?: string; // User's role in the tour
|
||||||
|
isPublicView?: boolean; // New prop to indicate public view
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
|
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
|
||||||
@@ -134,7 +135,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen || isPublicView) return null; // Do not render if public view
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ interface CommentModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
locationId: string;
|
locationId: string;
|
||||||
locationName: string;
|
locationName: string;
|
||||||
onCommentAdded?: () => void;
|
onCommentAdded?: () => void; // Callback to update comment count on parent
|
||||||
|
isPublicView?: boolean; // New prop to indicate public view
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName }) => {
|
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName }) => {
|
||||||
@@ -146,10 +147,10 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
|
|||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={(e) => setNewComment(e.target.value)}
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||||
placeholder="Viết bình luận..."
|
placeholder={isPublicView ? 'Đăng nhập để bình luận...' : 'Viết bình luận...'}
|
||||||
className="flex-1 bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
className="flex-1 bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
||||||
/>
|
/>
|
||||||
<button onClick={handleSend} disabled={!newComment.trim()} className="p-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 disabled:opacity-50 disabled:bg-gray-300 transition-all active:scale-95 shadow-lg shadow-blue-100">
|
<button onClick={handleSend} disabled={!newComment.trim() || isPublicView} className="p-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 disabled:opacity-50 disabled:bg-gray-300 transition-all active:scale-95 shadow-lg shadow-blue-100">
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ const formatTravelTime = (minutes: number) => {
|
|||||||
|
|
||||||
export const ItineraryTimeline = ({
|
export const ItineraryTimeline = ({
|
||||||
onAddLocation,
|
onAddLocation,
|
||||||
onEditLocation
|
onEditLocation,
|
||||||
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
|
isPublicView = false
|
||||||
|
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void, isPublicView?: boolean }) => {
|
||||||
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết
|
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết
|
||||||
const currentTour = useTourStore(state => state.currentTour);
|
const currentTour = useTourStore(state => state.currentTour);
|
||||||
const legs = useTourStore(state => state.legs);
|
const legs = useTourStore(state => state.legs);
|
||||||
@@ -48,11 +49,15 @@ export const ItineraryTimeline = ({
|
|||||||
const optimizeRouting = useTourStore(state => state.optimizeRouting);
|
const optimizeRouting = useTourStore(state => state.optimizeRouting);
|
||||||
const addLeg = useTourStore(state => state.addLeg);
|
const addLeg = useTourStore(state => state.addLeg);
|
||||||
const updateLeg = useTourStore(state => state.updateLeg);
|
const updateLeg = useTourStore(state => state.updateLeg);
|
||||||
|
// Removed: const { isPublicView } = useTourStore(state => state); // isPublicView is passed as a prop
|
||||||
const deleteLeg = useTourStore(state => state.deleteLeg);
|
const deleteLeg = useTourStore(state => state.deleteLeg);
|
||||||
const initializeLegs = useTourStore(state => state.initializeLegs);
|
const initializeLegs = useTourStore(state => state.initializeLegs);
|
||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
const deleteLocation = useTourStore(state => state.deleteLocation);
|
const deleteLocation = useTourStore(state => state.deleteLocation);
|
||||||
|
|
||||||
|
// Khai báo logic canEdit để sử dụng trong toàn bộ component
|
||||||
|
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
|
|
||||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||||
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
|
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
|
||||||
const [tempLegCount, setTempLegCount] = useState(3);
|
const [tempLegCount, setTempLegCount] = useState(3);
|
||||||
@@ -202,7 +207,7 @@ export const ItineraryTimeline = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAddLocation?.(leg.id)}
|
onClick={() => onAddLocation?.(leg.id)}
|
||||||
@@ -236,7 +241,7 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{totalDwellMinutes > 0 && (
|
{totalDwellMinutes > 0 && ( // Always show dwell time
|
||||||
<div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
|
<div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
Dừng: {formatTravelTime(totalDwellMinutes)}
|
Dừng: {formatTravelTime(totalDwellMinutes)}
|
||||||
@@ -251,7 +256,7 @@ export const ItineraryTimeline = ({
|
|||||||
<Zap className="w-3 h-3" />
|
<Zap className="w-3 h-3" />
|
||||||
Tối ưu
|
Tối ưu
|
||||||
</button>
|
</button>
|
||||||
)}
|
)} {/* Only show optimize button if canEdit */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vertical Line for the whole leg */}
|
{/* Vertical Line for the whole leg */}
|
||||||
@@ -365,7 +370,7 @@ export const ItineraryTimeline = ({
|
|||||||
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
|
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (
|
{canEdit && !isStartPoint && !isEndPoint && ( // Only show edit/delete if canEdit
|
||||||
<div className="flex gap-1 mt-2">
|
<div className="flex gap-1 mt-2">
|
||||||
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
|
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
@@ -409,7 +414,7 @@ export const ItineraryTimeline = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions at the bottom of the list */}
|
{/* Actions at the bottom of the list */}
|
||||||
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
{canEdit && ( // Only show these buttons if canEdit
|
||||||
<div className="flex flex-col gap-3 pb-20 mt-8">
|
<div className="flex flex-col gap-3 pb-20 mt-8">
|
||||||
<button
|
<button
|
||||||
onClick={handleDeclareLegs}
|
onClick={handleDeclareLegs}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
// State cho menu chuột phải chia sẻ
|
// State cho menu chuột phải chia sẻ
|
||||||
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string } | null>(null);
|
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean } | null>(null);
|
||||||
|
|
||||||
const handleShare = (id: string, title: string) => {
|
const handleShare = (id: string, title: string) => {
|
||||||
const shareUrl = `${window.location.origin}?viewTour=${id}`;
|
const shareUrl = `${window.location.origin}?viewTour=${id}`;
|
||||||
@@ -90,10 +90,21 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
text: `Khám phá hành trình du lịch: ${title}`,
|
text: `Khám phá hành trình du lịch: ${title}`,
|
||||||
url: shareUrl,
|
url: shareUrl,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
} else {
|
} else if (navigator.clipboard && window.isSecureContext) {
|
||||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||||
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!', 'success');
|
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!', 'success');
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Giải pháp dự phòng cho môi trường không có HTTPS
|
||||||
|
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 chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!', 'success');
|
||||||
|
} catch (err) {}
|
||||||
|
document.body.removeChild(textArea);
|
||||||
}
|
}
|
||||||
setShareMenu(null);
|
setShareMenu(null);
|
||||||
};
|
};
|
||||||
@@ -206,12 +217,17 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: () => onViewTour(tour.id),
|
click: () => onViewTour(tour.id),
|
||||||
contextmenu: (e) => {
|
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);
|
||||||
|
|
||||||
// Hiển thị menu tại vị trí chuột
|
// Hiển thị menu tại vị trí chuột
|
||||||
setShareMenu({
|
setShareMenu({
|
||||||
x: e.containerPoint.x,
|
x: e.containerPoint.x,
|
||||||
y: e.containerPoint.y,
|
y: e.containerPoint.y,
|
||||||
id: tour.id,
|
id: tour.id,
|
||||||
title: tour.title
|
title: tour.title,
|
||||||
|
canShare
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -254,12 +270,16 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
style={{ top: shareMenu.y, left: shareMenu.x }}
|
style={{ top: shareMenu.y, left: shareMenu.x }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
{shareMenu.canShare ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleShare(shareMenu.id, shareMenu.title)}
|
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"
|
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
|
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
|
||||||
</button>
|
</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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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 currentTour = useTourStore(state => state.currentTour);
|
||||||
const legs = useTourStore(state => state.legs);
|
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 [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||||
|
|
||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
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
|
// 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) => {
|
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
|
// SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi
|
||||||
|
// Nếu là public view, không có quyền chỉnh sửa
|
||||||
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
const canInvite = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
const canInvite = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||||
const isOwner = userRole === 'OWNER';
|
const isOwner = isPublicView ? false : userRole === 'OWNER';
|
||||||
|
const canShare = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||||
|
|
||||||
useEffect(() => {
|
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([]));
|
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);
|
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||||
|
|
||||||
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
// 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) {
|
if (initialViewState) {
|
||||||
setMapCenter(initialViewState.center);
|
setMapCenter(initialViewState.center);
|
||||||
}
|
}
|
||||||
const loadData = async () => {
|
// This useEffect is for initial load, but we now have tourId prop
|
||||||
// Nếu chưa có tour nào trong store, thử tải danh sách public trước
|
// The fetching logic is moved to the useEffect above that depends on tourId and isPublicView
|
||||||
if (publicTours.length === 0) {
|
// So this useEffect can be simplified or removed if its only purpose was initial data load.
|
||||||
await fetchPublicTours();
|
// if (publicTours.length === 0 && !isPublicView) { // Only fetch public tours if not in public view and not already loaded
|
||||||
}
|
// fetchPublicTours();
|
||||||
};
|
// }
|
||||||
loadData();
|
}, [initialViewState]); // Removed publicTours, currentTour, fetchPublicTours, fetchTour from dependencies
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleShare = () => {
|
const handleShare = () => {
|
||||||
if (!currentTour) return;
|
if (!currentTour) return;
|
||||||
@@ -273,10 +278,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
title: currentTour.title,
|
title: currentTour.title,
|
||||||
url: shareUrl,
|
url: shareUrl,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
} else {
|
} else if (navigator.clipboard && window.isSecureContext) {
|
||||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||||
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success');
|
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(); };
|
return () => { socket.disconnect(); };
|
||||||
}, [currentTour?.id]);
|
}, [currentTour?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
// This useEffect is for initial demo loading, might not be needed if tourId is always passed
|
||||||
// Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo
|
// useEffect(() => {
|
||||||
if (publicTours.length > 0 && !currentTour) {
|
// if (publicTours.length > 0 && !currentTour && !isPublicView) {
|
||||||
fetchTour(publicTours[0].id);
|
// fetchTour(publicTours[0].id);
|
||||||
}
|
// }
|
||||||
}, [publicTours, currentTour, fetchTour]);
|
// }, [publicTours, currentTour, fetchTour, isPublicView]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -471,9 +487,11 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||||
</button>
|
</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}
|
{tourInfo.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
{/* Nút chia sẻ: Chỉ dành cho OWNER, MANAGER, MEMBER */}
|
||||||
|
{canShare && (
|
||||||
<button
|
<button
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors"
|
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors"
|
||||||
@@ -481,8 +499,8 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
>
|
>
|
||||||
<Share2 className="w-5 h-5" />
|
<Share2 className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tour Header Info */}
|
{/* Tour Header Info */}
|
||||||
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
|
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
|
||||||
<img
|
<img
|
||||||
@@ -528,7 +546,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
|
|
||||||
{/* Member Avatars Stack */}
|
{/* Member Avatars Stack */}
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<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) => (
|
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
|
||||||
<button
|
<button
|
||||||
key={p.userId || i}
|
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" />
|
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
|
||||||
</button>
|
</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 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">
|
<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) || '?'}
|
{req.user?.name?.charAt(0) || '?'}
|
||||||
@@ -615,6 +633,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!isPublicView && ( // Hide add member button in public view
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!currentTour) return;
|
if (!currentTour) return;
|
||||||
@@ -627,6 +646,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -634,7 +654,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
|
|
||||||
{/* Financial Quick-View Widget or Quote */}
|
{/* Financial Quick-View Widget or Quote */}
|
||||||
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
|
<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')}
|
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'}`}
|
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>
|
||||||
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></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">
|
<div className="flex items-start gap-4 py-2">
|
||||||
<Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" />
|
<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>
|
<p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p>
|
||||||
@@ -691,7 +711,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id as any)}
|
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
|
activeTab === tab.id
|
||||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||||
@@ -735,7 +755,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
setTargetLegId(loc.legId);
|
setTargetLegId(loc.legId);
|
||||||
setMapCenter([loc.latitude, loc.longitude]);
|
setMapCenter([loc.latitude, loc.longitude]);
|
||||||
setIsAddLocationOpen(true);
|
setIsAddLocationOpen(true);
|
||||||
}} />
|
}} isPublicView={isPublicView} />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
||||||
<MapContainer
|
<MapContainer
|
||||||
@@ -745,7 +765,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
preferCanvas={true}
|
preferCanvas={true}
|
||||||
>
|
>
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
<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 */}
|
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
||||||
<MapTourBounds locations={allLocations} />
|
<MapTourBounds locations={allLocations} />
|
||||||
@@ -793,7 +813,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
</MapContainer>
|
</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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -821,7 +841,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && !isPublicView && ( // Hide settings tab in public view
|
||||||
<div className="space-y-4">
|
<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="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">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
@@ -999,7 +1019,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Action Button (Mobile) */}
|
{/* 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">
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1024,6 +1044,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
onRemoveMember={(userId) => removeMember(currentTour.id, userId)}
|
onRemoveMember={(userId) => removeMember(currentTour.id, userId)}
|
||||||
onMemberAdded={() => fetchTour(currentTour.id)}
|
onMemberAdded={() => fetchTour(currentTour.id)}
|
||||||
userRole={userRole || undefined}
|
userRole={userRole || undefined}
|
||||||
|
isPublicView={isPublicView} // Pass isPublicView
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1035,6 +1056,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
initialLegId={targetLegId || undefined}
|
initialLegId={targetLegId || undefined}
|
||||||
editingLocation={editingLocation}
|
editingLocation={editingLocation}
|
||||||
tourId={currentTour.id}
|
tourId={currentTour.id}
|
||||||
|
isPublicView={isPublicView} // Pass isPublicView
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1111,6 +1133,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
onClose={() => setIsCommentModalOpen(false)}
|
onClose={() => setIsCommentModalOpen(false)}
|
||||||
locationId={commentLocationId}
|
locationId={commentLocationId}
|
||||||
locationName={commentLocationName}
|
locationName={commentLocationName}
|
||||||
|
isPublicView={isPublicView} // Pass isPublicView
|
||||||
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user