Compare commits
35 Commits
a65be48d36
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 03ee337e28 | |||
| 48ffd7e49b | |||
| 8df94bca26 | |||
| d2aced396f | |||
| 4e93722ba5 | |||
| 33a5996bee | |||
| 90475d4130 | |||
| 8e984e609b | |||
| b48502c34a | |||
| 978992057a | |||
| ed0fb8dd64 | |||
| 828bc89890 | |||
| b8bf4f88cc | |||
| f74587376e | |||
| a135196cb5 | |||
| cfdcb573de | |||
| f17215f236 | |||
| 5e60614588 | |||
| e385049652 | |||
| ef295e0994 | |||
| c3382a422d | |||
| 5d47e6d291 | |||
| 957ba6c72c | |||
| 4b551ccc31 | |||
| 9dc1faee6f | |||
| b998833371 | |||
| 13cd68707e | |||
| f355b9471a | |||
| a64af5f9cf | |||
| 7182391241 | |||
| 28ea9abccd | |||
| 9f41400bd8 | |||
| 78c5754655 | |||
| 5145835c8a | |||
| 6b15e7ff02 |
@@ -1,2 +1,4 @@
|
||||
.env
|
||||
node_modules
|
||||
server/dist
|
||||
dist
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# To AI Agent: Implement Smart Date-Based Accordion Auto-Expansion and Smooth Viewport Auto-Scroll
|
||||
|
||||
## 1. Context & UI/UX Requirements
|
||||
We are enhancing the routing UX between `MemberDashboard.tsx` and `ItineraryTimeline.tsx` based on `image_cc31a4.png`.
|
||||
|
||||
### Functional Specifications:
|
||||
1. **Date-Matching Evaluation:** When a user clicks "Chi tiết hành trình" on a tour card, calculate if the current user system local date falls inside any Stage/Leg timeline block.
|
||||
2. **Auto-Expansion State:** On timeline page load, the matched Leg accordion must be expanded by default (`expandedStageId === leg.id`).
|
||||
3. **Smart Auto-Scroll Layout:** The viewport must smoothly auto-scroll so that the expanded Leg's title bar lands **exactly below the sticky navigation tab bar** ("Lộ trình", "Chi phí", etc.), making it fully visible at the top of the viewport without layout clipping.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Architecture & Layout Constraints
|
||||
|
||||
- **The Sticky Header Challenge:** Since the top navigation tab bar uses sticky/fixed positioning, a naive `scrollIntoView({ block: 'start' })` will cause the Leg's title to crawl *underneath* the tabs, hiding it.
|
||||
- **The Solution:** We will inject a dynamic CSS scroll-margin-top parameter (`scroll-mt-[70px]` or matching header height) on each Leg container, and trigger a minor delayed layout effect pool using a React `setTimeout` to wait for the DOM accordion expansion reflow before pulling the scroll trigger.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Refactoring Blueprint
|
||||
|
||||
### Step 1: Update Navigation Payload Handler in Dashboard Component
|
||||
Ensure the `MemberDashboard.tsx` accurately packages the matched target identifier into the client route state bucket:
|
||||
|
||||
```typescript
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNavigateToItinerary = (tour: any) => {
|
||||
let targetLegId = null;
|
||||
|
||||
if (tour?.legs && tour.legs.length > 0) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
for (const leg of tour.legs) {
|
||||
const rawStart = leg.startDate || leg.plannedStart || leg.date;
|
||||
const rawEnd = leg.endDate || leg.plannedEnd || leg.date;
|
||||
|
||||
if (rawStart) {
|
||||
const startBound = new Date(rawStart);
|
||||
startBound.setHours(0, 0, 0, 0);
|
||||
|
||||
const endBound = rawEnd ? new Date(rawEnd) : new Date(rawStart);
|
||||
endBound.setHours(23, 59, 59, 999);
|
||||
|
||||
if (today >= startBound && today <= endBound) {
|
||||
targetLegId = leg.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetLegId) {
|
||||
targetLegId = tour.legs[0].id; // Fallback to Chặng 1 if out of tour bounds
|
||||
}
|
||||
}
|
||||
|
||||
navigate(`/tour/${tour.id}`, {
|
||||
state: { defaultExpandedLegId: targetLegId, shouldScrollToTarget: true }
|
||||
});
|
||||
};
|
||||
|
||||
### Step 2: Inject Safe Identifiers and Scroll Margin inside ItineraryTimeline
|
||||
Locate the top-level outer container div of each Leg item inside the timeline loop (currentTour.legs.map). Assign a unique id and a scroll margin class utility:
|
||||
|
||||
{currentTour.legs.map((leg: any, legIdx: number) => (
|
||||
<div
|
||||
key={leg.id}
|
||||
id={`leg-anchor-node-${leg.id}`}
|
||||
/* CRITICAL: scroll-mt-[70px] leaves a 70px buffer space at the top.
|
||||
Adjust '70px' to match the exact height of your white Tour Navigation Tabs bar!
|
||||
*/
|
||||
className="scroll-mt-[70px] transition-all w-full mb-4"
|
||||
>
|
||||
{/* Accordion Header Title ("Chặng 2: Dạo chơi ở xứ hoa vàng...") */}
|
||||
<div className="flex items-center justify-between ...">
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
### Step 3: Implement Lifecycle Interaction Trigger Engine
|
||||
Inside ItineraryTimeline.tsx, listen to the passed history route parameters. Set the state, then handle the async smooth scrolling action:
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const location = useLocation();
|
||||
const [expandedStageId, setExpandedStageId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.defaultExpandedLegId) {
|
||||
const targetId = location.state.defaultExpandedLegId;
|
||||
|
||||
// 1. Instantly trigger the accordion state layout expansion
|
||||
setExpandedStageId(targetId);
|
||||
|
||||
// 2. Schedule a deferred macro-task callback to allow DOM re-renders to finish
|
||||
if (location.state?.shouldScrollToTarget) {
|
||||
const scrollTimer = setTimeout(() => {
|
||||
const targetElement = document.getElementById(`leg-anchor-node-${targetId}`);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
|
||||
// Clear history router state token flags to prevent repeating scroll on subsequent reload shifts
|
||||
window.history.replaceState({}, document.title);
|
||||
}, 150); // 150ms ensures smooth accordion height deployment transition finishes
|
||||
|
||||
return () => clearTimeout(scrollTimer);
|
||||
}
|
||||
} else if (currentTour?.legs && currentTour.legs.length > 0 && !expandedStageId) {
|
||||
setExpandedStageId(currentTour.legs[0].id);
|
||||
}
|
||||
}, [location.state, currentTour]);
|
||||
|
||||
## 4. Quality Control & Acceptance Verification
|
||||
[ ] Date Matching Accuracy: Set today's date context matching a target Leg configuration profile. Tap the transition interface button from dashboard. The page must route directly and expand the target container block node.
|
||||
|
||||
[ ] Flush Viewport Ceiling Test: The animated target header segment node must slide upwards smoothly. It must lock positions cleanly right below the lowest baseline layer shadow boundary of the Tab Controller without passing behind it.
|
||||
|
||||
[ ] State Cleanliness Check: Refreshing or navigating back and forth within the active Timeline panel after the initial landing should not lock or force layout views to keep jumping scroll heights automatically.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Enhancement Summary: Tag Selection & Photo Filtering
|
||||
|
||||
## Overview
|
||||
Successfully enhanced the travel planning application with improved photo tagging UI and photo filtering capabilities on the explore map.
|
||||
|
||||
## Changes Implemented
|
||||
|
||||
### 1. Enhanced TagSelectModal Component ✅
|
||||
**File**: `frontend/src/components/TagSelectModal.tsx`
|
||||
|
||||
**Features Added**:
|
||||
- **Image Preview**: Shows the uploaded photo at the top of the modal (max-height: 192px, with rounded corners)
|
||||
- **Custom Tag Input**: Text input with "Nhập thẻ mới..." placeholder
|
||||
- **Add Custom Tags**: Button and Enter key support to add user-defined tags
|
||||
- **Remove Custom Tags**: Trash icon on hover to delete custom tags
|
||||
- **Visual Feedback**: Selected tags highlighted in different colors (blue for predefined, emerald for custom)
|
||||
- **Summary Section**: Displays count and list of all selected tags before confirmation
|
||||
|
||||
**UI Improvements**:
|
||||
- Sticky header and footer for easy access
|
||||
- Separate sections for predefined tags and custom input
|
||||
- Max-height with scrolling for long tag lists
|
||||
- Smooth animations and transitions
|
||||
|
||||
### 2. LandingPage Integration ✅
|
||||
**File**: `frontend/src/pages/LandingPage.tsx`
|
||||
|
||||
**Updates**:
|
||||
- Added `photoPreviewUrl` state for temporary preview image
|
||||
- Created object URL using `URL.createObjectURL()` when file is selected
|
||||
- Pass preview URL to TagSelectModal component
|
||||
- Proper cleanup with `URL.revokeObjectURL()` on modal close or after upload
|
||||
- Included custom tags in upload formData as JSON
|
||||
|
||||
**State Management**:
|
||||
```typescript
|
||||
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
|
||||
```
|
||||
|
||||
### 3. ExploreMap Photo Tag Filtering ✅
|
||||
**File**: `frontend/src/pages/ExploreMap.tsx`
|
||||
|
||||
**Features Added**:
|
||||
- **Photo Tag Filter State**: `selectedPhotoFilterTags` for tracking active filters
|
||||
- **Available Photo Tags**: `availablePhotoTags` computed from all public photos' metadata
|
||||
- **Enhanced Filter Dropdown**: Two-section filter UI:
|
||||
- **Tour Section (🧳 Chuyến đi)**: Existing tour tags (single selection)
|
||||
- **Photo Section (📸 Ảnh công khai)**: Photo tags from uploads (multiple selection)
|
||||
- **Filtering Logic**: `groupedPhotos` useMemo filters photos based on selected tags
|
||||
- **Dynamic Tag Population**: Photo tags automatically extracted from `photo.metadata.tags`
|
||||
|
||||
**Filtering Behavior**:
|
||||
- Multiple photo tags can be selected simultaneously
|
||||
- Photos matching ANY selected tag are displayed (OR logic)
|
||||
- "Tất cả" button clears photo filters
|
||||
- Filter is independent from tour tag filtering
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Tag Storage
|
||||
- Backend stores tags in `photo.metadata.tags` as JSON array
|
||||
- No database schema changes required
|
||||
- Flexible for custom tags without pre-definition
|
||||
|
||||
### Frontend State Management
|
||||
```typescript
|
||||
// State
|
||||
const [selectedPhotoFilterTags, setSelectedPhotoFilterTags] = useState<string[]>([]);
|
||||
|
||||
// Computed
|
||||
const availablePhotoTags = React.useMemo(() => {
|
||||
const tagsSet = new Set<string>();
|
||||
publicPhotos.forEach(photo => {
|
||||
const tags = photo.metadata?.tags as string[] | undefined;
|
||||
if (Array.isArray(tags)) {
|
||||
tags.forEach(tag => tagsSet.add(tag));
|
||||
}
|
||||
});
|
||||
return Array.from(tagsSet).sort();
|
||||
}, [publicPhotos]);
|
||||
|
||||
// Filtered Results
|
||||
const groupedPhotos = React.useMemo(() => {
|
||||
let filteredPhotos = publicPhotos;
|
||||
if (selectedPhotoFilterTags.length > 0) {
|
||||
filteredPhotos = publicPhotos.filter((photo) => {
|
||||
const photoTags = photo.metadata?.tags as string[] | undefined;
|
||||
if (!Array.isArray(photoTags)) return false;
|
||||
return selectedPhotoFilterTags.some(tag => photoTags.includes(tag));
|
||||
});
|
||||
}
|
||||
// ... grouping and sorting logic
|
||||
}, [publicPhotos, selectedPhotoFilterTags]);
|
||||
```
|
||||
|
||||
## Testing Results
|
||||
|
||||
### Verified Functionality
|
||||
✅ Image preview displays in TagSelectModal
|
||||
✅ Custom tag input accepts user text
|
||||
✅ Custom tags can be added with button or Enter key
|
||||
✅ Custom tags can be removed with trash icon
|
||||
✅ All selected tags display in summary
|
||||
✅ Photo tag filtering works on ExploreMap
|
||||
✅ Multiple photo tags can be selected
|
||||
✅ "Tất cả" button clears filters
|
||||
✅ Docker build successful (0 errors)
|
||||
✅ All services running healthy
|
||||
|
||||
### Build Status
|
||||
- Frontend build: ✅ Success
|
||||
- Backend build: ✅ Success
|
||||
- Container deployment: ✅ All 4 services running
|
||||
- Browser testing: ✅ Filter dropdown functional
|
||||
|
||||
## User Workflow
|
||||
|
||||
### Photo Upload with Tags
|
||||
1. User clicks "Chụp ảnh" button
|
||||
2. Selects image from device
|
||||
3. Image is displayed in TagSelectModal preview
|
||||
4. User selects predefined tags from 11 categories
|
||||
5. User can add custom tags in textbox
|
||||
6. Confirms and photo is uploaded with all tags
|
||||
7. Tags stored in database for filtering
|
||||
|
||||
### Photo Discovery with Filtering
|
||||
1. User navigates to "Khám phá" (Explore)
|
||||
2. Clicks filter button to open dropdown
|
||||
3. Sees available photo tags from community uploads
|
||||
4. Selects one or more tags to filter
|
||||
5. Map refreshes showing only photos with selected tags
|
||||
6. Clear selection with "Tất cả" button to see all photos again
|
||||
|
||||
## Files Modified
|
||||
- ✅ `frontend/src/components/TagSelectModal.tsx` - Enhanced with preview and custom tags
|
||||
- ✅ `frontend/src/pages/LandingPage.tsx` - Integration with preview URL
|
||||
- ✅ `frontend/src/pages/ExploreMap.tsx` - Photo tag filtering implementation
|
||||
|
||||
## Browser Compatibility
|
||||
- Modern browsers with ES6+ support
|
||||
- Tested on latest Chrome/Firefox/Safari
|
||||
- Mobile responsive design with touch support
|
||||
|
||||
## Performance Considerations
|
||||
- Photo tag extraction done in useMemo (cached)
|
||||
- Filter operations are optimized with Set for uniqueness
|
||||
- Lazy filtering applied only to grouped photos
|
||||
- No additional API calls needed (uses existing photo data)
|
||||
|
||||
## Future Enhancements
|
||||
- Tag search/autocomplete in filter
|
||||
- Tag popularity sorting
|
||||
- Tag suggestions based on similar photos
|
||||
- User tag preferences/favorites
|
||||
- Tag analytics dashboard
|
||||
@@ -0,0 +1,104 @@
|
||||
# To AI Agent: Fix Infinite Re-render Loop and Flickering "ĐANG TÌM ĐƯỜNG TỐI ƯU..." Label
|
||||
|
||||
## 1. Context & Layout Bug Analysis
|
||||
We are resolving a critical performance and UI bug inside the Map routing engine interface (`image.png`):
|
||||
- **The Issue:** The floating loading indicator badge reading `"ĐANG TÌM ĐƯỜNG TỐI ƯU..."` keeps flashing, flickering, or appearing and disappearing in an infinite execution loop.
|
||||
- **Root Cause:** This is caused by a broken React lifecycle loop. Every time the map triggers a directions route query, it toggles a loading state variable (`isSearching: true`). Once the route loads, the state updates (`isSearching: false`), forcing a component re-render. If the coordinates object (`origin`, `destination`) or the map instance ref inside the `useEffect` dependency array changes its reference pointer on every render, the hook fires *again*, creating an endless loop of API fetching and component flashing.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Execution Strategy
|
||||
To terminate this flickering cycle, we must enforce a strict guard rail on the network/calculation trigger pipeline:
|
||||
1. **Coordinate Reference Stability:** Deconstruct the input latitude/longitude objects into raw primitive string values (e.g., `origin.lat`, `origin.lng`) inside the hook dependency array to avoid object reference mutations triggering re-renders.
|
||||
2. **Locking Ref Mechanism:** Implement a mutable React tracking reference (`const queryInProgress = useRef(false)`) to lock the execution window. If a fetch operation is active, block subsequent duplicate queries from firing.
|
||||
3. **Clean Loading State Termination:** Turn off the search state flag explicitly only *after* the route geometry polyline has completely finished rendering onto the map viewport layout.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Refactoring Blueprint
|
||||
|
||||
Locate your map navigation layer or modal component (e.g., `LocationNavigationModal.tsx`) and refactor the execution lifecycle loop as defined below:
|
||||
|
||||
```typescript
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
|
||||
// Inside your Map Navigation Modal / Layer component wrapper:
|
||||
export const LocationNavigationModal = ({ isOpen, routeData }) => {
|
||||
const [isSearchingRoute, setIsSearchingRoute] = useState(false);
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
|
||||
// CRITICAL FIX 2: Guard mechanism to block concurrent duplicated queries
|
||||
const fetchLockRef = useRef(false);
|
||||
|
||||
// Deconstruct coordinate primitives to secure a stable dependency array
|
||||
const originLat = routeData?.origin?.lat;
|
||||
const originLng = routeData?.origin?.lng;
|
||||
const destLat = routeData?.destination?.lat;
|
||||
const destLng = routeData?.destination?.lng;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !originLat || !originLng || !destLat || !destLng) return;
|
||||
|
||||
// If an operation is already locked and active, bail out immediately to prevent loops
|
||||
if (fetchLockRef.current) return;
|
||||
|
||||
const calculateOptimalRoute = async () => {
|
||||
try {
|
||||
// 1. Activate loading feedback banner
|
||||
setIsSearchingRoute(true);
|
||||
fetchLockRef.current = true; // Engage execution lock
|
||||
|
||||
console.log("Fetching route coordinates exactly once...");
|
||||
|
||||
// --- YOUR MAP COMPONENT ROUTING LOGIC START ---
|
||||
// Example: const response = await directionsService.route({...});
|
||||
// await mapInstanceRef.current.drawPolyline(response);
|
||||
// --- YOUR MAP COMPONENT ROUTING LOGIC END ---
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to compile route optimization maps:", error);
|
||||
} finally {
|
||||
// 2. Safe, definitive termination of the tracking states
|
||||
setIsSearchingRoute(false);
|
||||
fetchLockRef.current = false; // Disengage execution lock
|
||||
}
|
||||
};
|
||||
|
||||
calculateOptimalRoute();
|
||||
|
||||
// Cleanup phase: Reset execution parameters when inputs dismantle or modal closes
|
||||
return () => {
|
||||
fetchLockRef.current = false;
|
||||
setIsSearchingRoute(false);
|
||||
};
|
||||
|
||||
/* CRITICAL FIX 1: Explicitly tracking primitives only.
|
||||
Do NOT pass full 'routeData', 'mapInstanceRef' or object literals here!
|
||||
*/
|
||||
}, [isOpen, originLat, originLng, destLat, destLng]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{/* Map Content Target Canvas */}
|
||||
<div id="navigation-viewport-map-canvas" className="w-full h-full" />
|
||||
|
||||
{/* RENDER CONTROLLER: Only mount the label if routing calculations are actively processing */}
|
||||
{isSearchingRoute && (
|
||||
<div className="absolute top-16 left-4 z-30 bg-white/95 dark:bg-slate-900/95 border border-slate-200 dark:border-slate-800 px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 animate-pulse">
|
||||
{/* Circular Loading Spinner Element */}
|
||||
<div className="w-3.5 h-3.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-[11px] font-bold text-slate-700 dark:text-slate-200 uppercase tracking-wider">
|
||||
ĐANG TÌM ĐƯỜNG TỐI ƯU...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
## 4. Verification & Quality Acceptance Criteria
|
||||
[ ] Single Instance Trigger: Check console telemetry outputs. When the map modal mounts, the route compilation routine must print its trace log exactly once.
|
||||
|
||||
[ ] Flicker Nullification: The "ĐANG TÌM ĐƯỜNG TỐI ƯU..." badge must display smoothly with an animation pulse. It must not shake, blink, flash, or rapid-cycle on and off.
|
||||
|
||||
[ ] Deterministic Hiding: As soon as the blue route line draws completely across the map terrain grid layout, the loading badge must cleanly unmount and disappear from view without reappearing unless a new destination node button is clicked.
|
||||
@@ -0,0 +1,132 @@
|
||||
# To AI Agent: Implement Clickable Google Maps Hyperlinks in PDF Export Table
|
||||
|
||||
## 1. Context & Feature Objective
|
||||
We are upgrading the PDF export functionality within the Yotrip Travel Planner application. Currently, the PDF generates a static table containing location names, addresses, and coordinates.
|
||||
**Objective:** Automatically turn every location row inside the PDF table into an interactive, clickable hyperlink. When a user clicks on the location cell in the generated PDF document, it must immediately open a browser tab navigating directly to that exact location on Google Maps.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Architecture & Data Strategy
|
||||
|
||||
Because `jspdf-autotable` draws text onto a canvas layout, raw HTML tags like `<a>` will fail. We must implement a two-step rendering lifecycle:
|
||||
1. **Extraction & Styling State:** During the data loop, verify coordinates or address data. Generate a standard universal Google Maps search URL, save it into a coordinate-tracking index object, and transform the raw text cell into a styled cell object (Yale Blue text `#28536b` to mimic an online link).
|
||||
2. **Link Injection Layer:** Utilize the `didDrawCell` hook callback inside the `doc.autoTable` configuration to position a native invisible link window (`doc.link()`) precisely over the drawn dimensions of that specific location cell.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Refactoring Specification
|
||||
|
||||
### Step 1: Update Data Preparation Loop
|
||||
Locate the loop processing `currentTour.legs` inside your PDF generation code block. Initialize a temporary lookup map array named `pdfMapLinks` and refactor the item distribution logic as follows:
|
||||
|
||||
```typescript
|
||||
// Initialize an index mapping registry for hyperlinks before the loop execution
|
||||
const pdfMapLinks: { [key: number]: string } = {};
|
||||
|
||||
if (currentTour?.legs && currentTour.legs.length > 0) {
|
||||
currentTour.legs.forEach((leg: any, legIdx: number) => {
|
||||
const legName = leg.note || `Chặng ${legIdx + 1}`;
|
||||
const legLocations = (leg.locations || []).filter((loc: any) =>
|
||||
loc && (loc.plannedStart || loc.plannedEnd || loc.name)
|
||||
);
|
||||
|
||||
const startRow = globalRowIndex;
|
||||
|
||||
if (legLocations.length > 0) {
|
||||
legRowRanges[leg.id] = { start: startRow, count: legLocations.length };
|
||||
|
||||
legLocations.forEach((loc: any, locIdx: number) => {
|
||||
const isStartPoint = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
|
||||
const timeSource = isStartPoint ? loc.plannedEnd : loc.plannedStart;
|
||||
const timeStr = timeSource ? formatDateTime(timeSource) : '';
|
||||
|
||||
const locName = loc.name || '';
|
||||
const addressStr = loc.address || '';
|
||||
const coordStr = loc.latitude && loc.longitude
|
||||
? `\n${loc.latitude}, ${loc.longitude}`
|
||||
: '';
|
||||
const locationText = [locName, addressStr, coordStr].filter(Boolean).join('\n');
|
||||
const noteText = loc.note || '';
|
||||
|
||||
// 1. Generate standard universal Google Maps URL query pattern
|
||||
let mapUrl = '';
|
||||
if (loc.latitude && loc.longitude) {
|
||||
// Absolute Precision using GPS coordinates
|
||||
mapUrl = `https://www.google.com/maps/search/?api=1&query=${loc.latitude},${loc.longitude}`;
|
||||
} else if (addressStr || locName) {
|
||||
// Text-search query fallback if GPS coordinates are missing
|
||||
mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(addressStr || locName)}`;
|
||||
}
|
||||
|
||||
// 2. Register current row array position to map lookup index
|
||||
const currentRowPosition = tableRows.length;
|
||||
if (mapUrl) {
|
||||
pdfMapLinks[currentRowPosition] = mapUrl;
|
||||
}
|
||||
|
||||
// 3. Convert raw string into custom styled autoTable Cell configuration object
|
||||
const locationCellObj = {
|
||||
content: locationText,
|
||||
// Apply custom link colors matching theme style #28536b (Yale Blue)
|
||||
styles: mapUrl ? { textColor: [40, 83, 107], fontStyle: 'bold' as const } : {}
|
||||
};
|
||||
|
||||
tableRows.push([
|
||||
stt++,
|
||||
timeStr,
|
||||
locIdx === 0 ? legName : '',
|
||||
locationCellObj, // Injected as cell structure object
|
||||
noteText
|
||||
]);
|
||||
globalRowIndex++;
|
||||
});
|
||||
} else {
|
||||
legRowRanges[leg.id] = { start: startRow, count: 1 };
|
||||
tableRows.push([
|
||||
stt++,
|
||||
'',
|
||||
legName,
|
||||
'Chưa có địa điểm trong chặng này',
|
||||
''
|
||||
]);
|
||||
globalRowIndex++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
### Step 2: Inject Coordinate-Based Overlay inside doc.autoTable
|
||||
Locate the core configuration module block where doc.autoTable({}) is invoked. Inject the didDrawCell event engine to deploy the link bounds:
|
||||
|
||||
doc.autoTable({
|
||||
head: [['STT', 'Thời gian', 'Chặng', 'Địa điểm', 'Ghi chú']],
|
||||
body: tableRows,
|
||||
theme: 'grid',
|
||||
|
||||
// HOOK HANDLER: Overlays active coordinate zones on top of native cells after writing
|
||||
didDrawCell: (data: any) => {
|
||||
// Target explicitly: body section only + column index 3 (Location Column)
|
||||
if (data.section === 'body' && data.column.index === 3) {
|
||||
const activeRowIndex = data.row.index;
|
||||
const targetMapUrl = pdfMapLinks[activeRowIndex];
|
||||
|
||||
if (targetMapUrl) {
|
||||
// Build active link container utilizing jsPDF coordinates framework
|
||||
data.doc.link(
|
||||
data.cell.x,
|
||||
data.cell.y,
|
||||
data.cell.width,
|
||||
data.cell.height,
|
||||
{ url: targetMapUrl }
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
styles: { font: 'Roboto' } // Retain existing styling structure
|
||||
});
|
||||
|
||||
## 4. Quality Control & Acceptance Criteria
|
||||
[ ] Visual Differentiation: Location text cells containing map URLs must render cleanly in bold dark-blue ([40, 83, 107]) while empty state texts stay muted.
|
||||
|
||||
[ ] Coordinate Precision: Clicking a location card possessing explicit coordinates (latitude, longitude) must directly map to those absolute markers instead of doing an inaccurate keyword address query.
|
||||
|
||||
[ ] Boundary Accuracy: The click targets must fit perfectly inside the grid cell borders. Clicking near the edges of column 3 must register properly, while clicking column 2 (Leg name) or column 4 (Notes) must remain non-reactive.
|
||||
@@ -1,79 +0,0 @@
|
||||
# To AI Agent: Implement Mobile Horizontal Image Panning Component
|
||||
|
||||
## 1. Context & Objective
|
||||
We are developing a travel application. We need to implement a mobile-first image viewer component.
|
||||
**CRITICAL REQUIREMENT:** When a user swipes/drags horizontally on mobile, the UI must NOT switch to the next image. Instead, it must smoothly scroll/pan horizontally to reveal the hidden, unexposed parts of the *same* wide/panoramic image.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Stack & Scope
|
||||
- **Target Platform:** Mobile Web / Responsive (Touch-friendly).
|
||||
- **Preferred Method:** CSS-First approach utilizing Viewport Overflow (for optimal GPU performance and native inertia scrolling).
|
||||
- **Avoid:** Do NOT use global slider libraries (like standard Swiper/Slick) if they force image switching behavior.
|
||||
|
||||
---
|
||||
|
||||
## 3. UI/UX Specifications
|
||||
|
||||
### A. DOM Structure
|
||||
- A wrapper/container acting as the "window view" (`.image-pan-container`).
|
||||
- The target wide/panoramic image (`.image-pan-element`).
|
||||
|
||||
### B. CSS Rules & Constraints
|
||||
1. **Container (`.image-pan-container`):**
|
||||
- Must have a fixed width (e.g., `100vw` or `100%` of parent).
|
||||
- Must set `overflow-x: auto` and `overflow-y: hidden` to enable horizontal touch scrolling only.
|
||||
- Must enable smooth scrolling (`scroll-behavior: smooth`) and native touch momentum (`-webkit-overflow-scrolling: touch`).
|
||||
- **Crucial:** Hide the native scrollbar across all major browsers (Webkit, Firefox, IE/Edge) to make it look like a native mobile app feature.
|
||||
|
||||
2. **Image Element (`.image-pan-element`):**
|
||||
- Must fit the container's height perfectly (`height: 100%`).
|
||||
- Width must be calculated automatically based on aspect ratio (`width: auto`).
|
||||
- Must override any global framework styles: enforce `max-width: none !important`.
|
||||
- Do NOT use `object-fit: cover` as it will crop the scrolling data.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reference Code Blueprint
|
||||
|
||||
Use the following snippet as a baseline for your implementation:
|
||||
|
||||
```html
|
||||
<div class="image-pan-container">
|
||||
<img src="YOUR_PANORAMIC_IMAGE_URL" class="image-pan-element" alt="Panoramic View" />
|
||||
</div>
|
||||
|
||||
/* Styling Architecture */
|
||||
.image-pan-container {
|
||||
width: 100%;
|
||||
height: 400px; /* Adjust height based on project guidelines */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Hide scrollbars entirely */
|
||||
.image-pan-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.image-pan-container {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.image-pan-element {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
max-width: none !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
5. Acceptance Criteria
|
||||
[ ] The wide photo fills the component height and overflows horizontally without distortion.
|
||||
|
||||
[ ] Users can smoothly swipe left/right with their fingers to view all details of the photo.
|
||||
|
||||
[ ] No desktop/mobile scrollbars are visible during the interaction.
|
||||
|
||||
[ ] Ensure max-width override is active so Tailwind or other CSS frameworks don't crush the image width to 100%.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Travel Planning - Multi-User Travel Itinerary Management System
|
||||
|
||||
A comprehensive travel planning application that enables collaborative itinerary planning, expense tracking, and photo sharing for travel groups.
|
||||
|
||||
## Features
|
||||
|
||||
- **Tour Management**: Create and manage travel tours with multiple legs (stages) and locations
|
||||
- **Multi-user Collaboration**: Invite friends to join tours with role-based access control
|
||||
- **Interactive Maps**: Visual tour planning with Leaflet.js integration
|
||||
- **Expense Tracking**: Automatic cost splitting with configurable adult/child discounts
|
||||
- **Photo Sharing**: Secure photo album with privacy controls (PUBLIC, TOUR_ONLY, PRIVATE)
|
||||
- **Real-time Navigation**: Live GPS tracking and route optimization using OSRM API
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| **Frontend** | React + Vite | Single Page Application with fast HMR |
|
||||
| | TailwindCSS | Utility-first CSS framework |
|
||||
| | Zustand | Lightweight state management |
|
||||
| | Leaflet.js | Interactive map rendering |
|
||||
| **Backend** | NestJS | Scalable Node.js framework |
|
||||
| | JWT | Authentication & authorization |
|
||||
| | Prisma ORM | Type-safe database access |
|
||||
| | PostgreSQL + PostGIS | Spatial database for geographic data |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
travelplanning/
|
||||
├── backend/ # Backend API server
|
||||
│ ├── src/
|
||||
│ │ ├── auth/ # Authentication modules
|
||||
│ │ ├── main.ts # NestJS entry point
|
||||
│ │ └── v1/ # API v1 endpoints
|
||||
│ └── prisma/
|
||||
│ └── schema.prisma # Database schema
|
||||
├── frontend/ # React frontend
|
||||
│ └── src/
|
||||
│ ├── pages/ # Main pages
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ └── store/ # Zustand stores
|
||||
├── docs/ # Documentation
|
||||
│ ├── ARCHITECTURE.md # System architecture
|
||||
│ └── UITourDesign.md # UI design specifications
|
||||
└── .env # Environment variables
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The application uses PostgreSQL with Prisma ORM. Key models include:
|
||||
|
||||
- **User**: Registered users with admin capability
|
||||
- **Tour**: Travel itineraries with date ranges and participant management
|
||||
- **Leg**: Stages within a tour (ordered sequence)
|
||||
- **Location**: Geographic points with timing and status tracking
|
||||
- **Expense**: Cost tracking linked to legs/locations
|
||||
- **Photo**: Media storage with privacy controls
|
||||
- **TourParticipant**: Many-to-many relationship with role-based permissions
|
||||
|
||||
### User Roles
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| OWNER | Full access to all features |
|
||||
| MANAGER | Can edit tour content and manage members |
|
||||
| MEMBER | View tour and participate, access financial data |
|
||||
| MEMBER_NO_FINANCE | View tour only, no financial access |
|
||||
| VIEWER_ONLY | Read-only access to itinerary and photos |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Tours
|
||||
- `GET /api/v1/tours` - Get all public tours
|
||||
- `POST /api/v1/tours` - Create new tour
|
||||
- `GET /api/v1/tours/:id` - Get tour details
|
||||
- `PUT /api/v1/tours/:id` - Update tour
|
||||
|
||||
### Authentication
|
||||
- `POST /api/v1/auth/login` - User login
|
||||
- `POST /api/v1/auth/register` - User registration
|
||||
- `POST /api/v1/auth/promote-admin` - Admin role promotion (with secret key)
|
||||
|
||||
### Photos
|
||||
- `GET /api/v1/public-photos` - Get public photos
|
||||
- `POST /api/v1/tours/:id/photos` - Upload tour photos
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- PostgreSQL with PostGIS extension
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
cd frontend && npm install
|
||||
cd ../backend && npm install
|
||||
|
||||
# Set up database
|
||||
npx prisma migrate dev
|
||||
npx prisma generate
|
||||
|
||||
# Start development servers
|
||||
npm run dev # Frontend (Vite)
|
||||
npm run start:backend # Backend (NestJS)
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create `.env` in the root directory:
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/traveldb"
|
||||
JWT_SECRET="your-secret-key"
|
||||
```
|
||||
|
||||
## Mobile Optimization
|
||||
|
||||
The application is built mobile-first with support for:
|
||||
- Safe area insets for notch displays (iOS/Android)
|
||||
- Touch gestures for map interactions
|
||||
- Responsive layouts for all screen sizes
|
||||
- Device orientation and compass integration
|
||||
|
||||
## Documentation
|
||||
|
||||
See the `docs/` directory for detailed documentation:
|
||||
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - System architecture and data models
|
||||
- [UITourDesign.md](docs/UITourDesign.md) - UI design specifications
|
||||
@@ -0,0 +1,134 @@
|
||||
# To AI Agent: Audit Location Lock Bug and Refactor GPS Tracking into a Toggle Stateful Button
|
||||
|
||||
## 1. Context & Problem Statement
|
||||
Currently, in our travel planner application map engine (on pages like `TourNavigationPage.tsx`, `LocationNavigationModal.tsx`, or map utilities), the viewport is continuously forced to lock onto the user's live GPS position. This architecture severely damages mobile UX because:
|
||||
1. It prevents users from manually dragging, panning, or scouting other areas of the map terrain.
|
||||
2. There is no control interface to temporarily mute or disable live GPS tracking.
|
||||
|
||||
**Objective:** - Run a global audit across the entire codebase to locate functions driving this forced-center trap (e.g., custom hooks, `requestGpsPosition`, native geolocation callbacks, or reactive map state updates).
|
||||
- Refactor the logic so that live tracking is bound strictly to an independent toggle state button. The map must **ONLY** lock/re-center on the user's coordinates when this tracking toggle button is actively switched **ON**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 1: Codebase Audit Plan (Where to Search)
|
||||
|
||||
Scan the entire project repository (specifically `/frontend/src`) using code search patterns to intercept the lock mechanism. Target the following files and keywords:
|
||||
|
||||
### Key Target Files to Inspect:
|
||||
- `frontend/src/pages/TourNavigationPage.tsx`
|
||||
- `frontend/src/components/LocationNavigationModal.tsx`
|
||||
- Any custom hooks or contexts handling geography, such as `useGeolocation.ts`, `useMap.ts`, or generic map setup wrappers.
|
||||
|
||||
### Regex & Keyword Global Search Queries:
|
||||
- Search for native background watchers: `navigator.geolocation.watchPosition` or `navigator.geolocation.getCurrentPosition`
|
||||
- Search for custom map-centering loops: `requestGpsPosition`, `followUser`, `centerToUser`
|
||||
- Search for viewport mutation commands specific to our active map engine stack:
|
||||
- **Leaflet:** `.setView(`, `.panTo(`, `center={`
|
||||
- **Mapbox GL JS:** `.flyTo(`, `.easeTo(`, `.jumpTo(`
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 2: Technical Refactoring Blueprint
|
||||
|
||||
Once the tracking logic code blocks are isolated from Phase 1, implement the structural state safety rails below:
|
||||
|
||||
### Step 1: Initialize the Tracking State Guard
|
||||
Introduce a state hook controller (`isTrackingLocation`) to manage whether the view should actively mirror device coordinates:
|
||||
|
||||
```typescript
|
||||
// Add inside the map controller/page container component
|
||||
const [isTrackingLocation, setIsTrackingLocation] = useState(false);
|
||||
const watchIdRef = useRef<number | null>(null);
|
||||
|
||||
### Step 2: Encapsulate the Geolocation Watcher Handler
|
||||
Wrap your positioning tracking engine loop inside a conditional check governed directly by the state guard. Ensure that if the tracking state is disabled, the background watcher cleanly unmounts:
|
||||
|
||||
useEffect(() => {
|
||||
if (isTrackingLocation) {
|
||||
if (navigator.geolocation) {
|
||||
console.log("GPS Location Tracking engaged. Syncing viewport to center...");
|
||||
|
||||
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
const { latitude, longitude, heading } = position.coords;
|
||||
|
||||
if (mapRef.current) {
|
||||
// ✅ CORRECTION: Viewport ONLY repositions center when tracking button is active
|
||||
mapRef.current.easeTo({
|
||||
center: [longitude, latitude],
|
||||
zoom: 16, // Lock to comfortable navigation zoom level
|
||||
duration: 600
|
||||
});
|
||||
}
|
||||
},
|
||||
(error) => console.error("GPS stream tracking lost:", error),
|
||||
{ enableHighAccuracy: true }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Clean up tracking process instantly when toggled OFF
|
||||
if (watchIdRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchIdRef.current);
|
||||
watchIdRef.current = null;
|
||||
console.log("GPS Location Tracking disabled. Map control released to user.");
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (watchIdRef.current !== null) navigator.geolocation.clearWatch(watchIdRef.current);
|
||||
};
|
||||
}, [isTrackingLocation]);
|
||||
|
||||
### Step 3: Implement Gesture Detection (UX Safety Rail)
|
||||
If the user manually drags the screen while tracking is active, the tracking state must automatically toggle OFF so the viewport doesn't fight against the user's finger movements:
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
const map = mapRef.current;
|
||||
|
||||
const breakTrackingOnGesture = () => {
|
||||
if (isTrackingLocation) {
|
||||
console.log("User touch map interaction detected. Disengaging auto-center lock.");
|
||||
setIsTrackingLocation(false); // Automatically drop tracking flag on map pan/zoom
|
||||
}
|
||||
};
|
||||
|
||||
map.on('dragstart', breakTrackingOnGesture);
|
||||
map.on('zoomstart', breakTrackingOnGesture);
|
||||
map.on('movestart', breakTrackingOnGesture);
|
||||
|
||||
return () => {
|
||||
map.off('dragstart', breakTrackingOnGesture);
|
||||
map.off('zoomstart', breakTrackingOnGesture);
|
||||
map.off('movestart', breakTrackingOnGesture);
|
||||
};
|
||||
}, [isTrackingLocation]);
|
||||
|
||||
### Step 4: Render the UI Toggle Button UI Component
|
||||
Deploy a new independent floating button on top of the map canvas workspace (placed at bottom-24 right-6, just right above your custom Compass button layout):
|
||||
|
||||
{/* FLOATING LIVE GPS TRACKING CENTER LOCK BUTTON */}
|
||||
<button
|
||||
onClick={() => setIsTrackingLocation(!isTrackingLocation)}
|
||||
className={`absolute bottom-24 right-6 z-40 w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
|
||||
isTrackingLocation
|
||||
? 'bg-green-600 border-green-400 text-white animate-pulse'
|
||||
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-green-500'
|
||||
}`}
|
||||
title={isTrackingLocation ? "Tắt tự động định tâm vị trí" : "Bật tự động định tâm theo vị trí của bạn"}
|
||||
>
|
||||
{/* Replace Crosshair icon element with your active layout icon package asset */}
|
||||
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
## 4. Verification & Quality Acceptance Criteria
|
||||
|
||||
[ ] Code Erasure Verification: Confirm that old continuous loops or uncontrolled recursive .setView/.easeTo methods triggered instantly on map load are fully removed or properly contained inside the state block.
|
||||
|
||||
[ ] Default State Freedom: Upon opening the map page path, tracking must default to OFF. Users must be able to drag the map anywhere in the world without the screen snapped or yanked back to their physical house position.
|
||||
|
||||
[ ] Toggle Activation Centering: Pressing the new GPS tracking button must instantly engage the animation, center the map view directly on top of the user blue dot icon, and follow them smoothly if they move.
|
||||
|
||||
[ ] Manual Override Interception: Turn tracking ON. Drag the map manually with a finger gesture. Verify that the tracking button instantly changes style states back to deactivated and tracking shuts down cleanly.
|
||||
@@ -824,27 +824,13 @@ let TourController = class TourController {
|
||||
}
|
||||
}
|
||||
});
|
||||
const noteContent = `<h2>${filteredTitle} - Initial Planning</h2>
|
||||
<p><strong>Start Date:</strong> ${startDate ? new Date(startDate).toLocaleDateString() : 'TBD'}</p>
|
||||
<p><strong>End Date:</strong> ${endDate ? new Date(endDate).toLocaleDateString() : 'TBD'}</p>
|
||||
<p><strong>Adult Participants:</strong> ${adultCount || 1}</p>
|
||||
<p><strong>Child Participants:</strong> ${childCount || 0}</p>
|
||||
<h3>Key Items to Plan:</h3>
|
||||
<ul>
|
||||
<li>Accommodations</li>
|
||||
<li>Transportation</li>
|
||||
<li>Activities & Attractions</li>
|
||||
<li>Budget & Expenses</li>
|
||||
<li>Important Contact Numbers</li>
|
||||
<li>Special Requirements & Notes</li>
|
||||
</ul>
|
||||
<p><em>Add your planning notes here...</em></p>`;
|
||||
const noteContent = `<h1>${filteredTitle}</h1><h2>Ghi chú chung</h2><p><em>Nội dung ghi chú tổng quan của chuyến đi...</em></p>`;
|
||||
try {
|
||||
await this.prisma.tourNote.create({
|
||||
data: {
|
||||
tourId: tour.id,
|
||||
userId: req.user.id,
|
||||
title: `[${filteredTitle}] - Initial Planning`,
|
||||
title: `Ghi chú: ${filteredTitle}`,
|
||||
content: noteContent
|
||||
}
|
||||
});
|
||||
@@ -1582,6 +1568,7 @@ let TourController = class TourController {
|
||||
async joinByToken(body, req) {
|
||||
const { token } = body;
|
||||
console.log('[joinByToken] Request received, token length:', token ? token.length : 0);
|
||||
console.log('[joinByToken] Token received (full):', token);
|
||||
if (!token) {
|
||||
console.error('[joinByToken] No token provided');
|
||||
throw new common_1.BadRequestException('Vui lòng cung cấp token lời mời.');
|
||||
@@ -1595,6 +1582,8 @@ let TourController = class TourController {
|
||||
console.error('[joinByToken] Invitation not found for token:', token.substring(0, 20) + '...');
|
||||
const totalInvitations = await this.prisma.tourInvitation.count();
|
||||
console.log('[joinByToken] Total invitations in database:', totalInvitations);
|
||||
const allInvitations = await this.prisma.tourInvitation.findMany({ select: { token: true, email: true, tourId: true } });
|
||||
console.log('[joinByToken] All invitation tokens:', allInvitations);
|
||||
throw new common_1.NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.');
|
||||
}
|
||||
const userEmail = req.user.email;
|
||||
@@ -2257,6 +2246,19 @@ let PhotoController = class PhotoController {
|
||||
console.log(`[EXIF GPS] Sử dụng tọa độ dự phòng từ Frontend: lat=${lat}, lng=${lng}`);
|
||||
}
|
||||
}
|
||||
let tags = [];
|
||||
if (req.body.tags) {
|
||||
try {
|
||||
tags = JSON.parse(req.body.tags);
|
||||
if (!Array.isArray(tags)) {
|
||||
tags = [];
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
|
||||
console.warn('[TAGS] Failed to parse tags from request:', errorMsg);
|
||||
}
|
||||
}
|
||||
if (lat === undefined || lng === undefined) {
|
||||
lat = 10.7769;
|
||||
lng = 106.7009;
|
||||
@@ -2290,7 +2292,8 @@ let PhotoController = class PhotoController {
|
||||
privacy: 'PUBLIC',
|
||||
metadata: {
|
||||
lat: lat,
|
||||
lng: lng
|
||||
lng: lng,
|
||||
tags: tags
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -4207,6 +4210,45 @@ let TourNoteController = class TourNoteController {
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
async insertSection(tourId, body, req) {
|
||||
const tour = await this.prisma.tour.findUnique({ where: { id: tourId } });
|
||||
if (!tour)
|
||||
throw new common_1.NotFoundException('Không tìm thấy tour');
|
||||
const masterNote = await this.prisma.tourNote.findFirst({
|
||||
where: { tourId, title: `Ghi chú: ${tour.title}`, isDeleted: false, userId: req.user.id }
|
||||
});
|
||||
let note = masterNote;
|
||||
if (!note) {
|
||||
note = await this.prisma.tourNote.create({
|
||||
data: {
|
||||
tourId,
|
||||
userId: req.user.id,
|
||||
title: `Ghi chú: ${tour.title}`,
|
||||
content: `# ${tour.title}\n\n## Ghi chú chung\n\n*(Nội dung ghi chú tổng quan của chuyến đi...)*\n\n`
|
||||
}
|
||||
});
|
||||
}
|
||||
const leg = await this.prisma.leg.findUnique({ where: { id: body.legId } });
|
||||
const stageHeader = leg ? `<h2>Ghi chú: ${leg.note || `Chặng ${leg.sequence}`}</h2>` : '<h2>Ghi chú:</h2>';
|
||||
let content = note.content;
|
||||
const stageIndex = content.indexOf(stageHeader);
|
||||
if (stageIndex === -1) {
|
||||
content += `<br><br>${stageHeader}${body.noteSnippet}`;
|
||||
}
|
||||
else {
|
||||
const nextHeaderIndex = content.indexOf('<h2>', stageIndex + stageHeader.length);
|
||||
if (nextHeaderIndex !== -1) {
|
||||
content = content.slice(0, nextHeaderIndex) + body.noteSnippet + content.slice(nextHeaderIndex);
|
||||
}
|
||||
else {
|
||||
content += body.noteSnippet;
|
||||
}
|
||||
}
|
||||
return this.prisma.tourNote.update({
|
||||
where: { id: note.id },
|
||||
data: { content }
|
||||
});
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE),
|
||||
@@ -4247,6 +4289,16 @@ __decorate([
|
||||
__metadata("design:paramtypes", [String, String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourNoteController.prototype, "deleteNote", null);
|
||||
__decorate([
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE),
|
||||
(0, common_1.Post)('insert'),
|
||||
__param(0, (0, common_1.Param)('tourId')),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
__param(2, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourNoteController.prototype, "insertSection", null);
|
||||
TourNoteController = __decorate([
|
||||
(0, common_1.Controller)('tours/:tourId/notes'),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
|
||||
|
||||
@@ -789,29 +789,14 @@ class TourController {
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-create default note template for new tour
|
||||
const noteContent = `<h2>${filteredTitle} - Initial Planning</h2>
|
||||
<p><strong>Start Date:</strong> ${startDate ? new Date(startDate).toLocaleDateString() : 'TBD'}</p>
|
||||
<p><strong>End Date:</strong> ${endDate ? new Date(endDate).toLocaleDateString() : 'TBD'}</p>
|
||||
<p><strong>Adult Participants:</strong> ${adultCount || 1}</p>
|
||||
<p><strong>Child Participants:</strong> ${childCount || 0}</p>
|
||||
<h3>Key Items to Plan:</h3>
|
||||
<ul>
|
||||
<li>Accommodations</li>
|
||||
<li>Transportation</li>
|
||||
<li>Activities & Attractions</li>
|
||||
<li>Budget & Expenses</li>
|
||||
<li>Important Contact Numbers</li>
|
||||
<li>Special Requirements & Notes</li>
|
||||
</ul>
|
||||
<p><em>Add your planning notes here...</em></p>`;
|
||||
const noteContent = `<h1>${filteredTitle}</h1><h2>Ghi chú chung</h2><p><em>Nội dung ghi chú tổng quan của chuyến đi...</em></p>`;
|
||||
|
||||
try {
|
||||
await this.prisma.tourNote.create({
|
||||
data: {
|
||||
tourId: tour.id,
|
||||
userId: req.user.id,
|
||||
title: `[${filteredTitle}] - Initial Planning`,
|
||||
title: `Ghi chú: ${filteredTitle}`,
|
||||
content: noteContent
|
||||
}
|
||||
});
|
||||
@@ -2240,6 +2225,20 @@ class PhotoController {
|
||||
}
|
||||
}
|
||||
|
||||
// Lấy tags từ request (nếu có)
|
||||
let tags: string[] = [];
|
||||
if (req.body.tags) {
|
||||
try {
|
||||
tags = JSON.parse(req.body.tags);
|
||||
if (!Array.isArray(tags)) {
|
||||
tags = [];
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
|
||||
console.warn('[TAGS] Failed to parse tags from request:', errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Nếu vẫn không có, sử dụng vị trí mặc định (TP.HCM)
|
||||
if (lat === undefined || lng === undefined) {
|
||||
lat = 10.7769;
|
||||
@@ -2278,7 +2277,8 @@ class PhotoController {
|
||||
privacy: 'PUBLIC',
|
||||
metadata: {
|
||||
lat: lat,
|
||||
lng: lng
|
||||
lng: lng,
|
||||
tags: tags
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -4095,6 +4095,55 @@ class TourNoteController {
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE)
|
||||
@Post('insert')
|
||||
async insertSection(
|
||||
@Param('tourId') tourId: string,
|
||||
@Body() body: { legId: string; noteSnippet: string },
|
||||
@Req() req: any
|
||||
) {
|
||||
const tour = await this.prisma.tour.findUnique({ where: { id: tourId } });
|
||||
if (!tour) throw new NotFoundException('Không tìm thấy tour');
|
||||
|
||||
const masterNote = await this.prisma.tourNote.findFirst({
|
||||
where: { tourId, title: `Ghi chú: ${tour.title}`, isDeleted: false, userId: req.user.id }
|
||||
});
|
||||
|
||||
let note = masterNote;
|
||||
if (!note) {
|
||||
note = await this.prisma.tourNote.create({
|
||||
data: {
|
||||
tourId,
|
||||
userId: req.user.id,
|
||||
title: `Ghi chú: ${tour.title}`,
|
||||
content: `# ${tour.title}\n\n## Ghi chú chung\n\n*(Nội dung ghi chú tổng quan của chuyến đi...)*\n\n`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const leg = await this.prisma.leg.findUnique({ where: { id: body.legId } });
|
||||
const stageHeader = leg ? `<h2>Ghi chú: ${leg.note || `Chặng ${leg.sequence}`}</h2>` : '<h2>Ghi chú:</h2>';
|
||||
|
||||
let content = note.content;
|
||||
const stageIndex = content.indexOf(stageHeader);
|
||||
|
||||
if (stageIndex === -1) {
|
||||
content += `<br><br>${stageHeader}${body.noteSnippet}`;
|
||||
} else {
|
||||
const nextHeaderIndex = content.indexOf('<h2>', stageIndex + stageHeader.length);
|
||||
if (nextHeaderIndex !== -1) {
|
||||
content = content.slice(0, nextHeaderIndex) + body.noteSnippet + content.slice(nextHeaderIndex);
|
||||
} else {
|
||||
content += body.noteSnippet;
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.tourNote.update({
|
||||
where: { id: note.id },
|
||||
data: { content }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/notes')
|
||||
|
||||
|
After Width: | Height: | Size: 868 KiB |
|
Before Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 562 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 756 KiB |
|
Before Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 518 KiB |
|
Before Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 646 KiB |
|
Before Width: | Height: | Size: 844 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 755 KiB |
|
Before Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 516 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 561 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 844 KiB After Width: | Height: | Size: 844 KiB |
|
After Width: | Height: | Size: 408 KiB |
|
After Width: | Height: | Size: 422 KiB |
@@ -45,6 +45,7 @@ services:
|
||||
SMTP_SECURE: "${SMTP_SECURE}"
|
||||
SMTP_USER: "${SMTP_USER}"
|
||||
SMTP_PASS: "${SMTP_PASS}"
|
||||
TZ: "Asia/Ho_Chi_Minh"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -6,8 +6,23 @@
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<title>Travel Planner</title>
|
||||
<script type="module" crossorigin src="/assets/index-Chfn55jq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D_50XTpY.css">
|
||||
|
||||
<!-- Open Graph Meta Tags for Social Media Sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://yotrip.labz.io.vn" />
|
||||
<meta property="og:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta property="og:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||
<meta property="og:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<script type="module" crossorigin src="/assets/index-D6jMbgMg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nLng8wU9.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,6 +6,21 @@
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<title>Travel Planner</title>
|
||||
|
||||
<!-- Open Graph Meta Tags for Social Media Sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://yotrip.labz.io.vn" />
|
||||
<meta property="og:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta property="og:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||
<meta property="og:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,10 +3,39 @@ server {
|
||||
server_name yotrip.labz.io.vn localhost;
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Proxy uploaded files from backend (MUST come before image pattern matching)
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
add_header Cache-Control "public, max-age=31536000";
|
||||
}
|
||||
|
||||
# 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 (NOT including /uploads/)
|
||||
location ~* ^(?!/uploads/).*\.(?:jpg|jpeg|png|gif|ico|svg|avif|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
|
||||
@@ -19,14 +48,6 @@ server {
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Serve uploaded files from backend
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Proxy WebSocket connection
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend:3001;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { JoinTourPage } from './pages/JoinTourPage';
|
||||
import { MemberDashboard } from './pages/MemberDashboard';
|
||||
import { AdminDashboard } from './pages/AdminDashboard';
|
||||
import { ShareJourneyPage } from './pages/ShareJourneyPage';
|
||||
import { TourNavigationPage } from './pages/TourNavigationPage';
|
||||
import { ConfirmProvider } from './hooks/useConfirm';
|
||||
import { NotificationProvider } from './hooks/useNotification';
|
||||
|
||||
@@ -21,12 +22,13 @@ function App() {
|
||||
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [shareJourneyToken, setShareJourneyToken] = useState<string | null>(journeyTokenVal);
|
||||
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'admin' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney'>(
|
||||
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'admin' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney' | 'tourNavigation'>(
|
||||
journeyTokenVal ? 'shareJourney' : (viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing'))
|
||||
);
|
||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
|
||||
const [navigationPayload, setNavigationPayload] = useState<{ tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -122,26 +124,30 @@ function App() {
|
||||
};
|
||||
|
||||
const handleBackFromTourDetail = () => {
|
||||
// Check if this was a public view BEFORE clearing the flag
|
||||
const wasPublicView = isPublicTourView;
|
||||
|
||||
setCurrentTourId(null);
|
||||
setIsPublicTourView(false);
|
||||
|
||||
// If user was viewing a public tour, redirect to index/landing page
|
||||
// Otherwise redirect based on authentication and previous page
|
||||
if (wasPublicView) {
|
||||
// Public tour view - always redirect to index/landing
|
||||
setCurrentPage('landing');
|
||||
} else if (user) {
|
||||
// Authenticated user viewing their own tour - go back to previous page
|
||||
setCurrentPage(previousPage);
|
||||
} else {
|
||||
// Not authenticated and not public view - go to landing
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenNavigationPage = (payload: { tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string }) => {
|
||||
setNavigationPayload(payload);
|
||||
setCurrentPage('tourNavigation');
|
||||
};
|
||||
|
||||
const handleBackFromNavigation = () => {
|
||||
setNavigationPayload(null);
|
||||
setCurrentPage('tourDetail');
|
||||
};
|
||||
|
||||
const handleBackFromSignup = () => {
|
||||
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
|
||||
if (pendingInviteToken) {
|
||||
@@ -259,6 +265,7 @@ function App() {
|
||||
onBack={handleBackFromTourDetail}
|
||||
isPublicView={isPublicTourView}
|
||||
onOpenNotes={() => setCurrentPage('notes')}
|
||||
onOpenNavigationPage={handleOpenNavigationPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -267,6 +274,19 @@ function App() {
|
||||
return <MyNotePage tourId={currentTourId!} onBack={() => setCurrentPage('tourDetail')} />;
|
||||
}
|
||||
|
||||
if (currentPage === 'tourNavigation' && navigationPayload) {
|
||||
return (
|
||||
<TourNavigationPage
|
||||
tourId={navigationPayload.tourId}
|
||||
routeData={{
|
||||
origin: navigationPayload.origin,
|
||||
destination: navigationPayload.destination
|
||||
}}
|
||||
onBack={handleBackFromNavigation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'explore') {
|
||||
return (
|
||||
<ExploreMap
|
||||
|
||||
@@ -45,13 +45,13 @@ const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, cen
|
||||
{menuPos && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute z-[3000] bg-white rounded-xl shadow-xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
|
||||
className="absolute z-[3000] bg-[var(--surface)] rounded-xl shadow-xl border border-[var(--border)] py-1 w-44 animate-in zoom-in-95 duration-200"
|
||||
style={{ top: menuPos.y, left: menuPos.x }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onPick(menuPos.latlng); setMenuPos(null); }}
|
||||
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-xs font-bold text-blue-600 flex items-center gap-2"
|
||||
className="w-full text-left px-4 py-2 hover:bg-[var(--background)] text-xs font-bold text-blue-600 flex items-center gap-2"
|
||||
>
|
||||
<MapPin className="w-3 h-3" /> Thêm vào chặng hiện tại
|
||||
</button>
|
||||
@@ -88,6 +88,45 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
const { legs, addLocation, updateLocation, updateTourStartPoint, updateTourEndPoint, mapCenter, currentTour, userRole } = useTourStore();
|
||||
const notify = useNotification();
|
||||
|
||||
// Helper function to parse coordinates from pasted text (format: "lat, lng")
|
||||
const parseCoordinates = (text: string): { latitude: number; longitude: number } | null => {
|
||||
const trimmed = text.trim();
|
||||
// Match format: number, number (supports negative numbers and decimals)
|
||||
const coordMatch = trimmed.match(/^(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)$/);
|
||||
|
||||
if (coordMatch) {
|
||||
const lat = parseFloat(coordMatch[1]);
|
||||
const lng = parseFloat(coordMatch[2]);
|
||||
|
||||
// Validate latitude range: -90 to 90
|
||||
// Validate longitude range: -180 to 180
|
||||
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
return { latitude: lat, longitude: lng };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Handle paste event for coordinate inputs
|
||||
const handleCoordinatePaste = (e: React.ClipboardEvent<HTMLInputElement>, field: 'latitude' | 'longitude') => {
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
const parsed = parseCoordinates(pastedText);
|
||||
|
||||
if (parsed) {
|
||||
e.preventDefault();
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
latitude: parsed.latitude,
|
||||
longitude: parsed.longitude
|
||||
}));
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: `Tọa độ được phân tích: ${parsed.latitude}, ${parsed.longitude}`,
|
||||
type: 'success'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -348,18 +387,18 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] overflow-y-auto">
|
||||
<div className="relative w-full max-w-lg bg-[var(--surface)] rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<h2 className="text-2xl font-bold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<MapIcon className="w-6 h-6 text-blue-600" /> {titleText}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-6 h-6 text-gray-400" />
|
||||
<button onClick={onClose} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
|
||||
<X className="w-6 h-6 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mini Map Picker */}
|
||||
<div className="h-64 w-full rounded-3xl overflow-hidden mb-6 border border-gray-100 relative shadow-xl group">
|
||||
<div className="h-64 w-full rounded-3xl overflow-hidden mb-6 border border-[var(--border)] relative shadow-xl group">
|
||||
{/* Map Search Bar Overlay - Tích hợp tìm kiếm trực tiếp trên bản đồ */}
|
||||
<div className="absolute top-3 left-3 right-3 z-[1001] pointer-events-none">
|
||||
<div className="relative max-w-sm pointer-events-auto">
|
||||
@@ -367,7 +406,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm địa điểm trên bản đồ..."
|
||||
className="w-full pl-11 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold text-gray-800"
|
||||
className="w-full pl-11 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold text-[var(--text-primary)]"
|
||||
value={formData.name}
|
||||
onChange={e => handleSearchLocation(e.target.value)}
|
||||
/>
|
||||
@@ -378,7 +417,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); setHasNoResults(false); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-full text-gray-400 transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-[var(--background)] rounded-full text-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -387,24 +426,24 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
|
||||
{/* Dropdown kết quả tìm kiếm ngay trong khung bản đồ */}
|
||||
{(searchResults.length > 0 || hasNoResults) && (
|
||||
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-40 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
|
||||
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-[var(--border)] rounded-2xl shadow-2xl overflow-hidden max-h-40 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
|
||||
{hasNoResults ? (
|
||||
<div className="px-4 py-4 text-center text-gray-400 text-xs italic">Không tìm thấy địa điểm phù hợp...</div>
|
||||
<div className="px-4 py-4 text-center text-[var(--text-muted)] text-xs italic">Không tìm thấy địa điểm phù hợp...</div>
|
||||
) : (
|
||||
searchResults.map((result, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => selectSearchResult(result)}
|
||||
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors flex flex-col gap-0.5"
|
||||
className="w-full text-left px-4 py-3 hover:bg-[var(--background)] border-b border-[var(--border)] last:border-0 transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
|
||||
<div className="font-bold text-xs text-[var(--text-primary)] truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
|
||||
{result.type && (
|
||||
<span className="text-[8px] font-black uppercase text-blue-400 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100 shrink-0">{result.type}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div>
|
||||
<div className="text-[10px] text-[var(--text-muted)] truncate leading-tight">{result.display_name}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
@@ -418,7 +457,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
<Marker position={currentCoords} />
|
||||
<MapPicker center={currentCoords} onPick={handlePickLocation} />
|
||||
</MapContainer>
|
||||
<div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100">
|
||||
<div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-[var(--text-muted)] shadow-sm border border-[var(--border)]">
|
||||
CHUỘT PHẢI ĐỂ CHỌN VỊ TRÍ
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,23 +475,23 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Tên địa điểm</label>
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Tên địa điểm</label>
|
||||
<input
|
||||
required
|
||||
placeholder="Tên địa điểm..."
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all font-bold"
|
||||
className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all font-bold"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({...formData, name: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Địa chỉ</label>
|
||||
<input className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Address</label>
|
||||
<input className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none"
|
||||
value={formData.address} onChange={e => setFormData({...formData, address: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Ghi chú địa điểm / Dịch vụ sử dụng</label>
|
||||
<textarea className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none resize-none" rows={2}
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Ghi chú địa điểm / Dịch vụ sử dụng</label>
|
||||
<textarea className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none resize-none" rows={2}
|
||||
placeholder="Ví dụ: Ăn trưa tại quán X, thuê hướng dẫn viên..."
|
||||
value={formData.note} onChange={e => setFormData({...formData, note: e.target.value})} />
|
||||
</div>
|
||||
@@ -460,8 +499,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
<p className="text-xs font-black text-blue-500 uppercase tracking-widest">Chi phí nhanh tại điểm này</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label>
|
||||
<input type="text" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
<label className="block text-xs font-bold text-[var(--text-secondary)] mb-1">Số tiền (VNĐ)</label>
|
||||
<input type="text" className="w-full px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-xl outline-none text-sm"
|
||||
placeholder="0"
|
||||
value={formData.expenseAmount} onChange={e => {
|
||||
const rawValue = e.target.value.replace(/\D/g, ""); // Chỉ lấy số
|
||||
@@ -470,8 +509,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
|
||||
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
<label className="block text-xs font-bold text-[var(--text-secondary)] mb-1">Loại dịch vụ</label>
|
||||
<select className="w-full px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-xl outline-none text-sm"
|
||||
value={formData.expenseCategory} onChange={e => setFormData({...formData, expenseCategory: e.target.value})}>
|
||||
<option value="FOOD">Ăn uống</option>
|
||||
<option value="TRANSPORT">Di chuyển</option>
|
||||
@@ -482,20 +521,20 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Dịch vụ / Mô tả</label>
|
||||
<input className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
<label className="block text-xs font-bold text-[var(--text-secondary)] mb-1">Dịch vụ / Mô tả</label>
|
||||
<input className="w-full px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-xl outline-none text-sm"
|
||||
placeholder="Ví dụ: Ăn trưa, taxi, vé..."
|
||||
value={formData.expenseDescription} onChange={e => setFormData({...formData, expenseDescription: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Ghi chú chi phí</label>
|
||||
<textarea className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none resize-none text-sm" rows={2}
|
||||
<label className="block text-xs font-bold text-[var(--text-secondary)] mb-1">Ghi chú chi phí</label>
|
||||
<textarea className="w-full px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-xl outline-none resize-none text-sm" rows={2}
|
||||
placeholder="Ghi chú thêm..."
|
||||
value={formData.expenseNote} onChange={e => setFormData({...formData, expenseNote: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Thành viên đã thanh toán</label>
|
||||
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
<label className="block text-xs font-bold text-[var(--text-secondary)] mb-1">Thành viên đã thanh toán</label>
|
||||
<select className="w-full px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-xl outline-none text-sm"
|
||||
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
|
||||
<option value="">-- Chọn người thanh toán --</option>
|
||||
{currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.map((p: any) => {
|
||||
@@ -510,8 +549,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Gán vào chặng</label>
|
||||
<select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Gán vào chặng</label>
|
||||
<select required className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none"
|
||||
value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}>
|
||||
{legs.map(leg => (
|
||||
<option key={leg.id} value={leg.id}>
|
||||
@@ -523,19 +562,36 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Vĩ độ</label>
|
||||
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={formData.latitude} onChange={e => setFormData({...formData, latitude: e.target.value as any})} />
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Vĩ độ</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none"
|
||||
value={formData.latitude}
|
||||
onChange={e => setFormData({...formData, latitude: e.target.value as any})}
|
||||
onPaste={(e) => handleCoordinatePaste(e, 'latitude')}
|
||||
placeholder="13.50731401913824"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">💡 Dán "lat, lng" để tự Động điền cả vĩ độ và kinh độ</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Kinh độ</label>
|
||||
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={formData.longitude} onChange={e => setFormData({...formData, longitude: e.target.value as any})} />
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Kinh độ</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none"
|
||||
value={formData.longitude}
|
||||
onChange={e => setFormData({...formData, longitude: e.target.value as any})}
|
||||
onPaste={(e) => handleCoordinatePaste(e, 'longitude')}
|
||||
placeholder="109.28986362417251"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Loại</label>
|
||||
<select className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Loại</label>
|
||||
<select className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none"
|
||||
value={formData.type} onChange={e => setFormData({...formData, type: e.target.value as any})}>
|
||||
<option value="VISIT">Tham quan</option>
|
||||
<option value="EAT">Ăn uống</option>
|
||||
@@ -545,8 +601,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đầu</label>
|
||||
<input type="datetime-local" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Bắt đầu</label>
|
||||
<input type="datetime-local" className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none"
|
||||
value={formData.plannedStart} onChange={e => setFormData({...formData, plannedStart: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,12 +174,12 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200">
|
||||
<div className="relative w-full max-w-lg bg-[var(--surface)] rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<h2 className="text-2xl font-bold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<ImageIcon className="w-6 h-6 text-blue-600" /> Tải ảnh lên
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400">
|
||||
<button onClick={onClose} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors text-[var(--text-muted)]">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -187,22 +187,22 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-gray-200 rounded-[32px] p-10 flex flex-col items-center justify-center cursor-pointer hover:bg-blue-50/50 hover:border-blue-200 transition-all mb-6 group"
|
||||
className="border-2 border-dashed border-[var(--border)] rounded-[32px] p-10 flex flex-col items-center justify-center cursor-pointer hover:bg-[var(--background)]/50 hover:border-blue-200 transition-all mb-6 group"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} className="hidden" multiple accept="image/*" onChange={handleFileChange} />
|
||||
<div className="w-16 h-16 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 mb-4 group-hover:scale-110 transition-transform shadow-inner">
|
||||
<Upload className="w-8 h-8" />
|
||||
</div>
|
||||
<p className="text-sm font-black text-gray-700">Nhấn để chọn ảnh</p>
|
||||
<p className="text-xs text-gray-400 mt-1 font-medium">Hỗ trợ JPG, PNG, WEBP</p>
|
||||
<p className="text-sm font-black text-[var(--text-secondary)]">Nhấn để chọn ảnh</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1 font-medium">Hỗ trợ JPG, PNG, WEBP</p>
|
||||
</div>
|
||||
|
||||
{previews.length > 0 && (
|
||||
<div className="flex-1 overflow-y-auto mb-6 pr-2">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3">Đã chọn {previews.length} tệp</p>
|
||||
<p className="text-[10px] font-black text-[var(--text-muted)] uppercase tracking-widest mb-3">Đã chọn {previews.length} tệp</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{previews.map((src, idx) => (
|
||||
<div key={idx} className="relative aspect-square rounded-2xl overflow-hidden border border-gray-100 shadow-sm group">
|
||||
<div key={idx} className="relative aspect-square rounded-2xl overflow-hidden border border-[var(--border)] shadow-sm group">
|
||||
<img src={src} className="w-full h-full object-cover" alt="preview" />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -134,27 +134,27 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
|
||||
return (
|
||||
<div className="fixed inset-0 z-[5000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
|
||||
<div className="relative w-full max-w-md bg-[var(--surface)] rounded-[32px] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10">
|
||||
<div className="p-6 border-b border-[var(--border)] flex justify-between items-center bg-[var(--surface)] sticky top-0 z-10">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-gray-900 flex items-center gap-2">
|
||||
<h3 className="text-xl font-black text-[var(--text-primary)] flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-blue-600" />
|
||||
Bình luận
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 font-bold uppercase tracking-widest mt-1 truncate max-w-[250px]">{locationName}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] font-bold uppercase tracking-widest mt-1 truncate max-w-[250px]">{locationName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
<button onClick={onClose} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comment List */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50/50">
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-[var(--background)]/50">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-10"><Loader2 className="w-6 h-6 animate-spin text-blue-600" /></div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-400 italic text-sm">Chưa có bình luận nào.</div>
|
||||
<div className="text-center py-10 text-[var(--text-muted)] italic text-sm">Chưa có bình luận nào.</div>
|
||||
) : (
|
||||
comments.map((c) => (
|
||||
<div key={c.id} className="flex gap-3 animate-in slide-in-from-left-2 duration-300">
|
||||
@@ -162,21 +162,21 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
|
||||
<User className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-white p-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm">
|
||||
<div className="bg-[var(--surface)] p-3 rounded-2xl rounded-tl-none border border-[var(--border)] shadow-sm">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<p className="text-xs font-black text-gray-900">{c.userName}</p>
|
||||
<p className="text-xs font-black text-[var(--text-primary)]">{c.userName}</p>
|
||||
{(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && (
|
||||
<button
|
||||
onClick={() => setConfirmState({ open: true, commentId: c.id })}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
||||
className="text-[var(--text-muted)] hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{c.content}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{c.content}</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-1 ml-1 font-medium">
|
||||
<p className="text-[10px] text-[var(--text-muted)] mt-1 ml-1 font-medium">
|
||||
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -186,7 +186,7 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 bg-white border-t border-gray-100">
|
||||
<div className="p-4 bg-[var(--surface)] border-t border-[var(--border)]">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -194,7 +194,7 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
placeholder={isPublicView ? 'Đăng nhập để bình luận...' : 'Viết bình luận...'}
|
||||
className="flex-1 bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
||||
className="flex-1 bg-[var(--background)] border border-[var(--border)] rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] transition-all"
|
||||
/>
|
||||
<button onClick={handleSend} disabled={!newComment.trim() || isPublicView} className="p-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 disabled:opacity-50 disabled:bg-gray-300 transition-all active:scale-95 shadow-lg shadow-blue-100">
|
||||
<Send className="w-4 h-4" />
|
||||
|
||||
@@ -18,25 +18,25 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, title, messa
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onCancel} />
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||
<div className="relative w-full max-w-sm bg-[var(--surface)] rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-red-50 flex items-center justify-center text-red-500 shadow-inner">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
</div>
|
||||
<button onClick={onCancel} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
<button onClick={onCancel} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-black text-gray-900 mb-2">{title || 'Xác nhận'}</h3>
|
||||
<p className="text-sm text-gray-500 mb-8 leading-relaxed">
|
||||
<h3 className="text-xl font-black text-[var(--text-primary)] mb-2">{title || 'Xác nhận'}</h3>
|
||||
<p className="text-sm text-[var(--text-muted)] mb-8 leading-relaxed">
|
||||
{message || 'Bạn có chắc chắn muốn thực hiện hành động này không?'}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
|
||||
className="py-4 bg-[var(--background)] hover:bg-[var(--background)] text-[var(--text-secondary)] font-bold rounded-2xl transition-all active:scale-95"
|
||||
>
|
||||
Hủy bỏ
|
||||
</button>
|
||||
|
||||
@@ -103,18 +103,18 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||
<h2 className="text-xl font-bold text-gray-900">Tạo Tour mới</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">✕</button>
|
||||
<div className="relative w-full max-w-md bg-[var(--surface)] rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="p-5 border-b border-[var(--border)] flex justify-between items-center bg-[var(--background)]/50">
|
||||
<h2 className="text-xl font-bold text-[var(--text-primary)]">Tạo Tour mới</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4 overflow-y-auto flex-1">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label>
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Tên Tour</label>
|
||||
<input
|
||||
required
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="VD: Khám phá Đà Lạt"
|
||||
@@ -122,7 +122,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2 flex items-center gap-2">
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-2 flex items-center gap-2">
|
||||
<TagIcon className="w-4 h-4" /> Phân loại Tour
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -134,7 +134,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
||||
selectedTags.includes(tag)
|
||||
? 'bg-blue-600 text-white border-blue-600 shadow-md shadow-blue-100'
|
||||
: 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
|
||||
: 'bg-[var(--surface)] text-[var(--text-secondary)] border-[var(--border)] hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
@@ -148,7 +148,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
onChange={(e) => setCustomTag(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustomTag())}
|
||||
placeholder="Thêm nhãn tùy chỉnh..."
|
||||
className="flex-1 px-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="flex-1 px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-xl text-xs outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -161,9 +161,9 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Mô tả chuyến đi</label>
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Mô tả chuyến đi</label>
|
||||
<textarea
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 resize-none text-sm"
|
||||
className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 resize-none text-sm"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
@@ -173,19 +173,19 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đầu</label>
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Bắt đầu</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label>
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)] mb-1">Kết thúc</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
@@ -199,18 +199,18 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Người lớn</label>
|
||||
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
<label className="block text-[10px] font-bold text-[var(--text-muted)] uppercase mb-1">Người lớn</label>
|
||||
<input type="number" className="w-full px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-xl outline-none text-sm"
|
||||
value={adultCount} onChange={e => setAdultCount(Number(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Trẻ em</label>
|
||||
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
<label className="block text-[10px] font-bold text-[var(--text-muted)] uppercase mb-1">Trẻ em</label>
|
||||
<input type="number" className="w-full px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-xl outline-none text-sm"
|
||||
value={childCount} onChange={e => setChildCount(Number(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Giảm trẻ em %</label>
|
||||
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
<label className="block text-[10px] font-bold text-[var(--text-muted)] uppercase mb-1">Giảm trẻ em %</label>
|
||||
<input type="number" className="w-full px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-xl outline-none text-sm"
|
||||
value={childDiscount} onChange={e => setChildDiscount(Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,9 +218,9 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-bold text-gray-700">Thành viên tham gia ({members.length})</label>
|
||||
<label className="block text-sm font-bold text-[var(--text-secondary)]">Thành viên tham gia ({members.length})</label>
|
||||
{members.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3 mb-3 p-3 bg-gray-50 rounded-2xl border border-gray-100">
|
||||
<div className="flex flex-wrap gap-3 mb-3 p-3 bg-[var(--background)] rounded-2xl border border-[var(--border)]">
|
||||
{members.map((m) => {
|
||||
const initial = m.name?.charAt(0) || '?';
|
||||
return (
|
||||
@@ -232,13 +232,13 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMember(m.id)}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
|
||||
className="absolute -top-1 -right-1 w-4 h-4 bg-gray-400 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{m.name}</span>
|
||||
<span className="text-[10px] font-semibold text-[var(--text-secondary)] max-w-[72px] truncate">{m.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -247,13 +247,13 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm font-bold text-gray-800"
|
||||
className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--border)] rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm font-bold text-[var(--text-primary)]"
|
||||
placeholder="Tìm email hoặc nhập tên thành viên ngoài hệ thống..."
|
||||
value={query}
|
||||
onChange={(e) => searchUsers(e.target.value)}
|
||||
/>
|
||||
{(results.length > 0 || query.trim()) && (
|
||||
<div className="absolute bottom-full mb-2 left-0 right-0 bg-white border border-gray-100 rounded-2xl shadow-xl z-20 max-h-48 overflow-y-auto p-2 space-y-1">
|
||||
<div className="absolute bottom-full mb-2 left-0 right-0 bg-[var(--surface)] border border-[var(--border)] rounded-2xl shadow-xl z-20 max-h-48 overflow-y-auto p-2 space-y-1">
|
||||
{query.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -263,7 +263,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 rounded-xl text-blue-600 font-bold flex items-center gap-2"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-[var(--background)] rounded-xl text-blue-600 font-bold flex items-center gap-2"
|
||||
>
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-600 text-sm font-black">+</span>
|
||||
<span>Thêm thành viên ngoài hệ thống: "{query.trim()}"</span>
|
||||
@@ -274,10 +274,10 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => confirmAddMember(u)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 rounded-xl flex flex-col"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-[var(--background)] rounded-xl flex flex-col"
|
||||
>
|
||||
<span className="font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</span>
|
||||
<span className="text-xs text-gray-500">{u.email}</span>
|
||||
<span className="font-bold text-[var(--text-primary)]">{u.name || 'Chưa đặt tên'}</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">{u.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
@@ -48,8 +48,9 @@ export const ItineraryTimeline = ({
|
||||
}: {
|
||||
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
|
||||
onEditLocation?: (location: any) => void,
|
||||
onQuickNote?: (name: string) => void,
|
||||
onQuickNote?: (data: { legId: string; location: any; leg: any }) => void,
|
||||
onNavigate?: (location: any) => void,
|
||||
onOpenNavigationPage?: (routeData: { origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string }) => void,
|
||||
onSuccess?: () => void,
|
||||
isPublicView?: boolean
|
||||
}) => {
|
||||
@@ -57,7 +58,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
|
||||
@@ -76,6 +76,14 @@ export const ItineraryTimeline = ({
|
||||
const [commentLocationId, setCommentLocationId] = useState('');
|
||||
const [commentLocationName, setCommentLocationName] = useState('');
|
||||
|
||||
// State to track single expanded stage (exclusive single-expansion mode)
|
||||
const [expandedStageId, setExpandedStageId] = useState<string | null>(legs.length > 0 ? legs[0]?.id : null);
|
||||
|
||||
const toggleStageExpanded = (legId: string) => {
|
||||
// Exclusive mode: if clicking the same stage, close it. Otherwise, open only the clicked one.
|
||||
setExpandedStageId(prevId => prevId === legId ? null : legId);
|
||||
};
|
||||
|
||||
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
|
||||
const handleCommentIncrement = (locationId: string) => {
|
||||
const currentLegs = useTourStore.getState().legs;
|
||||
@@ -120,6 +128,40 @@ export const ItineraryTimeline = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerNavigation = (location: any) => {
|
||||
if (!location.latitude || !location.longitude) {
|
||||
alert("Địa điểm này chưa được cấu hình tọa độ GPS chính xác.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
alert("Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị toàn cầu GPS.");
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
onOpenNavigationPage?.({
|
||||
origin: {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude
|
||||
},
|
||||
destination: {
|
||||
lat: parseFloat(location.latitude),
|
||||
lng: parseFloat(location.longitude),
|
||||
name: location.name || "Điểm đến chọn sẵn"
|
||||
},
|
||||
tourTitle: currentTour?.title || "Chi tiết hành trình"
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
console.error("Error fetching native geolocation metrics:", error);
|
||||
alert("Không thể truy cập vị trí hiện tại của bạn. Vui lòng bật định vị GPS của thiết bị.");
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 8000 }
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddLeg = async () => {
|
||||
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
|
||||
if (note && currentTour) {
|
||||
@@ -196,12 +238,28 @@ export const ItineraryTimeline = ({
|
||||
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("ItineraryTimeline: Legs updated", legs);
|
||||
}, [legs]);
|
||||
const storedLegId = sessionStorage.getItem('defaultExpandedLegId');
|
||||
if (storedLegId) {
|
||||
setExpandedStageId(storedLegId);
|
||||
sessionStorage.removeItem('defaultExpandedLegId');
|
||||
|
||||
setTimeout(() => {
|
||||
const targetElement = document.getElementById(`leg-anchor-node-${storedLegId}`);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
window.history.replaceState({}, document.title);
|
||||
}, 150);
|
||||
} else if (legs.length > 0 && !expandedStageId) {
|
||||
setExpandedStageId(legs[0].id);
|
||||
}
|
||||
}, [legs, expandedStageId]);
|
||||
|
||||
return (
|
||||
<div id="itinerary-timeline-print-zone" className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
||||
<div className="px-2 pt-4">
|
||||
<div id="itinerary-timeline-print-zone" className="timeline-scroll-container itinerary-timeline-container">
|
||||
{legs.length === 0 ? (
|
||||
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
||||
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
@@ -220,9 +278,12 @@ export const ItineraryTimeline = ({
|
||||
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
||||
|
||||
return (
|
||||
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
{/* Leg Header */}
|
||||
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
|
||||
<section key={leg.id} id={`leg-anchor-node-${leg.id}`} className={`folder-node-wrapper scroll-mt-[70px] ${expandedStageId === leg.id ? 'expanded' : 'collapsed'}`}>
|
||||
{/* Folder header row - clickable to toggle exclusive expansion */}
|
||||
<div
|
||||
onClick={() => toggleStageExpanded(leg.id)}
|
||||
className="folder-header-row animate-in fade-in slide-in-from-bottom-4 duration-300 hover:bg-gray-50/50 transition-colors"
|
||||
>
|
||||
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm shrink-0">
|
||||
@@ -245,32 +306,47 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{/* Expand/Collapse indicator button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleStageExpanded(leg.id); }}
|
||||
className="ml-2 p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all shrink-0"
|
||||
title={expandedStageId === leg.id ? 'Collapse chặng này' : 'Expand chặng này'}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 transition-transform duration-300 ${
|
||||
expandedStageId === leg.id ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons Row - Below stage title */}
|
||||
<div className="px-4 py-2 bg-white border-b border-gray-100 flex items-center gap-2 flex-wrap">
|
||||
{canEdit && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onAddLocation?.(leg.id, legIdx === 0 && leg.locations.length === 0)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); onAddLocation?.(leg.id, legIdx === 0 && leg.locations.length === 0); }}
|
||||
className="p-2 bg-gray-50 text-gray-600 hover:text-blue-600 hover:bg-blue-100 rounded-xl transition-all border border-gray-200"
|
||||
title="Thêm địa điểm vào chặng này"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditLeg(leg)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); handleEditLeg(leg); }}
|
||||
className="p-2 bg-gray-50 text-gray-600 hover:text-blue-600 hover:bg-blue-100 rounded-xl transition-all border border-gray-200"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteLeg(leg.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteLeg(leg.id); }}
|
||||
className="p-2 bg-gray-50 text-red-600 hover:text-white-600 hover:bg-red-100 rounded-xl transition-all border border-gray-200"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{leg.totalDistance !== undefined && (
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100">
|
||||
{leg.totalDistance} km
|
||||
</div>
|
||||
@@ -280,27 +356,21 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{totalDwellMinutes > 0 && ( // Always show dwell time
|
||||
<div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
|
||||
{totalDwellMinutes > 0 && (
|
||||
<div className="text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1 flex">
|
||||
<Clock className="w-3 h-3" />
|
||||
Dừng: {formatTravelTime(totalDwellMinutes)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (
|
||||
<button
|
||||
onClick={() => optimizeRouting(leg.id)}
|
||||
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all"
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
Tối ưu
|
||||
</button>
|
||||
)} {/* Only show optimize button if canEdit */}
|
||||
</div>
|
||||
|
||||
{/* Vertical Line for the whole leg */}
|
||||
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */}
|
||||
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
|
||||
{/* Scrollable content body with proper z-index layering */}
|
||||
<div className="child-nodes-list-wrapper">
|
||||
{/* Folder child content box - Grid accordion for exclusive expansion */}
|
||||
<div className={`folder-child-content-box ${expandedStageId === leg.id ? 'expanded' : ''}`}>
|
||||
<div className="child-nodes-list relative">
|
||||
{/* Vertical Line for the whole leg - Dynamic height */}
|
||||
<div className="absolute left-6 top-16 w-0.5 bg-blue-100 -z-0 h-full" />
|
||||
|
||||
<div className="ml-2">
|
||||
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
|
||||
@@ -313,11 +383,11 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAddLocation?.(leg.id, true)}
|
||||
className="flex-1 bg-blue-50/20 p-4 rounded-xl border border-dashed border-blue-100 hover:border-blue-400 hover:bg-blue-50 transition-all flex items-center justify-between group"
|
||||
className="flex-1 bg-blue-50/20 dark:bg-slate-800/20 p-4 rounded-xl border border-dashed border-blue-100 dark:border-slate-700 hover:border-blue-400 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-slate-800/40 transition-all flex items-center justify-between group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<span className="inline-block px-2 py-0.5 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm xuất phát</span>
|
||||
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn để ghim điểm bắt đầu cho Tour...</h3>
|
||||
<span className="inline-block px-2 py-0.5 bg-blue-50 dark:bg-slate-700 text-blue-600 dark:text-blue-300 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm xuất phát</span>
|
||||
<h3 className="font-bold text-gray-400 dark:text-slate-400 text-sm italic">Nhấn để ghim điểm bắt đầu cho Tour...</h3>
|
||||
</div>
|
||||
<Plus className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
@@ -328,17 +398,17 @@ export const ItineraryTimeline = ({
|
||||
{legIdx === legs.length - 1 && !legs.some((l: any) => l.locations.some((loc: any) => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0)) && (
|
||||
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
|
||||
<div className="z-10 mt-1.5 mr-4">
|
||||
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-red-200 flex items-center justify-center text-red-400">
|
||||
<div className="w-8 h-8 bg-white dark:bg-slate-800 rounded-full border-2 border-dashed border-red-200 dark:border-red-700 flex items-center justify-center text-red-400">
|
||||
<Flag className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAddLocation?.(leg.id, false, true)}
|
||||
className="flex-1 bg-red-50/20 p-4 rounded-xl border border-dashed border-red-100 hover:border-red-400 hover:bg-red-50 transition-all flex items-center justify-between group"
|
||||
className="flex-1 bg-red-50/20 dark:bg-red-900/20 p-4 rounded-xl border border-dashed border-red-100 dark:border-red-700 hover:border-red-400 dark:hover:border-red-400 hover:bg-red-50 dark:hover:bg-red-900/40 transition-all flex items-center justify-between group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
||||
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn để ghim điểm kết thúc cho Tour...</h3>
|
||||
<span className="inline-block px-2 py-0.5 bg-red-50 dark:bg-red-700 text-red-600 dark:text-red-300 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
||||
<h3 className="font-bold text-gray-400 dark:text-slate-400 text-sm italic">Nhấn để ghim điểm kết thúc cho Tour...</h3>
|
||||
</div>
|
||||
<Plus className="w-5 h-5 text-red-500 group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
@@ -371,7 +441,7 @@ export const ItineraryTimeline = ({
|
||||
<div key={location.id}>
|
||||
<div className="relative flex group mb-6">
|
||||
{/* Timeline Node */}
|
||||
<div className="z-10 mt-1.5 mr-4">
|
||||
<div className="z-10 -ml-3.5 mr-1.5 mt-1.5">
|
||||
<button
|
||||
onClick={() => handleStatusClick(location)}
|
||||
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
||||
@@ -448,12 +518,12 @@ export const ItineraryTimeline = ({
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onQuickNote(location.name);
|
||||
onQuickNote({ legId: leg.id, location, leg });
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-bold transition-all border border-amber-100"
|
||||
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100"
|
||||
title="Ghi chú nhanh"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
<FileText className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -468,6 +538,16 @@ export const ItineraryTimeline = ({
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{location._count?.comments > 0 && `(${location._count.comments})`}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTriggerNavigation(location);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100"
|
||||
title="Chỉ đường từ vị trí của bạn"
|
||||
>
|
||||
<Navigation className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center text-sm font-black text-blue-600">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
@@ -522,8 +602,11 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div> {/* End of ml-2 wrapper */}
|
||||
</div> {/* End of child-nodes-list */}
|
||||
</div> {/* End of folder-child-content-box */}
|
||||
</div> {/* End of child-nodes-list-wrapper */}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -533,48 +616,47 @@ export const ItineraryTimeline = ({
|
||||
<div className="flex flex-col gap-3 pb-20 mt-8">
|
||||
<button
|
||||
onClick={handleDeclareLegs}
|
||||
className="w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold"
|
||||
className="w-full py-4 rounded-2xl bg-white dark:bg-slate-800 text-blue-600 dark:text-blue-300 border-2 border-dashed border-blue-200 dark:border-slate-700 hover:bg-blue-50 dark:hover:bg-slate-700 transition-all flex items-center justify-center gap-2 text-sm font-bold"
|
||||
>
|
||||
<List className="w-5 h-5" />
|
||||
{legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddLeg}
|
||||
className="w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold"
|
||||
className="w-full py-4 rounded-2xl bg-blue-600 dark:bg-blue-700 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> Thêm chặng lẻ vào cuối
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Khai báo số chặng (Popover) */}
|
||||
{isLegCountModalOpen && (
|
||||
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsLegCountModalOpen(false)} />
|
||||
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||
<div className="absolute inset-0 bg-gray-900/60 dark:bg-slate-950/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsLegCountModalOpen(false)} />
|
||||
<div className="relative w-full max-w-sm bg-white dark:bg-slate-900 rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-black text-gray-900">Số chặng lộ trình</h3>
|
||||
<button onClick={() => setIsLegCountModalOpen(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-xl font-black text-gray-900 dark:text-white">Số chặng lộ trình</h3>
|
||||
<button onClick={() => setIsLegCountModalOpen(false)} className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400 dark:text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 mb-6 leading-relaxed">
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mb-6 leading-relaxed">
|
||||
Bạn muốn chia chuyến đi này thành bao nhiêu chặng nhỏ? (Tối đa 20 chặng)
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-6 mb-8">
|
||||
<button
|
||||
onClick={() => setTempLegCount(Math.max(1, tempLegCount - 1))}
|
||||
className="w-12 h-12 rounded-2xl border-2 border-gray-100 flex items-center justify-center text-2xl font-bold text-gray-400 hover:border-blue-200 hover:text-blue-600 transition-all"
|
||||
className="w-12 h-12 rounded-2xl border-2 border-gray-100 dark:border-slate-700 flex items-center justify-center text-2xl font-bold text-gray-400 dark:text-slate-400 hover:border-blue-200 dark:hover:border-blue-500 hover:text-blue-600 dark:hover:text-blue-400 transition-all"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="text-4xl font-black text-blue-600 w-12 text-center">{tempLegCount}</span>
|
||||
<span className="text-4xl font-black text-blue-600 dark:text-blue-400 w-12 text-center">{tempLegCount}</span>
|
||||
<button
|
||||
onClick={() => setTempLegCount(Math.min(20, tempLegCount + 1))}
|
||||
className="w-12 h-12 rounded-2xl border-2 border-gray-100 flex items-center justify-center text-2xl font-bold text-gray-400 hover:border-blue-200 hover:text-blue-600 transition-all"
|
||||
className="w-12 h-12 rounded-2xl border-2 border-gray-100 dark:border-slate-700 flex items-center justify-center text-2xl font-bold text-gray-400 dark:text-slate-400 hover:border-blue-200 dark:hover:border-blue-500 hover:text-blue-600 dark:hover:text-blue-400 transition-all"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -582,7 +664,7 @@ export const ItineraryTimeline = ({
|
||||
|
||||
<button
|
||||
onClick={confirmDeclareLegs}
|
||||
className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
|
||||
className="w-full py-4 bg-blue-600 dark:bg-blue-700 hover:bg-blue-700 dark:hover:bg-blue-800 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 dark:shadow-blue-950 transition-all active:scale-95"
|
||||
>
|
||||
Xác nhận
|
||||
</button>
|
||||
@@ -593,58 +675,58 @@ export const ItineraryTimeline = ({
|
||||
{/* Modal Chỉnh sửa Chặng (Popover) */}
|
||||
{isEditModalOpen && (
|
||||
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsEditModalOpen(false)} />
|
||||
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||
<div className="absolute inset-0 bg-gray-900/60 dark:bg-slate-950/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsEditModalOpen(false)} />
|
||||
<div className="relative w-full max-w-md bg-white dark:bg-slate-900 rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-black text-gray-900">Chỉnh sửa Chặng</h3>
|
||||
<button onClick={() => setIsEditModalOpen(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-xl font-black text-gray-900 dark:text-white">Chỉnh sửa Chặng</h3>
|
||||
<button onClick={() => setIsEditModalOpen(false)} className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400 dark:text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Tên chặng</label>
|
||||
<label className="block text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">Tên chặng</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLegData.note}
|
||||
onChange={(e) => setEditingLegData({ ...editingLegData, note: e.target.value })}
|
||||
placeholder="VD: Ngày 1: Khởi hành"
|
||||
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
||||
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm text-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">
|
||||
<label className="flex items-center gap-2 text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">
|
||||
<AlignLeft className="w-3 h-3" /> Mô tả chi tiết
|
||||
</label>
|
||||
<textarea
|
||||
value={editingLegData.description}
|
||||
onChange={(e) => setEditingLegData({ ...editingLegData, description: e.target.value })}
|
||||
placeholder="Mô tả các hoạt động chính trong chặng này..."
|
||||
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none"
|
||||
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none text-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">
|
||||
<label className="flex items-center gap-2 text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">
|
||||
<CalendarIcon className="w-3 h-3" /> Bắt đầu
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editingLegData.startDate}
|
||||
onChange={(e) => setEditingLegData({ ...editingLegData, startDate: e.target.value })}
|
||||
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
||||
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm text-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Kết thúc</label>
|
||||
<label className="block text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">Kết thúc</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editingLegData.endDate}
|
||||
onChange={(e) => setEditingLegData({ ...editingLegData, endDate: e.target.value })}
|
||||
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
||||
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm text-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -653,13 +735,13 @@ export const ItineraryTimeline = ({
|
||||
<div className="grid grid-cols-2 gap-3 mt-8">
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
|
||||
className="py-4 bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-300 font-bold rounded-2xl transition-all active:scale-95"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
onClick={saveLegEdit}
|
||||
className="py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
|
||||
className="py-4 bg-blue-600 dark:bg-blue-700 hover:bg-blue-700 dark:hover:bg-blue-800 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 dark:shadow-blue-950 transition-all active:scale-95"
|
||||
>
|
||||
Lưu thay đổi
|
||||
</button>
|
||||
|
||||
@@ -209,7 +209,7 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/50">
|
||||
<div className="w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="w-full max-w-md bg-[var(--surface)] rounded-[32px] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="relative h-32 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
@@ -228,10 +228,10 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2 text-center">
|
||||
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-2 text-center">
|
||||
Gia nhập tour
|
||||
</h2>
|
||||
<p className="text-center text-gray-600 mb-6">
|
||||
<p className="text-center text-[var(--text-secondary)] mb-6">
|
||||
Đăng nhập để tham gia chuyến du lịch này
|
||||
</p>
|
||||
|
||||
@@ -248,45 +248,45 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
|
||||
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
<div className="w-full border-t border-[var(--border)]" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Hoặc</span>
|
||||
<span className="px-2 bg-[var(--surface)] text-[var(--text-muted)]">Hoặc</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email/Password Form */}
|
||||
<form onSubmit={handleEmailPasswordJoin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
<label className="block text-sm font-semibold text-[var(--text-secondary)] mb-2">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" />
|
||||
<Mail className="absolute left-3 top-3.5 w-5 h-5 text-[var(--text-muted)]" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-[var(--border)] rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition bg-[var(--background)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
<label className="block text-sm font-semibold text-[var(--text-secondary)] mb-2">
|
||||
Mật khẩu
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" />
|
||||
<Lock className="absolute left-3 top-3.5 w-5 h-5 text-[var(--text-muted)]" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Nhập mật khẩu"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-[var(--border)] rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition bg-[var(--background)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -311,7 +311,7 @@ export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
|
||||
</form>
|
||||
|
||||
{/* Signup Link */}
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
<div className="mt-6 text-center text-sm text-[var(--text-secondary)]">
|
||||
Chưa có tài khoản?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
interface NavModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
routeData: {
|
||||
origin: { lat: number; lng: number } | null;
|
||||
destination: { lat: number; lng: number; name: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface OSRMRoute {
|
||||
geometry: {
|
||||
coordinates: number[][];
|
||||
type: string;
|
||||
};
|
||||
legs: { distance: number; duration: number }[];
|
||||
distance: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const FitBounds = ({ coords }: { coords: [number, number][] }) => {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (coords.length > 0) {
|
||||
map.fitBounds(coords, { padding: [60, 60], maxZoom: 15 });
|
||||
}
|
||||
}, [map, coords]);
|
||||
return null;
|
||||
};
|
||||
|
||||
export const LocationNavigationModal: React.FC<NavModalProps> = ({ isOpen, onClose, routeData }) => {
|
||||
const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null);
|
||||
const [isSearchingRoute, setIsSearchingRoute] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [routeInfo, setRouteInfo] = useState<{ distance: string; duration: string } | null>(null);
|
||||
|
||||
const fetchLockRef = useRef(false);
|
||||
|
||||
const originLat = routeData?.origin?.lat;
|
||||
const originLng = routeData?.origin?.lng;
|
||||
const destLat = routeData?.destination?.lat;
|
||||
const destLng = routeData?.destination?.lng;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !originLat || !originLng || !destLat || !destLng) {
|
||||
setRouteGeometry(null);
|
||||
setError(null);
|
||||
setRouteInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetchLockRef.current) return;
|
||||
|
||||
const calculateOptimalRoute = async () => {
|
||||
try {
|
||||
setIsSearchingRoute(true);
|
||||
fetchLockRef.current = true;
|
||||
|
||||
const url = `https://router.project-osrm.org/route/v1/driving/${originLng},${originLat};${destLng},${destLat}?overview=full&geometries=geojson`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`OSRM error: ${res.status}`);
|
||||
const data: { code: string; routes: OSRMRoute[] } = await res.json();
|
||||
if (data.code !== 'Ok' || !data.routes?.length) throw new Error('Không tìm thấy lộ trình phù hợp');
|
||||
|
||||
const route = data.routes[0];
|
||||
const coords = route.geometry.coordinates.map((c: number[]) => [c[1], c[0]] as [number, number]);
|
||||
setRouteGeometry(coords);
|
||||
|
||||
const hours = Math.floor(route.duration / 3600);
|
||||
const minutes = Math.round((route.duration % 3600) / 60);
|
||||
const durationStr = hours > 0 ? `${hours}h${minutes}p` : `${minutes}p`;
|
||||
setRouteInfo({
|
||||
distance: (route.distance / 1000).toFixed(1),
|
||||
duration: durationStr
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSearchingRoute(false);
|
||||
fetchLockRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
calculateOptimalRoute();
|
||||
|
||||
return () => {
|
||||
fetchLockRef.current = false;
|
||||
setIsSearchingRoute(false);
|
||||
};
|
||||
}, [isOpen, originLat, originLng, destLat, destLng]);
|
||||
|
||||
const userIcon = useMemo(() => L.divIcon({
|
||||
className: '!bg-transparent !border-none',
|
||||
html: `<div class="w-8 h-8 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center"><div class="w-2 h-2 bg-white rounded-full"></div></div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
}), []);
|
||||
|
||||
const destIcon = useMemo(() => L.divIcon({
|
||||
className: '!bg-transparent !border-none',
|
||||
html: `<div class="w-8 h-8 bg-red-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-white text-xs font-black">Đ</div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
}), []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const center: [number, number] = routeData.origin && routeData.destination
|
||||
? [(routeData.origin.lat + routeData.destination.lat) / 2, (routeData.origin.lng + routeData.destination.lng) / 2]
|
||||
: [0, 0];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 w-full max-w-4xl h-[80vh] rounded-2xl overflow-hidden flex flex-col shadow-2xl">
|
||||
{/* Modal Header */}
|
||||
<div className="p-4 bg-slate-800 border-b border-slate-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-md font-bold text-white flex items-center gap-2">
|
||||
📍 Chỉ đường đến: <span className="text-blue-400">{routeData.destination?.name}</span>
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Tuyến đường ngắn nhất từ vị trí hiện tại của bạn</p>
|
||||
{routeInfo && (
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<span className="text-xs font-bold text-blue-300">{routeInfo.distance} km</span>
|
||||
<span className="text-xs text-gray-500">|</span>
|
||||
<span className="text-xs font-bold text-green-300">~{routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white text-sm font-bold px-3 py-1.5 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Đóng [X]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Map Container */}
|
||||
<div className="relative flex-1 bg-slate-950">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={14}
|
||||
className="h-full w-full"
|
||||
zoomControl={true}
|
||||
attributionControl={false}
|
||||
>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
|
||||
{routeData.origin && (
|
||||
<Marker position={[routeData.origin.lat, routeData.origin.lng]} icon={userIcon}>
|
||||
<Popup>
|
||||
<div className="text-xs font-bold text-blue-600">Bạn đang ở đây</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
{routeData.destination && (
|
||||
<Marker position={[routeData.destination.lat, routeData.destination.lng]} icon={destIcon}>
|
||||
<Popup>
|
||||
<div className="text-xs font-bold text-red-600">{routeData.destination.name}</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
{routeGeometry && <FitBounds coords={routeGeometry} />}
|
||||
{routeGeometry && (
|
||||
<Polyline positions={routeGeometry} color="#2563eb" weight={6} opacity={0.9} smoothFactor={1} />
|
||||
)}
|
||||
</MapContainer>
|
||||
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-red-900 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg z-[1000]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -172,16 +172,16 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
||||
/>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-300">
|
||||
<div className="relative w-full max-w-md bg-[var(--surface)] rounded-3xl shadow-2xl overflow-hidden max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-300">
|
||||
<div className="p-8 sm:p-10">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">Đăng nhập</h2>
|
||||
<p className="text-gray-500 mt-2">Chào mừng bạn quay trở lại!</p>
|
||||
<h2 className="text-3xl font-bold text-[var(--text-primary)]">Đăng nhập</h2>
|
||||
<p className="text-[var(--text-secondary)] mt-2">Chào mừng bạn quay trở lại!</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600"
|
||||
className="p-2 hover:bg-[var(--background)] rounded-full transition-colors text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
@@ -195,34 +195,34 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-gray-700 ml-1">Tài khoản hoặc Email</label>
|
||||
<label className="text-sm font-semibold text-[var(--text-secondary)] ml-1">Tài khoản hoặc Email</label>
|
||||
<div className="relative group">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="admin hoặc email..."
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<label className="text-sm font-semibold text-gray-700">Mật khẩu</label>
|
||||
<label className="text-sm font-semibold text-[var(--text-secondary)]">Mật khẩu</label>
|
||||
<button className="text-xs font-bold text-blue-600 hover:text-blue-700">Quên mật khẩu?</button>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,15 +239,15 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
||||
|
||||
<div className="relative my-6 flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200"></div>
|
||||
<div className="w-full border-t border-[var(--border)]"></div>
|
||||
</div>
|
||||
<span className="relative px-3 bg-white text-xs font-bold text-gray-400 uppercase">Hoặc</span>
|
||||
<span className="relative px-3 bg-[var(--surface)] text-xs font-bold text-[var(--text-muted)] uppercase">Hoặc</span>
|
||||
</div>
|
||||
|
||||
<div id="google-signin-btn-login" className="w-full flex justify-center"></div>
|
||||
|
||||
<div className="mt-10 pt-8 border-t border-gray-100 text-center">
|
||||
<p className="text-gray-500">
|
||||
<div className="mt-10 pt-8 border-t border-[var(--border)] text-center">
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
Chưa có tài khoản?{' '}
|
||||
<button
|
||||
onClick={() => { onClose(); onSwitchToSignup?.(); }}
|
||||
|
||||
@@ -39,13 +39,13 @@ export const NotificationModal: React.FC<NotificationModalProps> = ({
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onConfirm} />
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl overflow-hidden p-8 text-center animate-in zoom-in-95 duration-200">
|
||||
<div className="relative w-full max-w-sm bg-[var(--surface)] rounded-[32px] shadow-2xl overflow-hidden p-8 text-center animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-center mb-5">
|
||||
{icons[type]}
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-black text-gray-900 mb-2">{title}</h2>
|
||||
<p className="text-gray-500 text-sm leading-relaxed mb-8">
|
||||
<h2 className="text-xl font-black text-[var(--text-primary)] mb-2">{title}</h2>
|
||||
<p className="text-[var(--text-muted)] text-sm leading-relaxed mb-8">
|
||||
{message || "Bạn không được phép gỡ bỏ thành viên này!"}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit
|
||||
import { io } from 'socket.io-client';
|
||||
import { CoordinateSelectModal } from './CoordinateSelectModal';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import { useNotification } from '../hooks/useNotification';
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
@@ -48,6 +50,8 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
onUpdatePhoto
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const notify = useNotification();
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -358,7 +362,12 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId: string) => {
|
||||
if (!confirm(t('confirmDeleteComment') || 'Bạn có chắc chắn muốn xóa bình luận này không?')) return;
|
||||
const shouldDelete = await confirm({
|
||||
title: t('deleteComment') || 'Xóa bình luận',
|
||||
message: t('confirmDeleteComment') || 'Bạn có chắc chắn muốn xóa bình luận này không?'
|
||||
});
|
||||
if (!shouldDelete) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
||||
const res = await fetch(`/api/v1/public-photos/comments/${commentId}`, {
|
||||
@@ -369,13 +378,14 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
});
|
||||
if (res.ok) {
|
||||
setComments(prev => prev.filter(c => c.id !== commentId));
|
||||
notify({ title: 'Thành công', message: 'Bình luận đã được xóa.', type: 'success' });
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.message || 'Lỗi khi xóa bình luận.');
|
||||
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi xóa bình luận.', type: 'error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Lỗi khi xóa bình luận:', error);
|
||||
alert('Không thể kết nối đến máy chủ.');
|
||||
notify({ title: 'Lỗi', message: 'Không thể kết nối đến máy chủ.', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Check, Plus, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
|
||||
interface TagSelectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (tags: string[]) => void;
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
const AVAILABLE_TAGS = [
|
||||
{ id: 'phong-canh', label: '🏞️ Phong cảnh' },
|
||||
{ id: 'con-nguoi', label: '👥 Con người' },
|
||||
{ id: 'doi-thuong', label: '🎒 Đời thường' },
|
||||
{ id: 'bien', label: '🌊 Biển' },
|
||||
{ id: 'nui', label: '⛰️ Núi' },
|
||||
{ id: 'do-thi', label: '🏙️ Đô thị' },
|
||||
{ id: 'thuc-an', label: '🍜 Thức ăn' },
|
||||
{ id: 'cho', label: '🛍️ Chợ' },
|
||||
{ id: 'hien-dai', label: '🏗️ Hiện đại' },
|
||||
{ id: 'dong-vat', label: '🦁 Động vật' },
|
||||
{ id: 'thu-cung', label: '🐕 Thú cưng' }
|
||||
];
|
||||
|
||||
export const TagSelectModal: React.FC<TagSelectModalProps> = ({ isOpen, onClose, onConfirm, photoUrl }) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [customTagInput, setCustomTagInput] = useState('');
|
||||
const [customTags, setCustomTags] = useState<string[]>([]);
|
||||
|
||||
const toggleTag = (tagId: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tagId)
|
||||
? prev.filter(t => t !== tagId)
|
||||
: [...prev, tagId]
|
||||
);
|
||||
};
|
||||
|
||||
const addCustomTag = () => {
|
||||
const trimmedTag = customTagInput.trim();
|
||||
if (trimmedTag && !customTags.includes(trimmedTag)) {
|
||||
setCustomTags(prev => [...prev, trimmedTag]);
|
||||
setCustomTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeCustomTag = (tag: string) => {
|
||||
setCustomTags(prev => prev.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const allTags = [...selectedTags, ...customTags];
|
||||
onConfirm(allTags);
|
||||
setSelectedTags([]);
|
||||
setCustomTags([]);
|
||||
setCustomTagInput('');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedTags([]);
|
||||
setCustomTags([]);
|
||||
setCustomTagInput('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<div className="relative bg-white dark:bg-slate-900 rounded-3xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-300">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-700 px-6 py-5 flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
🏷️ Lựa chọn thẻ
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-xl transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Image Preview */}
|
||||
{photoUrl && (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt="Preview"
|
||||
className="max-w-full h-auto max-h-48 rounded-2xl shadow-lg object-cover border-2 border-gray-200 dark:border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Predefined Tags */}
|
||||
<div>
|
||||
<p className="text-xs font-black text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-widest">Thẻ có sẵn</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AVAILABLE_TAGS.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl transition-all text-xs font-bold border-2 ${
|
||||
selectedTags.includes(tag.id)
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-slate-800 border-gray-200 dark:border-slate-700 text-gray-900 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{tag.label.split(' ')[0]}</span>
|
||||
<span className="text-[10px]">{tag.label.substring(2)}</span>
|
||||
{selectedTags.includes(tag.id) && (
|
||||
<Check className="w-3 h-3 ml-auto" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Tag Input */}
|
||||
<div className="space-y-3 border-t border-gray-200 dark:border-slate-700 pt-4">
|
||||
<p className="text-xs font-black text-gray-600 dark:text-gray-400 uppercase tracking-widest">Thêm thẻ khác</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customTagInput}
|
||||
onChange={(e) => setCustomTagInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
addCustomTag();
|
||||
}
|
||||
}}
|
||||
placeholder="Nhập thẻ mới..."
|
||||
className="flex-1 px-3 py-2 border-2 border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-gray-900 dark:text-white rounded-xl text-sm font-bold placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-blue-600"
|
||||
/>
|
||||
<button
|
||||
onClick={addCustomTag}
|
||||
className="px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold transition-colors flex items-center gap-1 active:scale-95"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom Tags Display */}
|
||||
{customTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customTags.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 text-xs px-3 py-1.5 rounded-full font-semibold flex items-center gap-2 group"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => removeCustomTag(tag)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 hover:text-red-600" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* All Selected Tags Summary */}
|
||||
{(selectedTags.length > 0 || customTags.length > 0) && (
|
||||
<div className="pt-3 border-t border-gray-200 dark:border-slate-700">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2 font-bold">
|
||||
✓ {selectedTags.length + customTags.length} thẻ đã chọn
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTags.map(tagId => {
|
||||
const tag = AVAILABLE_TAGS.find(t => t.id === tagId);
|
||||
return (
|
||||
<span
|
||||
key={tagId}
|
||||
className="bg-blue-100 dark:bg-blue-950 text-blue-700 dark:text-blue-300 text-xs px-3 py-1.5 rounded-full font-semibold"
|
||||
>
|
||||
{tag?.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{customTags.map((tag, idx) => (
|
||||
<span
|
||||
key={`custom-${idx}`}
|
||||
className="bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 text-xs px-3 py-1.5 rounded-full font-semibold"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-white dark:bg-slate-900 border-t border-gray-200 dark:border-slate-700 px-6 py-4 flex gap-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-1 px-4 py-3 rounded-xl border-2 border-gray-200 dark:border-slate-700 text-gray-900 dark:text-white font-bold hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
{t('cancel') || 'Hủy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-blue-600 hover:bg-blue-700 text-white font-bold transition-colors shadow-md active:scale-95"
|
||||
>
|
||||
{t('confirm') || 'Xác nhận'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,9 +5,10 @@ import { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
interface TourChatProps {
|
||||
tourId: string;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false }) => {
|
||||
const notify = useNotification();
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
@@ -416,21 +417,24 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// currentUserId is defined at the top
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-150 rounded-2xl shadow-lg overflow-hidden flex flex-col h-[500px]">
|
||||
{/* Chat Header */}
|
||||
<div className="p-4 border-b border-gray-150 flex items-center gap-2 bg-gray-50/50">
|
||||
<div className={`chat-viewport-wrapper flex flex-col !overflow-hidden !w-full ${embedded ? 'h-full flex-1' : 'h-[100dvh]'} bg-white`}>
|
||||
{/* Fixed Top Layout Block - Header and Tabs Container */}
|
||||
<div className="fixed-top-layout-block flex-shrink-0 !w-full z-50 bg-white border-b border-gray-200">
|
||||
{/* Chat Header - Only show when not embedded */}
|
||||
{!embedded && (
|
||||
<div className="p-4 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-800">Trò chuyện nhóm hành trình</h3>
|
||||
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đổi thông tin, hình ảnh và định vị giữa các thành viên</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages list */}
|
||||
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-3 min-h-0 bg-slate-50/20">
|
||||
{/* Chat History Scroll Viewport - ONLY scrollable area */}
|
||||
<div className="chat-history-scroll-viewport flex-1 min-h-0 !overflow-y-auto !overflow-x-hidden p-4 flex flex-col gap-3 bg-slate-50/20">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 text-xs gap-1.5">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" /> Đang tải tin nhắn...
|
||||
@@ -468,7 +472,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${
|
||||
isMe
|
||||
? 'bg-blue-600 text-white rounded-tr-none'
|
||||
: 'bg-white text-gray-700 rounded-tl-none border border-gray-150 shadow-sm'
|
||||
: 'bg-white text-gray-700 rounded-tl-none border border-gray-200 shadow-sm'
|
||||
}`}>
|
||||
{/* Attachment Image */}
|
||||
{msg.attachmentUrl && (
|
||||
@@ -498,7 +502,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${
|
||||
isMe
|
||||
? 'bg-blue-700 border-blue-600 text-blue-100 hover:bg-blue-800'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-750 hover:bg-gray-200'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
|
||||
@@ -525,7 +529,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
|
||||
{/* Previews (Image & GPS Location) */}
|
||||
{(imagePreview || attachedLocation) && (
|
||||
<div className="px-4 py-2 border-t border-gray-150 bg-gray-50/80 flex flex-wrap gap-2">
|
||||
<div className="px-4 py-2 border-t border-gray-200 bg-gray-50/80 flex flex-wrap gap-2 !flex-shrink-0">
|
||||
{imagePreview && (
|
||||
<div className="relative w-16 h-16 rounded-lg overflow-hidden border border-gray-200 shadow-sm">
|
||||
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||
@@ -557,8 +561,8 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Input wrapper */}
|
||||
<div className="relative">
|
||||
{/* Locked Chat Input Footer */}
|
||||
<div className="locked-chat-input-footer !flex-shrink-0 !w-full !border-t border-gray-200 bg-white !z-40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 12px)' }}>
|
||||
{/* Mention list dropdown */}
|
||||
{showMentionList && filteredParticipants.length > 0 && (
|
||||
<div
|
||||
@@ -589,7 +593,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
{/* Chat Input form */}
|
||||
<form
|
||||
onSubmit={handleSendMessage}
|
||||
className="p-3 border-t border-gray-150 bg-gray-50 flex gap-2 items-center"
|
||||
className="p-3 flex gap-2 items-center !w-full relative"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
|
||||
@@ -1,10 +1,408 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
/* ============ LIGHT THEME (Default) ============ */
|
||||
:root {
|
||||
--main-header-height: 56px;
|
||||
--sub-nav-height: 48px;
|
||||
--combined-top-height: 104px;
|
||||
|
||||
/* Light Theme Variables - Soft Parchment & Yale Blue */
|
||||
--background: #f6f0ed; /* Parchment */
|
||||
--background-alt: #f9fafb; /* Light Gray Alt */
|
||||
--surface: #ffffff; /* Pure White for crisp card layers */
|
||||
--surface-muted: #f3f4f6; /* Muted Surface */
|
||||
--surface-hover: #f9fafb; /* Surface Hover */
|
||||
|
||||
--border: #e5e7eb; /* Light Border */
|
||||
--border-light: #f3f4f6; /* Light Border Alt */
|
||||
|
||||
--text-primary: #0596e4; /* Yale Blue (High contrast text) */
|
||||
--text-secondary: #74a8c4; /* Steel Blue */
|
||||
--text-accent: #c2948a; /* Rosy Taupe */
|
||||
--text-muted: #9ca3af; /* Muted Text */
|
||||
--text-disabled: #d1d5db; /* Disabled Text */
|
||||
|
||||
--primary: #28536b; /* Yale Blue for main buttons */
|
||||
--primary-hover: #1f4154; /* Darker Yale Blue */
|
||||
--primary-light: #eff6ff; /* Light Blue Background */
|
||||
|
||||
--secondary: #7ea8be; /* Steel Blue */
|
||||
--secondary-light: #f0f9ff; /* Light Secondary */
|
||||
|
||||
--success: #10b981; /* Green */
|
||||
--success-light: #ecfdf5;
|
||||
|
||||
--danger: #ef4444; /* Red */
|
||||
--danger-light: #fef2f2;
|
||||
|
||||
--warning: #f59e0b; /* Amber */
|
||||
--warning-light: #fffbeb;
|
||||
|
||||
--info: #3b82f6; /* Blue */
|
||||
--info-light: #eff6ff;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ============ DARK THEME ============ */
|
||||
:root.dark {
|
||||
/* Dark Theme Variables - Vintage Grape & Bondi Blue */
|
||||
--background: #1e293b; /* Dark slate-800 */
|
||||
--background-alt: #0f172a; /* Dark slate-900 */
|
||||
--surface: #1e293b; /* Dark slate-800 */
|
||||
--surface-muted: #334155; /* Dark slate-700 */
|
||||
--surface-hover: #334155; /* Dark slate-700 */
|
||||
|
||||
--border: #334155; /* Dark slate-700 */
|
||||
--border-light: #1e293b; /* Dark slate-800 */
|
||||
|
||||
--text-primary: #f8fafc; /* Soft White */
|
||||
--text-secondary: #cbd5e1; /* Light gray */
|
||||
--text-accent: #bce784; /* Lime Cream */
|
||||
--text-muted: #94a3b8; /* Muted Slate */
|
||||
--text-disabled: #64748b; /* Disabled Text */
|
||||
|
||||
--primary: #348aa7; /* Bondi Blue */
|
||||
--primary-hover: #296f86; /* Darker Bondi Blue */
|
||||
--primary-light: #0c2340; /* Dark Blue Background */
|
||||
|
||||
--secondary: #5dd39e; /* Emerald */
|
||||
--secondary-light: #064e3b; /* Dark Secondary */
|
||||
|
||||
--success: #10b981; /* Green */
|
||||
--success-light: #064e3b;
|
||||
|
||||
--danger: #ef4444; /* Red */
|
||||
--danger-light: #3f0f0f;
|
||||
|
||||
--warning: #f59e0b; /* Amber */
|
||||
--warning-light: #3f2009;
|
||||
|
||||
--info: #3b82f6; /* Blue */
|
||||
--info-light: #0c2340;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* FIX LỖI 2: Khai báo ép toàn bộ Input / Textarea trên hệ thống ăn theo màu Theme */
|
||||
input, textarea, select {
|
||||
background-color: var(--surface) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Đảm bảo chữ gợi ý (placeholder) không bị trùng nền */
|
||||
input::placeholder, textarea::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
html, body, #root, .app-container {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: 100vw !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* Fix viewport overflow on all container levels */
|
||||
body, html {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.itinerary-timeline-page {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.main-top-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: var(--main-header-height);
|
||||
z-index: 50;
|
||||
background-color: var(--surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sub-nav-menu {
|
||||
position: sticky;
|
||||
top: var(--main-header-height);
|
||||
height: var(--sub-nav-height);
|
||||
z-index: 40;
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Master viewport wrapper - contains entire itinerary */
|
||||
.itinerary-viewport {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: 100dvh !important;
|
||||
width: 100vw !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.itinerary-viewport-wrapper {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: 100dvh !important;
|
||||
width: 100vw !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* Global sticky header zone - locks main header + sub-nav at top */
|
||||
.global-sticky-header-zone {
|
||||
position: sticky !important;
|
||||
top: 0;
|
||||
z-index: 100 !important;
|
||||
background-color: var(--surface);
|
||||
color: var(--text-primary);
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
/* Fixed header block (handled at parent TourDetailPage level) */
|
||||
.fixed-top-header-block {
|
||||
flex-shrink: 0 !important;
|
||||
width: 100% !important;
|
||||
background-color: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Timeline scroll container - main scrollable region */
|
||||
.directory-scroll-viewport {
|
||||
flex: 1 !important;
|
||||
overflow-y: auto !important;
|
||||
position: relative !important;
|
||||
z-index: 10 !important;
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
background-color: var(--background);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.timeline-scroll-container {
|
||||
flex: 1 !important;
|
||||
overflow-y: auto !important;
|
||||
position: relative !important;
|
||||
z-index: 10 !important;
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Scrollable timeline area - removes all padding gaps */
|
||||
.timeline-scroll-area {
|
||||
flex: 1 1 0% !important;
|
||||
overflow-y: auto !important;
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
margin-top: 0px !important;
|
||||
background-color: var(--background);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.timeline-scroll-viewport {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Main timeline container with dynamic height support */
|
||||
.itinerary-timeline-container {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
width: 100% !important;
|
||||
max-width: 100vw !important;
|
||||
height: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
/* Individual folder node wrapper - base stage container */
|
||||
.folder-node-wrapper {
|
||||
width: 100% !important;
|
||||
background-color: var(--surface);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0px !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Individual stage card block - backwards compatibility */
|
||||
.stage-card-block {
|
||||
width: 100% !important;
|
||||
background-color: var(--surface);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1px !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Stage section - semantic wrapper */
|
||||
.stage-section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: block !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Folder header row - sticky positioning for pinned effect */
|
||||
.folder-header-row {
|
||||
position: sticky !important;
|
||||
top: 0px !important;
|
||||
z-index: 30 !important;
|
||||
width: 100% !important;
|
||||
padding: 12px 16px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--surface) !important;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.folder-header-row:hover {
|
||||
background-color: var(--surface-muted) !important;
|
||||
}
|
||||
|
||||
/* Stage header row - sticky positioning for pinned effect */
|
||||
.stage-header-row {
|
||||
position: sticky !important;
|
||||
top: 0px !important;
|
||||
z-index: 40 !important;
|
||||
width: 100% !important;
|
||||
padding: 12px 16px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--surface) !important;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.stage-header-row:hover {
|
||||
background-color: var(--surface-muted) !important;
|
||||
}
|
||||
|
||||
/* Alternate sticky header styling (kept for backwards compatibility) */
|
||||
.stage-sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background-color: #ffffff;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Folder child content box - Grid accordion for exclusive expansion */
|
||||
.folder-child-content-box {
|
||||
display: grid !important;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Expanded folder state - children visible */
|
||||
.folder-child-content-box.expanded {
|
||||
grid-template-rows: 1fr !important;
|
||||
}
|
||||
|
||||
/* Wrapper for child nodes list - proper z-index layering */
|
||||
.child-nodes-list-wrapper {
|
||||
position: relative;
|
||||
z-index: 20 !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Child nodes list - content container within expanded folder */
|
||||
.child-nodes-list {
|
||||
overflow: hidden;
|
||||
min-height: 0px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Remove padding from child nodes list when folder is collapsed */
|
||||
.folder-child-content-box:not(.expanded) .child-nodes-list {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* CSS Grid Accordion Engine - Smooth expand/collapse without layout shattering */
|
||||
.stage-accordion-content {
|
||||
display: grid !important;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.25s ease-out !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Expanded state - opens to full content height */
|
||||
.stage-accordion-content.expanded {
|
||||
grid-template-rows: 1fr !important;
|
||||
}
|
||||
|
||||
/* Scrollable content body - lower z-index so it scrolls behind header */
|
||||
.stage-scrollable-content-body {
|
||||
position: relative;
|
||||
z-index: 20 !important;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Inner content wrapper - structural compliance */
|
||||
.stage-points-list {
|
||||
overflow: hidden;
|
||||
min-height: 0px;
|
||||
padding: 0 16px 16px 16px;
|
||||
}
|
||||
|
||||
/* Legacy accordion container classes (for backwards compatibility) */
|
||||
.stage-accordion-transition-container {
|
||||
display: grid !important;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.stage-accordion-transition-container.expanded {
|
||||
grid-template-rows: 1fr !important;
|
||||
}
|
||||
|
||||
.stage-accordion-inner-content {
|
||||
overflow: hidden !important;
|
||||
min-height: 0px !important;
|
||||
width: 100% !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stage-content-body {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,20 @@ const DefaultIcon = L.icon({
|
||||
});
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
|
||||
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
|
||||
function RecenterMap({ position }: { position: [number, number] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.setView(position, map.getZoom());
|
||||
}, [position, map]);
|
||||
return null;
|
||||
}
|
||||
// Tag ID to Label Mapping for Public Photos
|
||||
const PHOTO_TAG_LABELS: { [key: string]: string } = {
|
||||
'phong-canh': '🏞️ Phong cảnh',
|
||||
'con-nguoi': '👥 Con người',
|
||||
'doi-thuong': '🎒 Đời thường',
|
||||
'bien': '🌊 Biển',
|
||||
'nui': '⛰️ Núi',
|
||||
'do-thi': '🏙️ Đô thị',
|
||||
'thuc-an': '🍜 Thức ăn',
|
||||
'cho': '🛍️ Chợ',
|
||||
'hien-dai': '🏗️ Hiện đại',
|
||||
'dong-vat': '🦁 Động vật',
|
||||
'thu-cung': '🐕 Thú cưng'
|
||||
};
|
||||
|
||||
// Component Helper để đóng menu khi tương tác với bản đồ
|
||||
function MapEvents({ onMapAction }: { onMapAction: () => void }) {
|
||||
@@ -187,15 +193,31 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
return null;
|
||||
});
|
||||
|
||||
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
|
||||
// Map dùng center cố định để không bị GPS tự nhảy vị trí người dùng
|
||||
const defaultCenter = initialViewState?.center || [10.7769, 106.7009];
|
||||
|
||||
const [userPos, setUserPos] = useState<[number, number]>(defaultCenter);
|
||||
const [mapCenter, setLocalMapCenter] = useState<[number, number]>(initialViewState?.center || defaultCenter);
|
||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
|
||||
const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]);
|
||||
const [selectedPhotoFilterTags, setSelectedPhotoFilterTags] = useState<string[]>([]);
|
||||
|
||||
const groupedPhotos = React.useMemo(() => {
|
||||
// Filter photos by selected tags if any are selected
|
||||
let filteredPhotos = publicPhotos;
|
||||
if (selectedPhotoFilterTags.length > 0) {
|
||||
filteredPhotos = publicPhotos.filter((photo) => {
|
||||
const photoTags = photo.metadata?.tags as string[] | undefined;
|
||||
if (!Array.isArray(photoTags)) return false;
|
||||
// Check if photo has at least one of the selected tags
|
||||
return selectedPhotoFilterTags.some(tag => photoTags.includes(tag));
|
||||
});
|
||||
}
|
||||
|
||||
const groups: { [key: string]: any[] } = {};
|
||||
publicPhotos.forEach((photo) => {
|
||||
filteredPhotos.forEach((photo) => {
|
||||
const lat = photo.metadata?.lat;
|
||||
const lng = photo.metadata?.lng;
|
||||
if (typeof lat === 'number' && typeof lng === 'number') {
|
||||
@@ -215,7 +237,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
});
|
||||
});
|
||||
return Object.values(groups);
|
||||
}, [publicPhotos]);
|
||||
}, [publicPhotos, selectedPhotoFilterTags]);
|
||||
|
||||
const fetchPublicPhotos = async () => {
|
||||
try {
|
||||
@@ -235,7 +257,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
const [blacklist, setBlacklist] = useState<any[]>([]);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
|
||||
const mapCenter = useTourStore(state => state.mapCenter);
|
||||
const storeMapCenter = useTourStore(state => state.mapCenter);
|
||||
|
||||
// Recommendations and GPS States
|
||||
const [recommendedLocations, setRecommendedLocations] = useState<any[]>([]);
|
||||
@@ -315,6 +337,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
||||
setUserGpsPos(posArray);
|
||||
setUserPos(posArray);
|
||||
setLocalMapCenter(posArray);
|
||||
setMapCenter(posArray);
|
||||
},
|
||||
() => {
|
||||
@@ -408,7 +431,28 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
fetchTrustedUsers();
|
||||
fetchBlacklist();
|
||||
fetchRecommendations();
|
||||
// Luôn tải danh sách ảnh công khai để hiển thị trên bản đồ cho tất cả mọi người
|
||||
fetchPublicPhotos();
|
||||
|
||||
// Chỉ tải danh sách tour khi người dùng đã đăng nhập và có token
|
||||
if (user || localStorage.getItem('token')) {
|
||||
fetchPublicTours();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Chỉ lấy vị trí GPS ban đầu để hiển thị marker, KHÔNG tự động nhảy bản đồ đến vị trí đó
|
||||
// Người dùng phải chủ động nhấn nút định vị mới nhảy bản đồ đến vị trí của mình
|
||||
useEffect(() => {
|
||||
if (!initialViewState && navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
||||
setUserPos(posArray);
|
||||
},
|
||||
() => console.log("Không thể lấy vị trí người dùng")
|
||||
);
|
||||
}
|
||||
}, [initialViewState]);
|
||||
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
|
||||
const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm
|
||||
const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]);
|
||||
@@ -464,6 +508,18 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
return Array.from(tagsSet);
|
||||
}, [publicTours]);
|
||||
|
||||
// Tổng hợp nhãn từ danh sách ảnh công khai để lọc ảnh
|
||||
const availablePhotoTags = React.useMemo(() => {
|
||||
const tagsSet = new Set<string>();
|
||||
publicPhotos.forEach(photo => {
|
||||
const tags = photo.metadata?.tags as string[] | undefined;
|
||||
if (Array.isArray(tags)) {
|
||||
tags.forEach(tag => tagsSet.add(tag));
|
||||
}
|
||||
});
|
||||
return Array.from(tagsSet).sort();
|
||||
}, [publicPhotos]);
|
||||
|
||||
// State cho menu chuột phải chia sẻ
|
||||
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean, isParticipant: boolean, hasPendingRequest: boolean } | null>(null);
|
||||
|
||||
@@ -537,6 +593,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
} else if (s.lat && s.lon) {
|
||||
const pos: [number, number] = [s.lat, s.lon];
|
||||
setUserPos(pos);
|
||||
setLocalMapCenter(pos);
|
||||
setMapCenter(pos);
|
||||
notify({
|
||||
title: 'Tìm thấy địa điểm',
|
||||
@@ -616,33 +673,35 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
<div className="flex items-center gap-3 pointer-events-auto">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="w-11 h-11 flex items-center justify-center bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 shrink-0"
|
||||
className="w-11 h-11 flex items-center justify-center bg-[var(--surface)] rounded-full shadow-xl hover:bg-[var(--background)] transition-all border border-[var(--border)] shrink-0"
|
||||
title="Quay lại"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-gray-800" />
|
||||
<ChevronLeft className="w-6 h-6 text-[var(--text-primary)]" />
|
||||
</button>
|
||||
|
||||
{/* Nút lọc Tag và Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsFilterDropdownOpen(prev => !prev)}
|
||||
className="w-11 h-11 bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center shrink-0"
|
||||
className="w-11 h-11 bg-[var(--surface)] rounded-full shadow-xl hover:bg-[var(--background)] transition-all border border-[var(--border)] flex items-center justify-center shrink-0"
|
||||
title="Lọc theo loại"
|
||||
>
|
||||
<Filter className="w-6 h-6 text-gray-800" />
|
||||
<Filter className="w-6 h-6 text-[var(--text-primary)]" />
|
||||
</button>
|
||||
|
||||
{/* Filter Dropdown Content */}
|
||||
{isFilterDropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-3 bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 flex flex-col gap-2 max-w-[200px] z-[1003] animate-in slide-in-from-left-2 duration-200">
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
|
||||
<div className="absolute top-full left-0 mt-3 bg-[var(--surface)]/90 backdrop-blur-md p-3 rounded-2xl shadow-xl border border-[var(--surface)]/20 flex flex-col gap-3 max-w-[220px] z-[1003] animate-in slide-in-from-left-2 duration-200 max-h-[400px] overflow-y-auto">
|
||||
{/* Tour Filter Section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--border)] mb-1.5">
|
||||
<Filter className="w-3.5 h-3.5 text-blue-600" />
|
||||
<span className="text-[11px] font-black uppercase text-gray-500 tracking-wider">Lọc theo loại</span>
|
||||
<span className="text-[10px] font-black uppercase text-[var(--text-muted)] tracking-wider">🧳 Chuyến đi</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5"> {/* Hiển thị tag theo chiều dọc */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
|
||||
>
|
||||
Tất cả
|
||||
</button>
|
||||
@@ -650,48 +709,86 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => { setSelectedFilterTag(tag === selectedFilterTag ? null : tag); setIsFilterDropdownOpen(false); }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo Filter Section */}
|
||||
{availablePhotoTags.length > 0 && (
|
||||
<div className="border-t border-[var(--border)] pt-3">
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--border)] mb-1.5">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-[10px] font-black uppercase text-[var(--text-muted)] tracking-wider">📸 Ảnh công khai</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => { setSelectedPhotoFilterTags([]); setIsFilterDropdownOpen(false); }}
|
||||
className={`px-2 py-1 rounded-lg text-[9px] font-bold transition-all ${selectedPhotoFilterTags.length === 0 ? 'bg-emerald-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
|
||||
>
|
||||
Tất cả
|
||||
</button>
|
||||
{availablePhotoTags.map(tagId => {
|
||||
const tagLabel = PHOTO_TAG_LABELS[tagId] || tagId;
|
||||
return (
|
||||
<button
|
||||
key={tagId}
|
||||
onClick={() => {
|
||||
setSelectedPhotoFilterTags(prev =>
|
||||
prev.includes(tagId)
|
||||
? prev.filter(t => t !== tagId)
|
||||
: [...prev, tagId]
|
||||
);
|
||||
}}
|
||||
className={`px-2 py-1 rounded-lg text-[9px] font-bold transition-all ${selectedPhotoFilterTags.includes(tagId) ? 'bg-emerald-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
|
||||
title={tagLabel}
|
||||
>
|
||||
{tagLabel.length > 13 ? tagLabel.substring(0, 13) + '...' : tagLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Box - Thay thế div "Khám phá khu vực" */}
|
||||
<div className="relative flex items-center bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white/20 flex-1 max-w-xs sm:max-w-md pointer-events-auto hidden sm:flex px-4 py-3">
|
||||
<div className="relative flex items-center bg-[var(--surface)]/90 backdrop-blur-md rounded-2xl shadow-xl border border-[var(--surface)]/20 flex-1 max-w-xs sm:max-w-md pointer-events-auto hidden sm:flex px-4 py-3">
|
||||
<Navigation className="w-4 h-4 text-blue-600 mr-2 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm kiếm địa điểm, tour..."
|
||||
className="flex-1 bg-transparent outline-none text-gray-800 text-sm font-medium"
|
||||
className="flex-1 bg-transparent outline-none text-[var(--text-primary)] text-sm font-medium"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{isSearchingSuggestions && <Loader2 className="w-4 h-4 animate-spin text-blue-500 mr-2" />}
|
||||
{searchQuery && (
|
||||
<button onClick={() => { setSearchQuery(''); setSuggestions([]); }} className="p-1 text-gray-400 hover:text-gray-600 rounded-full">
|
||||
<button onClick={() => { setSearchQuery(''); setSuggestions([]); }} className="p-1 text-[var(--text-muted)] hover:text-[var(--text-secondary)] rounded-full">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dropdown danh sách gợi ý */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-3 bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl border border-white/20 overflow-hidden z-[1003] animate-in slide-in-from-top-2 duration-200">
|
||||
<div className="absolute top-full left-0 right-0 mt-3 bg-[var(--surface)]/95 backdrop-blur-md rounded-2xl shadow-2xl border border-[var(--surface)]/20 overflow-hidden z-[1003] animate-in slide-in-from-top-2 duration-200">
|
||||
{suggestions.map((s, idx) => (
|
||||
<button
|
||||
key={`${s.type}-${s.id}-${idx}`}
|
||||
onClick={() => handleSelectSuggestion(s)}
|
||||
className="w-full text-left px-4 py-3 hover:bg-blue-50 flex items-center gap-3 transition-colors border-b border-gray-50 last:border-0"
|
||||
className="w-full text-left px-4 py-3 hover:bg-[var(--background)] flex items-center gap-3 transition-colors border-b border-[var(--border)] last:border-0"
|
||||
>
|
||||
<div className={`p-2 rounded-xl flex-shrink-0 ${s.type === 'tour' ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'}`}>
|
||||
{s.type === 'tour' ? <ImageIcon className="w-4 h-4" /> : <MapPin className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-bold text-gray-800 truncate">{s.name}</span>
|
||||
<span className="text-[10px] font-black uppercase text-gray-400 tracking-wider">
|
||||
<span className="text-sm font-bold text-[var(--text-primary)] truncate">{s.name}</span>
|
||||
<span className="text-[10px] font-black uppercase text-[var(--text-muted)] tracking-wider">
|
||||
{s.type === 'tour' ? 'Chuyến đi của bạn' : 'Địa điểm trên bản đồ'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -704,6 +801,18 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
|
||||
{/* Nhóm bên phải: Các thao tác người dùng */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto">
|
||||
{/* Nút định vị người dùng */}
|
||||
<button
|
||||
onClick={requestGpsPosition}
|
||||
className="w-11 h-11 bg-[var(--surface)] rounded-full shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center border border-[var(--border)] shrink-0"
|
||||
title="Vị trí của tôi"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Nút Ảnh của tôi */}
|
||||
{isLoggedInOrGuest && (
|
||||
<button
|
||||
@@ -711,7 +820,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
console.log("Đang mở Ảnh của tôi...");
|
||||
onOpenMyPhotos();
|
||||
}}
|
||||
className="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0"
|
||||
className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0"
|
||||
title="Ảnh của tôi"
|
||||
>
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
@@ -743,31 +852,31 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
|
||||
{/* Lựa chọn Ngôn ngữ */}
|
||||
<div className="relative group shrink-0">
|
||||
<button className="w-11 h-11 bg-white dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 transition-all flex items-center justify-center border border-gray-100 dark:border-slate-700">
|
||||
<button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
|
||||
<Globe className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-white dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
||||
<button onClick={() => changeLanguage('vi')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'vi' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>Tiếng Việt</button>
|
||||
<button onClick={() => changeLanguage('en')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'en' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>English</button>
|
||||
<button onClick={() => changeLanguage('zh')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'zh' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>中文</button>
|
||||
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
||||
<button onClick={() => changeLanguage('vi')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'vi' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>Tiếng Việt</button>
|
||||
<button onClick={() => changeLanguage('en')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'en' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>English</button>
|
||||
<button onClick={() => changeLanguage('zh')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'zh' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>中文</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lựa chọn Giao diện */}
|
||||
<div className="relative group shrink-0">
|
||||
<button className="w-11 h-11 bg-white dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 transition-all flex items-center justify-center border border-gray-100 dark:border-slate-700">
|
||||
<button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
|
||||
{theme === 'light' && <Sun className="w-5 h-5 text-amber-500" />}
|
||||
{theme === 'dark' && <Moon className="w-5 h-5 text-indigo-400" />}
|
||||
{theme === 'system' && <Laptop className="w-5 h-5 animate-pulse" />}
|
||||
</button>
|
||||
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-white dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
||||
<button onClick={() => changeTheme('light')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'light' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>
|
||||
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
||||
<button onClick={() => changeTheme('light')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'light' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
||||
<Sun className="w-3.5 h-3.5 text-amber-500" /> {t('themeLight')}
|
||||
</button>
|
||||
<button onClick={() => changeTheme('dark')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'dark' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>
|
||||
<button onClick={() => changeTheme('dark')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'dark' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
||||
<Moon className="w-3.5 h-3.5 text-indigo-400" /> {t('themeDark')}
|
||||
</button>
|
||||
<button onClick={() => changeTheme('system')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'system' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>
|
||||
<button onClick={() => changeTheme('system')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'system' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
||||
<Laptop className="w-3.5 h-3.5" /> {t('themeSystem')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -801,7 +910,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-gray-700 border border-gray-100 shrink-0"
|
||||
className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-[var(--text-secondary)] border border-[var(--border)] shrink-0"
|
||||
title="Đăng xuất"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
@@ -812,7 +921,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
|
||||
<MapContainer
|
||||
center={userPos}
|
||||
center={mapCenter}
|
||||
zoom={mapZoom}
|
||||
className="h-full w-full"
|
||||
preferCanvas={true}
|
||||
@@ -827,9 +936,6 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
{/* Đóng menu và dropdown khi tương tác bản đồ */}
|
||||
<MapEvents onMapAction={() => { setShareMenu(null); setSuggestions([]); setIsFilterDropdownOpen(false); }} />
|
||||
|
||||
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
||||
<RecenterMap position={userPos} />
|
||||
|
||||
<MarkerClusterGroup key={`cluster-${filteredTours.length}`} chunkedLoading>
|
||||
{filteredTours.map((tour) => {
|
||||
let startLoc = null;
|
||||
@@ -917,7 +1023,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
className: 'custom-bubble',
|
||||
html: `
|
||||
<div class="relative group w-14 h-14">
|
||||
<div class="w-14 h-14 rounded-full border-4 ${status.borderClass} shadow-lg overflow-hidden transition-transform group-hover:scale-110 flex items-center justify-center bg-gray-100">
|
||||
<div class="w-14 h-14 rounded-full border-4 ${status.borderClass} shadow-lg overflow-hidden transition-transform group-hover:scale-110 flex items-center justify-center bg-[var(--background)]">
|
||||
<img src="${tourImage}" class="w-full h-full object-cover" onerror="this.src='https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=100'"/>
|
||||
</div>
|
||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||
@@ -940,16 +1046,16 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
)}
|
||||
{tour.description && (
|
||||
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic mb-1.5">
|
||||
<div className="text-[10px] text-[var(--text-muted)] line-clamp-2 leading-tight italic mb-1.5">
|
||||
{tour.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-gray-100">
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-[var(--border)]">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
status.color === 'green' ? 'bg-emerald-500' :
|
||||
status.color === 'red' ? 'bg-rose-500' : 'bg-gray-400'
|
||||
status.color === 'red' ? 'bg-rose-500' : 'bg-[var(--text-muted)]'
|
||||
}`} />
|
||||
<span className="text-[9px] font-bold text-gray-600">{status.label}</span>
|
||||
<span className="text-[9px] font-bold text-[var(--text-secondary)]">{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -1067,13 +1173,13 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
{item.type === 'RESTAURANT' ? t('businessRestaurant') : item.type === 'HOTEL' ? t('businessHotel') : t('businessHomestay')}
|
||||
</div>
|
||||
{item.address && (
|
||||
<div className="text-[10px] text-gray-500 mb-1 font-semibold">{item.address}</div>
|
||||
<div className="text-[10px] text-[var(--text-muted)] mb-1 font-semibold">{item.address}</div>
|
||||
)}
|
||||
{item.phone && (
|
||||
<div className="text-[9px] text-gray-400">SĐT: {item.phone}</div>
|
||||
<div className="text-[9px] text-[var(--text-muted)]">SĐT: {item.phone}</div>
|
||||
)}
|
||||
{item.email && (
|
||||
<div className="text-[9px] text-gray-400">Email: {item.email}</div>
|
||||
<div className="text-[9px] text-[var(--text-muted)]">Email: {item.email}</div>
|
||||
)}
|
||||
<div className="text-[10px] text-slate-600 font-medium italic mt-1 pt-1 border-t border-emerald-100 whitespace-pre-line">
|
||||
{item.description}
|
||||
@@ -1088,7 +1194,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
{/* Context Menu Chia sẻ */}
|
||||
{shareMenu && (
|
||||
<div
|
||||
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
|
||||
className="absolute z-[2000] bg-[var(--surface)] rounded-2xl shadow-2xl border border-[var(--border)] py-2 w-48 animate-in zoom-in-95 duration-200"
|
||||
style={{ top: shareMenu.y, left: shareMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -1096,19 +1202,19 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
shareMenu.canShare ? (
|
||||
<button
|
||||
onClick={() => handleShare(shareMenu.id, shareMenu.title)}
|
||||
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2 transition-colors"
|
||||
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-blue-700 flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-4 py-2 text-xs text-gray-400 italic font-bold">Bạn đã gia nhập tour này</div>
|
||||
<div className="px-4 py-2 text-xs text-[var(--text-muted)] italic font-bold">Bạn đã gia nhập tour này</div>
|
||||
)
|
||||
) : shareMenu.hasPendingRequest ? (
|
||||
<button
|
||||
disabled
|
||||
className="w-full text-left px-4 py-2 text-sm font-bold text-gray-400 flex items-center gap-2 cursor-not-allowed bg-gray-50/50"
|
||||
className="w-full text-left px-4 py-2 text-sm font-bold text-[var(--text-muted)] flex items-center gap-2 cursor-not-allowed bg-[var(--background)]/50"
|
||||
>
|
||||
<Clock className="w-4 h-4 text-gray-400" /> Đang chờ duyệt...
|
||||
<Clock className="w-4 h-4 text-[var(--text-muted)]" /> Đang chờ duyệt...
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -1189,33 +1295,33 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
<div className="absolute bottom-6 left-6 z-[1002] pointer-events-auto flex flex-col items-start gap-2">
|
||||
<button
|
||||
onClick={() => setIsLeaderboardOpen(prev => !prev)}
|
||||
className="bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 text-slate-800 dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95"
|
||||
className="bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 text-[var(--text-primary)] dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-[var(--background)] dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95"
|
||||
>
|
||||
<Users className="w-4 h-4 text-amber-500" />
|
||||
<span>{t('trustedMembers')} ({trustedUsers.length})</span>
|
||||
</button>
|
||||
|
||||
{isLeaderboardOpen && (
|
||||
<div className="bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-gray-100 dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="bg-[var(--surface)]/95 dark:bg-slate-900/95 backdrop-blur-md border border-[var(--border)] dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
|
||||
<h4 className="text-xs font-black uppercase text-amber-600 dark:text-amber-500 tracking-widest mb-3 flex items-center gap-2">
|
||||
🏆 {t('trustedMembers')}
|
||||
</h4>
|
||||
{trustedUsers.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic">Chưa có thành viên nào được đánh giá.</p>
|
||||
<p className="text-xs text-[var(--text-muted)] italic">Chưa có thành viên nào được đánh giá.</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{trustedUsers.map((u, idx) => (
|
||||
<div key={u.id} className="flex items-center justify-between gap-3 p-2 bg-gray-50/50 dark:bg-slate-850/50 rounded-xl border border-gray-100/50 dark:border-slate-800/50">
|
||||
<div key={u.id} className="flex items-center justify-between gap-3 p-2 bg-[var(--background)]/50 dark:bg-slate-850/50 rounded-xl border border-[var(--border)]/50 dark:border-slate-800/50">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-5 h-5 font-bold text-[10px] text-gray-500 flex items-center justify-center bg-gray-100 dark:bg-slate-800 rounded-lg">
|
||||
<div className="w-5 h-5 font-bold text-[10px] text-[var(--text-muted)] flex items-center justify-center bg-[var(--background)] dark:bg-slate-800 rounded-lg">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<span className="text-xs font-bold text-gray-800 dark:text-slate-200 truncate">{u.name}</span>
|
||||
<span className="text-xs font-bold text-[var(--text-primary)] dark:text-slate-200 truncate">{u.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px] font-bold text-amber-500 shrink-0">
|
||||
<span>★</span>
|
||||
<span>{u.averageScore}</span>
|
||||
<span className="text-[9px] text-gray-400 font-medium">({u.ratingCount})</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-medium">({u.ratingCount})</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1233,7 +1339,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
setIsRecommendedOpen(prev => !prev);
|
||||
setIsBlacklistOpen(false);
|
||||
}}
|
||||
className="bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 text-slate-800 dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95 border-emerald-500/20"
|
||||
className="bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 text-[var(--text-primary)] dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-[var(--background)] dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95 border-emerald-500/20"
|
||||
>
|
||||
<Star className="w-4 h-4 text-emerald-500 fill-emerald-500 animate-pulse" />
|
||||
<span>{t('recommendedTitle') || 'Đề xuất dịch vụ'} ({recommendedLocations.length})</span>
|
||||
@@ -1245,7 +1351,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
setIsBlacklistOpen(prev => !prev);
|
||||
setIsRecommendedOpen(false);
|
||||
}}
|
||||
className="bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 text-slate-800 dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95 border-red-500/20"
|
||||
className="bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 text-[var(--text-primary)] dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-[var(--background)] dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95 border-red-500/20"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4 text-red-500 animate-pulse" />
|
||||
<span>{t('blacklistTitle') || 'Danh sách đen'} ({blacklist.length})</span>
|
||||
@@ -1253,8 +1359,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
|
||||
{/* Recommendations Panel */}
|
||||
{isRecommendedOpen && (
|
||||
<div className="bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-gray-100 dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
|
||||
<h4 className="text-xs font-black uppercase text-emerald-600 dark:text-emerald-500 tracking-widest mb-3 flex items-center gap-2 border-b border-gray-100 dark:border-slate-800 pb-2">
|
||||
<div className="bg-[var(--surface)]/95 dark:bg-slate-900/95 backdrop-blur-md border border-[var(--border)] dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
|
||||
<h4 className="text-xs font-black uppercase text-emerald-600 dark:text-emerald-500 tracking-widest mb-3 flex items-center gap-2 border-b border-[var(--border)] dark:border-slate-800 pb-2">
|
||||
🌟 {t('recommendedTitle') || 'Đề xuất chất lượng'}
|
||||
</h4>
|
||||
|
||||
@@ -1284,7 +1390,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
|
||||
{processedRecommendations.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic text-center py-4">Chưa có địa điểm đề xuất nào.</p>
|
||||
<p className="text-xs text-[var(--text-muted)] italic text-center py-4">Chưa có địa điểm đề xuất nào.</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{processedRecommendations.map((item) => {
|
||||
@@ -1324,6 +1430,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserPos([item.latitude, item.longitude]);
|
||||
setLocalMapCenter([item.latitude, item.longitude]);
|
||||
setMapCenter([item.latitude, item.longitude]);
|
||||
}}
|
||||
className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0"
|
||||
@@ -1332,7 +1439,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="text-[10px] text-slate-600 dark:text-slate-400 font-medium italic mt-1.5 pt-1.5 border-t border-gray-100 dark:border-slate-800 whitespace-pre-line">
|
||||
<div className="text-[10px] text-[var(--text-secondary)] dark:text-slate-400 font-medium italic mt-1.5 pt-1.5 border-t border-[var(--border)] dark:border-slate-800 whitespace-pre-line">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1345,8 +1452,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
|
||||
{/* Blacklist Panel */}
|
||||
{isBlacklistOpen && (
|
||||
<div className="bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-gray-100 dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
|
||||
<h4 className="text-xs font-black uppercase text-red-600 dark:text-red-500 tracking-widest mb-3 flex items-center gap-2 border-b border-gray-100 dark:border-slate-800 pb-2">
|
||||
<div className="bg-[var(--surface)]/95 dark:bg-slate-900/95 backdrop-blur-md border border-[var(--border)] dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
|
||||
<h4 className="text-xs font-black uppercase text-red-600 dark:text-red-500 tracking-widest mb-3 flex items-center gap-2 border-b border-[var(--border)] dark:border-slate-800 pb-2">
|
||||
⚠️ {t('blacklistTitle')}
|
||||
</h4>
|
||||
|
||||
@@ -1369,7 +1476,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
|
||||
{processedBlacklist.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic text-center py-4">{t('emptyBlacklist')}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] italic text-center py-4">{t('emptyBlacklist')}</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{processedBlacklist.map((item) => {
|
||||
@@ -1402,6 +1509,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserPos([item.latitude, item.longitude]);
|
||||
setLocalMapCenter([item.latitude, item.longitude]);
|
||||
setMapCenter([item.latitude, item.longitude]);
|
||||
}}
|
||||
className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0"
|
||||
@@ -1409,7 +1517,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
📍 Định vị trên bản đồ
|
||||
</button>
|
||||
)}
|
||||
<div className="text-[10px] text-red-600 dark:text-red-400 font-medium italic mt-1.5 pt-1.5 border-t border-gray-100 dark:border-slate-850">
|
||||
<div className="text-[10px] text-red-600 dark:text-red-400 font-medium italic mt-1.5 pt-1.5 border-t border-[var(--border)] dark:border-slate-850">
|
||||
Lý do: {item.reason}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,8 +101,8 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
|
||||
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8 font-sans">
|
||||
<div className="max-w-md w-full space-y-8 bg-white p-10 rounded-3xl shadow-2xl text-center border border-gray-100">
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-[var(--background)] py-12 px-4 sm:px-6 lg:px-8 font-sans">
|
||||
<div className="max-w-md w-full space-y-8 bg-[var(--surface)] p-10 rounded-3xl shadow-2xl text-center border border-[var(--border)]">
|
||||
|
||||
{/* Logo/Icon */}
|
||||
<div className="flex justify-center">
|
||||
@@ -114,16 +114,16 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
|
||||
{loading ? (
|
||||
<div className="space-y-4 py-6">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto" />
|
||||
<h2 className="text-xl font-bold text-gray-900">Đang xử lý tham gia hành trình...</h2>
|
||||
<p className="text-sm text-gray-500">Vui lòng đợi trong giây lát.</p>
|
||||
<h2 className="text-xl font-bold text-[var(--text-primary)]">Đang xử lý tham gia hành trình...</h2>
|
||||
<p className="text-sm text-[var(--text-muted)]">Vui lòng đợi trong giây lát.</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="w-12 h-12 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto">
|
||||
<AlertCircle className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Gia nhập thất bại</h2>
|
||||
<p className="text-sm text-red-600 bg-red-50 p-4 rounded-2xl font-semibold border border-red-100">{error}</p>
|
||||
<h2 className="text-xl font-bold text-[var(--text-primary)]">Gia nhập thất bại</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] bg-red-50 p-4 rounded-2xl font-semibold border border-red-100">{error}</p>
|
||||
<div className="pt-4 flex flex-col gap-2">
|
||||
{isLoggedIn ? (
|
||||
<button
|
||||
@@ -142,7 +142,7 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
|
||||
</button>
|
||||
<button
|
||||
onClick={onGoToHome}
|
||||
className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold py-3.5 rounded-2xl transition-all"
|
||||
className="w-full bg-[var(--background)] hover:bg-[var(--background)]/80 text-[var(--text-primary)] font-bold py-3.5 rounded-2xl transition-all border border-[var(--border)]"
|
||||
>
|
||||
Về trang chủ
|
||||
</button>
|
||||
@@ -155,7 +155,7 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
|
||||
<div className="w-12 h-12 bg-green-50 text-green-500 rounded-full flex items-center justify-center mx-auto">
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Thành công!</h2>
|
||||
<h2 className="text-xl font-bold text-[var(--text-primary)]">Thành công!</h2>
|
||||
<p className="text-sm text-green-700 bg-green-50 p-4 rounded-2xl font-semibold border border-green-100">{successMsg}</p>
|
||||
<div className="pt-4">
|
||||
<button
|
||||
@@ -171,8 +171,8 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-black text-gray-900 tracking-tight">Chào mừng bạn!</h2>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
<h2 className="text-2xl font-black text-[var(--text-primary)] tracking-tight">Chào mừng bạn!</h2>
|
||||
<p className="text-[var(--text-secondary)] text-sm leading-relaxed">
|
||||
Bạn nhận được một lời mời tham gia hành trình du lịch. Vui lòng đăng nhập hoặc tạo tài khoản để có thể join và xem các hoạt động, chi phí của tour.
|
||||
</p>
|
||||
|
||||
@@ -185,7 +185,7 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
|
||||
</button>
|
||||
<button
|
||||
onClick={onGoToSignup}
|
||||
className="w-full flex items-center justify-center gap-2 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-4 rounded-2xl border border-gray-200 transition-all active:scale-[0.98]"
|
||||
className="w-full flex items-center justify-center gap-2 bg-[var(--background)] hover:bg-[var(--border)] text-[var(--text-primary)] font-bold py-4 rounded-2xl border border-[var(--border)] transition-all active:scale-[0.98]"
|
||||
>
|
||||
<UserPlus className="w-5 h-5" /> Đăng ký tài khoản mới
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { LogIn, Compass, Map as MapIcon, Camera, ShieldAlert } from 'lucide-react';
|
||||
import { LogIn, Compass, Map as MapIcon, Camera, ShieldAlert, Image as ImageIcon, X } from 'lucide-react';
|
||||
import { LoginModal } from '../components/LoginModal';
|
||||
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
||||
import { TagSelectModal } from '../components/TagSelectModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { processImageModeration } from '../hooks/useImageModeration';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
@@ -18,7 +19,15 @@ interface LandingPageProps {
|
||||
|
||||
export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => {
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
|
||||
const [isPhotoSourceModalOpen, setIsPhotoSourceModalOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const galleryInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingPhotoFile, setPendingPhotoFile] = useState<File | null>(null);
|
||||
const [pendingPhotoLocation, setPendingPhotoLocation] = useState<GeolocationPosition | null>(null);
|
||||
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
|
||||
const notify = useNotification();
|
||||
const { t, lang, changeLanguage } = useTranslation();
|
||||
const { theme, changeTheme } = useTheme();
|
||||
@@ -26,7 +35,6 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||
const [blacklist, setBlacklist] = useState<any[]>([]);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [currentBgIndex, setCurrentBgIndex] = useState(0);
|
||||
const [bg1, setBg1] = useState('/background.avif');
|
||||
const [bg2, setBg2] = useState('');
|
||||
@@ -97,6 +105,52 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
}
|
||||
};
|
||||
|
||||
// Update Open Graph meta tags when public photos are fetched
|
||||
useEffect(() => {
|
||||
if (publicPhotos.length > 0) {
|
||||
const mainPhoto = publicPhotos[0];
|
||||
const photoUrl = mainPhoto.imageUrl || mainPhoto.originalUrl || '/background.avif';
|
||||
const photoTitle = mainPhoto.metadata?.title || 'Travel Planner - Khám phá chuyến đi tuyệt vời';
|
||||
const photoDescription = mainPhoto.metadata?.description || `Được chia sẻ bởi ${mainPhoto.uploader?.name || 'một thành viên'}. Khám phá những hành trình tuyệt vời trên Travel Planner.`;
|
||||
|
||||
// Update og:image
|
||||
let ogImage = document.querySelector('meta[property="og:image"]');
|
||||
if (!ogImage) {
|
||||
ogImage = document.createElement('meta');
|
||||
ogImage.setAttribute('property', 'og:image');
|
||||
document.head.appendChild(ogImage);
|
||||
}
|
||||
ogImage.setAttribute('content', `https://yotrip.labz.io.vn${photoUrl}`);
|
||||
|
||||
// Update og:title
|
||||
let ogTitle = document.querySelector('meta[property="og:title"]');
|
||||
if (!ogTitle) {
|
||||
ogTitle = document.createElement('meta');
|
||||
ogTitle.setAttribute('property', 'og:title');
|
||||
document.head.appendChild(ogTitle);
|
||||
}
|
||||
ogTitle.setAttribute('content', photoTitle);
|
||||
|
||||
// Update og:description
|
||||
let ogDescription = document.querySelector('meta[property="og:description"]');
|
||||
if (!ogDescription) {
|
||||
ogDescription = document.createElement('meta');
|
||||
ogDescription.setAttribute('property', 'og:description');
|
||||
document.head.appendChild(ogDescription);
|
||||
}
|
||||
ogDescription.setAttribute('content', photoDescription);
|
||||
|
||||
// Update twitter:image
|
||||
let twitterImage = document.querySelector('meta[name="twitter:image"]');
|
||||
if (!twitterImage) {
|
||||
twitterImage = document.createElement('meta');
|
||||
twitterImage.setAttribute('name', 'twitter:image');
|
||||
document.head.appendChild(twitterImage);
|
||||
}
|
||||
twitterImage.setAttribute('content', `https://yotrip.labz.io.vn${photoUrl}`);
|
||||
}
|
||||
}, [publicPhotos]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPublicPhotos();
|
||||
fetchTrustedUsers();
|
||||
@@ -144,7 +198,31 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
|
||||
]);
|
||||
|
||||
// 1. Xóa token cũ nếu có để đảm bảo guest không bị nhầm lẫn
|
||||
// Lưu file và location vào state pending, hiển thị modal tags
|
||||
setPendingPhotoFile(processedFile);
|
||||
setPendingPhotoLocation(location);
|
||||
|
||||
// Tạo preview URL cho ảnh
|
||||
const previewUrl = URL.createObjectURL(processedFile);
|
||||
setPhotoPreviewUrl(previewUrl);
|
||||
|
||||
setIsTagsModalOpen(true);
|
||||
} catch (error: any) {
|
||||
notify({ title: 'Lỗi', message: error.message, type: 'error' });
|
||||
} finally {
|
||||
// Reset input để có thể chọn lại cùng 1 file
|
||||
if (event.target) event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmTags = async (selectedTags: string[]) => {
|
||||
if (!pendingPhotoFile) return;
|
||||
|
||||
setIsTagsModalOpen(false);
|
||||
notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||||
|
||||
try {
|
||||
// 1. Xóa token cũ nếu có để đảm bảo guest không bị nhầm lẫn
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
|
||||
@@ -162,12 +240,16 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
localStorage.setItem('guest_user', JSON.stringify(guestUser));
|
||||
}
|
||||
|
||||
// 2. Tải ảnh lên
|
||||
// 3. Tải ảnh lên
|
||||
const formData = new FormData();
|
||||
formData.append('images', processedFile);
|
||||
if (location) {
|
||||
formData.append('latitude', location.coords.latitude.toString());
|
||||
formData.append('longitude', location.coords.longitude.toString());
|
||||
formData.append('images', pendingPhotoFile);
|
||||
if (pendingPhotoLocation) {
|
||||
formData.append('latitude', pendingPhotoLocation.coords.latitude.toString());
|
||||
formData.append('longitude', pendingPhotoLocation.coords.longitude.toString());
|
||||
}
|
||||
// Thêm tags vào formData
|
||||
if (selectedTags.length > 0) {
|
||||
formData.append('tags', JSON.stringify(selectedTags));
|
||||
}
|
||||
|
||||
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
|
||||
@@ -203,7 +285,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
throw new Error(errorData.message || 'Tải ảnh thất bại.');
|
||||
}
|
||||
|
||||
notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' });
|
||||
notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' });
|
||||
|
||||
// Xóa pendingInviteToken và parameter token khỏi URL để tránh tự động chuyển sang JoinTourPage
|
||||
localStorage.removeItem('pendingInviteToken');
|
||||
@@ -215,17 +297,28 @@ notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải
|
||||
await fetchPublicPhotos();
|
||||
setCurrentBgIndex(0);
|
||||
|
||||
// Xóa pending data
|
||||
setPendingPhotoFile(null);
|
||||
setPendingPhotoLocation(null);
|
||||
if (photoPreviewUrl) {
|
||||
URL.revokeObjectURL(photoPreviewUrl);
|
||||
setPhotoPreviewUrl('');
|
||||
}
|
||||
} catch (error: any) {
|
||||
notify({ title: 'Lỗi', message: error.message, type: 'error' });
|
||||
} finally {
|
||||
// Reset input để có thể chọn lại cùng 1 file
|
||||
if (event.target) event.target.value = '';
|
||||
// Cleanup on error too
|
||||
setPendingPhotoFile(null);
|
||||
setPendingPhotoLocation(null);
|
||||
if (photoPreviewUrl) {
|
||||
URL.revokeObjectURL(photoPreviewUrl);
|
||||
setPhotoPreviewUrl('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-dvh w-full overflow-hidden font-sans bg-gray-900 relative">
|
||||
<div className="h-dvh w-full overflow-hidden font-sans bg-[var(--background)] relative">
|
||||
{/* Background Image with Horizontal Panning */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
{/* Active image for panning */}
|
||||
@@ -468,6 +561,25 @@ return (
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Camera input - capture="environment" for rear camera */}
|
||||
<input
|
||||
type="file"
|
||||
ref={cameraInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Gallery input - no capture attribute for file picker */}
|
||||
<input
|
||||
type="file"
|
||||
ref={galleryInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Unified Bottom Container: Gallery thumbnails on top, action buttons below */}
|
||||
<div className="absolute bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4 flex flex-col items-center gap-4 max-w-md">
|
||||
{/* Community Gallery Previews */}
|
||||
@@ -517,7 +629,7 @@ return (
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={() => setIsPhotoSourceModalOpen(true)}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-white/20 backdrop-blur-lg hover:bg-white/30 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-white/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
<Camera className="w-4.5 h-4.5" />
|
||||
@@ -549,6 +661,77 @@ return (
|
||||
isOpen={isReportModalOpen}
|
||||
onClose={() => setIsReportModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Tag Select Modal */}
|
||||
<TagSelectModal
|
||||
isOpen={isTagsModalOpen}
|
||||
onClose={() => {
|
||||
setIsTagsModalOpen(false);
|
||||
setPendingPhotoFile(null);
|
||||
setPendingPhotoLocation(null);
|
||||
if (photoPreviewUrl) {
|
||||
URL.revokeObjectURL(photoPreviewUrl);
|
||||
setPhotoPreviewUrl('');
|
||||
}
|
||||
}}
|
||||
onConfirm={handleConfirmTags}
|
||||
photoUrl={photoPreviewUrl}
|
||||
/>
|
||||
|
||||
{/* Photo Source Selection Modal */}
|
||||
{isPhotoSourceModalOpen && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-[var(--surface)] rounded-3xl shadow-2xl max-w-sm w-full overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-500 px-6 py-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white">Chụp hoặc tải ảnh</h2>
|
||||
<button
|
||||
onClick={() => setIsPhotoSourceModalOpen(false)}
|
||||
className="p-1.5 hover:bg-white/20 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-3">
|
||||
{/* Camera Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsPhotoSourceModalOpen(false);
|
||||
cameraInputRef.current?.click();
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-4 bg-[var(--background)] hover:bg-[var(--background-alt)] border border-[var(--border)] rounded-2xl transition-all active:scale-95"
|
||||
>
|
||||
<div className="flex-shrink-0 p-3 bg-blue-100 rounded-full">
|
||||
<Camera className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-bold text-[var(--text-primary)]">Chụp ảnh bằng camera</div>
|
||||
<div className="text-sm text-[var(--text-muted)]">Dùng camera thiết bị của bạn</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Gallery Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsPhotoSourceModalOpen(false);
|
||||
galleryInputRef.current?.click();
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-4 bg-[var(--background)] hover:bg-[var(--background-alt)] border border-[var(--border)] rounded-2xl transition-all active:scale-95"
|
||||
>
|
||||
<div className="flex-shrink-0 p-3 bg-emerald-100 rounded-full">
|
||||
<ImageIcon className="w-6 h-6 text-emerald-600" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-bold text-[var(--text-primary)]">Tải ảnh từ thư viện</div>
|
||||
<div className="text-sm text-[var(--text-muted)]">Chọn ảnh từ thiết bị của bạn</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
Loader2,
|
||||
Bell,
|
||||
BellOff,
|
||||
ShieldAlert
|
||||
ShieldAlert,
|
||||
Camera
|
||||
} from 'lucide-react';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
@@ -108,6 +109,42 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
const [shareStatus, setShareStatus] = useState<any | null>(null);
|
||||
const [loadingShare, setLoadingShare] = useState(false);
|
||||
|
||||
const handleNavigateToItinerary = (tour: any) => {
|
||||
let targetLegId = null;
|
||||
|
||||
if (tour?.legs && tour.legs.length > 0) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
for (const leg of tour.legs) {
|
||||
const rawStart = leg.startDate || leg.plannedStart || leg.date;
|
||||
const rawEnd = leg.endDate || leg.plannedEnd || leg.date;
|
||||
|
||||
if (rawStart) {
|
||||
const startBound = new Date(rawStart);
|
||||
startBound.setHours(0, 0, 0, 0);
|
||||
|
||||
const endBound = rawEnd ? new Date(rawEnd) : new Date(rawStart);
|
||||
endBound.setHours(23, 59, 59, 999);
|
||||
|
||||
if (today >= startBound && today <= endBound) {
|
||||
targetLegId = leg.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetLegId) {
|
||||
targetLegId = tour.legs[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetLegId) {
|
||||
sessionStorage.setItem('defaultExpandedLegId', targetLegId);
|
||||
}
|
||||
onViewTour(tour.id, 'dashboard');
|
||||
};
|
||||
|
||||
const handleOpenShareModal = async (tour: any) => {
|
||||
setSharingTour(tour);
|
||||
setShareStatus(null);
|
||||
@@ -128,6 +165,56 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmergencyShare = async (tour: any) => {
|
||||
try {
|
||||
setLoadingShare(true);
|
||||
// Get current location
|
||||
const position = await new Promise<GeolocationCoordinates>((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 {
|
||||
@@ -154,7 +241,81 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
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<GeolocationCoordinates>((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<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
@@ -837,7 +998,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${
|
||||
activeTab === 'tours'
|
||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||
: 'bg-slate-900/60 border-slate-800/60'
|
||||
: 'bg-slate-850/80 border-slate-700/80'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -853,7 +1014,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="bg-slate-800 px-2.5 py-0.5 text-xs rounded-full text-slate-350 font-bold border border-slate-700">
|
||||
<span className="bg-slate-800 px-2.5 py-0.5 text-xs rounded-full text-white/90 font-bold border border-slate-700">
|
||||
{myTours.length}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-slate-500" />
|
||||
@@ -865,7 +1026,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${
|
||||
activeTab === 'photos'
|
||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||
: 'bg-slate-900/60 border-slate-800/60'
|
||||
: 'bg-slate-850/80 border-slate-700/80'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1300,22 +1461,14 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onViewTour(tour.id, 'dashboard')}
|
||||
className="flex-1 py-2 px-2 bg-slate-900 hover:bg-slate-800 text-slate-350 hover:text-white rounded-xl text-xs font-bold transition-all border border-slate-800 hover:border-slate-700 flex items-center justify-center gap-1"
|
||||
>
|
||||
<span>Xem chi tiết</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Chat button - moved to top */}
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem('tour_detail_default_tab', 'chat');
|
||||
setUnreadTourChats(prev => prev.filter(id => id !== tour.id));
|
||||
onViewTour(tour.id, 'dashboard');
|
||||
}}
|
||||
className="flex-1 py-2 px-2 bg-indigo-600/20 hover:bg-indigo-600 text-indigo-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-indigo-500/30 hover:border-indigo-500 flex items-center justify-center gap-1 relative whitespace-nowrap"
|
||||
className="w-full py-2 px-2 bg-indigo-600/20 hover:bg-indigo-600 text-black-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-indigo-500/30 hover:border-indigo-500 flex items-center justify-center gap-1 relative whitespace-nowrap"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
<span>Trò chuyện</span>
|
||||
@@ -1326,16 +1479,55 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full border border-slate-800" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Camera + Detail buttons row */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleTakeCameraPhoto(tour)}
|
||||
disabled={isUploading}
|
||||
className="flex-1 py-2 px-2 bg-green-600/20 hover:bg-green-600 text-black-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-green-500/30 hover:border-green-500 flex items-center justify-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Chụp ảnh</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenShareModal(tour)}
|
||||
className="w-full py-2 px-3 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20 hover:border-rose-500 flex items-center justify-center gap-1.5"
|
||||
onClick={() => handleNavigateToItinerary(tour)}
|
||||
className="flex-1 py-2 px-2 bg-slate-900 hover:bg-slate-800 text-slate-850 hover:text-white rounded-xl text-xs font-bold transition-all border border-slate-800 hover:border-slate-700 flex items-center justify-center gap-1"
|
||||
>
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-rose-500" />
|
||||
<span>Chia sẻ khẩn cấp</span>
|
||||
<span>Chi tiết hành trình</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Emergency Share Button - moved to bottom */}
|
||||
<button
|
||||
onClick={() => handleEmergencyShare(tour)}
|
||||
disabled={loadingShare}
|
||||
className="w-full py-2 px-3 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white-500 rounded-xl text-xs font-bold transition-all border border-rose-500/20 hover:border-rose-500 flex items-center justify-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loadingShare ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-rose-500" />
|
||||
)}
|
||||
<span>Chia sẻ vị trí khẩn cấp</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hidden camera input */}
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={handleCameraFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1465,7 +1657,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
setMobileShowDetail(true);
|
||||
}
|
||||
}}
|
||||
className="p-1.5 bg-indigo-950/50 hover:bg-indigo-600 text-indigo-300 hover:text-white border border-indigo-800/40 hover:border-indigo-500 rounded-lg text-xs font-bold transition-all"
|
||||
className="p-1.5 bg-indigo-950/550 hover:bg-indigo-600 text-blue-500 hover:text-white border border-indigo-800/40 hover:border-indigo-500 rounded-lg text-xs font-bold transition-all"
|
||||
title="Trò chuyện"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
@@ -1681,9 +1873,9 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: REALTIME CHAT */}
|
||||
{/* TAB 4: REALTIME CHAT */}
|
||||
{activeTab === 'chats' && (
|
||||
<div className="flex-1 w-full bg-slate-900/40 border-0 md:border border-slate-800/80 md:rounded-3xl overflow-hidden flex animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="flex flex-col h-[100dvh] md:h-auto w-full bg-slate-900/40 border-0 md:border border-slate-800/80 md:rounded-3xl overflow-hidden flex animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
|
||||
{/* Chats List sidebar: show if not mobile OR if mobile and no active chat user */}
|
||||
{(!isMobile || !activeChatUser) && (
|
||||
|
||||
@@ -339,15 +339,15 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<div className="min-h-screen bg-[var(--background)] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-4 flex items-center gap-4">
|
||||
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||
<div className="sticky top-0 z-30 bg-[var(--surface)]/80 backdrop-blur-md border-b border-[var(--border)] px-4 py-4 flex items-center gap-4">
|
||||
<button onClick={onBack} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
|
||||
<ChevronLeft className="w-6 h-6 text-[var(--text-secondary)]" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-gray-900">Ghi chú của tôi</h1>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Sổ tay hành trình cá nhân</p>
|
||||
<h1 className="text-xl font-black text-[var(--text-primary)]">Ghi chú của tôi</h1>
|
||||
<p className="text-[10px] font-bold text-[var(--text-muted)] uppercase tracking-widest">Sổ tay hành trình cá nhân</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -358,11 +358,11 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm kiếm nội dung ghi chú..."
|
||||
className="w-full pl-10 pr-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm"
|
||||
className="w-full pl-10 pr-4 py-3 bg-[var(--surface)] dark:bg-[var(--background-alt)] rounded-2xl border border-[var(--border)] dark:border-[var(--border)] shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm text-[var(--text-primary)] dark:text-[var(--text-primary)] placeholder-[var(--text-muted)]"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
@@ -374,7 +374,7 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<div className="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-4" />
|
||||
<p className="font-bold">Đang tải ghi chú...</p>
|
||||
</div>
|
||||
@@ -383,11 +383,11 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tiêu đề ghi chú..."
|
||||
className="w-full px-4 py-3 bg-white rounded-2xl border border-gray-100 shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-lg font-bold"
|
||||
className="w-full px-4 py-3 bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-lg font-bold"
|
||||
value={noteForm.title}
|
||||
onChange={(e) => setNoteForm({ ...noteForm, title: e.target.value })}
|
||||
/>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden min-h-[500px] flex flex-col">
|
||||
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm overflow-hidden min-h-[500px] flex flex-col h-full">
|
||||
<ReactQuill
|
||||
ref={quillRef}
|
||||
theme="snow"
|
||||
@@ -395,7 +395,7 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
||||
onChange={(content) => setNoteForm({ ...noteForm, content })}
|
||||
modules={quillModules}
|
||||
placeholder="Bắt đầu viết cảm nhận của bạn tại đây..."
|
||||
className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-editor]:flex-1 [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-gray-50 [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-gray-200 [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-gray-100 [&_.ql-stroke]:!stroke-gray-500 [&_.ql-fill]:!fill-gray-500 [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500"
|
||||
className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-container]:h-full [&_.ql-container]:min-h-0 [&_.ql-editor]:flex-1 [&_.ql-editor]:overflow-y-auto [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-[var(--border)] [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-[var(--border)] [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-[var(--border)] [&_.ql-stroke]:!stroke-[var(--text-secondary)] [&_.ql-fill]:!fill-[var(--text-secondary)] [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -416,26 +416,26 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
||||
) : filteredNotes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{filteredNotes.map((note) => (
|
||||
<div key={note.id} className="bg-white p-5 rounded-3xl border border-gray-100 shadow-sm hover:shadow-md transition-all group">
|
||||
<div key={note.id} className="bg-[var(--surface)] dark:bg-[var(--surface-muted)] p-5 rounded-3xl border border-[var(--border)] dark:border-[var(--border)] shadow-sm hover:shadow-md transition-all group">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900">{note.title}</h4>
|
||||
<div className="flex items-center gap-2 text-[10px] text-gray-400 font-bold uppercase mt-1">
|
||||
<h4 className="font-bold text-[var(--text-primary)] dark:text-[var(--text-primary)]">{note.title}</h4>
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] dark:text-[var(--text-muted)] font-bold uppercase mt-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(note.createdAt).toLocaleDateString('vi-VN')}
|
||||
{new Date(note.createdAt).toLocaleDateString("vi-VN")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleEditNote(note)}
|
||||
className="p-2 text-gray-300 hover:text-blue-500 hover:bg-blue-50 rounded-xl transition-all opacity-0 group-hover:opacity-100"
|
||||
className="p-2 text-[var(--text-muted)] dark:text-[var(--text-muted)] hover:text-blue-500 hover:bg-[var(--background)] dark:hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
|
||||
title="Sửa ghi chú"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteNote(note.id)}
|
||||
className="p-2 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all opacity-0 group-hover:opacity-100"
|
||||
className="p-2 text-[var(--text-muted)] dark:text-[var(--text-muted)] hover:text-red-500 hover:bg-[var(--background)] dark:hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
|
||||
title="Xóa ghi chú"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -443,19 +443,19 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-gray-600 line-clamp-3 prose prose-sm max-w-none ql-editor !p-0 [&_table]:border-collapse [&_table]:w-full [&_td]:border [&_td]:border-gray-200 [&_td]:p-2 [&_.ql-align-center]:text-center [&_.ql-align-right]:text-right [&_.ql-align-justify]:text-justify"
|
||||
className="text-sm text-[var(--text-secondary)] dark:text-[var(--text-secondary)] prose prose-sm max-w-none overflow-y-auto max-h-[280px] pr-1 [&_h2]:text-base [&_h2]:font-bold [&_h2]:mt-3 [&_h2]:mb-2 [&_h2]:text-[var(--text-primary)] [&_h4]:text-sm [&_h4]:font-bold [&_h4]:mt-2 [&_h4]:mb-1 [&_h4]:text-[var(--text-primary)] [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_li]:mb-1 [&_p]:mb-2 [&_p]:break-words"
|
||||
dangerouslySetInnerHTML={{ __html: note.content }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-24 bg-white rounded-[40px] border-2 border-dashed border-gray-100 shadow-inner">
|
||||
<div className="text-center py-24 bg-[var(--surface)] dark:bg-[var(--background-alt)] rounded-[40px] border-2 border-dashed border-[var(--border)] dark:border-[var(--border)] shadow-inner">
|
||||
<div className="w-20 h-20 bg-amber-50 rounded-3xl flex items-center justify-center mx-auto mb-6 text-amber-500">
|
||||
<FileText className="w-10 h-10" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">{searchQuery ? 'Không tìm thấy ghi chú' : 'Chưa có ghi chú nào'}</h3>
|
||||
<p className="text-sm text-gray-400 max-w-xs mx-auto mt-2">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{searchQuery ? 'Không tìm thấy ghi chú' : 'Chưa có ghi chú nào'}</h3>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-400 max-w-xs mx-auto mt-2">
|
||||
{searchQuery ? 'Hãy thử tìm kiếm với từ khóa khác.' : 'Hãy lưu lại những cảm nhận, lịch trình riêng hoặc các lưu ý quan trọng cho hành trình của bạn.'}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
|
||||
@@ -358,7 +358,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
title={isLiked ? "Bỏ thích" : "Thích"}
|
||||
>
|
||||
<Heart className={`w-4 h-4 transition-colors ${
|
||||
isLiked ? 'text-rose-500 fill-rose-500' : 'text-gray-300'
|
||||
isLiked ? 'text-rose-500 fill-rose-500' : 'text-[var(--text-muted)]'
|
||||
}`} />
|
||||
<span>{likeCount}</span>
|
||||
</button>
|
||||
|
||||
@@ -239,7 +239,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
if (error || !tour) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 p-4 transition-colors">
|
||||
<div className="bg-white dark:bg-slate-900 border border-gray-100 dark:border-slate-800 shadow-2xl rounded-[32px] p-8 max-w-md w-full text-center flex flex-col items-center gap-5">
|
||||
<div className="bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 shadow-2xl rounded-[32px] p-8 max-w-md w-full text-center flex flex-col items-center gap-5">
|
||||
<div className="w-16 h-16 bg-rose-50 dark:bg-rose-950/20 border border-rose-100 dark:border-rose-900/30 rounded-full flex items-center justify-center text-rose-500">
|
||||
<ShieldAlert className="w-8 h-8" />
|
||||
</div>
|
||||
@@ -261,11 +261,11 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 transition-colors overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="h-16 shrink-0 bg-white/70 dark:bg-slate-900/70 backdrop-blur-md border-b border-gray-100 dark:border-slate-800 px-4 md:px-6 flex items-center justify-between z-20">
|
||||
<header className="h-16 shrink-0 bg-[var(--surface)]/70 dark:bg-slate-900/70 backdrop-blur-md border-b border-[var(--border)] dark:border-slate-800 px-4 md:px-6 flex items-center justify-between z-20">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onGoToHome}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-xl transition-all active:scale-95 text-slate-500 dark:text-slate-400"
|
||||
className="p-2 hover:bg-[var(--background)] dark:hover:bg-slate-800 rounded-xl transition-all active:scale-95 text-[var(--text-muted)] dark:text-slate-400"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -274,7 +274,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
<span className="w-2.5 h-2.5 bg-rose-500 rounded-full animate-pulse"></span>
|
||||
<h1 className="text-sm md:text-base font-black uppercase tracking-tight">{t('emergencyJourney')}</h1>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 dark:text-slate-400 truncate max-w-[200px] sm:max-w-xs">{tour.title}</p>
|
||||
<p className="text-[10px] text-[var(--text-muted)] dark:text-slate-400 truncate max-w-[200px] sm:max-w-xs">{tour.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -283,7 +283,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => changeLanguage(e.target.value as any)}
|
||||
className="bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 text-slate-800 dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
|
||||
className="bg-[var(--background)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 text-[var(--text-primary)] dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="vi">VI</option>
|
||||
<option value="en">EN</option>
|
||||
@@ -294,7 +294,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => changeTheme(e.target.value as any)}
|
||||
className="bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 text-slate-800 dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
|
||||
className="bg-[var(--background)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 text-[var(--text-primary)] dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="light">{t('themeLight')}</option>
|
||||
<option value="dark">{t('themeDark')}</option>
|
||||
@@ -304,7 +304,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
</header>
|
||||
|
||||
{/* Mobile Tab Switcher */}
|
||||
<div className="lg:hidden h-12 shrink-0 bg-white dark:bg-slate-900 border-b border-gray-100 dark:border-slate-800 flex items-center z-10 p-1">
|
||||
<div className="lg:hidden h-12 shrink-0 bg-[var(--surface)] dark:bg-slate-900 border-b border-[var(--border)] dark:border-slate-800 flex items-center z-10 p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('map')}
|
||||
className={`flex-1 h-full flex items-center justify-center gap-2 text-xs font-bold rounded-lg transition-all ${activeTab === 'map' ? 'bg-rose-500 text-white shadow-md' : 'text-slate-500 dark:text-slate-400'}`}
|
||||
@@ -324,13 +324,13 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
{/* Main Layout Area */}
|
||||
<div className="flex-1 flex flex-col lg:grid lg:grid-cols-12 overflow-hidden">
|
||||
{/* Left Side Pane (Itinerary and Contacts) */}
|
||||
<div className={`flex-col lg:col-span-4 bg-white dark:bg-slate-900 border-r border-gray-100 dark:border-slate-800 overflow-y-auto no-scrollbar ${activeTab === 'itinerary' ? 'flex h-full' : 'hidden lg:flex'}`}>
|
||||
<div className={`flex-col lg:col-span-4 bg-[var(--surface)] dark:bg-slate-900 border-r border-[var(--border)] dark:border-slate-800 overflow-y-auto no-scrollbar ${activeTab === 'itinerary' ? 'flex h-full' : 'hidden lg:flex'}`}>
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Tour Meta Info */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg md:text-xl font-black text-slate-900 dark:text-white leading-tight">{tour.title}</h2>
|
||||
<h2 className="text-lg md:text-xl font-black text-[var(--text-primary)] dark:text-white leading-tight">{tour.title}</h2>
|
||||
{tour.startDate && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--text-muted)] dark:text-slate-400">
|
||||
<Calendar className="w-4 h-4 text-rose-500" />
|
||||
<span>
|
||||
{new Date(tour.startDate).toLocaleDateString()}
|
||||
@@ -339,7 +339,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
</div>
|
||||
)}
|
||||
{tour.description && (
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed bg-slate-50 dark:bg-slate-950 p-3 rounded-2xl border border-gray-100 dark:border-slate-800/40">
|
||||
<p className="text-xs text-[var(--text-secondary)] dark:text-slate-400 leading-relaxed bg-[var(--background)] dark:bg-slate-950 p-3 rounded-2xl border border-[var(--border)] dark:border-slate-800/40">
|
||||
{tour.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -358,8 +358,8 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
<div className="bg-rose-50/50 dark:bg-rose-950/10 border border-rose-100/50 dark:border-rose-950/20 p-4 rounded-2xl flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-rose-600 dark:text-rose-400 font-bold uppercase tracking-wider">{t('tourOwner')}</div>
|
||||
<div className="text-sm font-black text-slate-900 dark:text-white truncate mt-0.5">{ownerContact.name || 'Anonymous'}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{ownerContact.phone || t('noPhone')}</div>
|
||||
<div className="text-sm font-black text-[var(--text-primary)] dark:text-white truncate mt-0.5">{ownerContact.name || 'Anonymous'}</div>
|
||||
<div className="text-xs text-[var(--text-muted)] dark:text-slate-400 mt-0.5">{ownerContact.phone || t('noPhone')}</div>
|
||||
</div>
|
||||
{ownerContact.phone && (
|
||||
<a
|
||||
@@ -374,11 +374,11 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
|
||||
{/* Manager contacts */}
|
||||
{managerContacts.map((mgr, idx) => (
|
||||
<div key={idx} className="bg-slate-50 dark:bg-slate-950 border border-gray-100 dark:border-slate-800 p-4 rounded-2xl flex items-center justify-between gap-4">
|
||||
<div key={idx} className="bg-[var(--background)] dark:bg-slate-950 border border-[var(--border)] dark:border-slate-800 p-4 rounded-2xl flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 font-bold uppercase tracking-wider">{t('tourManager')}</div>
|
||||
<div className="text-sm font-black text-slate-900 dark:text-white truncate mt-0.5">{mgr.name}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{mgr.phone}</div>
|
||||
<div className="text-xs text-[var(--text-muted)] dark:text-slate-400 font-bold uppercase tracking-wider">{t('tourManager')}</div>
|
||||
<div className="text-sm font-black text-[var(--text-primary)] dark:text-white truncate mt-0.5">{mgr.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)] dark:text-slate-400 mt-0.5">{mgr.phone}</div>
|
||||
</div>
|
||||
<a
|
||||
href={`tel:${mgr.phone}`}
|
||||
@@ -390,20 +390,20 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
))}
|
||||
|
||||
{!ownerContact?.phone && managerContacts.length === 0 && (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 italic">{t('noPhone')}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] dark:text-slate-500 italic">{t('noPhone')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stops Hierarchy List */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
||||
<h3 className="text-xs font-black text-[var(--text-muted)] dark:text-slate-500 uppercase tracking-widest">
|
||||
{t('viewTimeline')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4 relative before:absolute before:left-3 before:top-2 before:bottom-2 before:w-[2px] before:bg-gray-100 dark:before:bg-slate-800">
|
||||
<div className="space-y-4 relative before:absolute before:left-3 before:top-2 before:bottom-2 before:w-[2px] before:bg-[var(--border)] dark:before:bg-slate-800">
|
||||
{allLocations.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 italic pl-6">{t('noStops')}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] dark:text-slate-500 italic pl-6">{t('noStops')}</p>
|
||||
) : (
|
||||
allLocations.map((loc, idx) => {
|
||||
const isStart = idx === 0;
|
||||
@@ -430,15 +430,15 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex-1 bg-slate-50/50 hover:bg-slate-50 dark:bg-slate-950/40 dark:hover:bg-slate-950/80 p-3 rounded-2xl border border-gray-100/50 dark:border-slate-800/40 transition-all select-none">
|
||||
<h4 className="text-xs font-black text-slate-800 dark:text-slate-200 group-hover:text-rose-500 dark:group-hover:text-rose-400 transition-colors">
|
||||
<div className="flex-1 bg-[var(--background)]/50 hover:bg-[var(--background)] dark:bg-slate-950/40 dark:hover:bg-slate-950/80 p-3 rounded-2xl border border-[var(--border)]/50 dark:border-slate-800/40 transition-all select-none">
|
||||
<h4 className="text-xs font-black text-[var(--text-primary)] dark:text-slate-200 group-hover:text-rose-500 dark:group-hover:text-rose-400 transition-colors">
|
||||
{loc.name}
|
||||
</h4>
|
||||
{loc.address && (
|
||||
<p className="text-[10px] text-slate-500 dark:text-slate-400 truncate mt-0.5">{loc.address}</p>
|
||||
<p className="text-[10px] text-[var(--text-muted)] dark:text-slate-400 truncate mt-0.5">{loc.address}</p>
|
||||
)}
|
||||
{loc.plannedStart && (
|
||||
<p className="text-[9px] text-slate-400 dark:text-slate-500 mt-1">
|
||||
<p className="text-[9px] text-[var(--text-muted)] dark:text-slate-500 mt-1">
|
||||
{new Date(loc.plannedStart).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{loc.plannedEnd && ` - ${new Date(loc.plannedEnd).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||
</p>
|
||||
@@ -454,7 +454,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
</div>
|
||||
|
||||
{/* Right Side Map Pane */}
|
||||
<div className={`relative flex-1 lg:col-span-8 h-full bg-slate-100 dark:bg-slate-950 ${activeTab === 'map' ? 'flex' : 'hidden lg:flex'}`}>
|
||||
<div className={`relative flex-1 lg:col-span-8 h-full bg-[var(--background)] dark:bg-slate-950 ${activeTab === 'map' ? 'flex' : 'hidden lg:flex'}`}>
|
||||
{allLocations.length > 0 ? (
|
||||
<MapContainer
|
||||
center={[allLocations[0].latitude, allLocations[0].longitude]}
|
||||
@@ -499,7 +499,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
<Popup>
|
||||
<div className="p-1">
|
||||
<div className="font-black text-xs text-slate-900">{loc.name}</div>
|
||||
{loc.address && <div className="text-[10px] text-gray-500 mt-0.5">{loc.address}</div>}
|
||||
{loc.address && <div className="text-[10px] text-[var(--text-muted)] mt-0.5">{loc.address}</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
@@ -527,7 +527,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
{/* Locate Me button */}
|
||||
<button
|
||||
onClick={handleLocateMe}
|
||||
className="w-12 h-12 bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-slate-700 dark:text-slate-200 hover:bg-gray-50 dark:hover:bg-slate-800 transition-all active:scale-95"
|
||||
className="w-12 h-12 bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-800 transition-all active:scale-95"
|
||||
title="Định vị của tôi"
|
||||
>
|
||||
{isLocating ? (
|
||||
@@ -541,7 +541,7 @@ export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoT
|
||||
{allLocations.length > 0 && (
|
||||
<button
|
||||
onClick={() => setFitBoundsTrigger(prev => prev + 1)}
|
||||
className="w-12 h-12 bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-slate-700 dark:text-slate-200 hover:bg-gray-50 dark:hover:bg-slate-800 transition-all active:scale-95"
|
||||
className="w-12 h-12 bg-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-800 transition-all active:scale-95"
|
||||
title="Bao phủ toàn bộ"
|
||||
>
|
||||
<Compass className="w-5 h-5 text-violet-500" />
|
||||
|
||||
@@ -157,20 +157,20 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 bg-white p-10 rounded-3xl shadow-2xl">
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--background)] py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 bg-[var(--surface)] p-10 rounded-3xl shadow-2xl">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-6 left-6 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
className="absolute top-6 left-6 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">
|
||||
<h1 className="text-3xl font-extrabold text-[var(--text-primary)]">
|
||||
{step === 'form' ? 'Tạo tài khoản mới' : 'Xác thực tài khoản'}
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-2 font-medium">
|
||||
<p className="text-[var(--text-muted)] mt-2 font-medium">
|
||||
{step === 'form'
|
||||
? 'Khám phá các tính năng lập kế hoạch chuyên nghiệp.'
|
||||
: `Vui lòng nhập mã OTP đã được gửi tới ${formData.email}`}
|
||||
@@ -187,61 +187,61 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
{step === 'form' ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Họ và tên</label>
|
||||
<label className="text-sm font-bold text-[var(--text-primary)] ml-1">Họ và tên</label>
|
||||
<div className="relative group">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Nhập họ và tên của bạn"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Email</label>
|
||||
<label className="text-sm font-bold text-[var(--text-primary)] ml-1">Email</label>
|
||||
<div className="relative group">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
placeholder="Nhập email của bạn"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Mật khẩu</label>
|
||||
<label className="text-sm font-bold text-[var(--text-primary)] ml-1">Mật khẩu</label>
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="Nhập mật khẩu"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Xác nhận mật khẩu</label>
|
||||
<label className="text-sm font-bold text-[var(--text-primary)] ml-1">Xác nhận mật khẩu</label>
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="Xác nhận mật khẩu"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,28 +249,28 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Số điện thoại</label>
|
||||
<label className="text-sm font-bold text-[var(--text-primary)] ml-1">Số điện thoại</label>
|
||||
<div className="relative group">
|
||||
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Nhập số điện thoại"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Địa chỉ</label>
|
||||
<label className="text-sm font-bold text-[var(--text-primary)] ml-1">Địa chỉ</label>
|
||||
<div className="relative group">
|
||||
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nhập địa chỉ"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,9 +278,9 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700 ml-1">Mã xác thực OTP</label>
|
||||
<label className="text-sm font-bold text-[var(--text-primary)] ml-1">Mã xác thực OTP</label>
|
||||
<div className="relative group">
|
||||
<ShieldCheck className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<ShieldCheck className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)] group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
@@ -288,7 +288,7 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
placeholder="Nhập 6 số mã OTP"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl text-center font-bold tracking-[0.5em] text-lg focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
className="w-full pl-12 pr-4 py-4 bg-[var(--background)] border border-[var(--border)] rounded-2xl text-center font-bold tracking-[0.5em] text-lg focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,9 +308,9 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
|
||||
<>
|
||||
<div className="relative my-6 flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200"></div>
|
||||
<div className="w-full border-t border-[var(--border)]"></div>
|
||||
</div>
|
||||
<span className="relative px-3 bg-white text-xs font-bold text-gray-400 uppercase">Hoặc</span>
|
||||
<span className="relative px-3 bg-[var(--surface)] text-xs font-bold text-[var(--text-muted)] uppercase">Hoặc</span>
|
||||
</div>
|
||||
|
||||
<div id="google-signin-btn-signup" className="w-full flex justify-center"></div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { robotoBase64 } from '../utils/pdfFont';
|
||||
import { robotoBase64, robotoBoldBase64 } from '../utils/pdfFont';
|
||||
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
||||
import { ExpenseManager } from '../components/ExpenseManager';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
@@ -148,28 +148,40 @@ const combineSegmentRoutes = (segmentRoutes: OSRMRoute[][], selectedIndices: num
|
||||
// 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]);
|
||||
const shouldFitRef = useRef(true);
|
||||
const prevLocKeyRef = useRef<string | null>(null);
|
||||
|
||||
const locKey = useMemo(() => JSON.stringify(locations.map((l: any) => [l.latitude, l.longitude])), [locations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (locations.length > 0) {
|
||||
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
|
||||
if (locations.length === 0) return;
|
||||
|
||||
if (prevLocKeyRef.current !== null && prevLocKeyRef.current !== locKey) {
|
||||
shouldFitRef.current = true;
|
||||
}
|
||||
prevLocKeyRef.current = locKey;
|
||||
|
||||
if (!shouldFitRef.current) return;
|
||||
|
||||
const bounds = L.latLngBounds(locations.map((l: any) => [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]);
|
||||
shouldFitRef.current = false;
|
||||
}, [locKey, map, locations.length]);
|
||||
|
||||
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();
|
||||
const lastTriggerRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (position && trigger > 0) {
|
||||
if (position && trigger > lastTriggerRef.current) {
|
||||
lastTriggerRef.current = trigger;
|
||||
map.setView(position, 16, { animate: true });
|
||||
}
|
||||
}, [trigger, position, map]);
|
||||
@@ -210,7 +222,7 @@ const MapRotationHandler = ({ rotation }: { rotation: number }) => {
|
||||
// 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)';
|
||||
container.style.transition = 'transform 0.15s ease-out';
|
||||
}, [rotation, map]);
|
||||
return null;
|
||||
};
|
||||
@@ -310,23 +322,23 @@ const MapContextMenu = ({ onAction, onOpen }: { onAction: (action: string, latln
|
||||
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"
|
||||
className="absolute z-[2000] bg-[var(--surface)] rounded-2xl shadow-2xl border border-[var(--border)] 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">
|
||||
<button onClick={() => { onAction('START', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-[var(--background)] text-sm font-bold text-[var(--text-secondary)] 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">
|
||||
<button onClick={() => { onAction('END', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-[var(--background)] text-sm font-bold text-[var(--text-secondary)] flex items-center gap-2 border-b border-[var(--border)]">
|
||||
<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>
|
||||
<div className="px-4 py-2 text-[10px] font-black text-[var(--text-muted)] 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"
|
||||
className="w-full text-left px-4 py-2 hover:bg-[var(--background)] text-sm font-medium text-[var(--text-secondary)] truncate"
|
||||
>
|
||||
Chặng {leg.sequence}: {leg.note || 'Không có ghi chú'}
|
||||
</button>
|
||||
@@ -348,12 +360,14 @@ export const TourDetailPage = ({
|
||||
onBack,
|
||||
tourId,
|
||||
isPublicView = false,
|
||||
onOpenNotes
|
||||
onOpenNotes,
|
||||
onOpenNavigationPage
|
||||
}: {
|
||||
onBack: () => void,
|
||||
tourId: string,
|
||||
isPublicView?: boolean,
|
||||
onOpenNotes?: () => void
|
||||
onOpenNotes?: () => void,
|
||||
onOpenNavigationPage?: (routeData: { origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string }) => 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();
|
||||
@@ -486,12 +500,12 @@ export const TourDetailPage = ({
|
||||
const handleExportPDF = async () => {
|
||||
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
||||
|
||||
// 1. Thêm font vào Virtual File System của jsPDF để hỗ trợ tiếng Việt
|
||||
doc.addFileToVFS("Roboto-Regular.ttf", robotoBase64);
|
||||
doc.addFont("Roboto-Regular.ttf", "Roboto", "normal");
|
||||
doc.addFileToVFS("Roboto-Bold.ttf", robotoBoldBase64);
|
||||
doc.addFont("Roboto-Bold.ttf", "Roboto", "bold");
|
||||
doc.setFont("Roboto");
|
||||
|
||||
// 2. Vẽ Tiêu đề & Thông tin Tour ở đầu trang
|
||||
const titleText = `LỊCH TRÌNH TOUR: ${currentTour?.title?.toUpperCase() || 'HÀNH TRÌNH TOUR'}`;
|
||||
doc.setFontSize(16);
|
||||
doc.text(titleText, 148.5, 15, { align: 'center' });
|
||||
@@ -508,48 +522,99 @@ export const TourDetailPage = ({
|
||||
doc.text(dateRangeStr, 148.5, startY, { align: 'center' });
|
||||
startY += 8;
|
||||
|
||||
// 3. Chuẩn bị dữ liệu bảng
|
||||
const tableColumn = ["STT", "Ngày giờ", "Chặng", "Địa điểm", "Ghi chú"];
|
||||
const tableRows: any[] = [];
|
||||
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
return `${hours}:${minutes} ${day}/${month}/${year}`;
|
||||
};
|
||||
const legRowRanges: Record<string, { start: number; count: number }> = {};
|
||||
const pdfMapLinks: { [key: number]: string } = {};
|
||||
|
||||
let stt = 1;
|
||||
let globalRowIndex = 0;
|
||||
|
||||
const extractAndFormatTimeInline = (primaryTime: any, fallbackLegObj: any) => {
|
||||
const absoluteTimeSource = primaryTime ||
|
||||
fallbackLegObj?.plannedStart ||
|
||||
fallbackLegObj?.startDate ||
|
||||
fallbackLegObj?.start_date ||
|
||||
fallbackLegObj?.date ||
|
||||
fallbackLegObj?.createdAt;
|
||||
|
||||
if (!absoluteTimeSource) return '';
|
||||
|
||||
const d = new Date(absoluteTimeSource);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
|
||||
const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
const ddmmyyyy = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
return `${hhmm}|${ddmmyyyy}`;
|
||||
};
|
||||
|
||||
if (currentTour?.legs && currentTour.legs.length > 0) {
|
||||
currentTour.legs.forEach((leg: any, legIdx: number) => {
|
||||
const legName = leg.note || `Chặng ${legIdx + 1}`;
|
||||
if (leg.locations && leg.locations.length > 0) {
|
||||
leg.locations.forEach((loc: any) => {
|
||||
const currentStt = stt++;
|
||||
const legNameCellObj = {
|
||||
content: legName,
|
||||
styles: { fontStyle: 'bold' as const }
|
||||
};
|
||||
|
||||
const arrivalStr = loc.arrivalTime ? `Đến: ${formatDateTime(loc.arrivalTime)}` : '';
|
||||
const departureStr = loc.departureTime ? `Đi: ${formatDateTime(loc.departureTime)}` : '';
|
||||
const timeText = [arrivalStr, departureStr].filter(Boolean).join('\n');
|
||||
const legLocations = (leg.locations || []).filter((loc: any) =>
|
||||
loc && (loc.plannedStart || loc.plannedEnd || loc.name)
|
||||
);
|
||||
|
||||
const locName = loc.name;
|
||||
const addressStr = loc.address ? `\n📍 Địa chỉ: ${loc.address}` : '';
|
||||
const locationText = `${locName}${addressStr}`;
|
||||
const startRow = globalRowIndex;
|
||||
|
||||
const noteText = loc.notes || '';
|
||||
if (legLocations.length > 0) {
|
||||
legRowRanges[leg.id] = { start: startRow, count: legLocations.length };
|
||||
|
||||
legLocations.forEach((loc: any, locIdx: number) => {
|
||||
const isStartPoint = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
|
||||
const initialTimeSource = isStartPoint ? loc.plannedEnd : loc.plannedStart;
|
||||
|
||||
const timeStr = extractAndFormatTimeInline(initialTimeSource, leg);
|
||||
|
||||
const locName = loc.name || '';
|
||||
const addressStr = loc.address || '';
|
||||
const coordStr = loc.latitude && loc.longitude ? `\n${loc.latitude}, ${loc.longitude}` : '';
|
||||
const locationText = [locName, addressStr, coordStr].filter(Boolean).join('\n');
|
||||
const noteText = loc.note || '';
|
||||
|
||||
let mapUrl = '';
|
||||
if (loc.latitude && loc.longitude) {
|
||||
mapUrl = `https://www.google.com/maps/search/?api=1&query=${loc.latitude},${loc.longitude}`;
|
||||
} else if (addressStr || locName) {
|
||||
mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(addressStr || locName)}`;
|
||||
}
|
||||
|
||||
const currentRowPosition = tableRows.length;
|
||||
if (mapUrl) {
|
||||
pdfMapLinks[currentRowPosition] = mapUrl;
|
||||
}
|
||||
|
||||
const locationCellObj = {
|
||||
content: locationText,
|
||||
styles: mapUrl ? { textColor: [40, 83, 107], fontStyle: 'bold' as const } : {}
|
||||
};
|
||||
|
||||
tableRows.push([
|
||||
currentStt,
|
||||
timeText,
|
||||
legName,
|
||||
locationText,
|
||||
stt++,
|
||||
timeStr,
|
||||
locIdx === 0 ? legNameCellObj : '',
|
||||
locationCellObj,
|
||||
noteText
|
||||
]);
|
||||
globalRowIndex++;
|
||||
});
|
||||
} else {
|
||||
const legTimeStr = extractAndFormatTimeInline(null, leg);
|
||||
|
||||
legRowRanges[leg.id] = { start: startRow, count: 1 };
|
||||
tableRows.push([
|
||||
stt++,
|
||||
legTimeStr,
|
||||
legNameCellObj,
|
||||
'Chưa có địa điểm trong chặng này',
|
||||
''
|
||||
]);
|
||||
globalRowIndex++;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -558,22 +623,108 @@ export const TourDetailPage = ({
|
||||
tableRows.push(["-", "-", "-", "Chưa có chặng hoặc địa điểm nào trong hành trình.", "-"]);
|
||||
}
|
||||
|
||||
// 4. Vẽ bảng dùng autoTable
|
||||
autoTable(doc, {
|
||||
head: [tableColumn],
|
||||
headStyles: {
|
||||
fillColor: [37, 99, 235],
|
||||
textColor: [255, 255, 255],
|
||||
font: 'Roboto',
|
||||
fontStyle: 'bold',
|
||||
halign: 'center',
|
||||
valign: 'middle'
|
||||
},
|
||||
body: tableRows,
|
||||
startY: startY,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [37, 99, 235], textColor: [255, 255, 255], fontStyle: 'normal' },
|
||||
styles: { font: "Roboto", fontSize: 9, cellPadding: 3, overflow: 'linebreak' },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 12, halign: 'center' }, // STT
|
||||
1: { cellWidth: 50 }, // Ngày giờ
|
||||
2: { cellWidth: 45 }, // Chặng
|
||||
3: { cellWidth: 90 }, // Địa điểm
|
||||
4: { cellWidth: 70 } // Ghi chú
|
||||
styles: {
|
||||
font: "Roboto",
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
overflow: 'linebreak',
|
||||
valign: 'top'
|
||||
},
|
||||
margin: { left: 15, right: 15 }
|
||||
columnStyles: {
|
||||
0: { cellWidth: 12, halign: 'center', valign: 'middle' },
|
||||
1: { cellWidth: 50, halign: 'center', valign: 'middle' },
|
||||
2: { cellWidth: 45, halign: 'center', valign: 'middle' },
|
||||
3: { cellWidth: 90, valign: 'top' },
|
||||
4: { cellWidth: 70, valign: 'top' }
|
||||
},
|
||||
margin: { left: 15, right: 15 },
|
||||
didParseCell: (cell: any) => {
|
||||
if (cell.section === 'body') {
|
||||
if (cell.column.index === 2) {
|
||||
let assigned = false;
|
||||
for (const leg of currentTour?.legs || []) {
|
||||
const range = legRowRanges[leg.id];
|
||||
if (range && cell.row.index >= range.start && cell.row.index < range.start + range.count) {
|
||||
if (cell.row.index === range.start) {
|
||||
cell.rowSpan = range.count;
|
||||
} else {
|
||||
cell.rowSpan = 0;
|
||||
}
|
||||
assigned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!assigned) {
|
||||
cell.rowSpan = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
willDrawCell: (data: any) => {
|
||||
if (data.section === 'body' && data.column.index === 1) {
|
||||
if (data.cell.text && data.cell.text.length > 0) {
|
||||
data.cell.customInlineBuffer = data.cell.text.join('');
|
||||
}
|
||||
data.cell.text = [];
|
||||
}
|
||||
},
|
||||
didDrawCell: (data: any) => {
|
||||
if (data.section === 'body' && data.column.index === 1) {
|
||||
const rawTextStr = data.cell.customInlineBuffer || data.cell.raw;
|
||||
|
||||
if (rawTextStr && typeof rawTextStr === 'string' && rawTextStr.includes('|')) {
|
||||
const [timePart, datePart] = rawTextStr.split('|');
|
||||
const separatorSpace = " ";
|
||||
|
||||
data.doc.setFont(data.cell.styles.font, 'bold');
|
||||
const timeWidth = data.doc.getTextWidth(timePart);
|
||||
|
||||
data.doc.setFont(data.cell.styles.font, 'normal');
|
||||
const spaceWidth = data.doc.getTextWidth(separatorSpace);
|
||||
const dateWidth = data.doc.getTextWidth(datePart);
|
||||
|
||||
const totalBlockWidth = timeWidth + spaceWidth + dateWidth;
|
||||
|
||||
const targetX = data.cell.x + (data.cell.width - totalBlockWidth) / 2;
|
||||
const targetY = data.cell.y + (data.cell.height / 2) + (data.cell.styles.fontSize / 2) - 1;
|
||||
|
||||
data.doc.setFont(data.cell.styles.font, 'bold');
|
||||
data.doc.setTextColor(239, 68, 68);
|
||||
data.doc.text(timePart, targetX, targetY);
|
||||
|
||||
data.doc.setFont(data.cell.styles.font, 'normal');
|
||||
data.doc.setTextColor(37, 99, 235);
|
||||
data.doc.text(datePart, targetX + timeWidth + spaceWidth, targetY);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.section === 'body' && data.column.index === 3) {
|
||||
const activeRowIndex = data.row.index;
|
||||
const targetMapUrl = pdfMapLinks[activeRowIndex];
|
||||
if (targetMapUrl) {
|
||||
data.doc.link(
|
||||
data.cell.x,
|
||||
data.cell.y,
|
||||
data.cell.width,
|
||||
data.cell.height,
|
||||
{ url: targetMapUrl }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -716,6 +867,8 @@ export const TourDetailPage = ({
|
||||
|
||||
// Theo dõi hướng thiết bị (la bàn)
|
||||
useEffect(() => {
|
||||
let lastHeading: number | null = null;
|
||||
|
||||
const handleOrientation = (event: any) => {
|
||||
let heading: number | null = null;
|
||||
|
||||
@@ -725,18 +878,18 @@ export const TourDetailPage = ({
|
||||
}
|
||||
// 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) {
|
||||
// Chỉ cập nhật nếu hướng thay đổi lớn hơn 2 độ để giảm tải vẽ lại
|
||||
if (lastHeading === null || Math.abs(heading - lastHeading) > 2) {
|
||||
lastHeading = heading;
|
||||
setDeviceOrientationHeading(heading);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Đăng ký cả hai loại sự kiện để hỗ trợ tối đa các dòng điện thoại
|
||||
@@ -969,6 +1122,8 @@ export const TourDetailPage = ({
|
||||
|
||||
// State cho Modal ghi chú nhanh
|
||||
const [quickNoteLocName, setQuickNoteLocName] = useState<string | null>(null);
|
||||
const [quickNoteLegId, setQuickNoteLegId] = useState<string | null>(null);
|
||||
const [quickNoteLocation, setQuickNoteLocation] = useState<any>(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
|
||||
@@ -1089,6 +1244,31 @@ export const TourDetailPage = ({
|
||||
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
||||
const deleteTour = useTourStore(state => state.deleteTour);
|
||||
|
||||
const toggleCompassMode = async () => {
|
||||
if (!isHeadingMode) {
|
||||
if (
|
||||
typeof DeviceOrientationEvent !== 'undefined' &&
|
||||
typeof (DeviceOrientationEvent as any).requestPermission === 'function'
|
||||
) {
|
||||
try {
|
||||
const permissionState = await (DeviceOrientationEvent as any).requestPermission();
|
||||
if (permissionState === 'granted') {
|
||||
setIsHeadingMode(true);
|
||||
} else {
|
||||
alert("Để xoay bản đồ theo hướng di chuyển, vui lòng cấp quyền truy cập cảm biến hướng (La bàn).");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error requesting compass permission:", error);
|
||||
setIsHeadingMode(true);
|
||||
}
|
||||
} else {
|
||||
setIsHeadingMode(true);
|
||||
}
|
||||
} else {
|
||||
setIsHeadingMode(false);
|
||||
setMapRotation(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Khôi phục vị trí và mức zoom từ localStorage
|
||||
const [initialViewState] = useState(() => {
|
||||
@@ -1559,62 +1739,76 @@ export const TourDetailPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
const reverseGeocode = async (lat: number, lng: number): Promise<string> => {
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return data.display_name || '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Reverse geocode failed:', e);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleQuickNote = async (data: { legId: string; location: any; leg: any }) => {
|
||||
if (isPublicView || !currentTour) return;
|
||||
setQuickNoteLocName(data.location.name);
|
||||
setQuickNoteLegId(data.legId);
|
||||
setQuickNoteLocation(data.location);
|
||||
setQuickNoteInput('');
|
||||
};
|
||||
|
||||
// Hàm xử lý submit ghi chú nhanh từ modal
|
||||
const submitQuickNote = () => {
|
||||
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour) return;
|
||||
const submitQuickNote = async () => {
|
||||
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour || !quickNoteLegId || !quickNoteLocation) 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);
|
||||
let resolvedAddress = quickNoteLocation.address || '';
|
||||
const hasCoords = quickNoteLocation.latitude && quickNoteLocation.longitude;
|
||||
if ((!resolvedAddress || resolvedAddress.trim() === '') && hasCoords) {
|
||||
const geo = await reverseGeocode(+quickNoteLocation.latitude, +quickNoteLocation.longitude);
|
||||
resolvedAddress = geo || `${quickNoteLocation.latitude}, ${quickNoteLocation.longitude}`;
|
||||
}
|
||||
|
||||
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
|
||||
const formattedTime = quickNoteLocation.plannedStart
|
||||
? new Date(quickNoteLocation.plannedStart).toLocaleString('vi-VN')
|
||||
: "Thời gian tùy hứng";
|
||||
|
||||
const userInputText = content;
|
||||
const noteSnippet = `<h4>📅 ${formattedTime} | 📍 ${resolvedAddress}</h4><ul><li><strong>Nhật ký:</strong> ${userInputText}</li></ul>`;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`/api/v1/tours/${currentTour.id}/notes/insert`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
legId: quickNoteLegId,
|
||||
noteSnippet
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi lưu ghi chú nhanh.', type: 'error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Lỗi khi lưu ghi chú nhanh:', error);
|
||||
notify({ title: 'Lỗi', message: 'Lỗi mạng khi lưu ghi chú nhanh.', type: 'error' });
|
||||
} finally {
|
||||
setQuickNoteLocName(null);
|
||||
setQuickNoteLegId(null);
|
||||
setQuickNoteLocation(null);
|
||||
setQuickNoteInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// Hàm xử lý xóa Tour vĩnh viễn
|
||||
@@ -1706,13 +1900,13 @@ export const TourDetailPage = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-20">
|
||||
<div className="min-h-screen bg-[var(--background)] 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" />
|
||||
<div className="sticky top-0 z-30 bg-[var(--surface)]/80 backdrop-blur-md border-b border-[var(--border)] px-4 py-3 flex items-center justify-between">
|
||||
<button onClick={onBack} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
|
||||
<ChevronLeft className="w-6 h-6 text-[var(--text-secondary)]" />
|
||||
</button>
|
||||
<h1 className="text-lg font-bold text-gray-800 truncate px-4 flex-1 text-center">
|
||||
<h1 className="text-lg font-bold text-[var(--text-primary)] truncate px-4 flex-1 text-center">
|
||||
{tourInfo.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -1738,6 +1932,8 @@ export const TourDetailPage = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!(activeTab === 'plan' && viewMode === 'map') && (
|
||||
<>
|
||||
{/* Tour Header Info */}
|
||||
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
|
||||
<img
|
||||
@@ -2001,11 +2197,14 @@ export const TourDetailPage = ({
|
||||
</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`}>
|
||||
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full !px-0 !pb-0' : 'max-w-2xl mx-auto 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">
|
||||
{!(activeTab === 'plan' && viewMode === 'map') && (
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-[60px] z-40">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -2013,7 +2212,7 @@ export const TourDetailPage = ({
|
||||
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
|
||||
className={`flex-1 flex items-center justify-center py-2 rounded-xl text-xs 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'
|
||||
@@ -2029,14 +2228,15 @@ export const TourDetailPage = ({
|
||||
</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" />
|
||||
{viewMode !== 'map' && (
|
||||
<div className="flex justify-center items-center mb-3">
|
||||
<div className="bg-gray-100 dark:bg-slate-800 p-1 rounded-2xl flex gap-1">
|
||||
<button
|
||||
onClick={() => setViewMode('timeline')}
|
||||
@@ -2051,21 +2251,26 @@ export const TourDetailPage = ({
|
||||
<MapIconLucide className="w-3.5 h-3.5" /> Bản đồ
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-end gap-2">
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export Buttons */}
|
||||
{viewMode !== 'map' && (
|
||||
<div className="flex justify-center gap-2 mb-6">
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95 whitespace-nowrap"
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-md transition-all active:scale-95 whitespace-nowrap"
|
||||
>
|
||||
📊 Google Sheets
|
||||
</button>
|
||||
<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 whitespace-nowrap"
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold shadow-md transition-all active:scale-95 whitespace-nowrap"
|
||||
>
|
||||
📥 {t('exportPDF') || 'Xuất PDF'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'timeline' ? (
|
||||
<ItineraryTimeline onAddLocation={(legId, isStart, isEnd) => {
|
||||
@@ -2078,22 +2283,20 @@ export const TourDetailPage = ({
|
||||
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)}
|
||||
onQuickNote={(data) => handleQuickNote(data)}
|
||||
onSuccess={() => fetchTour(tourId)}
|
||||
isPublicView={isPublicView}
|
||||
onNavigate={handleNavigateToLocation}
|
||||
onOpenNavigationPage={onOpenNavigationPage}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative animate-in fade-in duration-500">
|
||||
<div className="h-[calc(100vh-53px)] w-full md:rounded-3xl overflow-hidden md:shadow-xl md:border-4 md: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">
|
||||
@@ -2274,8 +2477,11 @@ export const TourDetailPage = ({
|
||||
|
||||
<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"
|
||||
onClick={() => {
|
||||
const leg = legs.find((l: any) => l.locations.some((lo: any) => lo.id === loc.id));
|
||||
handleQuickNote({ legId: loc.legId || leg?.id || '', location: loc, leg: leg || {} });
|
||||
}}
|
||||
className="flex items-center justify-center gap-1.5 py-1.5 text-yotripLight-steel hover:text-yotripLight-text dark:text-yotripDark-muted dark:hover:text-yotripDark-lime rounded-lg hover:bg-gray-100 dark:hover:bg-yotripDark-surface-muted transition-colors duration-200 border border-gray-100"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
GHI CHÚ NHANH
|
||||
@@ -2316,6 +2522,38 @@ export const TourDetailPage = ({
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
|
||||
{/* Floating Compass Button */}
|
||||
<button
|
||||
onClick={toggleCompassMode}
|
||||
className={`absolute bottom-16 right-4 z-[1001] w-10 h-10 rounded-xl border flex items-center justify-center shadow-xl transition-all active:scale-95 ${
|
||||
isHeadingMode
|
||||
? 'bg-blue-600 border-blue-400 text-white shadow-md'
|
||||
: 'bg-white/90 backdrop-blur-md border-white text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
title={isHeadingMode ? "Khóa hướng Bắc" : "Xoay theo hướng nhìn"}
|
||||
>
|
||||
<Compass className="w-5 h-5 transition-transform duration-300" style={{ transform: `rotate(${mapRotation}deg)` }} />
|
||||
</button>
|
||||
|
||||
{/* Floating Locate User Button */}
|
||||
<button
|
||||
onClick={() => setLocateTrigger(prev => prev + 1)}
|
||||
disabled={!userLocation}
|
||||
className={`absolute bottom-4 right-4 z-[1001] w-10 h-10 bg-white/90 backdrop-blur-md rounded-xl border border-white shadow-xl text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
title="Vị trí của tôi"
|
||||
>
|
||||
<LocateFixed className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Floating Back to Timeline Button */}
|
||||
<button
|
||||
onClick={() => setViewMode('timeline')}
|
||||
className="absolute top-3 left-14 z-[1001] h-9 px-3 bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-white text-gray-700 hover:bg-gray-50 hover:text-blue-600 transition-all active:scale-95 flex items-center gap-1.5 text-xs font-bold"
|
||||
title="Quay lại danh sách chặng"
|
||||
>
|
||||
<List className="w-4 h-4" /> Danh sách
|
||||
</button>
|
||||
|
||||
{/* Menu ngữ cảnh khi click chuột phải vào con đường */}
|
||||
{routeMenu && (
|
||||
<div
|
||||
@@ -2428,13 +2666,7 @@ export const TourDetailPage = ({
|
||||
</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>
|
||||
@@ -2897,7 +3129,7 @@ export const TourDetailPage = ({
|
||||
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"
|
||||
className="w-full px-4 py-2 border border-yotripLight-khaki rounded-xl focus:ring-yotripLight-text focus:border-yotripLight-text bg-white text-yotripLight-text dark:bg-yotripDark-surface dark:text-white dark:border-yotripDark-surface-muted dark:focus:ring-yotripDark-lime dark:focus:border-yotripDark-lime transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -2908,7 +3140,7 @@ export const TourDetailPage = ({
|
||||
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"
|
||||
className="w-full px-4 py-2 border border-yotripLight-khaki rounded-xl focus:ring-yotripLight-text focus:border-yotripLight-text bg-white text-yotripLight-text dark:bg-yotripDark-surface dark:text-white dark:border-yotripDark-surface-muted dark:focus:ring-yotripDark-lime dark:focus:border-yotripDark-lime transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -2920,7 +3152,7 @@ export const TourDetailPage = ({
|
||||
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"
|
||||
className="w-full px-4 py-2 border border-yotripLight-khaki rounded-xl focus:ring-yotripLight-text focus:border-yotripLight-text bg-white text-yotripLight-text dark:bg-yotripDark-surface dark:text-white dark:border-yotripDark-surface-muted dark:focus:ring-yotripDark-lime dark:focus:border-yotripDark-lime transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3004,15 +3236,24 @@ export const TourDetailPage = ({
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'chat' && currentTour && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||
<TourChat tourId={currentTour.id} />
|
||||
<div className="fixed left-0 right-0 bottom-0 flex flex-col bg-white z-[15]" style={{top: 'calc(60px + 5px)'}}>
|
||||
{/* Chat Group Header Banner - Fixed below nav + tabs, sticky to title */}
|
||||
<div className="flex-shrink-0 p-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-800">Trò chuyện trong nhóm</h3>
|
||||
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đổi thông tin, hình ảnh và định vị giữa các thành viên</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* TourChat with proper scrollable area */}
|
||||
<TourChat tourId={currentTour.id} embedded={true} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Action Button (Mobile) */}
|
||||
{((activeTab === 'plan' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (
|
||||
{((activeTab === 'plan' && viewMode === 'timeline' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -3099,6 +3340,29 @@ export const TourDetailPage = ({
|
||||
})}
|
||||
</MapContainer>
|
||||
|
||||
{/* Floating Compass Button (Fullscreen) */}
|
||||
<button
|
||||
onClick={toggleCompassMode}
|
||||
className={`absolute bottom-22 right-6 z-[1001] w-12 h-12 rounded-2xl border flex items-center justify-center shadow-xl transition-all active:scale-95 ${
|
||||
isHeadingMode
|
||||
? 'bg-blue-600 border-blue-400 text-white shadow-md'
|
||||
: 'bg-white/90 backdrop-blur-md border-white text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
title={isHeadingMode ? "Khóa hướng Bắc" : "Xoay theo hướng nhìn"}
|
||||
>
|
||||
<Compass className="w-6 h-6 transition-transform duration-300" style={{ transform: `rotate(${mapRotation}deg)` }} />
|
||||
</button>
|
||||
|
||||
{/* Floating Locate User Button (Fullscreen) */}
|
||||
<button
|
||||
onClick={() => setLocateTrigger(prev => prev + 1)}
|
||||
disabled={!userLocation}
|
||||
className={`absolute bottom-8 right-6 z-[1001] w-12 h-12 bg-white/90 backdrop-blur-md rounded-2xl border border-white shadow-xl text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
title="Vị trí của tôi"
|
||||
>
|
||||
<LocateFixed className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* 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
|
||||
@@ -3115,9 +3379,7 @@ export const TourDetailPage = ({
|
||||
<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);
|
||||
toggleCompassMode();
|
||||
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'}`}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap, useMapEvents } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { ChevronLeft, Compass } from 'lucide-react';
|
||||
|
||||
interface NavigationRouteData {
|
||||
origin: { lat: number; lng: number };
|
||||
destination: { lat: number; lng: number; name: string };
|
||||
tourTitle?: string;
|
||||
}
|
||||
|
||||
interface TourNavigationPageProps {
|
||||
tourId: string;
|
||||
routeData: NavigationRouteData | null;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const FitBounds = ({ coords, destination, hasInteractedRef }: { coords: [number, number][], destination: { lat: number; lng: number; name: string } | null, hasInteractedRef: React.MutableRefObject<boolean> }) => {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (coords.length > 0 && !hasInteractedRef.current && destination) {
|
||||
map.fitBounds(coords, { padding: [60, 60], maxZoom: 15 });
|
||||
}
|
||||
}, [map, coords, destination, hasInteractedRef]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const MapRotationHandler = ({ rotation }: { rotation: number }) => {
|
||||
const map = useMap();
|
||||
const cumulativeRotationRef = useRef(0);
|
||||
const prevRotationRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = map.getContainer();
|
||||
container.style.transformOrigin = 'center center';
|
||||
container.style.willChange = 'transform';
|
||||
|
||||
if (rotation === 0) {
|
||||
cumulativeRotationRef.current = 0;
|
||||
prevRotationRef.current = 0;
|
||||
container.style.transform = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let delta = rotation - prevRotationRef.current;
|
||||
if (delta > 180) delta -= 360;
|
||||
else if (delta < -180) delta += 360;
|
||||
|
||||
cumulativeRotationRef.current += delta;
|
||||
prevRotationRef.current = rotation;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const CompassInteractionDetector = ({
|
||||
onUserInteraction,
|
||||
hasInteractedRef
|
||||
}: {
|
||||
onUserInteraction: () => void;
|
||||
hasInteractedRef: React.MutableRefObject<boolean>;
|
||||
}) => {
|
||||
useMapEvents({
|
||||
movestart: () => { hasInteractedRef.current = true; },
|
||||
zoomstart: () => { hasInteractedRef.current = true; },
|
||||
dragstart: () => { hasInteractedRef.current = true; },
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
const MapRefSetter = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | null> }) => {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
mapRef.current = map;
|
||||
}, [map, mapRef]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const MapInteractionWatcher = ({ hasInteractedRef }: { hasInteractedRef: React.MutableRefObject<boolean> }) => {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const onZoomEnd = () => {
|
||||
map._userZoomLevel = map.getZoom();
|
||||
hasInteractedRef.current = true;
|
||||
};
|
||||
const onMoveEnd = () => {
|
||||
map._userCenter = map.getCenter();
|
||||
hasInteractedRef.current = true;
|
||||
};
|
||||
map.on('zoomend', onZoomEnd);
|
||||
map.on('moveend', onMoveEnd);
|
||||
return () => {
|
||||
map.off('zoomend', onZoomEnd);
|
||||
map.off('moveend', onMoveEnd);
|
||||
};
|
||||
}, [map, hasInteractedRef]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const MapSizeHandler = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | null> }) => {
|
||||
const map = useMap();
|
||||
const prevSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = map.getContainer();
|
||||
if (!container) return;
|
||||
|
||||
prevSizeRef.current = { width: container.clientWidth, height: container.clientHeight };
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
const newWidth = container.clientWidth;
|
||||
const newHeight = container.clientHeight;
|
||||
const prev = prevSizeRef.current;
|
||||
|
||||
if ((prev && (Math.abs(newWidth - prev.width) > 2 || Math.abs(newHeight - prev.height) > 2)) || newWidth === 0 || newHeight === 0) {
|
||||
prevSizeRef.current = { width: newWidth, height: newHeight };
|
||||
const userZoom = (map as any)._userZoomLevel as number | undefined;
|
||||
|
||||
map.invalidateSize({ animate: false });
|
||||
|
||||
if (userZoom !== undefined && Math.abs(map.getZoom() - userZoom) > 0.01) {
|
||||
map.setZoom(userZoom, { animate: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [map, mapRef]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId, routeData, onBack }) => {
|
||||
const [isCompassActive, setIsCompassActive] = useState(false);
|
||||
const [isLocatingUser, setIsLocatingUser] = useState(false);
|
||||
const [isTrackingLocation, setIsTrackingLocation] = useState(false);
|
||||
const [currentHeading, setCurrentHeading] = useState(0);
|
||||
const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const trackingWatchIdRef = useRef<number | null>(null);
|
||||
const compassWatchIdRef = useRef<number | null>(null);
|
||||
const fetchLockRef = useRef(false);
|
||||
const hasInteractedRef = useRef(false);
|
||||
const isProgrammaticMoveRef = useRef(false);
|
||||
|
||||
const originLat = routeData?.origin?.lat;
|
||||
const originLng = routeData?.origin?.lng;
|
||||
const destLat = routeData?.destination?.lat;
|
||||
const destLng = routeData?.destination?.lng;
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeData || !originLat || !originLng || !destLat || !destLng) {
|
||||
setRouteGeometry(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetchLockRef.current) return;
|
||||
|
||||
const calculateOptimalRoute = async () => {
|
||||
try {
|
||||
fetchLockRef.current = true;
|
||||
const url = `https://router.project-osrm.org/route/v1/driving/${originLng},${originLat};${destLng},${destLat}?overview=full&geometries=geojson`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`OSRM error: ${res.status}`);
|
||||
const data: { code: string; routes: Array<{ geometry: { coordinates: number[][] } }> } = await res.json();
|
||||
if (data.code !== 'Ok' || !data.routes?.length) throw new Error('Không tìm thấy lộ trình phù hợp');
|
||||
|
||||
const coords = data.routes[0].geometry.coordinates.map((c: number[]) => [c[1], c[0]] as [number, number]);
|
||||
setRouteGeometry(coords);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
fetchLockRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
calculateOptimalRoute();
|
||||
|
||||
return () => {
|
||||
fetchLockRef.current = false;
|
||||
};
|
||||
}, [routeData, originLat, originLng, destLat, destLng]);
|
||||
|
||||
// Effect 1: Live tracking using watchPosition
|
||||
useEffect(() => {
|
||||
if (isTrackingLocation) {
|
||||
if (navigator.geolocation) {
|
||||
console.log("GPS Location Tracking engaged. Syncing viewport to center...");
|
||||
|
||||
trackingWatchIdRef.current = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
const { latitude, longitude } = position.coords;
|
||||
if (mapRef.current) {
|
||||
const currentZoom = mapRef.current.getZoom();
|
||||
isProgrammaticMoveRef.current = true;
|
||||
mapRef.current.setView([latitude, longitude], Math.max(currentZoom, 16), { animate: true });
|
||||
setTimeout(() => {
|
||||
isProgrammaticMoveRef.current = false;
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
(error) => console.error("GPS stream tracking lost:", error),
|
||||
{ enableHighAccuracy: true }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (trackingWatchIdRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(trackingWatchIdRef.current);
|
||||
trackingWatchIdRef.current = null;
|
||||
console.log("GPS Location Tracking disabled. Map control released to user.");
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (trackingWatchIdRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(trackingWatchIdRef.current);
|
||||
trackingWatchIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isTrackingLocation]);
|
||||
|
||||
// Effect 2: Gesture detection to auto-toggle tracking OFF when user interacts
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
const map = mapRef.current;
|
||||
|
||||
const breakTrackingOnGesture = () => {
|
||||
if (isProgrammaticMoveRef.current) {
|
||||
return;
|
||||
}
|
||||
if (isTrackingLocation) {
|
||||
console.log("User touch map interaction detected. Disengaging auto-center lock.");
|
||||
setIsTrackingLocation(false);
|
||||
}
|
||||
if (isCompassActive) {
|
||||
console.log("User touch map interaction detected. Disengaging compass lock.");
|
||||
setIsCompassActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
map.on('dragstart', breakTrackingOnGesture);
|
||||
map.on('zoomstart', breakTrackingOnGesture);
|
||||
map.on('movestart', breakTrackingOnGesture);
|
||||
|
||||
return () => {
|
||||
map.off('dragstart', breakTrackingOnGesture);
|
||||
map.off('zoomstart', breakTrackingOnGesture);
|
||||
map.off('movestart', breakTrackingOnGesture);
|
||||
};
|
||||
}, [isTrackingLocation, isCompassActive]);
|
||||
|
||||
const handleUserInteraction = useCallback(() => {
|
||||
hasInteractedRef.current = true;
|
||||
if (isCompassActive) {
|
||||
setIsCompassActive(false);
|
||||
}
|
||||
}, [isCompassActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCompassActive) {
|
||||
setCurrentHeading(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.geolocation) {
|
||||
compassWatchIdRef.current = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
if (mapRef.current) {
|
||||
isProgrammaticMoveRef.current = true;
|
||||
mapRef.current.setView([position.coords.latitude, position.coords.longitude], undefined, { animate: true });
|
||||
setTimeout(() => {
|
||||
isProgrammaticMoveRef.current = false;
|
||||
}, 100);
|
||||
}
|
||||
if (position.coords.heading !== null) {
|
||||
setCurrentHeading(position.coords.heading);
|
||||
}
|
||||
},
|
||||
(err) => console.error("Compass tracking acquisition error:", err),
|
||||
{ enableHighAccuracy: true }
|
||||
);
|
||||
}
|
||||
|
||||
const handleOrientation = (event: any) => {
|
||||
let heading: number | null = null;
|
||||
if (event.webkitCompassHeading !== undefined && event.webkitCompassHeading !== null) {
|
||||
heading = event.webkitCompassHeading;
|
||||
} else if (event.alpha !== null && event.alpha !== undefined) {
|
||||
if (event.absolute === true || event.type === 'deviceorientationabsolute') {
|
||||
heading = (360 - event.alpha) % 360;
|
||||
}
|
||||
}
|
||||
if (heading !== null) {
|
||||
setCurrentHeading(heading);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('deviceorientation', handleOrientation, true);
|
||||
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
|
||||
|
||||
return () => {
|
||||
if (compassWatchIdRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(compassWatchIdRef.current);
|
||||
compassWatchIdRef.current = null;
|
||||
}
|
||||
window.removeEventListener('deviceorientation', handleOrientation, true);
|
||||
window.removeEventListener('deviceorientationabsolute', handleOrientation, true);
|
||||
};
|
||||
}, [isCompassActive]);
|
||||
|
||||
const userIcon = useMemo(() => L.divIcon({
|
||||
className: '!bg-transparent !border-none',
|
||||
html: `<div class="w-8 h-8 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center"><div class="w-2 h-2 bg-white rounded-full"></div></div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
}), []);
|
||||
|
||||
const destIcon = useMemo(() => L.divIcon({
|
||||
className: '!bg-transparent !border-none',
|
||||
html: `<div class="w-10 h-10 bg-red-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-white text-xs font-black">Đ</div>`,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 20]
|
||||
}), []);
|
||||
|
||||
if (!routeData) return null;
|
||||
|
||||
const center: [number, number] = routeData.origin && routeData.destination
|
||||
? [(routeData.origin.lat + routeData.destination.lat) / 2, (routeData.origin.lng + routeData.destination.lng) / 2]
|
||||
: [0, 0];
|
||||
|
||||
const centerOnUser = () => {
|
||||
if (navigator.geolocation) {
|
||||
setIsLocatingUser(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setIsLocatingUser(false);
|
||||
if (mapRef.current) {
|
||||
const currentZoom = mapRef.current.getZoom();
|
||||
isProgrammaticMoveRef.current = true;
|
||||
mapRef.current.setView([position.coords.latitude, position.coords.longitude], Math.max(currentZoom, 16), { animate: true });
|
||||
setTimeout(() => {
|
||||
isProgrammaticMoveRef.current = false;
|
||||
}, 100);
|
||||
hasInteractedRef.current = true;
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
setIsLocatingUser(false);
|
||||
console.error("Cannot get user location:", err);
|
||||
},
|
||||
{ enableHighAccuracy: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-950 flex flex-col overflow-hidden select-none text-white antialiased">
|
||||
<div className="w-full bg-[#1e293b]/95 backdrop-blur-md border-b border-slate-800 px-4 py-3.5 flex items-center gap-3 z-50 shrink-0"
|
||||
style={{ paddingTop: 'calc(0.75rem + env(safe-area-inset-top, 0px))' }}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-slate-300 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors"
|
||||
title="Quay lại danh sách lộ trình"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
<h2 className="text-sm font-bold truncate tracking-wide text-slate-100">
|
||||
{routeData.tourTitle || "Bản đồ chỉ đường"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative min-h-0" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={14}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
zoomControl={true}
|
||||
attributionControl={false}
|
||||
scrollWheelZoom={true}
|
||||
doubleClickZoom={true}
|
||||
touchZoom={true}
|
||||
dragging={true}
|
||||
inertia={true}
|
||||
maxZoom={20}
|
||||
minZoom={2}
|
||||
>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
|
||||
<MapRefSetter mapRef={mapRef} />
|
||||
<MapInteractionWatcher hasInteractedRef={hasInteractedRef} />
|
||||
<FitBounds coords={routeGeometry || []} destination={routeData?.destination || null} hasInteractedRef={hasInteractedRef} />
|
||||
{routeGeometry && (
|
||||
<Polyline positions={routeGeometry} color="#2563eb" weight={6} opacity={0.9} smoothFactor={1} />
|
||||
)}
|
||||
<MapSizeHandler mapRef={mapRef} />
|
||||
<MapRotationHandler rotation={currentHeading} />
|
||||
<CompassInteractionDetector onUserInteraction={handleUserInteraction} hasInteractedRef={hasInteractedRef} />
|
||||
</MapContainer>
|
||||
|
||||
{/* Locate User Button */}
|
||||
<button
|
||||
onClick={centerOnUser}
|
||||
className={`absolute bottom-8 left-6 z-[999] w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
|
||||
isLocatingUser
|
||||
? 'bg-blue-600 border-blue-400 text-white animate-pulse'
|
||||
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-blue-400'
|
||||
}`}
|
||||
style={{ bottom: 'calc(2rem + env(safe-area-inset-bottom, 0px))' }}
|
||||
title="Định vị vị trí hiện tại của bạn"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* FLOATING LIVE GPS TRACKING CENTER LOCK BUTTON */}
|
||||
<button
|
||||
onClick={() => setIsTrackingLocation(!isTrackingLocation)}
|
||||
className={`absolute bottom-24 right-6 z-[999] w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
|
||||
isTrackingLocation
|
||||
? 'bg-green-600 border-green-400 text-white animate-pulse'
|
||||
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-green-500'
|
||||
}`}
|
||||
style={{ bottom: 'calc(6rem + env(safe-area-inset-bottom, 0px))' }}
|
||||
title={isTrackingLocation ? "Tắt tự động định tâm vị trí" : "Bật tự động định tâm theo vị trí của bạn"}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsCompassActive(!isCompassActive)}
|
||||
className={`absolute bottom-8 right-6 z-[999] w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
|
||||
isCompassActive
|
||||
? 'bg-blue-600 border-blue-400 text-white animate-pulse'
|
||||
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-blue-400'
|
||||
}`}
|
||||
style={{ bottom: 'calc(2rem + env(safe-area-inset-bottom, 0px))' }}
|
||||
>
|
||||
<Compass className="w-7 h-7" />
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-red-900 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg z-[1000]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,58 @@ const config: Config = {
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
// Light Theme Colors ("Classic Heritage & Soft Sand")
|
||||
yotripLight: {
|
||||
bg: '#f6f0ed', // Parchment - Global Page Backgrounds
|
||||
text: '#28536b', // Yale Blue - Primary Text & Main Titles
|
||||
steel: '#7ea8be', // Steel Blue - Active Navigation & Secondary Actions
|
||||
khaki: '#bbb193', // Khaki Beige - Borders, Dividers, Muted States
|
||||
rosy: '#c2948a', // Rosy Taupe - Special Highlight Badges, Notifications
|
||||
},
|
||||
// Dark Theme Colors ("Vintage Velvet & Aquatic Zest")
|
||||
yotripDark: {
|
||||
bg: '#513b56', // Vintage Grape - Global Page Backgrounds
|
||||
surface: '#525174', // Dusty Grape - Component Surfaces (Cards, Bubbles)
|
||||
lime: '#bce784', // Lime Cream - Brand Text Highlights, Active Icons
|
||||
bondi: '#348aa7', // Bondi Blue - Primary Action Buttons, Links
|
||||
emerald: '#5dd39e', // Emerald - Success Utilities & "Tối ưu" badges
|
||||
},
|
||||
// CSS Variable based colors for smooth theme support
|
||||
theme: {
|
||||
bg: 'var(--background)',
|
||||
'bg-alt': 'var(--background-alt)',
|
||||
surface: 'var(--surface)',
|
||||
'surface-muted': 'var(--surface-muted)',
|
||||
'surface-hover': 'var(--surface-hover)',
|
||||
border: 'var(--border)',
|
||||
'border-light': 'var(--border-light)',
|
||||
'text': 'var(--text-primary)',
|
||||
'text-secondary': 'var(--text-secondary)',
|
||||
'text-accent': 'var(--text-accent)',
|
||||
'text-muted': 'var(--text-muted)',
|
||||
'text-disabled': 'var(--text-disabled)',
|
||||
primary: 'var(--primary)',
|
||||
'primary-hover': 'var(--primary-hover)',
|
||||
'primary-light': 'var(--primary-light)',
|
||||
secondary: 'var(--secondary)',
|
||||
'secondary-light': 'var(--secondary-light)',
|
||||
success: 'var(--success)',
|
||||
'success-light': 'var(--success-light)',
|
||||
danger: 'var(--danger)',
|
||||
'danger-light': 'var(--danger-light)',
|
||||
warning: 'var(--warning)',
|
||||
'warning-light': 'var(--warning-light)',
|
||||
info: 'var(--info)',
|
||||
'info-light': 'var(--info-light)',
|
||||
}
|
||||
},
|
||||
boxShadow: {
|
||||
'theme-sm': 'var(--shadow-sm)',
|
||||
'theme-md': 'var(--shadow-md)',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||