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:
Vendored
+34
@@ -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) {
|
async getTourDetails(id) {
|
||||||
const tour = await this.prisma.tour.findUnique({
|
const tour = await this.prisma.tour.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -646,6 +673,13 @@ __decorate([
|
|||||||
__metadata("design:paramtypes", [Object]),
|
__metadata("design:paramtypes", [Object]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], TourController.prototype, "getPublicTours", null);
|
], 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([
|
__decorate([
|
||||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard),
|
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, rbac_middleware_1.TourRoleGuard),
|
||||||
(0, common_1.Get)(':id'),
|
(0, common_1.Get)(':id'),
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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)
|
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async getTourDetails(@Param('id', ParseUUIDPipe) id: string) {
|
async getTourDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
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 { UserManagementModal } from '@/components/UserManagementModal';
|
||||||
|
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
||||||
import { CreateTourModal } from '../components/CreateTourModal';
|
import { CreateTourModal } from '../components/CreateTourModal';
|
||||||
|
|
||||||
// Fix lỗi icon mặc định của Leaflet
|
// Fix lỗi icon mặc định của Leaflet
|
||||||
@@ -27,6 +28,16 @@ function RecenterMap({ position }: { position: [number, number] }) {
|
|||||||
return null;
|
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 đồ
|
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
|
||||||
function MapTracker() {
|
function MapTracker() {
|
||||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
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 fetchTour = useTourStore(state => state.fetchTour);
|
||||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
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)
|
// 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 [initialViewState] = useState(() => {
|
||||||
const saved = localStorage.getItem('map_view_state');
|
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 [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = 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(() => {
|
useEffect(() => {
|
||||||
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
|
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
|
||||||
if (user || localStorage.getItem('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 đồ */}
|
{/* Theo dõi di chuyển bản đồ */}
|
||||||
<MapTracker />
|
<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 độ */}
|
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
||||||
<RecenterMap position={userPos} />
|
<RecenterMap position={userPos} />
|
||||||
|
|
||||||
@@ -169,7 +204,16 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
key={tour.id}
|
key={tour.id}
|
||||||
position={markerPos}
|
position={markerPos}
|
||||||
eventHandlers={{
|
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({
|
icon={L.divIcon({
|
||||||
className: 'custom-bubble',
|
className: 'custom-bubble',
|
||||||
@@ -203,6 +247,22 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
</MapContainer>
|
</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 */}
|
{/* Admin Modal */}
|
||||||
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
||||||
|
|
||||||
@@ -215,6 +275,14 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
onViewTour(tour.id);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -28,7 +28,8 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
MessageSquare
|
MessageSquare,
|
||||||
|
Share2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
@@ -263,6 +264,22 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
loadData();
|
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
|
// Thiết lập kết nối WebSocket Real-time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentTour) return;
|
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">
|
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
|
||||||
{tourInfo.title}
|
{tourInfo.title}
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Tour Header Info */}
|
{/* Tour Header Info */}
|
||||||
|
|||||||
Reference in New Issue
Block a user