diff --git a/backend/uploads/members/71e900cb-2602-4b94-bc97-caf39444123d/originals/1782193414818-642900609.jpg b/backend/uploads/members/71e900cb-2602-4b94-bc97-caf39444123d/originals/1782193414818-642900609.jpg new file mode 100644 index 0000000..1e223e8 Binary files /dev/null and b/backend/uploads/members/71e900cb-2602-4b94-bc97-caf39444123d/originals/1782193414818-642900609.jpg differ diff --git a/backend/uploads/tours/1782193414818-642900609.jpg b/backend/uploads/tours/1782193414818-642900609.jpg new file mode 100644 index 0000000..f8710dc Binary files /dev/null and b/backend/uploads/tours/1782193414818-642900609.jpg differ diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 0da7adc..915cb86 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -3,10 +3,30 @@ server { server_name yotrip.labz.io.vn localhost; client_max_body_size 50M; + # JavaScript and CSS files - immutable caching + location ~* \.(?:js|css)$ { + root /usr/share/nginx/html; + expires 1y; + add_header Cache-Control "public, immutable"; + add_header Vary "Accept-Encoding" always; + access_log off; + } + + # Images and fonts - long caching + location ~* \.(?:jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ { + root /usr/share/nginx/html; + expires 1y; + add_header Cache-Control "public, max-age=31536000"; + access_log off; + } + + # Main app route - SPA fallback (only for HTML) location / { root /usr/share/nginx/html; index index.html index.htm; + # Only redirect actual routes to index.html, not assets try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; } # Proxy API requests to backend diff --git a/frontend/src/components/ItineraryTimeline.tsx b/frontend/src/components/ItineraryTimeline.tsx index ec361ad..5e9bdb9 100644 --- a/frontend/src/components/ItineraryTimeline.tsx +++ b/frontend/src/components/ItineraryTimeline.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { format, differenceInMinutes, parseISO } from 'date-fns'; -import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText, Flag } from 'lucide-react'; +import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText, Flag, ChevronDown } from 'lucide-react'; import { useTourStore } from '@/store/useTourStore'; import { useConfirm } from '@/hooks/useConfirm'; import { useNotification } from '@/hooks/useNotification'; @@ -57,7 +57,6 @@ export const ItineraryTimeline = ({ const currentTour = useTourStore(state => state.currentTour); const legs = useTourStore(state => state.legs); const userRole = useTourStore(state => state.userRole); - const optimizeRouting = useTourStore(state => state.optimizeRouting); const addLeg = useTourStore(state => state.addLeg); const updateLeg = useTourStore(state => state.updateLeg); // Removed: const { isPublicView } = useTourStore(state => state); // isPublicView is passed as a prop @@ -259,32 +258,47 @@ export const ItineraryTimeline = ({ )} -
e.stopPropagation()}> - {canEdit && ( - <> - - - - - )} - {leg.totalDistance !== undefined && ( -
+ {/* Expand/Collapse indicator button */} + +
+ + {/* Action Buttons Row - Below stage title */} +
+ {canEdit && ( + <> + + + + + )} + {leg.totalDistance !== undefined && ( +
{leg.totalDistance} km
@@ -294,22 +308,12 @@ export const ItineraryTimeline = ({
)} - {totalDwellMinutes > 0 && ( // Always show dwell time -
- - Dừng: {formatTravelTime(totalDwellMinutes)} -
- )} -
- {['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && ( - - )} {/* Only show optimize button if canEdit */} + {totalDwellMinutes > 0 && ( +
+ + Dừng: {formatTravelTime(totalDwellMinutes)} +
+ )} {/* Scrollable content body with proper z-index layering */} diff --git a/frontend/src/index.css b/frontend/src/index.css index 3b3c1a2..46328ae 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -135,9 +135,10 @@ .folder-node-wrapper { width: 100% !important; background-color: #ffffff; - margin-bottom: 1px !important; + margin-bottom: 0px !important; display: flex; flex-direction: column; + border-bottom: 1px solid #f3f4f6; } /* Individual stage card block - backwards compatibility */ diff --git a/frontend/src/pages/MemberDashboard.tsx b/frontend/src/pages/MemberDashboard.tsx index bcb64ce..4cde484 100644 --- a/frontend/src/pages/MemberDashboard.tsx +++ b/frontend/src/pages/MemberDashboard.tsx @@ -22,7 +22,8 @@ import { Loader2, Bell, BellOff, - ShieldAlert + ShieldAlert, + Camera } from 'lucide-react'; import { useTourStore } from '@/store/useTourStore'; import { useNotification } from '@/hooks/useNotification'; @@ -128,6 +129,56 @@ export const MemberDashboard: React.FC = ({ } }; + const handleEmergencyShare = async (tour: any) => { + try { + setLoadingShare(true); + // Get current location + const position = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition( + (pos) => resolve(pos.coords), + (err) => reject(err), + { enableHighAccuracy: true, timeout: 5000 } + ); + }); + + // Send location message to tour chat + const token = localStorage.getItem('token'); + const message = `📍 Vị trí hiện tại: ${position.latitude.toFixed(6)}, ${position.longitude.toFixed(6)}\n🔗 Google Maps: https://maps.google.com/?q=${position.latitude},${position.longitude}`; + + const res = await fetch(`/api/v1/tours/${tour.id}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ content: message }) + }); + + if (res.ok) { + notify({ + title: 'Thành công', + message: 'Đã gửi vị trí hiện tại cho nhóm', + type: 'success' + }); + } else { + notify({ + title: 'Lỗi', + message: 'Không thể gửi vị trí', + type: 'error' + }); + } + } catch (e) { + console.error('Error sharing location:', e); + notify({ + title: 'Lỗi', + message: 'Không thể lấy vị trí hiện tại', + type: 'error' + }); + } finally { + setLoadingShare(false); + } + }; + const handleToggleShare = async (isEnabled: boolean) => { if (!sharingTour) return; try { @@ -153,8 +204,82 @@ export const MemberDashboard: React.FC = ({ console.error(e); } }; + + const handleTakeCameraPhoto = async (tour: any) => { + if (cameraInputRef.current) { + cameraInputRef.current.click(); + // Store tour ID for later processing + (cameraInputRef.current as any).dataset.tourId = tour.id; + } + }; + + const handleCameraFileSelect = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const tourId = (event.target as any).dataset.tourId; + // Get current location if available + try { + setIsLocating(true); + const position = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition( + (pos) => resolve(pos.coords), + (err) => reject(err), + { enableHighAccuracy: true, timeout: 5000 } + ); + }); + setAttachedLocation({ latitude: position.latitude, longitude: position.longitude }); + } catch (e) { + console.log('Could not get location:', e); + } + setIsLocating(false); + + // Upload photo to tour + setIsUploading(true); + try { + const token = localStorage.getItem('token'); + const formData = new FormData(); + formData.append('images', file); + if (attachedLocation) { + formData.append('latitude', attachedLocation.latitude.toString()); + formData.append('longitude', attachedLocation.longitude.toString()); + } + + const res = await fetch(`/api/v1/tours/${tourId}/photos`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }); + + if (res.ok) { + notify({ + title: 'Thành công', + message: 'Đã upload ảnh vào thư viện tour', + type: 'success' + }); + // Clear input + if (cameraInputRef.current) cameraInputRef.current.value = ''; + } else { + notify({ + title: 'Lỗi', + message: 'Không thể upload ảnh', + type: 'error' + }); + } + } catch (e) { + console.error('Upload error:', e); + notify({ + title: 'Lỗi', + message: 'Lỗi upload ảnh', + type: 'error' + }); + } finally { + setIsUploading(false); + } + } + }; const fileInputRef = useRef(null); + const cameraInputRef = useRef(null); const [isMobile, setIsMobile] = useState(window.innerWidth < 768); @@ -1300,42 +1425,73 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
+ {/* Chat button - moved to top */} + + + {/* Camera + Detail buttons row */}
+ + - -
+ {/* Emergency Share Button - moved to bottom */}
+ + {/* Hidden camera input */} +