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) {
|
||||
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'),
|
||||
|
||||
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)
|
||||
@Get(':id')
|
||||
async getTourDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user