41 Commits

Author SHA1 Message Date
3dtours 6026c2227a fix: UI của trang MyPhotoPage lỗi 2026-06-16 21:25:08 +07:00
3dtours 1eed9b00de feat: xem ảnh theo cấu trúc của lightroom 2026-06-16 20:38:40 +07:00
3dtours 1709638bfd optimize: xác nhận redis đã nén dữ liệu 2026-06-16 20:22:53 +07:00
3dtours 37b5d14d7e optimize: sử dụng redis để cache cho các response từ server 2026-06-16 20:17:17 +07:00
3dtours 2fabdb79df fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:55 +07:00
3dtours 00554224a1 fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:24 +07:00
3dtours 88b2182789 feat: thêm chức năng upload ảnh 2026-06-16 17:14:22 +07:00
3dtours 989d60643b feat: thêm nút tìm kiếm địa điểm cho phần tạo tour, phần thêm địa điểm 2026-06-16 15:45:26 +07:00
3dtours 1772ced959 feat: nút lọc filter tags dropdown 2026-06-16 15:35:45 +07:00
3dtours c005009da2 feat: Chuyển khám phá địa điểm thành box tìm kiếm địa điểm trên bản đồ 2026-06-16 15:28:10 +07:00
3dtours bfd18e05dd feat: tạo hook useNotification.tsx để dùng chung toàn hệ thống 2026-06-16 12:41:00 +07:00
3dtours 29d39ae7b0 feat: tạo hook useConfirm.tsx để dùng chung toàn hệ thống 2026-06-16 12:29:09 +07:00
3dtours 047170d4be feat: cho phép người dùng và người quản lí xóa bình luận 2026-06-16 12:21:44 +07:00
3dtours fc96ee9eb8 fix: tìm kiếm địa điểm với cách gõ tên trên bản đồ 2026-06-16 12:15:01 +07:00
3dtours 0a584a13c7 feat: tìm kiếm địa điểm trên bản đồ 2026-06-16 11:37:21 +07:00
3dtours f78c72ad60 fix: hiển thị mô tả và tên của hành trình có thêm tags 2026-06-16 11:21:43 +07:00
3dtours 8cca4a83ca fix: tags không có trong csdl gây nên lỗi crash backend 2026-06-16 11:15:39 +07:00
3dtours bc49e081c6 feat: cho phép lấy thời gian và địa điểm hiện tại để gán cho điểm 2026-06-16 10:55:18 +07:00
3dtours 9e26aabce6 Sửa lỗi hiển thị chi phí ở điểm của chặng 2026-06-16 10:36:38 +07:00
3dtours 3e1a5db1a6 Sửa lỗi hiển thị bảng liệt kê chi phí 2026-06-16 10:28:33 +07:00
3dtours baa83aad8a Sửa lỗi người nhận được line xem được nội dung Tour nhưng không xem được bình luận 2026-06-16 09:56:59 +07:00
3dtours 302a82e887 Sửa lỗi người nhận được liên kết chia sẻ không xem được nội dung Tour 2026-06-16 09:54:32 +07:00
3dtours 63da413b3c Sửa lỗi OWNER, MANAGER không thể chia sẻ link 2026-06-16 09:33:15 +07:00
3dtours 02c493f7e0 Thêm tính năng click chuột phải vào bong bóng để lấy link chia sẻ 2026-06-16 08:46:45 +07:00
3dtours b939fc959d Thêm tính năng hiển thị tooltip trên bong bóng Tour 2026-06-16 08:35:48 +07:00
3dtours 5464d90948 Thêm tính năng bình luận ở mỗi điểm của chặng 2026-06-16 08:27:54 +07:00
3dtours c5530f36df feat: cập nhật giao diện tour hiển thị mô tả trên banner 2026-06-15 21:28:41 +07:00
3dtours dcaf71032c Cài đặt tính năng thay đổi tên và mô tả tour 2026-06-15 21:22:05 +07:00
3dtours aec9112064 Cài đặt tính năng người quản lí cập nhật số người tham gia 2026-06-15 20:55:36 +07:00
3dtours 8dac79fc4b Cài đặt tính năng người quản lí có thể khai báo số lượng người tham gia 2026-06-15 20:52:43 +07:00
3dtours 75cf96898c Cài đặt tính năng in bảng thống kê ra pdf 2026-06-15 20:43:32 +07:00
3dtours f52580717f Cài đặt tính năng hiển thị chi phí 2026-06-15 20:20:34 +07:00
3dtours 879f20985c Chỉnh sửa ngày của hành trình là ngày của chặng đầu tiên và chặng hoàn thành 2026-06-15 19:57:54 +07:00
3dtours 4edfd0eb59 Sửa lỗi chỉnh sửa tiêu đề chặng và ngày giờ của chặng 2026-06-15 19:53:58 +07:00
3dtours 55f0a8e775 Sửa lỗi không thể khai báo số lượng chặng 2026-06-15 19:42:45 +07:00
3dtours 7812d1395b Sửa lỗi thành viên có thể gửi lời mời các users khác 2026-06-15 19:38:07 +07:00
3dtours c9ef98b2ff Sửa lỗi không truy cập IP từ ngoài 2026-06-15 19:31:03 +07:00
3dtours 71f4aa9ecd Sửa lỗi frontend không load dược danh sách thêm thành viên 2026-06-15 18:27:54 +07:00
3dtours 34e2af8825 Sửa lỗi frontend không kết nối với backend 2026-06-15 18:25:37 +07:00
3dtours 4716b841dc Sửa lỗi tái cấu trúc thư mục và khai báo import 2026-06-15 16:31:28 +07:00
3dtours 967f6b4f6a Phục hồi lại và chuẩn bị tái cấu trúc thư mục 2026-06-15 15:00:37 +07:00
93 changed files with 9739 additions and 4061 deletions
-91
View File
@@ -1,91 +0,0 @@
import React, { useState, useEffect } from 'react';
import { LandingPage } from './LandingPage.js';
import { TourDetailPage } from './TourDetailPage.js';
import { ExploreMap } from './ExploreMap.js';
import { SignupPage } from './SignupPage.js';
import { useTourStore } from './useTourStore.js';
const App = () => {
type View = 'landing' | 'explore' | 'detail' | 'signup';
const [view, setView] = useState<View>('landing');
const [isInitialSetup, setIsInitialSetup] = useState(false);
const [user, setUser] = useState<any>(null);
const [isUserLoaded, setIsUserLoaded] = useState(false); // Trạng thái để biết user đã được load từ localStorage chưa
const fetchTour = useTourStore(state => state.fetchTour);
useEffect(() => {
// Tự động xác định địa chỉ IP của Backend dựa trên hostname hiện tại
const API_BASE = `http://${window.location.hostname}:3001`;
// Khôi phục phiên đăng nhập từ localStorage
const savedUser = localStorage.getItem('user');
if (savedUser) {
const parsedUser = JSON.parse(savedUser);
setUser(parsedUser);
}
setIsUserLoaded(true); // Đánh dấu user đã được load
// Kiểm tra xem hệ thống đã được cài đặt chưa
fetch(`${API_BASE}/api/v1/auth/status`)
.then(res => res.ok ? res.json() : Promise.reject())
.then(data => setIsInitialSetup(!!data.isInitialSetup))
.catch(() => setIsInitialSetup(false));
}, []); // Chạy một lần khi component mount
// Effect để xử lý chuyển hướng nếu user đã đăng nhập và đang ở trang landing
useEffect(() => {
if (isUserLoaded && user && view === 'landing') {
setView('explore');
}
}, [isUserLoaded, user, view]);
const handleLoginSuccess = (userData: any) => {
setUser(userData);
};
const handleLogout = () => {
localStorage.removeItem('user');
localStorage.removeItem('token');
setUser(null);
setView('landing');
};
return (
<div className="app-container">
{view === 'landing' && (
<LandingPage
isInitialSetup={isInitialSetup}
onContinue={() => setView('explore')}
onGoToSignup={() => setView('signup')}
onGoToMap={() => setView('explore')}
onLoginSuccess={handleLoginSuccess}
/>
)}
{view === 'signup' && (
<SignupPage
onBack={() => setView('landing')}
onSuccess={() => setView('landing')}
/>
)}
{view === 'explore' && (
<ExploreMap
onBack={() => setView('landing')}
onLogout={user ? handleLogout : undefined}
user={user}
onViewTour={(id) => {
fetchTour(id);
setView('detail');
}}
/>
)}
{view === 'detail' && (
<TourDetailPage onBack={() => setView('explore')} />
)}
</div>
);
};
export default App;
-42
View File
@@ -1,42 +0,0 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
interface ConfirmModalProps {
isOpen: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
isOpen,
title = 'Xác nhận',
message,
confirmText = 'Xác nhận',
cancelText = 'Hủy',
onConfirm,
onCancel,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[2200] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={onCancel} />
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
<h3 className="text-base font-bold text-gray-900">{title}</h3>
<p className="mt-2 text-sm text-gray-600">{message}</p>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onCancel} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
{cancelText}
</button>
<button onClick={onConfirm} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
{confirmText}
</button>
</div>
</div>
</div>
);
};
-77
View File
@@ -1,77 +0,0 @@
import React, { useState, useMemo } from 'react';
import { Wallet, Users, Info } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
export const ExpenseManager = () => {
const { legs } = useTourStore();
const [adults, setAdults] = useState(2);
const [children, setChildren] = useState(1);
const [discount, setDiscount] = useState(30);
const totals = useMemo(() => {
const totalAmount = legs.reduce((acc, leg) =>
acc + leg.expenses.reduce((lAcc: number, exp: any) => lAcc + Number(exp.amount), 0), 0
);
const childRateFactor = 1 - (discount / 100);
const weightedCount = adults + (children * childRateFactor);
const adultPrice = totalAmount / weightedCount;
const childPrice = adultPrice * childRateFactor;
return {
total: totalAmount,
adultPrice: Math.round(adultPrice),
childPrice: Math.round(childPrice)
};
}, [legs, adults, children, discount]);
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
<div className="flex items-center gap-2 mb-6 text-blue-600">
<Users className="w-5 h-5" />
<h3 className="font-bold">Cấu hình thành viên</h3>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Người lớn</label>
<input type="number" value={adults} onChange={e => setAdults(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Trẻ em</label>
<input type="number" value={children} onChange={e => setChildren(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Giảm trẻ em (%)</label>
<input type="number" value={discount} onChange={e => setDiscount(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
<div className="bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200">
<div className="flex justify-between items-start mb-8">
<div>
<p className="text-blue-100 text-sm">Tổng chi phí chuyến đi</p>
<h2 className="text-3xl font-bold mt-1">{totals.total.toLocaleString()} VND</h2>
</div>
<Wallet className="w-8 h-8 opacity-20" />
</div>
<div className="grid grid-cols-2 gap-4 border-t border-blue-500 pt-6">
<div>
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi người lớn</p>
<p className="text-xl font-bold">{totals.adultPrice.toLocaleString()}đ</p>
</div>
<div>
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi trẻ em (-{discount}%)</p>
<p className="text-xl font-bold">{totals.childPrice.toLocaleString()}đ</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 text-gray-400 text-xs px-2">
<Info className="w-4 h-4" />
<p>Chi phí đưc tự đng tính toán dựa trên hóa đơn của các chặng.</p>
</div>
</div>
);
};
-204
View File
@@ -1,204 +0,0 @@
import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTourStore } from './useTourStore.js';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2 } from 'lucide-react';
import { UserManagementModal } from './UserManagementModal.js';
import { CreateTourModal } from './CreateTourModal.js';
// Fix lỗi icon mặc định của Leaflet
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(position, map.getZoom());
}, [position, map]);
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);
useMapEvents({
moveend: (e) => {
const map = e.target;
const center = map.getCenter();
const zoom = map.getZoom();
const coords: [number, number] = [center.lat, center.lng];
setMapCenter(coords);
// Lưu vị trí và mức zoom vào localStorage để sử dụng cho lần sau
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
},
});
return null;
}
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => {
// Thêm fetchTour vào destructuring từ store
const { publicTours, fetchPublicTours, fetchTour, setMapCenter } = useTourStore();
// 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');
if (saved) {
try { return JSON.parse(saved); } catch (e) { return null; }
}
return null;
});
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
const [mapZoom] = useState(initialViewState?.zoom || 13);
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
useEffect(() => {
fetchPublicTours();
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
if (!initialViewState) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserPos(posArray);
setMapCenter(posArray);
},
() => console.log("Không thể lấy vị trí người dùng")
);
} else {
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
setMapCenter(initialViewState.center);
}
}, []);
return (
<div className="h-screen w-full relative">
{/* Nút quay lại */}
<button
onClick={onBack}
className="absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all"
>
<X className="w-6 h-6 text-gray-800" />
</button>
{/* Nút đăng xuất - Chỉ hiển thị khi có user login */}
{onLogout && (
<button
onClick={onLogout}
className="absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700"
>
<LogOut className="w-5 h-5" />
<span className="hidden sm:inline">Đăng xuất</span>
</button>
)}
{/* Nút quản lý người dùng cho Admin */}
{user?.isAdmin && (
<button
onClick={() => setIsAdminModalOpen(true)}
className="absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold"
>
<Settings className="w-5 h-5" />
<span className="hidden sm:inline">Quản hệ thống</span>
</button>
)}
{/* Nút tạo Tour mới */}
{user && (
<button
onClick={() => setIsCreateModalOpen(true)}
className="absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold"
>
<Navigation className="w-5 h-5" />
<span className="hidden sm:inline">Tạo Tour mới</span>
</button>
)}
{/* Header Overlay */}
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
<div className="flex items-center gap-2">
<Navigation className="w-4 h-4 text-blue-600" />
<span className="font-bold text-gray-800">Đang khám phá khu vực của bạn</span>
</div>
</div>
<MapContainer
center={userPos}
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; OpenStreetMap contributors'
/>
{/* Theo dõi di chuyển bản đồ */}
<MapTracker />
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
<RecenterMap position={userPos} />
<MarkerClusterGroup chunkedLoading>
{publicTours.map((tour) => {
const startLoc = tour.legs?.[0]?.locations?.[0];
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
const markerPos = startLoc
? [startLoc.latitude, startLoc.longitude] as [number, number]
: userPos;
return (
<React.Fragment key={tour.id}>
<Marker
position={markerPos}
eventHandlers={{
click: () => onViewTour(tour.id)
}}
icon={L.divIcon({
className: 'custom-bubble',
html: `
<div class="relative group">
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${tourImage}" class="w-full h-full object-cover" />
</div>
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
S
</div>
</div>
`,
iconSize: [48, 48],
iconAnchor: [24, 24]
})}
/>
</React.Fragment>
);
})}
</MarkerClusterGroup>
</MapContainer>
{/* Admin Modal */}
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
{/* Create Tour Modal */}
<CreateTourModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={(tour) => {
fetchTour(tour.id);
onViewTour(tour.id);
}}
/>
</div>
);
};
-894
View File
@@ -1,894 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { ItineraryTimeline } from './ItineraryTimeline.js';
import { ExpenseManager } from './ExpenseManager.js';
import { useTourStore } from './useTourStore.js';
import { AddLocationModal } from './AddLocationModal.js';
import { AddMemberModal } from './AddMemberModal.js';
import { ConfirmModal } from './ConfirmModal.js';
import { NotificationModal, useNotificationModal } from './components/NotificationModal.js';
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
import { useMap } from 'react-leaflet';
import {
Map as MapIcon,
Wallet,
Image as ImageIcon,
Calendar,
Users,
ChevronLeft,
Settings,
Quote,
Plus,
List,
Map as MapIconLucide,
MapPin,
Flag,
Clock,
Check,
X
} from 'lucide-react';
import L from 'leaflet';
// Định nghĩa kiểu dữ liệu cho Địa điểm để khớp với Schema Prisma
type LocationType = 'MOVE' | 'VISIT' | 'REST' | 'EAT';
// Fix lỗi icon mặc định của Leaflet cho môi trường Vite
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
// Định nghĩa các static icons để ngăn chặn việc khởi tạo lại liên tục gây crash khi unmount
const START_ICON = L.divIcon({
className: 'custom-marker-s',
html: `<div class="w-6 h-6 bg-blue-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">S</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const END_ICON = L.divIcon({
className: 'custom-marker-e',
html: `<div class="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">E</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const VISIT_ICON = L.divIcon({
className: 'custom-marker-v',
html: `<div class="w-4 h-4 bg-indigo-500 rounded-full border-2 border-white shadow-md"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
// Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình
const MapTourBounds = ({ locations }: { locations: any[] }) => {
const map = useMap();
// Tạo một key dựa trên giá trị tọa độ để tránh chạy lại khi chỉ thay đổi tham chiếu mảng
const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]);
useEffect(() => {
if (locations.length > 0) {
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
if (locations.length === 1) {
// Chỉ thực hiện nếu bản đồ chưa ở đúng vị trí (tránh trigger moveend liên tục)
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
} else {
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
}
}
}, [locKey, map]);
return null;
};
// Menu ngữ cảnh cho bản đồ
const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => {
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
// Sử dụng selector để tránh re-render khi mapCenter thay đổi
const legs = useTourStore(state => state.legs);
const setMapCenter = useTourStore(state => state.setMapCenter);
useMapEvents({
contextmenu: (e) => {
// Ngăn menu mặc định của trình duyệt hiện lên.
if (e.originalEvent) {
L.DomEvent.preventDefault(e.originalEvent);
L.DomEvent.stopPropagation(e.originalEvent);
}
console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`);
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
},
moveend: (e) => {
const map = e.target;
const center = map.getCenter();
const zoom = map.getZoom();
const coords: [number, number] = [center.lat, center.lng];
// Chỉ cập nhật store nếu tọa độ thay đổi đáng kể (> 0.0001) để tránh loop
const currentStored = useTourStore.getState().mapCenter;
const diff = Math.abs(currentStored[0] - coords[0]) + Math.abs(currentStored[1] - coords[1]);
if (diff > 0.0001) {
setMapCenter(coords);
}
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
},
click: () => setMenuPos(null),
dragstart: () => setMenuPos(null),
});
// Ngăn chặn các sự kiện của bản đồ khi tương tác với menu
useEffect(() => {
if (menuPos && menuRef.current) {
L.DomEvent.disableClickPropagation(menuRef.current);
L.DomEvent.disableScrollPropagation(menuRef.current);
}
}, [menuPos]);
if (!menuPos) return null;
return (
<div
ref={menuRef}
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: menuPos.y, left: menuPos.x }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<button onClick={() => { onAction('START', menuPos.latlng); setMenuPos(null); }} 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">
<div className="w-2 h-2 rounded-full bg-blue-600" /> Bắt đu từ đây
</button>
<button onClick={() => { onAction('END', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-green-50 text-sm font-bold text-gray-700 flex items-center gap-2 border-b border-gray-50">
<div className="w-2 h-2 rounded-full bg-green-600" /> Kết thúc đây
</button>
<div className="px-4 py-2 text-[10px] font-black text-gray-400 uppercase tracking-widest">Thêm vào chặng</div>
{legs.map(leg => (
<button
key={leg.id}
onClick={() => { onAction(`ADD_TO_LEG_${leg.id}`, menuPos.latlng); setMenuPos(null); }}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-medium text-gray-600 truncate"
>
Chặng {leg.sequence}: {leg.note || 'Không có ghi chú'}
</button>
))}
</div>
);
};
export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [targetLegId, setTargetLegId] = useState<string | null>(null);
const [editingLocation, setEditingLocation] = useState<any>(null);
const [selectedMember, setSelectedMember] = useState<any>(null);
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
const [joinRequests, setJoinRequests] = useState<any[]>([]);
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
const {
currentTour, legs, fetchTour, fetchPublicTours, publicTours,
userRole, mapCenter, setMapCenter, updateTourStartPoint,
updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember,
fetchJoinRequests, acceptJoinRequest, rejectJoinRequest
} = useTourStore();
const notificationModal = useNotificationModal();
// Khôi phục vị trí và mức zoom từ localStorage
const [initialViewState] = useState(() => {
const saved = localStorage.getItem('map_view_state');
if (saved) {
try { return JSON.parse(saved); } catch (e) { return null; }
}
return null;
});
// 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 || '');
useEffect(() => {
if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([]));
}
}, [currentTour, userRole]);
const [mapZoom] = useState(initialViewState?.zoom || 13);
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
useEffect(() => {
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();
}, []);
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]);
// Hàm xử lý khai báo số chặng
const handleDeclareLegs = async () => {
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
const count = parseInt(countStr || "0");
if (count > 0 && currentTour) {
await initializeLegs(currentTour.id, count);
}
};
// Hàm xử lý các hành động từ Context Menu của bản đồ
const handleMapAction = async (action: string, latlng: L.LatLng) => {
// Bước 1: Lấy tọa độ (lat, lng) tại vị trí click (đã nhận qua tham số latlng)
console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng });
// Đảm bảo có tour và ít nhất một chặng để ghim
const currentLegs = useTourStore.getState().legs;
if (!currentTour || currentLegs.length === 0) {
alert("Tour chưa có chặng nào. Vui lòng tạo chặng (Leg) trước khi thực hiện.");
return;
}
let targetLegId = currentLegs[0].id; // Mặc định là chặng đầu
let defaultName = "Địa điểm mới";
let locationType: LocationType = 'VISIT'; // Mặc định là tham quan
if (action === 'START') {
defaultName = "Điểm bắt đầu";
locationType = 'MOVE'; // Điểm bắt đầu thường liên quan đến di chuyển
}
if (action === 'END') {
targetLegId = currentLegs[currentLegs.length - 1].id;
defaultName = "Điểm kết thúc";
locationType = 'MOVE'; // Điểm kết thúc cũng liên quan đến di chuyển
}
if (action.startsWith('ADD_TO_LEG_')) {
targetLegId = action.replace('ADD_TO_LEG_', '');
// Loại địa điểm mặc định vẫn là VISIT nếu thêm vào chặng cụ thể
}
// Bước 2: Gửi request đến dịch vụ bản đồ để phân tích tọa độ thành tên địa điểm cụ thể
let detectedName = "";
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
const data = await res.json();
const addr = data.address;
// Ưu tiên lấy tên Location/Tòa nhà/Tên đường, bỏ qua Tỉnh/Thành phố nếu có thông tin chi tiết hơn
detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
addr.shop || addr.office || addr.leisure || addr.attraction ||
addr.road || addr.neighbourhood || addr.suburb ||
data.display_name?.split(',')[0] || "";
console.log(`[FRONTEND] Geocoding Result: "${detectedName}"`);
} catch (e) {
console.warn("[FRONTEND] Reverse Geocoding failed:", e);
}
try {
if (action === 'START') {
// Bước 3: Mutate State & UI - Đặt startLocationName = resolvedPlaceName
const resolvedPlaceName = detectedName || "Điểm xuất phát";
console.log(`[FRONTEND] Updating START point to: ${resolvedPlaceName} at`, latlng);
await updateTourStartPoint(currentTour.id, {
name: resolvedPlaceName,
latitude: latlng.lat,
longitude: latlng.lng,
});
console.log("[FRONTEND] START point updated successfully.");
// Giao diện Top Banner và Ghim màu xanh sẽ tự động cập nhật
// khi store fetch lại dữ liệu tour và re-render.
} else if (action === 'END') {
// Bước 2: Thiết lập Điểm kết thúc
const finalName = detectedName || "Điểm kết thúc";
await updateTourEndPoint(currentTour.id, {
name: finalName,
latitude: latlng.lat,
longitude: latlng.lng,
});
} else {
// Đối với việc thêm địa điểm vào chặng, vẫn sử dụng Prompt để người dùng đặt tên theo ý muốn
const name = window.prompt("Xác nhận tên địa điểm tham quan:", detectedName || defaultName);
if (!name) return;
await addLocation(currentTour.id, {
name,
address: '',
latitude: latlng.lat,
longitude: latlng.lng,
legId: targetLegId,
type: locationType as any,
});
}
} catch (error: any) {
// Xử lý lỗi từ API (Ví dụ: Tour chưa có chặng nào)
if (error.message.includes('Không tìm thấy chặng')) {
alert("Lỗi: Bạn cần tạo ít nhất một Chặng (Leg) trước khi xác định điểm Bắt đầu/Kết thúc.");
} else {
alert("Đã xảy ra lỗi: " + error.message);
}
}
};
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
const startPoint = legs[0]?.locations[0];
const lastLeg = legs[legs.length - 1];
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1];
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
const tabs = [
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
].filter(t => t.visible);
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
const travelQuotes = [
"Đừng nghe họ nói, hãy tự mình đi xem.",
"Thế giới là một cuốn sách, và ai không đi du lịch thì chỉ mới đọc được một trang.",
"Hành trình ngàn dặm bắt đầu từ một bước chân.",
"Đi là để trở về, nhưng với một tâm hồn mới."
];
const randomQuote = useMemo(() => travelQuotes[Math.floor(Math.random() * travelQuotes.length)], []);
const tourInfo = {
title: currentTour?.title || "Hành trình khám phá TP.HCM",
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
membersCount: currentTour?.participants?.length || 0,
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
};
return (
<div className="min-h-screen bg-gray-50 pb-20">
{/* Top Navigation Bar */}
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between">
<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">
{tourInfo.title}
</h1>
<div className="w-10" /> {/* Spacer */}
</div>
{/* Tour Header Info */}
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
<img
src={tourInfo.coverImage}
className="absolute inset-0 w-full h-full object-cover opacity-60"
alt="Tour Cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
<div className="relative z-10 p-6 text-white pt-28 pb-20">
<div className="max-w-2xl mx-auto space-y-4">
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
{/* Dòng tóm tắt Lộ trình */}
<div className="mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full">
<span className="text-white/60 mr-1">Lộ trình:</span>
<span className="text-blue-300">Điểm xuất phát:</span>
<span className="ml-1 text-white banner-location-text" title={startPoint?.name}>{startPoint?.name || '...'}</span>
<span className="mx-2 text-white/30">-</span>
<span className="text-green-300">Điểm kết thúc:</span>
<span className="ml-1 text-white banner-location-text" title={endPoint?.name}>{endPoint?.name || '...'}</span>
</div>
<div className="flex flex-wrap gap-4 text-sm font-medium opacity-90">
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
<Calendar className="w-4 h-4 mr-1.5" />
{tourInfo.date}
</div>
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
<Users className="w-4 h-4 mr-1.5" />
{tourInfo.membersCount} thành viên
</div>
</div>
{/* Member Avatars Stack */}
<div className="flex items-center gap-2 mt-4">
<div className="flex flex-wrap gap-2">
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
<button
key={p.userId || i}
onClick={() => {
setSelectedMember(p);
setIsMemberDetailOpen(true);
}}
className="w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg hover:scale-110 transition-transform"
title={p.user?.name || p.userId}
>
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
</button>
))}
{joinRequests.slice(0, 3).map((req: any) => (
<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) || '?'}
</div>
<div className="absolute -top-1 -right-1 flex">
<button
type="button"
disabled={joinRequestActionId === req.id}
onClick={async (e) => {
e.stopPropagation();
if (!currentTour) return;
setConfirmState({
open: true,
title: 'Chấp nhận yêu cầu',
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
});
}}
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
aria-label="Accept"
>
+
</button>
<button
type="button"
disabled={joinRequestActionId === req.id}
onClick={async (e) => {
e.stopPropagation();
if (!currentTour) return;
setConfirmState({
open: true,
title: 'Từ chối yêu cầu',
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
});
}}
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
aria-label="Reject"
>
x
</button>
</div>
</div>
))}
{tourInfo.membersCount > 5 && (
<div className="w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg">
+{tourInfo.membersCount - 5}
</div>
)}
</div>
<button
onClick={() => {
if (!currentTour) return;
if (canEdit) setIsAddMemberOpen(true);
}}
disabled={!canEdit}
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
canEdit ? '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>
</div>
{/* Financial Quick-View Widget or Quote */}
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
<div className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`}>
<div className="flex justify-between items-center">
{hasFinanceAccess ? (
<>
<div>
<p className="text-indigo-100 text-xs font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại</p>
<h3 className="text-3xl font-black">{tourInfo.budget}</h3>
</div>
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
</>
) : (
<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>
</div>
)}
</div>
</div>
</div>
{/* Khối hiển thị Điểm đầu & Điểm cuối (Dưới Financial Quick-View) */}
<div className="max-w-2xl mx-auto mt-4 px-4 grid grid-cols-1 sm:grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-500">
{startPoint && (
<div className="bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner">
<MapPin className="w-5 h-5" />
</div>
<div className="overflow-hidden">
<p className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5">Điểm xuất phát</p>
<p className="text-sm font-bold text-gray-800 truncate">{startPoint.name}</p>
</div>
</div>
)}
{endPoint && (
<div className="bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner">
<Flag className="w-5 h-5" />
</div>
<div className="overflow-hidden">
<p className="text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5">Điểm kết thúc</p>
<p className="text-sm font-bold text-gray-800 truncate">{endPoint.name}</p>
</div>
</div>
)}
</div>
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`}>
{/* Tab Switcher */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20">
{tabs.map((tab) => (
<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 ${
activeTab === tab.id
? 'bg-blue-50 text-blue-600 shadow-sm'
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
}`}
>
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}`} />
{tab.label}
</button>
))}
</div>
{/* Tab Panels */}
<div className="transition-opacity duration-300">
{activeTab === 'plan' && (
<div className="animate-in fade-in slide-in-from-bottom-2">
{/* View Mode Toggle */}
<div className="flex justify-center mb-6">
<div className="bg-gray-100 p-1 rounded-2xl flex gap-1">
<button
onClick={() => setViewMode('timeline')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
>
<List className="w-3.5 h-3.5" /> Danh sách
</button>
<button
onClick={() => setViewMode('map')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
>
<MapIconLucide className="w-3.5 h-3.5" /> Bản đ
</button>
</div>
</div>
{viewMode === 'timeline' ? (
<ItineraryTimeline onAddLocation={(legId) => {
setTargetLegId(legId);
setEditingLocation(null);
setIsAddLocationOpen(true);
}} onEditLocation={(loc) => {
setEditingLocation(loc);
setTargetLegId(loc.legId);
setMapCenter([loc.latitude, loc.longitude]);
setIsAddLocationOpen(true);
}} />
) : (
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
<MapContainer
center={initialViewState?.center || mapCenter}
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
{canEdit && <MapContextMenu onAction={handleMapAction} />}
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
<MapTourBounds locations={allLocations} />
{/* Vẽ đường Polyline nối các điểm - Liên tục toàn bộ lộ trình xuyên suốt các chặng */}
{allLocations.length > 1 && (
<Polyline
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
color="#3b82f6"
weight={3}
dashArray="5, 10"
smoothFactor={1.5}
/>
)}
<MarkerClusterGroup chunkedLoading>
{legs.flatMap(l => l.locations).map((loc: any) => {
const isStart = startPoint?.id === loc.id;
const isEnd = endPoint?.id === loc.id;
// Sử dụng các icon tĩnh đã định nghĩa ở trên
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
return (
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
<Popup>
<div className="font-bold">{loc.name}</div>
<div className="text-xs text-gray-500">{loc.type}</div>
</Popup>
</Marker>
);
})}
</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
</div>
</div>
)}
</div>
)}
{activeTab === 'expense' && (
<div className="animate-in fade-in slide-in-from-bottom-4">
<ExpenseManager />
</div>
)}
{activeTab === 'photo' && (
<div className="grid grid-cols-3 gap-1.5 animate-in fade-in">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white">
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<img
src={`https://picsum.photos/seed/${i + 10}/400/400`}
alt="Tour photo"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
</div>
))}
</div>
)}
{activeTab === 'settings' && (
<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">
<Clock className="w-6 h-6 text-blue-500" />
<h3 className="text-lg font-bold text-gray-900">Yêu cầu tham gia</h3>
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2 py-1 rounded-full">{joinRequests.length} đang chờ</span>
</div>
<div className="space-y-2">
{joinRequests.map((req: any) => (
<div key={req.id} className="flex items-center justify-between gap-3 p-3 rounded-2xl border border-gray-100 bg-white">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-700 font-bold text-sm">
{req.user?.name?.charAt(0) || '?'}
</div>
<div>
<div className="text-sm font-bold text-gray-800">{req.user?.name || req.userId}</div>
<div className="text-[11px] text-gray-500">
Đưc mời bởi {req.requestedBy?.name} {new Date(req.createdAt).toLocaleString('vi-VN')}
</div>
</div>
</div>
<div className="flex gap-2">
<button
disabled={joinRequestActionId === req.id}
onClick={async () => {
if (!currentTour) return;
setConfirmState({
open: true,
title: 'Chấp nhận yêu cầu',
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
});
}}
className="p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50"
aria-label="Accept"
>
<Check className="w-4 h-4" />
</button>
<button
disabled={joinRequestActionId === req.id}
onClick={async () => {
if (!currentTour) return;
setConfirmState({
open: true,
title: 'Từ chối yêu cầu',
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
onConfirm: async () => {
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
} finally {
setJoinRequestActionId(null);
setConfirmState({ open: false });
}
},
});
}}
className="p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50"
aria-label="Reject"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
))}
{joinRequests.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">Không yêu cầu tham gia nào đang chờ phê duyệt.</div>
)}
</div>
</div>
<div className="p-8 text-center bg-gray-50 rounded-3xl border border-dashed border-gray-200">
<Settings className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">Tính năng cài đt khác đang đưc cập nhật...</p>
</div>
</div>
)}
</div>
</div>
{/* Floating Action Button (Mobile) */}
{canEdit && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
<button
onClick={() => {
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
setEditingLocation(null);
if (activeTab === 'plan') setIsAddLocationOpen(true);
}}
className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
</button>
</div>
)}
{/* Add Member Modal */}
{currentTour && (
<AddMemberModal
isOpen={isAddMemberOpen}
onClose={() => setIsAddMemberOpen(false)}
tourId={currentTour.id}
participants={currentTour.participants || []}
joinRequests={joinRequests}
onRemoveMember={(userId) => removeMember(currentTour.id, userId)}
onMemberAdded={() => fetchTour(currentTour.id)}
userRole={userRole || undefined}
/>
)}
{/* Add Location Modal */}
{currentTour && (
<AddLocationModal
isOpen={isAddLocationOpen}
onClose={() => setIsAddLocationOpen(false)}
initialLegId={targetLegId || undefined}
editingLocation={editingLocation}
tourId={currentTour.id}
/>
)}
{/* Member Detail Popover */}
{isMemberDetailOpen && selectedMember && (
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={() => setIsMemberDetailOpen(false)} />
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold">
{selectedMember.user?.name?.charAt(0) || '?'}
</div>
<div>
<div className="text-base font-bold text-gray-900">{selectedMember.user?.name || 'Chưa đặt tên'}</div>
<div className="text-xs text-gray-500">{selectedMember.user?.email}</div>
<div className="text-[10px] font-semibold text-gray-500">{selectedMember.role}</div>
</div>
</div>
{(selectedMember.user?.phone || selectedMember.user?.address) && (
<div className="mt-3 text-xs text-gray-600 space-y-1">
{selectedMember.user?.phone && <div>📞 {selectedMember.user.phone}</div>}
{selectedMember.user?.address && <div>📍 {selectedMember.user.address}</div>}
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<button onClick={() => setIsMemberDetailOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100">Đóng</button>
{canEdit && selectedMember.role !== 'OWNER' && (
<button
onClick={async () => {
if (!currentTour || !selectedMember) return;
try {
await removeMember(currentTour.id, selectedMember.userId);
setIsMemberDetailOpen(false);
} catch (e) {
notificationModal.openModal('Thông báo', 'Không thể xóa thành viên', 'error');
}
}}
className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold"
>
Xóa
</button>
)}
{canEdit && selectedMember.role === 'OWNER' && (
<button
onClick={() => {
setIsMemberDetailOpen(false);
setIsAddMemberOpen(true);
}}
className="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold"
>
Mời thêm người
</button>
)}
</div>
</div>
</div>
)}
<ConfirmModal
isOpen={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={() => confirmState.onConfirm?.()}
onCancel={() => setConfirmState({ open: false })}
/>
<NotificationModal
isOpen={notificationModal.modalState?.isOpen ?? false}
title={notificationModal.modalState?.title}
message={notificationModal.modalState?.message}
type={notificationModal.modalState?.type}
onConfirm={() => notificationModal.closeModal()}
/>
</div>
);
};
+7
View File
@@ -0,0 +1,7 @@
import { OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor();
onModuleInit(): Promise<void>;
onModuleDestroy(): Promise<void>;
}
+34
View File
@@ -0,0 +1,34 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PrismaService = void 0;
const common_1 = require("@nestjs/common");
const client_1 = require("@prisma/client");
const adapter_pg_1 = require("@prisma/adapter-pg");
const pg_1 = require("pg");
let PrismaService = class PrismaService extends client_1.PrismaClient {
constructor() {
console.log('--- [PRISMA CHECK] ---');
console.log('DATABASE_URL nhận được:', process.env.DATABASE_URL ? 'ĐÃ ĐỌC THÀNH CÔNG ✔️' : 'VẪN BỊ UNDEFINED ❌');
console.log('----------------------');
const pool = new pg_1.Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new adapter_pg_1.PrismaPg(pool);
super({ adapter });
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
};
exports.PrismaService = PrismaService;
exports.PrismaService = PrismaService = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [])
], PrismaService);
//# sourceMappingURL=prisma.service.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"prisma.service.js","sourceRoot":"","sources":["../../prisma/prisma.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA2E;AAC3E,2CAA8C;AAC9C,mDAA8C;AAC9C,2BAA0B;AAGnB,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,qBAAY;IAC7C;QACE,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,OAAO,CAAC,GAAG,CAAC,yBAAyB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,sBAAsB,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC;QACjH,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,MAAM,IAAI,GAAG,IAAI,SAAI,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;QACtE,MAAM,OAAO,GAAG,IAAI,qBAAQ,CAAC,IAAI,CAAC,CAAC;QACnC,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;IACrB,CAAC;IAED,KAAK,CAAC,YAAY,KAAK,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IAC/C,KAAK,CAAC,eAAe,KAAK,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;CACtD,CAAA;AAZY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;;GACA,aAAa,CAYzB"}
+7
View File
@@ -0,0 +1,7 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export declare class AdminGuard implements CanActivate {
private prisma;
constructor(prisma: PrismaService);
canActivate(context: ExecutionContext): Promise<boolean>;
}
+40
View File
@@ -0,0 +1,40 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AdminGuard = void 0;
const common_1 = require("@nestjs/common");
const prisma_service_1 = require("../../prisma/prisma.service");
let AdminGuard = class AdminGuard {
constructor(prisma) {
this.prisma = prisma;
}
async canActivate(context) {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.id) {
throw new common_1.ForbiddenException('Yêu cầu xác thực không hợp lệ. Vui lòng đăng nhập.');
}
const dbUser = await this.prisma.user.findUnique({
where: { id: user.id },
select: { isAdmin: true, isBlocked: true },
});
if (!dbUser || !dbUser.isAdmin || dbUser.isBlocked) {
throw new common_1.ForbiddenException('Truy cập bị từ chối. Bạn không có quyền quản trị viên hệ thống.');
}
return true;
}
};
exports.AdminGuard = AdminGuard;
exports.AdminGuard = AdminGuard = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], AdminGuard);
//# sourceMappingURL=admin.guard.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"admin.guard.js","sourceRoot":"","sources":["../../../src/auth/admin.guard.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,gEAA4D;AAGrD,IAAM,UAAU,GAAhB,MAAM,UAAU;IACrB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QAGpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;YACtB,MAAM,IAAI,2BAAkB,CAAC,oDAAoD,CAAC,CAAC;QACrF,CAAC;QAGD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC/C,KAAK,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE;YACtB,MAAM,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;SAC3C,CAAC,CAAC;QAEH,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;YACnD,MAAM,IAAI,2BAAkB,CAAC,iEAAiE,CAAC,CAAC;QAClG,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAzBY,gCAAU;qBAAV,UAAU;IADtB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,UAAU,CAyBtB"}
+4
View File
@@ -0,0 +1,4 @@
declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
export declare class JwtAuthGuard extends JwtAuthGuard_base {
}
export {};
+18
View File
@@ -0,0 +1,18 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.JwtAuthGuard = void 0;
const common_1 = require("@nestjs/common");
const passport_1 = require("@nestjs/passport");
let JwtAuthGuard = class JwtAuthGuard extends (0, passport_1.AuthGuard)('jwt') {
};
exports.JwtAuthGuard = JwtAuthGuard;
exports.JwtAuthGuard = JwtAuthGuard = __decorate([
(0, common_1.Injectable)()
], JwtAuthGuard);
//# sourceMappingURL=jwt-auth.guard.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAGtC,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B"}
+19
View File
@@ -0,0 +1,19 @@
import { PrismaService } from '../../prisma/prisma.service';
declare const JwtStrategy_base: new (...args: any) => any;
export declare class JwtStrategy extends JwtStrategy_base {
private prisma;
constructor(prisma: PrismaService);
validate(payload: any): Promise<{
id: string;
email: string;
passwordHash: string;
name: string | null;
phone: string | null;
address: string | null;
avatar: string | null;
createdAt: Date;
isAdmin: boolean;
isBlocked: boolean;
}>;
}
export {};
+41
View File
@@ -0,0 +1,41 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.JwtStrategy = void 0;
const common_1 = require("@nestjs/common");
const passport_1 = require("@nestjs/passport");
const passport_jwt_1 = require("passport-jwt");
const prisma_service_1 = require("../../prisma/prisma.service");
let JwtStrategy = class JwtStrategy extends (0, passport_1.PassportStrategy)(passport_jwt_1.Strategy) {
constructor(prisma) {
super({
jwtFromRequest: passport_jwt_1.ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'super-secret',
});
this.prisma = prisma;
}
async validate(payload) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user) {
throw new common_1.UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
}
return user;
}
};
exports.JwtStrategy = JwtStrategy;
exports.JwtStrategy = JwtStrategy = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], JwtStrategy);
//# sourceMappingURL=jwt.strategy.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../../../src/auth/jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmE;AACnE,+CAAoD;AACpD,+CAAoD;AACpD,gEAA4D;AAGrD,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,IAAA,2BAAgB,EAAC,uBAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,yBAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,8BAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AApBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAoBvB"}
+10
View File
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Cache } from 'cache-manager';
import { HttpAdapterHost } from '@nestjs/core';
export declare class CompressCacheInterceptor implements NestInterceptor {
private cacheManager;
private readonly httpAdapterHost;
constructor(cacheManager: Cache, httpAdapterHost: HttpAdapterHost);
intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>>;
}
+123
View File
@@ -0,0 +1,123 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CompressCacheInterceptor = void 0;
const common_1 = require("@nestjs/common");
const rxjs_1 = require("rxjs");
const operators_1 = require("rxjs/operators");
const cache_manager_1 = require("@nestjs/cache-manager");
const zlib = __importStar(require("zlib"));
const util_1 = require("util");
const core_1 = require("@nestjs/core");
const gzip = (0, util_1.promisify)(zlib.gzip);
const gunzip = (0, util_1.promisify)(zlib.gunzip);
const COMPRESSION_THRESHOLD = 100;
let CompressCacheInterceptor = class CompressCacheInterceptor {
constructor(cacheManager, httpAdapterHost) {
this.cacheManager = cacheManager;
this.httpAdapterHost = httpAdapterHost;
}
async intercept(context, next) {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const request = context.getArgByIndex(0);
const response = context.getArgByIndex(1);
if (httpAdapter.getRequestMethod(request) !== 'GET') {
return next.handle();
}
const cacheKey = httpAdapter.getRequestUrl(request);
let cachedData = await this.cacheManager.get(cacheKey);
if (cachedData) {
try {
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
const decompressed = await gunzip(cachedData);
const jsonString = decompressed.toString('utf8');
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
return (0, rxjs_1.of)(JSON.parse(jsonString));
}
else {
const jsonString = cachedData.toString();
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
return (0, rxjs_1.of)(JSON.parse(jsonString));
}
}
catch (e) {
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
await this.cacheManager.del(cacheKey);
}
}
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
return next.handle().pipe((0, operators_1.tap)(async (data) => {
if (!data)
return;
const jsonString = JSON.stringify(data);
const ttl = 60000;
if (jsonString.length > COMPRESSION_THRESHOLD) {
try {
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
await this.cacheManager.set(cacheKey, compressed, ttl);
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
}
catch (e) {
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
}
else {
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
}));
}
};
exports.CompressCacheInterceptor = CompressCacheInterceptor;
exports.CompressCacheInterceptor = CompressCacheInterceptor = __decorate([
(0, common_1.Injectable)(),
__param(0, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [Object, core_1.HttpAdapterHost])
], CompressCacheInterceptor);
//# sourceMappingURL=compress-cache.interceptor.js.map
@@ -0,0 +1 @@
{"version":3,"file":"compress-cache.interceptor.js","sourceRoot":"","sources":["../../../src/common/compress-cache.interceptor.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAAoG;AACpG,+BAAsC;AACtC,8CAAqC;AACrC,yDAAsD;AAEtD,2CAA6B;AAC7B,+BAAiC;AACjC,uCAA+C;AAG/C,MAAM,IAAI,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAClC,MAAM,MAAM,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAItC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAG3B,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IACnC,YACiC,YAAmB,EACjC,eAAgC;QADlB,iBAAY,GAAZ,YAAY,CAAO;QACjC,oBAAe,GAAf,eAAe,CAAiB;IAChD,CAAC;IAEJ,KAAK,CAAC,SAAS,CAAC,OAAyB,EAAE,IAAiB;QAC1D,MAAM,WAAW,GAAG,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC;QACrD,MAAM,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QACzC,MAAM,QAAQ,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QAG1C,IAAI,WAAW,CAAC,gBAAgB,CAAC,OAAO,CAAC,KAAK,KAAK,EAAE,CAAC;YACpD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC;QACvB,CAAC;QAED,MAAM,QAAQ,GAAG,WAAW,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QACpD,IAAI,UAAU,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAS,QAAQ,CAAC,CAAC;QAE/D,IAAI,UAAU,EAAE,CAAC;YACf,IAAI,CAAC;gBAEH,IAAI,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;oBAC7G,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,CAAC;oBAC9C,MAAM,UAAU,GAAG,YAAY,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;oBAEjD,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,kBAAkB,CAAC,CAAC;oBAC/D,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;qBAAM,CAAC;oBAEN,MAAM,UAAU,GAAG,UAAU,CAAC,QAAQ,EAAE,CAAC;oBACzC,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,oBAAoB,CAAC,CAAC;oBACjE,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;YACH,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,OAAO,CAAC,KAAK,CAAC,8CAA8C,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;gBAE5E,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YACxC,CAAC;QACH,CAAC;QAID,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;QAEnD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CACvB,IAAA,eAAG,EAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YACjB,IAAI,CAAC,IAAI;gBAAE,OAAO;YAElB,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YACxC,MAAM,GAAG,GAAG,KAAK,CAAC;YAElB,IAAI,UAAU,CAAC,MAAM,GAAG,qBAAqB,EAAE,CAAC;gBAC9C,IAAI,CAAC;oBACH,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC;oBAC/D,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;oBACvD,OAAO,CAAC,GAAG,CAAC,kCAAkC,QAAQ,KAAK,UAAU,CAAC,MAAM,OAAO,UAAU,CAAC,MAAM,SAAS,CAAC,CAAC;gBAEjH,CAAC;gBAAC,OAAO,CAAC,EAAE,CAAC;oBACX,OAAO,CAAC,KAAK,CAAC,mCAAmC,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;oBAEjE,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;gBACzD,CAAC;YACH,CAAC;iBAAM,CAAC;gBAEN,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;YACzD,CAAC;QACH,CAAC,CAAC,CACH,CAAC;IACJ,CAAC;CACF,CAAA;AAxEY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,mBAAU,GAAE;IAGR,WAAA,IAAA,eAAM,EAAC,6BAAa,CAAC,CAAA;6CACY,sBAAe;GAHxC,wBAAwB,CAwEpC"}
+7
View File
@@ -0,0 +1,7 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export declare class TourRoleGuard implements CanActivate {
private prisma;
constructor(prisma: PrismaService);
canActivate(context: ExecutionContext): Promise<boolean>;
}
+58
View File
@@ -0,0 +1,58 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TourRoleGuard = void 0;
const common_1 = require("@nestjs/common");
const prisma_service_1 = require("../../prisma/prisma.service");
let TourRoleGuard = class TourRoleGuard {
constructor(prisma) {
this.prisma = prisma;
}
async canActivate(context) {
const request = context.switchToHttp().getRequest();
const user = request.user;
const tourId = request.params.id || request.params.tourId;
const path = request.url;
if (!user || !tourId) {
throw new common_1.ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
}
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
request.tourParticipation = null;
return true;
}
const participation = await this.prisma.tourParticipant.findUnique({
where: {
tourId_userId: {
tourId: tourId,
userId: user.id,
},
},
});
if (!participation) {
throw new common_1.ForbiddenException("Bạn không phải là thành viên của tour này.");
}
request.tourParticipation = participation;
const role = participation.role;
const isPlanPath = path.includes('/plans');
const isExpensePath = path.includes('/expenses');
if ((role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
(isPlanPath || isExpensePath)) {
throw new common_1.ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
}
return true;
}
};
exports.TourRoleGuard = TourRoleGuard;
exports.TourRoleGuard = TourRoleGuard = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], TourRoleGuard);
//# sourceMappingURL=rbac.middleware.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../../../src/common/rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,gEAA4D;AAGrD,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,2BAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAED,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,0CAA0C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YACzJ,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;YACjC,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC,UAAU,CAAC;YACjE,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,aAAa,EAAE,CAAC;YACnB,MAAM,IAAI,2BAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAED,OAAO,CAAC,iBAAiB,GAAG,aAAa,CAAC;QAE1C,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC;QAChC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAEjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,aAAa,CAAC;YACxD,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,2BAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AA/CY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,aAAa,CA+CzB"}
+23
View File
@@ -0,0 +1,23 @@
import 'reflect-metadata';
import { OnGatewayConnection } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { PrismaService } from '../prisma/prisma.service';
import { ParticipantRole } from '@prisma/client';
import { Reflector } from '@nestjs/core';
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { Cache } from 'cache-manager';
export declare const ROLES_KEY = "roles";
export declare const Roles: (...roles: ParticipantRole[]) => import("@nestjs/common").CustomDecorator<string>;
export declare class TourRoleGuard implements CanActivate {
private reflector;
private prisma;
private cacheManager;
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
canActivate(context: ExecutionContext): Promise<boolean>;
}
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
notifyNewComment(tourId: string, data: any): void;
}
+1519
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -1,8 +1,10 @@
{ {
"$schema": "https://json.schemastore.org/nest-cli", "$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": ".", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true "deleteOutDir": true,
"assets": ["**/*.prisma", "schema.sql"],
"watchAssets": true
} }
} }
+45
View File
@@ -0,0 +1,45 @@
{
"name": "backend",
"version": "0.0.1",
"scripts": {
"start:dev": "nest start --watch",
"db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma",
"db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma",
"db:seed": "dotenv -e ../.env -- tsx seed.ts"
},
"devDependencies": {
"@nestjs/cli": "^11.0.23",
"@types/bcrypt": "^5.0.2",
"@types/node": "^20.14.10",
"@types/pg": "^8.11.6",
"dotenv-cli": "^7.4.2",
"prisma": "^5.16.2",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.5.3"
},
"dependencies": {
"@nestjs/cache-manager": "^3.1.3",
"@nestjs/common": "^11.1.27",
"@nestjs/core": "^11.1.27",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.27",
"@nestjs/platform-socket.io": "^11.1.27",
"@nestjs/websockets": "^11.1.27",
"@prisma/adapter-pg": "^5.16.2",
"@prisma/client": "^5.16.2",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5",
"dotenv": "^17.4.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.12.0",
"redis": "^6.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sharp": "^0.35.1",
"socket.io": "^4.8.3"
}
}
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Leg" ADD COLUMN "description" TEXT,
ADD COLUMN "endDate" TIMESTAMP(3),
ADD COLUMN "startDate" TIMESTAMP(3);
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "childDiscount" INTEGER NOT NULL DEFAULT 30;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "description" TEXT;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"locationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "tags" TEXT[];
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
@@ -6,6 +6,9 @@ import { Pool } from 'pg';
@Injectable() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() { constructor() {
console.log('--- [PRISMA CHECK] ---');
console.log('DATABASE_URL nhận được:', process.env.DATABASE_URL ? 'ĐÃ ĐỌC THÀNH CÔNG ✔️' : 'VẪN BỊ UNDEFINED ❌');
console.log('----------------------');
const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool); const adapter = new PrismaPg(pool);
super({ adapter }); super({ adapter });
@@ -3,10 +3,12 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL")
} }
// --- Enums --- // --- Enums ---
@@ -71,16 +73,24 @@ model User {
receivedJoinRequests JoinRequest[] @relation("JoinRequester") receivedJoinRequests JoinRequest[] @relation("JoinRequester")
uploadedPhotos Photo[] uploadedPhotos Photo[]
paidExpenses Expense[] @relation("ExpensePaidBy") paidExpenses Expense[] @relation("ExpensePaidBy")
comments Comment[]
} }
model Tour { model Tour {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
description String?
startDate DateTime? startDate DateTime?
endDate DateTime? endDate DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
totalCost Decimal @default(0) @db.Decimal(15, 2) totalCost Decimal @default(0) @db.Decimal(15, 2)
adultCount Int @default(1)
childCount Int @default(0)
childDiscount Int @default(30)
tags String[]
createdById String createdById String
creator User @relation("TourCreator", fields: [createdById], references: [id]) creator User @relation("TourCreator", fields: [createdById], references: [id])
@@ -122,6 +132,9 @@ model Leg {
tourId String tourId String
sequence Int sequence Int
note String? @db.Text note String? @db.Text
startDate DateTime?
endDate DateTime?
description String? @db.Text
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade) tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
locations Location[] locations Location[]
@@ -147,6 +160,7 @@ model Location {
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade) leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
expenses Expense[] expenses Expense[]
photos Photo[] photos Photo[]
comments Comment[]
} }
model Expense { model Expense {
@@ -166,15 +180,26 @@ model Expense {
model Photo { model Photo {
id String @id @default(uuid()) id String @id @default(uuid())
tourId String tourId String?
locationId String? locationId String?
uploaderId String uploaderId String
imageUrl String imageUrl String?
originalUrl String?
capturedAt DateTime @default(now()) capturedAt DateTime @default(now())
metadata Json? metadata Json?
privacy PrivacyLevel @default(TOUR_ONLY) privacy PrivacyLevel @default(TOUR_ONLY)
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade) tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
uploader User @relation(fields: [uploaderId], references: [id]) uploader User @relation(fields: [uploaderId], references: [id])
} }
model Comment {
id String @id @default(uuid())
content String
createdAt DateTime @default(now())
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
+25 -2
View File
@@ -1,8 +1,12 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg'; import { Pool } from 'pg';
import 'dotenv/config'; import * as dotenv from 'dotenv';
import * as bcrypt from 'bcrypt'; import * as path from 'path';
import bcrypt from 'bcrypt';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool); const adapter = new PrismaPg(pool);
@@ -90,6 +94,25 @@ async function main() {
}, },
}); });
console.log('--- Đang tạo bình luận mẫu... ---');
const dinhDocLap = await prisma.location.findFirst({ where: { name: 'Dinh Độc Lập' } });
if (dinhDocLap) {
await prisma.comment.createMany({
data: [
{
content: 'Chỗ này rất đẹp, giàu giá trị lịch sử!',
locationId: dinhDocLap.id,
userId: owner.id,
},
{
content: 'Nên đi vào buổi sáng cho mát mẻ mọi người nhé.',
locationId: dinhDocLap.id,
userId: photoMember.id,
}
],
});
}
console.log('--- Seed dữ liệu hoàn tất! ---'); console.log('--- Seed dữ liệu hoàn tất! ---');
console.log(`Email đăng nhập Owner: ${owner.email}`); console.log(`Email đăng nhập Owner: ${owner.email}`);
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`); console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
@@ -1,5 +1,5 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { PrismaService } from './prisma.service.js'; import { PrismaService } from '../../prisma/prisma.service';
@Injectable() @Injectable()
export class AdminGuard implements CanActivate { export class AdminGuard implements CanActivate {
@@ -1,7 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from './prisma.service.js'; import { PrismaService } from '../../prisma/prisma.service';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -0,0 +1,91 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Inject } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import * as zlib from 'zlib';
import { promisify } from 'util';
import { HttpAdapterHost } from '@nestjs/core';
// Promisify các hàm nén/giải nén
const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);
// Ngưỡng nén: Chỉ nén nếu chuỗi JSON lớn hơn ngưỡng này (bytes)
// Nén dữ liệu quá nhỏ có thể làm tăng kích thước do overhead của header nén
const COMPRESSION_THRESHOLD = 100;
@Injectable()
export class CompressCacheInterceptor implements NestInterceptor {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly httpAdapterHost: HttpAdapterHost, // Để truy cập request/response
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const request = context.getArgByIndex(0);
const response = context.getArgByIndex(1);
// Chỉ áp dụng cho các request GET
if (httpAdapter.getRequestMethod(request) !== 'GET') {
return next.handle();
}
const cacheKey = httpAdapter.getRequestUrl(request);
let cachedData = await this.cacheManager.get<Buffer>(cacheKey);
if (cachedData) {
try {
// Kiểm tra xem dữ liệu có phải là Buffer và có Gzip header (0x1f 0x8b) không
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
const decompressed = await gunzip(cachedData);
const jsonString = decompressed.toString('utf8');
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
return of(JSON.parse(jsonString));
} else {
// Dữ liệu không nén (lưu dưới dạng string hoặc buffer thường)
const jsonString = cachedData.toString();
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
return of(JSON.parse(jsonString));
}
} catch (e) {
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
// Nếu giải nén lỗi, coi như cache miss và xóa cache bị lỗi
await this.cacheManager.del(cacheKey);
}
}
// Cache miss hoặc giải nén lỗi, tiếp tục xử lý request
// Đặt header MISS ngay lập tức trước khi chạy logic Controller
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
return next.handle().pipe(
tap(async (data) => { // Sử dụng tap để thực hiện side effect (lưu cache) mà không thay đổi dữ liệu gốc
if (!data) return;
const jsonString = JSON.stringify(data);
const ttl = 60000; // TTL mặc định 1 phút (có thể cấu hình từ CACHE_TTL.DEFAULT)
if (jsonString.length > COMPRESSION_THRESHOLD) {
try {
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
await this.cacheManager.set(cacheKey, compressed, ttl);
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
// Không setHeader ở đây vì response có thể đã gửi xong
} catch (e) {
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
// Nếu nén lỗi, lưu dữ liệu không nén làm fallback
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
} else {
// Dữ liệu quá nhỏ, lưu không nén
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
})
);
}
}
@@ -1,5 +1,5 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { PrismaService } from './prisma.service.js'; import { PrismaService } from '../../prisma/prisma.service';
@Injectable() @Injectable()
export class TourRoleGuard implements CanActivate { export class TourRoleGuard implements CanActivate {
+576 -35
View File
@@ -1,14 +1,186 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
// Sửa đường dẫn: lùi 2 cấp từ backend/src để ra root monorepo
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
import 'reflect-metadata'; import 'reflect-metadata';
import * as fs from 'fs';
import * as zlib from 'zlib';
import { promisify } from 'util';
import sharp from 'sharp';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException } from '@nestjs/common'; import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject } from '@nestjs/common';
import { PrismaService } from './prisma.service.js'; import { NestExpressApplication } from '@nestjs/platform-express';
import 'dotenv/config'; import { FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { PrismaService } from '../prisma/prisma.service';
import { ParticipantRole } from '@prisma/client';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { AdminGuard } from './admin.guard.js'; import { AdminGuard } from './auth/admin.guard';
import { JwtModule, JwtService } from '@nestjs/jwt'; import { JwtModule, JwtService } from '@nestjs/jwt';
import { JwtAuthGuard } from './jwt-auth.guard.js'; import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { JwtStrategy } from './jwt.strategy.js'; import { JwtStrategy } from './auth/jwt.strategy';
import { TourRoleGuard } from './rbac.middleware.js'; import { Reflector } from '@nestjs/core';
import { SetMetadata, CanActivate, ExecutionContext } from '@nestjs/common';
import { CacheModule, CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { redisStore } from 'cache-manager-redis-yet';
import { HttpAdapterHost } from '@nestjs/core';
import { CompressCacheInterceptor } from './common/compress-cache.interceptor';
// Promisify các hàm nén để sử dụng async/await
const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);
// Khai báo vị trí thư mục upload cụ thể
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads');
// Cấu hình TTL (mili giây) cho từng loại dữ liệu
const CACHE_TTL = {
DEFAULT: 600000, // 10 phút mặc định
RESOURCE_TO_TOUR: 3600000, // 1 giờ cho ánh xạ tài nguyên -> tour
USER_ROLE: 300000, // 5 phút cho quyền hạn người dùng
};
async function bootstrap() {
if (!process.env.DATABASE_URL) {
throw new Error('❌ DATABASE_URL không tồn tại trong biến môi trường! Kiểm tra file .env tại: ' + envPath);
}
// Kiểm tra log xem biến môi trường đã nhận đúng chưa
console.log('====================================');
console.log('DATABASE_URL:', process.env.DATABASE_URL);
console.log('====================================');
// Chuyển sang dùng NestExpressApplication để cấu hình static assets
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.setGlobalPrefix('api/v1');
// Bật CORS để cho phép Frontend kết nối API không bị chặn
app.enableCors();
// Tự động tạo thư mục upload nếu chưa tồn tại
if (!fs.existsSync(UPLOAD_ROOT)) {
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
}
// Khai báo vị trí để ảnh upload có thể truy cập được từ bên ngoài qua URL
app.useStaticAssets(UPLOAD_ROOT, {
prefix: '/uploads/',
});
await app.listen(3001);
console.log(`🚀 Server is running on: http://localhost:3001`);
}
// Define ROLES_KEY and Roles decorator
export const ROLES_KEY = 'roles';
export const Roles = (...roles: ParticipantRole[]) => SetMetadata(ROLES_KEY, roles);
// Implement TourRoleGuard (assuming it's here or similar to this)
// This guard checks if the user is a participant of the tour and has one of the required roles.
@Injectable()
export class TourRoleGuard implements CanActivate {
constructor(
private reflector: Reflector,
private prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRoles = this.reflector.getAllAndOverride<ParticipantRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
// If no specific roles are required, default to OWNER and MANAGER for editing actions
const defaultRoles = [ParticipantRole.OWNER, ParticipantRole.MANAGER];
const rolesToCheck = requiredRoles && requiredRoles.length > 0 ? requiredRoles : defaultRoles;
const request = context.switchToHttp().getRequest();
const user = request.user; // User object from JwtAuthGuard
let tourId = request.params.tourId;
const resourceId = request.params.id || request.params.legId || request.params.locationId;
// Nếu không có tourId trực tiếp, tìm tourId thông qua các tài nguyên liên quan
if (!tourId && resourceId) {
const resCacheKey = `res-to-tour:${resourceId}`;
const compressedData = await this.cacheManager.get<Buffer>(resCacheKey);
if (compressedData) {
try {
const decompressed = await gunzip(compressedData);
tourId = decompressed.toString();
} catch (e) {
console.error('Lỗi giải nén cache:', e);
}
} else {
// Thử xem resourceId có phải là tourId không
const isTour = await this.prisma.tour.findUnique({ where: { id: resourceId }, select: { id: true } });
if (isTour) {
tourId = resourceId;
} else {
// Thử xem resourceId có phải là legId không
const leg = await this.prisma.leg.findUnique({ where: { id: resourceId }, select: { tourId: true } });
if (leg) {
tourId = leg.tourId;
} else {
// Thử xem resourceId có phải là locationId không
const loc = await this.prisma.location.findUnique({
where: { id: resourceId },
include: { leg: { select: { tourId: true } } }
});
if (loc) {
tourId = loc.leg.tourId;
} else {
// Thử xem resourceId có phải là photoId không
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true } });
if (photo) tourId = photo.tourId;
}
}
}
// Cache ánh xạ tài nguyên -> tour trong 1 giờ để giảm tải query ngược
if (tourId) {
try {
const compressed = await gzip(Buffer.from(tourId));
await this.cacheManager.set(resCacheKey, compressed, CACHE_TTL.RESOURCE_TO_TOUR);
} catch (e) {
await this.cacheManager.set(resCacheKey, tourId, CACHE_TTL.RESOURCE_TO_TOUR);
}
}
}
}
if (!user || !tourId) {
// Nếu đây là các route công khai hoặc không liên quan đến Tour, cho phép đi qua
// nhưng ở đây chúng ta đang áp dụng guard cho các route cần phân quyền Tour
if (!resourceId && !request.params.tourId) return true;
return false;
}
// Cache vai trò người dùng trong tour (5 phút)
const roleCacheKey = `user-role:${user.id}:${tourId}`;
let role = await this.cacheManager.get<ParticipantRole>(roleCacheKey);
if (!role) {
const participation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: user.id } },
});
if (!participation) return false;
role = participation.role;
await this.cacheManager.set(roleCacheKey, role, CACHE_TTL.USER_ROLE);
}
if (!rolesToCheck.some(r => role === r)) {
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này trong tour này.');
}
return true;
}
}
@Controller() @Controller()
class AppController { class AppController {
@@ -18,7 +190,7 @@ class AppController {
} }
} }
@Controller('v1/auth') @Controller('auth')
class AuthController { class AuthController {
constructor(private prisma: PrismaService, private jwtService: JwtService) {} constructor(private prisma: PrismaService, private jwtService: JwtService) {}
@@ -75,19 +247,72 @@ class AuthController {
} }
} }
@Controller('v1/tours') @Controller('tours') // Controller mới để xử lý các tour công khai
class TourController { class PublicTourController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache
@Get(':id/public')
async getPublicTourDetails(@Param('id', ParseUUIDPipe) id: string) {
console.log(`[PublicTour] Đang truy vấn chi tiết Tour ID: ${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: {
expenses: {
include: {
location: { select: { name: true, plannedStart: true } },
paidBy: { select: { name: true } }
}
},
locations: {
orderBy: { plannedStart: 'asc' },
include: { _count: { select: { comments: true } } }
},
},
},
},
});
if (!tour) {
console.warn(`[PublicTour] Không tìm thấy Tour với ID: ${id} trong Database`);
throw new NotFoundException(`Không tìm thấy Tour`);
}
return tour;
}
}
@Controller('tours')
class TourController {
constructor(
private prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) {}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Post() @Post()
async createTour(@Body() body: any, @Req() req: any) { async createTour(@Body() body: any, @Req() req: any) {
const { title, startDate, endDate } = body; const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
return this.prisma.tour.create({ return this.prisma.tour.create({
data: { data: {
title, title,
description,
startDate: startDate ? new Date(startDate) : null, startDate: startDate ? new Date(startDate) : null,
endDate: endDate ? new Date(endDate) : null, endDate: endDate ? new Date(endDate) : null,
tags: tags || [],
adultCount: adultCount || 1,
childCount: childCount || 0,
childDiscount: childDiscount || 0,
createdById: req.user.id, createdById: req.user.id,
participants: { participants: {
create: { create: {
@@ -110,6 +335,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER) // Allow members to add locations
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/locations') @Post(':tourId/locations')
async addLocation(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) { async addLocation(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
@@ -152,6 +378,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can set start point
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/start-point') @Post(':tourId/start-point')
async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) { async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
@@ -189,6 +416,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can set end point
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/end-point') @Post(':tourId/end-point')
async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) { async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
@@ -224,6 +452,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can initialize legs
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/legs/batch') @Post(':tourId/legs/batch')
async initializeLegs(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { count: number }) { async initializeLegs(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { count: number }) {
@@ -269,6 +498,7 @@ class TourController {
return allLegs; return allLegs;
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add legs
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/legs') @Post(':tourId/legs')
async addLeg(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) { async addLeg(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) {
@@ -287,6 +517,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can update tour details
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Patch(':id') @Patch(':id')
async updateTour(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) { async updateTour(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
@@ -295,22 +526,80 @@ class TourController {
where: { id }, where: { id },
data: { data: {
title: body.title, title: body.title,
description: body.description,
startDate: body.startDate ? new Date(body.startDate) : undefined, startDate: body.startDate ? new Date(body.startDate) : undefined,
tags: body.tags,
endDate: body.endDate ? new Date(body.endDate) : undefined, endDate: body.endDate ? new Date(body.endDate) : undefined,
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
}, },
}); });
} }
@Roles(ParticipantRole.OWNER) // Only owner can delete tour
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Delete(':id') @Delete(':id')
async deleteTour(@Param('id', ParseUUIDPipe) id: string) { async deleteTour(@Param('id', ParseUUIDPipe) id: string) {
// Xóa tour sẽ xóa cascade các Leg, Location, Expense nhờ config onDelete: Cascade trong schema // 1. Lấy thông tin chi tiết Tour cùng các tài nguyên liên quan để dọn dẹp cache và file
const tour = await this.prisma.tour.findUnique({
where: { id },
include: {
participants: true,
photos: true,
legs: {
include: { locations: true }
}
}
});
if (!tour) throw new NotFoundException('Không tìm thấy tour');
// --- BẮT ĐẦU DỌN DẸP CACHE ---
// a. Xóa cache vai trò của tất cả thành viên trong tour này
for (const participant of tour.participants) {
await this.cacheManager.del(`user-role:${participant.userId}:${id}`);
}
// b. Xóa cache mapping tài nguyên (Leg, Location, Photo) về Tour này
await this.cacheManager.del(`res-to-tour:${id}`); // Bản thân tour
for (const leg of tour.legs) {
await this.cacheManager.del(`res-to-tour:${leg.id}`);
for (const loc of leg.locations) {
await this.cacheManager.del(`res-to-tour:${loc.id}`);
}
}
for (const photo of tour.photos) {
await this.cacheManager.del(`res-to-tour:${photo.id}`);
}
// --- KẾT THÚC DỌN DẸP CACHE ---
// 2. Xóa các file vật lý (ảnh 2K) trong thư mục uploads/tours
for (const photo of tour.photos) {
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
fs.unlinkSync(displayFilePath);
}
}
}
// Cập nhật DB: Xóa link ảnh 2K vì file vật lý đã bị xóa, tourId sẽ tự động SetNull
await this.prisma.photo.updateMany({
where: { tourId: id },
data: { imageUrl: null }
});
await this.prisma.tour.delete({ await this.prisma.tour.delete({
where: { id }, where: { id },
}); });
return { success: true }; return { success: true };
} }
// getPublicTours does not need TourRoleGuard as it's for any logged-in user to see their tours
@UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('explore') @Get('explore')
async getPublicTours(@Req() req: any) { async getPublicTours(@Req() req: any) {
@@ -323,17 +612,26 @@ 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' },
include: { include: {
locations: { orderBy: { plannedStart: 'asc' } } locations: {
orderBy: { plannedStart: 'asc' },
include: { _count: { select: { comments: true } } }
}
} }
} }
} }
}); });
} }
@UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) // Any participant can view tour details
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Get(':id') @Get(':id')
async getTourDetails(@Param('id', ParseUUIDPipe) id: string) { async getTourDetails(@Param('id', ParseUUIDPipe) id: string) {
@@ -351,7 +649,16 @@ class TourController {
legs: { legs: {
orderBy: { sequence: 'asc' }, orderBy: { sequence: 'asc' },
include: { include: {
locations: { orderBy: { plannedStart: 'asc' } }, expenses: {
include: {
location: { select: { name: true, plannedStart: true } },
paidBy: { select: { name: true } }
}
},
locations: {
orderBy: { plannedStart: 'asc' },
include: { _count: { select: { comments: true } } }
},
}, },
}, },
}, },
@@ -361,12 +668,16 @@ class TourController {
return tour; return tour;
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add members
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/members') @Post(':tourId/members')
async addMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId: string; role?: string }, @Req() req: any) { async addMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId: string; role?: string }, @Req() req: any) {
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'] as const; const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'] as const;
const role = validRoles.includes(body.role as any) ? body.role as 'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY' : 'MEMBER'; const role = validRoles.includes(body.role as any) ? body.role as 'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY' : 'MEMBER';
// Xóa cache khi thay đổi quyền hạn hoặc thêm thành viên mới
await this.cacheManager.del(`user-role:${body.userId}:${tourId}`);
const participation = await this.prisma.tourParticipant.findUnique({ const participation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: body.userId } }, where: { tourId_userId: { tourId, userId: body.userId } },
}); });
@@ -411,6 +722,7 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can view join requests
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Get(':tourId/join-requests') @Get(':tourId/join-requests')
async getJoinRequests(@Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any) { async getJoinRequests(@Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any) {
@@ -426,6 +738,7 @@ class TourController {
return requests; return requests;
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) // Any participant can create a join request for themselves or others (if they have permission)
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/join-requests') @Post(':tourId/join-requests')
async createJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId?: string }, @Req() req: any) { async createJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId?: string }, @Req() req: any) {
@@ -461,6 +774,7 @@ class TourController {
return joinRequest; return joinRequest;
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can accept join requests
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/join-requests/:requestId/accept') @Post(':tourId/join-requests/:requestId/accept')
async acceptJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) { async acceptJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
@@ -471,7 +785,7 @@ class TourController {
}); });
role = participation?.role; role = participation?.role;
} }
if (!role || !(role === 'OWNER' || role === 'MANAGER')) { if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
throw new ForbiddenException('Bạn không có quyền chấp nhận yêu cầu tham gia.'); throw new ForbiddenException('Bạn không có quyền chấp nhận yêu cầu tham gia.');
} }
@@ -485,6 +799,8 @@ class TourController {
throw new BadRequestException('Yêu cầu này đã được xử lý trước đó.'); throw new BadRequestException('Yêu cầu này đã được xử lý trước đó.');
} }
await this.cacheManager.del(`user-role:${joinRequest.userId}:${tourId}`);
const existing = await this.prisma.tourParticipant.findUnique({ const existing = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: joinRequest.userId } }, where: { tourId_userId: { tourId, userId: joinRequest.userId } },
}); });
@@ -513,6 +829,7 @@ class TourController {
return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' }; return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' };
} }
@Roles(ParticipantRole.OWNER) // Only owner can reject join requests
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/join-requests/:requestId/reject') @Post(':tourId/join-requests/:requestId/reject')
async rejectJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) { async rejectJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
@@ -523,7 +840,7 @@ class TourController {
}); });
role = participation?.role; role = participation?.role;
} }
if (!role || !(role === 'OWNER' || role === 'MANAGER')) { if (!role || role !== 'OWNER') {
throw new ForbiddenException('Bạn không có quyền từ chối yêu cầu tham gia.'); throw new ForbiddenException('Bạn không có quyền từ chối yêu cầu tham gia.');
} }
@@ -545,6 +862,7 @@ class TourController {
return { success: true, message: 'Đã từ chối yêu cầu tham gia.' }; return { success: true, message: 'Đã từ chối yêu cầu tham gia.' };
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can remove members
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Delete(':tourId/members/:userId') @Delete(':tourId/members/:userId')
async removeMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('userId', ParseUUIDPipe) userId: string) { async removeMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('userId', ParseUUIDPipe) userId: string) {
@@ -554,14 +872,70 @@ class TourController {
if (!participation) { if (!participation) {
throw new NotFoundException('Thành viên này không có trong tour'); throw new NotFoundException('Thành viên này không có trong tour');
} }
await this.cacheManager.del(`user-role:${userId}:${tourId}`);
await this.prisma.tourParticipant.delete({ await this.prisma.tourParticipant.delete({
where: { tourId_userId: { tourId, userId } }, where: { tourId_userId: { tourId, userId } },
}); });
return { message: 'Đã xóa thành viên khỏi tour' }; return { message: 'Đã xóa thành viên khỏi tour' };
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) // All members can upload photos
@UseGuards(JwtAuthGuard, TourRoleGuard) // TourRoleGuard will now check for these roles
@Post(':tourId/photos')
@UseInterceptors(FilesInterceptor('images', 10)) // Chuyển sang memory storage để xử lý ảnh trước khi lưu
async uploadPhotos(@Param('tourId', ParseUUIDPipe) tourId: string, @UploadedFiles() files: any[], @Req() req: any) {
if (!files || files.length === 0) {
throw new BadRequestException('Vui lòng chọn ít nhất một ảnh');
}
const uploaderId = req.user.id;
// Đường dẫn ảnh gốc cho từng thành viên
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
// Đường dẫn ảnh hiển thị chung của Tour
const tourDisplayPath = path.join(UPLOAD_ROOT, 'tours');
// Đảm bảo các thư mục tồn tại
if (!fs.existsSync(memberOriginalDir)) fs.mkdirSync(memberOriginalDir, { recursive: true });
if (!fs.existsSync(tourDisplayPath)) fs.mkdirSync(tourDisplayPath, { recursive: true });
return Promise.all(files.map(async (file) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const extension = path.extname(file.originalname).toLowerCase() || '.jpg';
const filename = `${uniqueSuffix}${extension}`;
const originalFilePath = path.join(memberOriginalDir, filename);
const displayFilePath = path.join(tourDisplayPath, filename);
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của thành viên
await fs.promises.writeFile(originalFilePath, file.buffer);
// 2. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px)
// Sử dụng Sharp để resize và tối ưu dung lượng ảnh
await sharp(file.buffer)
.resize(2560, 2560, {
fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K
withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh
})
.jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng
.toFile(displayFilePath);
// 3. Lưu thông tin vào Database (Lưu cả 2 đường dẫn)
return this.prisma.photo.create({
data: {
tourId: tourId,
uploaderId: uploaderId,
imageUrl: `/uploads/tours/${filename}`, // URL ảnh 2K dùng để render
originalUrl: `/uploads/members/${uploaderId}/originals/${filename}`, // URL ảnh gốc để tải xuống
privacy: 'TOUR_ONLY',
}
});
}));
}
} }
@Controller('v1/locations') @Controller('locations')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
class LocationController { class LocationController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@@ -618,8 +992,9 @@ class LocationController {
} }
} }
@Controller('v1/legs') @Controller('legs')
@UseGuards(JwtAuthGuard) @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Leg operations
@UseGuards(JwtAuthGuard, TourRoleGuard)
class LegController { class LegController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@@ -629,7 +1004,10 @@ class LegController {
where: { id }, where: { id },
data: { data: {
note: body.note, note: body.note,
sequence: body.sequence sequence: body.sequence,
startDate: body.startDate ? new Date(body.startDate) : undefined,
endDate: body.endDate ? new Date(body.endDate) : undefined,
description: body.description,
} }
}); });
} }
@@ -662,7 +1040,9 @@ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: numbe
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
} }
@Controller('v1/routing') @Controller('routing')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Routing operations
@UseGuards(JwtAuthGuard, TourRoleGuard)
class RoutingController { class RoutingController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@@ -775,7 +1155,50 @@ class RoutingController {
} }
} }
@Controller('v1/users') @Controller('photos')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) // All members can delete their own photos
@UseGuards(JwtAuthGuard, TourRoleGuard)
class PhotoController {
constructor(private prisma: PrismaService) {}
@Delete(':id')
async deletePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const photo = await this.prisma.photo.findUnique({
where: { id },
});
if (!photo) {
throw new NotFoundException('Không tìm thấy ảnh.');
}
// Chỉ người tải lên mới có quyền xóa ảnh của họ
if (photo.uploaderId !== req.user.id) {
throw new ForbiddenException('Bạn không có quyền xóa ảnh này.');
}
// Xóa file 2K (imageUrl) nếu tồn tại
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
fs.unlinkSync(displayFilePath);
}
}
// Xóa file gốc (originalUrl) nếu tồn tại
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
if (fs.existsSync(originalFilePath)) {
fs.unlinkSync(originalFilePath);
}
}
await this.prisma.photo.delete({ where: { id } });
return { message: 'Ảnh đã được xóa thành công.' };
}
}
@Controller('users')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for User management (Admin/Manager)
class UserController { class UserController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@@ -796,6 +1219,20 @@ class UserController {
return users.filter((u: any) => u.id !== currentUserId); return users.filter((u: any) => u.id !== currentUserId);
} }
// getMyPhotos does not need TourRoleGuard as it's for the user's own photos
@UseGuards(JwtAuthGuard)
@Get('me/photos')
async getMyPhotos(@Req() req: any) {
return this.prisma.photo.findMany({
where: { uploaderId: req.user.id },
include: {
tour: { select: { title: true } }
},
orderBy: { capturedAt: 'desc' }
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for updating user (Admin/Manager)
@Patch(':id') @Patch(':id')
async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) { async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) {
if (data.password) { if (data.password) {
@@ -809,6 +1246,7 @@ class UserController {
}); });
} }
@Roles(ParticipantRole.OWNER) // Only owner can delete user
@Delete(':id') @Delete(':id')
async deleteUser(@Param('id', ParseUUIDPipe) id: string) { async deleteUser(@Param('id', ParseUUIDPipe) id: string) {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
@@ -817,11 +1255,36 @@ class UserController {
const adminCount = await this.prisma.user.count({ where: { isAdmin: true } }); const adminCount = await this.prisma.user.count({ where: { isAdmin: true } });
if (adminCount <= 1) throw new BadRequestException('Không thể xóa Quản trị viên cuối cùng'); if (adminCount <= 1) throw new BadRequestException('Không thể xóa Quản trị viên cuối cùng');
} }
// 1. Xác định thư mục chứa ảnh gốc của thành viên
const memberDir = path.join(UPLOAD_ROOT, 'members', id);
// 2. Tìm tất cả ảnh của user này để dọn dẹp nốt các bản 2K còn lại trong thư mục tours
const photos = await this.prisma.photo.findMany({
where: { uploaderId: id }
});
for (const photo of photos) {
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) fs.unlinkSync(displayFilePath);
}
}
// 2. Xóa các ràng buộc và dữ liệu trong DB
await this.prisma.photo.deleteMany({ where: { uploaderId: id } });
await this.prisma.tourParticipant.deleteMany({ where: { userId: id } }); await this.prisma.tourParticipant.deleteMany({ where: { userId: id } });
await this.prisma.user.delete({ where: { id } }); await this.prisma.user.delete({ where: { id } });
// 3. Xóa vật lý toàn bộ thư mục ảnh gốc
if (fs.existsSync(memberDir)) {
fs.rmSync(memberDir, { recursive: true, force: true });
}
return { message: 'Đã xóa người dùng' }; return { message: 'Đã xóa người dùng' };
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for blocking user (Admin/Manager)
@Post('block/:id') @Post('block/:id')
async toggleBlock(@Param('id', ParseUUIDPipe) id: string) { async toggleBlock(@Param('id', ParseUUIDPipe) id: string) {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
@@ -835,26 +1298,104 @@ class UserController {
} }
} }
@WebSocketGateway({ cors: { origin: '*' } })
export class CommentGateway implements OnGatewayConnection {
@WebSocketServer() server: Server;
handleConnection(client: Socket) {
console.log(`[WS] Client connected: ${client.id}`);
}
@SubscribeMessage('joinTour')
handleJoinTour(client: Socket, tourId: string) {
client.join(`tour_${tourId}`);
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
}
notifyNewComment(tourId: string, data: any) {
// Gửi thông báo tới tất cả client trong phòng của Tour này
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
}
}
@Controller('locations')
class CommentController {
constructor(
private prisma: PrismaService,
private commentGateway: CommentGateway
) {}
@Get(':locationId/comments')
// Cho phép khách xem bình luận mà không cần đăng nhập
async getComments(@Param('locationId', ParseUUIDPipe) locationId: string) {
return this.prisma.comment.findMany({
where: { locationId },
include: {
user: { select: { name: true } }
},
orderBy: { createdAt: 'asc' }
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY)
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':locationId/comments')
async addComment(
@Param('locationId', ParseUUIDPipe) locationId: string,
@Body() body: { content: string },
@Req() req: any
) {
const comment = await this.prisma.comment.create({
data: {
content: body.content,
locationId,
userId: req.user.id
},
include: { user: { select: { name: true } } }
});
// Tìm tourId để gửi thông báo vào đúng phòng
const location = await this.prisma.location.findUnique({
where: { id: locationId },
include: { leg: { select: { tourId: true } } }
});
if (location?.leg?.tourId) {
this.commentGateway.notifyNewComment(location.leg.tourId, {
...comment,
locationId // Gửi kèm locationId để UI biết điểm nào cần tăng số lượng
});
}
return comment;
}
}
@Module({ @Module({
imports: [ imports: [
CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => ({
store: await redisStore({
url: process.env.REDIS_URL || 'redis://localhost:6379',
ttl: CACHE_TTL.DEFAULT, // Cấu hình TTL mặc định cho toàn bộ store
}),
}),
}),
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET || 'super-secret', secret: process.env.JWT_SECRET || 'super-secret',
signOptions: { expiresIn: '1d' }, signOptions: { expiresIn: '1d' },
}), }) as any,
], ],
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController], controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController],
providers: [PrismaService, JwtStrategy, TourRoleGuard], providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector],
exports: [PrismaService] exports: [PrismaService]
}) })
class AppModule {} class AppModule {}
async function bootstrap() {
const app = await NestFactory.create(AppModule); bootstrap().catch(err => {
app.enableCors(); // Cho phép Frontend gọi API console.error('💥 Lỗi khởi động Server:');
app.setGlobalPrefix('api'); // Tất cả API sẽ bắt đầu bằng /api/... console.error(err);
process.exit(1);
const port = process.env.PORT || 3001; });
await app.listen(port);
console.log(`🚀 Server is running on: http://localhost:${port}`);
}
bootstrap();
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node", // Đảm bảo module resolution là 'node'
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false,
"resolveJsonModule": true, // Cho phép import các file .json
"esModuleInterop": true, // Cho phép cú pháp import/export ES Modules với CommonJS
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@ Tài liệu này mô tả kiến trúc tổng thể, mô hình dữ liệu và c
--- ---
## 8. Cấu Trúc Thư Mục (Directory Structure) ## 1. Cấu Trúc Thư Mục (Directory Structure)
```text ```text
/home/locpham/travelplanning/ /home/locpham/travelplanning/
+2 -1
View File
@@ -3,10 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title> <title>Travel Planner</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>
</html> </html>
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"leaflet": "^1.9.4",
"lucide-react": "^0.284.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"socket.io-client": "^4.8.3",
"zustand": "^5.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.1",
"@types/leaflet": "^1.9.12",
"@types/react": "^18.3.12",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.1",
"typescript": "^5.7.0",
"vite": "^8.0.16"
}
}
@@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
"@tailwindcss/postcss": {}, "@tailwindcss/postcss": {},
"autoprefixer": {}, autoprefixer: {},
}, },
} };
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

