Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03ee337e28 | |||
| 48ffd7e49b | |||
| 8df94bca26 | |||
| d2aced396f | |||
| 4e93722ba5 | |||
| 33a5996bee | |||
| 90475d4130 | |||
| 8e984e609b | |||
| b48502c34a | |||
| 978992057a | |||
| ed0fb8dd64 | |||
| 828bc89890 | |||
| b8bf4f88cc | |||
| f74587376e | |||
| a135196cb5 | |||
| cfdcb573de | |||
| f17215f236 | |||
| 5e60614588 | |||
| e385049652 | |||
| ef295e0994 | |||
| c3382a422d | |||
| 5d47e6d291 | |||
| 957ba6c72c | |||
| 4b551ccc31 | |||
| 9dc1faee6f | |||
| b998833371 | |||
| 13cd68707e | |||
| f355b9471a | |||
| a64af5f9cf | |||
| 7182391241 | |||
| 28ea9abccd | |||
| 9f41400bd8 | |||
| 78c5754655 | |||
| 5145835c8a | |||
| 6b15e7ff02 | |||
| a65be48d36 | |||
| ac91f6e4c8 | |||
| 09b1ee882d | |||
| acf867a375 | |||
| dd2635a44d | |||
| 29c19c6372 | |||
| 01d6d7439f | |||
| 860395cb14 | |||
| b1a539235b | |||
| 403c169ddd | |||
| 8d79cd76f6 | |||
| 392a4d4766 | |||
| 55fba75fda | |||
| 36157bd53b | |||
| 52706cab7d | |||
| ae97f061e8 | |||
| e34d197dd0 | |||
| fdf41e05fc | |||
| ae74035ad5 | |||
| ac11bb56db | |||
| 5081546cdf | |||
| 9da8a9494d | |||
| 4373ed684a | |||
| def0e0d0f9 | |||
| 36e8658dad | |||
| 6c1b77d7d2 | |||
| 4640526e4b | |||
| c7692ab9b6 | |||
| 36fbbb9386 | |||
| ef11309399 | |||
| 6f2dd0fc01 | |||
| 623f0164c8 | |||
| 88b47099b1 | |||
| 659d2a0840 | |||
| 68950dba10 | |||
| 3e215ae8e8 | |||
| e7b8c672e0 | |||
| 2462850e2e | |||
| abb61f65cd | |||
| da32d59161 | |||
| 40b1c4f2ab | |||
| 55de887f87 | |||
| ffe3cb6ecd | |||
| 69c67a1636 | |||
| f181009fa5 | |||
| b6551da147 | |||
| 2afb2971da | |||
| 14ffbb8657 | |||
| fbe5ef873b | |||
| d6ee1dde74 | |||
| 288b40ad12 | |||
| 3a3296c340 | |||
| e664a3797e | |||
| 36418673db | |||
| 98e4a4a340 | |||
| bfbbb66747 | |||
| 1933b58f84 | |||
| 05dc80bb6d | |||
| 4d63bff214 | |||
| c5474f1ff6 | |||
| 7ad785fed9 | |||
| e66f242c2c | |||
| 6026c2227a | |||
| 1eed9b00de | |||
| 1709638bfd | |||
| 37b5d14d7e | |||
| 2fabdb79df | |||
| 00554224a1 | |||
| 88b2182789 | |||
| 989d60643b | |||
| 1772ced959 | |||
| c005009da2 | |||
| bfd18e05dd | |||
| 29d39ae7b0 | |||
| 047170d4be | |||
| fc96ee9eb8 | |||
| 0a584a13c7 | |||
| f78c72ad60 | |||
| 8cca4a83ca | |||
| bc49e081c6 | |||
| 9e26aabce6 | |||
| 3e1a5db1a6 | |||
| baa83aad8a | |||
| 302a82e887 | |||
| 63da413b3c | |||
| 02c493f7e0 | |||
| b939fc959d | |||
| 5464d90948 | |||
| c5530f36df | |||
| dcaf71032c | |||
| aec9112064 | |||
| 8dac79fc4b | |||
| 75cf96898c | |||
| f52580717f | |||
| 879f20985c | |||
| 4edfd0eb59 | |||
| 55f0a8e775 | |||
| 7812d1395b | |||
| c9ef98b2ff | |||
| 71f4aa9ecd | |||
| 34e2af8825 | |||
| 4716b841dc | |||
| 967f6b4f6a |
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
server/node_modules/
|
||||
|
||||
# Bỏ qua cấu hình hệ thống và Git
|
||||
.git/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Bỏ qua các file log và build
|
||||
*.log
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
@@ -1,2 +1,4 @@
|
||||
.env
|
||||
node_modules
|
||||
server/dist
|
||||
dist
|
||||
|
||||
Vendored
+2
-1
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"continue.enableConsole": true
|
||||
"continue.enableConsole": true,
|
||||
"remote.autoForwardPortsFallback": 0
|
||||
}
|
||||
@@ -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">Vé 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ụ / Mô 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">Vĩ độ</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,346 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react';
|
||||
|
||||
interface AddMemberModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tourId: string;
|
||||
participants?: Array<{ userId: string; role: string; user?: { id: string; name: string; email: string } }>;
|
||||
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;
|
||||
}
|
||||
|
||||
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY'>('MEMBER');
|
||||
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 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]);
|
||||
|
||||
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
setFetchError('');
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const res = await fetch(`${API_BASE}/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');
|
||||
const data = await res.json();
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
setFetchError(err.message || 'Không thể tải danh sách người dùng');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
fetchUsers();
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setQuery('');
|
||||
setSelectedUser(null);
|
||||
setRole('MEMBER');
|
||||
setFetchError('');
|
||||
setSubmitError('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleRemove = async (userId: string, memberName: string) => {
|
||||
if (!onRemoveMember) return;
|
||||
setConfirmTarget({ userId, name: memberName });
|
||||
setIsConfirmOpen(true);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestAction = async (reqId: string, action: 'accept' | 'reject', userName: string) => {
|
||||
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`;
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.message || data.error || (action === 'accept' ? 'Không thể chấp nhận' : 'Không thể từ chối'));
|
||||
}
|
||||
await onMemberAdded();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Thao tác thất bại');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!selectedUser) return;
|
||||
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}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || data.error || 'Thao tác thất bại');
|
||||
}
|
||||
await onMemberAdded?.();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setSubmitError(err.message || 'Thao tác thất bại');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
{canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.length})</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{participants.map((p) => {
|
||||
const rawToken = localStorage.getItem('token');
|
||||
let currentUserId: string | null = null;
|
||||
try {
|
||||
const payload = JSON.parse(atob((rawToken || '').split('.')[1]));
|
||||
currentUserId = payload.sub;
|
||||
} catch {
|
||||
currentUserId = null;
|
||||
}
|
||||
const isCurrentUser = currentUserId && p.userId === currentUserId;
|
||||
const isOwner = p.role === 'OWNER';
|
||||
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
|
||||
return (
|
||||
<div key={p.userId} className="flex flex-col items-center gap-1">
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
|
||||
{p.user?.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
{canRemove && (
|
||||
<button
|
||||
onClick={() => handleRemove(p.userId, p.user?.name || p.userId)}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{p.user?.name || p.userId}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{participants.length === 0 && (
|
||||
<span className="text-xs text-gray-400">Chưa có thành viên nào</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{joinRequests.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-amber-500" /> Đang chờ phê duyệt ({joinRequests.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(joinRequests as any[]).map((req) => (
|
||||
<div key={req.id} className="flex flex-col items-center gap-1 relative">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold text-sm overflow-hidden">
|
||||
{req.user?.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1 flex">
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionLoading === req.id}
|
||||
onClick={() => handleRequestAction(req.id, 'accept', req.user?.name || req.userId)}
|
||||
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={actionLoading === req.id}
|
||||
onClick={() => handleRequestAction(req.id, 'reject', req.user?.name || req.userId)}
|
||||
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>
|
||||
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{req.user?.name || req.userId}</span>
|
||||
<span className="text-[9px] font-bold text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full border border-amber-200">PENDING</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canCreateDirectly && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as any)}
|
||||
>
|
||||
<option value="OWNER">OWNER</option>
|
||||
<option value="MANAGER">MANAGER</option>
|
||||
<option value="MEMBER">MEMBER</option>
|
||||
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE</option>
|
||||
<option value="VIEWER_ONLY">VIEWER_ONLY</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{fetchError && (
|
||||
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
|
||||
{fetchError}
|
||||
</div>
|
||||
)}
|
||||
{submitError && (
|
||||
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
|
||||
{visibleUsers.map((u) => {
|
||||
const isSelected = selectedUser === u.id;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => setSelectedUser(u.id)}
|
||||
disabled={requestUserIds.has(u.id)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${
|
||||
isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'
|
||||
} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
|
||||
{u.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
|
||||
<div className="text-[11px] text-gray-500">{u.email}</div>
|
||||
<div className="text-[11px] text-gray-400">{u.phone || ''} {u.address ? `• ${u.address}` : ''}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
|
||||
{u.isAdmin ? (
|
||||
<span className="px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100">ADMIN</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100">USER</span>
|
||||
)}
|
||||
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!loading && visibleUsers.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-500">Không tìm thấy người dùng phù hợp</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 border-t border-gray-100 flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
disabled={!selectedUser || submitting}
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all"
|
||||
>
|
||||
{submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời'}
|
||||
</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 có 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
|
||||
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const createTour = useTourStore((state) => state.createTour);
|
||||
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const searchUsers = async (value: string) => {
|
||||
setQuery(value);
|
||||
if (!value.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const res = await fetch(`${API_BASE}/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');
|
||||
const data = await res.json();
|
||||
setResults(Array.isArray(data) ? data : []);
|
||||
} catch (e: any) {
|
||||
setResults([]);
|
||||
setError(e.message || 'Không thể tải người dùng');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAddMember = (user: any) => {
|
||||
setMembers((prev) => (prev.some((m) => m.id === user.id) ? prev : [...prev, user]));
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const removeMember = (userId: string) => {
|
||||
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
||||
};
|
||||
|
||||
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 });
|
||||
onSuccess(tour);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Lỗi khi tạo tour');
|
||||
} 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-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Tạo Tour mới</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label>
|
||||
<input
|
||||
required
|
||||
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"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="VD: Khám phá Đà Lạt"
|
||||
/>
|
||||
</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="date"
|
||||
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"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label>
|
||||
<input
|
||||
type="date"
|
||||
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"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
{members.map((m) => (
|
||||
<div key={m.id} className="relative">
|
||||
<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 text-sm overflow-hidden">
|
||||
{m.name}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMember(m.id)}
|
||||
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<div className="text-[10px] text-center mt-1 max-w-[70px] truncate">{m.name}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
className="w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none"
|
||||
placeholder="Tìm email..."
|
||||
value={query}
|
||||
onChange={(e) => searchUsers(e.target.value)}
|
||||
/>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto">
|
||||
{results.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => confirmAddMember(u)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50"
|
||||
>
|
||||
<span className="font-bold text-gray-900">{u.name}</span>
|
||||
<span className="block text-xs text-gray-500">{u.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600 mt-2">{error}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? 'Đang tạo...' : 'Xác nhận tạo Tour'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
# To AI Agent: Implement Smart Date-Based Accordion Auto-Expansion and Smooth Viewport Auto-Scroll
|
||||
|
||||
## 1. Context & UI/UX Requirements
|
||||
We are enhancing the routing UX between `MemberDashboard.tsx` and `ItineraryTimeline.tsx` based on `image_cc31a4.png`.
|
||||
|
||||
### Functional Specifications:
|
||||
1. **Date-Matching Evaluation:** When a user clicks "Chi tiết hành trình" on a tour card, calculate if the current user system local date falls inside any Stage/Leg timeline block.
|
||||
2. **Auto-Expansion State:** On timeline page load, the matched Leg accordion must be expanded by default (`expandedStageId === leg.id`).
|
||||
3. **Smart Auto-Scroll Layout:** The viewport must smoothly auto-scroll so that the expanded Leg's title bar lands **exactly below the sticky navigation tab bar** ("Lộ trình", "Chi phí", etc.), making it fully visible at the top of the viewport without layout clipping.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Architecture & Layout Constraints
|
||||
|
||||
- **The Sticky Header Challenge:** Since the top navigation tab bar uses sticky/fixed positioning, a naive `scrollIntoView({ block: 'start' })` will cause the Leg's title to crawl *underneath* the tabs, hiding it.
|
||||
- **The Solution:** We will inject a dynamic CSS scroll-margin-top parameter (`scroll-mt-[70px]` or matching header height) on each Leg container, and trigger a minor delayed layout effect pool using a React `setTimeout` to wait for the DOM accordion expansion reflow before pulling the scroll trigger.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Refactoring Blueprint
|
||||
|
||||
### Step 1: Update Navigation Payload Handler in Dashboard Component
|
||||
Ensure the `MemberDashboard.tsx` accurately packages the matched target identifier into the client route state bucket:
|
||||
|
||||
```typescript
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNavigateToItinerary = (tour: any) => {
|
||||
let targetLegId = null;
|
||||
|
||||
if (tour?.legs && tour.legs.length > 0) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
for (const leg of tour.legs) {
|
||||
const rawStart = leg.startDate || leg.plannedStart || leg.date;
|
||||
const rawEnd = leg.endDate || leg.plannedEnd || leg.date;
|
||||
|
||||
if (rawStart) {
|
||||
const startBound = new Date(rawStart);
|
||||
startBound.setHours(0, 0, 0, 0);
|
||||
|
||||
const endBound = rawEnd ? new Date(rawEnd) : new Date(rawStart);
|
||||
endBound.setHours(23, 59, 59, 999);
|
||||
|
||||
if (today >= startBound && today <= endBound) {
|
||||
targetLegId = leg.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetLegId) {
|
||||
targetLegId = tour.legs[0].id; // Fallback to Chặng 1 if out of tour bounds
|
||||
}
|
||||
}
|
||||
|
||||
navigate(`/tour/${tour.id}`, {
|
||||
state: { defaultExpandedLegId: targetLegId, shouldScrollToTarget: true }
|
||||
});
|
||||
};
|
||||
|
||||
### Step 2: Inject Safe Identifiers and Scroll Margin inside ItineraryTimeline
|
||||
Locate the top-level outer container div of each Leg item inside the timeline loop (currentTour.legs.map). Assign a unique id and a scroll margin class utility:
|
||||
|
||||
{currentTour.legs.map((leg: any, legIdx: number) => (
|
||||
<div
|
||||
key={leg.id}
|
||||
id={`leg-anchor-node-${leg.id}`}
|
||||
/* CRITICAL: scroll-mt-[70px] leaves a 70px buffer space at the top.
|
||||
Adjust '70px' to match the exact height of your white Tour Navigation Tabs bar!
|
||||
*/
|
||||
className="scroll-mt-[70px] transition-all w-full mb-4"
|
||||
>
|
||||
{/* Accordion Header Title ("Chặng 2: Dạo chơi ở xứ hoa vàng...") */}
|
||||
<div className="flex items-center justify-between ...">
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
### Step 3: Implement Lifecycle Interaction Trigger Engine
|
||||
Inside ItineraryTimeline.tsx, listen to the passed history route parameters. Set the state, then handle the async smooth scrolling action:
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const location = useLocation();
|
||||
const [expandedStageId, setExpandedStageId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.defaultExpandedLegId) {
|
||||
const targetId = location.state.defaultExpandedLegId;
|
||||
|
||||
// 1. Instantly trigger the accordion state layout expansion
|
||||
setExpandedStageId(targetId);
|
||||
|
||||
// 2. Schedule a deferred macro-task callback to allow DOM re-renders to finish
|
||||
if (location.state?.shouldScrollToTarget) {
|
||||
const scrollTimer = setTimeout(() => {
|
||||
const targetElement = document.getElementById(`leg-anchor-node-${targetId}`);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
|
||||
// Clear history router state token flags to prevent repeating scroll on subsequent reload shifts
|
||||
window.history.replaceState({}, document.title);
|
||||
}, 150); // 150ms ensures smooth accordion height deployment transition finishes
|
||||
|
||||
return () => clearTimeout(scrollTimer);
|
||||
}
|
||||
} else if (currentTour?.legs && currentTour.legs.length > 0 && !expandedStageId) {
|
||||
setExpandedStageId(currentTour.legs[0].id);
|
||||
}
|
||||
}, [location.state, currentTour]);
|
||||
|
||||
## 4. Quality Control & Acceptance Verification
|
||||
[ ] Date Matching Accuracy: Set today's date context matching a target Leg configuration profile. Tap the transition interface button from dashboard. The page must route directly and expand the target container block node.
|
||||
|
||||
[ ] Flush Viewport Ceiling Test: The animated target header segment node must slide upwards smoothly. It must lock positions cleanly right below the lowest baseline layer shadow boundary of the Tab Controller without passing behind it.
|
||||
|
||||
[ ] State Cleanliness Check: Refreshing or navigating back and forth within the active Timeline panel after the initial landing should not lock or force layout views to keep jumping scroll heights automatically.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Enhancement Summary: Tag Selection & Photo Filtering
|
||||
|
||||
## Overview
|
||||
Successfully enhanced the travel planning application with improved photo tagging UI and photo filtering capabilities on the explore map.
|
||||
|
||||
## Changes Implemented
|
||||
|
||||
### 1. Enhanced TagSelectModal Component ✅
|
||||
**File**: `frontend/src/components/TagSelectModal.tsx`
|
||||
|
||||
**Features Added**:
|
||||
- **Image Preview**: Shows the uploaded photo at the top of the modal (max-height: 192px, with rounded corners)
|
||||
- **Custom Tag Input**: Text input with "Nhập thẻ mới..." placeholder
|
||||
- **Add Custom Tags**: Button and Enter key support to add user-defined tags
|
||||
- **Remove Custom Tags**: Trash icon on hover to delete custom tags
|
||||
- **Visual Feedback**: Selected tags highlighted in different colors (blue for predefined, emerald for custom)
|
||||
- **Summary Section**: Displays count and list of all selected tags before confirmation
|
||||
|
||||
**UI Improvements**:
|
||||
- Sticky header and footer for easy access
|
||||
- Separate sections for predefined tags and custom input
|
||||
- Max-height with scrolling for long tag lists
|
||||
- Smooth animations and transitions
|
||||
|
||||
### 2. LandingPage Integration ✅
|
||||
**File**: `frontend/src/pages/LandingPage.tsx`
|
||||
|
||||
**Updates**:
|
||||
- Added `photoPreviewUrl` state for temporary preview image
|
||||
- Created object URL using `URL.createObjectURL()` when file is selected
|
||||
- Pass preview URL to TagSelectModal component
|
||||
- Proper cleanup with `URL.revokeObjectURL()` on modal close or after upload
|
||||
- Included custom tags in upload formData as JSON
|
||||
|
||||
**State Management**:
|
||||
```typescript
|
||||
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
|
||||
```
|
||||
|
||||
### 3. ExploreMap Photo Tag Filtering ✅
|
||||
**File**: `frontend/src/pages/ExploreMap.tsx`
|
||||
|
||||
**Features Added**:
|
||||
- **Photo Tag Filter State**: `selectedPhotoFilterTags` for tracking active filters
|
||||
- **Available Photo Tags**: `availablePhotoTags` computed from all public photos' metadata
|
||||
- **Enhanced Filter Dropdown**: Two-section filter UI:
|
||||
- **Tour Section (🧳 Chuyến đi)**: Existing tour tags (single selection)
|
||||
- **Photo Section (📸 Ảnh công khai)**: Photo tags from uploads (multiple selection)
|
||||
- **Filtering Logic**: `groupedPhotos` useMemo filters photos based on selected tags
|
||||
- **Dynamic Tag Population**: Photo tags automatically extracted from `photo.metadata.tags`
|
||||
|
||||
**Filtering Behavior**:
|
||||
- Multiple photo tags can be selected simultaneously
|
||||
- Photos matching ANY selected tag are displayed (OR logic)
|
||||
- "Tất cả" button clears photo filters
|
||||
- Filter is independent from tour tag filtering
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Tag Storage
|
||||
- Backend stores tags in `photo.metadata.tags` as JSON array
|
||||
- No database schema changes required
|
||||
- Flexible for custom tags without pre-definition
|
||||
|
||||
### Frontend State Management
|
||||
```typescript
|
||||
// State
|
||||
const [selectedPhotoFilterTags, setSelectedPhotoFilterTags] = useState<string[]>([]);
|
||||
|
||||
// Computed
|
||||
const availablePhotoTags = React.useMemo(() => {
|
||||
const tagsSet = new Set<string>();
|
||||
publicPhotos.forEach(photo => {
|
||||
const tags = photo.metadata?.tags as string[] | undefined;
|
||||
if (Array.isArray(tags)) {
|
||||
tags.forEach(tag => tagsSet.add(tag));
|
||||
}
|
||||
});
|
||||
return Array.from(tagsSet).sort();
|
||||
}, [publicPhotos]);
|
||||
|
||||
// Filtered Results
|
||||
const groupedPhotos = React.useMemo(() => {
|
||||
let filteredPhotos = publicPhotos;
|
||||
if (selectedPhotoFilterTags.length > 0) {
|
||||
filteredPhotos = publicPhotos.filter((photo) => {
|
||||
const photoTags = photo.metadata?.tags as string[] | undefined;
|
||||
if (!Array.isArray(photoTags)) return false;
|
||||
return selectedPhotoFilterTags.some(tag => photoTags.includes(tag));
|
||||
});
|
||||
}
|
||||
// ... grouping and sorting logic
|
||||
}, [publicPhotos, selectedPhotoFilterTags]);
|
||||
```
|
||||
|
||||
## Testing Results
|
||||
|
||||
### Verified Functionality
|
||||
✅ Image preview displays in TagSelectModal
|
||||
✅ Custom tag input accepts user text
|
||||
✅ Custom tags can be added with button or Enter key
|
||||
✅ Custom tags can be removed with trash icon
|
||||
✅ All selected tags display in summary
|
||||
✅ Photo tag filtering works on ExploreMap
|
||||
✅ Multiple photo tags can be selected
|
||||
✅ "Tất cả" button clears filters
|
||||
✅ Docker build successful (0 errors)
|
||||
✅ All services running healthy
|
||||
|
||||
### Build Status
|
||||
- Frontend build: ✅ Success
|
||||
- Backend build: ✅ Success
|
||||
- Container deployment: ✅ All 4 services running
|
||||
- Browser testing: ✅ Filter dropdown functional
|
||||
|
||||
## User Workflow
|
||||
|
||||
### Photo Upload with Tags
|
||||
1. User clicks "Chụp ảnh" button
|
||||
2. Selects image from device
|
||||
3. Image is displayed in TagSelectModal preview
|
||||
4. User selects predefined tags from 11 categories
|
||||
5. User can add custom tags in textbox
|
||||
6. Confirms and photo is uploaded with all tags
|
||||
7. Tags stored in database for filtering
|
||||
|
||||
### Photo Discovery with Filtering
|
||||
1. User navigates to "Khám phá" (Explore)
|
||||
2. Clicks filter button to open dropdown
|
||||
3. Sees available photo tags from community uploads
|
||||
4. Selects one or more tags to filter
|
||||
5. Map refreshes showing only photos with selected tags
|
||||
6. Clear selection with "Tất cả" button to see all photos again
|
||||
|
||||
## Files Modified
|
||||
- ✅ `frontend/src/components/TagSelectModal.tsx` - Enhanced with preview and custom tags
|
||||
- ✅ `frontend/src/pages/LandingPage.tsx` - Integration with preview URL
|
||||
- ✅ `frontend/src/pages/ExploreMap.tsx` - Photo tag filtering implementation
|
||||
|
||||
## Browser Compatibility
|
||||
- Modern browsers with ES6+ support
|
||||
- Tested on latest Chrome/Firefox/Safari
|
||||
- Mobile responsive design with touch support
|
||||
|
||||
## Performance Considerations
|
||||
- Photo tag extraction done in useMemo (cached)
|
||||
- Filter operations are optimized with Set for uniqueness
|
||||
- Lazy filtering applied only to grouped photos
|
||||
- No additional API calls needed (uses existing photo data)
|
||||
|
||||
## Future Enhancements
|
||||
- Tag search/autocomplete in filter
|
||||
- Tag popularity sorting
|
||||
- Tag suggestions based on similar photos
|
||||
- User tag preferences/favorites
|
||||
- Tag analytics dashboard
|
||||
@@ -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
@@ -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 lý 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='© 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
# To AI Agent: Fix Infinite Re-render Loop and Flickering "ĐANG TÌM ĐƯỜNG TỐI ƯU..." Label
|
||||
|
||||
## 1. Context & Layout Bug Analysis
|
||||
We are resolving a critical performance and UI bug inside the Map routing engine interface (`image.png`):
|
||||
- **The Issue:** The floating loading indicator badge reading `"ĐANG TÌM ĐƯỜNG TỐI ƯU..."` keeps flashing, flickering, or appearing and disappearing in an infinite execution loop.
|
||||
- **Root Cause:** This is caused by a broken React lifecycle loop. Every time the map triggers a directions route query, it toggles a loading state variable (`isSearching: true`). Once the route loads, the state updates (`isSearching: false`), forcing a component re-render. If the coordinates object (`origin`, `destination`) or the map instance ref inside the `useEffect` dependency array changes its reference pointer on every render, the hook fires *again*, creating an endless loop of API fetching and component flashing.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Execution Strategy
|
||||
To terminate this flickering cycle, we must enforce a strict guard rail on the network/calculation trigger pipeline:
|
||||
1. **Coordinate Reference Stability:** Deconstruct the input latitude/longitude objects into raw primitive string values (e.g., `origin.lat`, `origin.lng`) inside the hook dependency array to avoid object reference mutations triggering re-renders.
|
||||
2. **Locking Ref Mechanism:** Implement a mutable React tracking reference (`const queryInProgress = useRef(false)`) to lock the execution window. If a fetch operation is active, block subsequent duplicate queries from firing.
|
||||
3. **Clean Loading State Termination:** Turn off the search state flag explicitly only *after* the route geometry polyline has completely finished rendering onto the map viewport layout.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Refactoring Blueprint
|
||||
|
||||
Locate your map navigation layer or modal component (e.g., `LocationNavigationModal.tsx`) and refactor the execution lifecycle loop as defined below:
|
||||
|
||||
```typescript
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
|
||||
// Inside your Map Navigation Modal / Layer component wrapper:
|
||||
export const LocationNavigationModal = ({ isOpen, routeData }) => {
|
||||
const [isSearchingRoute, setIsSearchingRoute] = useState(false);
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
|
||||
// CRITICAL FIX 2: Guard mechanism to block concurrent duplicated queries
|
||||
const fetchLockRef = useRef(false);
|
||||
|
||||
// Deconstruct coordinate primitives to secure a stable dependency array
|
||||
const originLat = routeData?.origin?.lat;
|
||||
const originLng = routeData?.origin?.lng;
|
||||
const destLat = routeData?.destination?.lat;
|
||||
const destLng = routeData?.destination?.lng;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !originLat || !originLng || !destLat || !destLng) return;
|
||||
|
||||
// If an operation is already locked and active, bail out immediately to prevent loops
|
||||
if (fetchLockRef.current) return;
|
||||
|
||||
const calculateOptimalRoute = async () => {
|
||||
try {
|
||||
// 1. Activate loading feedback banner
|
||||
setIsSearchingRoute(true);
|
||||
fetchLockRef.current = true; // Engage execution lock
|
||||
|
||||
console.log("Fetching route coordinates exactly once...");
|
||||
|
||||
// --- YOUR MAP COMPONENT ROUTING LOGIC START ---
|
||||
// Example: const response = await directionsService.route({...});
|
||||
// await mapInstanceRef.current.drawPolyline(response);
|
||||
// --- YOUR MAP COMPONENT ROUTING LOGIC END ---
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to compile route optimization maps:", error);
|
||||
} finally {
|
||||
// 2. Safe, definitive termination of the tracking states
|
||||
setIsSearchingRoute(false);
|
||||
fetchLockRef.current = false; // Disengage execution lock
|
||||
}
|
||||
};
|
||||
|
||||
calculateOptimalRoute();
|
||||
|
||||
// Cleanup phase: Reset execution parameters when inputs dismantle or modal closes
|
||||
return () => {
|
||||
fetchLockRef.current = false;
|
||||
setIsSearchingRoute(false);
|
||||
};
|
||||
|
||||
/* CRITICAL FIX 1: Explicitly tracking primitives only.
|
||||
Do NOT pass full 'routeData', 'mapInstanceRef' or object literals here!
|
||||
*/
|
||||
}, [isOpen, originLat, originLng, destLat, destLng]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{/* Map Content Target Canvas */}
|
||||
<div id="navigation-viewport-map-canvas" className="w-full h-full" />
|
||||
|
||||
{/* RENDER CONTROLLER: Only mount the label if routing calculations are actively processing */}
|
||||
{isSearchingRoute && (
|
||||
<div className="absolute top-16 left-4 z-30 bg-white/95 dark:bg-slate-900/95 border border-slate-200 dark:border-slate-800 px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 animate-pulse">
|
||||
{/* Circular Loading Spinner Element */}
|
||||
<div className="w-3.5 h-3.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-[11px] font-bold text-slate-700 dark:text-slate-200 uppercase tracking-wider">
|
||||
ĐANG TÌM ĐƯỜNG TỐI ƯU...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
## 4. Verification & Quality Acceptance Criteria
|
||||
[ ] Single Instance Trigger: Check console telemetry outputs. When the map modal mounts, the route compilation routine must print its trace log exactly once.
|
||||
|
||||
[ ] Flicker Nullification: The "ĐANG TÌM ĐƯỜNG TỐI ƯU..." badge must display smoothly with an animation pulse. It must not shake, blink, flash, or rapid-cycle on and off.
|
||||
|
||||
[ ] Deterministic Hiding: As soon as the blue route line draws completely across the map terrain grid layout, the loading badge must cleanly unmount and disappear from view without reappearing unless a new destination node button is clicked.
|
||||
@@ -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 có 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
# To AI Agent: Implement Clickable Google Maps Hyperlinks in PDF Export Table
|
||||
|
||||
## 1. Context & Feature Objective
|
||||
We are upgrading the PDF export functionality within the Yotrip Travel Planner application. Currently, the PDF generates a static table containing location names, addresses, and coordinates.
|
||||
**Objective:** Automatically turn every location row inside the PDF table into an interactive, clickable hyperlink. When a user clicks on the location cell in the generated PDF document, it must immediately open a browser tab navigating directly to that exact location on Google Maps.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Architecture & Data Strategy
|
||||
|
||||
Because `jspdf-autotable` draws text onto a canvas layout, raw HTML tags like `<a>` will fail. We must implement a two-step rendering lifecycle:
|
||||
1. **Extraction & Styling State:** During the data loop, verify coordinates or address data. Generate a standard universal Google Maps search URL, save it into a coordinate-tracking index object, and transform the raw text cell into a styled cell object (Yale Blue text `#28536b` to mimic an online link).
|
||||
2. **Link Injection Layer:** Utilize the `didDrawCell` hook callback inside the `doc.autoTable` configuration to position a native invisible link window (`doc.link()`) precisely over the drawn dimensions of that specific location cell.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Refactoring Specification
|
||||
|
||||
### Step 1: Update Data Preparation Loop
|
||||
Locate the loop processing `currentTour.legs` inside your PDF generation code block. Initialize a temporary lookup map array named `pdfMapLinks` and refactor the item distribution logic as follows:
|
||||
|
||||
```typescript
|
||||
// Initialize an index mapping registry for hyperlinks before the loop execution
|
||||
const pdfMapLinks: { [key: number]: string } = {};
|
||||
|
||||
if (currentTour?.legs && currentTour.legs.length > 0) {
|
||||
currentTour.legs.forEach((leg: any, legIdx: number) => {
|
||||
const legName = leg.note || `Chặng ${legIdx + 1}`;
|
||||
const legLocations = (leg.locations || []).filter((loc: any) =>
|
||||
loc && (loc.plannedStart || loc.plannedEnd || loc.name)
|
||||
);
|
||||
|
||||
const startRow = globalRowIndex;
|
||||
|
||||
if (legLocations.length > 0) {
|
||||
legRowRanges[leg.id] = { start: startRow, count: legLocations.length };
|
||||
|
||||
legLocations.forEach((loc: any, locIdx: number) => {
|
||||
const isStartPoint = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
|
||||
const timeSource = isStartPoint ? loc.plannedEnd : loc.plannedStart;
|
||||
const timeStr = timeSource ? formatDateTime(timeSource) : '';
|
||||
|
||||
const locName = loc.name || '';
|
||||
const addressStr = loc.address || '';
|
||||
const coordStr = loc.latitude && loc.longitude
|
||||
? `\n${loc.latitude}, ${loc.longitude}`
|
||||
: '';
|
||||
const locationText = [locName, addressStr, coordStr].filter(Boolean).join('\n');
|
||||
const noteText = loc.note || '';
|
||||
|
||||
// 1. Generate standard universal Google Maps URL query pattern
|
||||
let mapUrl = '';
|
||||
if (loc.latitude && loc.longitude) {
|
||||
// Absolute Precision using GPS coordinates
|
||||
mapUrl = `https://www.google.com/maps/search/?api=1&query=${loc.latitude},${loc.longitude}`;
|
||||
} else if (addressStr || locName) {
|
||||
// Text-search query fallback if GPS coordinates are missing
|
||||
mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(addressStr || locName)}`;
|
||||
}
|
||||
|
||||
// 2. Register current row array position to map lookup index
|
||||
const currentRowPosition = tableRows.length;
|
||||
if (mapUrl) {
|
||||
pdfMapLinks[currentRowPosition] = mapUrl;
|
||||
}
|
||||
|
||||
// 3. Convert raw string into custom styled autoTable Cell configuration object
|
||||
const locationCellObj = {
|
||||
content: locationText,
|
||||
// Apply custom link colors matching theme style #28536b (Yale Blue)
|
||||
styles: mapUrl ? { textColor: [40, 83, 107], fontStyle: 'bold' as const } : {}
|
||||
};
|
||||
|
||||
tableRows.push([
|
||||
stt++,
|
||||
timeStr,
|
||||
locIdx === 0 ? legName : '',
|
||||
locationCellObj, // Injected as cell structure object
|
||||
noteText
|
||||
]);
|
||||
globalRowIndex++;
|
||||
});
|
||||
} else {
|
||||
legRowRanges[leg.id] = { start: startRow, count: 1 };
|
||||
tableRows.push([
|
||||
stt++,
|
||||
'',
|
||||
legName,
|
||||
'Chưa có địa điểm trong chặng này',
|
||||
''
|
||||
]);
|
||||
globalRowIndex++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
### Step 2: Inject Coordinate-Based Overlay inside doc.autoTable
|
||||
Locate the core configuration module block where doc.autoTable({}) is invoked. Inject the didDrawCell event engine to deploy the link bounds:
|
||||
|
||||
doc.autoTable({
|
||||
head: [['STT', 'Thời gian', 'Chặng', 'Địa điểm', 'Ghi chú']],
|
||||
body: tableRows,
|
||||
theme: 'grid',
|
||||
|
||||
// HOOK HANDLER: Overlays active coordinate zones on top of native cells after writing
|
||||
didDrawCell: (data: any) => {
|
||||
// Target explicitly: body section only + column index 3 (Location Column)
|
||||
if (data.section === 'body' && data.column.index === 3) {
|
||||
const activeRowIndex = data.row.index;
|
||||
const targetMapUrl = pdfMapLinks[activeRowIndex];
|
||||
|
||||
if (targetMapUrl) {
|
||||
// Build active link container utilizing jsPDF coordinates framework
|
||||
data.doc.link(
|
||||
data.cell.x,
|
||||
data.cell.y,
|
||||
data.cell.width,
|
||||
data.cell.height,
|
||||
{ url: targetMapUrl }
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
styles: { font: 'Roboto' } // Retain existing styling structure
|
||||
});
|
||||
|
||||
## 4. Quality Control & Acceptance Criteria
|
||||
[ ] Visual Differentiation: Location text cells containing map URLs must render cleanly in bold dark-blue ([40, 83, 107]) while empty state texts stay muted.
|
||||
|
||||
[ ] Coordinate Precision: Clicking a location card possessing explicit coordinates (latitude, longitude) must directly map to those absolute markers instead of doing an inaccurate keyword address query.
|
||||
|
||||
[ ] Boundary Accuracy: The click targets must fit perfectly inside the grid cell borders. Clicking near the edges of column 3 must register properly, while clicking column 2 (Leg name) or column 4 (Notes) must remain non-reactive.
|
||||
-162
@@ -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 lý và 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 ký
|
||||
</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>
|
||||
);
|
||||
};
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoginModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSwitchToSignup?: () => void;
|
||||
onLoginSuccess?: (user: any) => void;
|
||||
}
|
||||
|
||||
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitchToSignup, onLoginSuccess }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Đăng nhập thất bại');
|
||||
}
|
||||
|
||||
// Lưu phiên đăng nhập
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(data.user);
|
||||
}
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
|
||||
<div className="p-8 sm:p-10">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">Đăng nhập</h2>
|
||||
<p className="text-gray-500 mt-2">Chào mừng bạn quay trở lại!</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</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-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold 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
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
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-2">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<label className="text-sm font-semibold text-gray-700">Mật khẩu</label>
|
||||
<button className="text-xs font-bold text-blue-600 hover:text-blue-700">Quên mật khẩu?</button>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<Lock 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="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Đang xử lý...' : 'Đăng nhập'}
|
||||
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : <ArrowRight className="w-5 h-5" />}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-10 pt-8 border-t border-gray-100 text-center">
|
||||
<p className="text-gray-500">
|
||||
Chưa có tài khoản?{' '}
|
||||
<button
|
||||
onClick={() => { onClose(); onSwitchToSignup?.(); }}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
Tạo tài khoản ngay
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
# Travel Planning - Multi-User Travel Itinerary Management System
|
||||
|
||||
A comprehensive travel planning application that enables collaborative itinerary planning, expense tracking, and photo sharing for travel groups.
|
||||
|
||||
## Features
|
||||
|
||||
- **Tour Management**: Create and manage travel tours with multiple legs (stages) and locations
|
||||
- **Multi-user Collaboration**: Invite friends to join tours with role-based access control
|
||||
- **Interactive Maps**: Visual tour planning with Leaflet.js integration
|
||||
- **Expense Tracking**: Automatic cost splitting with configurable adult/child discounts
|
||||
- **Photo Sharing**: Secure photo album with privacy controls (PUBLIC, TOUR_ONLY, PRIVATE)
|
||||
- **Real-time Navigation**: Live GPS tracking and route optimization using OSRM API
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| **Frontend** | React + Vite | Single Page Application with fast HMR |
|
||||
| | TailwindCSS | Utility-first CSS framework |
|
||||
| | Zustand | Lightweight state management |
|
||||
| | Leaflet.js | Interactive map rendering |
|
||||
| **Backend** | NestJS | Scalable Node.js framework |
|
||||
| | JWT | Authentication & authorization |
|
||||
| | Prisma ORM | Type-safe database access |
|
||||
| | PostgreSQL + PostGIS | Spatial database for geographic data |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
travelplanning/
|
||||
├── backend/ # Backend API server
|
||||
│ ├── src/
|
||||
│ │ ├── auth/ # Authentication modules
|
||||
│ │ ├── main.ts # NestJS entry point
|
||||
│ │ └── v1/ # API v1 endpoints
|
||||
│ └── prisma/
|
||||
│ └── schema.prisma # Database schema
|
||||
├── frontend/ # React frontend
|
||||
│ └── src/
|
||||
│ ├── pages/ # Main pages
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ └── store/ # Zustand stores
|
||||
├── docs/ # Documentation
|
||||
│ ├── ARCHITECTURE.md # System architecture
|
||||
│ └── UITourDesign.md # UI design specifications
|
||||
└── .env # Environment variables
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The application uses PostgreSQL with Prisma ORM. Key models include:
|
||||
|
||||
- **User**: Registered users with admin capability
|
||||
- **Tour**: Travel itineraries with date ranges and participant management
|
||||
- **Leg**: Stages within a tour (ordered sequence)
|
||||
- **Location**: Geographic points with timing and status tracking
|
||||
- **Expense**: Cost tracking linked to legs/locations
|
||||
- **Photo**: Media storage with privacy controls
|
||||
- **TourParticipant**: Many-to-many relationship with role-based permissions
|
||||
|
||||
### User Roles
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| OWNER | Full access to all features |
|
||||
| MANAGER | Can edit tour content and manage members |
|
||||
| MEMBER | View tour and participate, access financial data |
|
||||
| MEMBER_NO_FINANCE | View tour only, no financial access |
|
||||
| VIEWER_ONLY | Read-only access to itinerary and photos |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Tours
|
||||
- `GET /api/v1/tours` - Get all public tours
|
||||
- `POST /api/v1/tours` - Create new tour
|
||||
- `GET /api/v1/tours/:id` - Get tour details
|
||||
- `PUT /api/v1/tours/:id` - Update tour
|
||||
|
||||
### Authentication
|
||||
- `POST /api/v1/auth/login` - User login
|
||||
- `POST /api/v1/auth/register` - User registration
|
||||
- `POST /api/v1/auth/promote-admin` - Admin role promotion (with secret key)
|
||||
|
||||
### Photos
|
||||
- `GET /api/v1/public-photos` - Get public photos
|
||||
- `POST /api/v1/tours/:id/photos` - Upload tour photos
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- PostgreSQL with PostGIS extension
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
cd frontend && npm install
|
||||
cd ../backend && npm install
|
||||
|
||||
# Set up database
|
||||
npx prisma migrate dev
|
||||
npx prisma generate
|
||||
|
||||
# Start development servers
|
||||
npm run dev # Frontend (Vite)
|
||||
npm run start:backend # Backend (NestJS)
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create `.env` in the root directory:
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/traveldb"
|
||||
JWT_SECRET="your-secret-key"
|
||||
```
|
||||
|
||||
## Mobile Optimization
|
||||
|
||||
The application is built mobile-first with support for:
|
||||
- Safe area insets for notch displays (iOS/Android)
|
||||
- Touch gestures for map interactions
|
||||
- Responsive layouts for all screen sizes
|
||||
- Device orientation and compass integration
|
||||
|
||||
## Documentation
|
||||
|
||||
See the `docs/` directory for detailed documentation:
|
||||
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - System architecture and data models
|
||||
- [UITourDesign.md](docs/UITourDesign.md) - UI design specifications
|
||||
-196
@@ -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 bè.</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ọ và 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>
|
||||
);
|
||||
};
|
||||
@@ -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 có 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
# To AI Agent: Audit Location Lock Bug and Refactor GPS Tracking into a Toggle Stateful Button
|
||||
|
||||
## 1. Context & Problem Statement
|
||||
Currently, in our travel planner application map engine (on pages like `TourNavigationPage.tsx`, `LocationNavigationModal.tsx`, or map utilities), the viewport is continuously forced to lock onto the user's live GPS position. This architecture severely damages mobile UX because:
|
||||
1. It prevents users from manually dragging, panning, or scouting other areas of the map terrain.
|
||||
2. There is no control interface to temporarily mute or disable live GPS tracking.
|
||||
|
||||
**Objective:** - Run a global audit across the entire codebase to locate functions driving this forced-center trap (e.g., custom hooks, `requestGpsPosition`, native geolocation callbacks, or reactive map state updates).
|
||||
- Refactor the logic so that live tracking is bound strictly to an independent toggle state button. The map must **ONLY** lock/re-center on the user's coordinates when this tracking toggle button is actively switched **ON**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 1: Codebase Audit Plan (Where to Search)
|
||||
|
||||
Scan the entire project repository (specifically `/frontend/src`) using code search patterns to intercept the lock mechanism. Target the following files and keywords:
|
||||
|
||||
### Key Target Files to Inspect:
|
||||
- `frontend/src/pages/TourNavigationPage.tsx`
|
||||
- `frontend/src/components/LocationNavigationModal.tsx`
|
||||
- Any custom hooks or contexts handling geography, such as `useGeolocation.ts`, `useMap.ts`, or generic map setup wrappers.
|
||||
|
||||
### Regex & Keyword Global Search Queries:
|
||||
- Search for native background watchers: `navigator.geolocation.watchPosition` or `navigator.geolocation.getCurrentPosition`
|
||||
- Search for custom map-centering loops: `requestGpsPosition`, `followUser`, `centerToUser`
|
||||
- Search for viewport mutation commands specific to our active map engine stack:
|
||||
- **Leaflet:** `.setView(`, `.panTo(`, `center={`
|
||||
- **Mapbox GL JS:** `.flyTo(`, `.easeTo(`, `.jumpTo(`
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 2: Technical Refactoring Blueprint
|
||||
|
||||
Once the tracking logic code blocks are isolated from Phase 1, implement the structural state safety rails below:
|
||||
|
||||
### Step 1: Initialize the Tracking State Guard
|
||||
Introduce a state hook controller (`isTrackingLocation`) to manage whether the view should actively mirror device coordinates:
|
||||
|
||||
```typescript
|
||||
// Add inside the map controller/page container component
|
||||
const [isTrackingLocation, setIsTrackingLocation] = useState(false);
|
||||
const watchIdRef = useRef<number | null>(null);
|
||||
|
||||
### Step 2: Encapsulate the Geolocation Watcher Handler
|
||||
Wrap your positioning tracking engine loop inside a conditional check governed directly by the state guard. Ensure that if the tracking state is disabled, the background watcher cleanly unmounts:
|
||||
|
||||
useEffect(() => {
|
||||
if (isTrackingLocation) {
|
||||
if (navigator.geolocation) {
|
||||
console.log("GPS Location Tracking engaged. Syncing viewport to center...");
|
||||
|
||||
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
const { latitude, longitude, heading } = position.coords;
|
||||
|
||||
if (mapRef.current) {
|
||||
// ✅ CORRECTION: Viewport ONLY repositions center when tracking button is active
|
||||
mapRef.current.easeTo({
|
||||
center: [longitude, latitude],
|
||||
zoom: 16, // Lock to comfortable navigation zoom level
|
||||
duration: 600
|
||||
});
|
||||
}
|
||||
},
|
||||
(error) => console.error("GPS stream tracking lost:", error),
|
||||
{ enableHighAccuracy: true }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Clean up tracking process instantly when toggled OFF
|
||||
if (watchIdRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchIdRef.current);
|
||||
watchIdRef.current = null;
|
||||
console.log("GPS Location Tracking disabled. Map control released to user.");
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (watchIdRef.current !== null) navigator.geolocation.clearWatch(watchIdRef.current);
|
||||
};
|
||||
}, [isTrackingLocation]);
|
||||
|
||||
### Step 3: Implement Gesture Detection (UX Safety Rail)
|
||||
If the user manually drags the screen while tracking is active, the tracking state must automatically toggle OFF so the viewport doesn't fight against the user's finger movements:
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
const map = mapRef.current;
|
||||
|
||||
const breakTrackingOnGesture = () => {
|
||||
if (isTrackingLocation) {
|
||||
console.log("User touch map interaction detected. Disengaging auto-center lock.");
|
||||
setIsTrackingLocation(false); // Automatically drop tracking flag on map pan/zoom
|
||||
}
|
||||
};
|
||||
|
||||
map.on('dragstart', breakTrackingOnGesture);
|
||||
map.on('zoomstart', breakTrackingOnGesture);
|
||||
map.on('movestart', breakTrackingOnGesture);
|
||||
|
||||
return () => {
|
||||
map.off('dragstart', breakTrackingOnGesture);
|
||||
map.off('zoomstart', breakTrackingOnGesture);
|
||||
map.off('movestart', breakTrackingOnGesture);
|
||||
};
|
||||
}, [isTrackingLocation]);
|
||||
|
||||
### Step 4: Render the UI Toggle Button UI Component
|
||||
Deploy a new independent floating button on top of the map canvas workspace (placed at bottom-24 right-6, just right above your custom Compass button layout):
|
||||
|
||||
{/* FLOATING LIVE GPS TRACKING CENTER LOCK BUTTON */}
|
||||
<button
|
||||
onClick={() => setIsTrackingLocation(!isTrackingLocation)}
|
||||
className={`absolute bottom-24 right-6 z-40 w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
|
||||
isTrackingLocation
|
||||
? 'bg-green-600 border-green-400 text-white animate-pulse'
|
||||
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-green-500'
|
||||
}`}
|
||||
title={isTrackingLocation ? "Tắt tự động định tâm vị trí" : "Bật tự động định tâm theo vị trí của bạn"}
|
||||
>
|
||||
{/* Replace Crosshair icon element with your active layout icon package asset */}
|
||||
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
## 4. Verification & Quality Acceptance Criteria
|
||||
|
||||
[ ] Code Erasure Verification: Confirm that old continuous loops or uncontrolled recursive .setView/.easeTo methods triggered instantly on map load are fully removed or properly contained inside the state block.
|
||||
|
||||
[ ] Default State Freedom: Upon opening the map page path, tracking must default to OFF. Users must be able to drag the map anywhere in the world without the screen snapped or yanked back to their physical house position.
|
||||
|
||||
[ ] Toggle Activation Centering: Pressing the new GPS tracking button must instantly engage the animation, center the map view directly on top of the user blue dot icon, and follow them smoothly if they move.
|
||||
|
||||
[ ] Manual Override Interception: Turn tracking ON. Drag the map manually with a finger gesture. Verify that the tracking button instantly changes style states back to deactivated and tracking shuts down cleanly.
|
||||
@@ -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 lý người dùng
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">Quản trị viên có 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
@@ -0,0 +1,38 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install openssl for Prisma
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Development stage
|
||||
FROM base AS development
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
EXPOSE 3001
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
|
||||
# Build stage for production
|
||||
FROM base AS build
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
RUN npm prune --production
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
RUN apk add --no-cache openssl
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY --from=build /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=build /usr/src/app/dist ./dist
|
||||
COPY --from=build /usr/src/app/prisma ./prisma
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/src/main.js"]
|
||||
@@ -0,0 +1,39 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5';
|
||||
const ownerUserId = '5b2053bb-f523-4a11-817c-f47fef7322bb'; // owner@travel.com
|
||||
|
||||
// Check if owner@travel.com is already a participant
|
||||
const existing = await prisma.tourParticipant.findFirst({
|
||||
where: { tourId, userId: ownerUserId }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.tourParticipant.update({
|
||||
where: { id: existing.id },
|
||||
data: { role: 'OWNER' }
|
||||
});
|
||||
console.log('Updated existing participant to OWNER');
|
||||
} else {
|
||||
// Demote current owner to MEMBER or just keep them
|
||||
const result = await prisma.tourParticipant.create({
|
||||
data: {
|
||||
tourId,
|
||||
userId: ownerUserId,
|
||||
role: 'OWNER'
|
||||
}
|
||||
});
|
||||
console.log('Created new OWNER participant:', result);
|
||||
}
|
||||
|
||||
// Update tour createdById to ownerUserId
|
||||
await prisma.tour.update({
|
||||
where: { id: tourId },
|
||||
data: { createdById: ownerUserId }
|
||||
});
|
||||
console.log('Updated tour creator to owner@travel.com');
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
+7
@@ -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
@@ -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
@@ -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
@@ -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>;
|
||||
}
|
||||
Vendored
+40
@@ -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
@@ -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"}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
|
||||
export declare class JwtAuthGuard extends JwtAuthGuard_base {
|
||||
}
|
||||
declare const JwtAuthGuardNoAnonymous_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
|
||||
export declare class JwtAuthGuardNoAnonymous extends JwtAuthGuardNoAnonymous_base {
|
||||
handleRequest(err: any, user: any, info: any): any;
|
||||
}
|
||||
export {};
|
||||
+34
@@ -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;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.JwtAuthGuardNoAnonymous = exports.JwtAuthGuard = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const passport_1 = require("@nestjs/passport");
|
||||
const common_2 = require("@nestjs/common");
|
||||
let JwtAuthGuard = class JwtAuthGuard extends (0, passport_1.AuthGuard)('jwt') {
|
||||
};
|
||||
exports.JwtAuthGuard = JwtAuthGuard;
|
||||
exports.JwtAuthGuard = JwtAuthGuard = __decorate([
|
||||
(0, common_1.Injectable)()
|
||||
], JwtAuthGuard);
|
||||
let JwtAuthGuardNoAnonymous = class JwtAuthGuardNoAnonymous extends (0, passport_1.AuthGuard)('jwt') {
|
||||
handleRequest(err, user, info) {
|
||||
if (err || !user) {
|
||||
throw err || new common_2.UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
|
||||
}
|
||||
if (user.isAnonymous) {
|
||||
throw new common_2.UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
};
|
||||
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous;
|
||||
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous = __decorate([
|
||||
(0, common_1.Injectable)()
|
||||
], JwtAuthGuardNoAnonymous);
|
||||
//# sourceMappingURL=jwt-auth.guard.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAC7C,2CAAuD;AAGhD,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B;AAI9C,IAAM,uBAAuB,GAA7B,MAAM,uBAAwB,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;IAC3D,aAAa,CAAC,GAAQ,EAAE,IAAS,EAAE,IAAS;QAC1C,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YACjB,MAAM,GAAG,IAAI,IAAI,8BAAqB,CAAC,6CAA6C,CAAC,CAAC;QACxF,CAAC;QAGD,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YACrB,MAAM,IAAI,8BAAqB,CAAC,gGAAgG,CAAC,CAAC;QACpI,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAbY,0DAAuB;kCAAvB,uBAAuB;IADnC,IAAA,mBAAU,GAAE;GACA,uBAAuB,CAanC"}
|
||||
+20
@@ -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
@@ -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
@@ -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;QAID,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAtBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAsBvB"}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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
|
||||
@@ -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"}
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
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 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;
|
||||
handleJoinUser(client: Socket, userId: string): void;
|
||||
notifyNewMessage(receiverId: string, data: any): void;
|
||||
notifyConnectionAccepted(requesterId: string, data: any): void;
|
||||
notifyJoinRequestAccepted(userId: string, data: any): void;
|
||||
}
|
||||
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>;
|
||||
sendTourInvitation(email: string, tourTitle: string, inviteLink: string): Promise<any>;
|
||||
}
|
||||
Vendored
+4963
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
|
||||
> backend@0.0.1 start:dev
|
||||
> nest start --watch
|
||||
|
||||
[2J[3J[H[[90m10:46:05 PM[0m] Starting compilation in watch mode...
|
||||
|
||||
[[90m10:46:08 PM[0m] Found 0 errors. Watching for file changes.
|
||||
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[NestFactory] [39m[32mStarting Nest application...[39m
|
||||
--- [PRISMA CHECK] ---
|
||||
DATABASE_URL nhận được: ĐÃ ĐỌC THÀNH CÔNG ✔️
|
||||
----------------------
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigHostModule dependencies initialized[39m[38;5;3m +40ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mJwtModule dependencies initialized[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigModule dependencies initialized[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mCacheModule dependencies initialized[39m[38;5;3m +15ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mAppModule dependencies initialized[39m[38;5;3m +2ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[WebSocketsController] [39m[32mCommentGateway subscribed to the "joinTour" message[39m[38;5;3m +11ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[WebSocketsController] [39m[32mCommentGateway subscribed to the "joinPhoto" message[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAppController {/api/v1}:[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1, GET} route[39m[38;5;3m +2ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAuthController {/api/v1/auth}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/convert-guest, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/create-guest, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/status, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/login, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/signup/request, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/signup/verify, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPublicTourController {/api/v1/tours}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id/public, GET} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mTourController {/api/v1/tours}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/locations, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/start-point, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/end-point, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/legs/batch, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/legs, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, PATCH} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/explore, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/members, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests, GET} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests/:requestId/accept, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests/:requestId/reject, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/members/:userId, DELETE} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/photos, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mUserController {/api/v1/users}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/me/photos, GET} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/:id, PATCH} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/block/:id, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mRoutingController {/api/v1/routing}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/routing/optimize/:legId, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mLegController {/api/v1/legs}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/legs/:id, PATCH} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/legs/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mLocationController {/api/v1/locations}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:id, PATCH} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:id, DELETE} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mCommentController {/api/v1/locations}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:locationId/comments, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:locationId/comments, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPhotoController {/api/v1/photos}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/photos/upload-anonymous, POST} route[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/photos/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPublicPhotoController {/api/v1/public-photos}:[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos/:photoId/comments, GET} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos/:photoId/comments, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAdminOtpController {/api/v1/admin/otp}:[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/admin/otp/send, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/admin/otp/verify, POST} route[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[NestApplication] [39m[32mNest application successfully started[39m[38;5;3m +16ms[39m
|
||||
🚀 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
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"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,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Location" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TourParticipant" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `TourParticipant` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- A unique constraint covering the columns `[tourId,userId]` on the table `TourParticipant` will be added. If there are existing duplicate values, this will fail.
|
||||
- The required column `id` was added to the `TourParticipant` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "TourParticipant" DROP CONSTRAINT "TourParticipant_pkey",
|
||||
ADD COLUMN "displayName" TEXT,
|
||||
ADD COLUMN "id" TEXT;
|
||||
|
||||
UPDATE "TourParticipant" SET "id" = md5(random()::text);
|
||||
|
||||
ALTER TABLE "TourParticipant" ALTER COLUMN "id" SET NOT NULL,
|
||||
ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TourParticipant_tourId_userId_key" ON "TourParticipant"("tourId", "userId");
|
||||
@@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "TourInvitation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiredAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TourInvitation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TourInvitation_token_key" ON "TourInvitation"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TourInvitation_tourId_email_key" ON "TourInvitation"("tourId", "email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourInvitation" ADD CONSTRAINT "TourInvitation_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ConnectionStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ConnectionType" AS ENUM ('FRIEND', 'FAMILY');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserConnection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"requesterId" TEXT NOT NULL,
|
||||
"receiverId" TEXT NOT NULL,
|
||||
"status" "ConnectionStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"type" "ConnectionType" NOT NULL DEFAULT 'FRIEND',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserConnection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DirectMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"senderId" TEXT NOT NULL,
|
||||
"receiverId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DirectMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UserConnection_requesterId_idx" ON "UserConnection"("requesterId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UserConnection_receiverId_idx" ON "UserConnection"("receiverId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserConnection_requesterId_receiverId_key" ON "UserConnection"("requesterId", "receiverId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DirectMessage_senderId_idx" ON "DirectMessage"("senderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DirectMessage_receiverId_idx" ON "DirectMessage"("receiverId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "DirectMessage" ADD COLUMN "attachmentUrl" TEXT,
|
||||
ADD COLUMN "latitude" DOUBLE PRECISION,
|
||||
ADD COLUMN "longitude" DOUBLE PRECISION;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TourMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"senderId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"attachmentUrl" TEXT,
|
||||
"latitude" DOUBLE PRECISION,
|
||||
"longitude" DOUBLE PRECISION,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TourMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TourMessage_tourId_idx" ON "TourMessage"("tourId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TourMessage_senderId_idx" ON "TourMessage"("senderId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "WordFilter" (
|
||||
"id" TEXT NOT NULL,
|
||||
"word" TEXT NOT NULL,
|
||||
"replacement" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WordFilter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ModerationSetting" (
|
||||
"id" TEXT NOT NULL,
|
||||
"blockNsfw" BOOLEAN NOT NULL DEFAULT false,
|
||||
"blurFaces" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "ModerationSetting_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TourRating" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"targetUserId" TEXT NOT NULL,
|
||||
"raterUserId" TEXT NOT NULL,
|
||||
"honesty" INTEGER NOT NULL DEFAULT 5,
|
||||
"transparency" INTEGER NOT NULL DEFAULT 5,
|
||||
"enthusiasm" INTEGER NOT NULL DEFAULT 5,
|
||||
"cheerfulness" INTEGER NOT NULL DEFAULT 5,
|
||||
"seriousness" INTEGER NOT NULL DEFAULT 5,
|
||||
"planning" INTEGER NOT NULL DEFAULT 5,
|
||||
"survival" INTEGER NOT NULL DEFAULT 5,
|
||||
"averageScore" DOUBLE PRECISION NOT NULL DEFAULT 5.0,
|
||||
"comment" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TourRating_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TourShare" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TourShare_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WordFilter_word_key" ON "WordFilter"("word");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TourRating_tourId_targetUserId_raterUserId_key" ON "TourRating"("tourId", "targetUserId", "raterUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TourShare_tourId_key" ON "TourShare"("tourId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TourShare_token_key" ON "TourShare"("token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_raterUserId_fkey" FOREIGN KEY ("raterUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourShare" ADD CONSTRAINT "TourShare_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "BusinessReport" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT,
|
||||
"email" TEXT,
|
||||
"address" TEXT,
|
||||
"latitude" DOUBLE PRECISION,
|
||||
"longitude" DOUBLE PRECISION,
|
||||
"reason" TEXT NOT NULL,
|
||||
"isBlacklisted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "BusinessReport_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ModerationSetting" ADD COLUMN "trashRetentionDays" INTEGER NOT NULL DEFAULT 30;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Photo" ADD COLUMN "deletedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Tour" ADD COLUMN "deletedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TourNote" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "TourNote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RecommendedLocation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT,
|
||||
"email" TEXT,
|
||||
"address" TEXT,
|
||||
"latitude" DOUBLE PRECISION,
|
||||
"longitude" DOUBLE PRECISION,
|
||||
"description" TEXT NOT NULL,
|
||||
"stars" INTEGER NOT NULL DEFAULT 5,
|
||||
"isApproved" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RecommendedLocation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Photo" ADD COLUMN "flaggedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "flaggedReason" TEXT,
|
||||
ADD COLUMN "isFlagged" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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 });
|
||||
@@ -0,0 +1,402 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["driverAdapters"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// --- Enums ---
|
||||
|
||||
enum ParticipantRole {
|
||||
OWNER
|
||||
MANAGER
|
||||
MEMBER
|
||||
MEMBER_NO_FINANCE
|
||||
VIEWER_ONLY
|
||||
}
|
||||
|
||||
enum JoinRequestStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ExpenseCategory {
|
||||
ACCOMMODATION
|
||||
FOOD
|
||||
TRANSPORT
|
||||
TICKET
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum LocationStatus {
|
||||
PENDING
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
enum LocationType {
|
||||
MOVE
|
||||
VISIT
|
||||
REST
|
||||
EAT
|
||||
}
|
||||
|
||||
enum PrivacyLevel {
|
||||
PUBLIC
|
||||
TOUR_ONLY
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
// --- Models ---
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String? @unique
|
||||
passwordHash String?
|
||||
name String?
|
||||
phone String?
|
||||
address String?
|
||||
avatar String?
|
||||
createdAt DateTime @default(now())
|
||||
isAdmin Boolean @default(false)
|
||||
isBlocked Boolean @default(false)
|
||||
isAnonymous Boolean @default(false)
|
||||
|
||||
createdTours Tour[] @relation("TourCreator")
|
||||
tourParticipations TourParticipant[]
|
||||
requestedJoinRequests JoinRequest[] @relation("JoinRequestUser")
|
||||
receivedJoinRequests JoinRequest[] @relation("JoinRequester")
|
||||
uploadedPhotos Photo[]
|
||||
paidExpenses Expense[] @relation("ExpensePaidBy")
|
||||
comments Comment[]
|
||||
sentConnections UserConnection[] @relation("ConnectionRequester")
|
||||
receivedConnections UserConnection[] @relation("ConnectionReceiver")
|
||||
sentMessages DirectMessage[] @relation("MessageSender")
|
||||
receivedMessages DirectMessage[] @relation("MessageReceiver")
|
||||
tourMessages TourMessage[]
|
||||
receivedRatings TourRating[] @relation("RatedUser")
|
||||
sentRatings TourRating[] @relation("RatingUser")
|
||||
tourNotes TourNote[]
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
participants TourParticipant[]
|
||||
joinRequests JoinRequest[]
|
||||
legs Leg[]
|
||||
photos Photo[]
|
||||
invitations TourInvitation[]
|
||||
tourMessages TourMessage[]
|
||||
ratings TourRating[]
|
||||
share TourShare?
|
||||
notes TourNote[]
|
||||
isDeleted Boolean @default(false)
|
||||
deletedAt DateTime?
|
||||
}
|
||||
|
||||
model JoinRequest {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
userId String
|
||||
requestedById String
|
||||
status JoinRequestStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
user User @relation("JoinRequestUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
requestedBy User @relation("JoinRequester", fields: [requestedById], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tourId, status])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model TourParticipant {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
userId String?
|
||||
role ParticipantRole @default(MEMBER)
|
||||
displayName String?
|
||||
adultCount Int @default(1)
|
||||
childCount Int @default(0)
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tourId, userId])
|
||||
}
|
||||
|
||||
model Leg {
|
||||
id String @id @default(uuid())
|
||||
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[]
|
||||
expenses Expense[]
|
||||
}
|
||||
|
||||
model Location {
|
||||
id String @id @default(uuid())
|
||||
legId String
|
||||
name String
|
||||
address String?
|
||||
latitude Float
|
||||
longitude Float
|
||||
|
||||
plannedStart DateTime?
|
||||
plannedEnd DateTime?
|
||||
actualStart DateTime?
|
||||
actualEnd DateTime?
|
||||
|
||||
status LocationStatus @default(PENDING)
|
||||
type LocationType @default(VISIT)
|
||||
|
||||
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
|
||||
expenses Expense[]
|
||||
photos Photo[]
|
||||
comments Comment[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Expense {
|
||||
id String @id @default(uuid())
|
||||
legId String @map("leg_id")
|
||||
locationId String? @map("location_id")
|
||||
category ExpenseCategory
|
||||
amount Decimal @db.Decimal(15, 2)
|
||||
description String? @db.Text
|
||||
note String? @db.Text
|
||||
paidById String? @map("paid_by_id")
|
||||
|
||||
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
paidBy User? @relation("ExpensePaidBy", fields: [paidById], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
model Photo {
|
||||
id String @id @default(uuid())
|
||||
tourId String?
|
||||
locationId String?
|
||||
uploaderId String
|
||||
imageUrl String?
|
||||
originalUrl String?
|
||||
capturedAt DateTime @default(now())
|
||||
metadata Json?
|
||||
privacy PrivacyLevel @default(TOUR_ONLY)
|
||||
isFlagged Boolean @default(false)
|
||||
flaggedReason String?
|
||||
flaggedAt DateTime?
|
||||
|
||||
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[]
|
||||
isDeleted Boolean @default(false)
|
||||
deletedAt DateTime?
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
model TourInvitation {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
email String
|
||||
role ParticipantRole @default(MEMBER)
|
||||
token String @unique
|
||||
createdAt DateTime @default(now())
|
||||
expiredAt DateTime
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tourId, email])
|
||||
}
|
||||
|
||||
enum ConnectionStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ConnectionType {
|
||||
FRIEND
|
||||
FAMILY
|
||||
}
|
||||
|
||||
model UserConnection {
|
||||
id String @id @default(uuid())
|
||||
requesterId String
|
||||
receiverId String
|
||||
status ConnectionStatus @default(PENDING)
|
||||
type ConnectionType @default(FRIEND)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
requester User @relation("ConnectionRequester", fields: [requesterId], references: [id], onDelete: Cascade)
|
||||
receiver User @relation("ConnectionReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([requesterId, receiverId])
|
||||
@@index([requesterId])
|
||||
@@index([receiverId])
|
||||
}
|
||||
|
||||
model DirectMessage {
|
||||
id String @id @default(uuid())
|
||||
senderId String
|
||||
receiverId String
|
||||
content String @db.Text
|
||||
attachmentUrl String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade)
|
||||
receiver User @relation("MessageReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([senderId])
|
||||
@@index([receiverId])
|
||||
}
|
||||
|
||||
model TourMessage {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
senderId String
|
||||
content String @db.Text
|
||||
attachmentUrl String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tourId])
|
||||
@@index([senderId])
|
||||
}
|
||||
|
||||
model WordFilter {
|
||||
id String @id @default(uuid())
|
||||
word String @unique
|
||||
replacement String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model ModerationSetting {
|
||||
id String @id @default(uuid())
|
||||
blockNsfw Boolean @default(false)
|
||||
blurFaces Boolean @default(false)
|
||||
trashRetentionDays Int @default(30)
|
||||
}
|
||||
|
||||
model TourRating {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
targetUserId String
|
||||
raterUserId String
|
||||
honesty Int @default(5)
|
||||
transparency Int @default(5)
|
||||
enthusiasm Int @default(5)
|
||||
cheerfulness Int @default(5)
|
||||
seriousness Int @default(5)
|
||||
planning Int @default(5)
|
||||
survival Int @default(5)
|
||||
averageScore Float @default(5.0)
|
||||
comment String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
targetUser User @relation("RatedUser", fields: [targetUserId], references: [id], onDelete: Cascade)
|
||||
raterUser User @relation("RatingUser", fields: [raterUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tourId, targetUserId, raterUserId])
|
||||
}
|
||||
|
||||
model TourShare {
|
||||
id String @id @default(uuid())
|
||||
tourId String @unique
|
||||
token String @unique @default(uuid())
|
||||
isEnabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model BusinessReport {
|
||||
id String @id @default(uuid())
|
||||
type String // e.g. "USER", "RESTAURANT", "HOTEL", "HOMESTAY"
|
||||
name String
|
||||
phone String?
|
||||
email String?
|
||||
address String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
reason String @db.Text
|
||||
isBlacklisted Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model TourNote {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isDeleted Boolean @default(false)
|
||||
deletedAt DateTime?
|
||||
}
|
||||
|
||||
model RecommendedLocation {
|
||||
id String @id @default(uuid())
|
||||
type String // e.g. "RESTAURANT", "HOTEL", "HOMESTAY"
|
||||
name String
|
||||
phone String?
|
||||
email String?
|
||||
address String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
description String @db.Text
|
||||
stars Int @default(5)
|
||||
isApproved Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
@@ -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 {
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
|
||||
// Guard that rejects anonymous/guest users - used for dashboard and sensitive endpoints
|
||||
@Injectable()
|
||||
export class JwtAuthGuardNoAnonymous extends AuthGuard('jwt') {
|
||||
handleRequest(err: any, user: any, info: any) {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
|
||||
}
|
||||
|
||||
// Reject anonymous/temporary users
|
||||
if (user.isAnonymous) {
|
||||
throw new UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -22,6 +22,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
|
||||
}
|
||||
|
||||
// Note: We allow anonymous users to pass JWT validation
|
||||
// Individual endpoints decide whether to accept anonymous users based on their guard
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
+4746
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
async function test() {
|
||||
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'owner@travel.com', password: '123456' })
|
||||
});
|
||||
const loginData = await loginRes.json();
|
||||
|
||||
if (loginRes.ok) {
|
||||
const token = loginData.access_token;
|
||||
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Tour ID from seed
|
||||
const res = await fetch(`http://localhost:3001/api/v1/tours/${tourId}/members`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ displayName: 'Offline Member X', role: 'MEMBER' })
|
||||
});
|
||||
console.log('Add status:', res.status);
|
||||
const data = await res.json();
|
||||
console.log('Add response:', data);
|
||||
} else {
|
||||
console.error('Login failed:', loginData);
|
||||
}
|
||||
}
|
||||
|
||||
test().catch(console.error);
|
||||
@@ -0,0 +1,14 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const users = await prisma.user.findMany();
|
||||
console.log('--- Users in DB ---');
|
||||
console.log(users);
|
||||
|
||||
const participants = await prisma.tourParticipant.findMany();
|
||||
console.log('--- Tour Participants in DB ---');
|
||||
console.log(participants);
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,17 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const tours = await prisma.tour.findMany({
|
||||
include: {
|
||||
participants: {
|
||||
include: {
|
||||
user: { select: { email: true, name: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log(JSON.stringify(tours, null, 2));
|
||||
}
|
||||
|
||||
main().finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,36 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Use existing tour ID from seed
|
||||
|
||||
console.log('Inserting first manual member...');
|
||||
try {
|
||||
const p1 = await prisma.tourParticipant.create({
|
||||
data: {
|
||||
tourId,
|
||||
role: 'MEMBER',
|
||||
displayName: 'Manual Member 1'
|
||||
}
|
||||
});
|
||||
console.log('Inserted:', p1);
|
||||
} catch (err) {
|
||||
console.error('Failed to insert first:', err);
|
||||
}
|
||||
|
||||
console.log('Inserting second manual member...');
|
||||
try {
|
||||
const p2 = await prisma.tourParticipant.create({
|
||||
data: {
|
||||
tourId,
|
||||
role: 'MEMBER',
|
||||
displayName: 'Manual Member 2'
|
||||
}
|
||||
});
|
||||
console.log('Inserted:', p2);
|
||||
} catch (err) {
|
||||
console.error('Failed to insert second:', err);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,22 @@
|
||||
async function test() {
|
||||
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'photomember@travel.com', password: '123456' })
|
||||
});
|
||||
const loginData = await loginRes.json();
|
||||
|
||||
if (loginRes.ok) {
|
||||
const token = loginData.access_token;
|
||||
const usersRes = await fetch('http://localhost:3001/api/v1/users?q=owner@travel.com', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
console.log('Users status:', usersRes.status);
|
||||
const usersData = await usersRes.json();
|
||||
console.log('Users data:', usersData);
|
||||
} else {
|
||||
console.error('Login failed:', loginData);
|
||||
}
|
||||
}
|
||||
|
||||
test().catch(console.error);
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node", // Đảm bảo module resolution là 'node'
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"resolveJsonModule": true, // Cho phép import các file .json
|
||||
"esModuleInterop": true, // Cho phép cú pháp import/export ES Modules với CommonJS
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 868 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 844 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 408 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,73 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: yotrip-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: travel_db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: yotrip-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
target: development
|
||||
container_name: yotrip-backend
|
||||
command: >
|
||||
sh -c "npx prisma migrate dev --schema=prisma/schema.prisma && npm run start:dev"
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./backend:/usr/src/app
|
||||
- /usr/src/app/node_modules
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
PORT: 3001
|
||||
ADMIN_SECRET_KEY: "yotrip_secret_admin_key"
|
||||
JWT_SECRET: "super-secret"
|
||||
FRONTEND_URL: "http://localhost:5173"
|
||||
NODE_ENV: development
|
||||
SMTP_HOST: "${SMTP_HOST}"
|
||||
SMTP_PORT: "${SMTP_PORT}"
|
||||
SMTP_SECURE: "${SMTP_SECURE}"
|
||||
SMTP_USER: "${SMTP_USER}"
|
||||
SMTP_PASS: "${SMTP_PASS}"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
target: development
|
||||
container_name: yotrip-frontend
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- ./frontend:/usr/src/app
|
||||
- /usr/src/app/node_modules
|
||||
environment:
|
||||
VITE_API_URL: "http://localhost:3001"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
@@ -0,0 +1,69 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: yotrip-db-prod
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: travel_db
|
||||
volumes:
|
||||
- pg_data_prod:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: yotrip-redis-prod
|
||||
restart: always
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
target: production
|
||||
container_name: yotrip-backend-prod
|
||||
restart: always
|
||||
command: >
|
||||
sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js"
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./backend/uploads:/usr/src/app/uploads
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
PORT: 3001
|
||||
ADMIN_SECRET_KEY: "${ADMIN_SECRET_KEY}"
|
||||
JWT_SECRET: "super-secret"
|
||||
FRONTEND_URL: "${FRONTEND_URL}"
|
||||
NODE_ENV: production
|
||||
SMTP_HOST: "${SMTP_HOST}"
|
||||
SMTP_PORT: "${SMTP_PORT}"
|
||||
SMTP_SECURE: "${SMTP_SECURE}"
|
||||
SMTP_USER: "${SMTP_USER}"
|
||||
SMTP_PASS: "${SMTP_PASS}"
|
||||
TZ: "Asia/Ho_Chi_Minh"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
target: production
|
||||
args:
|
||||
- VITE_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
container_name: yotrip-frontend-prod
|
||||
restart: always
|
||||
ports:
|
||||
- "3002:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
pg_data_prod:
|
||||
@@ -0,0 +1,68 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: yotrip-db-prod
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: travel_db
|
||||
volumes:
|
||||
- pg_data_prod:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: yotrip-redis-prod
|
||||
restart: always
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
target: production
|
||||
container_name: yotrip-backend-prod
|
||||
restart: always
|
||||
command: >
|
||||
sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js"
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./backend/uploads:/usr/src/app/uploads
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
PORT: 3001
|
||||
ADMIN_SECRET_KEY: "${ADMIN_SECRET_KEY}"
|
||||
JWT_SECRET: "super-secret"
|
||||
FRONTEND_URL: "${FRONTEND_URL}"
|
||||
NODE_ENV: production
|
||||
SMTP_HOST: "${SMTP_HOST}"
|
||||
SMTP_PORT: "${SMTP_PORT}"
|
||||
SMTP_SECURE: "${SMTP_SECURE}"
|
||||
SMTP_USER: "${SMTP_USER}"
|
||||
SMTP_PASS: "${SMTP_PASS}"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
target: production
|
||||
args:
|
||||
- VITE_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
container_name: yotrip-frontend-prod
|
||||
restart: always
|
||||
ports:
|
||||
- "3002:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
pg_data_prod:
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user