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 */}
+