3358 lines
166 KiB
TypeScript
3358 lines
166 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
import { io } from 'socket.io-client';
|
|
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
|
import { ExpenseManager } from '../components/ExpenseManager';
|
|
import { useTourStore } from '@/store/useTourStore';
|
|
import { useTranslation } from '../hooks/useTranslation';
|
|
import { AddLocationModal } from '@/components/AddLocationModal';
|
|
import { AddMemberModal } from '../components/AddMemberModal';
|
|
import { MembersTab } from '../components/MembersTab';
|
|
import { useConfirm } from '@/hooks/useConfirm';
|
|
import { useNotification } from '@/hooks/useNotification';
|
|
import { CommentModal } from '@/components/CommentModal';
|
|
import { AddPhotoModal } from '@/components/AddPhotoModal';
|
|
import { TourChat } from '../components/TourChat';
|
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap, Tooltip } from 'react-leaflet';
|
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
|
import { CoordinateSelectModal } from '../components/CoordinateSelectModal';
|
|
import {
|
|
Map as MapIcon,
|
|
Wallet,
|
|
Image as ImageIcon,
|
|
Upload,
|
|
Calendar,
|
|
Users,
|
|
ChevronLeft,
|
|
Settings,
|
|
Quote,
|
|
Plus,
|
|
List,
|
|
Map as MapIconLucide,
|
|
MapPin,
|
|
Search,
|
|
LocateFixed,
|
|
Loader2,
|
|
Compass,
|
|
Car,
|
|
Bike,
|
|
Navigation,
|
|
Footprints,
|
|
Flag,
|
|
Clock,
|
|
Check,
|
|
X,
|
|
MessageSquare,
|
|
Share2,
|
|
Tag as TagIcon,
|
|
Trash2,
|
|
FileText,
|
|
Edit,
|
|
Download,
|
|
Heart
|
|
} 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';
|
|
|
|
// Định nghĩa kiểu dữ liệu cho OSRM Route
|
|
interface OSRMRoute {
|
|
geometry: {
|
|
coordinates: [number, number][]; // [lng, lat]
|
|
};
|
|
distance: number; // meters
|
|
duration: number; // seconds
|
|
legs: { distance: number; duration: number; }[]; // OSRM's internal legs for a route
|
|
}
|
|
|
|
// Định nghĩa kiểu dữ liệu cho điểm (bao gồm cả userLocation khi được chuyển đổi)
|
|
interface LocationPoint {
|
|
latitude: number;
|
|
longitude: number;
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
legId: string;
|
|
}
|
|
|
|
// 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',
|
|
});
|
|
|
|
/**
|
|
* Helper tính khoảng cách giữa 2 tọa độ (Haversine formula)
|
|
*/
|
|
function 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
|
|
}
|
|
|
|
// Helper function to combine segment routes into a single overall route
|
|
const combineSegmentRoutes = (segmentRoutes: OSRMRoute[][], selectedIndices: number[]): OSRMRoute | null => {
|
|
if (segmentRoutes.length === 0) return null;
|
|
|
|
let combinedGeometry: [number, number][] = [];
|
|
let combinedDistance = 0;
|
|
let combinedDuration = 0;
|
|
const combinedLegs: { distance: number; duration: number; }[] = [];
|
|
|
|
for (let i = 0; i < segmentRoutes.length; i++) {
|
|
const segmentIndex = selectedIndices[i] !== undefined ? selectedIndices[i] : 0; // Default to first alternative
|
|
const chosenRoute = segmentRoutes[i][segmentIndex];
|
|
|
|
if (!chosenRoute) {
|
|
// If a segment has no chosen route (e.g., no alternatives or API failed),
|
|
// we cannot form a complete route.
|
|
return null;
|
|
}
|
|
|
|
// Concatenate geometry, avoiding duplicate points at segment junctions
|
|
if (i > 0 && combinedGeometry.length > 0 && chosenRoute.geometry.coordinates.length > 0) {
|
|
const lastPointOfPrev = combinedGeometry[combinedGeometry.length - 1];
|
|
const firstPointOfCurrent = chosenRoute.geometry.coordinates[0];
|
|
// OSRM coordinates are [lng, lat]
|
|
if (Math.abs(lastPointOfPrev[0] - firstPointOfCurrent[0]) < 1e-6 &&
|
|
Math.abs(lastPointOfPrev[1] - firstPointOfCurrent[1]) < 1e-6) {
|
|
combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates.slice(1));
|
|
} else {
|
|
combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates);
|
|
}
|
|
} else {
|
|
combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates);
|
|
}
|
|
|
|
combinedDistance += chosenRoute.distance;
|
|
combinedDuration += chosenRoute.duration;
|
|
combinedLegs.push(...chosenRoute.legs); // OSRM legs are sub-segments within a route
|
|
}
|
|
|
|
return {
|
|
geometry: { coordinates: combinedGeometry },
|
|
distance: combinedDistance,
|
|
duration: combinedDuration,
|
|
legs: combinedLegs,
|
|
};
|
|
};
|
|
|
|
// 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;
|
|
};
|
|
// Component Helper để di chuyển tâm bản đồ về vị trí người dùng khi nhấn nút
|
|
const RecenterUser = ({ position, trigger }: { position: [number, number] | null, trigger: number }) => {
|
|
const map = useMap();
|
|
useEffect(() => {
|
|
if (position && trigger > 0) {
|
|
map.setView(position, 16, { animate: true });
|
|
}
|
|
}, [trigger, position, map]);
|
|
return null;
|
|
};
|
|
|
|
// Component Helper để xử lý xoay bản đồ theo hướng di chuyển
|
|
const MapRotationHandler = ({ rotation }: { rotation: number }) => {
|
|
const map = useMap();
|
|
// Sử dụng Ref để lưu trữ giá trị xoay cộng dồn, giúp bản đồ luôn xoay theo hướng ngắn nhất
|
|
// Thay vì nhảy giá trị từ -359 về 0 (làm CSS xoay ngược 1 vòng), ta tính toán delta.
|
|
const cumulativeRotationRef = useRef(0);
|
|
const prevRotationRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
const container = map.getContainer();
|
|
|
|
// Đảm bảo tâm xoay luôn ở giữa và kích hoạt tăng tốc phần cứng để giảm lag trên iOS
|
|
container.style.transformOrigin = 'center center';
|
|
container.style.willChange = 'transform';
|
|
|
|
if (rotation === 0) {
|
|
cumulativeRotationRef.current = 0;
|
|
prevRotationRef.current = 0;
|
|
container.style.transform = `rotate(0deg) scale(1)`;
|
|
return;
|
|
}
|
|
|
|
let delta = rotation - prevRotationRef.current;
|
|
// Chuẩn hóa delta trong khoảng [-180, 180] để tìm hướng xoay gần nhất
|
|
if (delta > 180) delta -= 360;
|
|
else if (delta < -180) delta += 360;
|
|
|
|
cumulativeRotationRef.current += delta;
|
|
prevRotationRef.current = rotation;
|
|
|
|
// Áp dụng transform với giá trị cộng dồn liên tục.
|
|
// 1. Dùng translateZ(0) để kích hoạt GPU trên iOS.
|
|
// 2. Giảm scale xuống ~1.6 (vừa đủ che góc) để giảm tải cho bộ nhớ đệm đồ họa.
|
|
container.style.transform = `rotate(${cumulativeRotationRef.current}deg) scale(2.2) translateZ(0)`;
|
|
container.style.transition = 'transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1)';
|
|
}, [rotation, map]);
|
|
return null;
|
|
};
|
|
|
|
// Component Helper để hiển thị mẹo khi người dùng dừng chuột trên bản đồ quá 3 giây
|
|
const MapHoverTip = ({ canEdit }: { canEdit: boolean }) => {
|
|
const [tipPos, setTipPos] = useState<L.LatLng | null>(null);
|
|
const [visible, setVisible] = useState(false);
|
|
const timerRef = React.useRef<any>(null);
|
|
|
|
useMapEvents({
|
|
mousemove: (e) => {
|
|
if (!canEdit) return;
|
|
setVisible(false);
|
|
setTipPos(e.latlng);
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
timerRef.current = setTimeout(() => {
|
|
setVisible(true);
|
|
}, 3000);
|
|
},
|
|
mousedown: () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
setVisible(false);
|
|
},
|
|
dragstart: () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
setVisible(false);
|
|
},
|
|
contextmenu: () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
setVisible(false);
|
|
}
|
|
});
|
|
|
|
if (!visible || !tipPos) return null;
|
|
|
|
return (
|
|
<Marker position={tipPos} icon={L.divIcon({ className: 'opacity-0' })}>
|
|
<Tooltip direction="top" offset={[0, -10]} opacity={0.9} permanent>
|
|
<span className="text-[10px] font-bold text-blue-600 whitespace-nowrap">Mẹo: nhấn giữ chuột phải để ghim</span>
|
|
</Tooltip>
|
|
</Marker>
|
|
);
|
|
};
|
|
// Menu ngữ cảnh cho bản đồ
|
|
const MapContextMenu = ({ onAction, onOpen }: { onAction: (action: string, latlng: L.LatLng) => void; onOpen?: () => 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 });
|
|
if (onOpen) onOpen();
|
|
},
|
|
|
|
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>
|
|
);
|
|
};
|
|
|
|
const getMostLikedPhoto = (photos: any[]) => {
|
|
if (!photos || photos.length === 0) return null;
|
|
return [...photos].sort((a, b) => {
|
|
const likesA = a.metadata?.likedUserIds?.length || 0;
|
|
const likesB = b.metadata?.likedUserIds?.length || 0;
|
|
return likesB - likesA;
|
|
})[0];
|
|
};
|
|
|
|
export const TourDetailPage = ({
|
|
onBack,
|
|
tourId,
|
|
isPublicView = false,
|
|
onOpenNotes
|
|
}: {
|
|
onBack: () => void,
|
|
tourId: string,
|
|
isPublicView?: boolean,
|
|
onOpenNotes?: () => void
|
|
}) => {
|
|
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
|
const { t } = useTranslation();
|
|
const notify = useNotification();
|
|
const confirm = useConfirm();
|
|
const currentTour = useTourStore(state => state.currentTour);
|
|
const legs = useTourStore(state => state.legs);
|
|
const publicTours = useTourStore(state => state.publicTours);
|
|
const userRole = useTourStore(state => state.userRole);
|
|
const mapCenter = useTourStore(state => state.mapCenter);
|
|
const [userLocation, setUserLocation] = useState<[number, number] | null>(null); // Vị trí hiện tại của người dùng
|
|
const [gpsHeading, setGpsHeading] = useState<number | null>(null); // Hướng di chuyển từ GPS
|
|
const [userSpeed, setUserSpeed] = useState<number | null>(null); // Tốc độ di chuyển từ GPS
|
|
|
|
// Di chuyển khai báo state lên trên useEffect để tránh lỗi "before initialization"
|
|
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings' | 'members' | 'chat'>(() => {
|
|
const defaultTab = localStorage.getItem('tour_detail_default_tab');
|
|
localStorage.removeItem('tour_detail_default_tab');
|
|
if (defaultTab === 'chat') return 'chat';
|
|
return 'plan';
|
|
});
|
|
const [mergingId, setMergingId] = useState<string | null>(null);
|
|
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
|
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
|
const [isHeadingMode, setIsHeadingMode] = useState(false);
|
|
const [mapRotation, setMapRotation] = useState(0);
|
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
|
const [isMapFullscreen, setIsMapFullscreen] = useState(false);
|
|
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
|
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
|
|
|
const [isRatingModalOpen, setIsRatingModalOpen] = useState(false);
|
|
const [ratingTargetUser, setRatingTargetUser] = useState<any>(null);
|
|
const [ratingScores, setRatingScores] = useState({
|
|
honesty: 5,
|
|
transparency: 5,
|
|
enthusiasm: 5,
|
|
cheerfulness: 5,
|
|
seriousness: 5,
|
|
planning: 5,
|
|
survival: 5
|
|
});
|
|
const [ratingComment, setRatingComment] = useState('');
|
|
const [isSubmittingRating, setIsSubmittingRating] = useState(false);
|
|
|
|
const handleSubmitRating = async () => {
|
|
if (!ratingTargetUser) return;
|
|
setIsSubmittingRating(true);
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const res = await fetch(`/api/v1/tours/${tourId}/ratings`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({
|
|
targetUserId: ratingTargetUser.userId || ratingTargetUser.user?.id,
|
|
...ratingScores,
|
|
comment: ratingComment
|
|
})
|
|
});
|
|
if (res.ok) {
|
|
notify({ title: 'Thành công', message: 'Cảm ơn bạn đã gửi đánh giá!', type: 'success' });
|
|
setIsRatingModalOpen(false);
|
|
setRatingComment('');
|
|
setRatingScores({
|
|
honesty: 5,
|
|
transparency: 5,
|
|
enthusiasm: 5,
|
|
cheerfulness: 5,
|
|
seriousness: 5,
|
|
planning: 5,
|
|
survival: 5
|
|
});
|
|
} else {
|
|
const err = await res.json();
|
|
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi gửi đánh giá.', type: 'error' });
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
notify({ title: 'Lỗi', message: 'Lỗi mạng khi gửi đánh giá.', type: 'error' });
|
|
} finally {
|
|
setIsSubmittingRating(false);
|
|
}
|
|
};
|
|
|
|
const [shareStatus, setShareStatus] = useState<{ isEnabled: boolean; token: string } | null>(null);
|
|
|
|
const fetchShareStatus = async () => {
|
|
if (isPublicView) return;
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const res = await fetch(`/api/v1/tours/${tourId}/share`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setShareStatus(data);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error fetching share status:', e);
|
|
}
|
|
};
|
|
|
|
const handleToggleShare = async (isEnabled: boolean) => {
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const res = await fetch(`/api/v1/tours/${tourId}/share`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ isEnabled })
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setShareStatus(data);
|
|
notify({
|
|
title: 'Thành công',
|
|
message: isEnabled ? 'Đã bật chia sẻ hành trình cứu hộ.' : 'Đã tắt chia sẻ.',
|
|
type: 'success'
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const handleExportPDF = async () => {
|
|
if (!(window as any).html2pdf) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js';
|
|
script.onload = () => resolve();
|
|
script.onerror = () => reject(new Error('Failed to load html2pdf'));
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
const style = document.createElement('style');
|
|
style.innerHTML = `
|
|
.pdf-exclude { display: none !important; }
|
|
.pdf-container { padding: 40px !important; color: #000 !important; background: #fff !important; }
|
|
.pdf-title { font-size: 24px !important; font-weight: bold !important; margin-bottom: 20px !important; text-align: center !important; }
|
|
.pdf-timeline { margin-top: 20px; }
|
|
.pdf-location-card { border: 1px solid #e5e7eb; padding: 15px; border-radius: 12px; margin-bottom: 15px; background: #fafafa; }
|
|
.pdf-leg-header { font-size: 16px; font-weight: bold; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #3b82f6; padding-bottom: 5px; }
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
const element = document.createElement('div');
|
|
element.className = 'pdf-container font-sans text-black bg-white';
|
|
|
|
const titleEl = document.createElement('h1');
|
|
titleEl.className = 'pdf-title';
|
|
titleEl.innerText = `Hành Trình: ${currentTour?.title || 'Tour Itinerary'}`;
|
|
element.appendChild(titleEl);
|
|
|
|
const subEl = document.createElement('div');
|
|
subEl.style.textAlign = 'center';
|
|
subEl.style.marginBottom = '30px';
|
|
subEl.style.fontSize = '12px';
|
|
subEl.style.color = '#555';
|
|
subEl.innerText = `Thời gian: ${currentTour?.startDate ? new Date(currentTour.startDate).toLocaleDateString('vi-VN') : ''} - ${currentTour?.endDate ? new Date(currentTour.endDate).toLocaleDateString('vi-VN') : ''}`;
|
|
element.appendChild(subEl);
|
|
|
|
const printDom = document.getElementById('itinerary-timeline-print-zone');
|
|
if (printDom) {
|
|
const clone = printDom.cloneNode(true) as HTMLElement;
|
|
|
|
// Clean clone layout by removing action buttons and interactive inputs, expenses
|
|
clone.querySelectorAll('button, input, textarea, .pdf-exclude, .expense-badge, .paid-by-badge, .comment-section, .location-actions, .mt-2.text-indigo-650, .flex.gap-2.mt-2').forEach(el => {
|
|
el.remove();
|
|
});
|
|
|
|
// Clear styles or apply simple standard styles so PDF generation is clean
|
|
clone.style.background = 'white';
|
|
clone.style.color = 'black';
|
|
|
|
element.appendChild(clone);
|
|
} else {
|
|
notify({ title: 'Lỗi', message: 'Không tìm thấy vùng hiển thị lịch trình để xuất PDF.', type: 'error' });
|
|
document.head.removeChild(style);
|
|
return;
|
|
}
|
|
|
|
const opt = {
|
|
margin: 10,
|
|
filename: `Lich_trinh_${currentTour?.title || 'tour'}.pdf`,
|
|
image: { type: 'jpeg', quality: 0.98 },
|
|
html2canvas: { scale: 2, useCORS: true },
|
|
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
|
};
|
|
|
|
try {
|
|
notify({ title: 'Đang tạo PDF...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
|
await (window as any).html2pdf().from(element).set(opt).save();
|
|
notify({ title: 'Thành công', message: 'Lịch trình đã được xuất ra tập tin PDF thành công.', type: 'success' });
|
|
} catch (err) {
|
|
console.error(err);
|
|
notify({ title: 'Lỗi', message: 'Không thể xuất PDF lịch trình.', type: 'error' });
|
|
} finally {
|
|
document.head.removeChild(style);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchShareStatus();
|
|
}, [tourId]);
|
|
|
|
// State cho vị trí và hướng của người dùng
|
|
|
|
|
|
|
|
const [deviceOrientationHeading, setDeviceOrientationHeading] = useState<number | null>(null); // Hướng thiết bị (la bàn)
|
|
|
|
// Xác định hướng hiển thị của người dùng (ưu tiên hướng di chuyển từ GPS, sau đó đến la bàn thiết bị)
|
|
const currentHeading = useMemo(() => {
|
|
if (gpsHeading !== null && userSpeed !== null && userSpeed > 0.5) {
|
|
return gpsHeading;
|
|
} else if (deviceOrientationHeading !== null) {
|
|
return deviceOrientationHeading;
|
|
}
|
|
return 0;
|
|
}, [gpsHeading, userSpeed, deviceOrientationHeading]);
|
|
|
|
// Định nghĩa Icons bên trong Component bằng useMemo để đảm bảo tính ổn định và tránh lỗi render
|
|
const mapIcons = useMemo(() => ({
|
|
start: L.divIcon({
|
|
className: '!bg-transparent !border-none',
|
|
html: `<div class="w-7 h-7 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-[11px] font-black text-white animate-in zoom-in duration-300">S</div>`,
|
|
iconSize: [28, 28],
|
|
iconAnchor: [14, 14]
|
|
}),
|
|
end: L.divIcon({
|
|
className: '!bg-transparent !border-none',
|
|
html: `<div class="w-7 h-7 bg-green-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-[11px] font-black text-white animate-in zoom-in duration-300">E</div>`,
|
|
iconSize: [28, 28],
|
|
iconAnchor: [14, 14]
|
|
}),
|
|
visit: L.divIcon({
|
|
className: '!bg-transparent !border-none',
|
|
html: `<div class="w-5 h-5 bg-indigo-500 rounded-full border-2 border-white shadow-lg hover:scale-125 transition-transform flex items-center justify-center"><div class="w-1.5 h-1.5 bg-white rounded-full opacity-50"></div></div>`,
|
|
iconSize: [20, 20],
|
|
iconAnchor: [10, 10]
|
|
}),
|
|
user: L.divIcon({
|
|
className: '!bg-transparent !border-none',
|
|
html: `
|
|
<div class="relative flex items-center justify-center">
|
|
<!-- Hiệu ứng ping tỏa lan -->
|
|
<div class="absolute w-8 h-8 bg-blue-400 rounded-full opacity-30 animate-ping"></div>
|
|
|
|
<!-- Marker chính màu xanh -->
|
|
<div class="w-5 h-5 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center z-10">
|
|
<!-- Mũi tên chỉ hướng nhìn (xoay theo heading) -->
|
|
<div style="transform: rotate(${currentHeading}deg); transition: transform 0.2s ease-out;" class="absolute inset-0 flex flex-col items-center">
|
|
<div class="w-0 h-0 border-l-[5px] border-l-transparent border-r-[5px] border-r-transparent border-b-[8px] border-b-white mt-[1px]"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16]
|
|
})
|
|
}), [currentHeading]);
|
|
|
|
useEffect(() => {
|
|
// Theo dõi vị trí GPS của người dùng (bao gồm hướng và tốc độ khi di chuyển)
|
|
if (!isPublicView && navigator.geolocation) {
|
|
const watchId = navigator.geolocation.watchPosition(
|
|
(pos) => {
|
|
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
|
|
setGpsHeading(pos.coords.heading);
|
|
setUserSpeed(pos.coords.speed);
|
|
},
|
|
(err) => console.warn("Lỗi định vị người dùng:", err),
|
|
{ enableHighAccuracy: true }
|
|
);
|
|
return () => navigator.geolocation.clearWatch(watchId);
|
|
} else {
|
|
setGpsHeading(null);
|
|
setUserSpeed(null);
|
|
}
|
|
}, [isPublicView]); // Chỉ phụ thuộc vào isPublicView
|
|
|
|
// State mới cho hướng thiết bị (la bàn)
|
|
|
|
|
|
// Theo dõi hướng thiết bị (la bàn)
|
|
useEffect(() => {
|
|
const handleOrientation = (event: any) => {
|
|
let heading: number | null = null;
|
|
|
|
// 1. Đối với iOS: Sử dụng webkitCompassHeading (đã chuẩn hóa hướng Bắc thực)
|
|
if (event.webkitCompassHeading !== undefined && event.webkitCompassHeading !== null) {
|
|
heading = event.webkitCompassHeading;
|
|
}
|
|
// 2. Đối với Android (Chrome): Cần kiểm tra tính tuyệt đối của dữ liệu
|
|
else if (event.alpha !== null && event.alpha !== undefined) {
|
|
// Chrome trên Android chỉ cung cấp hướng la bàn chuẩn khi event.absolute là true
|
|
// hoặc khi nhận từ sự kiện 'deviceorientationabsolute'
|
|
if (event.absolute === true || event.type === 'deviceorientationabsolute') {
|
|
// Alpha trên Android tăng theo chiều ngược kim đồng hồ (0=North, 90=West)
|
|
// Cần chuyển đổi sang chiều kim đồng hồ để khớp với logic quay bản đồ
|
|
heading = (360 - event.alpha) % 360;
|
|
}
|
|
}
|
|
|
|
if (heading !== null) {
|
|
setDeviceOrientationHeading(heading);
|
|
}
|
|
};
|
|
|
|
// Đăng ký cả hai loại sự kiện để hỗ trợ tối đa các dòng điện thoại
|
|
window.addEventListener('deviceorientation', handleOrientation, true);
|
|
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
|
|
|
|
return () => {
|
|
window.removeEventListener('deviceorientation', handleOrientation, true);
|
|
window.removeEventListener('deviceorientationabsolute', handleOrientation, true);
|
|
};
|
|
}, []);
|
|
|
|
// Logic kết hợp để xác định hướng xoay bản đồ
|
|
useEffect(() => {
|
|
if (!isHeadingMode) {
|
|
setMapRotation(0); // Đặt lại hướng Bắc nếu chế độ xoay tắt
|
|
return;
|
|
}
|
|
|
|
let newRotation: number | null = null;
|
|
// Ưu tiên hướng GPS nếu có và người dùng đang di chuyển (tốc độ > 0.5 m/s)
|
|
if (gpsHeading !== null && userSpeed !== null && userSpeed > 0.5) {
|
|
newRotation = -gpsHeading;
|
|
} else if (deviceOrientationHeading !== null) {
|
|
// Nếu không di chuyển hoặc không có hướng GPS, dùng hướng thiết bị
|
|
newRotation = -deviceOrientationHeading;
|
|
}
|
|
|
|
if (newRotation !== null) {
|
|
// Cập nhật ngay lập tức để tăng độ nhạy, CSS transition sẽ lo phần mượt mà
|
|
setMapRotation(newRotation);
|
|
}
|
|
}, [isHeadingMode, gpsHeading, userSpeed, deviceOrientationHeading]);
|
|
|
|
const [isStartPointAction, setIsStartPointAction] = useState(false);
|
|
const [isEndPointAction, setIsEndPointAction] = 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 [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
|
|
const currentUserId = useMemo(() => {
|
|
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
|
if (!token) return null;
|
|
try {
|
|
return JSON.parse(atob(token.split('.')[1])).sub;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
const currentUser = useMemo(() => {
|
|
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
|
|
if (!userStr) return null;
|
|
try {
|
|
return JSON.parse(userStr);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
const [isEditingPhoto, setIsEditingPhoto] = useState(false);
|
|
const [editPhotoTitle, setEditPhotoTitle] = useState('');
|
|
const [editPhotoDescription, setEditPhotoDescription] = useState('');
|
|
const [editPhotoLat, setEditPhotoLat] = useState<number | ''>('');
|
|
const [editPhotoLng, setEditPhotoLng] = useState<number | ''>('');
|
|
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
|
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
|
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
|
const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds)
|
|
? selectedPhotoForDisplay.metadata.likedUserIds
|
|
: [];
|
|
const isPhotoLiked = currentUser && likedUserIds.includes(currentUser.id);
|
|
const photoLikeCount = likedUserIds.length;
|
|
|
|
const handleToggleLikePhoto = async () => {
|
|
if (!selectedPhotoForDisplay) return;
|
|
try {
|
|
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setSelectedPhotoForDisplay((prev: any) => {
|
|
if (!prev) return null;
|
|
return {
|
|
...prev,
|
|
metadata: {
|
|
...prev.metadata,
|
|
likedUserIds: data.likedUserIds
|
|
}
|
|
};
|
|
});
|
|
if (currentTour) {
|
|
fetchTour(currentTour.id);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling like:', error);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const lat = selectedPhotoForDisplay?.metadata?.lat;
|
|
const lng = selectedPhotoForDisplay?.metadata?.lng;
|
|
if (typeof lat === 'number' && typeof lng === 'number') {
|
|
setResolvedAddress('Đang xác định địa điểm...');
|
|
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=vi`)
|
|
.then(res => {
|
|
if (!res.ok) throw new Error();
|
|
return res.json();
|
|
})
|
|
.then(data => {
|
|
if (data && data.display_name) {
|
|
const shortAddress = data.display_name.split(',').slice(0, 3).join(',').trim();
|
|
setResolvedAddress(shortAddress || data.display_name);
|
|
} else {
|
|
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
|
});
|
|
} else {
|
|
setResolvedAddress('Chưa xác định tọa độ');
|
|
}
|
|
}, [selectedPhotoForDisplay?.id, selectedPhotoForDisplay?.metadata?.lat, selectedPhotoForDisplay?.metadata?.lng]);
|
|
|
|
useEffect(() => {
|
|
if (selectedPhotoForDisplay) {
|
|
setEditPhotoTitle(selectedPhotoForDisplay.metadata?.title || '');
|
|
setEditPhotoDescription(selectedPhotoForDisplay.metadata?.description || '');
|
|
setEditPhotoLat(selectedPhotoForDisplay.metadata?.lat ?? '');
|
|
setEditPhotoLng(selectedPhotoForDisplay.metadata?.lng ?? '');
|
|
setIsEditingPhoto(false);
|
|
}
|
|
}, [selectedPhotoForDisplay]);
|
|
|
|
const handleSavePhotoEdit = async () => {
|
|
if (!selectedPhotoForDisplay) return;
|
|
if (editPhotoLat !== '' && (isNaN(editPhotoLat) || editPhotoLat < -90 || editPhotoLat > 90)) {
|
|
notify({ title: 'Lỗi', message: 'Vĩ độ không hợp lệ (-90 đến 90).', type: 'error' });
|
|
return;
|
|
}
|
|
if (editPhotoLng !== '' && (isNaN(editPhotoLng) || editPhotoLng < -180 || editPhotoLng > 180)) {
|
|
notify({ title: 'Lỗi', message: 'Kinh độ không hợp lệ (-180 đến 180).', type: 'error' });
|
|
return;
|
|
}
|
|
|
|
setIsSavingPhotoEdit(true);
|
|
try {
|
|
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
title: editPhotoTitle,
|
|
description: editPhotoDescription,
|
|
latitude: editPhotoLat === '' ? undefined : editPhotoLat,
|
|
longitude: editPhotoLng === '' ? undefined : editPhotoLng
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update photo info');
|
|
|
|
const updatedPhoto = await response.json();
|
|
notify({ title: 'Thành công', message: 'Thông tin ảnh đã được cập nhật.', type: 'success' });
|
|
|
|
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
|
|
|
|
if (currentTour) {
|
|
fetchTour(currentTour.id);
|
|
}
|
|
setIsEditingPhoto(false);
|
|
} catch (error) {
|
|
notify({ title: 'Lỗi', message: 'Không thể cập nhật thông tin ảnh. Vui lòng thử lại.', type: 'error' });
|
|
} finally {
|
|
setIsSavingPhotoEdit(false);
|
|
}
|
|
};
|
|
const [joinRequests, setJoinRequests] = useState<any[]>([]);
|
|
const [titleInput, setTitleInput] = useState(currentTour?.title ?? '');
|
|
const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? '');
|
|
// State cho input số lượng người tham gia
|
|
const [adultCountInput, setAdultCountInput] = useState(currentTour?.adultCount ?? 0);
|
|
const [childCountInput, setChildCountInput] = useState(currentTour?.childCount ?? 0);
|
|
const [childDiscountInput, setChildDiscountInput] = useState(currentTour?.childDiscount ?? 0);
|
|
const [tagsInput, setTagsInput] = useState<string[]>(currentTour?.tags ?? []);
|
|
const [customTag, setCustomTag] = useState('');
|
|
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
|
|
const [selectedLegIdForPhoto, setSelectedLegIdForPhoto] = useState<string | 'all'>('all'); // Keep this state
|
|
|
|
// Memoized filtered photos based on selectedLegIdForPhoto
|
|
const filteredPhotos = useMemo(() => {
|
|
if (!currentTour?.photos) return [];
|
|
let photosToFilter = currentTour.photos;
|
|
|
|
if (selectedLegIdForPhoto !== 'all') {
|
|
const targetLeg = legs.find(l => l.id === selectedLegIdForPhoto);
|
|
if (targetLeg) {
|
|
const locationIdsInLeg = targetLeg.locations.map((loc: any) => loc.id);
|
|
// Filter photos that have a locationId and that locationId is in the current leg
|
|
photosToFilter = photosToFilter.filter((p: any) => p.locationId && locationIdsInLeg.includes(p.locationId));
|
|
} else {
|
|
photosToFilter = []; // If leg not found, no photos
|
|
}
|
|
}
|
|
return photosToFilter;
|
|
}, [currentTour?.photos, selectedLegIdForPhoto, legs]);
|
|
|
|
// Effect to set initial selected photo for display or reset if current one is no longer in filtered list
|
|
useEffect(() => {
|
|
if (filteredPhotos.length > 0 && !selectedPhotoForDisplay) {
|
|
setSelectedPhotoForDisplay(getMostLikedPhoto(filteredPhotos));
|
|
} else if (selectedPhotoForDisplay && !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id)) {
|
|
setSelectedPhotoForDisplay(filteredPhotos.length > 0 ? getMostLikedPhoto(filteredPhotos) : null);
|
|
}
|
|
}, [filteredPhotos, selectedPhotoForDisplay]);
|
|
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
|
const [commentLocationId, setCommentLocationId] = useState('');
|
|
const [commentLocationName, setCommentLocationName] = useState('');
|
|
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
|
|
|
// State cho Modal ghi chú nhanh
|
|
const [quickNoteLocName, setQuickNoteLocName] = useState<string | null>(null);
|
|
const [quickNoteInput, setQuickNoteInput] = useState('');
|
|
|
|
// Đồng bộ hóa các ô nhập dữ liệu cài đặt với currentTour khi tour tải/cập nhật
|
|
useEffect(() => {
|
|
if (currentTour) {
|
|
setTitleInput(currentTour.title ?? '');
|
|
setDescriptionInput(currentTour.description ?? '');
|
|
setAdultCountInput(currentTour.adultCount ?? 0);
|
|
setChildCountInput(currentTour.childCount ?? 0);
|
|
setChildDiscountInput(currentTour.childDiscount ?? 0);
|
|
setTagsInput(currentTour.tags ?? []);
|
|
}
|
|
}, [currentTour]);
|
|
|
|
// State tìm kiếm cho chế độ Bản đồ trong Tab Lộ trình
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [locateTrigger, setLocateTrigger] = useState(0);
|
|
const [isMapControlsOpen, setIsMapControlsOpen] = useState(false);
|
|
const [isRoutingLoading, setIsRoutingLoading] = useState(false);
|
|
const [travelMode, setTravelMode] = useState<'driving' | 'bike' | 'foot'>('driving');
|
|
const [routes, setRoutes] = useState<any[]>([]);
|
|
const [selectedRouteIndex, setSelectedRouteIndex] = useState(0);
|
|
const [routeMenu, setRouteMenu] = useState<{ x: number, y: number, index: number } | null>(null);
|
|
const [drivingRoute, setDrivingRoute] = useState<[number, number][]>([]);
|
|
const [segmentDistances, setSegmentDistances] = useState<number[]>([]);
|
|
|
|
const handleSearchLocation = async (query: string) => {
|
|
setSearchQuery(query);
|
|
if (query.trim().length < 2) {
|
|
setSearchResults([]);
|
|
return;
|
|
}
|
|
setIsSearching(true);
|
|
try {
|
|
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&addressdetails=1&namedetails=1&accept-language=vi`);
|
|
const data = await res.json();
|
|
setSearchResults(data);
|
|
} catch (e) {
|
|
console.error("Lỗi tìm kiếm:", e);
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
};
|
|
|
|
const handleDeletePhoto = async (photoId: string) => {
|
|
const isConfirmed = await confirm({
|
|
title: 'Xóa ảnh này?',
|
|
message: 'Bạn có chắc chắn muốn xóa ảnh này khỏi chuyến đi? Hành động này không thể hoàn tác.'
|
|
});
|
|
|
|
if (!isConfirmed) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/photos/${photoId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Không thể xóa ảnh');
|
|
|
|
notify({ title: 'Thành công', message: 'Đã xóa ảnh.', type: 'success' });
|
|
fetchTour(tourId); // Re-fetch tour to update photo list
|
|
setSelectedPhotoForDisplay(null); // Reset selected photo after deletion
|
|
} catch (error) {
|
|
notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' });
|
|
}
|
|
};
|
|
|
|
const fetchTour = useTourStore(state => state.fetchTour);
|
|
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action
|
|
|
|
useEffect(() => {
|
|
if (currentTour) {
|
|
setTagsInput(currentTour.tags || []);
|
|
}
|
|
}, [currentTour]);
|
|
|
|
// Hàm tối ưu để cập nhật số lượng bình luận mà không cần fetch lại toàn bộ Tour
|
|
const handleCommentIncrement = (locationId: string) => {
|
|
const currentLegs = useTourStore.getState().legs;
|
|
const updatedLegs = currentLegs.map(leg => ({
|
|
...leg,
|
|
locations: leg.locations.map(loc =>
|
|
loc.id === locationId
|
|
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
|
|
: loc
|
|
)
|
|
}));
|
|
// Cập nhật trực tiếp vào Store
|
|
useTourStore.setState({ legs: updatedLegs });
|
|
};
|
|
|
|
const handleCommentDecrement = (locationId: string) => {
|
|
const currentLegs = useTourStore.getState().legs;
|
|
const updatedLegs = currentLegs.map(leg => ({
|
|
...leg,
|
|
locations: leg.locations.map(loc =>
|
|
loc.id === locationId
|
|
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
|
|
: loc
|
|
)
|
|
}));
|
|
useTourStore.setState({ legs: updatedLegs });
|
|
};
|
|
|
|
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
|
const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint);
|
|
const updateTourEndPoint = useTourStore(state => state.updateTourEndPoint);
|
|
const updateTourDetails = useTourStore(state => state.updateTourDetails); // Thêm action này
|
|
const initializeLegs = useTourStore(state => state.initializeLegs);
|
|
const addLocation = useTourStore(state => state.addLocation);
|
|
const removeMember = useTourStore(state => state.removeMember);
|
|
const fetchJoinRequests = useTourStore(state => state.fetchJoinRequests);
|
|
const acceptJoinRequest = useTourStore(state => state.acceptJoinRequest);
|
|
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
|
const deleteTour = useTourStore(state => state.deleteTour);
|
|
|
|
|
|
// 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
|
|
// Nếu là public view, không có quyền chỉnh sửa
|
|
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
|
const canInvite = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
|
const canUploadPhoto = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE'].includes(userRole || '');
|
|
const isOwner = isPublicView ? false : userRole === 'OWNER';
|
|
const canShare = isPublicView || ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
|
const canManage = isOwner || (!isPublicView && userRole === 'MANAGER');
|
|
|
|
const duplicateMatches = useMemo(() => {
|
|
if (!canManage || !currentTour?.participants) return [];
|
|
|
|
const manualMembers = currentTour.participants.filter((p: any) => !p.userId && p.displayName);
|
|
const systemMembers = currentTour.participants.filter((p: any) => p.userId && p.user?.name);
|
|
|
|
const matches: Array<{ manual: any; system: any }> = [];
|
|
|
|
manualMembers.forEach((m: any) => {
|
|
const match = systemMembers.find((s: any) => {
|
|
return s.user.name.trim().toLowerCase() === m.displayName.trim().toLowerCase();
|
|
});
|
|
if (match) {
|
|
matches.push({ manual: m, system: match });
|
|
}
|
|
});
|
|
|
|
return matches;
|
|
}, [canManage, currentTour?.participants]);
|
|
|
|
useEffect(() => {
|
|
if (isPublicView) {
|
|
fetchPublicTourDetails(tourId);
|
|
} else if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
|
fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([]));
|
|
}
|
|
// Fetch tour details when tourId changes or public view status changes
|
|
if (tourId) { isPublicView ? fetchPublicTourDetails(tourId) : fetchTour(tourId); }
|
|
}, [tourId, isPublicView, userRole]); // Add tourId to dependencies
|
|
|
|
// New useEffect to manage initial photo display when currentTour or legs change
|
|
useEffect(() => {
|
|
if (currentTour && currentTour.photos && currentTour.photos.length > 0) {
|
|
// If there are photos, and no leg is selected, default to 'all' and first photo
|
|
if (selectedLegIdForPhoto === 'all' && !selectedPhotoForDisplay) {
|
|
setSelectedPhotoForDisplay(getMostLikedPhoto(currentTour.photos));
|
|
} else if (selectedLegIdForPhoto !== 'all') {
|
|
// If a specific leg is selected, try to find a photo for that leg
|
|
const targetLeg = legs.find(l => l.id === selectedLegIdForPhoto);
|
|
if (targetLeg) {
|
|
const locationIdsInLeg = targetLeg.locations.map((loc: any) => loc.id);
|
|
const photosInLeg = currentTour.photos.filter((p: any) => p.locationId && locationIdsInLeg.includes(p.locationId));
|
|
if (photosInLeg.length > 0 && !selectedPhotoForDisplay) {
|
|
setSelectedPhotoForDisplay(getMostLikedPhoto(photosInLeg));
|
|
} else if (selectedPhotoForDisplay && !photosInLeg.some(p => p.id === selectedPhotoForDisplay.id)) {
|
|
setSelectedPhotoForDisplay(photosInLeg.length > 0 ? getMostLikedPhoto(photosInLeg) : null);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, [currentTour, legs, selectedLegIdForPhoto, selectedPhotoForDisplay]);
|
|
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]);
|
|
|
|
// Tạo key định danh cho lộ trình để buộc bản đồ vẽ lại khi dữ liệu thay đổi
|
|
const routeKey = useMemo(() => JSON.stringify({
|
|
locations: allLocations.map(l => ({ id: l.id, lat: l.latitude, lon: l.longitude })),
|
|
user: userLocation ? { lat: userLocation[0].toFixed(5), lon: userLocation[1].toFixed(5) } : null,
|
|
travelMode: travelMode,
|
|
selectedRouteIndex: selectedRouteIndex // Include selectedRouteIndex to force re-render when alternative is chosen
|
|
}), [allLocations, userLocation, travelMode, selectedRouteIndex]);
|
|
|
|
// Tự động tìm các quãng đường di chuyển thực tế theo phương tiện và vẽ lên bản đồ
|
|
useEffect(() => {
|
|
const fetchRoutes = async () => {
|
|
// Prepare points: userLocation + allLocations
|
|
const currentPoints: LocationPoint[] = [...allLocations];
|
|
if (userLocation) {
|
|
currentPoints.unshift({
|
|
latitude: userLocation[0],
|
|
longitude: userLocation[1],
|
|
id: 'user-location', // Dummy ID
|
|
name: 'Vị trí hiện tại', // Dummy name
|
|
type: 'MOVE', // Dummy type
|
|
legId: '', // Dummy legId
|
|
});
|
|
}
|
|
|
|
if (currentPoints.length < 2) {
|
|
setRoutes([]);
|
|
setSelectedRouteIndex(0);
|
|
setDrivingRoute([]);
|
|
setSegmentDistances([]);
|
|
return;
|
|
}
|
|
|
|
// Chèn vị trí người dùng vào đầu danh sách tọa độ nếu có
|
|
const coordsArray = allLocations.map(loc => `${loc.longitude},${loc.latitude}`);
|
|
|
|
if (userLocation) {
|
|
coordsArray.unshift(`${userLocation[1]},${userLocation[0]}`);
|
|
}
|
|
|
|
const coordsString = coordsArray.join(';');
|
|
|
|
setIsRoutingLoading(true);
|
|
try {
|
|
const segmentPromises: Promise<OSRMRoute[] | null>[] = [];
|
|
for (let i = 0; i < currentPoints.length - 1; i++) {
|
|
const p1 = currentPoints[i];
|
|
const p2 = currentPoints[i + 1];
|
|
const coordsString = `${p1.longitude},${p1.latitude};${p2.longitude},${p2.latitude}`;
|
|
|
|
segmentPromises.push(
|
|
fetch(`https://router.project-osrm.org/route/v1/${travelMode}/${coordsString}?overview=full&geometries=geojson&alternatives=3`) // Yêu cầu tối đa 5 phương án thay thế cho mỗi phân đoạn
|
|
.then(res => {
|
|
if (!res.ok) throw new Error(`OSRM API error: ${res.status}`);
|
|
return res.json();
|
|
})
|
|
.then(data => {
|
|
if (data.code === 'Ok' && data.routes.length > 0) {
|
|
return data.routes; // Array of OSRMRoute for this segment
|
|
}
|
|
return null; // No routes for this segment
|
|
})
|
|
.catch(error => {
|
|
console.error(`Lỗi lấy lộ trình cho phân đoạn ${i}-${i+1}:`, error);
|
|
return null;
|
|
})
|
|
);
|
|
}
|
|
|
|
const allSegmentAlternativesRaw = await Promise.all(segmentPromises);
|
|
const allSegmentAlternatives: OSRMRoute[][] = allSegmentAlternativesRaw.filter((seg): seg is OSRMRoute[] => seg !== null);
|
|
|
|
if (allSegmentAlternatives.length === 0) {
|
|
setRoutes([]);
|
|
setSelectedRouteIndex(0);
|
|
return;
|
|
}
|
|
|
|
// Now, construct overall alternative routes from segment alternatives
|
|
// Heuristic: Take the first alternative of each segment to form the primary route.
|
|
// Then, for subsequent overall alternatives, try different alternatives for the first segment,
|
|
// keeping other segments at their primary alternative.
|
|
|
|
const overallAlternativeRoutes: OSRMRoute[] = [];
|
|
// Limit overall alternatives based on the number of alternatives for the first segment, up to 3
|
|
const maxOverallAlternativesToGenerate = 3; // Số lượng lộ trình tổng thể muốn tạo
|
|
|
|
// Heuristic để tạo các lộ trình tổng thể đa dạng hơn:
|
|
// 1. Lộ trình chính (fastest/default cho tất cả các phân đoạn)
|
|
const primarySegmentIndices = allSegmentAlternatives.map(() => 0);
|
|
const primaryCombinedRoute = combineSegmentRoutes(allSegmentAlternatives, primarySegmentIndices);
|
|
if (primaryCombinedRoute) {
|
|
overallAlternativeRoutes.push(primaryCombinedRoute);
|
|
}
|
|
|
|
// 2. Các lộ trình thay thế: Thử kết hợp các phương án thay thế từ các phân đoạn
|
|
// Ví dụ: Lấy phương án thứ N của mỗi phân đoạn (nếu có), hoặc phương án 0 nếu không có
|
|
for (let altChoice = 1; altChoice < maxOverallAlternativesToGenerate; altChoice++) {
|
|
const selectedSegmentIndices: number[] = allSegmentAlternatives.map(segmentAlts =>
|
|
Math.min(altChoice, segmentAlts.length - 1) // Chọn phương án thứ 'altChoice', hoặc phương án cuối cùng nếu không đủ
|
|
);
|
|
const combinedRoute = combineSegmentRoutes(allSegmentAlternatives, selectedSegmentIndices);
|
|
if (combinedRoute && !overallAlternativeRoutes.some(r => JSON.stringify(r.geometry.coordinates) === JSON.stringify(combinedRoute.geometry.coordinates))) {
|
|
overallAlternativeRoutes.push(combinedRoute);
|
|
}
|
|
}
|
|
setRoutes(overallAlternativeRoutes);
|
|
setSelectedRouteIndex(0); // Always select the first overall alternative by default
|
|
|
|
} catch (error) {
|
|
console.error(`Lỗi lấy lộ trình ${travelMode}:`, error);
|
|
setRoutes([]);
|
|
} finally {
|
|
setIsRoutingLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchRoutes();
|
|
}, [allLocations, travelMode, userLocation]);
|
|
|
|
// Cập nhật dữ liệu lộ trình hiển thị khi người dùng chọn phương án khác
|
|
useEffect(() => {
|
|
if (routes.length > 0 && routes[selectedRouteIndex]) {
|
|
const route = routes[selectedRouteIndex];
|
|
// OSRM trả về [lng, lat], cần đổi sang [lat, lng] cho Leaflet
|
|
const mappedCoords: [number, number][] = route.geometry.coordinates.map((c: any) => [c[1], c[0]]);
|
|
setDrivingRoute(mappedCoords);
|
|
// Lưu quãng đường từng chặng của lộ trình được chọn
|
|
setSegmentDistances(route.legs.map((leg: any) => leg.distance / 1000));
|
|
}
|
|
}, [selectedRouteIndex, routes]);
|
|
|
|
// Tính toán thông tin hiển thị cho lộ trình đang chọn để đề xuất cho người dùng
|
|
const selectedRouteInfo = useMemo(() => {
|
|
if (!routes || routes.length === 0 || !routes[selectedRouteIndex]) return null;
|
|
const r = routes[selectedRouteIndex];
|
|
|
|
// Định dạng thời gian di chuyển
|
|
const duration = r.duration;
|
|
const hours = Math.floor(duration / 3600);
|
|
const minutes = Math.round((duration % 3600) / 60);
|
|
const durationStr = hours > 0 ? `${hours}h${minutes}p` : `${minutes}p`;
|
|
|
|
// Xác định nhãn đề xuất: Index 0 thường là lộ trình tối ưu nhất của OSRM (thông dụng nhất)
|
|
// Kiểm tra thêm nếu đây là lộ trình ngắn nhất trong các phương án
|
|
const isShortest = routes.length > 1 && r.distance === Math.min(...routes.map(rt => rt.distance));
|
|
|
|
let label = "Lộ trình";
|
|
if (selectedRouteIndex === 0) label = "Đề xuất";
|
|
else if (isShortest) label = "Ngắn nhất";
|
|
else label = `Lựa chọn ${selectedRouteIndex + 1}`;
|
|
|
|
return {
|
|
distance: (r.distance / 1000).toFixed(1),
|
|
duration: durationStr,
|
|
label
|
|
};
|
|
}, [routes, selectedRouteIndex]);
|
|
|
|
const handleNavigateToLocation = (location: any) => {
|
|
setMapCenter([location.latitude, location.longitude]);
|
|
setViewMode('map');
|
|
setLocateTrigger(prev => prev + 1);
|
|
|
|
notify({
|
|
title: 'Bắt đầu chỉ đường',
|
|
message: `Đang hiển thị lộ trình từ vị trí của bạn đến ${location.name}`,
|
|
type: 'info'
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (initialViewState) {
|
|
setMapCenter(initialViewState.center);
|
|
}
|
|
// This useEffect is for initial load, but we now have tourId prop
|
|
// The fetching logic is moved to the useEffect above that depends on tourId and isPublicView
|
|
// So this useEffect can be simplified or removed if its only purpose was initial data load.
|
|
// if (publicTours.length === 0 && !isPublicView) { // Only fetch public tours if not in public view and not already loaded
|
|
// fetchPublicTours();
|
|
// }
|
|
}, [initialViewState]); // Removed publicTours, currentTour, fetchPublicTours, fetchTour from dependencies
|
|
|
|
|
|
const handleShare = () => {
|
|
if (!currentTour) return;
|
|
// Tạo link với query param ?viewTour=...
|
|
const shareUrl = `${window.location.origin}?viewTour=${currentTour.id}`;
|
|
if (navigator.share) {
|
|
navigator.share({
|
|
title: currentTour.title,
|
|
url: shareUrl,
|
|
}).catch(() => {});
|
|
} else if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Đã sao chép liên kết chia sẻ chuyến đi!',
|
|
type: 'success'
|
|
});
|
|
});
|
|
} else {
|
|
// Giải pháp dự phòng cho môi trường không có HTTPS (truy cập qua IP)
|
|
const textArea = document.createElement("textarea");
|
|
textArea.value = shareUrl;
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Đã sao chép liên kết chia sẻ chuyến đi!',
|
|
type: 'success'
|
|
});
|
|
} catch (err) {}
|
|
document.body.removeChild(textArea);
|
|
}
|
|
};
|
|
|
|
// Thiết lập kết nối WebSocket Real-time
|
|
useEffect(() => {
|
|
if (!currentTour) return;
|
|
|
|
const socket = io(); // Kết nối qua Proxy của Vite (cùng origin)
|
|
|
|
socket.on('connect', () => {
|
|
socket.emit('joinTour', currentTour.id);
|
|
});
|
|
|
|
socket.on('commentAdded', (data: any) => {
|
|
// Cập nhật UI ngay lập tức khi bất kỳ ai bình luận
|
|
handleCommentIncrement(data.locationId);
|
|
});
|
|
|
|
socket.on('tourMessageReceived', (data: any) => {
|
|
if (data.tourId === currentTour.id && activeTab !== 'chat') {
|
|
setUnreadChatCount(prev => prev + 1);
|
|
}
|
|
});
|
|
|
|
return () => { socket.disconnect(); };
|
|
}, [currentTour?.id, activeTab]);
|
|
|
|
// This useEffect is for initial demo loading, might not be needed if tourId is always passed
|
|
// useEffect(() => {
|
|
// if (publicTours.length > 0 && !currentTour && !isPublicView) {
|
|
// fetchTour(publicTours[0].id);
|
|
// }
|
|
// }, [publicTours, currentTour, fetchTour, isPublicView]);
|
|
|
|
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Đồng bộ tiêu đề ghi chú khi đổi tên chuyến đi
|
|
const syncTourNoteTitle = (tourId: string, newTitle: string) => {
|
|
const savedNotes = localStorage.getItem('my_journey_notes');
|
|
if (!savedNotes) return;
|
|
try {
|
|
let notes = JSON.parse(savedNotes);
|
|
if (Array.isArray(notes)) {
|
|
let updated = false;
|
|
const oldTitle = currentTour?.title || '';
|
|
notes = notes.map((n: any) => {
|
|
if (n.tourId === tourId || n.title === `Ghi chú của hành trình: ${oldTitle}`) {
|
|
n.tourId = tourId;
|
|
n.title = `Ghi chú của hành trình: ${newTitle}`;
|
|
if (oldTitle && n.content) {
|
|
n.content = n.content.split(`<strong>${oldTitle}</strong>`).join(`<strong>${newTitle}</strong>`);
|
|
}
|
|
updated = true;
|
|
}
|
|
return n;
|
|
});
|
|
if (updated) {
|
|
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Error syncing tour note title:", e);
|
|
}
|
|
};
|
|
|
|
// Hàm xử lý cập nhật số lượng người tham gia
|
|
const handleUpdateTourInfo = async () => {
|
|
if (!currentTour) return;
|
|
try {
|
|
await updateTourDetails(currentTour.id, {
|
|
title: titleInput,
|
|
description: descriptionInput,
|
|
adultCount: adultCountInput,
|
|
childCount: childCountInput,
|
|
childDiscount: childDiscountInput,
|
|
tags: tagsInput
|
|
});
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Đã cập nhật thông tin chuyến đi.',
|
|
type: 'success'
|
|
});
|
|
if (titleInput !== currentTour.title) {
|
|
syncTourNoteTitle(currentTour.id, titleInput);
|
|
}
|
|
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
|
|
} catch (error: any) {
|
|
notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật thông tin.', type: 'error' });
|
|
}
|
|
};
|
|
|
|
// Hàm ghi chú nhanh cho từng địa điểm, tự động append vào note chung của Tour
|
|
const handleQuickNote = (locationName: string) => {
|
|
if (isPublicView) return;
|
|
setQuickNoteLocName(locationName);
|
|
setQuickNoteInput('');
|
|
};
|
|
|
|
// Hàm xử lý submit ghi chú nhanh từ modal
|
|
const submitQuickNote = () => {
|
|
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour) return;
|
|
const content = quickNoteInput;
|
|
|
|
const storedUser = localStorage.getItem('user');
|
|
const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' };
|
|
const userName = user.name || 'Thành viên';
|
|
const now = new Date().toLocaleString('vi-VN');
|
|
|
|
const noteTitle = `Ghi chú của hành trình: ${currentTour.title}`;
|
|
const savedNotes = localStorage.getItem('my_journey_notes');
|
|
let notes = [];
|
|
try {
|
|
notes = savedNotes ? JSON.parse(savedNotes) : [];
|
|
} catch (e) { notes = []; }
|
|
|
|
let targetNote = notes.find((n: any) => n.tourId === currentTour.id || n.title === noteTitle);
|
|
|
|
// Tạo khối nội dung dạng "Textbox" chuyên nghiệp
|
|
const newContentLine = `
|
|
<div class="quick-note-box" style="border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; background: #fffbeb; border-radius: 8px; border: 1px solid #fef3c7; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
|
<span style="font-size: 10px; color: #b45309; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">🕒 ${now} • 👤 ${userName}</span>
|
|
</div>
|
|
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${quickNoteLocName}:</strong> ${content}</p>
|
|
</div>
|
|
<p></p>
|
|
`;
|
|
|
|
if (targetNote) {
|
|
targetNote.tourId = currentTour.id;
|
|
targetNote.title = noteTitle;
|
|
targetNote.content += newContentLine;
|
|
} else {
|
|
const newNote = {
|
|
id: Date.now().toString(),
|
|
tourId: currentTour.id,
|
|
title: noteTitle,
|
|
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${currentTour.title}</strong> của bạn tại đây...</p>` + newContentLine,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
notes.unshift(newNote);
|
|
}
|
|
|
|
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
|
|
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
|
|
setQuickNoteLocName(null);
|
|
setQuickNoteInput('');
|
|
};
|
|
|
|
// Hàm xử lý xóa Tour vĩnh viễn
|
|
const handleDeleteTour = async () => {
|
|
if (!currentTour) return;
|
|
const isConfirmed = await confirm({
|
|
title: 'Xóa Tour vĩnh viễn?',
|
|
message: 'Toàn bộ dữ liệu về lộ trình, chi phí và hình ảnh của chuyến đi này sẽ bị xóa bỏ hoàn toàn. Bạn có chắc chắn muốn thực hiện?'
|
|
});
|
|
|
|
if (isConfirmed) {
|
|
try {
|
|
await deleteTour(currentTour.id);
|
|
notify({ title: 'Thành công', message: 'Hành trình đã được xóa.', type: 'success' });
|
|
onBack(); // Quay về trang khám phá sau khi xóa thành công
|
|
} catch (error: any) {
|
|
notify({ title: 'Lỗi', message: error.message || 'Không thể xóa hành trình.', type: 'error' });
|
|
}
|
|
}
|
|
};
|
|
|
|
// Ngăn chặn sự kiện click trên menu lộ trình làm ảnh hưởng bản đồ
|
|
const routeMenuRef = React.useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
if (routeMenu && routeMenuRef.current) {
|
|
L.DomEvent.disableClickPropagation(routeMenuRef.current);
|
|
}
|
|
}, [routeMenu]);
|
|
|
|
// Đóng menu lộ trình khi chuyển tab hoặc thay đổi chế độ xem
|
|
useEffect(() => {
|
|
setRouteMenu(null);
|
|
}, [activeTab, viewMode]);
|
|
|
|
// 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 = useMemo(() =>
|
|
legs.flatMap(l => l.locations).find(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0),
|
|
[legs]
|
|
);
|
|
const endPoint = useMemo(() =>
|
|
legs.flatMap(l => l.locations).find(loc => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0),
|
|
[legs]
|
|
);
|
|
|
|
// Đị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: 'chat', label: 'Trò chuyện', icon: MessageSquare, visible: !!userRole, hasBadge: unreadChatCount > 0, badgeCount: unreadChatCount },
|
|
{ id: 'members', label: 'Thành viên', icon: Users, visible: canManage },
|
|
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
|
|
].filter(t => t.visible);
|
|
|
|
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
|
|
|
// Logic tính toán ngày hiển thị: Ưu tiên ngày của Tour, sau đó đến ngày của các Chặng
|
|
const tourDateDisplay = useMemo(() => {
|
|
if (currentTour?.startDate && currentTour?.endDate) {
|
|
return `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}`;
|
|
}
|
|
const firstLeg = legs[0];
|
|
const lastLeg = legs[legs.length - 1];
|
|
const start = firstLeg?.startDate;
|
|
const end = lastLeg?.endDate || lastLeg?.startDate;
|
|
if (start && end) {
|
|
return `${new Date(start).toLocaleDateString('vi-VN')} - ${new Date(end).toLocaleDateString('vi-VN')}`;
|
|
} else if (start) {
|
|
return `Từ ${new Date(start).toLocaleDateString('vi-VN')}`;
|
|
}
|
|
return "Chưa xác định ngày";
|
|
}, [currentTour, legs]);
|
|
|
|
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: tourDateDisplay,
|
|
membersCount: currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.length || 0,
|
|
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
|
coverImage: getMostLikedPhoto(currentTour?.photos || [])?.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 flex-1 text-center">
|
|
{tourInfo.title}
|
|
</h1>
|
|
<div className="flex items-center gap-1">
|
|
{/* Nút Ghi chú: Chỉ hiển thị cho người dùng đã đăng nhập và không phải view công khai */}
|
|
{!isPublicView && onOpenNotes && (
|
|
<button
|
|
onClick={onOpenNotes}
|
|
className="p-2 hover:bg-amber-50 text-amber-600 rounded-full transition-colors"
|
|
title="Ghi chú của tôi"
|
|
>
|
|
<FileText className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
{/* Nút chia sẻ: Chỉ dành cho OWNER, MANAGER, MEMBER */}
|
|
{canShare && (
|
|
<button
|
|
onClick={handleShare}
|
|
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors"
|
|
title="Chia sẻ tour"
|
|
>
|
|
<Share2 className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Tour Header Info */}
|
|
<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>
|
|
|
|
{/* Nhãn hiển thị ngay dưới Tiêu đề */}
|
|
{currentTour?.tags && currentTour.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
{currentTour.tags.map((tag: string) => (
|
|
<span key={tag} className="px-2.5 py-1 bg-white/20 backdrop-blur-md border border-white/30 rounded-lg text-[10px] font-black uppercase tracking-wider">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Mô tả hiển thị dưới Nhãn */}
|
|
{currentTour?.description && (
|
|
<p className="text-sm md:text-base text-white/90 max-w-xl line-clamp-3 md:line-clamp-none bg-black/20 backdrop-blur-sm p-4 rounded-2xl border border-white/10 italic leading-relaxed">
|
|
<Quote className="w-4 h-4 inline-block mr-2 opacity-50" />
|
|
{currentTour.description}
|
|
</p>
|
|
)}
|
|
|
|
{/* 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"> {/* Always show participants */}
|
|
{currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.slice(0, 5).map((p: any) => {
|
|
const memberName = p.user?.name || p.displayName || 'Thành viên';
|
|
return (
|
|
<button
|
|
key={p.id}
|
|
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={memberName}
|
|
>
|
|
{p.user ? (
|
|
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
|
|
) : (
|
|
<span>{memberName.charAt(0)}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
{isOwner && !isPublicView && joinRequests.slice(0, 3).map((req: any) => ( // Hide join requests in public view
|
|
<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;
|
|
const isConfirmed = await confirm({
|
|
title: 'Chấp nhận yêu cầu',
|
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
|
|
});
|
|
if (isConfirmed) {
|
|
setJoinRequestActionId(req.id);
|
|
try {
|
|
await acceptJoinRequest(currentTour.id, req.id);
|
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
} catch (e: any) {
|
|
notify({ title: 'Thông báo', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
|
|
} finally {
|
|
setJoinRequestActionId(null);
|
|
}
|
|
}
|
|
}}
|
|
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;
|
|
const isConfirmed = await confirm({
|
|
title: 'Từ chối yêu cầu',
|
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
|
|
});
|
|
if (isConfirmed) {
|
|
setJoinRequestActionId(req.id);
|
|
try {
|
|
await rejectJoinRequest(currentTour.id, req.id);
|
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
} catch (e: any) {
|
|
notify({ title: 'Thông báo', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
|
|
} finally {
|
|
setJoinRequestActionId(null);
|
|
}
|
|
}
|
|
}}
|
|
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>
|
|
{!isPublicView && canInvite && (
|
|
<button
|
|
onClick={() => {
|
|
if (!currentTour) return;
|
|
setIsAddMemberOpen(true);
|
|
}}
|
|
className="flex items-center gap-1.5 px-3 py-2 rounded-full border border-white/20 bg-white/10 hover:bg-white/20 text-white font-bold text-xs transition-all ml-2 shadow-md hover:scale-105 active:scale-95"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
Thêm thành viên
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{duplicateMatches.map((match) => (
|
|
<div key={match.manual.id} className="mt-3 p-3 bg-amber-500/20 backdrop-blur-md border border-amber-500/30 rounded-2xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-xs text-amber-100 animate-in fade-in slide-in-from-top-1 shadow-lg">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-2.5 h-2.5 rounded-full bg-amber-400 animate-pulse shrink-0"></span>
|
|
<span>
|
|
Phát hiện thành viên ngoài hệ thống <strong>"{match.manual.displayName}"</strong> trùng tên với tài khoản <strong>"{match.system.user.name}"</strong> vừa tham gia.
|
|
</span>
|
|
</div>
|
|
<button
|
|
disabled={mergingId === match.manual.id}
|
|
onClick={async () => {
|
|
const isConfirmed = await confirm({
|
|
title: 'Hợp nhất thành viên',
|
|
message: `Gán tài khoản "${match.system.user.name}" thay thế cho thành viên thủ công "${match.manual.displayName}"? Thao tác này không thể hoàn tác.`
|
|
});
|
|
if (!isConfirmed) return;
|
|
|
|
setMergingId(match.manual.id);
|
|
try {
|
|
const res = await fetch(`/api/v1/tours/${currentTour.id}/members/merge`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
manualParticipantId: match.manual.id,
|
|
systemUserId: match.system.userId
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(data.message || 'Hợp nhất thất bại.');
|
|
}
|
|
notify({ title: 'Thành công', message: 'Đã gán thành viên ngoài hệ thống thành công!', type: 'success' });
|
|
fetchTour(currentTour.id);
|
|
} catch (e: any) {
|
|
notify({ title: 'Lỗi', message: e.message || 'Không thể hợp nhất', type: 'error' });
|
|
} finally {
|
|
setMergingId(null);
|
|
}
|
|
}}
|
|
className={`px-3.5 py-2 bg-amber-500 hover:bg-amber-600 active:scale-95 text-white font-black rounded-xl transition-all shrink-0 shadow-md text-[11px] ${mergingId === match.manual.id ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
{mergingId === match.manual.id ? 'Đang xử lý...' : 'Gán & Hợp nhất'}
|
|
</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 // Always show quote if public view, otherwise show financial widget if has access
|
|
onClick={() => hasFinanceAccess && setActiveTab('expense')}
|
|
className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200 cursor-pointer hover:scale-[1.02] active:scale-95' : 'bg-white text-gray-600 border border-gray-100'}`}
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
{hasFinanceAccess ? (
|
|
<>
|
|
<div>
|
|
<p className="text-indigo-100 text-[10px] font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại (Nhấn để xem chi tiết)</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>
|
|
</>
|
|
) : ( // If no finance access or is public view, show quote
|
|
<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);
|
|
if (tab.id === 'chat') setUnreadChatCount(0);
|
|
}}
|
|
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all relative ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
|
|
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}
|
|
{tab.hasBadge && tab.badgeCount !== undefined && tab.badgeCount > 0 && (
|
|
<span className="absolute -top-1 -right-1.5 bg-red-500 text-white text-[9px] font-black rounded-full px-1.5 py-0.5 animate-bounce shadow-md">
|
|
{tab.badgeCount}
|
|
</span>
|
|
)}
|
|
</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-between items-center mb-6">
|
|
<div className="flex-1" />
|
|
<div className="bg-gray-100 dark:bg-slate-800 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 dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : '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 dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
|
|
>
|
|
<MapIconLucide className="w-3.5 h-3.5" /> Bản đồ
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 flex justify-end">
|
|
<button
|
|
onClick={handleExportPDF}
|
|
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95"
|
|
>
|
|
📥 {t('exportPDF') || 'Xuất PDF'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{viewMode === 'timeline' ? (
|
|
<ItineraryTimeline onAddLocation={(legId, isStart, isEnd) => {
|
|
setTargetLegId(legId);
|
|
setEditingLocation(null);
|
|
setIsStartPointAction(!!isStart);
|
|
setIsEndPointAction(!!isEnd);
|
|
setIsAddLocationOpen(true);
|
|
}} onEditLocation={(loc) => {
|
|
setEditingLocation(loc);
|
|
setTargetLegId(loc.legId);
|
|
setMapCenter([loc.latitude, loc.longitude]);
|
|
|
|
// Kiểm tra xem địa điểm đang sửa có phải là điểm mốc đặc biệt không (dựa trên timestamp 1970)
|
|
const isStart = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
|
|
const isEnd = loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0;
|
|
setIsStartPointAction(!!isStart);
|
|
setIsEndPointAction(!!isEnd);
|
|
|
|
setIsAddLocationOpen(true);
|
|
}}
|
|
onQuickNote={(locName: string) => handleQuickNote(locName)}
|
|
onSuccess={() => fetchTour(tourId)}
|
|
isPublicView={isPublicView}
|
|
onNavigate={handleNavigateToLocation}
|
|
/>
|
|
) : (
|
|
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative animate-in fade-in duration-500">
|
|
{/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
|
|
{!isPublicView && (
|
|
<div className="absolute top-3 right-3 z-[1001] w-48 md:w-64">
|
|
<div className="relative group">
|
|
<input
|
|
type="text"
|
|
placeholder="Tìm địa điểm..."
|
|
className="w-full pl-8 pr-8 py-2 bg-white/95 backdrop-blur-md border border-white/20 rounded-xl shadow-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all text-xs font-bold"
|
|
value={searchQuery}
|
|
onChange={e => handleSearchLocation(e.target.value)}
|
|
/>
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-blue-500" />
|
|
{isSearching ? (
|
|
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 animate-spin text-blue-500" />
|
|
) : searchQuery && (
|
|
<button onClick={() => { setSearchQuery(''); setSearchResults([]); }} className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-red-500 transition-colors">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Kết quả tìm kiếm */}
|
|
{searchResults.length > 0 && (
|
|
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-48 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
|
|
{searchResults.map((result, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => {
|
|
setMapCenter([parseFloat(result.lat), parseFloat(result.lon)]);
|
|
setSearchResults([]);
|
|
setSearchQuery('');
|
|
notify({
|
|
title: 'Tìm thấy địa điểm',
|
|
message: `Đã di chuyển bản đồ tới: ${result.namedetails?.name || result.display_name.split(',')[0]}`,
|
|
type: 'success'
|
|
});
|
|
}}
|
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors"
|
|
>
|
|
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
|
|
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Overlay thông tin lộ trình đề xuất (Lộ trình thông dụng nhất) */}
|
|
{selectedRouteInfo && drivingRoute.length > 0 && (
|
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1001] bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl shadow-xl border border-white flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-500">
|
|
<div className="flex items-center gap-2">
|
|
<Navigation className="w-3.5 h-3.5 text-blue-600 rotate-45" />
|
|
<span className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">{selectedRouteInfo.label}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs font-bold text-blue-700 whitespace-nowrap">
|
|
<span>{selectedRouteInfo.distance} km</span>
|
|
<span className="text-gray-300">•</span>
|
|
<span>{selectedRouteInfo.duration}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<MapContainer
|
|
center={initialViewState?.center || mapCenter}
|
|
zoom={mapZoom}
|
|
className="h-full w-full"
|
|
preferCanvas={true}
|
|
attributionControl={false}
|
|
>
|
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
|
|
{canEdit && !isPublicView && (
|
|
<>
|
|
<MapHoverTip canEdit={canEdit} />
|
|
<MapContextMenu onAction={handleMapAction} onOpen={() => setRouteMenu(null)} />
|
|
</>
|
|
)}
|
|
|
|
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
|
<MapTourBounds locations={allLocations} />
|
|
|
|
{/* Xử lý xoay bản đồ theo Heading */}
|
|
<MapRotationHandler rotation={mapRotation} />
|
|
|
|
{/* Xử lý di chuyển tâm bản đồ về phía người dùng */}
|
|
<RecenterUser position={userLocation} trigger={locateTrigger} />
|
|
|
|
{/* Hiển thị vị trí hiện tại của người dùng */}
|
|
{userLocation && (
|
|
<Marker position={userLocation} icon={mapIcons.user} zIndexOffset={1000}>
|
|
<Popup>
|
|
<div className="text-xs font-bold text-blue-600">Bạn đang ở đây</div>
|
|
</Popup>
|
|
</Marker>
|
|
)}
|
|
|
|
{/* Vẽ tất cả lộ trình: Vẽ các đường phụ trước, đường chính sau để hiển thị đè lên trên */}
|
|
{routes.length > 0 ? (
|
|
[...routes]
|
|
.map((r, i) => ({ data: r, index: i }))
|
|
.sort((a, b) => {
|
|
if (a.index === selectedRouteIndex) return 1;
|
|
if (b.index === selectedRouteIndex) return -1;
|
|
return 0;
|
|
})
|
|
.map(({ data, index }) => (
|
|
<Polyline
|
|
key={`polyline-${index}-${index === selectedRouteIndex ? 'active' : 'alt'}-${routeKey}`}
|
|
positions={data.geometry.coordinates.map((c: any) => [c[1], c[0]])}
|
|
color={index === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
|
|
weight={index === selectedRouteIndex ? 6 : 12}
|
|
opacity={index === selectedRouteIndex ? 1 : 0.35}
|
|
dashArray={index === selectedRouteIndex ? undefined : "15, 15"}
|
|
smoothFactor={1}
|
|
eventHandlers={{
|
|
click: (e) => {
|
|
const originalEvent = (e as any).originalEvent;
|
|
if (originalEvent) L.DomEvent.stopPropagation(originalEvent);
|
|
setRouteMenu(null);
|
|
setSelectedRouteIndex(index);
|
|
},
|
|
contextmenu: (e) => {
|
|
const originalEvent = (e as any).originalEvent;
|
|
if (originalEvent) {
|
|
L.DomEvent.stopPropagation(originalEvent);
|
|
L.DomEvent.preventDefault(originalEvent);
|
|
// Đánh dấu để MapContextMenu biết đã có Layer xử lý
|
|
(originalEvent as any)._routeTriggered = true;
|
|
}
|
|
|
|
setRouteMenu({ x: (e as any).containerPoint.x, y: (e as any).containerPoint.y, index });
|
|
},
|
|
mouseover: (e) => {
|
|
if (index !== selectedRouteIndex) {
|
|
(e.target as L.Polyline).setStyle({ opacity: 0.7, weight: 14, color: '#64748b' });
|
|
}
|
|
},
|
|
mouseout: (e) => {
|
|
if (index !== selectedRouteIndex) {
|
|
(e.target as L.Polyline).setStyle({ opacity: 0.35, weight: 12, color: '#94a3b8' });
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
))
|
|
) : allLocations.length > 1 && (
|
|
/* Vẽ đường thẳng nét đứt nếu không lấy được dữ liệu lộ trình thực tế */
|
|
<Polyline
|
|
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
|
|
color="#3b82f6"
|
|
weight={3}
|
|
dashArray="5, 10"
|
|
smoothFactor={1.5}
|
|
/>
|
|
)}
|
|
|
|
<MarkerClusterGroup key={`cluster-group-${allLocations.length}`} chunkedLoading>
|
|
{allLocations.map((loc: any, index: number) => {
|
|
const isStart = startPoint && startPoint.id === loc.id;
|
|
const isEnd = endPoint && endPoint.id === loc.id;
|
|
|
|
// Lấy icon tương ứng từ mapIcons memoized
|
|
const icon = isStart ? mapIcons.start : isEnd ? mapIcons.end : mapIcons.visit;
|
|
|
|
// Tính quãng đường từ điểm trước đó (A -> B) để hiển thị tại điểm B
|
|
const prevLoc = index > 0 ? allLocations[index - 1] : null;
|
|
const distanceToPrev = prevLoc
|
|
? calculateDistance(prevLoc.latitude, prevLoc.longitude, loc.latitude, loc.longitude).toFixed(1)
|
|
: null;
|
|
const drivingDist = index > 0 ? segmentDistances[index - 1] : null;
|
|
|
|
return (
|
|
<Marker key={`marker-${loc.id}-${isStart ? 'start' : isEnd ? 'end' : 'visit'}`} position={[loc.latitude, loc.longitude]} icon={icon}>
|
|
<Popup>
|
|
<div className="p-1">
|
|
<div className="font-bold text-gray-900 leading-tight mb-0.5">{loc.name}</div>
|
|
<div className="text-[10px] text-gray-400 mb-2 uppercase tracking-widest">{loc.type}</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<button
|
|
onClick={() => handleQuickNote(loc.name)}
|
|
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-black transition-all border border-amber-100"
|
|
>
|
|
<FileText className="w-3 h-3" />
|
|
GHI CHÚ NHANH
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setCommentLocationId(loc.id);
|
|
setCommentLocationName(loc.name);
|
|
setIsCommentModalOpen(true);
|
|
}}
|
|
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg text-[10px] font-black transition-all border border-blue-100"
|
|
>
|
|
<MessageSquare className="w-3 h-3" />
|
|
BÌNH LUẬN {loc._count?.comments > 0 && `(${loc._count.comments})`}
|
|
</button>
|
|
</div>
|
|
|
|
{(drivingDist || (distanceToPrev && distanceToPrev !== "0.0")) && (
|
|
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center gap-2 animate-in fade-in slide-in-from-bottom-1">
|
|
<div className="p-1.5 bg-blue-50 rounded-lg">
|
|
<Navigation className="w-3 h-3 text-blue-600 rotate-45" />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-[9px] font-black text-blue-400 uppercase leading-none tracking-tighter mb-0.5">
|
|
{drivingDist
|
|
? `${travelMode === 'driving' ? 'Đường ô tô' : travelMode === 'bike' ? 'Đường xe máy' : 'Đường đi bộ'} từ điểm trước`
|
|
: 'Khoảng cách chim bay'}
|
|
</span>
|
|
<span className="text-xs font-black text-blue-700">{drivingDist ? drivingDist.toFixed(1) : distanceToPrev} km</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Popup>
|
|
</Marker>
|
|
);
|
|
})}
|
|
</MarkerClusterGroup>
|
|
</MapContainer>
|
|
|
|
{/* Menu ngữ cảnh khi click chuột phải vào con đường */}
|
|
{routeMenu && (
|
|
<div
|
|
ref={routeMenuRef}
|
|
className="absolute z-[2001] bg-white rounded-2xl shadow-2xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
|
|
style={{ top: routeMenu.y, left: routeMenu.x }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
setSelectedRouteIndex(routeMenu.index);
|
|
setRouteMenu(null);
|
|
notify({
|
|
title: 'Đã chọn đường đi',
|
|
message: `Hệ thống đã chuyển sang Lựa chọn ${routeMenu.index + 1}`,
|
|
type: 'success'
|
|
});
|
|
}}
|
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 text-sm font-bold text-blue-600 flex items-center gap-2 transition-colors rounded-xl"
|
|
>
|
|
<Navigation className="w-4 h-4 rotate-45" /> Đi đường này
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Overlay điều khiển trên bản đồ */}
|
|
<div className="absolute top-3 left-3 z-[1001] flex flex-col gap-1.5">
|
|
<button
|
|
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
|
|
className="w-9 h-9 bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-white text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center shrink-0"
|
|
>
|
|
{isMapControlsOpen ? (
|
|
<X className="w-4 h-4 text-gray-400" />
|
|
) : (
|
|
travelMode === 'driving' ? <Car className="w-4 h-4" /> :
|
|
travelMode === 'bike' ? <Bike className="w-4 h-4" /> :
|
|
<Footprints className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
|
|
{isMapControlsOpen && (
|
|
<div className="bg-white/90 backdrop-blur-md p-1 rounded-xl shadow-xl border border-white flex flex-col gap-1 animate-in slide-in-from-top-2 duration-300 items-center">
|
|
<button
|
|
onClick={() => { setTravelMode('driving'); setIsMapControlsOpen(false); }}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${travelMode === 'driving' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
|
title="Ô tô"
|
|
>
|
|
<Car className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => { setTravelMode('bike'); setIsMapControlsOpen(false); }}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${travelMode === 'bike' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
|
title="Xe máy / Xe đạp"
|
|
>
|
|
<Bike className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${travelMode === 'foot' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
|
title="Đi bộ"
|
|
>
|
|
<Footprints className="w-3.5 h-3.5" />
|
|
</button>
|
|
|
|
{/* Nút La bàn / Xoay bản đồ */}
|
|
<button
|
|
onClick={() => {
|
|
const newMode = !isHeadingMode;
|
|
setIsHeadingMode(newMode);
|
|
if (!newMode) setMapRotation(0);
|
|
setIsMapControlsOpen(false);
|
|
}}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border-t border-gray-100 mt-0.5 ${isHeadingMode ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
|
title={isHeadingMode ? "Khóa hướng Bắc" : "Xoay theo hướng nhìn"}
|
|
>
|
|
<Compass className="w-3.5 h-3.5 transition-transform duration-300" style={{ transform: `rotate(${mapRotation}deg)` }} />
|
|
</button>
|
|
|
|
{/* Nút Tìm tôi */}
|
|
<button
|
|
onClick={() => { setLocateTrigger(prev => prev + 1); setIsMapControlsOpen(false); }}
|
|
disabled={!userLocation}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border-t border-gray-100 mt-0.5 ${!userLocation ? 'opacity-30 cursor-not-allowed' : 'text-blue-600 hover:bg-blue-50'}`}
|
|
title="Vị trí của tôi"
|
|
>
|
|
<LocateFixed className="w-3.5 h-3.5" />
|
|
</button>
|
|
|
|
{/* Danh sách lộ trình rút gọn */}
|
|
{routes.length > 0 && (
|
|
<div className="pt-1 border-t border-gray-100 flex flex-col gap-1">
|
|
<div className="flex flex-col gap-1 max-h-[160px] overflow-y-auto pr-1 custom-scrollbar">
|
|
{routes.map((route, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => { setSelectedRouteIndex(idx); setIsMapControlsOpen(false); }}
|
|
title={`${(route.distance / 1000).toFixed(1)} km - ${Math.round(route.duration / 60)}p`}
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border ${
|
|
selectedRouteIndex === idx
|
|
? 'bg-blue-600 text-white border-blue-600 shadow-sm'
|
|
: 'bg-gray-50 text-gray-600 border-gray-100'
|
|
}`}
|
|
>
|
|
<span className="text-[10px] font-bold">{idx + 1}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Chỉ báo đang tìm đường */}
|
|
{isRoutingLoading && (
|
|
<div className="bg-white/90 backdrop-blur-md px-3 py-2 rounded-xl shadow-lg border border-white flex items-center gap-2 animate-pulse animate-in slide-in-from-left-2">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-600" />
|
|
<span className="text-[10px] font-black text-gray-500 uppercase tracking-tighter">Đang tìm đường tối ưu...</span>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'expense' && (
|
|
<div className="animate-in fade-in slide-in-from-bottom-4">
|
|
<ExpenseManager />
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'photo' && (
|
|
<div className="animate-in fade-in">
|
|
{/* Main container for the new layout */}
|
|
<div className="flex flex-col md:flex-row gap-4">
|
|
{/* Left Column: Leg List */}
|
|
<div className="md:w-1/4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex-shrink-0">
|
|
<h3 className="text-sm font-bold text-gray-800 mb-3">Chặng của Tour</h3>
|
|
<div className="space-y-2">
|
|
<button
|
|
onClick={() => {
|
|
setSelectedLegIdForPhoto('all');
|
|
setSelectedPhotoForDisplay(null); // Reset selected photo when changing leg
|
|
}}
|
|
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
|
|
selectedLegIdForPhoto === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Tất cả ảnh
|
|
</button>
|
|
{legs.map(leg => (
|
|
<button
|
|
key={leg.id}
|
|
onClick={() => {
|
|
setSelectedLegIdForPhoto(leg.id);
|
|
setSelectedPhotoForDisplay(null); // Reset selected photo when changing leg
|
|
}}
|
|
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
|
|
selectedLegIdForPhoto === leg.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Chặng {leg.sequence}: {leg.note || 'Không ghi chú'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column: Large Photo Display */}
|
|
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
|
|
{selectedPhotoForDisplay ? (
|
|
<div className="relative w-full h-full flex flex-col items-center justify-center">
|
|
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
|
|
<img
|
|
src={selectedPhotoForDisplay.imageUrl}
|
|
alt="Selected Tour Photo"
|
|
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
|
|
onClick={() => setIsFullscreen(true)}
|
|
/>
|
|
|
|
{/* Overlays (Only show when not editing) */}
|
|
{!isEditingPhoto && (
|
|
<>
|
|
{/* Like (Heart) button overlay */}
|
|
<button
|
|
onClick={handleToggleLikePhoto}
|
|
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-black/60 hover:bg-black/75 border border-white/10 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
|
|
title={isPhotoLiked ? "Bỏ thích" : "Thích"}
|
|
>
|
|
<Heart className={`w-4 h-4 transition-colors ${
|
|
isPhotoLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
|
|
}`} />
|
|
<span>{photoLikeCount}</span>
|
|
</button>
|
|
|
|
{/* Delete button overlay (if owner) */}
|
|
{currentUserId === selectedPhotoForDisplay.uploaderId && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeletePhoto(selectedPhotoForDisplay.id);
|
|
}}
|
|
className="absolute top-4 right-4 z-10 p-2 bg-black/60 hover:bg-red-650 border border-white/10 text-white rounded-full transition-all active:scale-95 backdrop-blur-md"
|
|
title="Xóa ảnh này"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Bottom metadata details gradient panel overlay */}
|
|
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
|
|
<div className="flex justify-between items-start gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
{selectedPhotoForDisplay.metadata?.title ? (
|
|
<h3 className="text-base font-extrabold text-white break-words drop-shadow-md">
|
|
{selectedPhotoForDisplay.metadata.title}
|
|
</h3>
|
|
) : (
|
|
<span className="text-xs text-gray-350 italic block mb-1 drop-shadow-md">Chưa có tiêu đề</span>
|
|
)}
|
|
{selectedPhotoForDisplay.metadata?.description ? (
|
|
<p className="text-xs text-gray-250 leading-relaxed mt-1 break-words drop-shadow-sm max-h-16 overflow-y-auto no-scrollbar">
|
|
{selectedPhotoForDisplay.metadata.description}
|
|
</p>
|
|
) : (
|
|
<span className="text-[11px] text-gray-350 italic block mt-1 drop-shadow-sm">Chưa có mô tả</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edit Button Overlay */}
|
|
{((!isPublicView && currentUser?.isAdmin) || (currentUserId && currentUserId === selectedPhotoForDisplay.uploaderId)) && (
|
|
<button
|
|
onClick={() => setIsEditingPhoto(true)}
|
|
className="p-2 bg-white/10 hover:bg-white/20 border border-white/15 rounded-xl text-white hover:text-gray-200 transition-all shrink-0 backdrop-blur-sm"
|
|
title="Chỉnh sửa thông tin"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Additional metadata info inside bottom overlay */}
|
|
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-3 text-xs text-gray-200">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2 text-gray-300 text-[10px] font-bold uppercase tracking-wider">
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
|
</div>
|
|
<div className="flex items-center gap-2 font-bold" title={selectedPhotoForDisplay.metadata?.lat && selectedPhotoForDisplay.metadata?.lng ? `${selectedPhotoForDisplay.metadata.lat.toFixed(6)}, ${selectedPhotoForDisplay.metadata.lng.toFixed(6)}` : ''}>
|
|
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
|
Địa điểm: {resolvedAddress}
|
|
</div>
|
|
<div className="text-[10px] text-gray-400">
|
|
Tải lên bởi: {selectedPhotoForDisplay.uploader?.name || 'Thành viên'}
|
|
</div>
|
|
</div>
|
|
|
|
{(currentUser?.isAdmin || currentUserId === selectedPhotoForDisplay.uploaderId) && selectedPhotoForDisplay.originalUrl && (
|
|
<a
|
|
href={selectedPhotoForDisplay.originalUrl}
|
|
download
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold uppercase tracking-widest text-[9px] transition-all shadow-md active:scale-95 shrink-0"
|
|
>
|
|
<Download className="w-3.5 h-3.5 animate-pulse" /> Tải ảnh gốc
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{isEditingPhoto && (
|
|
<div className="mt-6 w-full flex flex-col gap-4 px-2">
|
|
<div className="space-y-3 bg-gray-50 border border-gray-150 p-4 rounded-2xl w-full text-left">
|
|
<h4 className="text-xs font-black uppercase tracking-wider text-blue-600">Chỉnh sửa thông tin ảnh</h4>
|
|
|
|
<div className="space-y-2">
|
|
<div>
|
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Tiêu đề</label>
|
|
<input
|
|
type="text"
|
|
value={editPhotoTitle}
|
|
onChange={(e) => setEditPhotoTitle(e.target.value)}
|
|
placeholder="Nhập tiêu đề..."
|
|
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Mô tả</label>
|
|
<textarea
|
|
value={editPhotoDescription}
|
|
onChange={(e) => setEditPhotoDescription(e.target.value)}
|
|
placeholder="Nhập mô tả..."
|
|
rows={2}
|
|
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Vĩ độ</label>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
value={editPhotoLat}
|
|
onChange={(e) => setEditPhotoLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
|
placeholder="Vĩ độ..."
|
|
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Kinh độ</label>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
value={editPhotoLng}
|
|
onChange={(e) => setEditPhotoLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
|
placeholder="Kinh độ..."
|
|
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-start">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsMapOpen(true)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 hover:text-gray-900 rounded-xl text-[10px] font-bold transition-all"
|
|
>
|
|
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
|
Chọn trên bản đồ
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<button
|
|
onClick={() => setIsEditingPhoto(false)}
|
|
disabled={isSavingPhotoEdit}
|
|
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-xs font-bold transition-all"
|
|
>
|
|
Hủy
|
|
</button>
|
|
<button
|
|
onClick={handleSavePhotoEdit}
|
|
disabled={isSavingPhotoEdit}
|
|
className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
|
|
>
|
|
{isSavingPhotoEdit ? (
|
|
<>
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
Đang lưu...
|
|
</>
|
|
) : (
|
|
'Lưu lại'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-gray-400">
|
|
<ImageIcon className="w-16 h-16 mx-auto mb-4" />
|
|
<p className="text-lg font-medium">Chọn một ảnh để xem chi tiết</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Row: Thumbnails */}
|
|
<div className="mt-4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4">
|
|
<h3 className="text-sm font-bold text-gray-800 mb-3">
|
|
{selectedLegIdForPhoto === 'all' ? 'Tất cả ảnh' : `Ảnh của Chặng ${legs.find(l => l.id === selectedLegIdForPhoto)?.sequence || ''}`}
|
|
</h3>
|
|
{filteredPhotos.length > 0 ? (
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 overflow-x-auto pb-2">
|
|
{filteredPhotos.map((photo: any) => (
|
|
<div
|
|
key={photo.id}
|
|
onClick={() => setSelectedPhotoForDisplay(photo)}
|
|
className={`aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border-2 ${
|
|
selectedPhotoForDisplay?.id === photo.id ? 'border-blue-500' : 'border-transparent'
|
|
} hover:border-blue-300 transition-all cursor-pointer`}
|
|
>
|
|
<img
|
|
src={photo.imageUrl}
|
|
alt="Thumbnail"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="py-10 text-center text-gray-400">
|
|
<ImageIcon className="w-12 h-12 mx-auto mb-3" />
|
|
<p className="text-md font-medium">Chưa có ảnh nào cho chặng này.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'settings' && !isPublicView && ( // Hide settings tab in public view
|
|
<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>
|
|
{isOwner && <div className="flex gap-2">
|
|
<button
|
|
disabled={joinRequestActionId === req.id}
|
|
onClick={async () => {
|
|
if (!currentTour) return;
|
|
const isConfirmed = await confirm({
|
|
title: 'Chấp nhận yêu cầu',
|
|
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
|
|
});
|
|
if (isConfirmed) {
|
|
setJoinRequestActionId(req.id);
|
|
try {
|
|
await acceptJoinRequest(currentTour.id, req.id);
|
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
} catch (e: any) {
|
|
notify({ title: 'Thông báo', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
|
|
} finally {
|
|
setJoinRequestActionId(null);
|
|
}
|
|
}
|
|
}}
|
|
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;
|
|
const isConfirmed = await confirm({
|
|
title: 'Từ chối yêu cầu',
|
|
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
|
|
});
|
|
if (isConfirmed) {
|
|
setJoinRequestActionId(req.id);
|
|
try {
|
|
await rejectJoinRequest(currentTour.id, req.id);
|
|
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
|
} catch (e: any) {
|
|
notify({ title: 'Thông báo', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
|
|
} finally {
|
|
setJoinRequestActionId(null);
|
|
}
|
|
}
|
|
}}
|
|
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>
|
|
|
|
{/* Emergency Sharing Settings */}
|
|
<div className="p-6 bg-white dark:bg-slate-900 rounded-3xl border border-dashed border-rose-200 dark:border-rose-950/40 shadow-sm animate-in zoom-in-95">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xl">🚨</span>
|
|
<div>
|
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{t('emergencyShare')}</h3>
|
|
<p className="text-xs text-gray-500 dark:text-slate-400">{t('emergencyShareTooltip')}</p>
|
|
</div>
|
|
</div>
|
|
{shareStatus && (
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={shareStatus.isEnabled}
|
|
onChange={(e) => handleToggleShare(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-gray-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-rose-500"></div>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{shareStatus?.isEnabled && (
|
|
<div className="mt-4 bg-rose-50 dark:bg-rose-950/20 p-4 rounded-2xl border border-rose-100 dark:border-rose-950/30 flex flex-col gap-2">
|
|
<div className="text-xs font-bold text-rose-700 dark:text-rose-400 uppercase tracking-widest">Đường dẫn chia sẻ khẩn cấp:</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
readOnly
|
|
value={`${window.location.origin}/journey/${shareStatus.token}`}
|
|
className="flex-1 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-xl px-3 py-2 text-xs text-slate-800 dark:text-slate-100 select-all"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(`${window.location.origin}/journey/${shareStatus.token}`);
|
|
notify({ title: 'Thành công', message: 'Đã sao chép liên kết!', type: 'success' });
|
|
}}
|
|
className="bg-rose-600 hover:bg-rose-700 text-white font-bold px-3 py-2 rounded-xl text-xs transition-all active:scale-95 shrink-0"
|
|
>
|
|
{t('copyShareLink')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{canEdit && (
|
|
<div className="p-6 bg-white rounded-3xl border border-gray-100 shadow-sm animate-in zoom-in-95">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<Settings className="w-6 h-6 text-blue-500" />
|
|
<h3 className="text-lg font-bold text-gray-900">Thông tin cơ bản</h3>
|
|
</div>
|
|
|
|
<div className="space-y-4 mb-8">
|
|
{isOwner && (
|
|
<>
|
|
<div>
|
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Tiêu đề Tour</label>
|
|
<input
|
|
type="text"
|
|
value={titleInput}
|
|
onChange={(e) => setTitleInput(e.target.value)}
|
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold"
|
|
placeholder="Nhập tên chuyến đi..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Mô tả chuyến đi</label>
|
|
<textarea
|
|
value={descriptionInput}
|
|
onChange={(e) => setDescriptionInput(e.target.value)}
|
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[100px] resize-none"
|
|
placeholder="Viết vài dòng giới thiệu về hành trình này..."
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Users className="w-6 h-6 text-purple-500" />
|
|
<h3 className="text-md font-bold text-gray-800">Số lượng người tham gia</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div>
|
|
<label htmlFor="adultCount" className="block text-sm font-medium text-gray-700 mb-1">Số lượng người lớn</label>
|
|
<input
|
|
type="number"
|
|
id="adultCount"
|
|
value={adultCountInput}
|
|
onChange={(e) => setAdultCountInput(Number(e.target.value))}
|
|
min="0"
|
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="childCount" className="block text-sm font-medium text-gray-700 mb-1">Số lượng trẻ em</label>
|
|
<input
|
|
type="number"
|
|
id="childCount"
|
|
value={childCountInput}
|
|
onChange={(e) => setChildCountInput(Number(e.target.value))}
|
|
min="0"
|
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="childDiscount" className="block text-sm font-medium text-gray-700 mb-1">Giảm giá trẻ em (%)</label>
|
|
<input
|
|
type="number"
|
|
id="childDiscount"
|
|
value={childDiscountInput}
|
|
onChange={(e) => setChildDiscountInput(Number(e.target.value))}
|
|
min="0"
|
|
max="100"
|
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1 flex items-center gap-2">
|
|
<TagIcon className="w-3 h-3" /> Phân loại Tour
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{availableTags.map(tag => (
|
|
<button
|
|
key={tag}
|
|
type="button"
|
|
onClick={() => setTagsInput(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])}
|
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all border ${
|
|
tagsInput.includes(tag)
|
|
? 'bg-blue-600 text-white border-blue-600'
|
|
: 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
|
|
}`}
|
|
>
|
|
{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2 mt-3">
|
|
<input
|
|
type="text"
|
|
value={customTag}
|
|
onChange={(e) => setCustomTag(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), setTagsInput(prev => customTag.trim() && !prev.includes(customTag.trim()) ? [...prev, customTag.trim()] : prev), setCustomTag(''))}
|
|
placeholder="Thêm nhãn tùy chỉnh..."
|
|
className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => { if (customTag.trim() && !tagsInput.includes(customTag.trim())) { setTagsInput([...tagsInput, customTag.trim()]); setCustomTag(''); } }}
|
|
className="px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all border border-blue-100"
|
|
>
|
|
Thêm
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleUpdateTourInfo}
|
|
className="w-full mt-6 px-4 py-4 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-100 active:scale-95"
|
|
>
|
|
Lưu thay đổi
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Danger Zone - Khu vực dành cho các hành động quan trọng */}
|
|
<div className="p-6 bg-red-50 rounded-3xl border border-red-100 animate-in zoom-in-95">
|
|
<div className="flex items-center gap-3 mb-4 text-red-600">
|
|
<Trash2 className="w-6 h-6" />
|
|
<h3 className="text-lg font-bold">Vùng nguy hiểm</h3>
|
|
</div>
|
|
<p className="text-sm text-red-500 mb-6 font-medium">Một khi đã xóa, bạn sẽ không thể khôi phục lại dữ liệu của hành trình này.</p>
|
|
<button
|
|
onClick={handleDeleteTour}
|
|
className="w-full py-4 bg-red-600 hover:bg-red-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-red-100 active:scale-95"
|
|
>
|
|
Xóa Tour vĩnh viễn
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{activeTab === 'members' && canManage && currentTour && (
|
|
<MembersTab
|
|
tourId={currentTour.id}
|
|
participants={currentTour.participants || []}
|
|
joinRequests={joinRequests}
|
|
userRole={userRole}
|
|
canManage={canManage}
|
|
isOwner={isOwner}
|
|
onRemoveMember={(memberIdOrUserId: string) => removeMember(currentTour.id, memberIdOrUserId)}
|
|
onRefresh={() => fetchTour(currentTour.id)}
|
|
onOpenAddMember={() => setIsAddMemberOpen(true)}
|
|
/>
|
|
)}
|
|
{activeTab === 'chat' && currentTour && (
|
|
<div className="animate-in fade-in slide-in-from-bottom-2">
|
|
<TourChat tourId={currentTour.id} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Floating Action Button (Mobile) */}
|
|
{((activeTab === 'plan' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (
|
|
<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);
|
|
setIsStartPointAction(false);
|
|
setIsEndPointAction(false);
|
|
if (activeTab === 'plan') setIsAddLocationOpen(true);
|
|
if (activeTab === 'photo' && canUploadPhoto) setIsAddPhotoOpen(true);
|
|
}}
|
|
className="bg-blue-600 text-white px-4 py-2.5 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold text-sm">
|
|
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Fullscreen Map Overlay - Hiển thị khi click vào nút trạng thái/chỉ đường */}
|
|
{isMapFullscreen && (
|
|
<div className="fixed inset-0 z-[5000] bg-white animate-in fade-in slide-in-from-bottom-16 duration-500 overflow-hidden">
|
|
{/* Nút X để quay lại */}
|
|
<button
|
|
onClick={() => setIsMapFullscreen(false)}
|
|
className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-4 z-[1002] w-11 h-11 bg-white/90 backdrop-blur-md rounded-full shadow-2xl flex items-center justify-center border border-white/20 hover:bg-white transition-all active:scale-95 group"
|
|
title="Đóng bản đồ"
|
|
>
|
|
<X className="w-6 h-6 text-gray-800 group-hover:rotate-90 transition-transform duration-300" />
|
|
</button>
|
|
|
|
{/* Transparent Top Bar Label - Glassmorphism style */}
|
|
{selectedRouteInfo && drivingRoute.length > 0 && (
|
|
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-1/2 -translate-x-1/2 z-[1001] bg-white/20 backdrop-blur-lg px-6 py-2.5 rounded-full border border-white/30 flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500 shadow-xl">
|
|
<div className="flex items-center gap-2">
|
|
<Navigation className="w-4 h-4 text-blue-600 rotate-45 fill-blue-600" />
|
|
<span className="text-[11px] font-black text-gray-700 uppercase tracking-widest">{selectedRouteInfo.label}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-sm font-black text-blue-700 whitespace-nowrap">
|
|
<span>{selectedRouteInfo.distance} km</span>
|
|
<span className="text-gray-400 opacity-40">•</span>
|
|
<span>{selectedRouteInfo.duration}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<MapContainer
|
|
center={mapCenter}
|
|
zoom={mapZoom}
|
|
className="h-full w-full"
|
|
preferCanvas={true}
|
|
attributionControl={false}
|
|
>
|
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
|
|
<MapTourBounds locations={allLocations} />
|
|
<MapRotationHandler rotation={mapRotation} />
|
|
<RecenterUser position={userLocation} trigger={locateTrigger} />
|
|
|
|
{userLocation && (
|
|
<Marker position={userLocation} icon={mapIcons.user} />
|
|
)}
|
|
|
|
{routes.length > 0 ? (
|
|
routes.map((r, i) => (
|
|
<Polyline
|
|
key={`fs-poly-${i}`}
|
|
positions={r.geometry.coordinates.map((c: any) => [c[1], c[0]])}
|
|
color={i === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
|
|
weight={i === selectedRouteIndex ? 6 : 4}
|
|
opacity={i === selectedRouteIndex ? 1 : 0.4}
|
|
/>
|
|
))
|
|
) : allLocations.length > 1 && (
|
|
<Polyline positions={allLocations.map(l => [l.latitude, l.longitude]) as any} color="#3b82f6" weight={3} dashArray="5, 10" />
|
|
)}
|
|
|
|
{allLocations.map((loc: any, index: number) => {
|
|
const isStart = startPoint && startPoint.id === loc.id;
|
|
const isEnd = endPoint && endPoint.id === loc.id;
|
|
return (
|
|
<Marker key={`fs-marker-${loc.id}`} position={[loc.latitude, loc.longitude]} icon={isStart ? mapIcons.start : isEnd ? mapIcons.end : mapIcons.visit}>
|
|
<Popup>
|
|
<div className="font-bold">{loc.name}</div>
|
|
</Popup>
|
|
</Marker>
|
|
);
|
|
})}
|
|
</MapContainer>
|
|
|
|
{/* Overlay điều khiển trên bản đồ toàn màn hình */}
|
|
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 z-[1001] flex flex-col gap-2">
|
|
<button
|
|
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
|
|
className="w-11 h-11 bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white text-blue-600 flex items-center justify-center transition-all active:scale-95"
|
|
>
|
|
{travelMode === 'driving' ? <Car className="w-5 h-5" /> : travelMode === 'bike' ? <Bike className="w-5 h-5" /> : <Footprints className="w-5 h-5" />}
|
|
</button>
|
|
|
|
{isMapControlsOpen && (
|
|
<div className="bg-white/90 backdrop-blur-md p-1.5 rounded-2xl shadow-xl border border-white flex flex-col gap-1.5 animate-in slide-in-from-top-2">
|
|
<button onClick={() => { setTravelMode('driving'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'driving' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Car className="w-4 h-4" /></button>
|
|
<button onClick={() => { setTravelMode('bike'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'bike' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Bike className="w-4 h-4" /></button>
|
|
<button onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'foot' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Footprints className="w-4 h-4" /></button>
|
|
<button
|
|
onClick={() => {
|
|
const newMode = !isHeadingMode;
|
|
setIsHeadingMode(newMode);
|
|
if (!newMode) setMapRotation(0);
|
|
setIsMapControlsOpen(false);
|
|
}}
|
|
className={`w-9 h-9 flex items-center justify-center rounded-xl border-t border-gray-100 transition-all ${isHeadingMode ? 'bg-blue-600 text-white' : 'text-gray-500'}`}
|
|
>
|
|
<Compass className="w-4 h-4" style={{ transform: `rotate(${mapRotation}deg)` }} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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}
|
|
isPublicView={isPublicView} // Pass isPublicView
|
|
/>
|
|
)}
|
|
|
|
{/* Add Location Modal */}
|
|
{currentTour && (
|
|
<AddLocationModal
|
|
isOpen={isAddLocationOpen}
|
|
onClose={() => setIsAddLocationOpen(false)}
|
|
initialLegId={targetLegId || undefined}
|
|
isStartPoint={isStartPointAction}
|
|
isEndPoint={isEndPointAction}
|
|
editingLocation={editingLocation}
|
|
tourId={currentTour.id}
|
|
isPublicView={isPublicView} // Pass isPublicView
|
|
onSuccess={() => {
|
|
console.log("TourDetailPage: AddLocationModal onSuccess -> Re-fetching tour data.");
|
|
fetchTour(tourId); // Re-fetch tour data after adding/editing location
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Add Photo Modal */}
|
|
{currentTour && (
|
|
<AddPhotoModal
|
|
isOpen={isAddPhotoOpen}
|
|
onClose={() => setIsAddPhotoOpen(false)}
|
|
tourId={currentTour.id}
|
|
onSuccess={() => fetchTour(currentTour.id)}
|
|
/>
|
|
)}
|
|
|
|
{/* Quick Note Modal */}
|
|
{quickNoteLocName && (
|
|
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={() => setQuickNoteLocName(null)} />
|
|
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h3 className="text-xl font-black text-gray-900">Ghi chú nhanh</h3>
|
|
<button onClick={() => setQuickNoteLocName(null)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
|
<X className="w-5 h-5 text-gray-400" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<p className="text-sm font-bold text-gray-700">
|
|
📍 Địa điểm: <span className="text-blue-600">{quickNoteLocName}</span>
|
|
</p>
|
|
|
|
<div>
|
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Nội dung ghi chú</label>
|
|
<textarea
|
|
value={quickNoteInput}
|
|
onChange={(e) => setQuickNoteInput(e.target.value)}
|
|
placeholder="Nhập nội dung ghi chú nhanh..."
|
|
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 mt-8">
|
|
<button
|
|
onClick={() => setQuickNoteLocName(null)}
|
|
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
|
|
>
|
|
Hủy
|
|
</button>
|
|
<button
|
|
onClick={submitQuickNote}
|
|
disabled={!quickNoteInput.trim()}
|
|
className="py-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
|
|
>
|
|
Lưu
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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) || selectedMember.displayName?.charAt(0) || '?'}
|
|
</div>
|
|
<div>
|
|
<div className="text-base font-bold text-gray-900">{selectedMember.user?.name || selectedMember.displayName || 'Chưa đặt tên'}</div>
|
|
<div className="text-xs text-gray-500">{selectedMember.user?.email || 'Thành viên ngoài hệ thống'}</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>
|
|
|
|
{!isPublicView && currentUser && selectedMember && (selectedMember.userId || selectedMember.user?.id) && currentUser.id !== (selectedMember.userId || selectedMember.user?.id) && (selectedMember.role === 'OWNER' || selectedMember.role === 'MANAGER') && (
|
|
<button
|
|
onClick={() => {
|
|
setIsMemberDetailOpen(false);
|
|
setRatingTargetUser(selectedMember);
|
|
setIsRatingModalOpen(true);
|
|
}}
|
|
className="px-3 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-xl text-sm font-bold flex items-center gap-1 shadow-md transition-all active:scale-95"
|
|
>
|
|
⭐ {t('rateOrganizer') || 'Đánh giá'}
|
|
</button>
|
|
)}
|
|
|
|
{canEdit && selectedMember.role !== 'OWNER' && (
|
|
<button
|
|
onClick={async () => {
|
|
if (!currentTour || !selectedMember) return;
|
|
try {
|
|
await removeMember(currentTour.id, selectedMember.userId || selectedMember.id);
|
|
setIsMemberDetailOpen(false);
|
|
} catch (e) {
|
|
notify({ title: 'Thông báo', message: 'Không thể xóa thành viên', type: '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>
|
|
)}
|
|
|
|
{/* Organizer Rating Modal */}
|
|
{isRatingModalOpen && ratingTargetUser && (
|
|
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in" onClick={() => setIsRatingModalOpen(false)} />
|
|
<div className="relative w-full max-w-lg bg-white dark:bg-slate-900 rounded-[32px] shadow-2xl p-8 max-h-[90vh] overflow-y-auto flex flex-col animate-in zoom-in-95 duration-250 text-slate-800 dark:text-slate-100 border dark:border-slate-800">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h3 className="text-xl font-black text-slate-950 dark:text-white flex items-center gap-2">
|
|
⭐ {t('rateTitle') || 'Đánh giá Người tạo Tour'}
|
|
</h3>
|
|
<button onClick={() => setIsRatingModalOpen(false)} className="p-2 hover:bg-gray-150 dark:hover:bg-slate-850 rounded-full transition-colors">
|
|
<X className="w-5 h-5 text-gray-400" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 mb-6 bg-slate-50 dark:bg-slate-850 p-4 rounded-2xl border border-slate-100 dark:border-slate-800">
|
|
<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 overflow-hidden">
|
|
{ratingTargetUser.avatar ? (
|
|
<img src={ratingTargetUser.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
|
) : (
|
|
<span>{ratingTargetUser.user?.name?.charAt(0) || ratingTargetUser.displayName?.charAt(0) || '?'}</span>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-black dark:text-white">{ratingTargetUser.user?.name || ratingTargetUser.displayName}</div>
|
|
<div className="text-xs text-gray-450 uppercase tracking-widest font-bold">{ratingTargetUser.role}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 flex-1">
|
|
{[
|
|
{ key: 'honesty', label: t('honesty') || 'Trung thực' },
|
|
{ key: 'transparency', label: t('transparency') || 'Minh bạch' },
|
|
{ key: 'enthusiasm', label: t('enthusiasm') || 'Nhiệt tình' },
|
|
{ key: 'cheerfulness', label: t('cheerfulness') || 'Vui vẻ' },
|
|
{ key: 'seriousness', label: t('seriousness') || 'Nghiêm túc' },
|
|
{ key: 'planning', label: t('planning') || 'Có kế hoạch' },
|
|
{ key: 'survival', label: t('survival') || 'Kỹ năng sinh tồn' }
|
|
].map((c) => (
|
|
<div key={c.key} className="flex items-center justify-between border-b border-slate-100 dark:border-slate-850 pb-2">
|
|
<span className="text-xs font-bold text-slate-700 dark:text-slate-350">{c.label}</span>
|
|
<div className="flex gap-1">
|
|
{[1, 2, 3, 4, 5].map((star) => {
|
|
const currentVal = (ratingScores as any)[c.key];
|
|
return (
|
|
<button
|
|
key={star}
|
|
type="button"
|
|
onClick={() => setRatingScores(prev => ({ ...prev, [c.key]: star }))}
|
|
className={`w-6 h-6 text-xl transition-all active:scale-95 ${
|
|
star <= currentVal ? 'text-amber-450' : 'text-gray-300 dark:text-gray-700 hover:text-amber-300'
|
|
}`}
|
|
>
|
|
★
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<div className="mt-4">
|
|
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Nhận xét khác</label>
|
|
<textarea
|
|
value={ratingComment}
|
|
onChange={(e) => setRatingComment(e.target.value)}
|
|
placeholder={t('rateCommentPlaceholder') || 'Nhập ý kiến đánh giá khác...'}
|
|
rows={3}
|
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-slate-850 border border-gray-200 dark:border-slate-800 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-xs resize-none text-slate-900 dark:text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 mt-6">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsRatingModalOpen(false)}
|
|
className="py-3.5 bg-gray-100 dark:bg-slate-800 hover:bg-gray-250 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-300 font-bold rounded-2xl transition-all active:scale-95 text-xs"
|
|
>
|
|
{t('cancel') || 'Hủy'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmitRating}
|
|
disabled={isSubmittingRating}
|
|
className="py-3.5 bg-amber-500 hover:bg-amber-600 disabled:opacity-50 text-white font-bold rounded-2xl shadow-lg transition-all active:scale-95 text-xs flex items-center justify-center gap-1.5"
|
|
>
|
|
{isSubmittingRating ? (
|
|
<Loader2 className="w-4.5 h-4.5 animate-spin" />
|
|
) : (
|
|
<>
|
|
<span>{t('save') || 'Gửi đánh giá'}</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<CommentModal
|
|
isOpen={isCommentModalOpen}
|
|
onClose={() => setIsCommentModalOpen(false)}
|
|
locationId={commentLocationId}
|
|
locationName={commentLocationName}
|
|
isPublicView={isPublicView} // Pass isPublicView
|
|
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
|
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
|
/>
|
|
<CoordinateSelectModal
|
|
isOpen={isMapOpen}
|
|
onClose={() => setIsMapOpen(false)}
|
|
initialLat={typeof editPhotoLat === 'number' ? editPhotoLat : undefined}
|
|
initialLng={typeof editPhotoLng === 'number' ? editPhotoLng : undefined}
|
|
onSelect={(lat, lng) => {
|
|
setEditPhotoLat(lat);
|
|
setEditPhotoLng(lng);
|
|
}}
|
|
/>
|
|
|
|
{isFullscreen && selectedPhotoForDisplay && (
|
|
<div
|
|
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
|
|
onClick={() => setIsFullscreen(false)}
|
|
>
|
|
<button
|
|
onClick={() => setIsFullscreen(false)}
|
|
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
|
|
>
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
<img
|
|
src={selectedPhotoForDisplay.imageUrl}
|
|
alt="Fullscreen photo"
|
|
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}; |