87 Commits

Author SHA1 Message Date
3dtours e34d197dd0 fix: sửa lỗi hiển thị thumbnail khi chia sẻ trên facebook 2026-06-20 14:44:31 +07:00
3dtours fdf41e05fc fix: sửa lỗi hiển thị trên browser của điện thoại 2026-06-20 12:19:30 +07:00
3dtours ae74035ad5 fix: cho phép người đã tải ảnh lên được quyền chỉnh sửa 2026-06-20 08:27:58 +07:00
3dtours ac11bb56db fix: bố cục lại cách hiển thị thông tin ảnh 2026-06-20 08:04:12 +07:00
3dtours 5081546cdf feat: cho phép người dùng chọn tọa độ trên bản đồ khi sửa ảnh 2026-06-20 07:40:50 +07:00
3dtours 9da8a9494d feat: cho phép người dùng chỉnh sửa thông tin ảnh đã upload 2026-06-20 07:37:01 +07:00
3dtours 4373ed684a fix: ảnh phải giữ nguyên hướng chụp 2026-06-20 07:28:28 +07:00
3dtours def0e0d0f9 fix: sửa lỗi các nút bị che dưới thanh địa chỉ của chrome, hỗ trợ iphone upload ảnh heic 2026-06-20 07:05:25 +07:00
3dtours 36e8658dad fix: sửa lỗi iphone ko thể upload 2026-06-19 22:49:51 +07:00
3dtours 6c1b77d7d2 feat: tải ảnh lên bằng tài khoản public và cho phép bình luận 2026-06-19 22:38:16 +07:00
3dtours 4640526e4b fix: thay đổi ảnh nền trang chủ 2026-06-19 12:38:17 +07:00
3dtours c7692ab9b6 fix: thay đổi giao diện trang chủ 2026-06-19 12:00:19 +07:00
3dtours 36fbbb9386 fix: remove leaflet liên kết trên bản đồ 2026-06-19 10:34:03 +07:00
3dtours ef11309399 feat: chuẩn bị backend cho tính năng anonymous user upload ảnh không cần đăng nhập 2026-06-19 08:46:59 +07:00
3dtours 6f2dd0fc01 fix: lỗi đọc nhầm .env ở thư mục backend 2026-06-19 08:13:35 +07:00
3dtours 623f0164c8 feat: cài đặt tính năng gửi mã OTP qua mail xác thực 2026-06-18 23:00:12 +07:00
3dtours 88b47099b1 feat: cài đặt tính năng gửi mã OTP qua mail xác thực 2026-06-18 23:00:03 +07:00
3dtours 659d2a0840 fix: hiển thị market chỉ hướng người dùng di chuyển trên iOS 2026-06-18 21:24:08 +07:00
3dtours 68950dba10 fix: hiển thị market chỉ hướng người dùng di chuyển 2026-06-18 10:51:47 +07:00
3dtours 3e215ae8e8 fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ đã mượt 2026-06-17 22:41:45 +07:00
3dtours e7b8c672e0 fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ đã mượt nhưng đến góc là xoay tròn 2026-06-17 22:36:05 +07:00
3dtours 2462850e2e fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ vẫn còn xoay tròn 2026-06-17 22:23:01 +07:00
3dtours abb61f65cd feat: xoay màn hình theo hướng người nhìn trong màn hình bản đồ vẫn còn lag 2026-06-17 22:20:57 +07:00
3dtours da32d59161 feat: nhấn nút toggle status full bản đồ, nhấn nút bản đồ thì giao diện cũ 2026-06-17 21:53:46 +07:00
3dtours 40b1c4f2ab feat: nhấn nút toggle status bản đồ sẽ hiển thị toàn màn hình 2026-06-17 21:41:43 +07:00
3dtours 55de887f87 feat: nhấn nút toggle status để chuyển đến bản đồ di chuyển 2026-06-17 21:30:41 +07:00
3dtours ffe3cb6ecd fix: tạm dừng thuật toán tìm đường đi 2026-06-17 21:16:31 +07:00
3dtours 69c67a1636 fix: sửa lại các tuyến đường đề xuất trên bản đồ 2026-06-17 20:49:30 +07:00
3dtours f181009fa5 fix: sửa lại các nút hiển thị trên màn hình gom lại thành 1 nút 2026-06-17 20:08:28 +07:00
3dtours b6551da147 fix: sửa lại cho phép chỉnh sửa thời gian bắt đầu và kết thúc của điểm xuất phát và điểm kết thúc 2026-06-17 19:46:55 +07:00
3dtours 2afb2971da fix: sửa lại hiển thị của các nút trên bản đồ 2026-06-17 19:37:29 +07:00
3dtours 14ffbb8657 fix: xóa bảng hiển thị mẹo trên bản đồ 2026-06-17 19:34:13 +07:00
3dtours fbe5ef873b feat: thêm tính năng hiển thị tooltip mẹo nhấn chuột phải 2026-06-17 19:30:58 +07:00
3dtours d6ee1dde74 feat: thêm tính năng nút la bàn để xoay map theo hướng Bắc hoặc theo hướng người dùng di chuyển 2026-06-17 19:19:29 +07:00
3dtours 288b40ad12 feat: thêm tính năng tìm tôi để tự động di chuyển bản đồ về vị trí của người dùng 2026-06-17 19:14:18 +07:00
3dtours 3a3296c340 feat: thêm tính năng hiển thị vị trí người dùng trên bản đồ 2026-06-17 18:27:52 +07:00
3dtours e664a3797e feat: thêm tính năng đường dành cho xe máy và đi bộ 2026-06-17 18:00:45 +07:00
3dtours 36418673db feat: thêm tính năng vẽ đường di chuyển trên bản đồ 2026-06-17 17:44:51 +07:00
3dtours 98e4a4a340 fix: lỗi không xuất hiện nút sửa cho điểm bắt đầu và điểm kết thúc 2026-06-17 17:39:53 +07:00
3dtours bfbbb66747 fix: lỗi bong bóng Tour vẫn xuất hiện và điểm kết thúc 2026-06-17 17:33:47 +07:00
3dtours 1933b58f84 fix: lỗi bong bóng Tour vẫn xuất hiện và điểm bắt đầu là điểm xuất phát 2026-06-17 17:29:40 +07:00
3dtours 05dc80bb6d fix: lỗi bong bóng Tour vẫn hiển thị sau khi xóa 2026-06-17 17:15:11 +07:00
3dtours 4d63bff214 feat: thêm nút xóa tour trong phần cài đặt của Tour 2026-06-17 15:48:00 +07:00
3dtours c5474f1ff6 fix: sửa tính năng hiển thị quãng đường tại điểm đến kế tiếp 2026-06-17 15:37:18 +07:00
3dtours 7ad785fed9 feat: thêm tính năng ghi note cho Tour 2026-06-17 12:28:24 +07:00
3dtours e66f242c2c fix: click vào nút bình luận không load dược page 2026-06-16 21:44:49 +07:00
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
116 changed files with 18240 additions and 5920 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
{
"continue.enableConsole": true
"continue.enableConsole": true,
"remote.autoForwardPortsFallback": 0
}
-312
View File
@@ -1,312 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
import L from 'leaflet';
// Cấu hình Icon mặc định để tránh crash Marker trong Modal
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',
});
// Component Helper xử lý việc chọn vị trí trên mini map bằng chuột phải
const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, center: [number, number] }) => {
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const map = useMap();
useMapEvents({
contextmenu: (e) => {
L.DomEvent.preventDefault(e.originalEvent);
L.DomEvent.stopPropagation(e.originalEvent);
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
},
click: () => setMenuPos(null),
dragstart: () => setMenuPos(null),
});
useEffect(() => {
if (menuPos && menuRef.current) {
L.DomEvent.disableClickPropagation(menuRef.current);
}
}, [menuPos]);
useEffect(() => {
map.setView(center, map.getZoom());
}, [center]);
return (
<>
{menuPos && (
<div
ref={menuRef}
className="absolute z-[3000] bg-white rounded-xl shadow-xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
style={{ top: menuPos.y, left: menuPos.x }}
>
<button
type="button"
onClick={() => { onPick(menuPos.latlng); setMenuPos(null); }}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-xs font-bold text-blue-600 flex items-center gap-2"
>
<MapPin className="w-3 h-3" /> Thêm vào chặng hiện tại
</button>
</div>
)}
</>
);
};
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any }) => {
const [formData, setFormData] = useState({
name: '',
address: '',
latitude: 10.7769,
longitude: 106.7009,
type: 'VISIT',
legId: '',
note: '',
expenseAmount: '',
expenseCategory: 'OTHER',
expenseDescription: '',
expenseNote: '',
paidById: '',
plannedStart: '',
plannedEnd: ''
});
const [isLoading, setIsLoading] = useState(false);
// 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();
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
useEffect(() => {
if (isOpen) {
if (editingLocation) {
const leg = legs.find(l => l.id === editingLocation.legId);
const expense = leg?.expenses?.find((e: any) => e.locationId === editingLocation.id);
setFormData({
name: editingLocation.name || '',
address: editingLocation.address || '',
latitude: editingLocation.latitude,
longitude: editingLocation.longitude,
type: editingLocation.type || 'VISIT',
legId: editingLocation.legId || '',
note: editingLocation.note || '',
expenseAmount: expense?.amount?.toString() || '',
expenseCategory: expense?.category || 'OTHER',
expenseDescription: expense?.description || '',
expenseNote: expense?.note || '',
paidById: expense?.paidById || '',
plannedStart: editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : '',
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
});
} else {
setFormData(prev => ({
...prev,
name: '', address: '',
legId: initialLegId || (legs.length > 0 ? legs[0].id : ''),
note: '', expenseAmount: '', expenseCategory: 'OTHER',
expenseDescription: '', expenseNote: '', paidById: '',
plannedStart: '', plannedEnd: ''
}));
}
}
}, [initialLegId, editingLocation, isOpen]);
useEffect(() => {
if (isOpen && !formData.name) {
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
}
}, [isOpen, mapCenter]);
// 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 targetLeg = legs.find(l => l.id === currentLegId);
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 handlePickLocation = async (latlng: L.LatLng) => {
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
// Tự động lấy tên địa điểm từ tọa độ vừa chọn
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;
const 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] || "";
setFormData(prev => ({ ...prev, name: detectedName || prev.name, address: data.display_name || prev.address }));
} catch (e) {}
};
// 3. Early return phải nằm SAU tất cả các khai báo Hook
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const payload: any = {
...formData,
legId: currentLegId,
latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any),
};
if (editingLocation) {
await updateLocation(editingLocation.id, payload);
} else {
await addLocation(tourId, payload);
}
onClose();
} catch (error) {
alert('Lỗi khi lưu địa điểm');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-[2000] 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] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<MapIcon className="w-6 h-6 text-blue-600" /> {titleText}
</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
{/* Mini Map Picker */}
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group">
<MapContainer center={[formData.latitude, formData.longitude]} zoom={13} className="h-full w-full">
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<Marker position={[formData.latitude, formData.longitude]} />
<MapPicker center={[formData.latitude, formData.longitude]} onPick={handlePickLocation} />
</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">
CHUỘT PHẢI Đ CHỌN VỊ TRÍ
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<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"
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Đa chỉ</label>
<input className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.address} onChange={e => setFormData({...formData, address: e.target.value})} />
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Ghi chú đa điểm / Dịch vụ sử dụng</label>
<textarea className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none resize-none" rows={2}
placeholder="Ví dụ: Ăn trưa tại quán X, thuê hướng dẫn viên..."
value={formData.note} onChange={e => setFormData({...formData, note: e.target.value})} />
</div>
<div className="bg-blue-50/60 border border-blue-100 rounded-2xl p-4 space-y-3">
<p className="text-xs font-black text-blue-500 uppercase tracking-widest">Chi phí nhanh tại điểm này</p>
<div className="grid grid-cols-2 gap-3">
<div>
<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"
placeholder="0"
value={formData.expenseAmount} onChange={e => setFormData({...formData, expenseAmount: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={formData.expenseCategory} onChange={e => setFormData({...formData, expenseCategory: e.target.value})}>
<option value="FOOD">Ăn uống</option>
<option value="TRANSPORT">Di chuyển</option>
<option value="ACCOMMODATION">Chỗ </option>
<option value="TICKET"> tham quan</option>
<option value="OTHER">Khác</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Dịch vụ / tả</label>
<input className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
placeholder="Ví dụ: Ăn trưa, taxi, vé..."
value={formData.expenseDescription} onChange={e => setFormData({...formData, expenseDescription: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Ghi chú chi phí</label>
<textarea className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none resize-none text-sm" rows={2}
placeholder="Ghi chú thêm..."
value={formData.expenseNote} onChange={e => setFormData({...formData, expenseNote: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Thành viên đã thanh toán</label>
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
<option value="">-- Chọn người thanh toán --</option>
{currentTour?.participants?.map((p: any) => {
const name = p.user?.name;
const email = p.user?.email;
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
return (
<option key={p.userId} value={p.userId}>{label || p.userId}</option>
);
})}
</select>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Gán vào chặng</label>
<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})}>
{legs.map(leg => (
<option key={leg.id} value={leg.id}>Chặng {leg.sequence}: {leg.note || 'Không có tên'}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1"> đ</label>
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.latitude} onChange={e => setFormData({...formData, latitude: e.target.value as any})} />
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Kinh đ</label>
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.longitude} onChange={e => setFormData({...formData, longitude: e.target.value as any})} />
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Loại</label>
<select className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.type} onChange={e => setFormData({...formData, type: e.target.value as any})}>
<option value="VISIT">Tham quan</option>
<option value="EAT">Ăn uống</option>
<option value="REST">Nghỉ ngơi</option>
<option value="MOVE">Di chuyển</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label>
<input type="datetime-local" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.plannedStart} onChange={e => setFormData({...formData, plannedStart: e.target.value})} />
</div>
</div>
<button disabled={isLoading} className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl flex items-center justify-center gap-2 mt-4">
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : buttonText}
</button>
</form>
</div>
</div>
);
};
-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>
);
};
-366
View File
@@ -1,366 +0,0 @@
import React, { useState } from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
import { ConfirmModal } from './ConfirmModal.js';
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
if (!actual) return null;
const diff = differenceInMinutes(parseISO(actual), parseISO(planned));
const isLate = diff > 0;
return (
<div className={`flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`}>
{isLate ? <AlertCircle className="w-3 h-3 mr-1" /> : <CheckCircle2 className="w-3 h-3 mr-1" />}
<span>
{isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút`}
</span>
</div>
);
};
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
const p = 0.017453292519943295; // Math.PI / 180
const c = Math.cos;
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
c(lat1 * p) * c(lat2 * p) *
(1 - c((lon2 - lon1) * p)) / 2;
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
};
const formatTravelTime = (minutes: number) => {
if (minutes < 60) return `${minutes} phút`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
};
export const ItineraryTimeline = ({
onAddLocation,
onEditLocation
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore();
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
const toggleComplete = async (locationId: string) => {
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
console.log("Toggle status for location:", locationId);
};
const handleAddLeg = async () => {
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
if (note && currentTour) {
await addLeg(currentTour.id, { note });
}
};
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);
}
};
const handleEditLeg = async (leg: any) => {
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
if (note !== null) {
await updateLeg(leg.id, { note });
}
};
const handleDeleteLeg = async (legId: string) => {
setConfirmState({
open: true,
title: 'Xóa chặng',
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 });
}
},
});
};
const handleDeleteLocation = async (id: string) => {
setConfirmState({
open: true,
title: 'Xóa địa điểm',
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 });
}
},
});
};
return (
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
<div className="px-2 pt-4">
{legs.length === 0 ? (
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 font-medium">Chưa chặng nào trong lộ trình.</p>
</div>
) : (
legs.map((leg, legIdx) => {
const totalDwellMinutes = leg.locations.reduce((acc: number, loc: any) => {
if (loc.plannedStart && loc.plannedEnd) {
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
}
return acc;
}, 0);
// Xác định địa điểm cuối cùng của chặng trước đó để hiển thị tính liên tục
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
return (
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300">
{/* Leg Header */}
<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="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">
{leg.sequence}
</span>
{leg.note || `Chi tiết Chặng ${leg.sequence}`}
</div>
{prevLegLastLoc && (
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
<Navigation className="w-2.5 h-2.5 rotate-90" /> Tiếp nối từ {prevLegLastLoc.name}
</div>
)}
</div>
<div className="flex items-center gap-2 ml-4">
{['OWNER', 'MANAGER'].includes(userRole || '') && (
<>
<button
onClick={() => onAddLocation?.(leg.id)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
title="Thêm địa điểm vào chặng này"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={() => handleEditLeg(leg)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteLeg(leg.id)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
{leg.totalDistance !== undefined && (
<div className="hidden sm:flex items-center gap-2">
<div className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100">
{leg.totalDistance} km
</div>
<div className="text-xs font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg border border-gray-100 flex items-center gap-1">
<Clock className="w-3 h-3" />
~ {formatTravelTime(Math.round((leg.totalDistance / 35) * 60))}
</div>
</div>
)}
{totalDwellMinutes > 0 && (
<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" />
Dừng: {formatTravelTime(totalDwellMinutes)}
</div>
)}
</div>
{['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (
<button
onClick={() => optimizeRouting(leg.id)}
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all"
>
<Zap className="w-3 h-3" />
Tối ưu
</button>
)}
</div>
{/* Vertical Line for the whole leg */}
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */}
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
<div className="ml-2">
{leg.locations.map((location, idx) => {
// Logic quan trọng: Gán điểm cuối chặng này nối với điểm đầu chặng sau
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
const distanceToNext = nextLocation
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
: null;
const averageSpeed = 35; // km/h
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
const dwellMinutes = (location.plannedStart && location.plannedEnd)
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
: null;
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
return (
<div key={location.id}>
<div className="relative flex group mb-6">
{/* Timeline Node */}
<div className="z-10 mt-1.5 mr-4">
<button
onClick={() => toggleComplete(location.id)}
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
>
{location.status === 'COMPLETED' ? (
<CheckCircle2 className="w-8 h-8 bg-white rounded-full" />
) : (
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
)}
</button>
</div>
{/* Card Content */}
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
}`}>
<div className="flex justify-between items-start">
<div>
{isStartPoint && (
<span className="inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm bắt đu</span>
)}
{isEndPoint && (
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
)}
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
{location.name}
</h3>
<div className="flex items-center text-sm text-gray-500 mt-1">
<MapPin className="w-3 h-3 mr-1" />
<span className="truncate max-w-[200px] sm:max-w-md">{location.address}</span>
</div>
{location.note && (
<div className="mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic">
{location.note}
</div>
)}
{dwellMinutes !== null && (
<div className="flex items-center text-xs text-amber-600 font-medium mt-1">
<Clock className="w-3 h-3 mr-1" />
<span>Thời gian dừng: {formatTravelTime(dwellMinutes)}</span>
</div>
)}
{locationExpense && (
<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">
<Zap className="w-3 h-3" />
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ ({locationExpense.category})</span>
</div>
{locationExpense.description && (
<div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div>
)}
{locationExpense.note && (
<div className="text-[10px] text-gray-500 italic">{locationExpense.note}</div>
)}
{locationExpense.paidBy && (
<div className="text-[10px] font-semibold text-indigo-700">Đã thanh toán: {locationExpense.paidBy.name}</div>
)}
</div>
)}
</div>
<div className="text-right flex flex-col items-end">
<div className="flex items-center text-sm font-medium text-blue-600">
<Clock className="w-3 h-3 mr-1" />
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
</div>
{location.status === 'COMPLETED' && location.actualStart && (
<div className="text-[10px] text-gray-400 mt-1 italic">
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
</div>
)}
{['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (
<div className="flex gap-1 mt-2">
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
<Edit2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDeleteLocation(location.id)} className="p-1 text-gray-400 hover:text-red-600 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
{/* Logic tính toán độ lệch thời gian */}
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} />
</div>
</div>
{distanceToNext !== null && travelTimeMinutes !== null && (
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3">
<div className="w-8 flex justify-center">
<Navigation className="w-3 h-3 text-blue-400 rotate-180" />
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100">
{distanceToNext.toFixed(2)} km
</span>
<span className="text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
~ {formatTravelTime(travelTimeMinutes)}
</span>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
})
)}
{/* Actions at the bottom of the list */}
{['OWNER', 'MANAGER'].includes(userRole || '') && (
<div className="flex flex-col gap-3 pb-20 mt-8">
<button
onClick={handleDeclareLegs}
className="w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold"
>
<List className="w-5 h-5" />
{legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"}
</button>
<button
onClick={handleAddLeg}
className="w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold"
>
<Plus className="w-5 h-5" /> Thêm chặng lẻ vào cuối
</button>
</div>
)}
</div>
<ConfirmModal
isOpen={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={() => confirmState.onConfirm?.()}
onCancel={() => setConfirmState({ open: false })}
/>
</div>
);
};
-162
View File
@@ -1,162 +0,0 @@
import React, { useState, useMemo } from 'react';
import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react';
import { LoginModal } from './LoginModal.js';
const TRAVEL_IMAGES = [
"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1503220317375-aaad61436b1b?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1513581166391-887a96df91e7?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?auto=format&fit=crop&q=80",
"https://images.unsplash.com/photo-1530789253388-582c481c54b0?auto=format&fit=crop&q=80"
];
interface LandingPageProps {
onContinue?: () => void;
onGoToSignup?: () => void;
onGoToMap?: () => void;
onLoginSuccess?: (user: any) => void;
isInitialSetup?: boolean;
}
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
// Chọn ngẫu nhiên một hình ảnh khi người dùng truy cập trang
const backgroundImage = useMemo(() => {
return TRAVEL_IMAGES[Math.floor(Math.random() * TRAVEL_IMAGES.length)];
}, []);
return (
<div className="flex h-screen w-full flex-col md:flex-row overflow-hidden font-sans bg-gray-50 relative">
{/* Logo/Brand Header ở góc trên bên trái */}
<div className="absolute top-8 left-8 z-20 flex items-center gap-3 text-white md:text-white drop-shadow-lg">
<Compass className="w-10 h-10" />
<span className="text-2xl font-black tracking-tighter uppercase">Travel Planner</span>
</div>
{/* Nửa bên trái: Hiển thị một hình ảnh duy nhất */}
<div className="hidden md:block md:w-1/2 relative h-full bg-blue-900">
<img
src={backgroundImage}
className="w-full h-full object-cover opacity-70"
alt="Travel Background"
/>
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-transparent pointer-events-none" />
{/* Thumbnail Grid ở góc dưới bên trái */}
<div className="absolute bottom-12 left-8 z-20 flex flex-col gap-2 items-start drop-shadow-2xl">
{/* Hàng trên cùng: 1 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[0]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 1" />
</div>
{/* Hàng thứ 2: 2 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[1]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 2" />
<img src={TRAVEL_IMAGES[2]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 3" />
</div>
{/* Hàng dưới cùng: 3 thumbnail */}
<div className="flex gap-2">
<img src={TRAVEL_IMAGES[3]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 4" />
<img src={TRAVEL_IMAGES[4]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 5" />
<img src={TRAVEL_IMAGES[5]} className="w-14 h-14 object-cover rounded-xl border-2 border-white/50 transition-transform duration-300 hover:scale-110 cursor-pointer" alt="thumb 6" />
</div>
</div>
</div>
{/* Nửa bên phải: Nội dung chào mừng và Hành động */}
<div className="flex-1 md:w-1/2 flex flex-col justify-center items-center bg-white p-8 lg:p-16 h-full overflow-y-auto">
<div className="max-w-md w-full space-y-12">
<div className="space-y-4">
{isInitialSetup ? (
<>
<div className="inline-flex items-center px-3 py-1 rounded-full bg-amber-50 text-amber-700 text-xs font-bold border border-amber-100 mb-2">
<ShieldCheck className="w-3 h-3 mr-1" /> CẤU HÌNH HỆ THỐNG LẦN ĐU
</div>
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
Thiết lập Quản trị viên
</h1>
<p className="text-lg text-gray-500 leading-relaxed">
Chào mừng! Hệ thống vừa đưc cài đt. Vui lòng tạo tài khoản đu tiên đ quản vận hành trang web.
</p>
</>
) : (
<>
<h1 className="text-4xl font-extrabold text-gray-900 leading-tight">
Lên kế hoạch cho hành trình tiếp theo
</h1>
<p className="text-lg text-gray-500 leading-relaxed">
Khám phá các đa điểm nổi bật qua bản đ cộng đng hoặc đăng nhập đ bắt đu tự tạo chuyến đi cho riêng mình.
</p>
</>
)}
</div>
<div className="grid grid-cols-1 gap-4">
{isInitialSetup && (
<button
onClick={onGoToSignup}
className="flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 px-8 rounded-2xl transition-all shadow-xl shadow-blue-100 animate-bounce-subtle mb-2"
>
<UserPlus className="w-5 h-5" />
Bắt đu thiết lập ngay
</button>
)}
<button
onClick={onGoToMap}
className="flex items-center justify-center gap-3 bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-lg shadow-indigo-100"
>
<MapIcon className="w-5 h-5" />
Khám phá các hành trình du lịch
</button>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-2 bg-blue-50 hover:bg-blue-100 text-blue-700 font-bold py-4 rounded-2xl transition-all"
>
<LogIn className="w-5 h-5" />
Đăng nhập
</button>
<button
onClick={onGoToSignup}
className="flex items-center justify-center gap-2 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-4 rounded-2xl border border-gray-100 transition-all"
>
<UserPlus className="w-5 h-5" />
Đăng
</button>
</div>
<button
onClick={onContinue}
className="flex items-center justify-center gap-3 text-gray-400 hover:text-gray-600 py-2 transition-all text-sm font-medium"
>
Tiếp tục tham quan nh
<ArrowRight className="w-5 h-5" />
</button>
</div>
<div className="pt-12 flex items-center gap-4 text-sm text-gray-400 border-t border-gray-100">
<div className="flex -space-x-3">
{[1, 2, 3, 4].map(i => (
<img key={i} className="w-10 h-10 rounded-full border-4 border-white" src={`https://i.pravatar.cc/100?img=${i+20}`} alt="user" />
))}
</div>
<p className="font-medium">Tham gia cùng +2,400 người du lịch khác</p>
</div>
</div>
</div>
{/* Login Modal Component */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
onSwitchToSignup={onGoToSignup}
onLoginSuccess={onLoginSuccess}
/>
</div>
);
};
-196
View File
@@ -1,196 +0,0 @@
import React, { useState } from 'react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin } from 'lucide-react';
interface SignupPageProps {
onBack: () => void;
onSuccess: () => void;
}
export const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
phone: '',
address: ''
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Mật khẩu xác nhận không khớp');
return;
}
setIsLoading(true);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
password: formData.password,
name: formData.name,
phone: formData.phone || undefined,
address: formData.address || undefined
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng ký thất bại');
}
onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex h-screen w-full overflow-hidden font-sans bg-white">
{/* Nửa bên trái: Hình ảnh decor (Đồng bộ với LandingPage) */}
<div className="hidden lg:block lg:w-1/2 relative bg-blue-900">
<img
src="https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
alt="Travel background"
className="absolute inset-0 h-full w-full object-cover opacity-50"
/>
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/40 to-transparent" />
<div className="absolute top-12 left-12 flex items-center gap-2 text-white z-10">
<Compass className="w-8 h-8" />
<span className="text-2xl font-black uppercase tracking-tighter">Travel Planner</span>
</div>
<div className="absolute bottom-20 left-12 text-white z-10 max-w-md">
<h2 className="text-4xl font-bold mb-4">Bắt đu hành trình của riêng bạn.</h2>
<p className="text-blue-100 opacity-80">Tạo tài khoản đ lưu lại những kế hoạch du lịch tuyệt vời nhất cùng bạn .</p>
</div>
</div>
{/* Nửa bên phải: Form đăng ký */}
<div className="flex-1 flex flex-col justify-center px-8 sm:px-16 lg:px-24 py-12 overflow-y-auto">
<div className="max-w-md w-full mx-auto">
<button onClick={onBack} className="flex items-center text-gray-400 hover:text-blue-600 mb-8 transition-colors font-medium">
<ChevronLeft className="w-5 h-5" /> Quay lại
</button>
<div className="mb-10">
<h1 className="text-3xl font-extrabold text-gray-900">Tạo tài khoản mới</h1>
<p className="text-gray-500 mt-2 font-medium">Khám phá các tính năng lập kế hoạch chuyên nghiệp.</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
{error}
</div>
)}
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Họ tên</label>
<div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="text"
placeholder="Nguyễn Văn A"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Email</label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
required
type="email"
placeholder="email@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
<div className="relative group">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="tel"
placeholder="0912 345 678"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Đa chỉ</label>
<div className="relative group">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
<input
type="text"
placeholder="Quận 1, TP.HCM"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
<input
required
type="password"
placeholder="••••••••"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận</label>
<input
required
type="password"
placeholder="••••••••"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="w-full px-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
/>
</div>
</div>
<button
disabled={isLoading}
type="submit"
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-5 rounded-2xl shadow-xl shadow-blue-100 transition-all active:scale-[0.98] disabled:opacity-50 mt-4"
>
{isLoading ? 'Đang xử lý...' : 'Tạo tài khoản'}
{!isLoading && <ArrowRight className="w-5 h-5" />}
</button>
</form>
</div>
</div>
</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>
);
};
-154
View File
@@ -1,154 +0,0 @@
import React, { useState, useEffect } from 'react';
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, UserPlus } from 'lucide-react';
interface UserManagementModalProps {
isOpen: boolean;
onClose: () => void;
}
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const fetchUsers = async () => {
setLoading(true);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/users`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) throw new Error('Không thể tải danh sách người dùng');
const data = await response.json();
setUsers(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) fetchUsers();
}, [isOpen]);
const handleToggleBlock = async (id: string) => {
try {
const API_BASE = `http://${window.location.hostname}:3001`;
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
fetchUsers();
} catch (err) {
alert('Lỗi khi thay đổi trạng thái block');
}
};
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;
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/users/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
fetchUsers();
} catch (err: any) {
alert(err.message);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[2000] 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-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="w-6 h-6 text-blue-600" /> Quản người dùng
</h2>
<p className="text-sm text-gray-500">Quản trị viên quyền thêm, sửa, xóa hoặc khóa tài khoản.</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : error ? (
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold">{error}</div>
) : (
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
<th className="pb-4 font-bold px-2">Người dùng</th>
<th className="pb-4 font-bold">Vai trò</th>
<th className="pb-4 font-bold">Trạng thái</th>
<th className="pb-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{users.map(u => (
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || <User className="w-5 h-5" />}
</div>
<div>
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
<div className="text-xs text-gray-400">{u.email}</div>
</div>
</div>
</td>
<td className="py-4">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
)}
</td>
<td className="py-4">
{u.isBlocked ? (
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
) : (
<span className="text-green-500 text-xs font-bold">Đang hoạt đng</span>
)}
</td>
<td className="py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => handleToggleBlock(u.id)}
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
>
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(u.id)}
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
title="Xóa người dùng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</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"}
+20
View File
@@ -0,0 +1,20 @@
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 | null;
passwordHash: string | null;
name: string | null;
phone: string | null;
address: string | null;
avatar: string | null;
createdAt: Date;
isAdmin: boolean;
isBlocked: boolean;
isAnonymous: 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"}
+30
View File
@@ -0,0 +1,30 @@
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 EmailService {
private transporter;
constructor();
sendOTP(email: string, otp: string): Promise<any>;
}
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
handleJoinPhoto(client: Socket, photoId: string): void;
notifyNewComment(tourId: string, data: any): void;
notifyNewPhotoComment(photoId: string, data: any): void;
}
+2602
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",
"collection": "@nestjs/schematics",
"sourceRoot": ".",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": ["**/*.prisma", "schema.sql"],
"watchAssets": true
}
}
+80
View File
@@ -0,0 +1,80 @@
> backend@0.0.1 start:dev
> nest start --watch
[10:46:05 PM] Starting compilation in watch mode...
[10:46:08 PM] Found 0 errors. Watching for file changes.
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestFactory] Starting Nest application...
--- [PRISMA CHECK] ---
DATABASE_URL nhận được: ĐÃ ĐỌC THÀNH CÔNG ✔️
----------------------
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +40ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] JwtModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] CacheModule dependencies initialized +15ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] AppModule dependencies initialized +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinTour" message +11ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinPhoto" message +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AppController {/api/v1}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1, GET} route +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AuthController {/api/v1/auth}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/convert-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/create-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/status, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/login, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/request, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicTourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id/public, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] TourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/locations, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/start-point, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/end-point, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs/batch, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/explore, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/accept, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/reject, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members/:userId, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/photos, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] UserController {/api/v1/users}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/me/photos, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/block/:id, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] RoutingController {/api/v1/routing}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/routing/optimize/:legId, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LegController {/api/v1/legs}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LocationController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, DELETE} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] CommentController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PhotoController {/api/v1/photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/upload-anonymous, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicPhotoController {/api/v1/public-photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AdminOtpController {/api/v1/admin/otp}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/send, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestApplication] Nest application successfully started +16ms
🚀 Server is running on: http://localhost:3001
[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.
[WS] Client connected: sIV1IoQvXb-xaa9iAAAC
[WS] Client sIV1IoQvXb-xaa9iAAAC joined room: photo_557913e6-fcb4-4dae-8cff-a6ff52da3386
+49
View File
@@ -0,0 +1,49 @@
{
"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/config": "^4.0.4",
"@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",
"exifr": "^7.1.3",
"heic-convert": "^2.1.0",
"nodemailer": "^9.0.1",
"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,15 @@
-- DropForeignKey
ALTER TABLE "Photo" DROP CONSTRAINT "Photo_tourId_fkey";
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "originalUrl" TEXT,
ALTER COLUMN "tourId" DROP NOT NULL,
ALTER COLUMN "imageUrl" DROP NOT NULL;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAnonymous" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "email" DROP NOT NULL,
ALTER COLUMN "passwordHash" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "photoId" TEXT,
ALTER COLUMN "locationId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_photoId_fkey" FOREIGN KEY ("photoId") REFERENCES "Photo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -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()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
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 adapter = new PrismaPg(pool);
super({ adapter });
@@ -3,10 +3,12 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// --- Enums ---
@@ -55,8 +57,8 @@ enum PrivacyLevel {
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
email String? @unique
passwordHash String?
name String?
phone String?
address String?
@@ -64,6 +66,7 @@ model User {
createdAt DateTime @default(now())
isAdmin Boolean @default(false)
isBlocked Boolean @default(false)
isAnonymous Boolean @default(false)
createdTours Tour[] @relation("TourCreator")
tourParticipations TourParticipant[]
@@ -71,16 +74,24 @@ model User {
receivedJoinRequests JoinRequest[] @relation("JoinRequester")
uploadedPhotos Photo[]
paidExpenses Expense[] @relation("ExpensePaidBy")
comments Comment[]
}
model Tour {
id String @id @default(uuid())
title String
description String?
startDate DateTime?
endDate DateTime?
createdAt DateTime @default(now())
totalCost Decimal @default(0) @db.Decimal(15, 2)
adultCount Int @default(1)
childCount Int @default(0)
childDiscount Int @default(30)
tags String[]
createdById String
creator User @relation("TourCreator", fields: [createdById], references: [id])
@@ -122,6 +133,9 @@ model Leg {
tourId String
sequence Int
note String? @db.Text
startDate DateTime?
endDate DateTime?
description String? @db.Text
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
locations Location[]
@@ -147,6 +161,7 @@ model Location {
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
expenses Expense[]
photos Photo[]
comments Comment[]
}
model Expense {
@@ -166,15 +181,29 @@ model Expense {
model Photo {
id String @id @default(uuid())
tourId String
tourId String?
locationId String?
uploaderId String
imageUrl String
imageUrl String?
originalUrl String?
capturedAt DateTime @default(now())
metadata Json?
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)
uploader User @relation(fields: [uploaderId], references: [id])
comments Comment[]
}
model Comment {
id String @id @default(uuid())
content String
createdAt DateTime @default(now())
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
photoId String?
photo Photo? @relation(fields: [photoId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
+19
View File
@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
import * as path from 'path';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
const users = await prisma.user.findMany();
console.log('USERS:', users);
}
main().finally(() => pool.end());
+25 -2
View File
@@ -1,8 +1,12 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import 'dotenv/config';
import * as bcrypt from 'bcrypt';
import * as dotenv from 'dotenv';
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 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(`Email đăng nhập Owner: ${owner.email}`);
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
@@ -1,5 +1,5 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AdminGuard implements CanActivate {
@@ -1,7 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from './prisma.service.js';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
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 { PrismaService } from './prisma.service.js';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class TourRoleGuard implements CanActivate {
+2525
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node", // Đm bo 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 vi CommonJS
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 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
/home/locpham/travelplanning/
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-CtrQmjY1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i0kJVU1C.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+3 -2
View File
@@ -2,11 +2,12 @@
<html lang="en">
<head>
<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, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"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",
"react-quill": "^2.0.0",
"react-quill-new": "^3.8.3",
"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 {
plugins: {
"@tailwindcss/postcss": {},
"autoprefixer": {},
autoprefixer: {},
},
}
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

+137
View File
@@ -0,0 +1,137 @@
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 { MyNotePage } from './pages/MyNotePage';
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' | 'notes'>(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}
onOpenNotes={() => setCurrentPage('notes')}
/>
);
}
if (currentPage === 'notes') {
return <MyNotePage onBack={() => setCurrentPage('tourDetail')} />;
}
if (currentPage === 'explore') {
return (
<ExploreMap
onBack={handleBackFromTourDetail}
onLogout={handleLogout}
user={user}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onLoginSuccess={handleLoginSuccess}
/>
);
}
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;
@@ -0,0 +1,546 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { format, parseISO } from 'date-fns';
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 L from 'leaflet';
// Cấu hình Icon mặc định để tránh crash Marker trong Modal
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',
});
// Component Helper xử lý việc chọn vị trí trên mini map bằng chuột phải
const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, center: [number, number] }) => {
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const map = useMap();
useMapEvents({
contextmenu: (e) => {
L.DomEvent.preventDefault(e.originalEvent);
L.DomEvent.stopPropagation(e.originalEvent);
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
},
click: () => setMenuPos(null),
dragstart: () => setMenuPos(null),
});
useEffect(() => {
if (menuPos && menuRef.current) {
L.DomEvent.disableClickPropagation(menuRef.current);
}
}, [menuPos]);
useEffect(() => {
map.setView(center, map.getZoom());
}, [center]);
return (
<>
{menuPos && (
<div
ref={menuRef}
className="absolute z-[3000] bg-white rounded-xl shadow-xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
style={{ top: menuPos.y, left: menuPos.x }}
>
<button
type="button"
onClick={() => { onPick(menuPos.latlng); setMenuPos(null); }}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-xs font-bold text-blue-600 flex items-center gap-2"
>
<MapPin className="w-3 h-3" /> Thêm vào chặng hiện tại
</button>
</div>
)}
</>
);
};
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isStartPoint = false, isEndPoint = false, isPublicView = false, onSuccess }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isStartPoint?: boolean, isEndPoint?: boolean, isPublicView?: boolean, onSuccess?: () => void }) => {
const [formData, setFormData] = useState({
name: '',
address: '',
latitude: 10.7769,
longitude: 106.7009,
type: 'VISIT',
legId: '',
note: '',
expenseAmount: '',
expenseCategory: 'OTHER',
expenseDescription: '',
expenseNote: '',
paidById: '',
plannedStart: '',
plannedEnd: ''
});
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
const { legs, addLocation, updateLocation, updateTourStartPoint, updateTourEndPoint, 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
useEffect(() => {
if (isOpen) {
if (editingLocation) {
const leg = legs.find(l => l.id === editingLocation.legId);
const expense = leg?.expenses?.find((e: any) => e.locationId === editingLocation.id);
setFormData({
name: editingLocation.name || '',
address: editingLocation.address || '',
latitude: editingLocation.latitude,
longitude: editingLocation.longitude,
type: editingLocation.type || 'VISIT',
legId: editingLocation.legId || '',
note: editingLocation.note || '',
expenseAmount: expense?.amount ? Number(expense.amount).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".") : '',
expenseCategory: expense?.category || 'OTHER',
expenseDescription: expense?.description || '',
expenseNote: expense?.note || '',
paidById: expense?.paidById || '',
plannedStart: isStartPoint
? (editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : '')
: (editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : ''),
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
});
} else {
setFormData(prev => ({
...prev,
name: '', address: '',
legId: initialLegId || (legs.length > 0 ? legs[0].id : ''),
note: '', expenseAmount: '', expenseCategory: 'OTHER',
expenseDescription: '', expenseNote: '', paidById: '',
plannedStart: '', plannedEnd: ''
}));
}
}
}, [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(() => {
if (isOpen && !editingLocation && formData.legId && !formData.name) {
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] }));
}
}
}, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
// 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 targetLeg = legs.find(l => l.id === currentLegId);
const titleText = isStartPoint ? 'Thiết lập Điểm xuất phát' :
isEndPoint ? 'Thiết lập Điểm kết thúc' :
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 = isStartPoint ? 'Xác nhận Điểm xuất phát' :
isEndPoint ? 'Xác nhận Điểm kết thúc' :
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) => {
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
// Tự động lấy tên địa điểm từ tọa độ vừa chọn
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;
const 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] || "";
setFormData(prev => ({ ...prev, name: detectedName || prev.name, address: data.display_name || prev.address }));
} 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
if (!isOpen || isPublicView) return null; // Do not render if public view
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
if (isStartPoint) {
await updateTourStartPoint(tourId, {
name: formData.name || "Điểm xuất phát",
latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any),
plannedEnd: formData.plannedStart,
});
} else if (isEndPoint) {
await updateTourEndPoint(tourId, {
name: formData.name || "Điểm kết thúc",
latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any),
plannedStart: formData.plannedStart,
});
} else {
const payload: any = {
...formData,
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
legId: currentLegId,
latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any),
};
if (editingLocation) {
await updateLocation(editingLocation.id, payload);
} else {
await addLocation(tourId, payload);
}
}
notify({
title: 'Thành công',
message: isStartPoint ? 'Đã thiết lập điểm xuất phát.' : isEndPoint ? 'Đã thiết lập điểm kết thúc.' : editingLocation ? 'Đã cập nhật địa điểm.' : 'Đã thêm địa điểm mới.',
type: 'success'
});
onSuccess?.(); // Gọi callback onSuccess sau khi thành công
onClose();
} catch (error) {
notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' });
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-[2000] 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] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<MapIcon className="w-6 h-6 text-blue-600" /> {titleText}
</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
{/* Mini Map Picker */}
<div className="h-64 w-full rounded-3xl overflow-hidden mb-6 border border-gray-100 relative shadow-xl group">
{/* 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} attributionControl={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
<Marker position={currentCoords} />
<MapPicker center={currentCoords} onPick={handlePickLocation} />
</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">
CHUỘT PHẢI Đ CHỌN VỊ TRÍ
</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">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Tên đa điểm</label>
<input
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>
<label className="block text-sm font-bold text-gray-700 mb-1">Đa chỉ</label>
<input className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.address} onChange={e => setFormData({...formData, address: e.target.value})} />
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Ghi chú đa điểm / Dịch vụ sử dụng</label>
<textarea className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none resize-none" rows={2}
placeholder="Ví dụ: Ăn trưa tại quán X, thuê hướng dẫn viên..."
value={formData.note} onChange={e => setFormData({...formData, note: e.target.value})} />
</div>
<div className="bg-blue-50/60 border border-blue-100 rounded-2xl p-4 space-y-3">
<p className="text-xs font-black text-blue-500 uppercase tracking-widest">Chi phí nhanh tại điểm này</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label>
<input type="text" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
placeholder="0"
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>
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={formData.expenseCategory} onChange={e => setFormData({...formData, expenseCategory: e.target.value})}>
<option value="FOOD">Ăn uống</option>
<option value="TRANSPORT">Di chuyển</option>
<option value="ACCOMMODATION">Chỗ </option>
<option value="TICKET"> tham quan</option>
<option value="OTHER">Khác</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Dịch vụ / tả</label>
<input className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
placeholder="Ví dụ: Ăn trưa, taxi, vé..."
value={formData.expenseDescription} onChange={e => setFormData({...formData, expenseDescription: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Ghi chú chi phí</label>
<textarea className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none resize-none text-sm" rows={2}
placeholder="Ghi chú thêm..."
value={formData.expenseNote} onChange={e => setFormData({...formData, expenseNote: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-gray-600 mb-1">Thành viên đã thanh toán</label>
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
<option value="">-- Chọn người thanh toán --</option>
{currentTour?.participants?.map((p: any) => {
const name = p.user?.name;
const email = p.user?.email;
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
return (
<option key={p.userId} value={p.userId}>{label || p.userId}</option>
);
})}
</select>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Gán vào chặng</label>
<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})}>
{legs.map(leg => (
<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>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1"> đ</label>
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.latitude} onChange={e => setFormData({...formData, latitude: e.target.value as any})} />
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Kinh đ</label>
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.longitude} onChange={e => setFormData({...formData, longitude: e.target.value as any})} />
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Loại</label>
<select className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.type} onChange={e => setFormData({...formData, type: e.target.value as any})}>
<option value="VISIT">Tham quan</option>
<option value="EAT">Ăn uống</option>
<option value="REST">Nghỉ ngơi</option>
<option value="MOVE">Di chuyển</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label>
<input type="datetime-local" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={formData.plannedStart} onChange={e => setFormData({...formData, plannedStart: e.target.value})} />
</div>
</div>
<button disabled={isLoading} className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl flex items-center justify-center gap-2 mt-4">
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : buttonText}
</button>
</form>
</div>
</div>
);
};
@@ -1,5 +1,7 @@
import React, { useState, useEffect, useMemo } from '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 {
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 }>;
onRemoveMember?: (userId: string) => Promise<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 }) => {
@@ -21,10 +24,11 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const [submitting, setSubmitting] = useState(false);
const [fetchError, setFetchError] = 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 confirm = useConfirm();
const notify = useNotification();
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 visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
@@ -35,8 +39,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
setLoading(true);
setFetchError('');
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(query)}`, {
const res = await fetch(`/api/v1/users?q=${encodeURIComponent(query)}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
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) => {
if (!onRemoveMember) return;
setConfirmTarget({ userId, name: memberName });
setIsConfirmOpen(true);
};
const isConfirmed = await confirm({
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 (!confirmTarget || !onRemoveMember) return;
try {
await onRemoveMember(confirmTarget.userId);
} catch (err: any) {
setSubmitError(err.message || 'Không thể xóa thành viên');
} finally {
setIsConfirmOpen(false);
setConfirmTarget(null);
if (isConfirmed) {
try {
await onRemoveMember(userId);
} catch (err: any) {
setSubmitError(err.message || 'Không thể xóa thành viên');
}
}
};
@@ -86,10 +87,9 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
if (!onMemberAdded) return;
setActionLoading(reqId);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const endpoint = action === 'accept'
? `${API_BASE}/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}/accept`
: `/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
@@ -100,7 +100,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
}
await onMemberAdded();
} 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 {
setActionLoading(null);
}
@@ -111,12 +111,11 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
setSubmitting(true);
setSubmitError('');
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
const body = canCreateDirectly
? { userId: selectedUser, role }
: { userId: selectedUser };
const res = await fetch(`${API_BASE}${endpoint}`, {
const res = await fetch(`${endpoint}`, {
method: 'POST',
headers: {
'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 (
<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>
</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>
);
};
+189
View File
@@ -0,0 +1,189 @@
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 {
// Lấy tọa độ hiện tại của người dùng làm dự phòng nếu ảnh EXIF không có GPS
const location = await Promise.race([
new Promise<GeolocationPosition | null>((resolve) => {
if (!navigator.geolocation) {
resolve(null);
} else {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos),
() => resolve(null),
{ timeout: 4000, enableHighAccuracy: true }
);
}
}),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
]);
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('images', file);
});
if (location) {
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
}
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 { ConfirmModal } from './ConfirmModal';
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 [confirmState, setConfirmState] = useState({ open: false, commentId: '' });
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>
);
};
@@ -0,0 +1,163 @@
import React, { useState, useEffect } from 'react';
import { X, MapPin } from 'lucide-react';
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Fix Leaflet default marker icon bug
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],
});
interface CoordinateSelectModalProps {
isOpen: boolean;
onClose: () => void;
initialLat?: number;
initialLng?: number;
onSelect: (lat: number, lng: number) => void;
}
function MapClickEvents({ onClick }: { onClick: (lat: number, lng: number) => void }) {
useMapEvents({
click: (e) => {
onClick(e.latlng.lat, e.latlng.lng);
}
});
return null;
}
function MapInvalidator() {
const map = useMap();
useEffect(() => {
const timer = setTimeout(() => {
map.invalidateSize();
}, 250);
return () => clearTimeout(timer);
}, [map]);
return null;
}
function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(position, map.getZoom());
}, [position, map]);
return null;
}
export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
isOpen,
onClose,
initialLat,
initialLng,
onSelect
}) => {
const defaultCenter: [number, number] = [10.7769, 106.7009]; // TP.HCM default
const [position, setPosition] = useState<[number, number]>(defaultCenter);
const [hasSelected, setHasSelected] = useState(false);
useEffect(() => {
if (isOpen) {
if (typeof initialLat === 'number' && typeof initialLng === 'number' && !isNaN(initialLat) && !isNaN(initialLng)) {
setPosition([initialLat, initialLng]);
setHasSelected(true);
} else {
setPosition(defaultCenter);
setHasSelected(false);
}
}
}, [isOpen, initialLat, initialLng]);
if (!isOpen) return null;
const handleMapClick = (lat: number, lng: number) => {
setPosition([lat, lng]);
setHasSelected(true);
};
const handleConfirm = () => {
onSelect(position[0], position[1]);
onClose();
};
return (
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4 animate-in fade-in duration-200">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Content */}
<div className="relative w-full max-w-2xl h-[550px] bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col border border-gray-100 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="p-4 border-b border-gray-100 flex items-center justify-between bg-white shrink-0">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-blue-500" />
<div className="text-left">
<h3 className="font-extrabold text-sm text-gray-900">Chọn vị trí trên bản đ</h3>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Click lên bản đ đ chọn tọa đ</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 hover:bg-gray-100 rounded-full transition-all text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Map Body */}
<div className="flex-1 bg-gray-50 relative min-h-[300px]" style={{ zIndex: 10 }}>
<MapContainer
center={position}
zoom={13}
style={{ width: '100%', height: '100%', zIndex: 1 }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickEvents onClick={handleMapClick} />
<MapInvalidator />
<RecenterMap position={position} />
{hasSelected && (
<Marker position={position} icon={DefaultIcon} />
)}
</MapContainer>
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-100 flex items-center justify-between bg-white shrink-0">
<div className="text-xs text-gray-500">
{hasSelected ? (
<span className="font-semibold text-gray-700">
Tọa đ: {position[0].toFixed(6)}, {position[1].toFixed(6)}
</span>
) : (
<span className="italic text-gray-400">Chưa chọn vị trí</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-200 hover:bg-gray-50 text-gray-700 rounded-xl text-xs font-bold transition-all"
>
Hủy
</button>
<button
onClick={handleConfirm}
disabled={!hasSelected}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-blue-500/10"
>
Xác nhận
</button>
</div>
</div>
</div>
</div>
);
};
@@ -1,14 +1,22 @@
import React, { useState } from 'react';
import { Trash2 } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
import { Trash2, Users, Tag as TagIcon } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [startDate, setStartDate] = 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 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 [query, setQuery] = useState('');
const [results, setResults] = useState<any[]>([]);
@@ -23,8 +31,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
return;
}
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(value)}`, {
const res = await fetch(`/api/v1/users?q=${encodeURIComponent(value)}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
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));
};
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) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
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);
onClose();
} catch (e: any) {
@@ -84,6 +115,56 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
/>
</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>
<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 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>
<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">
File diff suppressed because one or more lines are too long
@@ -0,0 +1,657 @@
import React, { useState, useEffect, useMemo } from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText, Flag } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
import { CommentModal } from '@/components/CommentModal';
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
if (!actual) return null;
const diff = differenceInMinutes(parseISO(actual), parseISO(planned));
const isLate = diff > 0;
return (
<div className={`flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`}>
{isLate ? <AlertCircle className="w-3 h-3 mr-1" /> : <CheckCircle2 className="w-3 h-3 mr-1" />}
<span>
{isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút`}
</span>
</div>
);
};
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
const p = 0.017453292519943295; // Math.PI / 180
const c = Math.cos;
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
c(lat1 * p) * c(lat2 * p) *
(1 - c((lon2 - lon1) * p)) / 2;
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
};
const formatTravelTime = (minutes: number) => {
if (minutes < 60) return `${minutes} phút`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
};
export const ItineraryTimeline = ({
onAddLocation,
onEditLocation,
onQuickNote,
onNavigate,
onSuccess,
isPublicView = false
}: {
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
onEditLocation?: (location: any) => void,
onQuickNote?: (name: string) => void,
onNavigate?: (location: any) => void,
onSuccess?: () => void,
isPublicView?: boolean
}) => {
// 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 handleStatusClick = (location: any) => {
if (onNavigate) {
onNavigate(location);
}
};
const handleAddLeg = async () => {
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
if (note && currentTour) {
await addLeg(currentTour.id, { note });
}
};
const handleDeclareLegs = async () => {
setTempLegCount(legs.length > 0 ? legs.length : 3);
setIsLegCountModalOpen(true);
};
const confirmDeclareLegs = async () => {
if (tempLegCount > 0 && tempLegCount <= 20 && currentTour) {
await initializeLegs(currentTour.id, tempLegCount);
}
setIsLegCountModalOpen(false);
};
const handleEditLeg = async (leg: any) => {
setEditingLegData({
id: leg.id,
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 isConfirmed = await confirm({
title: 'Xóa chặng',
message: 'Bạn có chắc chắn muốn xóa chặng này?'
});
if (isConfirmed) {
try {
await deleteLeg(legId);
onSuccess?.();
} catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' });
}
}
};
const handleDeleteLocation = async (id: string) => {
const isConfirmed = await confirm({
title: 'Xóa địa điểm',
message: 'Bạn có chắc chắn muốn xóa địa điểm này?'
});
if (isConfirmed) {
try {
await deleteLocation(id);
onSuccess?.();
} catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' });
}
}
};
// Tạo mảng phẳng tất cả địa điểm để tính toán quãng đường liên tục giữa các chặng
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
useEffect(() => {
console.log("ItineraryTimeline: Legs updated", legs);
}, [legs]);
return (
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
<div className="px-2 pt-4">
{legs.length === 0 ? (
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 font-medium">Chưa chặng nào trong lộ trình.</p>
</div>
) : (
legs.map((leg, legIdx) => {
const totalDwellMinutes = leg.locations.reduce((acc: number, loc: any) => {
if (loc.plannedStart && loc.plannedEnd) {
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
}
return acc;
}, 0);
// Xác định địa điểm cuối cùng của chặng trước đó để hiển thị tính liên tục
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
return (
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300">
{/* Leg Header */}
<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="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 shrink-0">
{leg.sequence}
</span>
<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>
{prevLegLastLoc && (
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
<Navigation className="w-2.5 h-2.5 rotate-90" /> Tiếp nối từ {prevLegLastLoc.name}
</div>
)}
</div>
<div className="flex items-center gap-2 ml-4">
{canEdit && (
<>
<button
onClick={() => onAddLocation?.(leg.id)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
title="Thêm địa điểm vào chặng này"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={() => handleEditLeg(leg)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteLeg(leg.id)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
{leg.totalDistance !== undefined && (
<div className="hidden sm:flex items-center gap-2">
<div className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100">
{leg.totalDistance} km
</div>
<div className="text-xs font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg border border-gray-100 flex items-center gap-1">
<Clock className="w-3 h-3" />
~ {formatTravelTime(Math.round((leg.totalDistance / 35) * 60))}
</div>
</div>
)}
{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">
<Clock className="w-3 h-3" />
Dừng: {formatTravelTime(totalDwellMinutes)}
</div>
)}
</div>
{['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (
<button
onClick={() => optimizeRouting(leg.id)}
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all"
>
<Zap className="w-3 h-3" />
Tối ưu
</button>
)} {/* Only show optimize button if canEdit */}
</div>
{/* Vertical Line for the whole leg */}
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */}
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
<div className="ml-2">
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
{legIdx === 0 && !leg.locations.some(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
<div className="z-10 mt-1.5 mr-4">
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-blue-200 flex items-center justify-center text-blue-400">
<MapPin className="w-4 h-4" />
</div>
</div>
<button
onClick={() => onAddLocation?.(leg.id, true)}
className="flex-1 bg-blue-50/20 p-4 rounded-xl border border-dashed border-blue-100 hover:border-blue-400 hover:bg-blue-50 transition-all flex items-center justify-between group"
>
<div className="text-left">
<span className="inline-block px-2 py-0.5 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm xuất phát</span>
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn đ ghim điểm bắt đu cho Tour...</h3>
</div>
<Plus className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
</button>
</div>
)}
{/* Nút thêm nhanh "Điểm kết thúc" cho Chặng cuối nếu chưa có */}
{legIdx === legs.length - 1 && !legs.some(l => l.locations.some(loc => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0)) && (
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
<div className="z-10 mt-1.5 mr-4">
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-red-200 flex items-center justify-center text-red-400">
<Flag className="w-4 h-4" />
</div>
</div>
<button
onClick={() => onAddLocation?.(leg.id, false, true)}
className="flex-1 bg-red-50/20 p-4 rounded-xl border border-dashed border-red-100 hover:border-red-400 hover:bg-red-50 transition-all flex items-center justify-between group"
>
<div className="text-left">
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn đ ghim điểm kết thúc cho Tour...</h3>
</div>
<Plus className="w-5 h-5 text-red-500 group-hover:scale-110 transition-transform" />
</button>
</div>
)}
{leg.locations.map((location, idx) => {
// Tìm vị trí của điểm này trong toàn bộ hành trình
const globalIdx = allLocations.findIndex(loc => loc.id === location.id);
const prevLocation = globalIdx > 0 ? allLocations[globalIdx - 1] : null;
const distanceFromPrev = prevLocation
? calculateDistance(prevLocation.latitude, prevLocation.longitude, location.latitude, location.longitude)
: null;
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
// Nhận diện điểm mốc dựa trên timestamp đặc biệt (0) thay vì chỉ số mảng
const isStartPoint = location.plannedStart && new Date(location.plannedStart).getTime() === 0;
const isEndPoint = location.plannedEnd && new Date(location.plannedEnd).getTime() === 0;
const plannedTimeStr = isStartPoint ? location.plannedEnd : location.plannedStart;
const hasValidPlannedTime = plannedTimeStr && new Date(plannedTimeStr).getTime() !== 0;
const dwellMinutes = (location.plannedStart && location.plannedEnd && !isStartPoint && !isEndPoint)
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
: null;
return (
<div key={location.id}>
<div className="relative flex group mb-6">
{/* Timeline Node */}
<div className="z-10 mt-1.5 mr-4">
<button
onClick={() => handleStatusClick(location)}
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
>
{location.status === 'COMPLETED' ? (
<CheckCircle2 className="w-8 h-8 bg-white rounded-full" />
) : (
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
)}
</button>
</div>
{/* Card Content */}
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
}`}>
<div className="flex justify-between items-start">
<div>
{isStartPoint && (
<span className="inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm bắt đu</span>
)}
{isEndPoint && (
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
)}
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
{location.name}
</h3>
<div className="flex items-center text-sm text-gray-500 mt-1">
<MapPin className="w-3 h-3 mr-1" />
<span className="truncate max-w-[200px] sm:max-w-md">{location.address}</span>
</div>
{location.note && (
<div className="mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic">
{location.note}
</div>
)}
{dwellMinutes !== null && (
<div className="flex items-center text-xs text-amber-600 font-medium mt-1">
<Clock className="w-3 h-3 mr-1" />
<span>Thời gian dừng: {formatTravelTime(dwellMinutes)}</span>
</div>
)}
{locationExpense && (
<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">
<Zap className="w-3 h-3" />
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ</span>
</div>
{locationExpense.description && (
<div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div>
)}
{locationExpense.note && (
<div className="text-[10px] text-gray-500 italic">{locationExpense.note}</div>
)}
{locationExpense.paidBy && (
<div className="text-[10px] font-semibold text-indigo-700">Đã thanh toán: {locationExpense.paidBy.name}</div>
)}
</div>
)}
</div>
<div className="text-right flex flex-col items-end">
<div className="flex gap-1 mb-2">
{onQuickNote && !isPublicView && (
<button
onClick={() => onQuickNote(location.name)}
className="flex items-center gap-1 px-2 py-1 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-bold transition-all border border-amber-100"
title="Ghi chú nhanh"
>
<FileText className="w-3 h-3" />
</button>
)}
<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"
>
<MessageSquare className="w-3 h-3" />
{location._count?.comments > 0 && `(${location._count.comments})`}
</button>
</div>
<div className="flex items-center text-sm font-black text-blue-600">
<Clock className="w-3 h-3 mr-1" />
{hasValidPlannedTime ? format(parseISO(plannedTimeStr), 'HH:mm') : '--:--'}
</div>
{location.status === 'COMPLETED' && location.actualStart && (
<div className="text-[10px] text-gray-400 mt-1 italic">
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
</div>
)}
{canEdit && ( // Allow editing and deleting of all locations if user has edit permissions
<div className="flex gap-1 mt-2">
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
<Edit2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDeleteLocation(location.id)} className="p-1 text-gray-400 hover:text-red-600 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
{/* Logic tính toán độ lệch thời gian */}
<TimeVariance planned={hasValidPlannedTime ? plannedTimeStr : ''} actual={location.actualStart || null} />
</div>
</div>
{/* Hiển thị quãng đường di chuyển từ điểm trước ĐẾN điểm hiện tại */}
{distanceFromPrev !== null && distanceFromPrev > 0 && (
<div className="ml-14 -mt-4 mb-6 flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-500">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-xl border border-blue-100 shadow-sm">
<Navigation className="w-3 h-3 rotate-45" />
<span className="text-[10px] font-black uppercase tracking-tighter">
+{distanceFromPrev.toFixed(1)} km từ {prevLocation?.name.split(',')[0]}
</span>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
})
)}
{/* Actions at the bottom of the list */}
{canEdit && ( // Only show these buttons if canEdit
<div className="flex flex-col gap-3 pb-20 mt-8">
<button
onClick={handleDeclareLegs}
className="w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold"
>
<List className="w-5 h-5" />
{legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"}
</button>
<button
onClick={handleAddLeg}
className="w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold"
>
<Plus className="w-5 h-5" /> Thêm chặng lẻ vào cuối
</button>
</div>
)}
</div>
{/* Modal Khai báo số chặng (Popover) */}
{isLegCountModalOpen && (
<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={() => setIsLegCountModalOpen(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>
);
};
@@ -22,8 +22,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
setIsLoading(true);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
const response = await fetch(`/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 };
};
@@ -0,0 +1,734 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart } from 'lucide-react';
import { io } from 'socket.io-client';
import { CoordinateSelectModal } from './CoordinateSelectModal';
interface Comment {
id: string;
userName: string;
content: string;
createdAt: string;
userId: string;
}
interface PublicPhotoModalProps {
isOpen: boolean;
onClose: () => void;
photo: {
id: string;
imageUrl: string;
originalUrl?: string;
capturedAt: string;
metadata?: {
lat?: number;
lng?: number;
title?: string;
description?: string;
};
uploader?: {
id: string;
name: string;
};
uploaderId?: string;
};
photoGroup?: any[];
onSelectPhoto?: (photo: any) => void;
onLoginSuccess?: (user: any) => void;
onUpdatePhoto?: (updatedPhoto: any) => void;
}
export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
isOpen,
onClose,
photo,
photoGroup = [],
onSelectPhoto,
onLoginSuccess,
onUpdatePhoto
}) => {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSending, setIsSending] = useState(false);
const commentsEndRef = useRef<HTMLDivElement>(null);
const [currentUser, setCurrentUser] = useState<any>(null);
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editLat, setEditLat] = useState<number | ''>('');
const [editLng, setEditLng] = useState<number | ''>('');
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const lat = photo?.metadata?.lat;
const lng = photo?.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
setResolvedAddress('Đang xác định địa điểm...');
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=vi`)
.then(res => {
if (!res.ok) throw new Error();
return res.json();
})
.then(data => {
if (data && data.display_name) {
const shortAddress = data.display_name.split(',').slice(0, 3).join(',').trim();
setResolvedAddress(shortAddress || data.display_name);
} else {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
}
})
.catch(() => {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
});
} else {
setResolvedAddress('Chưa xác định tọa độ');
}
}, [photo?.id, photo?.metadata?.lat, photo?.metadata?.lng]);
const checkCurrentUser = () => {
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
if (userStr) {
try {
setCurrentUser(JSON.parse(userStr));
} catch (e) {}
} else {
setCurrentUser(null);
}
};
useEffect(() => {
checkCurrentUser();
}, [isOpen]);
useEffect(() => {
if (photo) {
setEditTitle(photo.metadata?.title || '');
setEditDescription(photo.metadata?.description || '');
setEditLat(photo.metadata?.lat ?? '');
setEditLng(photo.metadata?.lng ?? '');
setIsEditing(false); // Reset editing mode when selected photo changes
}
}, [photo]);
const handleSaveEdit = async () => {
if (editLat !== '' && (isNaN(editLat) || editLat < -90 || editLat > 90)) {
alert('Vĩ độ không hợp lệ (-90 đến 90)');
return;
}
if (editLng !== '' && (isNaN(editLng) || editLng < -180 || editLng > 180)) {
alert('Kinh độ không hợp lệ (-180 đến 180)');
return;
}
setIsSavingEdit(true);
try {
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
const res = await fetch(`/api/v1/photos/${photo.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
title: editTitle,
description: editDescription,
latitude: editLat === '' ? undefined : editLat,
longitude: editLng === '' ? undefined : editLng
})
});
if (res.ok) {
const updatedPhoto = await res.json();
setIsEditing(false);
if (onUpdatePhoto) {
onUpdatePhoto(updatedPhoto);
}
} else {
const err = await res.json();
alert(err.message || 'Lỗi khi cập nhật thông tin ảnh.');
}
} catch (error) {
console.error('Lỗi khi cập nhật thông tin ảnh:', error);
alert('Không thể kết nối đến máy chủ.');
} finally {
setIsSavingEdit(false);
}
};
const likedUserIds = photo.metadata && Array.isArray((photo.metadata as any).likedUserIds)
? (photo.metadata as any).likedUserIds
: [];
const isLiked = currentUser && likedUserIds.includes(currentUser.id);
const likeCount = likedUserIds.length;
const handleToggleLike = async () => {
let token = localStorage.getItem('token') || localStorage.getItem('guest_token');
let userObj = currentUser;
if (!token) {
try {
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (guestRes.ok) {
const guestData = await guestRes.json();
token = guestData.access_token;
userObj = guestData.user;
localStorage.setItem('token', token!);
localStorage.setItem('user', JSON.stringify(userObj));
if (onLoginSuccess) {
onLoginSuccess(userObj);
}
setCurrentUser(userObj);
} else {
return;
}
} catch (e) {
console.error('Không thể tạo phiên khách:', e);
return;
}
}
try {
const res = await fetch(`/api/v1/photos/${photo.id}/toggle-like`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (res.ok) {
const updatedPhoto = await res.json();
if (onUpdatePhoto) {
onUpdatePhoto(updatedPhoto);
}
}
} catch (error) {
console.error('Lỗi khi thích ảnh:', error);
}
};
const fetchComments = async () => {
setIsLoading(true);
try {
const res = await fetch(`/api/v1/public-photos/${photo.id}/comments`);
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 || !photo.id) return;
fetchComments();
const socket = io();
socket.emit('joinPhoto', photo.id);
socket.on('photoCommentAdded', (newCommentData: any) => {
if (newCommentData.photoId === photo.id) {
setComments(prev => {
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,
userId: newCommentData.userId
}
];
});
}
});
return () => {
socket.disconnect();
};
}, [isOpen, photo.id]);
useEffect(() => {
commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [comments]);
const handleSend = async () => {
if (!newComment.trim()) return;
setIsSending(true);
try {
let token = localStorage.getItem('token');
let currentUser = JSON.parse(localStorage.getItem('user') || 'null');
// Nếu chưa có token, tự động tạo tài khoản khách
if (!token) {
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo tài khoản khách tự động.');
const guestData = await guestRes.json();
token = guestData.access_token;
currentUser = guestData.user;
localStorage.setItem('guest_token', token!);
localStorage.setItem('guest_user', JSON.stringify(currentUser));
localStorage.setItem('token', token!);
localStorage.setItem('user', JSON.stringify(currentUser));
if (onLoginSuccess) {
onLoginSuccess(currentUser);
}
}
let res = await fetch(`/api/v1/public-photos/${photo.id}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ content: newComment })
});
if (res.status === 401) {
console.warn('Token invalid or expired. Creating a new guest user and retrying comment...');
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
localStorage.removeItem('token');
localStorage.removeItem('user');
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo lại phiên khách.');
const guestData = await guestRes.json();
token = guestData.access_token;
currentUser = guestData.user;
localStorage.setItem('guest_token', token!);
localStorage.setItem('guest_user', JSON.stringify(currentUser));
localStorage.setItem('token', token!);
localStorage.setItem('user', JSON.stringify(currentUser));
if (onLoginSuccess) {
onLoginSuccess(currentUser);
}
res = await fetch(`/api/v1/public-photos/${photo.id}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ content: newComment })
});
}
if (res.ok) {
setNewComment('');
fetchComments();
} else {
const err = await res.json();
console.error('Lỗi khi gửi bình luận:', err.message);
}
} catch (error) {
console.error('Lỗi khi gửi bình luận:', error);
} finally {
setIsSending(false);
}
};
const isAuthorized = currentUser?.isAdmin ||
(currentUser && photo.uploader && currentUser.id === photo.uploader.id) ||
(currentUser && photo.uploaderId && currentUser.id === photo.uploaderId);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[5000] md:flex md:items-center md:justify-center md:p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300"
onClick={onClose}
/>
{/* Container */}
<div className="fixed inset-0 overflow-y-auto flex flex-col bg-slate-900 text-slate-100 md:relative md:inset-auto md:overflow-hidden md:rounded-[32px] md:shadow-2xl md:border md:border-slate-800 md:w-full md:max-w-5xl md:h-[85vh] md:flex-row animate-in zoom-in-95 duration-300">
{/* Close Button Mobile/Desktop */}
<button
onClick={onClose}
className="fixed md:absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 md:top-4 md:right-4 z-50 p-2 bg-slate-950/60 hover:bg-slate-800/80 border border-slate-700/50 rounded-full text-slate-300 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
{/* Left Side: Photo Detail */}
<div className="relative w-full md:w-3/5 md:h-full bg-slate-950 flex flex-col overflow-hidden group shrink-0">
{/* Photo wrapper for mobile view (handles top overlay name and bottom-right like) */}
<div className="relative w-full flex items-center justify-center md:absolute md:inset-0 md:flex md:items-center md:justify-center bg-slate-950">
{/* Mobile Only: Uploader details overlay */}
<div className="absolute top-[calc(0.75rem+env(safe-area-inset-top,0px))] left-4 z-40 md:hidden flex items-center gap-2 bg-slate-950/70 backdrop-blur-md px-2.5 py-1.5 rounded-full border border-slate-700/50">
<div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center">
<User className="w-3 h-3 text-emerald-400" />
</div>
<span className="text-xs text-slate-200 max-w-[120px] truncate">
{photo.uploader?.name || 'Ẩn danh'}
</span>
</div>
{/* Like Button Overlay */}
<button
onClick={handleToggleLike}
className="absolute bottom-4 right-4 z-40 flex items-center gap-1.5 bg-slate-950/70 hover:bg-slate-900/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md md:absolute md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
title={isLiked ? "Bỏ thích" : "Thích"}
>
<Heart className={`w-4 h-4 transition-colors ${
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-slate-350 hover:text-rose-450'
}`} />
<span>{likeCount}</span>
</button>
<a
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center"
onClick={(e) => {
e.preventDefault();
setIsFullscreen(true);
}}
>
<img
src={photo.imageUrl}
alt="Public Map Upload"
className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none"
/>
</a>
</div>
{/* Info & Timeline overlay inside photo panel */}
<div className="relative p-6 bg-slate-900 border-b border-slate-800/60 md:absolute md:bottom-0 md:left-0 md:right-0 md:bg-gradient-to-t md:from-black/60 md:via-black/20 md:to-transparent md:border-b-0 flex flex-col gap-4">
{/* Timeline scroll */}
{photoGroup && photoGroup.length > 1 && (
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
Lịch sử nh tại vị trí này ({photoGroup.length})
</span>
<div className="flex gap-3 overflow-x-auto no-scrollbar py-1">
{photoGroup.map((p) => {
const isActive = p.id === photo.id;
return (
<button
key={p.id}
onClick={() => onSelectPhoto?.(p)}
className={`relative w-12 h-12 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
isActive ? 'border-emerald-500 scale-110 shadow-lg' : 'border-slate-700 hover:border-slate-500'
}`}
>
<img src={p.imageUrl} alt="Timeline thumbnail" className="w-full h-full object-cover" />
<div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[8px] text-center font-bold text-slate-300 py-0.5">
{new Date(p.capturedAt).toLocaleDateString('vi-VN', { month: '2-digit', day: '2-digit' })}
</div>
</button>
);
})}
</div>
</div>
)}
{isEditing ? (
<div className="flex flex-col gap-3 bg-slate-900/95 border border-slate-800 p-4 rounded-2xl animate-in slide-in-from-bottom-2">
<h4 className="text-xs font-black uppercase tracking-wider text-emerald-400">Chỉnh sửa thông tin nh</h4>
<div className="space-y-2">
<div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Tiêu đ</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Nhập tiêu đề cho ảnh..."
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
<div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1"> tả</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Mô tả bức ảnh này..."
rows={2}
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1"> đ</label>
<input
type="number"
step="any"
value={editLat}
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Vĩ độ..."
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
<div>
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Kinh đ</label>
<input
type="number"
step="any"
value={editLng}
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Kinh độ..."
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
</div>
<div className="flex justify-start">
<button
type="button"
onClick={() => setIsMapOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all"
>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Chọn trên bản đ
</button>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setIsEditing(false)}
disabled={isSavingEdit}
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-bold transition-all"
>
Hủy
</button>
<button
onClick={handleSaveEdit}
disabled={isSavingEdit}
className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
>
{isSavingEdit ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
Đang lưu...
</>
) : (
'Lưu lại'
)}
</button>
</div>
</div>
) : (
<>
{/* Title & Description display */}
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
{photo.metadata?.title ? (
<h4 className="text-sm font-black text-white tracking-tight leading-snug break-words">
{photo.metadata.title}
</h4>
) : (
<span className="text-[10px] text-slate-500 italic block mb-1">Chưa tiêu đ</span>
)}
{photo.metadata?.description ? (
<p className="text-xs text-slate-300 leading-relaxed mt-1 max-h-20 overflow-y-auto no-scrollbar break-words">
{photo.metadata.description}
</p>
) : (
<span className="text-[10px] text-slate-500 italic block mt-1">Chưa tả</span>
)}
</div>
{isAuthorized && (
<button
onClick={() => setIsEditing(true)}
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
title="Chỉnh sửa thông tin"
>
<Edit className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Photo Metadata */}
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-800/80 pt-3 text-xs text-slate-350">
<div className="space-y-1 text-left">
<div className="flex items-center gap-1.5 text-slate-400">
<Calendar className="w-3.5 h-3.5" />
Ngày chụp: {new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
<div className="flex items-center gap-1.5 text-slate-400" title={photo.metadata?.lat && photo.metadata?.lng ? `${photo.metadata.lat.toFixed(6)}, ${photo.metadata.lng.toFixed(6)}` : ''}>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Đa điểm: {resolvedAddress}
</div>
<div className="hidden md:flex items-center gap-1.5 text-xs text-emerald-400">
<User className="w-3.5 h-3.5" />
Người đăng: {photo.uploader?.name || 'Ẩn danh'}
</div>
</div>
{isAuthorized && photo.originalUrl && (
<a
href={photo.originalUrl}
download
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]"
>
<Download className="w-3.5 h-3.5 text-emerald-500" />
Tải nh gốc
</a>
)}
</div>
</>
)}
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
</div>
{/* Right Side: Comments */}
<div className="w-full md:w-2/5 md:flex-1 md:min-h-0 flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800">
{/* Comments Header */}
<div className="hidden md:block p-6 border-b border-slate-800">
<div>
<h3 className="text-lg font-black tracking-tight text-white flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-emerald-500" />
Bình luận cộng đng
</h3>
<p className="text-xs text-slate-400 mt-1">nh chia sẻ công khai trên bản đ</p>
</div>
</div>
{/* Comments list scroll area */}
<div className="md:flex-1 md:overflow-y-auto p-6 space-y-4 bg-slate-900/50">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
<Loader2 className="w-8 h-8 animate-spin text-emerald-500" />
<span className="text-xs font-semibold">Đang tải bình luận...</span>
</div>
) : comments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500 gap-3">
<div className="p-4 bg-slate-800/40 rounded-full text-slate-600">
<MessageSquare className="w-8 h-8" />
</div>
<span className="text-sm font-semibold italic">Chưa bình luận nào. Hãy bắt đu cuộc trò chuyện!</span>
</div>
) : (
comments.map((c) => {
return (
<div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60">
<User className="w-4.5 h-4.5 text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
<span className="text-[9px] font-medium text-slate-500">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
</div>
</div>
</div>
);
})
)}
<div ref={commentsEndRef} />
</div>
{/* Comment Input Area */}
<div className="sticky bottom-0 md:static p-4 bg-slate-950 md:bg-slate-950/40 border-t border-slate-800/80 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] md:pb-4 z-40">
<div className="relative flex items-center gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isSending && handleSend()}
placeholder="Viết bình luận công khai..."
className="flex-1 bg-slate-800/65 border border-slate-700/70 text-slate-100 placeholder-slate-500 rounded-2xl px-4 py-3 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
disabled={isSending}
/>
<button
onClick={handleSend}
disabled={!newComment.trim() || isSending}
className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg shadow-emerald-950/30 flex items-center justify-center"
>
{isSending ? (
<Loader2 className="w-4.5 h-4.5 animate-spin" />
) : (
<Send className="w-4.5 h-4.5" />
)}
</button>
</div>
</div>
</div>
</div>
<CoordinateSelectModal
isOpen={isMapOpen}
onClose={() => setIsMapOpen(false)}
initialLat={typeof editLat === 'number' ? editLat : undefined}
initialLng={typeof editLng === 'number' ? editLng : undefined}
onSelect={(lat, lng) => {
setEditLat(lat);
setEditLng(lng);
}}
/>
{isFullscreen && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
onClick={() => setIsFullscreen(false)}
>
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
>
<X className="w-6 h-6" />
</button>
<img
src={photo.imageUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,416 @@
import React, { useState, useEffect } from 'react';
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, Image as ImageIcon } from 'lucide-react';
interface UserManagementModalProps {
isOpen: boolean;
onClose: () => void;
}
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState<'users' | 'photos' | 'trash'>('users');
const [users, setUsers] = useState<any[]>([]);
const [photos, setPhotos] = useState<any[]>([]);
const [trashPhotos, setTrashPhotos] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [photosLoading, setPhotosLoading] = useState(false);
const [trashLoading, setTrashLoading] = useState(false);
const [cleaning, setCleaning] = useState(false);
const [error, setError] = useState('');
const fetchUsers = async () => {
setLoading(true);
try {
const response = await fetch(`/api/v1/users`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) throw new Error('Không thể tải danh sách người dùng');
const data = await response.json();
setUsers(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const fetchPhotos = async () => {
setPhotosLoading(true);
try {
const response = await fetch(`/api/v1/public-photos`);
if (!response.ok) throw new Error('Không thể tải danh sách ảnh công cộng');
const data = await response.json();
setPhotos(data);
} catch (err: any) {
setError(err.message);
} finally {
setPhotosLoading(false);
}
};
const fetchTrashPhotos = async () => {
setTrashLoading(true);
try {
const response = await fetch(`/api/v1/admin/trash-photos`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) throw new Error('Không thể tải danh sách ảnh rác');
const data = await response.json();
setTrashPhotos(data);
} catch (err: any) {
setError(err.message);
} finally {
setTrashLoading(false);
}
};
const handleCleanTrash = async () => {
if (!confirm('Bạn có chắc muốn dọn sạch tất cả ảnh rác? Thao tác này sẽ xóa vĩnh viễn các tệp vật lý và dọn dẹp các bản ghi mồ côi.')) return;
setCleaning(true);
try {
const response = await fetch(`/api/v1/admin/trash-photos/clean`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Dọn dẹp thất bại');
alert(data.message);
fetchTrashPhotos();
} catch (err: any) {
alert(err.message);
} finally {
setCleaning(false);
}
};
const handleDeleteSingleTrash = async (item: any) => {
if (!confirm('Xóa mục này vĩnh viễn?')) return;
try {
const response = await fetch(`/api/v1/admin/trash-photos/delete-single`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: item.type,
filePath: item.filePath,
id: item.id
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Xóa mục thất bại');
fetchTrashPhotos();
} catch (err: any) {
alert(err.message);
}
};
useEffect(() => {
if (isOpen) {
if (activeTab === 'users') {
fetchUsers();
} else if (activeTab === 'photos') {
fetchPhotos();
} else if (activeTab === 'trash') {
fetchTrashPhotos();
}
}
}, [isOpen, activeTab]);
const handleToggleBlock = async (id: string) => {
try {
await fetch(`/api/v1/users/block/${id}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
fetchUsers();
} catch (err) {
alert('Lỗi khi thay đổi trạng thái block');
}
};
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;
try {
const res = await fetch(`/api/v1/users/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
fetchUsers();
} catch (err: any) {
alert(err.message);
}
};
const handleDeletePhoto = async (id: string) => {
if (!confirm('Bạn có chắc chắn muốn xóa bức ảnh công khai này?')) return;
try {
const res = await fetch(`/api/v1/photos/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
fetchPhotos();
} catch (err: any) {
alert(err.message);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[2000] 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-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col h-[85vh]">
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="w-6 h-6 text-blue-600" /> Hệ thống quản trị
</h2>
<p className="text-sm text-gray-500">Quản thành viên các tài nguyên công cộng của ng dụng.</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
{/* Tab Selection */}
<div className="flex border-b border-gray-100 bg-gray-50/20 px-6">
<button
onClick={() => setActiveTab('users')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'users' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<User className="w-4 h-4" />
Thành viên
</button>
<button
onClick={() => setActiveTab('photos')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'photos' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<ImageIcon className="w-4 h-4" />
nh công cộng
</button>
<button
onClick={() => setActiveTab('trash')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'trash' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<Trash2 className="w-4 h-4" />
nh rác
</button>
</div>
{/* Content Body */}
<div className="flex-1 overflow-y-auto p-6">
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold mb-4">{error}</div>
)}
{activeTab === 'users' ? (
loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : (
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
<th className="pb-4 font-bold px-2">Người dùng</th>
<th className="pb-4 font-bold">Vai trò</th>
<th className="pb-4 font-bold">Trạng thái</th>
<th className="pb-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{users.map(u => (
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || <User className="w-5 h-5" />}
</div>
<div>
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
<div className="text-xs text-gray-400">{u.email}</div>
</div>
</div>
</td>
<td className="py-4">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
)}
</td>
<td className="py-4">
{u.isBlocked ? (
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
) : (
<span className="text-green-500 text-xs font-bold">Đang hoạt đng</span>
)}
</td>
<td className="py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => handleToggleBlock(u.id)}
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
>
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(u.id)}
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
title="Xóa người dùng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)
) : activeTab === 'photos' ? (
photosLoading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : photos.length === 0 ? (
<div className="text-center py-20 text-gray-400 italic">Chưa nh công cộng nào đưc tải lên.</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
{photos.map(p => (
<div key={p.id} className="relative group bg-gray-50 border border-gray-100 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col justify-between">
<div className="aspect-square bg-slate-900 overflow-hidden flex items-center justify-center relative">
<img src={p.imageUrl} alt="Public content" className="w-full h-full object-cover transition-transform group-hover:scale-105" />
{/* Delete button shown on hover/focus */}
<button
onClick={() => handleDeletePhoto(p.id)}
className="absolute top-2 right-2 p-2 bg-red-600 hover:bg-red-500 text-white rounded-xl shadow-lg transition-all active:scale-95 opacity-0 group-hover:opacity-100 focus:opacity-100 z-10"
title="Xóa ảnh công cộng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="p-3 bg-white">
<div className="font-bold text-xs text-gray-800 truncate" title={p.uploader?.name || 'Ẩn danh'}>
Đăng bởi: {p.uploader?.name || 'Ẩn danh'}
</div>
<div className="text-[10px] text-gray-400 mt-1">
{new Date(p.capturedAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</div>
</div>
</div>
))}
</div>
)
) : (
trashLoading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : (
<div className="space-y-4">
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-2xl border border-gray-100">
<div>
<span className="text-sm font-bold text-gray-800">
Tổng số lượng: <span className="text-red-500 font-extrabold text-base">{trashPhotos.length}</span> mục nh rác
</span>
<span className="mx-2 text-gray-300">|</span>
<span className="text-sm font-bold text-gray-800">
Dung lượng ưc tính: <span className="text-blue-600 font-extrabold text-base">
{(trashPhotos.reduce((acc, curr) => acc + (curr.size || 0), 0) / (1024 * 1024)).toFixed(2)} MB
</span>
</span>
</div>
{trashPhotos.length > 0 && (
<button
onClick={handleCleanTrash}
disabled={cleaning}
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-2 shadow-md transition-all active:scale-95 disabled:opacity-50"
>
{cleaning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Dọn sạch nh rác
</button>
)}
</div>
{trashPhotos.length === 0 ? (
<div className="text-center py-20 text-gray-400 italic">Không nh rác nào. Hệ thống của bạn sạch sẽ!</div>
) : (
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
<table className="w-full text-left border-collapse bg-white">
<thead>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100 bg-gray-50/50">
<th className="py-3 px-4 font-bold">Hình nh</th>
<th className="py-3 px-4 font-bold">Thông tin</th>
<th className="py-3 px-4 font-bold"> do</th>
<th className="py-3 px-4 font-bold">Kích thước</th>
<th className="py-3 px-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{trashPhotos.map((item, index) => (
<tr key={index} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-3 px-4">
{item.url ? (
<div className="w-12 h-12 rounded-lg bg-gray-900 overflow-hidden shadow-sm border border-gray-100 flex items-center justify-center">
<img src={item.url} alt="Trash preview" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLElement).style.display = 'none'; }} />
</div>
) : (
<div className="w-12 h-12 rounded-lg bg-gray-100 border border-gray-100 flex items-center justify-center text-gray-400 text-[10px] font-bold">
N/A
</div>
)}
</td>
<td className="py-3 px-4 max-w-[200px] truncate">
<span className="text-xs font-mono text-gray-500 block truncate" title={item.url || item.filePath}>
{item.url || item.filePath}
</span>
<span className="text-[10px] font-black uppercase text-gray-400 tracking-wider">
{item.type === 'file_only' ? 'Tệp tin mồ côi' : 'Bản ghi mồ côi'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-xs text-red-500 font-semibold">{item.reason}</span>
</td>
<td className="py-3 px-4 text-xs font-semibold text-gray-700">
{((item.size || 0) / 1024).toFixed(1)} KB
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => handleDeleteSingleTrash(item)}
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-all shadow-sm active:scale-95"
title="Xóa vĩnh viễn"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
)}
</div>
</div>
</div>
);
};
+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;
};
+654
View File
@@ -0,0 +1,654 @@
import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, 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, Share2, Filter, MapPin, Loader2 } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal';
import { useNotification } from '@/hooks/useNotification';
import { CreateTourModal } from '../components/CreateTourModal';
import { PublicPhotoModal } from '../components/PublicPhotoModal';
// 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, onLoginSuccess }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => 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 [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]);
const groupedPhotos = React.useMemo(() => {
const groups: { [key: string]: any[] } = {};
publicPhotos.forEach((photo) => {
const lat = photo.metadata?.lat;
const lng = photo.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(photo);
}
});
// Sort each group by likes descending
Object.values(groups).forEach((group) => {
group.sort((a, b) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
});
});
return Object.values(groups);
}, [publicPhotos]);
const fetchPublicPhotos = async () => {
try {
const response = await fetch('/api/v1/public-photos');
if (response.ok) {
const data = await response.json();
setPublicPhotos(data);
}
} catch (error) {
console.error('Error fetching public photos:', error);
}
};
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(() => {
// Luôn tải danh sách ảnh công khai để hiển thị trên bản đồ cho tất cả mọi người
fetchPublicPhotos();
// Chỉ tải danh sách tour 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 {
setMapCenter(initialViewState.center);
}
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const photoId = params.get('photoId');
if (photoId && publicPhotos.length > 0) {
const foundPhoto = publicPhotos.find((p) => p.id === photoId);
if (foundPhoto) {
const lat = foundPhoto.metadata?.lat;
const lng = foundPhoto.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
const group = publicPhotos.filter((p) => {
const pLat = p.metadata?.lat;
const pLng = p.metadata?.lng;
return typeof pLat === 'number' && typeof pLng === 'number' &&
Math.abs(pLat - lat) < 0.00001 &&
Math.abs(pLng - lng) < 0.00001;
});
group.sort((a, b) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
});
setSelectedPhoto(foundPhoto);
setSelectedPhotoGroup(group);
} else {
setSelectedPhoto(foundPhoto);
setSelectedPhotoGroup([foundPhoto]);
}
}
}
}, [publicPhotos]);
return (
<div className="h-dvh w-full relative overflow-hidden">
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] 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="w-11 h-11 flex items-center justify-center bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 shrink-0"
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="w-11 h-11 bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center shrink-0"
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="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0"
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="w-11 h-11 md:w-auto bg-green-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
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="w-11 h-11 md:w-auto bg-blue-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
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="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-gray-700 border border-gray-100 shrink-0"
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}
attributionControl={false}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* 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 key={`cluster-${filteredTours.length}`} 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>
{groupedPhotos.map((photoGroup) => {
const latestPhoto = photoGroup[0];
const lat = latestPhoto.metadata?.lat;
const lng = latestPhoto.metadata?.lng;
if (typeof lat !== 'number' || typeof lng !== 'number') return null;
return (
<Marker
key={latestPhoto.id}
position={[lat, lng]}
eventHandlers={{
click: () => {
setSelectedPhoto(latestPhoto);
setSelectedPhotoGroup(photoGroup);
const params = new URLSearchParams(window.location.search);
params.set('photoId', latestPhoto.id);
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
}
}}
icon={L.divIcon({
className: 'custom-photo-bubble',
html: `
<a href="${window.location.origin}/api/v1/public-photos/${latestPhoto.id}/share" onclick="event.preventDefault();" class="relative group block">
<div class="w-12 h-12 rounded-full border-4 border-emerald-500 shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover" />
</div>
<div class="absolute -bottom-1 -right-1 bg-emerald-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
📸
</div>
${photoGroup.length > 1 ? `
<div class="absolute -top-1 -left-1 bg-rose-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[9px] font-black text-white shadow-md animate-bounce">
${photoGroup.length}
</div>
` : ''}
</a>
`,
iconSize: [48, 48],
iconAnchor: [24, 24]
})}
/>
);
})}
</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) => {
// Tự động tạo ghi chú mới cho hành trình vừa tạo
const savedNotes = localStorage.getItem('my_journey_notes');
let notes = [];
try {
notes = savedNotes ? JSON.parse(savedNotes) : [];
} catch (e) { notes = []; }
const newTourNote = {
id: Date.now().toString(),
title: `Ghi chú của hành trình: ${tour.title}`,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${tour.title}</strong> của bạn tại đây...</p>`,
createdAt: new Date().toISOString()
};
localStorage.setItem('my_journey_notes', JSON.stringify([newTourNote, ...notes]));
fetchTour(tour.id);
onViewTour(tour.id);
}}
/>
{selectedPhoto && (
<PublicPhotoModal
isOpen={!!selectedPhoto}
onClose={() => {
setSelectedPhoto(null);
setSelectedPhotoGroup([]);
const params = new URLSearchParams(window.location.search);
if (params.has('photoId')) {
params.delete('photoId');
const newSearch = params.toString();
const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ''}`;
window.history.replaceState({}, '', newUrl);
}
}}
photo={selectedPhoto}
photoGroup={selectedPhotoGroup}
onSelectPhoto={(photo) => {
setSelectedPhoto(photo);
const params = new URLSearchParams(window.location.search);
params.set('photoId', photo.id);
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
}}
onLoginSuccess={onLoginSuccess}
onUpdatePhoto={(updatedPhoto) => {
setPublicPhotos((prev) =>
prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p))
);
setSelectedPhoto(updatedPhoto);
setSelectedPhotoGroup((prev) =>
prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p))
);
}}
/>
)}
</div>
);
};
+332
View File
@@ -0,0 +1,332 @@
import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
import { LoginModal } from '../components/LoginModal';
import { useNotification } from '@/hooks/useNotification';
interface LandingPageProps {
onContinue?: () => void;
onGoToSignup?: () => void;
onGoToMap?: () => void;
onLoginSuccess?: (user: any) => void;
isInitialSetup?: boolean;
}
// Component Animation cho người lữ hành
const TravelerAnimation = ({ onClick }: { onClick?: () => void }) => {
return <button
onClick={onClick}
className="relative w-48 h-48 flex items-center justify-center transition-transform active:scale-95 focus:outline-none"
style={{
animation: 'gentle-shake 5s ease-in-out infinite',
}}
aria-label="Khám phá bản đồ"
>
{/* Giảm kích thước icon xuống 75% (w-24 -> w-18, md:w-28 -> md:w-21) */}
<Camera className="w-18 h-18 md:w-21 md:h-21 text-white/80 drop-shadow-2xl" strokeWidth={1.5} />
{/* Keyframes cho animation rung lắc */}
<style>{`
@keyframes gentle-shake {
0%, 100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(2deg) scale(1.05);
}
}
`}</style>
</button>;
};
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [currentBgIndex, setCurrentBgIndex] = useState(0);
const [bg1, setBg1] = useState('/background.avif');
const [bg2, setBg2] = useState('');
const [fade1, setFade1] = useState(true);
const [fade2, setFade2] = useState(false);
const [activeSlot, setActiveSlot] = useState<1 | 2>(1);
useEffect(() => {
const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif';
if (!nextUrl) return;
const currentUrl = activeSlot === 1 ? bg1 : bg2;
if (nextUrl === currentUrl) return;
if (activeSlot === 1) {
setBg2(nextUrl);
setFade2(true);
setFade1(false);
setActiveSlot(2);
} else {
setBg1(nextUrl);
setFade1(true);
setFade2(false);
setActiveSlot(1);
}
}, [currentBgIndex, publicPhotos]);
const fetchPublicPhotos = async () => {
try {
const res = await fetch('/api/v1/public-photos');
if (res.ok) {
const data = await res.json();
const sortedData = data.sort((a: any, b: any) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
});
setPublicPhotos(sortedData);
}
} catch (e) {
console.error('Lỗi khi tải ảnh công khai:', e);
}
};
useEffect(() => {
fetchPublicPhotos();
}, []);
useEffect(() => {
if (publicPhotos.length <= 1) return;
const interval = setInterval(() => {
setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length);
}, 12000);
return () => clearInterval(interval);
}, [publicPhotos]);
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try {
// Lấy tọa độ hiện tại của người dùng với cơ chế chống treo (Promise.race)
const location = await Promise.race([
new Promise<GeolocationPosition | null>((resolve) => {
if (!navigator.geolocation) {
resolve(null);
} else {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos),
() => resolve(null),
{ timeout: 4000, enableHighAccuracy: true }
);
}
}),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
]);
// 1. Tạo tài khoản khách và lấy token
let guestToken = localStorage.getItem('guest_token');
let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
if (!guestToken) {
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo phiên khách.');
const guestData = await guestRes.json();
guestToken = guestData.access_token;
guestUser = guestData.user;
localStorage.setItem('guest_token', guestToken!);
localStorage.setItem('guest_user', JSON.stringify(guestUser));
}
// 2. Tải ảnh lên
const formData = new FormData();
formData.append('images', file);
if (location) {
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
}
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
body: formData,
});
if (uploadRes.status === 401) {
console.warn('Guest token invalid or expired. Creating a new guest user and retrying...');
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
localStorage.removeItem('token');
localStorage.removeItem('user');
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo lại phiên khách.');
const guestData = await guestRes.json();
guestToken = guestData.access_token;
guestUser = guestData.user;
localStorage.setItem('guest_token', guestToken!);
localStorage.setItem('guest_user', JSON.stringify(guestUser));
uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
body: formData,
});
}
if (!uploadRes.ok) {
const errorData = await uploadRes.json();
throw new Error(errorData.message || 'Tải ảnh thất bại.');
}
notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' });
// Cập nhật lại danh sách ảnh lập tức
await fetchPublicPhotos();
setCurrentBgIndex(0);
// Đăng nhập luôn bằng tài khoản khách này để người dùng xem được ảnh của mình trên bản đồ
localStorage.setItem('token', guestToken!);
localStorage.setItem('user', JSON.stringify(guestUser));
if (onLoginSuccess) {
onLoginSuccess(guestUser);
}
} catch (error: any) {
notify({ title: 'Lỗi', message: error.message, type: 'error' });
} finally {
// Reset input để có thể chọn lại cùng 1 file
if (event.target) event.target.value = '';
}
};
return (
<div className="h-dvh w-full overflow-hidden font-sans bg-gray-900 relative">
{/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning */}
<div className="absolute inset-0 z-0 bg-gray-950 overflow-hidden">
{/* Slot 1 */}
{bg1 && (
<img
key={bg1}
src={bg1}
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
style={{
opacity: fade1 ? 0.8 : 0,
}}
alt="Travel Background 1"
/>
)}
{/* Slot 2 */}
{bg2 && (
<img
key={bg2}
src={bg2}
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
style={{
opacity: fade2 ? 0.8 : 0,
}}
alt="Travel Background 2"
/>
)}
</div>
{/* Keyframes cho hiệu ứng panning từ trái sang phải */}
<style>{`
@keyframes pan-left-to-right {
0% {
transform: translateX(0) translateZ(0);
}
100% {
transform: translateX(-16.667%) translateZ(0);
}
}
.animate-panning {
animation: pan-left-to-right 13500ms linear forwards;
will-change: transform;
}
`}</style>
{/* Top Bar - Thanh điều hướng trên cùng */}
<div className="absolute top-0 left-0 right-0 z-20 p-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center">
<div className="flex items-center gap-2 text-white drop-shadow-lg">
<Compass className="w-8 h-8" />
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">Travel Planner</span>
</div>
<button
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-2 bg-white/10 backdrop-blur-md text-white font-bold py-2 px-4 rounded-full border border-white/20 hover:bg-white/20 transition-all"
>
<LogIn className="w-4 h-4" />
<span className="text-sm">Đăng nhập</span>
</button>
</div>
{/* Body: Animation người lữ hành */}
<div className="absolute inset-0 flex items-center justify-center z-10" >
{/* Kích hoạt click vào file input để chọn hoặc chụp ảnh */}
<TravelerAnimation onClick={() => fileInputRef.current?.click()} />
</div>
{/* Input chọn file ẩn để chụp/chọn ảnh */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
capture="environment"
className="hidden"
/>
{/* Community Gallery Previews */}
{publicPhotos.length > 0 && (
<div className="absolute bottom-[calc(7rem+env(safe-area-inset-bottom,0px))] left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md">
Khoảnh khắc từ cộng đng ({publicPhotos.length})
</span>
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-2 px-4 justify-center">
{publicPhotos.slice(0, 8).map((photo, index) => (
<button
key={photo.id}
onClick={() => setCurrentBgIndex(index)}
className={`relative w-14 h-14 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
currentBgIndex === index ? 'border-emerald-500 scale-110 shadow-lg' : 'border-white/20 hover:border-white/50'
}`}
>
<img src={photo.imageUrl} alt="Community thumbnail" className="w-full h-full object-cover" />
</button>
))}
</div>
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
)}
{/* Bottom Bar - Nút hành động chính */}
<div className="absolute bottom-[calc(2rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4">
<button
onClick={onGoToMap}
className="w-full max-w-md mx-auto flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 active:scale-95"
>
<MapIcon className="w-5 h-5" />
Khám phá các hành trình du lịch
</button>
</div>
{/* Login Modal Component */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
onSwitchToSignup={onGoToSignup}
onLoginSuccess={onLoginSuccess}
/>
</div>
);
};
+242
View File
@@ -0,0 +1,242 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { ChevronLeft, FileText, Plus, Search, Trash2, Loader2, Save, Calendar } from 'lucide-react';
import ReactQuill, { Quill } from 'react-quill-new'; // Nếu react-quill gặp lỗi với React 18, hãy dùng react-quill-new
import 'react-quill-new/dist/quill.snow.css';
// Cấu hình Lucide Icons cho Quill
const Icons = Quill.import('ui/icons');
Icons['bold'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>';
Icons['italic'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>';
Icons['underline'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>';
Icons['strike'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" y1="12" x2="20" y2="12"/></svg>';
Icons['link'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
Icons['image'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
Icons['clean'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.9-9.9c1-1 2.5-1 3.4 0l4.4 4.4c1 1 1 2.5 0 3.4L11 21Z"/><path d="m22 21-5.9-5.9"/><path d="M16 11l-5 5"/></svg>';
Icons['table'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/></svg>';
Icons['header']['1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="m17 12 3-2v8"/></svg>';
Icons['header']['2'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>';
Icons['list']['bullet'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>';
Icons['list']['check'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg>';
Icons['align'][''] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>';
Icons['align']['center'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="10" x2="6" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="18" y1="18" x2="6" y2="18"/></svg>';
Icons['align']['right'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="10" x2="7" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="7" y2="18"/></svg>';
Icons['align']['justify'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>';
Icons['indent']['+1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="11 17 16 12 11 7"/><line x1="18" y1="12" x2="3" y2="12"/></svg>';
Icons['indent']['-1'] = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 17 3 12 8 7"/><line x1="21" y1="12" x2="6" y2="12"/></svg>';
interface Note {
id: string;
title: string;
content: string;
createdAt: string;
}
export const MyNotePage = ({ onBack }: { onBack: () => void }) => {
const [isLoading, setIsLoading] = useState(false);
const [notes, setNotes] = useState<Note[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const quillRef = useRef<ReactQuill>(null);
// State cho form ghi chú
const [noteForm, setNoteForm] = useState({ title: '', content: '' });
// Khôi phục ghi chú từ localStorage khi load trang
useEffect(() => {
const saved = localStorage.getItem('my_journey_notes');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
setNotes(parsed);
}
} catch (e) { console.error("Lỗi parse notes:", e); }
}
}, []);
// Tự động lưu ghi chú vào localStorage khi có thay đổi
useEffect(() => {
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
}, [notes]);
// Cấu hình các công cụ định dạng cho ReactQuill
const quillModules = useMemo(() => ({
table: true,
toolbar: {
container: [
[{ 'header': 1 }, { 'header': 2 }],
['bold', 'italic', 'underline', 'strike'],
[{ 'align': [] }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'list': 'bullet' }, { 'list': 'check' }],
['link', 'image', 'table', 'clean'],
],
handlers: {
table: function() {
const rows = prompt('Nhập số hàng:', '3');
const cols = prompt('Nhập số cột:', '3');
if (rows && cols) {
const quill = (quillRef.current as any)?.getEditor();
if (quill) {
quill.getModule('table').insertTable(parseInt(rows), parseInt(cols));
}
}
}
}
},
}), []);
const handleSaveNote = () => {
if (!noteForm.title.trim() && !noteForm.content.trim()) return;
const note: Note = {
id: Date.now().toString(),
title: noteForm.title || 'Ghi chú không tiêu đề',
content: noteForm.content,
createdAt: new Date().toISOString(),
};
setNotes([note, ...notes]);
setIsCreating(false);
setNoteForm({ title: '', content: '' });
};
const handleDeleteNote = (id: string) => {
if (window.confirm("Bạn có chắc chắn muốn xóa ghi chú này?")) {
setNotes(prev => prev.filter(n => n.id !== id));
}
};
const filteredNotes = (notes || []).filter(n => {
const title = (n.title || '').toLowerCase();
const content = (n.content || '').replace(/<[^>]*>/g, '').toLowerCase();
const query = searchQuery.toLowerCase();
return title.includes(query) || content.includes(query);
});
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">Ghi chú của tôi</h1>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Sổ tay hành trình nhân</p>
</div>
</div>
<div className="flex-1 p-6 max-w-2xl mx-auto w-full">
{!isCreating && (
<div className="flex items-center gap-4 mb-8">
<div className="relative flex-1">
<input
type="text"
placeholder="Tìm kiếm nội dung ghi chú..."
className="w-full pl-10 pr-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
</div>
<button
onClick={() => setIsCreating(true)}
className="p-3 bg-amber-500 text-white rounded-2xl shadow-lg shadow-amber-200 hover:bg-amber-600 transition-all active:scale-95"
>
<Plus className="w-6 h-6" />
</button>
</div>
)}
{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 ghi chú...</p>
</div>
) : isCreating ? (
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
<input
type="text"
placeholder="Tiêu đề ghi chú..."
className="w-full px-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-lg font-bold"
value={noteForm.title}
onChange={(e) => setNoteForm({ ...noteForm, title: e.target.value })}
/>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden min-h-[500px] flex flex-col">
<ReactQuill
ref={quillRef}
theme="snow"
value={noteForm.content}
onChange={(content) => setNoteForm({ ...noteForm, content })}
modules={quillModules}
placeholder="Bắt đầu viết cảm nhận của bạn tại đây..."
className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-editor]:flex-1 [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-gray-50 [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-gray-200 [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-gray-100 [&_.ql-stroke]:!stroke-gray-500 [&_.ql-fill]:!fill-gray-500 [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500"
/>
</div>
<div className="flex gap-3">
<button
onClick={handleSaveNote}
className="flex-1 flex items-center justify-center gap-2 bg-amber-500 hover:bg-amber-600 text-white py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-amber-200"
>
<Save className="w-5 h-5" /> Lưu ghi chú
</button>
<button
onClick={() => { setIsCreating(false); setNoteForm({ title: '', content: '' }); }}
className="px-6 py-4 bg-gray-100 hover:bg-gray-200 text-gray-500 rounded-2xl font-black uppercase tracking-widest text-xs transition-all"
>
Hủy
</button>
</div>
</div>
) : filteredNotes.length > 0 ? (
<div className="grid grid-cols-1 gap-4">
{filteredNotes.map((note) => (
<div key={note.id} className="bg-white p-5 rounded-3xl border border-gray-100 shadow-sm hover:shadow-md transition-all group">
<div className="flex justify-between items-start mb-3">
<div>
<h4 className="font-bold text-gray-900">{note.title}</h4>
<div className="flex items-center gap-2 text-[10px] text-gray-400 font-bold uppercase mt-1">
<Calendar className="w-3 h-3" />
{new Date(note.createdAt).toLocaleDateString('vi-VN')}
</div>
</div>
<button
onClick={() => handleDeleteNote(note.id)}
className="p-2 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div
className="text-sm text-gray-600 line-clamp-3 prose prose-sm max-w-none ql-editor !p-0 [&_table]:border-collapse [&_table]:w-full [&_td]:border [&_td]:border-gray-200 [&_td]:p-2 [&_.ql-align-center]:text-center [&_.ql-align-right]:text-right [&_.ql-align-justify]:text-justify"
dangerouslySetInnerHTML={{ __html: note.content }}
/>
</div>
))}
</div>
) : (
<div className="text-center py-24 bg-white rounded-[40px] border-2 border-dashed border-gray-100 shadow-inner">
<div className="w-20 h-20 bg-amber-50 rounded-3xl flex items-center justify-center mx-auto mb-6 text-amber-500">
<FileText className="w-10 h-10" />
</div>
<h3 className="text-xl font-bold text-gray-900">{searchQuery ? 'Không tìm thấy ghi chú' : 'Chưa có ghi chú nào'}</h3>
<p className="text-sm text-gray-400 max-w-xs mx-auto mt-2">
{searchQuery ? 'Hãy thử tìm kiếm với từ khóa khác.' : 'Hãy lưu lại những cảm nhận, lịch trình riêng hoặc các lưu ý quan trọng cho hành trình của bạn.'}
</p>
{!searchQuery && (
<button
onClick={() => setIsCreating(true)}
className="mt-8 inline-flex items-center gap-2 bg-amber-500 hover:bg-amber-600 text-white px-8 py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-amber-200 active:scale-95"
>
<Plus className="w-5 h-5" /> Tạo ghi chú mới
</button>
)}
</div>
)}
</div>
</div>
);
};
+619
View File
@@ -0,0 +1,619 @@
import { useEffect, useState, useMemo } from 'react';
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit, Heart } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
import { CoordinateSelectModal } from '../components/CoordinateSelectModal';
const getMostLikedPhoto = (photos: any[]) => {
if (!photos || photos.length === 0) return null;
return [...photos].sort((a, b) => {
const likesA = a.metadata?.likedUserIds?.length || 0;
const likesB = b.metadata?.likedUserIds?.length || 0;
return likesB - likesA;
})[0];
};
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();
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editLat, setEditLat] = useState<number | ''>('');
const [editLng, setEditLng] = useState<number | ''>('');
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const lat = selectedPhotoForDisplay?.metadata?.lat;
const lng = selectedPhotoForDisplay?.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
setResolvedAddress('Đang xác định địa điểm...');
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=vi`)
.then(res => {
if (!res.ok) throw new Error();
return res.json();
})
.then(data => {
if (data && data.display_name) {
const shortAddress = data.display_name.split(',').slice(0, 3).join(',').trim();
setResolvedAddress(shortAddress || data.display_name);
} else {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
}
})
.catch(() => {
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
});
} else {
setResolvedAddress('Chưa xác định tọa độ');
}
}, [selectedPhotoForDisplay?.id, selectedPhotoForDisplay?.metadata?.lat, selectedPhotoForDisplay?.metadata?.lng]);
useEffect(() => {
if (selectedPhotoForDisplay) {
setEditTitle(selectedPhotoForDisplay.metadata?.title || '');
setEditDescription(selectedPhotoForDisplay.metadata?.description || '');
setEditLat(selectedPhotoForDisplay.metadata?.lat ?? '');
setEditLng(selectedPhotoForDisplay.metadata?.lng ?? '');
setIsEditing(false);
}
}, [selectedPhotoForDisplay]);
const handleSaveEdit = async () => {
if (editLat !== '' && (isNaN(editLat) || editLat < -90 || editLat > 90)) {
notify({ title: 'Lỗi', message: 'Vĩ độ không hợp lệ (-90 đến 90).', type: 'error' });
return;
}
if (editLng !== '' && (isNaN(editLng) || editLng < -180 || editLng > 180)) {
notify({ title: 'Lỗi', message: 'Kinh độ không hợp lệ (-180 đến 180).', type: 'error' });
return;
}
setIsSavingEdit(true);
try {
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
},
body: JSON.stringify({
title: editTitle,
description: editDescription,
latitude: editLat === '' ? undefined : editLat,
longitude: editLng === '' ? undefined : editLng
})
});
if (!response.ok) throw new Error('Failed to update photo info');
const updatedPhoto = await response.json();
notify({ title: 'Thành công', message: 'Thông tin ảnh đã được cập nhật.', type: 'success' });
// Update photos list
setPhotos(prev => prev.map(p => p.id === updatedPhoto.id ? { ...p, metadata: updatedPhoto.metadata } : p));
// Update selectedPhotoForDisplay
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
setIsEditing(false);
} catch (error) {
notify({ title: 'Lỗi', message: 'Không thể cập nhật thông tin ảnh. Vui lòng thử lại.', type: 'error' });
} finally {
setIsSavingEdit(false);
}
};
const currentUser = useMemo(() => {
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch (e) {
return null;
}
}, []);
const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds)
? selectedPhotoForDisplay.metadata.likedUserIds
: [];
const isLiked = currentUser && likedUserIds.includes(currentUser.id);
const likeCount = likedUserIds.length;
const handleToggleLike = async () => {
if (!selectedPhotoForDisplay) return;
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
if (!token) return;
try {
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const updatedPhoto = await response.json();
setPhotos(prev => prev.map(p => p.id === updatedPhoto.id ? { ...p, metadata: updatedPhoto.metadata } : p));
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
}
} catch (error) {
console.error('Lỗi khi thích ảnh:', error);
}
};
useEffect(() => {
const fetchPhotos = async () => {
try {
const response = await fetch('/api/v1/users/me/photos', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_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(getMostLikedPhoto(filteredPhotos));
} 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') || localStorage.getItem('guest_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">
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
<img
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Selected Photo"
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
onClick={() => setIsFullscreen(true)}
/>
{/* Overlays (Only show when not editing) */}
{!isEditing && (
<>
{/* Like (Heart) button overlay */}
<button
onClick={handleToggleLike}
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-black/60 hover:bg-black/75 border border-white/10 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
title={isLiked ? "Bỏ thích" : "Thích"}
>
<Heart className={`w-4 h-4 transition-colors ${
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
}`} />
<span>{likeCount}</span>
</button>
{/* Delete button overlay */}
<button
onClick={(e) => {
e.stopPropagation();
handleDeletePhoto(selectedPhotoForDisplay.id);
}}
className="absolute top-4 right-4 z-10 p-2 bg-black/60 hover:bg-red-600 border border-white/10 text-white rounded-full transition-all active:scale-95 backdrop-blur-md"
title="Xóa ảnh này"
>
<Trash2 className="w-4 h-4" />
</button>
{/* Bottom metadata details gradient panel overlay */}
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
{selectedPhotoForDisplay.metadata?.title ? (
<h3 className="text-base font-extrabold text-white break-words drop-shadow-md">
{selectedPhotoForDisplay.metadata.title}
</h3>
) : (
<span className="text-xs text-gray-350 italic block mb-1 drop-shadow-md">Chưa tiêu đ</span>
)}
{selectedPhotoForDisplay.metadata?.description ? (
<p className="text-xs text-gray-250 leading-relaxed mt-1 break-words drop-shadow-sm max-h-16 overflow-y-auto no-scrollbar">
{selectedPhotoForDisplay.metadata.description}
</p>
) : (
<span className="text-[11px] text-gray-350 italic block mt-1 drop-shadow-sm">Chưa tả</span>
)}
</div>
{/* Edit Button Overlay */}
<button
onClick={() => setIsEditing(true)}
className="p-2 bg-white/10 hover:bg-white/20 border border-white/15 rounded-xl text-white hover:text-gray-200 transition-all shrink-0 backdrop-blur-sm"
title="Chỉnh sửa thông tin"
>
<Edit className="w-4 h-4" />
</button>
</div>
{/* Additional metadata info inside bottom overlay */}
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-3 text-xs text-gray-200">
<div className="space-y-1">
<div className="flex items-center gap-2 text-gray-300 text-[10px] font-bold uppercase tracking-wider">
<Calendar className="w-3.5 h-3.5" />
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</div>
<div className="flex items-center gap-2 font-bold" title={selectedPhotoForDisplay.metadata?.lat && selectedPhotoForDisplay.metadata?.lng ? `${selectedPhotoForDisplay.metadata.lat.toFixed(6)}, ${selectedPhotoForDisplay.metadata.lng.toFixed(6)}` : ''}>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Đa điểm: {resolvedAddress}
</div>
{selectedPhotoForDisplay.tour?.title && (
<div className="text-[10px] text-gray-400">
Hành trình: {selectedPhotoForDisplay.tour.title}
</div>
)}
</div>
{selectedPhotoForDisplay.originalUrl && (
<a
href={selectedPhotoForDisplay.originalUrl}
download
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold uppercase tracking-widest text-[9px] transition-all shadow-md active:scale-95 shrink-0"
>
<Download className="w-3.5 h-3.5 animate-pulse" /> Tải nh gốc
</a>
)}
</div>
</div>
</>
)}
</div>
{isEditing && (
<div className="mt-6 w-full flex flex-col gap-4 px-2">
<div className="space-y-3 bg-gray-50 border border-gray-150 p-4 rounded-2xl w-full">
<h4 className="text-xs font-black uppercase tracking-wider text-blue-605">Chỉnh sửa thông tin nh</h4>
<div className="space-y-2">
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Tiêu đ</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Nhập tiêu đề..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> tả</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Nhập mô tả..."
rows={2}
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> đ</label>
<input
type="number"
step="any"
value={editLat}
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Vĩ độ..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Kinh đ</label>
<input
type="number"
step="any"
value={editLng}
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Kinh độ..."
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
</div>
<div className="flex justify-start">
<button
type="button"
onClick={() => setIsMapOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 hover:text-gray-900 rounded-xl text-[10px] font-bold transition-all"
>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Chọn trên bản đ
</button>
</div>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setIsEditing(false)}
disabled={isSavingEdit}
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-xs font-bold transition-all"
>
Hủy
</button>
<button
onClick={handleSaveEdit}
disabled={isSavingEdit}
className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
>
{isSavingEdit ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Đang lưu...
</>
) : (
'Lưu lại'
)}
</button>
</div>
</div>
</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>
<CoordinateSelectModal
isOpen={isMapOpen}
onClose={() => setIsMapOpen(false)}
initialLat={typeof editLat === 'number' ? editLat : undefined}
initialLng={typeof editLng === 'number' ? editLng : undefined}
onSelect={(lat, lng) => {
setEditLat(lat);
setEditLng(lng);
}}
/>
{isFullscreen && selectedPhotoForDisplay && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
onClick={() => setIsFullscreen(false)}
>
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
>
<X className="w-6 h-6" />
</button>
<img
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More