Thêm tính năng click chuột phải vào bong bóng để lấy link chia sẻ

This commit is contained in:
2026-06-16 08:46:45 +07:00
parent b939fc959d
commit 02c493f7e0
5 changed files with 159 additions and 5 deletions
+34
View File
@@ -370,6 +370,33 @@ let TourController = class TourController {
}
});
}
async getPublicTourDetails(id) {
const tour = await this.prisma.tour.findUnique({
where: { id },
include: {
participants: {
include: {
user: {
select: { id: true, name: true, email: true }
}
}
},
photos: true,
legs: {
orderBy: { sequence: 'asc' },
include: {
locations: {
orderBy: { plannedStart: 'asc' },
include: { _count: { select: { comments: true } } }
},
},
},
},
});
if (!tour)
throw new common_1.NotFoundException(`Không tìm thấy Tour`);
return tour;
}
async getTourDetails(id) {
const tour = await this.prisma.tour.findUnique({
where: { id },
@@ -646,6 +673,13 @@ __decorate([
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], TourController.prototype, "getPublicTours", null);
__decorate([
(0, common_1.Get)(':id/public'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String]),
__metadata("design:returntype", Promise)
], TourController.prototype, "getPublicTourDetails", null);
__decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard),
(0, common_1.Get)(':id'),
+1 -1
View File
File diff suppressed because one or more lines are too long
+29
View File
@@ -372,6 +372,35 @@ class TourController {
});
}
@Get(':id/public')
async getPublicTourDetails(@Param('id', ParseUUIDPipe) id: string) {
const tour = await this.prisma.tour.findUnique({
where: { id },
include: {
participants: {
include: {
user: {
select: { id: true, name: true, email: true }
}
}
},
photos: true,
legs: {
orderBy: { sequence: 'asc' },
include: {
locations: {
orderBy: { plannedStart: 'asc' },
include: { _count: { select: { comments: true } } }
},
},
},
},
});
if (!tour) throw new NotFoundException(`Không tìm thấy Tour`);
return tour;
}
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Get(':id')
async getTourDetails(@Param('id', ParseUUIDPipe) id: string) {
+70 -2
View File
@@ -5,8 +5,9 @@ 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, Edit2, Trash2 } from 'lucide-react';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2 } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal';
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
import { CreateTourModal } from '../components/CreateTourModal';
// Fix lỗi icon mặc định của Leaflet
@@ -27,6 +28,16 @@ function RecenterMap({ position }: { position: [number, number] }) {
return null;
}
// Component Helper để đóng menu khi tương tác với bản đồ
function MapEvents({ onMapAction }: { onMapAction: () => void }) {
useMapEvents({
click: onMapAction,
movestart: onMapAction,
dragstart: onMapAction,
});
return null;
}
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
function MapTracker() {
const setMapCenter = useTourStore(state => state.setMapCenter);
@@ -52,6 +63,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
const fetchTour = useTourStore(state => state.fetchTour);
const setMapCenter = useTourStore(state => state.setMapCenter);
const notificationModal = useNotificationModal();
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
const [initialViewState] = useState(() => {
const saved = localStorage.getItem('map_view_state');
@@ -66,6 +79,25 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
// State cho menu chuột phải chia sẻ
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string } | null>(null);
const handleShare = (id: string, title: string) => {
const shareUrl = `${window.location.origin}?viewTour=${id}`;
if (navigator.share) {
navigator.share({
title: title,
text: `Khám phá hành trình du lịch: ${title}`,
url: shareUrl,
}).catch(() => {});
} else {
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');
});
}
setShareMenu(null);
};
useEffect(() => {
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
if (user || localStorage.getItem('token')) {
@@ -153,6 +185,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
{/* Theo dõi di chuyển bản đồ */}
<MapTracker />
{/* Đóng menu khi tương tác bản đồ */}
<MapEvents onMapAction={() => setShareMenu(null)} />
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
<RecenterMap position={userPos} />
@@ -169,7 +204,16 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
key={tour.id}
position={markerPos}
eventHandlers={{
click: () => onViewTour(tour.id)
click: () => onViewTour(tour.id),
contextmenu: (e) => {
// Hiển thị menu tại vị trí chuột
setShareMenu({
x: e.containerPoint.x,
y: e.containerPoint.y,
id: tour.id,
title: tour.title
});
}
}}
icon={L.divIcon({
className: 'custom-bubble',
@@ -203,6 +247,22 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
</MarkerClusterGroup>
</MapContainer>
{/* Context Menu Chia sẻ */}
{shareMenu && (
<div
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
style={{ top: shareMenu.y, left: shareMenu.x }}
onClick={(e) => e.stopPropagation()}
>
<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>
)}
{/* Admin Modal */}
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
@@ -215,6 +275,14 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
onViewTour(tour.id);
}}
/>
<NotificationModal
isOpen={notificationModal.modalState?.isOpen ?? false}
title={notificationModal.modalState?.title}
message={notificationModal.modalState?.message}
type={notificationModal.modalState?.type}
onConfirm={() => notificationModal.closeModal()}
/>
</div>
);
};
+25 -2
View File
@@ -28,7 +28,8 @@ import {
Clock,
Check,
X,
MessageSquare
MessageSquare,
Share2
} from 'lucide-react';
import L from 'leaflet';
@@ -263,6 +264,22 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
loadData();
}, []);
const handleShare = () => {
if (!currentTour) return;
// Tạo link với query param ?viewTour=...
const shareUrl = `${window.location.origin}?viewTour=${currentTour.id}`;
if (navigator.share) {
navigator.share({
title: currentTour.title,
url: shareUrl,
}).catch(() => {});
} else {
navigator.clipboard.writeText(shareUrl).then(() => {
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success');
});
}
};
// Thiết lập kết nối WebSocket Real-time
useEffect(() => {
if (!currentTour) return;
@@ -457,7 +474,13 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
{tourInfo.title}
</h1>
<div className="w-10" /> {/* Spacer */}
<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 */}