+130
View File
@@ -0,0 +1,130 @@
import React, { useState, useEffect } from 'react';
import { LandingPage } from './pages/LandingPage';
import { ExploreMap } from './pages/ExploreMap';
import { TourDetailPage } from './pages/TourDetailPage';
import { SignupPage } from './pages/SignupPage';
import { MyPhotosPage } from './pages/MyPhotosPage';
import { useTourStore } from './store/useTourStore';
import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider } from './hooks/useNotification';
function App() {
const params = new URLSearchParams(window.location.search);
const viewTourId = params.get('viewTour');
const [user, setUser] = useState<any>(null);
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos'>(viewTourId ? 'tourDetail' : 'landing');
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
// Lấy action từ store
const fetchTour = useTourStore(state => state.fetchTour);
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const viewTourId = params.get('viewTour');
if (viewTourId) {
// Không gọi replaceState ngay để tránh mất ID khi component re-render hoặc refresh
} else {
// Kiểm tra đăng nhập bình thường nếu không có tham số viewTour
const token = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (token && storedUser) {
try {
setUser(JSON.parse(storedUser));
setCurrentPage('explore'); // Chuyển đến bản đồ khám phá nếu đã đăng nhập
} catch (e) {
console.error("Lỗi khi phân tích dữ liệu người dùng từ localStorage", e);
localStorage.removeItem('token');
localStorage.removeItem('user');
setCurrentPage('landing'); // Quay về trang Landing nếu dữ liệu lỗi
}
} else {
setCurrentPage('landing'); // Quay về trang Landing nếu chưa đăng nhập
}
}
}, []); // Chỉ chạy một lần khi component mount
const handleLoginSuccess = (loggedInUser: any) => {
setUser(loggedInUser);
setCurrentPage('explore');
};
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setUser(null);
setCurrentPage('landing');
};
const handleViewTour = (tourId: string) => {
setCurrentTourId(tourId);
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
setCurrentPage('tourDetail');
};
const handleBackFromTourDetail = () => {
setCurrentTourId(null);
setIsPublicTourView(false);
// Quay về trang khám phá nếu đã đăng nhập, ngược lại quay về Landing
if (user) {
setCurrentPage('explore');
} else {
setCurrentPage('landing');
}
};
const handleBackFromSignup = () => {
setCurrentPage('landing');
};
const handleSignupSuccess = () => {
setCurrentPage('landing'); // Quay về trang Landing sau khi đăng ký thành công
};
return (
<ConfirmProvider>
<NotificationProvider>
{(() => {
if (currentPage === 'tourDetail') {
return (
<TourDetailPage
tourId={currentTourId!}
onBack={handleBackFromTourDetail}
isPublicView={isPublicTourView}
/>
);
}
if (currentPage === 'explore') {
return (
<ExploreMap
onBack={handleBackFromTourDetail}
onLogout={handleLogout}
user={user}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
/>
);
}
if (currentPage === 'myPhotos') {
return (
<MyPhotosPage onBack={() => setCurrentPage('explore')} />
);
}
if (currentPage === 'signup') {
return <SignupPage onBack={handleBackFromSignup} onSuccess={handleSignupSuccess} />;
}
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
})()}
</NotificationProvider>
</ConfirmProvider>
);
}
export default App;
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react'; import { format, parseISO } from 'date-fns';
import { useTourStore } from './useTourStore.js'; import { X, MapPin, Loader2, Clock, Map as MapIcon, Navigation, Search } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
import { useNotification } from '@/hooks/useNotification';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
@@ -59,7 +61,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: '',
@@ -77,9 +79,14 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
plannedEnd: '' plannedEnd: ''
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [searchResults, setSearchResults] = useState<any[]>([]);
const [hasNoResults, setHasNoResults] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const searchTimeout = useRef<any>(null);
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng // Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
const { legs, addLocation, updateLocation, mapCenter, currentTour } = useTourStore(); const { legs, addLocation, updateLocation, mapCenter, currentTour, userRole } = useTourStore();
const notify = useNotification();
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook // 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
useEffect(() => { useEffect(() => {
@@ -96,7 +103,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
type: editingLocation.type || 'VISIT', type: editingLocation.type || 'VISIT',
legId: editingLocation.legId || '', legId: editingLocation.legId || '',
note: editingLocation.note || '', note: editingLocation.note || '',
expenseAmount: expense?.amount?.toString() || '', expenseAmount: expense?.amount ? Number(expense.amount).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".") : '',
expenseCategory: expense?.category || 'OTHER', expenseCategory: expense?.category || 'OTHER',
expenseDescription: expense?.description || '', expenseDescription: expense?.description || '',
expenseNote: expense?.note || '', expenseNote: expense?.note || '',
@@ -117,11 +124,31 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} }
}, [initialLegId, editingLocation, isOpen]); }, [initialLegId, editingLocation, isOpen]);
// Memoize tọa độ để tránh việc bản đồ tự động reset tâm khi re-render (ví dụ khi gõ tìm kiếm)
const currentCoords = useMemo<[number, number]>(
() => [formData.latitude, formData.longitude],
[formData.latitude, formData.longitude]
);
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng đó làm tham chiếu)
useEffect(() => { useEffect(() => {
if (isOpen && !formData.name) { if (isOpen && !editingLocation && formData.legId && !formData.name) {
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] })); const selectedLeg = legs.find(l => l.id === formData.legId);
if (selectedLeg && selectedLeg.locations && selectedLeg.locations.length > 0) {
// Di chuyển đến địa điểm cuối cùng của chặng để người dùng thấy điểm nối tiếp
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
setFormData(prev => ({
...prev,
latitude: lastLoc.latitude,
longitude: lastLoc.longitude
}));
} else {
// Nếu chặng chưa có điểm nào, mặc định dùng vị trí trung tâm hiện tại của tour
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
}
} }
}, [isOpen, mapCenter]); }, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
// 2. Thực hiện các tính toán và hàm xử lý // 2. Thực hiện các tính toán và hàm xử lý
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : ''); const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
@@ -129,6 +156,55 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới'); const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm'); const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
const handleSearchLocation = (query: string) => {
setFormData(prev => ({ ...prev, name: query }));
if (searchTimeout.current) clearTimeout(searchTimeout.current);
if (query.trim().length < 2) {
setSearchResults([]);
setIsSearching(false);
setHasNoResults(false);
return;
}
setIsSearching(true);
setHasNoResults(false);
searchTimeout.current = setTimeout(async () => {
try {
// Loại bỏ countrycodes=vn để tìm kiếm rộng hơn, thêm namedetails=1 để lấy tên chính xác
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=15&addressdetails=1&namedetails=1&accept-language=vi`, {
headers: {
'Accept-Language': 'vi'
}
});
const data = await res.json();
setSearchResults(data);
setHasNoResults(data.length === 0);
} catch (e) {
console.error("Lỗi tìm kiếm địa điểm:", e);
} finally {
setIsSearching(false);
}
}, 500);
};
const selectSearchResult = (result: any) => {
const lat = parseFloat(result.lat);
const lon = parseFloat(result.lon);
// Ưu tiên lấy tên từ namedetails nếu có, nếu không lấy phần đầu của display_name
const locationName = result.namedetails?.name || result.display_name.split(',')[0];
setFormData(prev => ({
...prev,
name: locationName,
address: result.display_name,
latitude: lat,
longitude: lon
}));
setSearchResults([]);
};
const handlePickLocation = async (latlng: L.LatLng) => { const handlePickLocation = async (latlng: L.LatLng) => {
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng })); setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
@@ -146,8 +222,60 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} catch (e) {} } catch (e) {}
}; };
const handleUseCurrentLocation = () => {
if (!navigator.geolocation) {
notify({ title: 'Thông báo', message: "Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị GPS.", type: 'info' });
return;
}
// Kiểm tra môi trường Secure Context (HTTPS) - Bắt buộc cho Geolocation trên Mobile
if (!window.isSecureContext) {
alert("Tính năng định vị GPS yêu cầu kết nối bảo mật (HTTPS). Nếu bạn đang truy cập qua địa chỉ IP, vui lòng sử dụng HTTPS hoặc Localhost.");
return;
}
navigator.geolocation.getCurrentPosition(
async (pos) => {
const latlng = L.latLng(pos.coords.latitude, pos.coords.longitude);
const now = new Date();
const formattedTime = format(now, "yyyy-MM-dd'T'HH:mm");
setFormData(prev => ({
...prev,
latitude: latlng.lat,
longitude: latlng.lng,
plannedStart: formattedTime
}));
// Tự động thực hiện reverse geocoding để lấy tên địa điểm và địa chỉ
handlePickLocation(latlng);
},
(err) => {
let errorMessage = "Không thể lấy vị trí: ";
switch(err.code) {
case err.PERMISSION_DENIED:
errorMessage += "Bạn đã từ chối quyền truy cập vị trí.";
break;
case err.POSITION_UNAVAILABLE:
errorMessage += "Thông tin vị trí không khả dụng.";
break;
case err.TIMEOUT:
errorMessage += "Hết thời gian chờ yêu cầu định vị.";
break;
default: errorMessage += err.message;
}
notify({ title: 'Lỗi định vị', message: errorMessage, type: 'error' });
},
{
enableHighAccuracy: true, // Ưu tiên dùng GPS thay vì Wifi/Cell tower
timeout: 10000, // Chờ tối đa 10 giây
maximumAge: 0 // Không dùng vị trí cũ trong cache
}
);
};
// 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();
@@ -155,6 +283,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
try { try {
const payload: any = { const payload: any = {
...formData, ...formData,
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
legId: currentLegId, legId: currentLegId,
latitude: parseFloat(formData.latitude as any), latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any), longitude: parseFloat(formData.longitude as any),
@@ -167,7 +296,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} }
onClose(); onClose();
} catch (error) { } catch (error) {
alert('Lỗi khi lưu địa điểm'); notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -187,22 +316,91 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
</div> </div>
{/* Mini Map Picker */} {/* Mini Map Picker */}
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group"> <div className="h-64 w-full rounded-3xl overflow-hidden mb-6 border border-gray-100 relative shadow-xl group">
<MapContainer center={[formData.latitude, formData.longitude]} zoom={13} className="h-full w-full"> {/* Map Search Bar Overlay - Tích hợp tìm kiếm trực tiếp trên bản đồ */}
<div className="absolute top-3 left-3 right-3 z-[1001] pointer-events-none">
<div className="relative max-w-sm pointer-events-auto">
<div className="relative group">
<input
type="text"
placeholder="Tìm địa điểm trên bản đồ..."
className="w-full pl-11 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold text-gray-800"
value={formData.name}
onChange={e => handleSearchLocation(e.target.value)}
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
{isSearching ? (
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
) : formData.name && (
<button
type="button"
onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); setHasNoResults(false); }}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-full text-gray-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Dropdown kết quả tìm kiếm ngay trong khung bản đồ */}
{(searchResults.length > 0 || hasNoResults) && (
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-40 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
{hasNoResults ? (
<div className="px-4 py-4 text-center text-gray-400 text-xs italic">Không tìm thấy đa điểm phù hợp...</div>
) : (
searchResults.map((result, idx) => (
<button
key={idx}
type="button"
onClick={() => selectSearchResult(result)}
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors flex flex-col gap-0.5"
>
<div className="flex items-center justify-between gap-2">
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
{result.type && (
<span className="text-[8px] font-black uppercase text-blue-400 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100 shrink-0">{result.type}</span>
)}
</div>
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div>
</button>
))
)}
</div>
)}
</div>
</div>
<MapContainer center={currentCoords} zoom={13} className="h-full w-full" zoomControl={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<Marker position={[formData.latitude, formData.longitude]} /> <Marker position={currentCoords} />
<MapPicker center={[formData.latitude, formData.longitude]} onPick={handlePickLocation} /> <MapPicker center={currentCoords} onPick={handlePickLocation} />
</MapContainer> </MapContainer>
<div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100"> <div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100">
CHUỘT PHẢI Đ CHỌN VỊ TRÍ CHUỘT PHẢI Đ CHỌN VỊ TRÍ
</div> </div>
</div> </div>
{/* Nút lấy vị trí và thời gian hiện tại - Chỉ dành cho OWNER/MANAGER khi thêm mới */}
{!editingLocation && (userRole === 'OWNER' || userRole === 'MANAGER') && (
<button
type="button"
onClick={handleUseCurrentLocation}
className="w-full mb-6 py-4 bg-indigo-50 hover:bg-indigo-100 text-indigo-600 rounded-2xl flex items-center justify-center gap-2 text-xs font-black uppercase tracking-widest border border-indigo-100 transition-all active:scale-95 shadow-sm"
>
<Navigation className="w-4 h-4 fill-current" /> Sử dụng vị trí & thời gian hiện tại
</button>
)}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1">Tên đa điểm</label> <label className="block text-sm font-bold text-gray-700 mb-1">Tên đa điểm</label>
<input required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none" <input
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} /> required
placeholder="Tên địa điểm..."
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all font-bold"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1">Đa chỉ</label> <label className="block text-sm font-bold text-gray-700 mb-1">Đa chỉ</label>
@@ -220,9 +418,13 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label> <label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label>
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm" <input type="text" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
placeholder="0" placeholder="0"
value={formData.expenseAmount} onChange={e => setFormData({...formData, expenseAmount: e.target.value})} /> value={formData.expenseAmount} onChange={e => {
const rawValue = e.target.value.replace(/\D/g, ""); // Chỉ lấy số
const formattedValue = rawValue.replace(/\B(?=(\d{3})+(?!\d))/g, "."); // Thêm dấu chấm
setFormData({...formData, expenseAmount: formattedValue});
}} />
</div> </div>
<div> <div>
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label> <label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
@@ -269,7 +471,10 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none" <select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}> value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}>
{legs.map(leg => ( {legs.map(leg => (
<option key={leg.id} value={leg.id}>Chặng {leg.sequence}: {leg.note || 'Không có tên'}</option> <option key={leg.id} value={leg.id}>
Chặng {leg.sequence}: {leg.note || 'Không có tên'}
{leg.startDate ? ` (${format(parseISO(leg.startDate), 'dd/MM')})` : ''}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -1,5 +1,7 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react'; import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react';
import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
interface AddMemberModalProps { interface AddMemberModalProps {
isOpen: boolean; isOpen: boolean;
@@ -9,7 +11,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 }) => {
@@ -21,22 +24,22 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [fetchError, setFetchError] = useState(''); const [fetchError, setFetchError] = useState('');
const [submitError, setSubmitError] = useState(''); const [submitError, setSubmitError] = useState('');
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [confirmTarget, setConfirmTarget] = useState<{ userId: string; name: string } | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const confirm = useConfirm();
const notify = useNotification();
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]); const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]); const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]);
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]); const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER'; const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true); setLoading(true);
setFetchError(''); setFetchError('');
try { try {
const API_BASE = `http://${window.location.hostname}:3001`; const res = await fetch(`/api/v1/users?q=${encodeURIComponent(query)}`, {
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(query)}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
}); });
if (!res.ok) throw new Error('Không thể tải danh sách người dùng'); if (!res.ok) throw new Error('Không thể tải danh sách người dùng');
@@ -66,19 +69,17 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const handleRemove = async (userId: string, memberName: string) => { const handleRemove = async (userId: string, memberName: string) => {
if (!onRemoveMember) return; if (!onRemoveMember) return;
setConfirmTarget({ userId, name: memberName }); const isConfirmed = await confirm({
setIsConfirmOpen(true); title: 'Xóa thành viên',
}; message: `Bạn có chắc chắn muốn xóa ${memberName} khỏi tour?`
});
const confirmRemove = async () => { if (isConfirmed) {
if (!confirmTarget || !onRemoveMember) return; try {
try { await onRemoveMember(userId);
await onRemoveMember(confirmTarget.userId); } catch (err: any) {
} catch (err: any) { setSubmitError(err.message || 'Không thể xóa thành viên');
setSubmitError(err.message || 'Không thể xóa thành viên'); }
} finally {
setIsConfirmOpen(false);
setConfirmTarget(null);
} }
}; };
@@ -86,10 +87,9 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
if (!onMemberAdded) return; if (!onMemberAdded) return;
setActionLoading(reqId); setActionLoading(reqId);
try { try {
const API_BASE = `http://${window.location.hostname}:3001`;
const endpoint = action === 'accept' const endpoint = action === 'accept'
? `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/accept` ? `/api/v1/tours/${tourId}/join-requests/${reqId}/accept`
: `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/reject`; : `/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }, headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
@@ -100,7 +100,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
} }
await onMemberAdded(); await onMemberAdded();
} catch (err: any) { } catch (err: any) {
alert(err.message || 'Thao tác thất bại'); notify({ title: 'Lỗi', message: err.message || 'Thao tác thất bại', type: 'error' });
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
@@ -111,12 +111,11 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
setSubmitting(true); setSubmitting(true);
setSubmitError(''); setSubmitError('');
try { try {
const API_BASE = `http://${window.location.hostname}:3001`;
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`; const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
const body = canCreateDirectly const body = canCreateDirectly
? { userId: selectedUser, role } ? { userId: selectedUser, role }
: { userId: selectedUser }; : { userId: selectedUser };
const res = await fetch(`${API_BASE}${endpoint}`, { const res = await fetch(`${endpoint}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -137,7 +136,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">
@@ -321,26 +320,6 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</button> </button>
</div> </div>
</div> </div>
{isConfirmOpen && (
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={() => setIsConfirmOpen(false)} />
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
<h3 className="text-base font-bold text-gray-900">Xác nhận xóa thành viên</h3>
<p className="mt-2 text-sm text-gray-600">
Bạn chắc muốn xóa <span className="font-semibold text-gray-800">{confirmTarget?.name}</span> khỏi tour này?
</p>
<div className="mt-4 flex justify-end gap-2">
<button onClick={() => setIsConfirmOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy
</button>
<button onClick={confirmRemove} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
Xóa
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
+168
View File
@@ -0,0 +1,168 @@
import React, { useState, useRef } from 'react';
import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useTourStore } from '@/store/useTourStore';
interface AddPhotoModalProps {
isOpen: boolean;
onClose: () => void;
tourId: string;
onSuccess?: () => void;
}
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess }) => {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
const fetchTour = useTourStore(state => state.fetchTour);
if (!isOpen) return null;
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
const newValidFiles: File[] = [];
const newValidPreviews: string[] = [];
for (const file of files) {
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
if (!file || file.size === 0) {
notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' });
continue;
}
const previewUrl = URL.createObjectURL(file);
// 2. Kiểm tra tính toàn vẹn: Thử load ảnh vào bộ nhớ để xác nhận file "tồn tại" và đọc được
const isValidImage = await new Promise<boolean>((resolve) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = previewUrl;
});
if (isValidImage) {
newValidFiles.push(file);
newValidPreviews.push(previewUrl);
} else {
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ
notify({ title: 'Lỗi ảnh', message: `Không thể đọc nội dung ảnh: ${file.name}. Vui lòng kiểm tra lại file.`, type: 'error' });
}
}
setSelectedFiles(prev => [...prev, ...newValidFiles]);
setPreviews(prev => [...prev, ...newValidPreviews]);
}
};
const removeFile = (index: number) => {
// Thu hồi URL khi xóa khỏi danh sách chờ để giải phóng bộ nhớ
URL.revokeObjectURL(previews[index]);
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
setPreviews(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedFiles.length === 0) return;
setIsUploading(true);
try {
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('images', file);
});
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
});
if (!response.ok) throw new Error('Upload failed');
notify({
title: 'Thành công',
message: `Đã tải lên ${selectedFiles.length} ảnh.`,
type: 'success'
});
fetchTour(tourId);
if (onSuccess) onSuccess();
onClose();
// Giải phóng bộ nhớ sau khi hoàn tất
previews.forEach(url => URL.revokeObjectURL(url));
setSelectedFiles([]);
setPreviews([]);
} catch (error) {
notify({
title: 'Lỗi',
message: 'Không thể tải ảnh lên. Vui lòng thử lại.',
type: 'error'
});
} finally {
setIsUploading(false);
}
};
return (
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<ImageIcon className="w-6 h-6 text-blue-600" /> Tải nh lên
</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-200 rounded-[32px] p-10 flex flex-col items-center justify-center cursor-pointer hover:bg-blue-50/50 hover:border-blue-200 transition-all mb-6 group"
>
<input type="file" ref={fileInputRef} className="hidden" multiple accept="image/*" onChange={handleFileChange} />
<div className="w-16 h-16 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 mb-4 group-hover:scale-110 transition-transform shadow-inner">
<Upload className="w-8 h-8" />
</div>
<p className="text-sm font-black text-gray-700">Nhấn đ chọn nh</p>
<p className="text-xs text-gray-400 mt-1 font-medium">Hỗ trợ JPG, PNG, WEBP</p>
</div>
{previews.length > 0 && (
<div className="flex-1 overflow-y-auto mb-6 pr-2">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3">Đã chọn {previews.length} tệp</p>
<div className="grid grid-cols-3 gap-3">
{previews.map((src, idx) => (
<div key={idx} className="relative aspect-square rounded-2xl overflow-hidden border border-gray-100 shadow-sm group">
<img src={src} className="w-full h-full object-cover" alt="preview" />
<button
type="button"
onClick={() => removeFile(idx)}
className="absolute top-1.5 right-1.5 p-1.5 bg-red-500/80 backdrop-blur-sm text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
<button
disabled={isUploading || selectedFiles.length === 0}
className="w-full py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-black uppercase tracking-widest rounded-2xl flex items-center justify-center gap-2 shadow-lg shadow-blue-100 transition-all active:scale-95"
>
{isUploading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Xác nhận tải lên'}
</button>
</form>
</div>
</div>
);
};
+217
View File
@@ -0,0 +1,217 @@
import React, { useState, useEffect } from 'react';
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
import { io } from 'socket.io-client';
import { useTourStore } from '@/store/useTourStore';
import { useConfirm } from '@/hooks/useConfirm';
interface Comment {
id: string;
userName: string;
content: string;
createdAt: string;
userId: string;
}
interface CommentModalProps {
isOpen: boolean;
onClose: () => void;
locationId: string;
locationName: string;
onCommentAdded?: () => void; // Callback to update comment count on parent
onCommentDeleted?: () => void;
isPublicView?: boolean; // New prop to indicate public view
}
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName, onCommentAdded, onCommentDeleted, isPublicView = false }) => {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
const confirm = useConfirm();
const userRole = useTourStore(state => state.userRole);
const currentUserId = React.useMemo(() => {
try {
const user = JSON.parse(localStorage.getItem('user') || '{}');
return user.id;
} catch { return null; }
}, []);
const fetchComments = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
headers
});
if (res.ok) {
const data = await res.json();
setComments(data.map((c: any) => ({
id: c.id,
userName: c.user?.name || 'Ẩn danh',
content: c.content,
createdAt: c.createdAt,
userId: c.userId
})));
}
} catch (error) {
console.error('Lỗi khi tải bình luận:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen || !locationId) return;
fetchComments();
// Lắng nghe bình luận mới qua Proxy (không cần hardcode URL)
const socket = io();
socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể
socket.on('commentAdded', (newCommentData: any) => {
if (newCommentData.locationId === locationId) {
setComments(prev => {
// Tránh trùng lặp nếu chính mình gửi
if (prev.find(c => c.id === newCommentData.id)) return prev;
return [...prev, {
id: newCommentData.id,
userName: newCommentData.user?.name || 'Ẩn danh',
content: newCommentData.content,
createdAt: newCommentData.createdAt
}];
});
}
});
return () => { socket.disconnect(); };
}, [isOpen, locationId]);
const handleSend = async () => {
if (!newComment.trim()) return;
try {
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ content: newComment })
});
if (res.ok) {
setNewComment('');
fetchComments();
onCommentAdded?.();
}
} catch (error) {
console.error('Lỗi khi gửi bình luận:', error);
}
};
const handleDelete = async (commentId: string) => {
try {
const res = await fetch(`/api/v1/locations/comments/${commentId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (res.ok) {
setComments(prev => prev.filter(c => c.id !== commentId));
onCommentDeleted?.();
}
} catch (error) {
console.error('Lỗi khi xóa bình luận:', error);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[5000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onClose} />
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10">
<div>
<h3 className="text-xl font-black text-gray-900 flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-600" />
Bình luận
</h3>
<p className="text-xs text-gray-500 font-bold uppercase tracking-widest mt-1 truncate max-w-[250px]">{locationName}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* Comment List */}
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50/50">
{isLoading ? (
<div className="flex justify-center py-10"><Loader2 className="w-6 h-6 animate-spin text-blue-600" /></div>
) : comments.length === 0 ? (
<div className="text-center py-10 text-gray-400 italic text-sm">Chưa bình luận nào.</div>
) : (
comments.map((c) => (
<div key={c.id} className="flex gap-3 animate-in slide-in-from-left-2 duration-300">
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 border border-blue-200">
<User className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1">
<div className="bg-white p-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm">
<div className="flex justify-between items-start mb-1">
<p className="text-xs font-black text-gray-900">{c.userName}</p>
{(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && (
<button
onClick={() => setConfirmState({ open: true, commentId: c.id })}
className="text-gray-400 hover:text-red-500 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<p className="text-sm text-gray-600 leading-relaxed">{c.content}</p>
</div>
<p className="text-[10px] text-gray-400 mt-1 ml-1 font-medium">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
))
)}
</div>
{/* Input Area */}
<div className="p-4 bg-white border-t border-gray-100">
<div className="relative flex items-center gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
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"
/>
<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" />
</button>
</div>
</div>
</div>
<ConfirmModal
isOpen={confirmState.open}
title="Xóa bình luận"
message="Bạn có chắc chắn muốn xóa bình luận này không? Hành động này sẽ không thể hoàn tác."
onConfirm={() => {
handleDelete(confirmState.commentId);
setConfirmState({ open: false, commentId: '' });
}}
onCancel={() => setConfirmState({ open: false, commentId: '' })}
/>
</div>
);
};
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
interface ConfirmModalProps {
isOpen: boolean;
title?: string;
message?: string;
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, title, message, onConfirm, onCancel }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4">
{/* Backdrop */}
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onCancel} />
{/* Modal Content */}
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-4">
<div className="w-12 h-12 rounded-2xl bg-red-50 flex items-center justify-center text-red-500 shadow-inner">
<AlertTriangle className="w-6 h-6" />
</div>
<button onClick={onCancel} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<h3 className="text-xl font-black text-gray-900 mb-2">{title || 'Xác nhận'}</h3>
<p className="text-sm text-gray-500 mb-8 leading-relaxed">
{message || 'Bạn có chắc chắn muốn thực hiện hành động này không?'}
</p>
<div className="grid grid-cols-2 gap-3">
<button
onClick={onCancel}
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
>
Hủy bỏ
</button>
<button
onClick={onConfirm}
className="py-4 bg-red-600 hover:bg-red-700 text-white font-bold rounded-2xl shadow-lg shadow-red-100 transition-all active:scale-95"
>
Xác nhận
</button>
</div>
</div>
</div>
);
};
@@ -1,14 +1,22 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Trash2 } from 'lucide-react'; import { Trash2, Users, Tag as TagIcon } from 'lucide-react';
import { useTourStore } from './useTourStore.js'; import { useTourStore } from '@/store/useTourStore';
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => { export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
const [adultCount, setAdultCount] = useState(2);
const [childCount, setChildCount] = useState(1);
const [childDiscount, setChildDiscount] = useState(30);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const createTour = useTourStore((state) => state.createTour); const createTour = useTourStore((state) => state.createTour);
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [customTag, setCustomTag] = useState('');
const [members, setMembers] = useState<any[]>([]); const [members, setMembers] = useState<any[]>([]);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<any[]>([]); const [results, setResults] = useState<any[]>([]);
@@ -23,8 +31,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
return; return;
} }
try { try {
const API_BASE = `http://${window.location.hostname}:3001`; const res = await fetch(`/api/v1/users?q=${encodeURIComponent(value)}`, {
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(value)}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
}); });
if (!res.ok) throw new Error('Không thể tải người dùng'); if (!res.ok) throw new Error('Không thể tải người dùng');
@@ -47,13 +54,37 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
setMembers((prev) => prev.filter((m) => m.id !== userId)); setMembers((prev) => prev.filter((m) => m.id !== userId));
}; };
const toggleTag = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
);
};
const addCustomTag = () => {
const tag = customTag.trim();
if (tag && !selectedTags.includes(tag)) {
setSelectedTags([...selectedTags, tag]);
setCustomTag('');
}
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
setError(''); setError('');
try { try {
const memberIds = members.map((m) => m.id); const memberIds = members.map((m) => m.id);
const tour = await createTour({ title, startDate, endDate, memberIds }); const tour = await createTour({
title,
description,
startDate,
endDate,
memberIds,
adultCount,
childCount,
childDiscount,
tags: selectedTags
});
onSuccess(tour); onSuccess(tour);
onClose(); onClose();
} catch (e: any) { } catch (e: any) {
@@ -84,6 +115,56 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
/> />
</div> </div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2 flex items-center gap-2">
<TagIcon className="w-4 h-4" /> Phân loại Tour
</label>
<div className="flex flex-wrap gap-2">
{availableTags.map(tag => (
<button
key={tag}
type="button"
onClick={() => toggleTag(tag)}
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all border ${
selectedTags.includes(tag)
? 'bg-blue-600 text-white border-blue-600 shadow-md shadow-blue-100'
: 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
}`}
>
{tag}
</button>
))}
</div>
<div className="flex gap-2 mt-3">
<input
type="text"
value={customTag}
onChange={(e) => setCustomTag(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustomTag())}
placeholder="Thêm nhãn tùy chỉnh..."
className="flex-1 px-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={addCustomTag}
className="px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all"
>
Thêm
</button>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1"> tả chuyến đi</label>
<textarea
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 resize-none text-sm"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Viết vài dòng giới thiệu về hành trình..."
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label> <label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label>
@@ -105,6 +186,31 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
</div> </div>
</div> </div>
<div className="bg-blue-50/50 p-4 rounded-2xl border border-blue-100 space-y-3">
<div className="flex items-center gap-2 text-blue-600 mb-1">
<Users className="w-4 h-4" />
<span className="text-xs font-black uppercase tracking-wider"> cấu đoàn & Đnh mức chi phí</span>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Người lớn</label>
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={adultCount} onChange={e => setAdultCount(Number(e.target.value))} />
</div>
<div>
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Trẻ em</label>
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={childCount} onChange={e => setChildCount(Number(e.target.value))} />
</div>
<div>
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Giảm trẻ em %</label>
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={childDiscount} onChange={e => setChildDiscount(Number(e.target.value))} />
</div>
</div>
<p className="text-[10px] text-blue-400 italic font-medium">* Dùng đ tính toán đơn giá bình quân trong báo cáo chi phí.</p>
</div>
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label> <label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
File diff suppressed because one or more lines are too long
@@ -1,8 +1,10 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns'; import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react'; import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare } from 'lucide-react';
import { useTourStore } from './useTourStore.js'; import { useTourStore } from '@/store/useTourStore';
import { ConfirmModal } from './ConfirmModal.js'; import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
import { CommentModal } from '@/components/CommentModal';
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => { const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
if (!actual) return null; if (!actual) return null;
@@ -36,12 +38,72 @@ const formatTravelTime = (minutes: number) => {
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`; return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
}; };
export const ItineraryTimeline = ({ export const ItineraryTimeline = ({
onAddLocation, onAddLocation,
onEditLocation onEditLocation,
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => { isPublicView = false
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore(); }: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void, isPublicView?: boolean }) => {
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false }); // 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 legs = useTourStore(state => state.legs);
const userRole = useTourStore(state => state.userRole);
const optimizeRouting = useTourStore(state => state.optimizeRouting);
const addLeg = useTourStore(state => state.addLeg);
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 initializeLegs = useTourStore(state => state.initializeLegs);
const fetchTour = useTourStore(state => state.fetchTour);
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 confirm = useConfirm();
const notify = useNotification();
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
const [tempLegCount, setTempLegCount] = useState(3);
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
const [commentLocationId, setCommentLocationId] = useState('');
const [commentLocationName, setCommentLocationName] = useState('');
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
const handleCommentIncrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
...leg,
locations: leg.locations.map(loc =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
: loc
)
}));
// Dùng setState của Zustand để cập nhật một phần dữ liệu
useTourStore.setState({ legs: updatedLegs });
};
const handleCommentDecrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map(leg => ({
...leg,
locations: leg.locations.map(loc =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
: loc
)
}));
useTourStore.setState({ legs: updatedLegs });
};
// State cho Modal sửa chặng
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingLegData, setEditingLegData] = useState({
id: '',
note: '',
description: '',
startDate: '',
endDate: ''
});
const toggleComplete = async (locationId: string) => { const toggleComplete = async (locationId: string) => {
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái // Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
@@ -56,50 +118,66 @@ export const ItineraryTimeline = ({
}; };
const handleDeclareLegs = async () => { const handleDeclareLegs = async () => {
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3"); setTempLegCount(legs.length > 0 ? legs.length : 3);
const count = parseInt(countStr || "0"); setIsLegCountModalOpen(true);
if (count > 0 && currentTour) { };
await initializeLegs(currentTour.id, count);
const confirmDeclareLegs = async () => {
if (tempLegCount > 0 && tempLegCount <= 20 && currentTour) {
await initializeLegs(currentTour.id, tempLegCount);
} }
setIsLegCountModalOpen(false);
}; };
const handleEditLeg = async (leg: any) => { const handleEditLeg = async (leg: any) => {
const note = window.prompt("Sửa ghi chú chặng:", leg.note || ""); setEditingLegData({
if (note !== null) { id: leg.id,
await updateLeg(leg.id, { note }); note: leg.note || "",
description: leg.description || "",
startDate: leg.startDate ? leg.startDate.split('T')[0] : "",
endDate: leg.endDate ? leg.endDate.split('T')[0] : ""
});
setIsEditModalOpen(true);
};
const saveLegEdit = async () => {
if (editingLegData.id) {
await updateLeg(editingLegData.id, {
note: editingLegData.note,
description: editingLegData.description,
startDate: editingLegData.startDate || null,
endDate: editingLegData.endDate || null
});
setIsEditModalOpen(false);
} }
}; };
const handleDeleteLeg = async (legId: string) => { const handleDeleteLeg = async (legId: string) => {
setConfirmState({ const isConfirmed = await confirm({
open: true,
title: 'Xóa chặng', title: 'Xóa chặng',
message: 'Bạn có chắc chắn muốn xóa chặng này?', message: 'Bạn có chắc chắn muốn xóa chặng này?'
onConfirm: async () => {
try {
await deleteLeg(legId);
} catch (err: any) {
alert(err.message);
} finally {
setConfirmState({ open: false });
}
},
}); });
if (isConfirmed) {
try {
await deleteLeg(legId);
} catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' });
}
}
}; };
const handleDeleteLocation = async (id: string) => { const handleDeleteLocation = async (id: string) => {
setConfirmState({ const isConfirmed = await confirm({
open: true,
title: 'Xóa địa điểm', title: 'Xóa địa điểm',
message: 'Bạn có chắc chắn muốn xóa địa điểm này?', message: 'Bạn có chắc chắn muốn xóa địa điểm này?'
onConfirm: async () => {
try {
await deleteLocation(id);
} catch (err: any) { alert(err.message); } finally {
setConfirmState({ open: false });
}
},
}); });
if (isConfirmed) {
try {
await deleteLocation(id);
} catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' });
}
}
}; };
return ( return (
@@ -128,10 +206,19 @@ export const ItineraryTimeline = ({
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2"> <div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1"> <div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm"> <span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm shrink-0">
{leg.sequence} {leg.sequence}
</span> </span>
{leg.note || `Chi tiết Chặng ${leg.sequence}`} <div className="flex flex-col overflow-hidden">
<span className="truncate leading-tight">{leg.note || `Chi tiết Chặng ${leg.sequence}`}</span>
{leg.startDate && (
<span className="text-[10px] text-gray-400 font-black uppercase tracking-wider flex items-center gap-1 mt-0.5">
<CalendarIcon className="w-2.5 h-2.5" />
{format(parseISO(leg.startDate), 'dd/MM/yyyy')}
{leg.endDate && leg.endDate !== leg.startDate && ` - ${format(parseISO(leg.endDate), 'dd/MM/yyyy')}`}
</span>
)}
</div>
</div> </div>
{prevLegLastLoc && ( {prevLegLastLoc && (
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10"> <div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
@@ -140,7 +227,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)}
@@ -174,7 +261,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)}
@@ -189,7 +276,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 */}
@@ -267,7 +354,7 @@ export const ItineraryTimeline = ({
<div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1"> <div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1">
<div className="flex items-center gap-1 font-bold"> <div className="flex items-center gap-1 font-bold">
<Zap className="w-3 h-3" /> <Zap className="w-3 h-3" />
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ ({locationExpense.category})</span> <span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ</span>
</div> </div>
{locationExpense.description && ( {locationExpense.description && (
<div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div> <div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div>
@@ -283,7 +370,18 @@ export const ItineraryTimeline = ({
</div> </div>
<div className="text-right flex flex-col items-end"> <div className="text-right flex flex-col items-end">
<div className="flex items-center text-sm font-medium text-blue-600"> <button
onClick={() => {
setCommentLocationId(location.id);
setCommentLocationName(location.name);
setIsCommentModalOpen(true);
}}
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100 mb-2"
>
<MessageSquare className="w-3 h-3" />
BÌNH LUẬN {location._count?.comments > 0 && `(${location._count.comments})`}
</button>
<div className="flex items-center text-sm font-black text-blue-600">
<Clock className="w-3 h-3 mr-1" /> <Clock className="w-3 h-3 mr-1" />
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'} {location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
</div> </div>
@@ -292,7 +390,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" />
@@ -336,7 +434,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}
@@ -354,12 +452,134 @@ export const ItineraryTimeline = ({
</div> </div>
)} )}
</div> </div>
<ConfirmModal
isOpen={confirmState.open} {/* Modal Khai báo số chặng (Popover) */}
title={confirmState.title} {isLegCountModalOpen && (
message={confirmState.message} <div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
onConfirm={() => confirmState.onConfirm?.()} <div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsLegCountModalOpen(false)} />
onCancel={() => setConfirmState({ open: false })} <div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-gray-900">Số chặng lộ trình</h3>
<button onClick={() => setIsLegCountModalOpen(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<p className="text-sm text-gray-500 mb-6 leading-relaxed">
Bạn muốn chia chuyến đi này thành bao nhiêu chặng nhỏ? (Tối đa 20 chặng)
</p>
<div className="flex items-center justify-center gap-6 mb-8">
<button
onClick={() => setTempLegCount(Math.max(1, tempLegCount - 1))}
className="w-12 h-12 rounded-2xl border-2 border-gray-100 flex items-center justify-center text-2xl font-bold text-gray-400 hover:border-blue-200 hover:text-blue-600 transition-all"
>
-
</button>
<span className="text-4xl font-black text-blue-600 w-12 text-center">{tempLegCount}</span>
<button
onClick={() => setTempLegCount(Math.min(20, tempLegCount + 1))}
className="w-12 h-12 rounded-2xl border-2 border-gray-100 flex items-center justify-center text-2xl font-bold text-gray-400 hover:border-blue-200 hover:text-blue-600 transition-all"
>
+
</button>
</div>
<button
onClick={confirmDeclareLegs}
className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
>
Xác nhận
</button>
</div>
</div>
)}
{/* Modal Chỉnh sửa Chặng (Popover) */}
{isEditModalOpen && (
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsEditModalOpen(false)} />
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-gray-900">Chỉnh sửa Chặng</h3>
<button onClick={() => setIsEditModalOpen(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<div className="space-y-5">
<div>
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Tên chặng</label>
<input
type="text"
value={editingLegData.note}
onChange={(e) => setEditingLegData({ ...editingLegData, note: e.target.value })}
placeholder="VD: Ngày 1: Khởi hành"
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
/>
</div>
<div>
<label className="flex items-center gap-2 text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">
<AlignLeft className="w-3 h-3" /> tả chi tiết
</label>
<textarea
value={editingLegData.description}
onChange={(e) => setEditingLegData({ ...editingLegData, description: e.target.value })}
placeholder="Mô tả các hoạt động chính trong chặng này..."
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-2 text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">
<CalendarIcon className="w-3 h-3" /> Bắt đu
</label>
<input
type="date"
value={editingLegData.startDate}
onChange={(e) => setEditingLegData({ ...editingLegData, startDate: e.target.value })}
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
/>
</div>
<div>
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Kết thúc</label>
<input
type="date"
value={editingLegData.endDate}
onChange={(e) => setEditingLegData({ ...editingLegData, endDate: e.target.value })}
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-8">
<button
onClick={() => setIsEditModalOpen(false)}
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
>
Hủy
</button>
<button
onClick={saveLegEdit}
className="py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
>
Lưu thay đi
</button>
</div>
</div>
</div>
)}
<CommentModal
isOpen={isCommentModalOpen}
onClose={() => setIsCommentModalOpen(false)}
locationId={commentLocationId}
locationName={commentLocationName}
isPublicView={isPublicView}
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
/> />
</div> </div>
); );
@@ -22,8 +22,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
setIsLoading(true); setIsLoading(true);
try { try {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/auth/login`, {
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
interface NotificationModalProps {
isOpen: boolean;
title?: string;
message?: string;
type?: 'success' | 'error' | 'info';
onConfirm: () => void;
}
/**
* NotificationModal - Component hiển thị thông báo phản hồi cho người dùng
*/
export const NotificationModal: React.FC<NotificationModalProps> = ({
isOpen,
title = 'Thông báo',
message,
type = 'info',
onConfirm,
}) => {
if (!isOpen) return null;
const icons = {
success: <CheckCircle className="w-12 h-12 text-green-500" />,
error: <AlertCircle className="w-12 h-12 text-red-500" />,
info: <Info className="w-12 h-12 text-blue-500" />,
};
const colors = {
success: 'bg-green-600 hover:bg-green-700 shadow-green-100',
error: 'bg-red-600 hover:bg-red-700 shadow-red-100',
info: 'bg-blue-600 hover:bg-blue-700 shadow-blue-100',
};
return (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
{/* Backdrop */}
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onConfirm} />
{/* Modal Content */}
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl overflow-hidden p-8 text-center animate-in zoom-in-95 duration-200">
<div className="flex justify-center mb-5">
{icons[type]}
</div>
<h2 className="text-xl font-black text-gray-900 mb-2">{title}</h2>
<p className="text-gray-500 text-sm leading-relaxed mb-8">
{message || "Bạn không được phép gỡ bỏ thành viên này!"}
</p>
<button
onClick={onConfirm}
className={`w-full py-4 text-white font-bold rounded-2xl transition-all shadow-lg active:scale-95 ${colors[type]}`}
>
Đã hiểu
</button>
</div>
</div>
);
};
/**
* Custom hook đ quản trạng thái của NotificationModal
*/
export const useNotificationModal = () => {
const [modalState, setModalState] = useState<{
isOpen: boolean;
title?: string;
message?: string;
type?: 'success' | 'error' | 'info';
}>({
isOpen: false,
title: 'Thông báo',
message: '',
type: 'info',
});
const openModal = (title: string, message: string, type: 'success' | 'error' | 'info' = 'info') => {
setModalState({ isOpen: true, title, message, type });
};
const closeModal = () => {
setModalState((prev) => ({ ...prev, isOpen: false }));
};
return { modalState, openModal, closeModal };
};
@@ -14,8 +14,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true); setLoading(true);
try { try {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/users`, {
const response = await fetch(`${API_BASE}/api/v1/users`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
}); });
if (!response.ok) throw new Error('Không thể tải danh sách người dùng'); if (!response.ok) throw new Error('Không thể tải danh sách người dùng');
@@ -34,8 +33,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
const handleToggleBlock = async (id: string) => { const handleToggleBlock = async (id: string) => {
try { try {
const API_BASE = `http://${window.location.hostname}:3001`; await fetch(`/api/v1/users/block/${id}`, {
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
}); });
@@ -48,8 +46,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?')) return; if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?')) return;
try { try {
const API_BASE = `http://${window.location.hostname}:3001`; const res = await fetch(`/api/v1/users/${id}`, {
const res = await fetch(`${API_BASE}/api/v1/users/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
}); });
+60
View File
@@ -0,0 +1,60 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { ConfirmModal } from '../components/ConfirmModal';
interface ConfirmOptions {
title?: string;
message?: string;
}
const ConfirmContext = createContext<((options: ConfirmOptions) => Promise<boolean>) | undefined>(undefined);
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, setState] = useState<{
isOpen: boolean;
title?: string;
message?: string;
resolve?: (value: boolean) => void;
}>({ isOpen: false });
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
setState({
isOpen: true,
title: options.title,
message: options.message,
resolve,
});
});
}, []);
const handleConfirm = () => {
const resolve = state.resolve;
setState({ isOpen: false, resolve: undefined });
resolve?.(true);
};
const handleCancel = () => {
const resolve = state.resolve;
setState({ isOpen: false, resolve: undefined });
resolve?.(false);
};
return (
<ConfirmContext.Provider value={confirm}>
{children}
<ConfirmModal
isOpen={state.isOpen}
title={state.title}
message={state.message}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</ConfirmContext.Provider>
);
};
export const useConfirm = () => {
const confirm = useContext(ConfirmContext);
if (!confirm) throw new Error('useConfirm must be used within a ConfirmProvider');
return confirm;
};
+64
View File
@@ -0,0 +1,64 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { NotificationModal } from '../components/NotificationModal';
interface NotificationOptions {
title: string;
message: string;
type?: 'success' | 'error' | 'info';
}
const NotificationContext = createContext<((options: NotificationOptions) => void) | undefined>(undefined);
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, setState] = useState<{
isOpen: boolean;
title?: string;
message?: string;
type?: 'success' | 'error' | 'info';
}>({ isOpen: false });
const notify = useCallback(({ title, message, type = 'info' }: NotificationOptions) => {
setState({
isOpen: true,
title,
message,
type,
});
}, []);
const handleClose = useCallback(() => {
setState(prev => ({ ...prev, isOpen: false }));
}, []);
// Tự động đóng sau 3 giây nếu modal đang mở
useEffect(() => {
if (state.isOpen) {
const timer = setTimeout(() => {
handleClose();
}, 3000);
return () => clearTimeout(timer); // Xóa timer nếu người dùng bấm nút đóng trước 3 giây hoặc thông báo mới đè lên
}
}, [state.isOpen, handleClose]);
return (
<NotificationContext.Provider value={notify}>
{children}
<NotificationModal
isOpen={state.isOpen}
title={state.title}
message={state.message}
type={state.type}
onConfirm={handleClose}
/>
</NotificationContext.Provider>
);
};
export const useNotification = () => {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotification must be used within a NotificationProvider');
}
return context;
};
+488
View File
@@ -0,0 +1,488 @@
import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, Tooltip } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTourStore } from '@/store/useTourStore';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon, MapPin, Loader2 } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal';
import { useNotification } from '@/hooks/useNotification';
import { CreateTourModal } from '../components/CreateTourModal';
// Fix lỗi icon mặc định của Leaflet
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(position, map.getZoom());
}, [position, map]);
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);
useMapEvents({
moveend: (e) => {
const map = e.target;
const center = map.getCenter();
const zoom = map.getZoom();
const coords: [number, number] = [center.lat, center.lng];
setMapCenter(coords);
// Lưu vị trí và mức zoom vào localStorage để sử dụng cho lần sau
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
},
});
return null;
}
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void }) => {
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const fetchTour = useTourStore(state => state.fetchTour);
const setMapCenter = useTourStore(state => state.setMapCenter);
const notify = useNotification();
// 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');
if (saved) {
try { return JSON.parse(saved); } catch (e) { return null; }
}
return null;
});
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
const [mapZoom] = useState(initialViewState?.zoom || 13);
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm
const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]);
const [isSearchingSuggestions, setIsSearchingSuggestions] = useState(false);
// Logic xử lý gợi ý tự động khi người dùng gõ
useEffect(() => {
const timer = setTimeout(async () => {
if (searchQuery.trim().length < 2) {
setSuggestions([]);
return;
}
setIsSearchingSuggestions(true);
try {
// 1. Lọc các Tour hiện có khớp với từ khóa
const tourMatches = publicTours
.filter(t => t.title.toLowerCase().includes(searchQuery.toLowerCase()))
.map(t => ({ type: 'tour' as const, id: t.id, name: t.title }));
// 2. Tìm kiếm địa điểm thực tế trên bản đồ qua OpenStreetMap
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=5&addressdetails=1&accept-language=vi`);
const data = await res.json();
const locationMatches = data.map((item: any) => ({
type: 'location' as const,
id: item.place_id,
name: item.display_name,
lat: parseFloat(item.lat),
lon: parseFloat(item.lon)
}));
// Hợp nhất kết quả: Tour ưu tiên lên đầu
setSuggestions([...tourMatches, ...locationMatches]);
} catch (err) {
console.error("Lỗi tìm kiếm gợi ý:", err);
} finally {
setIsSearchingSuggestions(false);
}
}, 500); // Debounce 500ms để tránh gọi API quá nhiều
return () => clearTimeout(timer);
}, [searchQuery, publicTours]);
const [selectedFilterTag, setSelectedFilterTag] = useState<string | null>(null);
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
// Tổng hợp nhãn từ danh sách Tour đang có để hiển thị bộ lọc đầy đủ (bao gồm cả nhãn tùy chỉnh)
const allFilterTags = React.useMemo(() => {
const tagsSet = new Set(availableTags);
publicTours.forEach(tour => {
tour.tags?.forEach((tag: string) => tagsSet.add(tag));
});
return Array.from(tagsSet);
}, [publicTours]);
// State cho menu chuột phải chia sẻ
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean } | 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 if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(shareUrl).then(() => {
notify({
title: 'Thành công',
message: 'Đã 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!',
type: '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');
notify({
title: 'Thành công',
message: 'Đã 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!',
type: 'success'
});
} catch (err) {}
document.body.removeChild(textArea);
}
setShareMenu(null);
};
const handleSelectSuggestion = (s: any) => {
if (s.type === 'tour') {
onViewTour(s.id);
} else if (s.lat && s.lon) {
const pos: [number, number] = [s.lat, s.lon];
setUserPos(pos);
setMapCenter(pos);
notify({
title: 'Tìm thấy địa điểm',
message: `Đã di chuyển bản đồ tới: ${s.name.split(',')[0]}`,
type: 'success'
});
}
setSuggestions([]);
setSearchQuery('');
};
const filteredTours = React.useMemo(() => {
if (!selectedFilterTag) return publicTours;
return publicTours.filter(tour => tour.tags?.includes(selectedFilterTag));
}, [publicTours, selectedFilterTag]);
useEffect(() => {
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
if (user || localStorage.getItem('token')) {
fetchPublicTours();
}
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
if (!initialViewState) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserPos(posArray);
setMapCenter(posArray);
},
() => console.log("Không thể lấy vị trí người dùng")
);
} else {
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
setMapCenter(initialViewState.center);
}
}, []);
return (
<div className="h-screen w-full relative">
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
<div className="absolute top-4 left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none">
{/* Nhóm bên trái: Quay lại và Thông tin vị trí */}
<div className="flex items-center gap-3 pointer-events-auto">
<button
onClick={onBack}
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100"
title="Quay lại"
>
<X className="w-6 h-6 text-gray-800" />
</button>
{/* Nút lọc Tag và Dropdown */}
<div className="relative">
<button
onClick={() => setIsFilterDropdownOpen(prev => !prev)}
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center"
title="Lọc theo loại"
>
<Filter className="w-6 h-6 text-gray-800" />
</button>
{/* Filter Dropdown Content */}
{isFilterDropdownOpen && (
<div className="absolute top-full left-0 mt-3 bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 flex flex-col gap-2 max-w-[200px] z-[1003] animate-in slide-in-from-left-2 duration-200">
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
<Filter className="w-3.5 h-3.5 text-blue-600" />
<span className="text-[11px] font-black uppercase text-gray-500 tracking-wider">Lọc theo loại</span>
</div>
<div className="flex flex-col gap-1.5"> {/* Hiển thị tag theo chiều dọc */}
<button
onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }}
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
>
Tất cả
</button>
{allFilterTags.map(tag => (
<button
key={tag}
onClick={() => { setSelectedFilterTag(tag === selectedFilterTag ? null : tag); setIsFilterDropdownOpen(false); }}
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
>
{tag}
</button>
))}
</div>
</div>
)}
</div>
{/* Search Box - Thay thế div "Khám phá khu vực" */}
<div className="relative flex items-center bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white/20 flex-1 max-w-xs sm:max-w-md pointer-events-auto hidden sm:flex px-4 py-3">
<Navigation className="w-4 h-4 text-blue-600 mr-2 flex-shrink-0" />
<input
type="text"
placeholder="Tìm kiếm địa điểm, tour..."
className="flex-1 bg-transparent outline-none text-gray-800 text-sm font-medium"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{isSearchingSuggestions && <Loader2 className="w-4 h-4 animate-spin text-blue-500 mr-2" />}
{searchQuery && (
<button onClick={() => { setSearchQuery(''); setSuggestions([]); }} className="p-1 text-gray-400 hover:text-gray-600 rounded-full">
<X className="w-4 h-4" />
</button>
)}
{/* Dropdown danh sách gợi ý */}
{suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-3 bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl border border-white/20 overflow-hidden z-[1003] animate-in slide-in-from-top-2 duration-200">
{suggestions.map((s, idx) => (
<button
key={`${s.type}-${s.id}-${idx}`}
onClick={() => handleSelectSuggestion(s)}
className="w-full text-left px-4 py-3 hover:bg-blue-50 flex items-center gap-3 transition-colors border-b border-gray-50 last:border-0"
>
<div className={`p-2 rounded-xl flex-shrink-0 ${s.type === 'tour' ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'}`}>
{s.type === 'tour' ? <ImageIcon className="w-4 h-4" /> : <MapPin className="w-4 h-4" />}
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-bold text-gray-800 truncate">{s.name}</span>
<span className="text-[10px] font-black uppercase text-gray-400 tracking-wider">
{s.type === 'tour' ? 'Chuyến đi của bạn' : 'Địa điểm trên bản đồ'}
</span>
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Nhóm bên phải: Các thao tác người dùng */}
<div className="flex items-center gap-2 pointer-events-auto">
{/* Nút Ảnh của tôi */}
{user && (
<button
onClick={() => {
console.log("Đang mở Ảnh của tôi...");
onOpenMyPhotos();
}}
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center gap-2 font-bold border border-blue-100"
title="Ảnh của tôi"
>
<ImageIcon className="w-5 h-5" />
<span className="hidden md:inline text-sm">nh của tôi</span>
</button>
)}
{/* Nút tạo Tour mới */}
{user && (
<button
onClick={() => setIsCreateModalOpen(true)}
className="bg-green-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold"
title="Tạo Tour mới"
>
<Navigation className="w-5 h-5" />
<span className="hidden md:inline text-sm">Tạo Tour</span>
</button>
)}
{/* Nút quản lý người dùng cho Admin */}
{user?.isAdmin && (
<button
onClick={() => setIsAdminModalOpen(true)}
className="bg-blue-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold"
title="Quản lý hệ thống"
>
<Settings className="w-5 h-5" />
<span className="hidden md:inline text-sm">Hệ thống</span>
</button>
)}
{/* Nút đăng xuất */}
{onLogout && (
<button
onClick={onLogout}
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700 border border-gray-100"
title="Đăng xuất"
>
<LogOut className="w-5 h-5" />
<span className="hidden md:inline text-sm">Rời đi</span>
</button>
)}
</div>
</div>
<MapContainer
center={userPos}
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; OpenStreetMap contributors'
/>
{/* Theo dõi di chuyển bản đồ */}
<MapTracker />
{/* Đóng menu và dropdown khi tương tác bản đồ */}
<MapEvents onMapAction={() => { setShareMenu(null); setSuggestions([]); setIsFilterDropdownOpen(false); }} />
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
<RecenterMap position={userPos} />
<MarkerClusterGroup chunkedLoading>
{filteredTours.map((tour) => {
const startLoc = tour.legs?.[0]?.locations?.[0];
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
const markerPos = startLoc
? [startLoc.latitude, startLoc.longitude] as [number, number]
: userPos;
return (
<Marker
key={tour.id}
position={markerPos}
eventHandlers={{
click: () => onViewTour(tour.id),
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
setShareMenu({
x: e.containerPoint.x,
y: e.containerPoint.y,
id: tour.id,
title: tour.title,
canShare
});
}
}}
icon={L.divIcon({
className: 'custom-bubble',
html: `
<div class="relative group">
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${tourImage}" class="w-full h-full object-cover" onerror="this.src='https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=100'"/>
</div>
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
S
</div>
</div>
`,
iconSize: [48, 48],
iconAnchor: [24, 24]
})}
>
<Tooltip direction="top" offset={[0, -20]} opacity={1}>
<div className="p-1 max-w-[180px]">
<div className="font-black text-blue-600 text-[11px] mb-0.5 uppercase tracking-tight truncate">{tour.title}</div>
{tour.tags && tour.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1">
{tour.tags.map((tag: string) => (
<span key={tag} className="px-1.5 py-0.5 bg-blue-50 text-blue-500 rounded text-[8px] font-bold border border-blue-100">{tag}</span>
))}
</div>
)}
{tour.description && (
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic">
{tour.description}
</div>
)}
</div>
</Tooltip>
</Marker>
);
})}
</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()}
>
{shareMenu.canShare ? (
<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 className="px-4 py-2 text-xs text-gray-400 italic">Bạn không quyền chia sẻ tour này</div>
)}
</div>
)}
{/* Admin Modal */}
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
{/* Create Tour Modal */}
<CreateTourModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={(tour) => {
fetchTour(tour.id);
onViewTour(tour.id);
}}
/>
</div>
);
};
@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react'; import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react';
import { LoginModal } from './LoginModal.js'; import { LoginModal } from '../components/LoginModal';
const TRAVEL_IMAGES = [ const TRAVEL_IMAGES = [
"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&q=80", "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&q=80",
+301
View File
@@ -0,0 +1,301 @@
import React, { useEffect, useState, useMemo } from 'react';
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2 } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const [photos, setPhotos] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedTourIdForPhoto, setSelectedTourIdForPhoto] = useState<string | 'all'>('all'); // Changed from filterTourId
const [filterDate, setFilterDate] = useState<string>('');
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest'); // 'newest' by default
const notify = useNotification();
const confirm = useConfirm();
useEffect(() => {
const fetchPhotos = async () => {
try {
const response = await fetch('/api/v1/users/me/photos', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) throw new Error('Failed to fetch photos');
const data = await response.json();
setPhotos(data);
} catch (error) {
notify({ title: 'Lỗi', message: 'Không thể tải danh sách ảnh.', type: 'error' });
} finally {
setIsLoading(false);
}
};
fetchPhotos();
}, []);
// Lấy danh sách các Tour duy nhất để hiển thị trong bộ lọc
const toursWithPhotos = useMemo(() => {
const tourMap = new Map();
photos.forEach(p => {
if (p.tourId && p.tour) {
tourMap.set(p.tourId, { id: p.tourId, title: p.tour.title });
}
});
return Array.from(tourMap.values());
}, [photos]);
// Logic lọc ảnh tại Frontend
const filteredPhotos = useMemo(() => {
let photosToFilter = photos.filter(p => {
const matchTour = selectedTourIdForPhoto === 'all' || p.tourId === selectedTourIdForPhoto;
// So sánh ngày định dạng YYYY-MM-DD
const photoDate = p.capturedAt ? p.capturedAt.split('T')[0] : '';
const matchDate = !filterDate || photoDate === filterDate;
return matchTour && matchDate;
});
let sortedPhotos = photosToFilter;
// Sắp xếp ảnh
if (sortOrder === 'newest') {
sortedPhotos.sort((a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime());
} else { // 'oldest'
sortedPhotos.sort((a, b) => new Date(a.capturedAt).getTime() - new Date(b.capturedAt).getTime());
}
return sortedPhotos;
}, [photos, selectedTourIdForPhoto, filterDate, sortOrder]);
// Effect để thiết lập ảnh được chọn hiển thị hoặc reset nếu ảnh hiện tại không còn trong danh sách lọc
useEffect(() => {
if (filteredPhotos.length > 0 && (!selectedPhotoForDisplay || !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id))) {
setSelectedPhotoForDisplay(filteredPhotos[0]);
} else if (filteredPhotos.length === 0) {
setSelectedPhotoForDisplay(null);
} else if (selectedPhotoForDisplay) {
// Nếu ảnh đang chọn vẫn còn trong danh sách lọc, không làm gì cả
} else {
// Nếu không có ảnh nào để hiển thị
setSelectedPhotoForDisplay(null); // No photos to display
}
}, [filteredPhotos, selectedPhotoForDisplay]);
const handleDeletePhoto = async (photoId: string) => {
const isConfirmed = await confirm({
title: 'Xóa ảnh này?',
message: 'Bạn có chắc chắn muốn xóa ảnh này không? Hành động này không thể hoàn tác.'
});
if (!isConfirmed) return;
try {
const response = await fetch(`/api/v1/photos/${photoId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) throw new Error('Failed to delete photo');
notify({ title: 'Thành công', message: 'Ảnh đã được xóa.', type: 'success' });
// Cập nhật lại danh sách ảnh sau khi xóa
setPhotos(prev => prev.filter(p => p.id !== photoId));
setSelectedPhotoForDisplay(null); // Reset ảnh đang hiển thị
} catch (error) {
notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' });
}
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Header */}
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-4 flex items-center gap-4">
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-gray-600" />
</button>
<div>
<h1 className="text-xl font-black text-gray-900">nh của tôi</h1>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Kho lưu trữ nh gốc nhân</p>
</div>
</div>
{/* Filter Bar - Thanh công cụ lọc */}
<div className="bg-white border-b border-gray-100 px-6 py-4 flex flex-wrap items-center gap-4 sticky top-[73px] z-20 shadow-sm">
<div className="flex items-center gap-2 text-gray-500">
<Filter className="w-4 h-4" />
<span className="text-xs font-bold uppercase tracking-wider text-gray-400">Bộ lọc:</span>
</div>
{/* Chỉ báo Tour hiện tại */}
<div className="relative min-w-[150px]">
<div className="px-3 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold border border-blue-100 truncate max-w-[200px]">
{selectedTourIdForPhoto === 'all' ? 'Tất cả hành trình' : toursWithPhotos.find(t => t.id === selectedTourIdForPhoto)?.title || 'Tour đã chọn'}
</div>
</div>
{/* Lọc theo Thời gian */}
<div className="relative">
<input
type="date"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
className="pl-3 pr-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500 transition-all text-gray-700 cursor-pointer"
/>
</div>
{/* Lọc theo Sắp xếp */}
<div className="relative min-w-[120px]">
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as 'newest' | 'oldest')}
className="w-full pl-3 pr-8 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500 transition-all appearance-none cursor-pointer text-gray-700"
>
<option value="newest">Mới nhất</option>
<option value="oldest"> nhất</option>
</select>
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
<ChevronLeft className="w-3 h-3 -rotate-90" />
</div>
</div>
{/* Reset Filters - Nút xóa nhanh lọc */}
{(selectedTourIdForPhoto !== 'all' || filterDate) && (
<button
onClick={() => { setSelectedTourIdForPhoto('all'); setFilterDate(''); }}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-bold text-red-500 hover:bg-red-50 rounded-xl transition-all"
>
<X className="w-3.5 h-3.5" />
Xóa lọc
</button>
)}
<div className="ml-auto">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">
Kết quả: <span className="text-blue-600">{filteredPhotos.length}</span> / {photos.length} nh
</p>
</div>
</div>
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<Loader2 className="w-10 h-10 animate-spin mb-4" />
<p className="font-bold">Đang tải kho nh...</p>
</div>
) : (
<div className="animate-in fade-in">
<div className="flex flex-col md:flex-row gap-4">
{/* Left Column: Tour List */}
<div className="md:w-1/4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex-shrink-0">
<h3 className="text-sm font-bold text-gray-800 mb-3">Tour của bạn</h3>
<div className="space-y-2">
<button
onClick={() => { setSelectedTourIdForPhoto('all'); setSelectedPhotoForDisplay(null); }}
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
selectedTourIdForPhoto === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Tất cả nh
</button>
{toursWithPhotos.map(tour => (
<button
key={tour.id}
onClick={() => { setSelectedTourIdForPhoto(tour.id); setSelectedPhotoForDisplay(null); }}
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
selectedTourIdForPhoto === tour.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tour.title}
</button>
))}
</div>
</div>
{/* Right Column: Large Photo Display */}
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
{selectedPhotoForDisplay ? (
<div className="relative w-full h-full flex flex-col items-center justify-center">
<img
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Selected Photo"
className="max-w-full max-h-[calc(100vh-350px)] object-contain rounded-xl shadow-md"
/>
<button
onClick={(e) => {
e.stopPropagation();
handleDeletePhoto(selectedPhotoForDisplay.id);
}}
className="absolute top-4 right-4 p-2 bg-red-500/80 backdrop-blur-sm text-white rounded-full shadow-lg hover:bg-red-600 transition-all active:scale-90"
title="Xóa ảnh này"
>
<Trash2 className="w-5 h-5" />
</button>
<div className="mt-6 w-full flex items-center justify-between px-2">
<div className="space-y-1">
<div className="flex items-center gap-2 text-gray-900 font-bold">
<MapPin className="w-4 h-4 text-blue-500" />
{selectedPhotoForDisplay.tour?.title || 'Không rõ hành trình'}
</div>
<div className="flex items-center gap-2 text-gray-400 text-xs font-medium uppercase tracking-wider">
<Calendar className="w-3.5 h-3.5" />
{new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</div>
</div>
{selectedPhotoForDisplay.originalUrl && (
<a
href={selectedPhotoForDisplay.originalUrl}
download
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-black uppercase tracking-widest text-[10px] transition-all shadow-lg shadow-blue-900/20 active:scale-95"
>
<Download className="w-4 h-4" /> Tải xuống nh gốc
</a>
)}
</div>
</div>
) : (
<div className="text-center text-gray-400 py-20">
<ImageIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
<p className="text-lg font-bold">Chọn một tấm nh đ xem</p>
</div>
)}
</div>
</div>
{/* Bottom Row: Thumbnails */}
<div className="mt-4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4">
<div className="flex items-center justify-between mb-4 px-1">
<h3 className="text-xs font-black text-gray-400 uppercase tracking-widest">Kho nh ({filteredPhotos.length})</h3>
</div>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-3 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
{filteredPhotos.map((photo: any) => (
<div
key={photo.id}
onClick={() => setSelectedPhotoForDisplay(photo)}
className={`aspect-square bg-gray-100 rounded-xl overflow-hidden relative group border-2 transition-all cursor-pointer ${
selectedPhotoForDisplay?.id === photo.id ? 'border-blue-500 scale-[0.98]' : 'border-transparent hover:border-blue-200'
}`}
>
<img
src={photo.imageUrl || photo.originalUrl}
alt="thumbnail"
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
</div>
) }
<div className="h-4"></div>
<div className="py-32 text-center bg-white rounded-[40px] border-2 border-dashed border-gray-100">
<ImageIcon className="w-16 h-16 text-gray-200 mx-auto mb-4" />
<h3 className="text-xl font-bold text-gray-400">Chưa nh nào</h3>
<p className="text-sm text-gray-300">Hãy tham gia các chuyến đi lưu lại khoảnh khắc nhé!</p>
</div>
{}
</div>
</div>
);
};
@@ -29,9 +29,7 @@ export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) =>
setIsLoading(true); setIsLoading(true);
try { try {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/auth/signup`, {
const response = await fetch(`${API_BASE}/api/v1/auth/signup`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ interface TourState {
setTour: (tour: any) => void; setTour: (tour: any) => void;
updateLegs: (legs: any[]) => void; updateLegs: (legs: any[]) => void;
createTour: (tourData: any) => Promise<any>; createTour: (tourData: any) => Promise<any>;
updateTourDetails: (tourId: string, data: any) => Promise<void>;
updateTour: (id: string, data: any) => Promise<void>; updateTour: (id: string, data: any) => Promise<void>;
deleteTour: (id: string) => Promise<void>; deleteTour: (id: string) => Promise<void>;
addLeg: (tourId: string, data: any) => Promise<void>; addLeg: (tourId: string, data: any) => Promise<void>;
@@ -27,6 +28,7 @@ interface TourState {
setActiveLegId: (id: string | null) => void; setActiveLegId: (id: string | null) => void;
setMapCenter: (pos: [number, number]) => void; setMapCenter: (pos: [number, number]) => void;
fetchTour: (id: string) => Promise<void>; fetchTour: (id: string) => Promise<void>;
fetchPublicTourDetails: (tourId: string) => Promise<void>;
fetchPublicTours: () => Promise<void>; fetchPublicTours: () => Promise<void>;
createJoinRequest: (tourId: string, userId?: string) => Promise<any>; createJoinRequest: (tourId: string, userId?: string) => Promise<any>;
fetchJoinRequests: (tourId: string) => Promise<any[]>; fetchJoinRequests: (tourId: string) => Promise<any[]>;
@@ -46,10 +48,9 @@ export const useTourStore = create<TourState>((set, get) => ({
setActiveLegId: (id) => set({ activeLegId: id }), setActiveLegId: (id) => set({ activeLegId: id }),
setMapCenter: (pos) => set({ mapCenter: pos }), setMapCenter: (pos) => set({ mapCenter: pos }),
fetchTour: async (id: string) => { fetchTour: async (id: string) => {
const API_BASE = `http://${window.location.hostname}:3001`;
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
try { try {
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, { const response = await fetch(`/api/v1/tours/${id}`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -74,11 +75,10 @@ export const useTourStore = create<TourState>((set, get) => ({
} }
}, },
fetchPublicTours: async () => { fetchPublicTours: async () => {
const API_BASE = `http://${window.location.hostname}:3001`;
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) return; if (!token) return;
const response = await fetch(`${API_BASE}/api/v1/tours/explore`, { const response = await fetch(`/api/v1/tours/explore`, {
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
@@ -88,9 +88,27 @@ export const useTourStore = create<TourState>((set, get) => ({
const data = await response.json(); const data = await response.json();
set({ publicTours: data }); set({ publicTours: data });
}, },
fetchPublicTourDetails: async (tourId: string) => {
try {
// Reset state cũ trước khi tải dữ liệu mới
set({ currentTour: null, legs: [], userRole: 'VIEWER_ONLY' });
const response = await fetch(`/api/v1/tours/${tourId}/public`);
if (!response.ok) throw new Error('Không thể tải tour công khai');
const data = await response.json();
const legs = data.legs || [];
set({
currentTour: data,
legs,
userRole: 'VIEWER_ONLY',
activeLegId: legs.length > 0 ? legs[0].id : null
});
} catch (err: any) {
console.error('Lỗi khi tải tour công khai:', err);
}
},
createTour: async (tourData: any) => { createTour: async (tourData: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours`, {
const response = await fetch(`${API_BASE}/api/v1/tours`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -106,9 +124,24 @@ export const useTourStore = create<TourState>((set, get) => ({
await get().fetchPublicTours(); await get().fetchPublicTours();
return tour; return tour;
}, },
updateTourDetails: async (tourId: string, data: any) => {
const token = localStorage.getItem('token');
const response = await fetch(`/api/v1/tours/${tourId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || errorData.error || 'Lỗi khi cập nhật thông tin tour');
}
},
updateTour: async (id: string, data: any) => { updateTour: async (id: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${id}`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -122,8 +155,7 @@ export const useTourStore = create<TourState>((set, get) => ({
get().fetchPublicTours(); get().fetchPublicTours();
}, },
deleteTour: async (id: string) => { deleteTour: async (id: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${id}`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` 'Authorization': `Bearer ${localStorage.getItem('token')}`
@@ -139,8 +171,7 @@ export const useTourStore = create<TourState>((set, get) => ({
get().fetchPublicTours(); get().fetchPublicTours();
}, },
addLeg: async (tourId: string, data: any) => { addLeg: async (tourId: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/legs`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -152,8 +183,7 @@ export const useTourStore = create<TourState>((set, get) => ({
get().fetchTour(tourId); get().fetchTour(tourId);
}, },
initializeLegs: async (tourId: string, count: number) => { initializeLegs: async (tourId: string, count: number) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/legs/batch`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs/batch`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -165,8 +195,7 @@ export const useTourStore = create<TourState>((set, get) => ({
await get().fetchTour(tourId); await get().fetchTour(tourId);
}, },
updateLeg: async (legId: string, data: any) => { updateLeg: async (legId: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/legs/${legId}`, {
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -180,8 +209,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
deleteLeg: async (legId: string) => { deleteLeg: async (legId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/legs/${legId}`, {
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` 'Authorization': `Bearer ${localStorage.getItem('token')}`
@@ -196,8 +224,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
addLocation: async (tourId: string, locationData: any) => { addLocation: async (tourId: string, locationData: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/locations`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/locations`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -210,8 +237,7 @@ export const useTourStore = create<TourState>((set, get) => ({
get().fetchTour(tourId); get().fetchTour(tourId);
}, },
updateLocation: async (locationId: string, data: any) => { updateLocation: async (locationId: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/locations/${locationId}`, {
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -225,8 +251,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
deleteLocation: async (locationId: string) => { deleteLocation: async (locationId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/locations/${locationId}`, {
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` 'Authorization': `Bearer ${localStorage.getItem('token')}`
@@ -238,8 +263,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
updateTourStartPoint: async (tourId: string, data: any) => { updateTourStartPoint: async (tourId: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/start-point`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/start-point`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -253,8 +277,7 @@ export const useTourStore = create<TourState>((set, get) => ({
await get().fetchTour(tourId); await get().fetchTour(tourId);
}, },
updateTourEndPoint: async (tourId: string, data: any) => { updateTourEndPoint: async (tourId: string, data: any) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/end-point`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/end-point`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -266,8 +289,7 @@ export const useTourStore = create<TourState>((set, get) => ({
await get().fetchTour(tourId); await get().fetchTour(tourId);
}, },
optimizeRouting: async (legId: string) => { optimizeRouting: async (legId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/routing/optimize/${legId}`, {
const response = await fetch(`${API_BASE}/api/v1/routing/optimize/${legId}`, {
method: 'POST' method: 'POST'
}); });
const { locations, totalDistance } = await response.json(); const { locations, totalDistance } = await response.json();
@@ -281,8 +303,7 @@ export const useTourStore = create<TourState>((set, get) => ({
} }
}, },
removeMember: async (tourId: string, userId: string) => { removeMember: async (tourId: string, userId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/members/${userId}`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members/${userId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`, Authorization: `Bearer ${localStorage.getItem('token')}`,
@@ -293,8 +314,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
addMember: async (tourId: string, member: { userId: string; role?: string }) => { addMember: async (tourId: string, member: { userId: string; role?: string }) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/members`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -310,8 +330,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
createJoinRequest: async (tourId: string, userId?: string) => { createJoinRequest: async (tourId: string, userId?: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -326,8 +345,7 @@ export const useTourStore = create<TourState>((set, get) => ({
return response.json(); return response.json();
}, },
fetchJoinRequests: async (tourId: string) => { fetchJoinRequests: async (tourId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('token')}` Authorization: `Bearer ${localStorage.getItem('token')}`
} }
@@ -339,8 +357,7 @@ export const useTourStore = create<TourState>((set, get) => ({
return response.json(); return response.json();
}, },
acceptJoinRequest: async (tourId: string, requestId: string) => { acceptJoinRequest: async (tourId: string, requestId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/join-requests/${requestId}/accept`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/accept`, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('token')}` Authorization: `Bearer ${localStorage.getItem('token')}`
@@ -354,8 +371,7 @@ export const useTourStore = create<TourState>((set, get) => ({
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
rejectJoinRequest: async (tourId: string, requestId: string) => { rejectJoinRequest: async (tourId: string, requestId: string) => {
const API_BASE = `http://${window.location.hostname}:3001`; const response = await fetch(`/api/v1/tours/${tourId}/join-requests/${requestId}/reject`, {
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/reject`, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('token')}` Authorization: `Bearer ${localStorage.getItem('token')}`
+14
View File
@@ -0,0 +1,14 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {},
},
plugins: [],
};
export default config;
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": "./",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+49
View File
@@ -0,0 +1,49 @@
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import fs from 'fs';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
// Nạp các biến môi trường từ thư mục gốc
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
// Cấu hình HTTPS nếu tìm thấy file chứng chỉ (ví dụ đặt tại thư mục gốc của dự án)
const httpsConfig = fs.existsSync('../key.pem') && fs.existsSync('../cert.pem')
? {
key: fs.readFileSync('../key.pem'),
cert: fs.readFileSync('../cert.pem'),
}
: undefined;
return {
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3002,
host: true,
// Cho phép các host từ file .env
allowedHosts: env.ALLOWED_HOSTS ? env.ALLOWED_HOSTS.split(',') : true,
https: httpsConfig,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3001',
ws: true,
changeOrigin: true,
},
},
},
};
});
+2577 -2421
View File
File diff suppressed because it is too large Load Diff
+18 -49
View File
@@ -1,57 +1,26 @@
{ {
"name": "travel-planning-backend", "name": "travel-planning-monorepo",
"version": "1.0.0", "version": "1.0.0",
"description": "Travel Planning Backend with NestJS and Prisma 7", "private": true,
"type": "module", "type": "module",
"workspaces": [
"backend",
"frontend"
],
"scripts": { "scripts": {
"build": "nest build", "build": "npm run build --workspace=backend && npm run build --workspace=frontend",
"start": "nest start", "start:backend": "npm run start:dev --workspace=backend",
"start:dev": "nest start --watch", "start:frontend": "npm run start:dev --workspace=frontend",
"frontend": "vite", "start:dev": "concurrently -n \"BACKEND,FRONTEND\" -c \"magenta,cyan\" \"npm run start:dev --workspace=backend\" \"npm run dev --workspace=frontend\"",
"db:migrate": "npx prisma migrate dev", "db:migrate": "npm run db:migrate --workspace=backend",
"db:generate": "npx prisma generate", "db:generate": "npm run db:generate --workspace=backend",
"db:seed": "npx tsx seed.ts" "db:seed": "npm run db:seed --workspace=backend"
},
"dependencies": {
"@nestjs/common": "^11.1.26",
"@nestjs/core": "^11.1.26",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.26",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcrypt": "^5.1.1",
"date-fns": "^2.30.0",
"dotenv": "^16.3.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.284.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"zustand": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.0.0", "concurrently": "^8.2.2"
"@tailwindcss/postcss": "^4.3.1", },
"@types/bcrypt": "^5.0.2", "dependencies": {
"@types/leaflet": "^1.9.12", "jspdf": "^4.2.1",
"@types/node": "^20.0.0", "jspdf-autotable": "^5.0.8"
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.10.0",
"@types/react": "^18.3.12",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
"prisma": "^7.8.0",
"tailwindcss": "^4.3.1",
"tsx": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^8.0.16"
} }
} }
-3
View File
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
Executable
+87
View File
@@ -0,0 +1,87 @@
#!/bin/bash
# Ensure the script exits if any command fails
set -e
echo "Starting monorepo refactoring..."
# --- 1. Create new directories ---
echo "Creating new directories..."
mkdir -p backend/src/auth
mkdir -p backend/src/common
mkdir -p backend/deprecated-migrations
mkdir -p frontend/src/components
mkdir -p frontend/src/pages
mkdir -p frontend/src/store
mkdir -p docs
echo "Directories created."
# --- 2. Move files to backend/ ---
echo "Moving backend files..."
mv admin.guard.ts backend/src/auth/
mv jwt-auth.guard.ts backend/src/auth/
mv jwt.strategy.ts backend/src/auth/
mv rbac.middleware.ts backend/src/common/
mv main.ts backend/src/
mv nest-cli.json backend/
mv tsconfig.json backend/
mv tsconfig.build.json backend/
mv seed.ts backend/
# Special handling for prisma directory and prisma.service.ts
# First, move the existing prisma directory to backend/
mv prisma backend/
# Then, move prisma.service.ts into the newly moved backend/prisma directory
mv prisma.service.ts backend/prisma/
# Move old migrations (not Prisma's) to deprecated-migrations
if [ -d "migrations" ]; then
mv migrations backend/deprecated-migrations/
else
echo "Warning: 'migrations' directory not found at root. Skipping move."
fi
# Move schema.sql if it exists and is related to backend
if [ -f "schema.sql" ]; then
mv schema.sql backend/prisma/
else
echo "Warning: 'schema.sql' not found at root. Skipping move."
fi
echo "Backend files moved."
# --- 3. Move files to frontend/ ---
echo "Moving frontend files..."
mv App.tsx frontend/src/
mv CreateTourModal.tsx frontend/src/components/
mv ExpenseManager.tsx frontend/src/components/ # Assuming this is a component
mv ExploreMap.tsx frontend/src/pages/ # Assuming this is a page
mv index.tsx frontend/src/
mv ItineraryTimeline.tsx frontend/src/components/ # Assuming this is a component
mv LandingPage.tsx frontend/src/pages/
mv LoginModal.tsx frontend/src/components/
mv SignupPage.tsx frontend/src/pages/
mv TourDetailPage.tsx frontend/src/pages/
mv useTourStore.ts frontend/src/store/
mv vite.config.ts frontend/
mv tailwind.config.ts frontend/
mv postcss.config.js frontend/
mv index.css frontend/src/
echo "Frontend files moved."
# --- 4. Move files to docs/ ---
echo "Moving documentation files..."
mv ARCHITECTURE.md docs/
mv UITourDesign.md docs/
echo "Documentation files moved."
echo "Monorepo refactoring complete. Please verify the new structure."
echo "Next, you will need to update import paths and configuration files."
-13
View File
@@ -1,13 +0,0 @@
import type { Config } from 'tailwindcss'
export default {
content: [
"./index.html",
"./*.{js,ts,jsx,tsx}",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
} satisfies Config
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"ignoreDeprecations": "5.0",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"noEmit": false,
"jsx": "react-jsx",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}
Executable
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# Đảm bảo script dừng nếu có lỗi xảy ra
set -e
echo "🚀 Đang quét và cập nhật các đường dẫn import cho cấu trúc Monorepo..."
# --- 1. CẬP NHẬT BACKEND (NestJS) ---
# Cập nhật backend/src/main.ts
# Các Guard/Middleware nay nằm trong auth/ và common/
sed -i -E "s|from\s+['\"](\./)(admin\.guard\|jwt-auth\.guard\|jwt\.strategy)['\"]|from './auth/\2'|g" backend/src/main.ts
sed -i -E "s|from\s+['\"](\./)(rbac\.middleware)['\"]|from './common/\2'|g" backend/src/main.ts
# Đảm bảo Prisma service trỏ đúng về thư mục prisma ở root từ backend/src/
sed -i -E "s|from\s+['\"](\.\./)+(prisma/prisma\.service)['\"]|from '../../\2'|g" backend/src/main.ts
# Cập nhật các file trong backend/src/auth/ (Guards & Strategies)
# Từ backend/src/auth/ cần lùi 3 cấp để ra root prisma/
find backend/src/auth -name "*.ts" -exec sed -i -E "s|from\s+['\"](\.\./)+(prisma/prisma\.service)['\"]|from '../../../\2'|g" {} +
find backend/src/common -name "*.ts" -exec sed -i -E "s|from\s+['\"](\.\./)+(prisma/prisma\.service)['\"]|from '../../../\2'|g" {} +
# --- 2. CẬP NHẬT FRONTEND (React + Vite) ---
# Cập nhật App.tsx (frontend/src/App.tsx)
# Trỏ vào thư mục pages/ và store/
sed -i -E "s|from\s+['\"](\./)(LandingPage\|SignupPage\|TourDetailPage\|ExploreMap)['\"]|from './pages/\2'|g" frontend/src/App.tsx
sed -i -E "s|from\s+['\"](\./)(useTourStore)['\"]|from './store/\2'|g" frontend/src/App.tsx
# Cập nhật các Trang (frontend/src/pages/*.tsx)
# Các trang cần trỏ tới components/ và store/ nằm ở thư mục anh em
# Bao gồm: CreateTourModal, LoginModal, ExpenseManager, ItineraryTimeline, AddMemberModal, ConfirmModal, NotificationModal
find frontend/src/pages -name "*.tsx" -exec sed -i -E "s|from\s+['\"](\./\|@/components/)(CreateTourModal\|LoginModal\|ExpenseManager\|ItineraryTimeline\|AddMemberModal\|ConfirmModal\|NotificationModal)['\"]|from '../components/\2'|g" {} +
find frontend/src/pages -name "*.tsx" -exec sed -i -E "s|from\s+['\"](\./)(useTourStore)['\"]|from '../store/\2'|g" {} +
find frontend/src/pages -name "*.tsx" -exec sed -i -E "s|from\s+['\"](\./index\.css)['\"]|from '../index.css'|g" {} +
# Cập nhật các Component (frontend/src/components/*.tsx)
# Các component/modal cần trỏ tới store/
find frontend/src/components -name "*.tsx" -exec sed -i -E "s|from\s+['\"](\./)(useTourStore)['\"]|from '../store/\2'|g" {} +
echo "✅ Hoàn tất cập nhật Import Paths."
echo "💡 Lưu ý: Hãy kiểm tra lại các lỗi đỏ trong VS Code. Nếu bạn có sử dụng Path Alias (như @/components), chúng ta sẽ cấu hình nó ở bước tiếp theo trong tsconfig.json."
-11
View File
@@ -1,11 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3002, // Thay đổi cổng thành 5173 (mặc định của Vite) hoặc cổng khác bạn muốn
strictPort: true,
host: true,
},
});